Files
BB-fat ce11325de2 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.
2026-08-14 16:43:51 +00:00

230 lines
7.6 KiB
TypeScript

// 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);
});
});