mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
feat(dsh-plugin-browserskill): reveal tool schemas only after skill invocation
Progressive disclosure, final stage: with lazyTools on (default), the eleven browser_* tool schemas stay out of the system prompt — the browser-skill catalog entry is the sole advertisement. One successful skill invocation (model tool call via tools/result, or a /browser-skill user gesture via the session append feed) registers the suite for the process lifetime, idempotent on repeats; session resume is covered by scanning durable events for a past tool/call+tool/result pair (or skill-invocation message) on session entry and at apply time. lazyTools: false keeps the legacy always-on registration. Investigation (recorded in the ticket): tools/result carries normalized name/arguments with an isError discriminant; dsh 0.1 has no official global visibility switch (ctx.tools.restrict is agent-scoped and throws from a plain context), making conditional registration the intended pattern; per-step assembly picks the new suite up on the next step, and visibility changes are first-class inputs of tool-skill's catalog digest. registerTools now returns the combined disposer via a prototype-preserving tracking overlay; the skill prelude tells the model the browser_* tools unlock on skill load. Tests: +8 (hidden-before/reveal/idempotent/gesture/history hit+miss/dispose/ scanner pairing/two-state apply wiring). 124/124 green.
This commit is contained in:
@@ -5,6 +5,11 @@ through this plugin's `browser_*` tools. Each tool spawns `bsk <cmd> --json` —
|
|||||||
same engine the canonical CLI skill documents — so the workflow, judgment rules, and
|
same engine the canonical CLI skill documents — so the workflow, judgment rules, and
|
||||||
safety constraints are identical.
|
safety constraints are identical.
|
||||||
|
|
||||||
|
> **Tool availability**: invoking this skill (which you just did) makes the plugin's
|
||||||
|
> `browser_*` tools available for the rest of the session — they are injected on
|
||||||
|
> successful skill load. Until then they are deliberately hidden, so do not attempt
|
||||||
|
> a browser action before loading this skill.
|
||||||
|
|
||||||
## Tool ↔ CLI map
|
## Tool ↔ CLI map
|
||||||
|
|
||||||
The tools below are thin, structured wrappers over the bsk CLI verbs of the same
|
The tools below are thin, structured wrappers over the bsk CLI verbs of the same
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
|
|
||||||
import type { Context } from "@deepseek-ai/cordis";
|
import type { Context } from "@deepseek-ai/cordis";
|
||||||
import Schema from "@deepseek-ai/schemastery";
|
import Schema from "@deepseek-ai/schemastery";
|
||||||
|
import { armLazyTools } from "./lazy-tools";
|
||||||
import { ObservationService } from "./observation";
|
import { ObservationService } from "./observation";
|
||||||
import { registerObservationRoutes } from "./observation-http";
|
import { registerObservationRoutes } from "./observation-http";
|
||||||
import { KeyedExecutor } from "./queue";
|
import { KeyedExecutor } from "./queue";
|
||||||
@@ -43,6 +44,12 @@ export const Config = Schema.object({
|
|||||||
idleIntervalMs: Schema.number()
|
idleIntervalMs: Schema.number()
|
||||||
.default(8000)
|
.default(8000)
|
||||||
.description("Thumbnail refresh cadence for idle sessions; also the recent-activity window."),
|
.description("Thumbnail refresh cadence for idle sessions; also the recent-activity window."),
|
||||||
|
lazyTools: Schema.boolean()
|
||||||
|
.default(true)
|
||||||
|
.description(
|
||||||
|
"Reveal the browser_* tools only after the browser-skill skill is invoked (default true); " +
|
||||||
|
"false registers the full suite at load.",
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Config = PluginConfig;
|
export type Config = PluginConfig;
|
||||||
@@ -64,6 +71,7 @@ export function apply(
|
|||||||
observationEnabled: config.observationEnabled ?? true,
|
observationEnabled: config.observationEnabled ?? true,
|
||||||
thumbnailIntervalMs: config.thumbnailIntervalMs ?? 1500,
|
thumbnailIntervalMs: config.thumbnailIntervalMs ?? 1500,
|
||||||
idleIntervalMs: config.idleIntervalMs ?? 8000,
|
idleIntervalMs: config.idleIntervalMs ?? 8000,
|
||||||
|
lazyTools: config.lazyTools ?? true,
|
||||||
};
|
};
|
||||||
const runner = options.runnerFactory?.(resolved.bskPath) ?? createBskRunner(resolved.bskPath);
|
const runner = options.runnerFactory?.(resolved.bskPath) ?? createBskRunner(resolved.bskPath);
|
||||||
const registry = new SessionRegistry(resolved.maxSessions);
|
const registry = new SessionRegistry(resolved.maxSessions);
|
||||||
@@ -80,11 +88,17 @@ export function apply(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
registerTools({ ctx, runner, registry, config: resolved, observation, queue });
|
|
||||||
// Progressive disclosure of the BSK agent skill (catalog entry resident,
|
// Progressive disclosure of the BSK agent skill (catalog entry resident,
|
||||||
// body on demand) through the official skill seam; silent no-op when the
|
// body on demand) through the official skill seam; silent no-op when the
|
||||||
// composition lacks it.
|
// composition lacks it. With lazyTools on, the skill entry is initially the
|
||||||
|
// ONLY model-visible advertisement — the tool suite reveals itself on a
|
||||||
|
// successful skill invocation (or a session whose history already has one).
|
||||||
const unregisterSkill = registerBskSkill(ctx);
|
const unregisterSkill = registerBskSkill(ctx);
|
||||||
|
const removeSuite = resolved.lazyTools
|
||||||
|
? armLazyTools(ctx, () =>
|
||||||
|
registerTools({ ctx, runner, registry, config: resolved, observation, queue }),
|
||||||
|
)
|
||||||
|
: registerTools({ ctx, runner, registry, config: resolved, observation, queue });
|
||||||
// Route registration rides ctx.inject: the webServer service may be provided
|
// Route registration rides ctx.inject: the webServer service may be provided
|
||||||
// AFTER this plugin loads, and in headless compositions it never appears (the
|
// AFTER this plugin loads, and in headless compositions it never appears (the
|
||||||
// callback simply never runs, leaving the rest of the plugin unaffected).
|
// callback simply never runs, leaving the rest of the plugin unaffected).
|
||||||
@@ -115,6 +129,7 @@ export function apply(
|
|||||||
// are swallowed so one stale handle cannot abort the rest.
|
// are swallowed so one stale handle cannot abort the rest.
|
||||||
ctx.effect(() => {
|
ctx.effect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
|
removeSuite();
|
||||||
unregisterSkill();
|
unregisterSkill();
|
||||||
removeRoutes();
|
removeRoutes();
|
||||||
observation.dispose();
|
observation.dispose();
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
/**
|
||||||
|
* Lazy tool-schema injection ("progressive disclosure", final stage): with
|
||||||
|
* `lazyTools` on (the default), the 11 browser_* tool schemas stay OUT of the
|
||||||
|
* system prompt until the `browser-skill` skill has actually been invoked —
|
||||||
|
* the skill catalog entry is the only advertisement. One successful
|
||||||
|
* invocation (model tool call, or a user's `/browser-skill` gesture) reveals
|
||||||
|
* the whole suite for the rest of the process lifetime; repeated invocations
|
||||||
|
* are idempotent no-ops. Session resume is covered by scanning durable
|
||||||
|
* session events for a past successful invocation (tool/call + tool/result
|
||||||
|
* pair, or a skill-invocation sourced message) when a session is entered.
|
||||||
|
*
|
||||||
|
* Verified against dsh 0.1 (recorded in the PR ticket):
|
||||||
|
* - `tools/result(exec, result)`: exec carries normalized `name`/`arguments`,
|
||||||
|
* result is discriminated by `isError` — the model-invocation trigger.
|
||||||
|
* - There is NO official global tool-visibility switch (`ctx.tools.restrict`
|
||||||
|
* is agent-scoped and throws from a plain host context), so conditional
|
||||||
|
* registration + this hook is the intended pattern; the registry supports
|
||||||
|
* mid-flight register/dispose with an unfiltered `tools/change` notice, and
|
||||||
|
* tool-skill's per-step catalog digest treats visibility changes as a
|
||||||
|
* first-class cache-invalidation input — the suite simply appears in the
|
||||||
|
* NEXT step's assembly.
|
||||||
|
* - Durable events: `tool/call` {callId, name, arguments(JSON string)} pairs
|
||||||
|
* with `tool/result` {message: {callId, isError}} — the history signal.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Context } from "@deepseek-ai/cordis";
|
||||||
|
import { BSK_SKILL_NAME } from "./skill-content.generated";
|
||||||
|
|
||||||
|
/** Structural view of the pieces of the session seam we consume. */
|
||||||
|
interface SessionLike {
|
||||||
|
events: readonly SessionEventLike[];
|
||||||
|
}
|
||||||
|
interface SessionEventLike {
|
||||||
|
type: string;
|
||||||
|
data?: unknown;
|
||||||
|
}
|
||||||
|
interface SessionsLike {
|
||||||
|
list(): SessionLike[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToolResultExecutionLike {
|
||||||
|
name: string;
|
||||||
|
arguments?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a tool arguments payload that may be normalized (object) or raw JSON. */
|
||||||
|
function skillNameOf(args: unknown): string | undefined {
|
||||||
|
if (typeof args === "string") {
|
||||||
|
try {
|
||||||
|
return skillNameOf(JSON.parse(args));
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof args === "object" && args !== null && "name" in args) {
|
||||||
|
const name = (args as { name?: unknown }).name;
|
||||||
|
return typeof name === "string" ? name : undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSkillInvocationMessage(data: unknown): boolean {
|
||||||
|
if (typeof data !== "object" || data === null) return false;
|
||||||
|
const source = (data as { source?: unknown }).source;
|
||||||
|
if (typeof source !== "object" || source === null) return false;
|
||||||
|
const { kind, name } = source as { kind?: unknown; name?: unknown };
|
||||||
|
return kind === "skill-invocation" && name === BSK_SKILL_NAME;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A durable log proves the skill was successfully invoked when a successful
|
||||||
|
* `tool/result` pairs a `tool/call` for skill/browser-skill — or when a
|
||||||
|
* `/browser-skill` user gesture landed as a skill-invocation message.
|
||||||
|
*/
|
||||||
|
export function hasSuccessfulSkillInvocation(events: readonly SessionEventLike[]): boolean {
|
||||||
|
const skillCallIds = new Set<unknown>();
|
||||||
|
for (const event of events) {
|
||||||
|
if (event.type !== "tool/call") continue;
|
||||||
|
const data = event.data as { callId?: unknown; name?: unknown; arguments?: unknown };
|
||||||
|
if (data?.name === "skill" && skillNameOf(data.arguments) === BSK_SKILL_NAME) {
|
||||||
|
skillCallIds.add(data.callId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const event of events) {
|
||||||
|
if (event.type === "tool/result") {
|
||||||
|
const message = (event.data as { message?: unknown })?.message;
|
||||||
|
if (typeof message !== "object" || message === null) continue;
|
||||||
|
const { callId, isError } = message as { callId?: unknown; isError?: unknown };
|
||||||
|
if (isError === false && skillCallIds.has(callId)) return true;
|
||||||
|
}
|
||||||
|
if (isSkillInvocationMessage(event.data)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arm the lazy reveal. Returns a disposer tearing down listeners and — when
|
||||||
|
* the reveal already happened — the tool suite itself.
|
||||||
|
* @param registerSuite - registers the 11 browser_* tools, returns their disposer.
|
||||||
|
*/
|
||||||
|
export function armLazyTools(ctx: Context, registerSuite: () => () => void): () => void {
|
||||||
|
let suiteDisposer: (() => void) | undefined;
|
||||||
|
const disposers: (() => void)[] = [];
|
||||||
|
const ensureSuite = (): void => {
|
||||||
|
if (suiteDisposer !== undefined) return;
|
||||||
|
suiteDisposer = registerSuite();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Live trigger: a successful model invocation of skill/browser-skill.
|
||||||
|
const onToolResult = (exec: ToolResultExecutionLike, result: { isError: boolean }): void => {
|
||||||
|
if (result.isError) return;
|
||||||
|
if (exec.name !== "skill") return;
|
||||||
|
if (skillNameOf(exec.arguments) === BSK_SKILL_NAME) ensureSuite();
|
||||||
|
};
|
||||||
|
disposers.push(ctx.on("tools/result" as never, onToolResult as never));
|
||||||
|
|
||||||
|
const scanSession = (session: SessionLike): void => {
|
||||||
|
try {
|
||||||
|
if (hasSuccessfulSkillInvocation(session.events)) ensureSuite();
|
||||||
|
} catch {
|
||||||
|
// A session object that cannot be read must never break plugin startup.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Live gesture/append feed: covers /browser-skill user gestures (no tool
|
||||||
|
// call happens on that path) landing as skill-invocation messages.
|
||||||
|
const onSessionEvent = (_session: SessionLike, event: SessionEventLike): void => {
|
||||||
|
if (isSkillInvocationMessage(event?.data)) ensureSuite();
|
||||||
|
};
|
||||||
|
disposers.push(ctx.on("session/event" as never, onSessionEvent as never));
|
||||||
|
|
||||||
|
// History restore: sessions entered from now on, plus any already live.
|
||||||
|
const onSessionCreated = (session: SessionLike): void => scanSession(session);
|
||||||
|
disposers.push(ctx.on("session/created" as never, onSessionCreated as never));
|
||||||
|
const sessions = ctx.get("sessions") as SessionsLike | null | undefined;
|
||||||
|
if (sessions != null && typeof sessions.list === "function") {
|
||||||
|
for (const session of sessions.list()) scanSession(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
for (const dispose of disposers.splice(0)) dispose();
|
||||||
|
suiteDisposer?.();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -26,6 +26,13 @@ export interface PluginConfig {
|
|||||||
observationEnabled: boolean;
|
observationEnabled: boolean;
|
||||||
thumbnailIntervalMs: number;
|
thumbnailIntervalMs: number;
|
||||||
idleIntervalMs: number;
|
idleIntervalMs: number;
|
||||||
|
/**
|
||||||
|
* Lazy tool-schema injection: when true (default), the browser_* tools are
|
||||||
|
* registered only after the `browser-skill` skill has been successfully
|
||||||
|
* invoked (skill catalog entry advertises alone until then); when false,
|
||||||
|
* the suite is registered at apply time (legacy always-on behavior).
|
||||||
|
*/
|
||||||
|
lazyTools: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolDeps {
|
export interface ToolDeps {
|
||||||
@@ -136,9 +143,22 @@ function abortError(): Error {
|
|||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Register the full browser tool suite. */
|
/** Register the full browser tool suite; returns the combined unregister disposer. */
|
||||||
export function registerTools(deps: ToolDeps): void {
|
export function registerTools(deps: ToolDeps): () => void {
|
||||||
const { ctx, registry } = deps;
|
// Track every registration through a prototype-preserving overlay so the
|
||||||
|
// lazy-injection path can dispose the whole suite in one call.
|
||||||
|
const disposers: (() => void)[] = [];
|
||||||
|
const ctx = Object.create(deps.ctx) as ToolDeps["ctx"];
|
||||||
|
const toolsOverlay = Object.create(deps.ctx.tools) as ToolDeps["ctx"]["tools"];
|
||||||
|
const originalRegister = deps.ctx.tools.register;
|
||||||
|
toolsOverlay.register = (definition: Parameters<typeof originalRegister>[0]) => {
|
||||||
|
const dispose = originalRegister.call(deps.ctx.tools, definition);
|
||||||
|
// Test doubles occasionally return the registry map instead of a disposer.
|
||||||
|
if (typeof dispose === "function") disposers.push(dispose);
|
||||||
|
return dispose;
|
||||||
|
};
|
||||||
|
ctx.tools = toolsOverlay;
|
||||||
|
const { registry } = deps;
|
||||||
|
|
||||||
ctx.tools.register(
|
ctx.tools.register(
|
||||||
defineTool({
|
defineTool({
|
||||||
@@ -934,4 +954,7 @@ export function registerTools(deps: ToolDeps): void {
|
|||||||
presentResult: presentTerminalResult,
|
presentResult: presentTerminalResult,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
return () => {
|
||||||
|
for (const dispose of disposers.splice(0)) dispose();
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ describe("dispose cleanup ownership", () => {
|
|||||||
disposers.push(fn());
|
disposers.push(fn());
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
apply(ctx as never, { maxSessions: 5 }, { runnerFactory: () => runner });
|
apply(ctx as never, { maxSessions: 5, lazyTools: false }, { runnerFactory: () => runner });
|
||||||
|
|
||||||
const start = tools.get("browser_session_start");
|
const start = tools.get("browser_session_start");
|
||||||
const snapshot = tools.get("browser_snapshot");
|
const snapshot = tools.get("browser_snapshot");
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
// Lazy tool-schema injection: suite hidden before a successful skill
|
||||||
|
// invocation, revealed by the model trigger / user gesture / session history,
|
||||||
|
// idempotent on repeats, and torn down cleanly. Plus the apply-level
|
||||||
|
// lazyTools two-state wiring.
|
||||||
|
|
||||||
|
import type { ToolDefinition, ToolRunContext } from "@deepseek-ai/dsh-tools";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { apply } from "../src/index";
|
||||||
|
import { armLazyTools, hasSuccessfulSkillInvocation } from "../src/lazy-tools";
|
||||||
|
import type { BskRunOptions, BskRunResult } from "../src/runner";
|
||||||
|
|
||||||
|
function fakeEventCtx(sessions?: { list(): { events: unknown[] }[] }) {
|
||||||
|
const listeners = new Map<string, (...args: never[]) => void>();
|
||||||
|
const ctx = {
|
||||||
|
on: (event: string, listener: (...args: never[]) => void) => {
|
||||||
|
listeners.set(event, listener);
|
||||||
|
return () => listeners.delete(event);
|
||||||
|
},
|
||||||
|
get: (key: string) => (key === "sessions" ? sessions : undefined),
|
||||||
|
};
|
||||||
|
return { ctx: ctx as never, listeners };
|
||||||
|
}
|
||||||
|
|
||||||
|
function callListeners(
|
||||||
|
listeners: Map<string, (...args: never[]) => void>,
|
||||||
|
event: string,
|
||||||
|
...args: unknown[]
|
||||||
|
): void {
|
||||||
|
(listeners.get(event) as ((...a: unknown[]) => void) | undefined)?.(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("armLazyTools", () => {
|
||||||
|
it("keeps the suite hidden until a successful skill invocation reveals it", () => {
|
||||||
|
const { ctx, listeners } = fakeEventCtx();
|
||||||
|
const registerSuite = vi.fn(() => () => {});
|
||||||
|
armLazyTools(ctx, registerSuite);
|
||||||
|
expect(registerSuite).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// wrong tool
|
||||||
|
callListeners(listeners, "tools/result", { name: "bash", arguments: {} }, { isError: false });
|
||||||
|
// wrong skill name
|
||||||
|
callListeners(
|
||||||
|
listeners,
|
||||||
|
"tools/result",
|
||||||
|
{ name: "skill", arguments: { name: "other-skill" } },
|
||||||
|
{ isError: false },
|
||||||
|
);
|
||||||
|
// failed invocation
|
||||||
|
callListeners(
|
||||||
|
listeners,
|
||||||
|
"tools/result",
|
||||||
|
{ name: "skill", arguments: { name: "browser-skill" } },
|
||||||
|
{ isError: true },
|
||||||
|
);
|
||||||
|
expect(registerSuite).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// the real trigger (arguments may arrive as a raw JSON string)
|
||||||
|
callListeners(
|
||||||
|
listeners,
|
||||||
|
"tools/result",
|
||||||
|
{ name: "skill", arguments: '{"name":"browser-skill"}' },
|
||||||
|
{ isError: false },
|
||||||
|
);
|
||||||
|
expect(registerSuite).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idempotent across repeated invocations", () => {
|
||||||
|
const { ctx, listeners } = fakeEventCtx();
|
||||||
|
const registerSuite = vi.fn(() => () => {});
|
||||||
|
armLazyTools(ctx, registerSuite);
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
callListeners(
|
||||||
|
listeners,
|
||||||
|
"tools/result",
|
||||||
|
{ name: "skill", arguments: { name: "browser-skill" } },
|
||||||
|
{ isError: false },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(registerSuite).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reveals on a /browser-skill user gesture (skill-invocation message)", () => {
|
||||||
|
const { ctx, listeners } = fakeEventCtx();
|
||||||
|
const registerSuite = vi.fn(() => () => {});
|
||||||
|
armLazyTools(ctx, registerSuite);
|
||||||
|
callListeners(
|
||||||
|
listeners,
|
||||||
|
"session/event",
|
||||||
|
{ events: [] },
|
||||||
|
{
|
||||||
|
type: "user/message",
|
||||||
|
data: { source: { kind: "skill-invocation", name: "browser-skill" } },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(registerSuite).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores from session history: hit at boot, on session entry, and miss", () => {
|
||||||
|
const hitEvents = [
|
||||||
|
{
|
||||||
|
type: "tool/call",
|
||||||
|
data: { callId: "c1", name: "skill", arguments: '{"name":"browser-skill"}' },
|
||||||
|
},
|
||||||
|
{ type: "tool/result", data: { message: { callId: "c1", isError: false } } },
|
||||||
|
];
|
||||||
|
// boot-time live sessions are scanned
|
||||||
|
const boot = fakeEventCtx({ list: () => [{ events: hitEvents }] });
|
||||||
|
const bootRegister = vi.fn(() => () => {});
|
||||||
|
armLazyTools(boot.ctx, bootRegister);
|
||||||
|
expect(bootRegister).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// a session entered later with the same proof
|
||||||
|
const { ctx, listeners } = fakeEventCtx({ list: () => [] });
|
||||||
|
const registerSuite = vi.fn(() => () => {});
|
||||||
|
armLazyTools(ctx, registerSuite);
|
||||||
|
callListeners(listeners, "session/created", { events: hitEvents });
|
||||||
|
expect(registerSuite).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// miss: failed result proves nothing
|
||||||
|
const miss = fakeEventCtx({
|
||||||
|
list: () => [
|
||||||
|
{
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
type: "tool/call",
|
||||||
|
data: { callId: "c1", name: "skill", arguments: '{"name":"browser-skill"}' },
|
||||||
|
},
|
||||||
|
{ type: "tool/result", data: { message: { callId: "c1", isError: true } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const missRegister = vi.fn(() => () => {});
|
||||||
|
armLazyTools(miss.ctx, missRegister);
|
||||||
|
expect(missRegister).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disposes listeners and the revealed suite", () => {
|
||||||
|
const { ctx, listeners } = fakeEventCtx();
|
||||||
|
const suiteDispose = vi.fn();
|
||||||
|
const disarm = armLazyTools(ctx, () => suiteDispose);
|
||||||
|
callListeners(
|
||||||
|
listeners,
|
||||||
|
"tools/result",
|
||||||
|
{ name: "skill", arguments: { name: "browser-skill" } },
|
||||||
|
{ isError: false },
|
||||||
|
);
|
||||||
|
disarm();
|
||||||
|
expect(suiteDispose).toHaveBeenCalledTimes(1);
|
||||||
|
expect(listeners.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hasSuccessfulSkillInvocation", () => {
|
||||||
|
it("pairs call and result by callId and honors gestures", () => {
|
||||||
|
expect(hasSuccessfulSkillInvocation([])).toBe(false);
|
||||||
|
expect(
|
||||||
|
hasSuccessfulSkillInvocation([
|
||||||
|
{
|
||||||
|
type: "tool/call",
|
||||||
|
data: { callId: "x", name: "skill", arguments: { name: "browser-skill" } },
|
||||||
|
},
|
||||||
|
{ type: "tool/result", data: { message: { callId: "x", isError: false } } },
|
||||||
|
]),
|
||||||
|
).toBe(true);
|
||||||
|
// result for an unrelated call id does not count
|
||||||
|
expect(
|
||||||
|
hasSuccessfulSkillInvocation([
|
||||||
|
{
|
||||||
|
type: "tool/call",
|
||||||
|
data: { callId: "x", name: "skill", arguments: { name: "browser-skill" } },
|
||||||
|
},
|
||||||
|
{ type: "tool/result", data: { message: { callId: "y", isError: false } } },
|
||||||
|
]),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
hasSuccessfulSkillInvocation([
|
||||||
|
{
|
||||||
|
type: "user/message",
|
||||||
|
data: { source: { kind: "skill-invocation", name: "browser-skill" } },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("lazyTools wiring in apply()", () => {
|
||||||
|
function applyHarness(config: Record<string, unknown>) {
|
||||||
|
const tools = new Map<string, ToolDefinition>();
|
||||||
|
const listeners = new Map<string, (...args: never[]) => void>();
|
||||||
|
const ctx = {
|
||||||
|
tools: { register: (def: ToolDefinition) => tools.set(def.name, def) },
|
||||||
|
get: () => undefined,
|
||||||
|
inject: () => {},
|
||||||
|
effect: () => {},
|
||||||
|
on: (event: string, listener: (...args: never[]) => void) => {
|
||||||
|
listeners.set(event, listener);
|
||||||
|
return () => listeners.delete(event);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const runner = {
|
||||||
|
async run(): Promise<BskRunResult> {
|
||||||
|
return { code: 0, stdout: "{}", stderr: "", timedOut: false, aborted: false };
|
||||||
|
},
|
||||||
|
killAll() {},
|
||||||
|
killFor: () => 0,
|
||||||
|
};
|
||||||
|
apply(ctx as never, config, { runnerFactory: () => runner as never });
|
||||||
|
return { tools, listeners };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("lazyTools: false registers the suite at apply time", () => {
|
||||||
|
const { tools } = applyHarness({ lazyTools: false });
|
||||||
|
expect(tools.has("browser_session_start")).toBe(true);
|
||||||
|
expect(tools.has("browser_emulate")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lazyTools: true hides the suite until the skill fires", () => {
|
||||||
|
const { tools, listeners } = applyHarness({ lazyTools: true });
|
||||||
|
expect(tools.has("browser_session_start")).toBe(false);
|
||||||
|
callListeners(
|
||||||
|
listeners,
|
||||||
|
"tools/result",
|
||||||
|
{ name: "skill", arguments: { name: "browser-skill" } },
|
||||||
|
{ isError: false },
|
||||||
|
);
|
||||||
|
expect(tools.has("browser_session_start")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,6 +16,7 @@ const CONFIG: PluginConfig = {
|
|||||||
observationEnabled: false,
|
observationEnabled: false,
|
||||||
thumbnailIntervalMs: 1500,
|
thumbnailIntervalMs: 1500,
|
||||||
idleIntervalMs: 8000,
|
idleIntervalMs: 8000,
|
||||||
|
lazyTools: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** An observation service with the feature off: instrumentation becomes a no-op. */
|
/** An observation service with the feature off: instrumentation becomes a no-op. */
|
||||||
|
|||||||
Reference in New Issue
Block a user