diff --git a/packages/dsh-plugin-browserskill/src/lazy-tools.ts b/packages/dsh-plugin-browserskill/src/lazy-tools.ts index fe30119f..f7a61ec2 100644 --- a/packages/dsh-plugin-browserskill/src/lazy-tools.ts +++ b/packages/dsh-plugin-browserskill/src/lazy-tools.ts @@ -133,8 +133,23 @@ export function armLazyTools(ctx: Context, registerSuite: () => () => void): () // 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(); + const onSessionEvent = (session: SessionLike, event: SessionEventLike): void => { + if (suiteDisposer !== undefined) return; + if (isSkillInvocationMessage(event?.data)) { + ensureSuite(); + return; + } + // Reload recovery. The history scan below runs once, when the plugin is + // applied, and it covers neither of the reload cases: a session that + // already exists never emits `session/created`, and `ctx.get("sessions")` + // yields nothing when the sessions service is registered after this + // plugin. The suite then stays hidden until the model happens to invoke + // the skill again, which surfaces as `unknown tool "browser_session"`. + // + // The session is already in hand on every event, so let any later event + // re-derive the reveal from durable history. Guarded above, so this scans + // only while still hidden. + if (session !== undefined && session !== null) scanSession(session); }; disposers.push(ctx.on("session/event" as never, onSessionEvent as never)); diff --git a/packages/dsh-plugin-browserskill/tests/lazy-tools.test.ts b/packages/dsh-plugin-browserskill/tests/lazy-tools.test.ts index e70a6323..d50a8a87 100644 --- a/packages/dsh-plugin-browserskill/tests/lazy-tools.test.ts +++ b/packages/dsh-plugin-browserskill/tests/lazy-tools.test.ts @@ -135,6 +135,65 @@ describe("armLazyTools", () => { expect(missRegister).not.toHaveBeenCalled(); }); + it("re-arms from history on a later event when the boot scan could not see the session", () => { + const hitEvents = [ + { + type: "tool/call", + data: { callId: "c1", name: "skill", arguments: '{"name":"browser-skill"}' }, + }, + { type: "tool/result", data: { message: { callId: "c1", isError: false } } }, + ]; + // After a plugin reload the sessions service may not be registered yet, so + // ctx.get("sessions") yields nothing and the boot scan covers nothing; the + // session already exists, so session/created never fires for it either. + const { ctx, listeners } = fakeEventCtx(); + const registerSuite = vi.fn(() => () => {}); + armLazyTools(ctx, registerSuite); + expect(registerSuite).not.toHaveBeenCalled(); + + // An ordinary event carrying no invocation of its own still hands over the + // session, whose durable history proves the skill already ran. + callListeners(listeners, "session/event", { events: hitEvents }, { type: "message/append" }); + expect(registerSuite).toHaveBeenCalledTimes(1); + }); + + it("does not reveal from an event whose session has no proof", () => { + const { ctx, listeners } = fakeEventCtx(); + const registerSuite = vi.fn(() => () => {}); + armLazyTools(ctx, registerSuite); + callListeners( + listeners, + "session/event", + { events: [{ type: "message/append", data: {} }] }, + { type: "message/append" }, + ); + expect(registerSuite).not.toHaveBeenCalled(); + }); + + it("stops scanning session history once the suite is revealed", () => { + const { ctx, listeners } = fakeEventCtx(); + const registerSuite = vi.fn(() => () => {}); + armLazyTools(ctx, registerSuite); + callListeners( + listeners, + "tools/result", + { name: "skill", arguments: { name: "browser-skill" } }, + { isError: false }, + ); + expect(registerSuite).toHaveBeenCalledTimes(1); + + // Events arrive constantly; re-deriving from history on each one after the + // reveal would be pure waste. A session that would throw if read proves + // the history is not touched again. + const exploding = { + get events(): never { + throw new Error("session history must not be read after the reveal"); + }, + }; + callListeners(listeners, "session/event", exploding, { type: "message/append" }); + expect(registerSuite).toHaveBeenCalledTimes(1); + }); + it("disposes listeners and the revealed suite", () => { const { ctx, listeners } = fakeEventCtx(); const suiteDispose = vi.fn();