From 2eee76f78b46f48e27d9e961fcfd3a9f1edde9a9 Mon Sep 17 00:00:00 2001 From: drakeo338 Date: Fri, 18 Sep 2026 17:53:34 +0000 Subject: [PATCH 1/4] 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(); From e1a401844598dec0fd5b7c1349d2f7c8b93424bc Mon Sep 17 00:00:00 2001 From: drakezhang Date: Sun, 20 Sep 2026 14:18:12 +0800 Subject: [PATCH 2/4] fix(dsh): recover lazy tools without rescanning streaming history Parse current durable tool results and support both session history APIs. Scan each session once, process later events incrementally, and retry recovery when services or history become available. Cover official DSH messages, reloads, long conversations, failed reads, and explicit history access counts. --- packages/dsh-plugin-browserskill/README.md | 5 + .../docs/development.md | 9 + packages/dsh-plugin-browserskill/package.json | 1 + .../dsh-plugin-browserskill/src/lazy-tools.ts | 172 ++++++++++--- .../tests/lazy-tools.integration.test.ts | 145 +++++++++++ .../tests/lazy-tools.test.ts | 237 +++++++++++++++++- pnpm-lock.yaml | 3 + 7 files changed, 522 insertions(+), 50 deletions(-) create mode 100644 packages/dsh-plugin-browserskill/tests/lazy-tools.integration.test.ts diff --git a/packages/dsh-plugin-browserskill/README.md b/packages/dsh-plugin-browserskill/README.md index acf86870..91c92156 100644 --- a/packages/dsh-plugin-browserskill/README.md +++ b/packages/dsh-plugin-browserskill/README.md @@ -147,6 +147,11 @@ the model. The six `browser_*` tool schemas are added to the system prompt after the `browser-skill` skill is successfully invoked, either by the model or through `/browser-skill`. Set `lazyTools: false` to make the tools available immediately. +After a plugin reload, a live or resumed conversation's successful skill invocation +restores the tools from its stored history. If an older plugin reports +`unknown tool "browser_session"`, invoke `skill browser-skill` again, or set +`lazyTools: false` in the profile patch as a temporary workaround. + ## Live browser view The dsh Web UI prefers a **Browser Skill** tab in DSH's native right sidebar. diff --git a/packages/dsh-plugin-browserskill/docs/development.md b/packages/dsh-plugin-browserskill/docs/development.md index a2e6abce..fa3f8731 100644 --- a/packages/dsh-plugin-browserskill/docs/development.md +++ b/packages/dsh-plugin-browserskill/docs/development.md @@ -31,6 +31,15 @@ plugin is unloaded. Repeated invocations do not register duplicate tools. Enteri a resumed conversation whose history contains a successful skill invocation also registers the tools. Setting `lazyTools: false` registers them at plugin startup. +Reload recovery recognizes both current DSH tool-result messages (a tool source +and a matching `tool-result` content block) and older flat `callId`/`isError` +messages. It scans each session object's existing append-only history once per +plugin lifetime, using `snapshotEvents()` on newer hosts or the legacy `events` +getter. It then folds new events without reading or copying the full log on +streaming updates. Pending call IDs are isolated by session and removed when their +results arrive. A failed history read is retried on a later event; sessions missed +at startup are discovered through service readiness or their first later event. + ## Observation subscriptions and routes - **Native presentation**: the client optionally injects `sidebarRight` and diff --git a/packages/dsh-plugin-browserskill/package.json b/packages/dsh-plugin-browserskill/package.json index 81187b8d..a26c08da 100644 --- a/packages/dsh-plugin-browserskill/package.json +++ b/packages/dsh-plugin-browserskill/package.json @@ -107,6 +107,7 @@ "@deepseek-ai/dsh-client-ui-tool": "^0.1.0-rc.6", "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", "@deepseek-ai/dsh-scope": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "0.1.0-rc.6", "@deepseek-ai/dsh-skill": "^0.1.0-rc.6", "@deepseek-ai/dsh-tools": "^0.1.0-rc.6", "@deepseek-ai/schemastery": "^3.18.1", diff --git a/packages/dsh-plugin-browserskill/src/lazy-tools.ts b/packages/dsh-plugin-browserskill/src/lazy-tools.ts index f7a61ec2..000dcbff 100644 --- a/packages/dsh-plugin-browserskill/src/lazy-tools.ts +++ b/packages/dsh-plugin-browserskill/src/lazy-tools.ts @@ -7,7 +7,8 @@ * 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. + * pair, or a skill-invocation sourced message) when a session is entered or + * first observed after a reload. Later events are consumed incrementally. * * Verified against dsh 0.1 (recorded in the PR ticket): * - `tools/result(exec, result)`: exec carries normalized `name`/`arguments`, @@ -19,8 +20,8 @@ * 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. + * - Durable results use message.source.callId and a tool-result content + * block. Older hosts stored callId/isError directly on the message. */ import type { Context } from "@deepseek-ai/cordis"; @@ -28,7 +29,8 @@ import { BSK_SKILL_NAME } from "./skill-content.generated"; /** Structural view of the pieces of the session seam we consume. */ interface SessionLike { - events: readonly SessionEventLike[]; + events?: readonly SessionEventLike[]; + snapshotEvents?(): readonly SessionEventLike[]; } interface SessionEventLike { type: string; @@ -43,6 +45,67 @@ interface ToolResultExecutionLike { arguments?: unknown; } +function record(value: unknown): Record | undefined { + return typeof value === "object" && value !== null + ? (value as Record) + : undefined; +} + +function isCallId(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} + +/** Accept current DSH messages and the older flat tool-result shape. */ +function toolResultOf(data: unknown): { callId: string; isError: boolean } | undefined { + const message = record(record(data)?.message); + if (message === undefined) return; + const source = record(message.source); + if (source?.kind === "tool") { + const blocks = message.content; + const block = Array.isArray(blocks) && blocks.length === 1 ? record(blocks[0]) : undefined; + if ( + isCallId(source.callId) && + block?.type === "tool-result" && + block.toolCallId === source.callId && + typeof block.isError === "boolean" + ) { + return { callId: source.callId, isError: block.isError }; + } + return; + } + if (isCallId(message.callId) && typeof message.isError === "boolean") { + return { callId: message.callId, isError: message.isError }; + } +} + +/** One session's append-only history fold; retain only unsettled skill calls. */ +class SkillInvocationState { + successful = false; + private readonly pending = new Set(); + + consume(event: SessionEventLike): void { + if (this.successful) return; + if (event.type === "user/message" && isSkillInvocationMessage(event.data)) { + this.successful = true; + } else if (event.type === "tool/call") { + const data = record(event.data); + if ( + data?.name === "skill" && + skillNameOf(data.arguments) === BSK_SKILL_NAME && + isCallId(data.callId) + ) { + this.pending.add(data.callId); + } + } else if (event.type === "tool/result") { + const result = toolResultOf(event.data); + if (result !== undefined && this.pending.delete(result.callId)) { + this.successful = !result.isError; + } + } + if (this.successful) this.pending.clear(); + } +} + /** Parse a tool arguments payload that may be normalized (object) or raw JSON. */ function skillNameOf(args: unknown): string | undefined { if (typeof args === "string") { @@ -73,22 +136,10 @@ function isSkillInvocationMessage(data: unknown): boolean { * `/browser-skill` user gesture landed as a skill-invocation message. */ export function hasSuccessfulSkillInvocation(events: readonly SessionEventLike[]): boolean { - const skillCallIds = new Set(); + const state = new SkillInvocationState(); 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; + state.consume(event); + if (state.successful) return true; } return false; } @@ -99,12 +150,17 @@ export function hasSuccessfulSkillInvocation(events: readonly SessionEventLike[] * @param registerSuite - registers the six browser tools and returns their disposer. */ export function armLazyTools(ctx: Context, registerSuite: () => () => void): () => void { + let disposed = false; + let revealRequested = false; + let sessionStates = new WeakMap(); let suiteDisposer: (() => void) | undefined; const disposers: (() => void)[] = []; const ensureSuite = (): void => { - if (suiteDisposer !== undefined) return; + if (disposed || suiteDisposer !== undefined) return; + revealRequested = true; try { suiteDisposer = registerSuite(); + sessionStates = new WeakMap(); } catch (error) { // A failed reveal must not strand the plugin: stay hidden, log, retry on // the next trigger instead of latching a half-registered suite. @@ -117,52 +173,86 @@ export function armLazyTools(ctx: Context, registerSuite: () => () => void): () // Live trigger: a successful model invocation of skill/browser-skill. const onToolResult = (exec: ToolResultExecutionLike, result: { isError: boolean }): void => { - if (result.isError) return; + if (result.isError !== false) 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 => { + const stateFor = (session: SessionLike): SkillInvocationState | undefined => { + const known = sessionStates.get(session); + if (known !== undefined) return known; try { - if (hasSuccessfulSkillInvocation(session.events)) ensureSuite(); + const state = new SkillInvocationState(); + // Newer hosts expose an immutable snapshot method; earlier 0.1 hosts + // expose an events getter. Both are read only once per live session. + const history = + typeof session.snapshotEvents === "function" ? session.snapshotEvents() : session.events; + if (history === undefined) return undefined; + for (const event of history) { + state.consume(event); + if (state.successful) break; + } + // A failed read is not a completed scan: retry on the next event. + sessionStates.set(session, state); + return state; } catch { - // A session object that cannot be read must never break plugin startup. + return undefined; } }; + const scanSession = (session: SessionLike): void => { + if (disposed || suiteDisposer !== undefined) return; + if (revealRequested || stateFor(session)?.successful) ensureSuite(); + }; + // 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 (suiteDisposer !== undefined) return; - if (isSkillInvocationMessage(event?.data)) { + if (disposed || suiteDisposer !== undefined) return; + if ( + revealRequested || + (event?.type === "user/message" && 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); + if (session == null || event == null) return; + // The first event can expose a session missed during startup. Scan its + // existing history once; never read/copy the log on subsequent tokens. + const state = stateFor(session); + state?.consume(event); + if (state?.successful) 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); - } + const scanExisting = (context: Context): void => { + if (disposed || suiteDisposer !== undefined) return; + try { + const sessions = context.get("sessions") as SessionsLike | null | undefined; + if (sessions != null && typeof sessions.list === "function") { + for (const session of sessions.list()) scanSession(session); + } + } catch { + // An unavailable registry must not prevent later session/event recovery. + } + }; + scanExisting(ctx); + // Also restore as soon as a late sessions service is available, before the + // next tool lookup. Cordis owns this watcher's lifetime with the plugin. + const watcher = ctx.inject(["sessions"], scanExisting); + disposers.push(() => { + void watcher.dispose(); + }); return () => { + if (disposed) return; + disposed = true; for (const dispose of disposers.splice(0)) dispose(); suiteDisposer?.(); + sessionStates = new WeakMap(); }; } diff --git a/packages/dsh-plugin-browserskill/tests/lazy-tools.integration.test.ts b/packages/dsh-plugin-browserskill/tests/lazy-tools.integration.test.ts new file mode 100644 index 00000000..33c216fd --- /dev/null +++ b/packages/dsh-plugin-browserskill/tests/lazy-tools.integration.test.ts @@ -0,0 +1,145 @@ +import { Context } from "@deepseek-ai/cordis"; +import { CallId, createToolResultMessage, createUserMessage } from "@deepseek-ai/dsh-llm"; +import { SessionId, SessionStore } from "@deepseek-ai/dsh-session"; +import { describe, expect, it, vi } from "vitest"; +import { armLazyTools } from "../src/lazy-tools"; + +describe("lazy tools with the DSH session lifecycle", () => { + it("restores a successful model invocation after a real Cordis plugin unload/reload", async () => { + const root = new Context(); + const sessions = root.plugin(SessionStore); + await sessions; + const session = sessions.ctx.sessions.create(SessionId("reload")); + let registered = false; + const registerSuite = vi.fn(() => { + registered = true; + return () => { + registered = false; + }; + }); + const plugin = (ctx: Context) => { + const disarm = armLazyTools(ctx, registerSuite); + ctx.effect(() => disarm); + }; + let mounted = root.plugin(plugin); + await mounted; + try { + expect(registered).toBe(false); + session.append("tool/call", { + turn: 0, + step: 0, + callId: CallId("skill"), + name: "skill", + arguments: '{"name":"browser-skill"}', + }); + session.append( + "tool/result", + { + turn: 0, + step: 0, + message: createToolResultMessage({ + callId: CallId("skill"), + isError: false, + content: [], + }), + }, + { surfaceOp: "append" }, + ); + // DSH also emits the live tool result; the reloaded plugin must recover + // without receiving that notification a second time. + root.emit( + "tools/result", + { name: "skill", arguments: { name: "browser-skill" } } as never, + { isError: false } as never, + ); + expect(registered).toBe(true); + await mounted.dispose(); + expect(registered).toBe(false); + + const readHistory = vi.spyOn(session, "events", "get"); + mounted = root.plugin(plugin); + await mounted; + expect(registered).toBe(true); + expect(registerSuite).toHaveBeenCalledTimes(2); + expect(readHistory).toHaveBeenCalledTimes(1); + // The restarted plugin needs no second skill call or new event to restore. + expect(session.events.filter((event) => event.type === "tool/call")).toHaveLength(1); + readHistory.mockRestore(); + } finally { + await mounted.dispose(); + await sessions.dispose(); + } + }); + + it("restores a missed session through the real append feed", async () => { + const root = new Context(); + const sessions = root.plugin(SessionStore); + await sessions; + const session = sessions.ctx.sessions.create(SessionId("missed")); + session.append( + "user/message", + createUserMessage({ + content: [{ type: "text", text: "skill instructions" }], + source: { kind: "skill-invocation", name: "browser-skill", form: "instructions" }, + }), + { surfaceOp: "append" }, + ); + const registerSuite = vi.fn(() => () => {}); + // Suppress only startup discovery; publication still uses real Cordis events. + const disarm = armLazyTools( + { + get: () => undefined, + on: root.on.bind(root), + inject: () => ({ dispose() {} }), + } as unknown as Context, + registerSuite, + ); + try { + expect(registerSuite).not.toHaveBeenCalled(); + session.append("assistant/chunk", { + turn: 0, + step: 0, + chunk: { type: "text-delta", index: 0, text: "x" }, + }); + expect(registerSuite).toHaveBeenCalledTimes(1); + } finally { + disarm(); + await sessions.dispose(); + } + }); + + it("watches a sessions service that is mounted after the browser plugin", async () => { + const root = new Context(); + const registerSuite = vi.fn(() => () => {}); + const mounted = root.plugin((ctx) => { + const disarm = armLazyTools(ctx, registerSuite); + ctx.effect(() => disarm); + }); + await mounted; + const sessions = root.plugin(SessionStore); + await sessions; + try { + const session = sessions.ctx.sessions.create(SessionId("late")); + session.append("tool/call", { + turn: 0, + step: 0, + callId: CallId("c"), + name: "skill", + arguments: '{"name":"browser-skill"}', + }); + session.append( + "tool/result", + { + turn: 0, + step: 0, + message: createToolResultMessage({ callId: CallId("c"), isError: false, content: [] }), + }, + { surfaceOp: "append" }, + ); + expect(registerSuite).toHaveBeenCalledTimes(1); + } finally { + await mounted.dispose(); + await sessions.dispose(); + } + }); +}); diff --git a/packages/dsh-plugin-browserskill/tests/lazy-tools.test.ts b/packages/dsh-plugin-browserskill/tests/lazy-tools.test.ts index b5eb63be..04fa53dc 100644 --- a/packages/dsh-plugin-browserskill/tests/lazy-tools.test.ts +++ b/packages/dsh-plugin-browserskill/tests/lazy-tools.test.ts @@ -3,25 +3,60 @@ // 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 { createToolResultMessage } from "@deepseek-ai/dsh-llm"; +import { Session, SessionId } from "@deepseek-ai/dsh-session"; +import type { ToolDefinition } 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"; +import type { BskRunResult } from "../src/runner"; import { memoryStartJournal } from "../src/start-journal"; -function fakeEventCtx(sessions?: { list(): { events: unknown[] }[] }) { +function fakeEventCtx(sessions?: { + list(): { events?: readonly unknown[]; snapshotEvents?(): readonly unknown[] }[]; +}) { const listeners = new Map void>(); + let onSessionsReady: ((ctx: unknown) => void) | undefined; const ctx = { on: (event: string, listener: (...args: never[]) => void) => { listeners.set(event, listener); return () => listeners.delete(event); }, get: (key: string) => (key === "sessions" ? sessions : undefined), + inject: (_deps: string[], callback: (ctx: unknown) => void) => { + onSessionsReady = callback; + return { + dispose: () => { + onSessionsReady = undefined; + }, + }; + }, + }; + return { + ctx: ctx as never, + listeners, + provideSessions(value: NonNullable) { + sessions = value; + onSessionsReady?.(ctx); + }, }; - return { ctx: ctx as never, listeners }; } +const skillCall = (callId = "skill-1") => ({ + type: "tool/call", + data: { callId, name: "skill", arguments: '{"name":"browser-skill"}' }, +}); +const skillResult = (isError = false, callId = "skill-1") => ({ + type: "tool/result", + data: { + message: createToolResultMessage({ + callId: callId as never, + content: [{ type: "text", text: isError ? "skill unavailable" : "skill instructions" }], + isError, + }), + }, +}); + function callListeners( listeners: Map void>, event: string, @@ -183,15 +218,18 @@ describe("armLazyTools", () => { ); 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. + // The implementation catches read failures, so assert the getter call count. + const readHistory = vi.fn(() => { + throw new Error("session history must not be read after the reveal"); + }); const exploding = { get events(): never { - throw new Error("session history must not be read after the reveal"); + return readHistory(); }, }; callListeners(listeners, "session/event", exploding, { type: "message/append" }); + callListeners(listeners, "session/created", exploding); + expect(readHistory).not.toHaveBeenCalled(); expect(registerSuite).toHaveBeenCalledTimes(1); }); @@ -206,12 +244,193 @@ describe("armLazyTools", () => { { isError: false }, ); disarm(); + disarm(); expect(suiteDispose).toHaveBeenCalledTimes(1); expect(listeners.size).toBe(0); }); + + it("restores current DSH history when a missed session later emits an ordinary event", () => { + const { ctx, listeners } = fakeEventCtx(); + const registerSuite = vi.fn(() => () => {}); + armLazyTools(ctx, registerSuite); + const session = { events: [skillCall(), skillResult()] }; + callListeners(listeners, "session/event", session, { type: "turn/start", data: {} }); + expect(registerSuite).toHaveBeenCalledTimes(1); + }); + + it("restores as soon as a late sessions service becomes available", () => { + const host = fakeEventCtx(); + const registerSuite = vi.fn(() => () => {}); + const disarm = armLazyTools(host.ctx, registerSuite); + host.provideSessions({ list: () => [{ events: [skillCall(), skillResult()] }] }); + expect(registerSuite).toHaveBeenCalledTimes(1); + disarm(); + host.provideSessions({ list: () => [{ events: [skillCall(), skillResult()] }] }); + expect(registerSuite).toHaveBeenCalledTimes(1); + }); + + it("restores from the newer DSH snapshotEvents API", () => { + const session = { snapshotEvents: vi.fn(() => [skillCall(), skillResult()]) }; + const { ctx } = fakeEventCtx({ list: () => [session] }); + const registerSuite = vi.fn(() => () => {}); + armLazyTools(ctx, registerSuite); + expect(session.snapshotEvents).toHaveBeenCalledTimes(1); + expect(registerSuite).toHaveBeenCalledTimes(1); + }); + + it("consumes new events without taking another snapshot on newer DSH", () => { + const session = { snapshotEvents: vi.fn(() => [skillCall()]) }; + const { ctx, listeners } = fakeEventCtx({ list: () => [session] }); + const registerSuite = vi.fn(() => () => {}); + armLazyTools(ctx, registerSuite); + for (let i = 0; i < 20_000; i++) { + callListeners(listeners, "session/event", session, { type: "assistant/chunk" }); + } + expect(registerSuite).not.toHaveBeenCalled(); + callListeners(listeners, "session/event", session, skillResult()); + expect(registerSuite).toHaveBeenCalledTimes(1); + expect(session.snapshotEvents).toHaveBeenCalledTimes(1); + }); + + it("scans a long uninvoked session once and then consumes live call/results", () => { + const session = Session.create(SessionId("streaming")); + const readHistory = vi.spyOn(session, "events", "get"); + const { ctx, listeners } = fakeEventCtx({ list: () => [session] }); + const registerSuite = vi.fn(() => () => {}); + armLazyTools(ctx, registerSuite); + for (let i = 0; i < 20_000; i++) { + const event = session.append("assistant/chunk", { + turn: 0, + step: 0, + chunk: { type: "text-delta", index: 0, text: "x" }, + }); + callListeners(listeners, "session/event", session, event); + } + callListeners(listeners, "session/created", session); + expect(readHistory).toHaveBeenCalledTimes(1); + expect(registerSuite).not.toHaveBeenCalled(); + + callListeners(listeners, "session/event", session, skillCall()); + callListeners(listeners, "session/event", session, skillResult(true)); + // A failed call is settled; a later result alone cannot turn it into proof. + callListeners(listeners, "session/event", session, skillResult()); + expect(registerSuite).not.toHaveBeenCalled(); + callListeners(listeners, "session/event", session, skillCall("retry")); + callListeners(listeners, "session/event", session, skillResult(false, "retry")); + expect(registerSuite).toHaveBeenCalledTimes(1); + expect(readHistory).toHaveBeenCalledTimes(1); + readHistory.mockRestore(); + }); + + it("pairs a result arriving after the first scan with an earlier pending call", () => { + const session = { events: [skillCall()] }; + const { ctx, listeners } = fakeEventCtx({ list: () => [session] }); + const registerSuite = vi.fn(() => () => {}); + armLazyTools(ctx, registerSuite); + expect(registerSuite).not.toHaveBeenCalled(); + callListeners(listeners, "session/event", session, skillResult()); + expect(registerSuite).toHaveBeenCalledTimes(1); + }); + + it("keeps call correlation local to each session object", () => { + const first = { events: [skillCall()] }; + const second = { events: [] }; + const { ctx, listeners } = fakeEventCtx({ list: () => [first, second] }); + const registerSuite = vi.fn(() => () => {}); + armLazyTools(ctx, registerSuite); + callListeners(listeners, "session/event", second, skillResult()); + expect(registerSuite).not.toHaveBeenCalled(); + callListeners(listeners, "session/event", first, skillResult()); + expect(registerSuite).toHaveBeenCalledTimes(1); + }); + + it("retries unreadable history without caching the failure as an empty scan", () => { + const history = vi + .fn() + .mockImplementationOnce(() => { + throw new Error("history not ready"); + }) + .mockReturnValue([skillCall(), skillResult()]); + const session = { + get events() { + return history(); + }, + }; + const { ctx, listeners } = fakeEventCtx({ list: () => [session] }); + const registerSuite = vi.fn(() => () => {}); + armLazyTools(ctx, registerSuite); + expect(registerSuite).not.toHaveBeenCalled(); + callListeners(listeners, "session/event", session, { type: "turn/start" }); + expect(history).toHaveBeenCalledTimes(2); + expect(registerSuite).toHaveBeenCalledTimes(1); + }); + + it("recovers after a startup registry failure", () => { + const { ctx, listeners } = fakeEventCtx({ + list() { + throw new Error("registry unavailable"); + }, + }); + const registerSuite = vi.fn(() => () => {}); + expect(() => armLazyTools(ctx, registerSuite)).not.toThrow(); + callListeners( + listeners, + "session/event", + { events: [skillCall(), skillResult()] }, + { type: "turn/start" }, + ); + expect(registerSuite).toHaveBeenCalledTimes(1); + }); + + it("retries registration on the next event without re-reading successful history", () => { + const warning = vi.spyOn(console, "warn").mockImplementation(() => {}); + const history = vi.fn(() => [skillCall(), skillResult()]); + const session = { + get events() { + return history(); + }, + }; + const { ctx, listeners } = fakeEventCtx({ list: () => [session] }); + const registerSuite = vi + .fn() + .mockImplementationOnce(() => { + throw new Error("registration failed"); + }) + .mockReturnValue(() => {}); + try { + armLazyTools(ctx, registerSuite); + callListeners(listeners, "session/event", session, { type: "turn/start" }); + expect(registerSuite).toHaveBeenCalledTimes(2); + expect(history).toHaveBeenCalledTimes(1); + } finally { + warning.mockRestore(); + } + }); }); describe("hasSuccessfulSkillInvocation", () => { + it("recognizes official DSH result messages and requires successful matching calls", () => { + expect(hasSuccessfulSkillInvocation([skillCall(), skillResult()])).toBe(true); + expect(hasSuccessfulSkillInvocation([skillCall(), skillResult(true)])).toBe(false); + expect(hasSuccessfulSkillInvocation([skillCall(), skillResult(false, "other")])).toBe(false); + expect(hasSuccessfulSkillInvocation([skillResult(), skillCall()])).toBe(false); + expect(hasSuccessfulSkillInvocation([skillResult()])).toBe(false); + }); + + it("rejects missing identities and inconsistent modern result blocks", () => { + expect( + hasSuccessfulSkillInvocation([ + { type: "tool/call", data: { name: "skill", arguments: { name: "browser-skill" } } }, + { type: "tool/result", data: { message: { isError: false } } }, + ]), + ).toBe(false); + const message = structuredClone(skillResult().data.message); + message.content[0].toolCallId = "different" as never; + expect( + hasSuccessfulSkillInvocation([skillCall(), { type: "tool/result", data: { message } }]), + ).toBe(false); + }); + it("pairs call and result by callId and honors gestures", () => { expect(hasSuccessfulSkillInvocation([])).toBe(false); expect( @@ -251,7 +470,7 @@ describe("lazyTools wiring in apply()", () => { const ctx = { tools: { register: (def: ToolDefinition) => tools.set(def.name, def) }, get: () => undefined, - inject: () => {}, + inject: () => ({ dispose() {} }), effect: () => {}, on: (event: string, listener: (...args: never[]) => void) => { listeners.set(event, listener); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4fc27beb..09c1b5ef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -117,6 +117,9 @@ importers: '@deepseek-ai/dsh-scope': specifier: ^0.1.0-rc.6 version: 0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)) + '@deepseek-ai/dsh-session': + specifier: 0.1.0-rc.6 + version: 0.1.0-rc.6(6fd26f59436a18b115f326d6060415e6) '@deepseek-ai/dsh-skill': specifier: ^0.1.0-rc.6 version: 0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1))(@deepseek-ai/dsh-llm@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-attachment@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-brand@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)))(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)))(@deepseek-ai/dsh-brand@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)))(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1))(@deepseek-ai/dsh-timeout@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1))))(@deepseek-ai/dsh-scope@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1))) From a4ac06e48ea244df19d7c17440e7d08a74b1e440 Mon Sep 17 00:00:00 2001 From: drakezhang Date: Sun, 20 Sep 2026 16:04:13 +0800 Subject: [PATCH 3/4] fix(dsh): avoid retrying tool registration on streamed events Retain successful invocation proof after failures and defer retries to turn/session boundaries or new skill invocations. Add regression coverage for sustained streaming and next-turn recovery. --- .../docs/development.md | 3 + .../dsh-plugin-browserskill/src/lazy-tools.ts | 15 ++--- .../tests/lazy-tools.integration.test.ts | 44 ++++++++++++++ .../tests/lazy-tools.test.ts | 58 ++++++++++++++++++- 4 files changed, 112 insertions(+), 8 deletions(-) diff --git a/packages/dsh-plugin-browserskill/docs/development.md b/packages/dsh-plugin-browserskill/docs/development.md index fa3f8731..f16f952c 100644 --- a/packages/dsh-plugin-browserskill/docs/development.md +++ b/packages/dsh-plugin-browserskill/docs/development.md @@ -39,6 +39,9 @@ getter. It then folds new events without reading or copying the full log on streaming updates. Pending call IDs are isolated by session and removed when their results arrive. A failed history read is retried on a later event; sessions missed at startup are discovered through service readiness or their first later event. +Failed registration retains the invocation proof and retries on the next turn, +session entry, service discovery, or successful skill invocation. Streaming +events continue to fold without repeated registration attempts or warnings. ## Observation subscriptions and routes diff --git a/packages/dsh-plugin-browserskill/src/lazy-tools.ts b/packages/dsh-plugin-browserskill/src/lazy-tools.ts index 000dcbff..aabc2c8b 100644 --- a/packages/dsh-plugin-browserskill/src/lazy-tools.ts +++ b/packages/dsh-plugin-browserskill/src/lazy-tools.ts @@ -151,20 +151,21 @@ export function hasSuccessfulSkillInvocation(events: readonly SessionEventLike[] */ export function armLazyTools(ctx: Context, registerSuite: () => () => void): () => void { let disposed = false; - let revealRequested = false; + let revealPending = false; let sessionStates = new WeakMap(); let suiteDisposer: (() => void) | undefined; const disposers: (() => void)[] = []; const ensureSuite = (): void => { if (disposed || suiteDisposer !== undefined) return; - revealRequested = true; try { suiteDisposer = registerSuite(); + revealPending = false; sessionStates = new WeakMap(); } catch (error) { - // A failed reveal must not strand the plugin: stay hidden, log, retry on - // the next trigger instead of latching a half-registered suite. + // Keep successful invocation proof, but retry only at a lifecycle + // boundary or a new invocation, never on every streaming chunk. suiteDisposer = undefined; + revealPending = true; console.warn( `[dsh-plugin-browserskill] lazy tool registration failed: ${error instanceof Error ? error.message : String(error)}`, ); @@ -203,7 +204,7 @@ export function armLazyTools(ctx: Context, registerSuite: () => () => void): () const scanSession = (session: SessionLike): void => { if (disposed || suiteDisposer !== undefined) return; - if (revealRequested || stateFor(session)?.successful) ensureSuite(); + if (revealPending || stateFor(session)?.successful) ensureSuite(); }; // Live gesture/append feed: covers /browser-skill user gestures (no tool @@ -211,7 +212,7 @@ export function armLazyTools(ctx: Context, registerSuite: () => () => void): () const onSessionEvent = (session: SessionLike, event: SessionEventLike): void => { if (disposed || suiteDisposer !== undefined) return; if ( - revealRequested || + (revealPending && event?.type === "turn/start") || (event?.type === "user/message" && isSkillInvocationMessage(event.data)) ) { ensureSuite(); @@ -222,7 +223,7 @@ export function armLazyTools(ctx: Context, registerSuite: () => () => void): () // existing history once; never read/copy the log on subsequent tokens. const state = stateFor(session); state?.consume(event); - if (state?.successful) ensureSuite(); + if (state?.successful && !revealPending) ensureSuite(); }; disposers.push(ctx.on("session/event" as never, onSessionEvent as never)); diff --git a/packages/dsh-plugin-browserskill/tests/lazy-tools.integration.test.ts b/packages/dsh-plugin-browserskill/tests/lazy-tools.integration.test.ts index 33c216fd..b2ce288f 100644 --- a/packages/dsh-plugin-browserskill/tests/lazy-tools.integration.test.ts +++ b/packages/dsh-plugin-browserskill/tests/lazy-tools.integration.test.ts @@ -5,6 +5,50 @@ import { describe, expect, it, vi } from "vitest"; import { armLazyTools } from "../src/lazy-tools"; describe("lazy tools with the DSH session lifecycle", () => { + it("retries a failed reveal on a real turn boundary instead of each streamed chunk", async () => { + const root = new Context(); + const sessions = root.plugin(SessionStore); + await sessions; + const session = sessions.ctx.sessions.create(SessionId("registration-retry")); + const warning = vi.spyOn(console, "warn").mockImplementation(() => {}); + const registerSuite = vi + .fn() + .mockImplementationOnce(() => { + throw new Error("registration unavailable"); + }) + .mockReturnValue(() => {}); + const mounted = root.plugin((ctx) => { + const disarm = armLazyTools(ctx, registerSuite); + ctx.effect(() => disarm); + }); + await mounted; + try { + session.append( + "user/message", + createUserMessage({ + content: [{ type: "text", text: "skill instructions" }], + source: { kind: "skill-invocation", name: "browser-skill", form: "instructions" }, + }), + { surfaceOp: "append" }, + ); + for (let i = 0; i < 20_000; i++) { + session.append("assistant/chunk", { + turn: 0, + step: 0, + chunk: { type: "text-delta", index: 0, text: "x" }, + }); + } + expect(registerSuite).toHaveBeenCalledTimes(1); + expect(warning).toHaveBeenCalledTimes(1); + session.append("turn/start", { turn: 1 }); + expect(registerSuite).toHaveBeenCalledTimes(2); + } finally { + await mounted.dispose(); + await sessions.dispose(); + warning.mockRestore(); + } + }); + it("restores a successful model invocation after a real Cordis plugin unload/reload", async () => { const root = new Context(); const sessions = root.plugin(SessionStore); diff --git a/packages/dsh-plugin-browserskill/tests/lazy-tools.test.ts b/packages/dsh-plugin-browserskill/tests/lazy-tools.test.ts index 04fa53dc..7730b069 100644 --- a/packages/dsh-plugin-browserskill/tests/lazy-tools.test.ts +++ b/packages/dsh-plugin-browserskill/tests/lazy-tools.test.ts @@ -382,7 +382,7 @@ describe("armLazyTools", () => { expect(registerSuite).toHaveBeenCalledTimes(1); }); - it("retries registration on the next event without re-reading successful history", () => { + it("retries registration on the next turn without re-reading successful history", () => { const warning = vi.spyOn(console, "warn").mockImplementation(() => {}); const history = vi.fn(() => [skillCall(), skillResult()]); const session = { @@ -406,6 +406,62 @@ describe("armLazyTools", () => { warning.mockRestore(); } }); + + it.each([ + "history", + "tool result", + "gesture", + ] as const)("defers failed registration from %s during streaming and recovers on the next turn", (trigger) => { + const warning = vi.spyOn(console, "warn").mockImplementation(() => {}); + const history = vi.fn(() => (trigger === "history" ? [skillCall(), skillResult()] : [])); + const session = { + get events() { + return history(); + }, + }; + const { ctx, listeners } = fakeEventCtx({ list: () => [session] }); + let available = false; + const registerSuite = vi.fn(() => { + if (!available) throw new Error("registration unavailable"); + return () => {}; + }); + const disarm = armLazyTools(ctx, registerSuite); + try { + if (trigger === "tool result") { + callListeners( + listeners, + "tools/result", + { name: "skill", arguments: { name: "browser-skill" } }, + { isError: false }, + ); + } else if (trigger === "gesture") { + callListeners(listeners, "session/event", session, { + type: "user/message", + data: { source: { kind: "skill-invocation", name: "browser-skill" } }, + }); + } + expect(registerSuite).toHaveBeenCalledTimes(1); + for (let i = 0; i < 20_000; i++) { + callListeners(listeners, "session/event", session, { type: "assistant/chunk" }); + } + expect(registerSuite).toHaveBeenCalledTimes(1); + expect(warning).toHaveBeenCalledTimes(1); + expect(history).toHaveBeenCalledTimes(1); + + // A live result/gesture may not be in the first snapshot. Keep its + // successful invocation proof, without retrying registration per token. + available = true; + callListeners(listeners, "session/event", session, { type: "turn/start" }); + expect(registerSuite).toHaveBeenCalledTimes(2); + callListeners(listeners, "session/event", session, { type: "assistant/chunk" }); + expect(registerSuite).toHaveBeenCalledTimes(2); + expect(warning).toHaveBeenCalledTimes(1); + expect(history).toHaveBeenCalledTimes(1); + } finally { + disarm(); + warning.mockRestore(); + } + }); }); describe("hasSuccessfulSkillInvocation", () => { From 963f694be372eeb4eae4d7b5b44aa124b6f32004 Mon Sep 17 00:00:00 2001 From: drakezhang Date: Sun, 20 Sep 2026 16:40:26 +0800 Subject: [PATCH 4/4] fix(dsh): retry lazy tool registration once per session batch --- .../dsh-plugin-browserskill/skill/SKILL.md | 3 ++ .../dsh-plugin-browserskill/src/lazy-tools.ts | 12 ++++- .../tests/lazy-tools.test.ts | 52 +++++++++++++++++++ 3 files changed, 66 insertions(+), 1 deletion(-) diff --git a/packages/dsh-plugin-browserskill/skill/SKILL.md b/packages/dsh-plugin-browserskill/skill/SKILL.md index 584a7d33..6ccfb579 100644 --- a/packages/dsh-plugin-browserskill/skill/SKILL.md +++ b/packages/dsh-plugin-browserskill/skill/SKILL.md @@ -79,6 +79,9 @@ unknown effects or switch backends to bypass limits. Borrow confirmation still a ## Recover +- Unknown browser tool after plugin reload: invoke `skill` with `name: "browser-skill"` + again (users can enter `/browser-skill`), then retry the intended browser tool once + after its schema appears. If it remains unavailable, report the failure. - Stale ref: observe, then retry the intended action once. - Unknown tab/session: list owned resources or start a session; never guess IDs. - Failed or interrupted session stop: accepted cleanup continues in the background. diff --git a/packages/dsh-plugin-browserskill/src/lazy-tools.ts b/packages/dsh-plugin-browserskill/src/lazy-tools.ts index aabc2c8b..7590a250 100644 --- a/packages/dsh-plugin-browserskill/src/lazy-tools.ts +++ b/packages/dsh-plugin-browserskill/src/lazy-tools.ts @@ -232,10 +232,20 @@ export function armLazyTools(ctx: Context, registerSuite: () => () => void): () disposers.push(ctx.on("session/created" as never, onSessionCreated as never)); const scanExisting = (context: Context): void => { if (disposed || suiteDisposer !== undefined) return; + // Invocation proof is already retained: retry once for this discovery, + // independently of how many sessions the registry currently contains. + if (revealPending) { + ensureSuite(); + return; + } try { const sessions = context.get("sessions") as SessionsLike | null | undefined; if (sessions != null && typeof sessions.list === "function") { - for (const session of sessions.list()) scanSession(session); + for (const session of sessions.list()) { + scanSession(session); + // A failed attempt also ends this batch; later lifecycle events retry. + if (revealPending || suiteDisposer !== undefined) break; + } } } catch { // An unavailable registry must not prevent later session/event recovery. diff --git a/packages/dsh-plugin-browserskill/tests/lazy-tools.test.ts b/packages/dsh-plugin-browserskill/tests/lazy-tools.test.ts index 7730b069..d1dfd34b 100644 --- a/packages/dsh-plugin-browserskill/tests/lazy-tools.test.ts +++ b/packages/dsh-plugin-browserskill/tests/lazy-tools.test.ts @@ -407,6 +407,58 @@ describe("armLazyTools", () => { } }); + it.each([ + "startup", + "service injection", + ] as const)("attempts registration once per session batch discovered through %s", (trigger) => { + const warning = vi.spyOn(console, "warn").mockImplementation(() => {}); + const sessions = Array.from({ length: 100 }, (_, index) => ({ + snapshotEvents: vi.fn(() => (index === 3 ? [skillCall(), skillResult()] : [])), + })); + const registry = { list: vi.fn(() => sessions) }; + const host = fakeEventCtx(trigger === "startup" ? registry : undefined); + let available = false; + const suiteDispose = vi.fn(); + const registerSuite = vi.fn(() => { + if (!available) throw new Error("registration unavailable"); + return suiteDispose; + }); + const disarm = armLazyTools(host.ctx, registerSuite); + try { + if (trigger === "service injection") host.provideSessions(registry); + expect(registerSuite).toHaveBeenCalledTimes(1); + expect(warning).toHaveBeenCalledTimes(1); + for (const [index, session] of sessions.entries()) { + expect(session.snapshotEvents).toHaveBeenCalledTimes(index <= 3 ? 1 : 0); + } + + // Pending recovery is global: one later batch means one retry, + // without enumerating or scanning the remaining sessions. + host.provideSessions(registry); + expect(registerSuite).toHaveBeenCalledTimes(2); + expect(warning).toHaveBeenCalledTimes(2); + expect(registry.list).toHaveBeenCalledTimes(1); + + // The successful invocation proof survives repeated failures even + // when the next discovery no longer lists the original session. + available = true; + const emptyRegistry = { list: vi.fn(() => []) }; + host.provideSessions(emptyRegistry); + expect(registerSuite).toHaveBeenCalledTimes(3); + expect(warning).toHaveBeenCalledTimes(2); + expect(emptyRegistry.list).not.toHaveBeenCalled(); + host.provideSessions(registry); + expect(registerSuite).toHaveBeenCalledTimes(3); + for (const [index, session] of sessions.entries()) { + expect(session.snapshotEvents).toHaveBeenCalledTimes(index <= 3 ? 1 : 0); + } + } finally { + disarm(); + warning.mockRestore(); + } + expect(suiteDispose).toHaveBeenCalledTimes(1); + }); + it.each([ "history", "tool result",