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..f16f952c 100644 --- a/packages/dsh-plugin-browserskill/docs/development.md +++ b/packages/dsh-plugin-browserskill/docs/development.md @@ -31,6 +31,18 @@ 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. +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 - **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/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 fe30119f..7590a250 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(); - 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); - } - } + const state = new SkillInvocationState(); 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,16 +150,22 @@ 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 revealPending = false; + let sessionStates = new WeakMap(); let suiteDisposer: (() => void) | undefined; const disposers: (() => void)[] = []; const ensureSuite = (): void => { - if (suiteDisposer !== undefined) return; + if (disposed || suiteDisposer !== undefined) return; 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)}`, ); @@ -117,37 +174,96 @@ 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 (revealPending || 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 (isSkillInvocationMessage(event?.data)) ensureSuite(); + const onSessionEvent = (session: SessionLike, event: SessionEventLike): void => { + if (disposed || suiteDisposer !== undefined) return; + if ( + (revealPending && event?.type === "turn/start") || + (event?.type === "user/message" && isSkillInvocationMessage(event.data)) + ) { + ensureSuite(); + return; + } + 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 && !revealPending) 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; + // 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); + // 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. + } + }; + 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..b2ce288f --- /dev/null +++ b/packages/dsh-plugin-browserskill/tests/lazy-tools.integration.test.ts @@ -0,0 +1,189 @@ +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("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); + 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 ab557bff..d1dfd34b 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, @@ -136,6 +171,68 @@ 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); + + // 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 { + return readHistory(); + }, + }; + callListeners(listeners, "session/event", exploding, { type: "message/append" }); + callListeners(listeners, "session/created", exploding); + expect(readHistory).not.toHaveBeenCalled(); + expect(registerSuite).toHaveBeenCalledTimes(1); + }); + it("disposes listeners and the revealed suite", () => { const { ctx, listeners } = fakeEventCtx(); const suiteDispose = vi.fn(); @@ -147,12 +244,301 @@ 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 turn 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(); + } + }); + + 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", + "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", () => { + 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( @@ -192,7 +578,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)))