From 2eee76f78b46f48e27d9e961fcfd3a9f1edde9a9 Mon Sep 17 00:00:00 2001 From: drakeo338 Date: Fri, 18 Sep 2026 17:53:34 +0000 Subject: [PATCH] fix(dsh-plugin): re-arm lazy tools from history after a plugin reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a daemon restart that reloads the plugin, browser_* calls fail with unknown tool "browser_session" until the model happens to invoke the skill again, even though the session's durable history already proves it ran. None of the three triggers covers that case. The live tools/result hook needs a fresh invocation. session/created never fires for a session that already exists. The boot scan runs once during apply(), and ctx.get("sessions") yields nothing when the sessions service is registered after this plugin, so it covers nothing at all. session/event already receives the session and ignored it. Reading its history when nothing else has revealed the suite closes the gap, and is guarded so the scan stops once revealed — these events are frequent and re-deriving on each one after the reveal is waste. Keeps the reveal derived from durable history rather than adding a persisted flag, so there is no new state to migrate or keep consistent. --- .../dsh-plugin-browserskill/src/lazy-tools.ts | 19 +++++- .../tests/lazy-tools.test.ts | 59 +++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) 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();