import { existsSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { ToolDefinition, ToolRunContext } from "@deepseek-ai/dsh-tools"; import { describe, expect, it, vi } from "vitest"; import { ObservationService } from "../src/observation"; import { KeyedExecutor } from "../src/queue"; import type { BskRunner, BskRunOptions, BskRunResult } from "../src/runner"; import { SessionRegistry } from "../src/sessions"; import { type PluginConfig, registerTools } from "../src/tools"; const CONFIG: PluginConfig = { bskPath: "bsk", defaultTimeoutMs: 120_000, maxSessions: 5, observationEnabled: false, thumbnailIntervalMs: 1500, idleIntervalMs: 8000, lazyTools: false, }; /** An observation service with the feature off: instrumentation becomes a no-op. */ function disabledObservation(deps: { ctx: unknown; runner: BskRunner; registry: SessionRegistry }) { return new ObservationService({ ctx: deps.ctx as never, runner: deps.runner, registry: deps.registry, queue: new KeyedExecutor(), options: { enabled: false, thumbnailIntervalMs: 1500, idleIntervalMs: 8000 }, }); } interface FakeCall { args: string[]; options: BskRunOptions; } /** Mark a canned answer as a per-call sequence (each call shifts one reply). */ function seq(items: unknown[]): { __seq: unknown[] } { return { __seq: items }; } /** A runner that answers canned JSON per command prefix and records calls. */ function fakeRunner(responses: Record) { const calls: FakeCall[] = []; return { calls, runner: { async run(args: string[], options: BskRunOptions = {}): Promise { calls.push({ args, options }); if (options.signal?.aborted) { return { code: null, stdout: "", stderr: "", timedOut: false, aborted: true }; } for (const [prefix, response] of Object.entries(responses)) { if (args.join(" ").startsWith(prefix)) { if (response instanceof Error) throw response; const reply = typeof response === "object" && response !== null && "__seq" in response ? (response as { __seq: unknown[] }).__seq.shift() : response; return { code: 0, stdout: JSON.stringify(reply), stderr: "", timedOut: false, aborted: false, }; } } return { code: 2, stdout: JSON.stringify({ code: "unexpected", message: `no canned response for: ${args.join(" ")}`, }), stderr: "", timedOut: false, aborted: false, }; }, killAll() {}, killFor: () => 0, }, }; } function makeCtx(services: Record = {}) { const tools = new Map(); const ctx = { tools: { register: (def: ToolDefinition) => tools.set(def.name, def) }, get: (key: string) => services[key], }; return { ctx, tools }; } function makeExec(overrides: Record = {}): ToolRunContext { return { callId: "test-call", name: "test", signal: new AbortController().signal, ...overrides, } as unknown as ToolRunContext; } const SNAPSHOT_REPLY = { text: '- button "OK" [ref=@e1]', ref_count: 1, tab_id: 7, truncated: false, }; function setup( responses: Record, services: Record = {}, maxSessions = 5, ) { const { ctx, tools } = makeCtx(services); const { runner, calls } = fakeRunner(responses); const registry = new SessionRegistry(maxSessions); const config = { ...CONFIG, maxSessions }; registerTools({ ctx: ctx as never, runner: runner as BskRunner, registry, config, observation: disabledObservation({ ctx, runner: runner as BskRunner, registry }), queue: new KeyedExecutor(), }); return { tools, calls, registry }; } async function startSession(tools: Map, sessionId = "s1") { const tool = tools.get("browser_session_start"); if (tool === undefined) throw new Error("browser_session_start not registered"); return tool.execute({}, makeExec()) as Promise<{ sessionId: string }>; } const START_REPLY = (id: string) => ({ session_id: id, browser_instance_id: "chrome-1" }); describe("tool registration", () => { it("registers the full browser tool suite", () => { const { tools } = setup({}); expect([...tools.keys()].sort()).toEqual([ "browser_click", "browser_emulate", "browser_fill", "browser_navigate", "browser_observe", "browser_press", "browser_screenshot", "browser_session_list", "browser_session_start", "browser_session_stop", "browser_snapshot", ]); }); it("validates model args through defineTool (missing required param)", async () => { const { tools } = setup({}); const navigate = tools.get("browser_navigate"); await expect(navigate?.execute({}, makeExec())).rejects.toThrow(/invalid arguments/); }); }); describe("browser_session_start", () => { it("maps the start reply and tracks the session as current", async () => { const { tools, registry } = setup({ "session start": START_REPLY("s1") }); const value = await startSession(tools); expect(value.sessionId).toBe("s1"); expect(registry.current()).toBe("s1"); }); it("passes window size and opens the initial URL and device preset", async () => { const { tools, calls } = setup({ "session start": START_REPLY("s1"), emulate: { tab_id: 7, cleared: false }, navigate: { tab_id: 7, url: "https://example.com", reached: "load" }, }); const tool = tools.get("browser_session_start"); const value = (await tool?.execute( { width: 1280, height: 800, url: "https://example.com", device: "iphone-14", noFocus: true }, makeExec(), )) as { sessionId: string; url: string; device: string }; expect(value).toMatchObject({ sessionId: "s1", url: "https://example.com", device: "iphone-14", }); expect(calls[0].args).toEqual([ "session", "start", "--width", "1280", "--height", "800", "--no-focus", ]); expect(calls[1].args).toEqual(["emulate", "--session", "s1", "--device", "iphone-14"]); expect(calls[2].args).toEqual(["navigate", "--session", "s1", "https://example.com"]); }); it("requires width and height together", async () => { const { tools } = setup({ "session start": START_REPLY("s1") }); const tool = tools.get("browser_session_start"); await expect(tool?.execute({ width: 1280 }, makeExec())).rejects.toThrow(/together/); }); it("cleans up a half-initialized session when the initial navigate fails", async () => { const { tools, calls, registry } = setup({ "session start": START_REPLY("s1"), "session stop": {}, }); // navigate has no canned response -> non-zero exit -> error. const tool = tools.get("browser_session_start"); await expect(tool?.execute({ url: "https://example.com" }, makeExec())).rejects.toThrow( /navigate/, ); expect(calls.some((c) => c.args.join(" ") === "session stop s1")).toBe(true); expect(registry.current()).toBeUndefined(); }); }); describe("multi-session behavior", () => { const responses = { "session start": START_REPLY("s1"), snapshot: SNAPSHOT_REPLY, }; it("operation tools default to the current session and echo it back", async () => { const { tools, calls } = setup(responses); await startSession(tools); const snapshot = tools.get("browser_snapshot"); const value = (await snapshot?.execute({}, makeExec())) as { session: string; refCount: number; }; expect(value).toMatchObject({ session: "s1", refCount: 1 }); expect(calls[1].args).toEqual(["snapshot", "--session", "s1"]); }); it("an explicit owned session wins and becomes the new current session", async () => { const { tools, calls, registry } = setup({ "session start": seq([START_REPLY("s1"), START_REPLY("s2")]), snapshot: SNAPSHOT_REPLY, }); await startSession(tools, "s1"); await startSession(tools, "s2"); const snapshot = tools.get("browser_snapshot"); const value = (await snapshot?.execute({ session: "s1" }, makeExec())) as { session: string; }; expect(value.session).toBe("s1"); expect(calls[2].args).toEqual(["snapshot", "--session", "s1"]); expect(registry.current()).toBe("s1"); }); it("rejects an explicit foreign session without touching the daemon", async () => { const { tools, calls } = setup(responses); await startSession(tools); const callsBefore = calls.length; const snapshot = tools.get("browser_snapshot"); await expect(snapshot?.execute({ session: "other" }, makeExec())).rejects.toThrow( /does not belong to this plugin/, ); expect(calls.length).toBe(callsBefore); }); it("fails with guidance when no session exists", async () => { const { tools } = setup(responses); const snapshot = tools.get("browser_snapshot"); await expect(snapshot?.execute({}, makeExec())).rejects.toThrow(/browser_session_start/); }); it("enforces the configured session cap before spawning", async () => { const { tools, calls } = setup( { "session start": seq([START_REPLY("s1"), START_REPLY("s2")]) }, {}, 1, ); await startSession(tools); const start = tools.get("browser_session_start"); await expect(start?.execute({}, makeExec())).rejects.toThrow(/session limit/); // A rejected start must not spawn another bsk session (no leaked session). expect(calls.filter((c) => c.args.join(" ").startsWith("session start"))).toHaveLength(1); }); it("shares the cap atomically across concurrent starts (reservation race)", async () => { const { tools, calls } = setup( { "session start": seq([START_REPLY("s1"), START_REPLY("s2")]) }, {}, 1, ); const start = tools.get("browser_session_start"); if (start === undefined) throw new Error("not registered"); // Two truly concurrent starts against a cap of 1: exactly one may spawn. const outcomes = await Promise.allSettled([ start.execute({}, makeExec()), start.execute({}, makeExec()), ]); const fulfilled = outcomes.filter((o) => o.status === "fulfilled"); const rejected = outcomes.filter((o) => o.status === "rejected"); expect(fulfilled).toHaveLength(1); expect(rejected).toHaveLength(1); expect(String((rejected[0] as PromiseRejectedResult).reason)).toMatch(/session limit/); expect(calls.filter((c) => c.args.join(" ").startsWith("session start"))).toHaveLength(1); }); it("refuses to stop a session the plugin did not create", async () => { const { tools, calls } = setup(responses); await startSession(tools); const stop = tools.get("browser_session_stop"); await expect(stop?.execute({ session: "ext7" }, makeExec())).rejects.toThrow( /does not belong to this plugin/, ); // No stop command may reach the daemon for a foreign session. expect(calls.some((c) => c.args.join(" ").startsWith("session stop"))).toBe(false); }); }); describe("browser_session_stop / list", () => { it("stops the current session and clears the registry", async () => { const { tools, calls, registry } = setup({ "session start": START_REPLY("s1"), "session stop": {}, }); await startSession(tools); const stop = tools.get("browser_session_stop"); const value = (await stop?.execute({}, makeExec())) as { stopped: string }; expect(value.stopped).toBe("s1"); expect(calls[1].args).toEqual(["session", "stop", "s1"]); expect(registry.current()).toBeUndefined(); }); it("lists only plugin-owned sessions with the current marker (no daemon view)", async () => { const { tools, calls } = setup({ "session start": seq([START_REPLY("s1"), START_REPLY("s2")]), }); await startSession(tools); await startSession(tools); const list = tools.get("browser_session_list"); const value = (await list?.execute({}, makeExec())) as { sessions: { sessionId: string; current: boolean }[]; }; expect(value.sessions).toEqual([ { sessionId: "s1", browserInstanceId: "chrome-1", current: false }, { sessionId: "s2", browserInstanceId: "chrome-1", current: true }, ]); // Registry-only: listing must not call the daemon at all. expect(calls.some((c) => c.args.join(" ").startsWith("session list"))).toBe(false); }); }); describe("interaction tools", () => { const responses = { "session start": START_REPLY("s1"), click: { tab_id: 7, used_ref: "@e1", x: 10, y: 20 }, fill: { tab_id: 7, used_ref: "@e2", value_length: 5 }, press: { tab_id: 7, key: "Enter", code: "Enter", modifiers: [] }, navigate: { tab_id: 7, url: "https://a.test", final_url: "https://b.test", reached: "load" }, emulate: { tab_id: 7, cleared: false }, }; it("browser_click maps target, button, and click count", async () => { const { tools, calls } = setup(responses); await startSession(tools); const click = tools.get("browser_click"); const value = (await click?.execute( { target: "@e1", button: "right", clickCount: 2 }, makeExec(), )) as { session: string; x: number; y: number }; expect(value).toMatchObject({ session: "s1", x: 10, y: 20 }); expect(calls[1].args).toEqual([ "click", "--session", "s1", "--button", "right", "--click-count", "2", "@e1", ]); }); it("browser_fill passes value and --no-clear", async () => { const { tools, calls } = setup(responses); await startSession(tools); const fill = tools.get("browser_fill"); await fill?.execute({ target: "e2", value: "hello", noClear: true }, makeExec()); expect(calls[1].args).toEqual([ "fill", "--session", "s1", "--value", "hello", "--no-clear", "e2", ]); }); it("browser_press picks --ref for snapshot refs and --selector otherwise", async () => { const { tools, calls } = setup(responses); await startSession(tools); const press = tools.get("browser_press"); await press?.execute({ key: "Enter", target: "@e3" }, makeExec()); expect(calls[1].args).toEqual(["press", "--session", "s1", "--ref", "@e3", "Enter"]); await press?.execute({ key: "Ctrl+A", target: "#field" }, makeExec()); expect(calls[2].args).toEqual(["press", "--session", "s1", "--selector", "#field", "Ctrl+A"]); }); it("browser_navigate converts waitUntil and timeoutMs", async () => { const { tools, calls } = setup(responses); await startSession(tools); const navigate = tools.get("browser_navigate"); const value = (await navigate?.execute( { url: "https://a.test", waitUntil: "networkidle", timeoutMs: 1500 }, makeExec(), )) as { finalUrl: string; reached: string }; expect(value).toMatchObject({ finalUrl: "https://b.test", reached: "load" }); expect(calls[1].args).toEqual([ "navigate", "--session", "s1", "--wait-until", "networkidle", "--timeout", "1500ms", "https://a.test", ]); }); it("browser_emulate rejects off combined with overrides", async () => { const { tools } = setup(responses); await startSession(tools); const emulate = tools.get("browser_emulate"); await expect(emulate?.execute({ off: true, device: "iphone-14" }, makeExec())).rejects.toThrow( /mutually exclusive/, ); }); }); describe("browser_screenshot", () => { function pngFile(): string { const dir = mkdtempSync(join(tmpdir(), "bsk-test-")); const path = join(dir, "shot.png"); // Minimal PNG header bytes are enough: the plugin never decodes the image. writeFileSync(path, Buffer.from([0x89, 0x50, 0x4e, 0x47, 1, 2, 3, 4])); return path; } function jpegFile(): string { const dir = mkdtempSync(join(tmpdir(), "bsk-test-")); const path = join(dir, "shot.png"); // A JPEG SOI marker (FF D8 FF) even though the file is named ".png" — // mirrors captureVisibleTab returning JPEG on some Chromium builds. writeFileSync(path, Buffer.from([0xff, 0xd8, 0xff, 0xdb, 0, 1, 2, 3, 4])); return path; } it("returns the PNG path when no attachment store is mounted", async () => { const path = pngFile(); const { tools, calls } = setup({ "session start": START_REPLY("s1"), screenshot: { tab_id: 7, width: 800, height: 600, format: "png", path, byte_size: 8 }, }); await startSession(tools); const screenshot = tools.get("browser_screenshot"); const value = (await screenshot?.execute({ ref: "@e1" }, makeExec())) as { path: string; image?: unknown; }; expect(value.path).toBe(path); expect(value.image).toBeUndefined(); expect(calls[1].args.slice(0, 3)).toEqual(["screenshot", "--session", "s1"]); expect(calls[1].args).toContain("--ref"); const rendered = screenshot?.output.render({ session: "s1" }, value as never); expect(rendered?.every((block) => block.type === "text")).toBe(true); }); it("commits the image through the attachment store on an image-capable route", async () => { const path = pngFile(); const services = { attachments: { imageLimits: { mediaTypes: ["image/png"], maxImageBytes: 10_000_000, maxMessageImageBytes: 10_000_000, }, saveImage: async (input: { data: Uint8Array; mediaType: string; name?: string }) => ({ attachmentId: "att-1", mediaType: input.mediaType, bytes: input.data.byteLength, width: 800, height: 600, name: input.name, }), }, llm: { resolveModelInfo: async () => ({ inputModalities: ["text", "image"] }), }, }; const { tools } = setup( { "session start": START_REPLY("s1"), screenshot: { tab_id: 7, width: 800, height: 600, format: "png", path, byte_size: 8 }, }, services, ); await startSession(tools); const exec = makeExec({ agent: { session: { requestHeader: () => ({ config: { provider: "deepseek", model: "vl" } }) }, options: {}, }, }); const screenshot = tools.get("browser_screenshot"); const value = (await screenshot?.execute({}, exec)) as { image?: { attachmentId: string; mediaType: string }; }; expect(value.image).toMatchObject({ attachmentId: "att-1", mediaType: "image/png" }); const rendered = screenshot?.output.render({}, value as never) as { type: string }[]; expect(rendered.map((block) => block.type)).toEqual(["text", "image"]); }); it("declares the real media type when capture bytes are JPEG, not a hard-coded PNG", async () => { const path = jpegFile(); const services = { attachments: { imageLimits: { mediaTypes: ["image/png", "image/jpeg", "image/webp", "image/gif"], maxImageBytes: 10_000_000, maxMessageImageBytes: 10_000_000, }, saveImage: async (input: { data: Uint8Array; mediaType: string; name?: string }) => ({ attachmentId: "att-jpeg", mediaType: input.mediaType, bytes: input.data.byteLength, width: 800, height: 600, name: input.name, }), }, llm: { resolveModelInfo: async () => ({ inputModalities: ["text", "image"] }), }, }; const { tools } = setup( { "session start": START_REPLY("s1"), screenshot: { tab_id: 7, width: 800, height: 600, format: "png", path, byte_size: 9 }, }, services, ); await startSession(tools); const exec = makeExec({ agent: { session: { requestHeader: () => ({ config: { provider: "deepseek", model: "vl" } }) }, options: {}, }, }); const screenshot = tools.get("browser_screenshot"); const value = (await screenshot?.execute({}, exec)) as { image?: { attachmentId: string; mediaType: string }; }; // The reference media type must reflect the sniffed JPEG bytes, not "image/png". expect(value.image).toMatchObject({ attachmentId: "att-jpeg", mediaType: "image/jpeg" }); }); it("stays path-only when the model route is text-only", async () => { const path = pngFile(); const services = { attachments: { imageLimits: { mediaTypes: ["image/png"], maxImageBytes: 1e7, maxMessageImageBytes: 1e7 }, saveImage: async () => { throw new Error("must not be called"); }, }, llm: { resolveModelInfo: async () => ({ inputModalities: ["text"] }) }, }; const { tools } = setup( { "session start": START_REPLY("s1"), screenshot: { tab_id: 7, width: 1, height: 1, format: "png", path, byte_size: 8 }, }, services, ); await startSession(tools); const exec = makeExec({ agent: { session: { requestHeader: () => ({ config: { provider: "p", model: "m" } }) }, options: {}, }, }); const screenshot = tools.get("browser_screenshot"); const value = (await screenshot?.execute({}, exec)) as { image?: unknown; path: string }; expect(value.image).toBeUndefined(); expect(value.path).toBe(path); }); }); describe("error and cancellation semantics", () => { it("surfaces the bsk JSON error envelope with code and hint", async () => { const { tools } = setup({ "session start": START_REPLY("s1"), }); await startSession(tools); const snapshot = tools.get("browser_snapshot"); await expect(snapshot?.execute({}, makeExec())).rejects.toThrow(/no canned response/); }); it("turns an aborted child into an AbortError", async () => { const { tools } = setup({ "session start": START_REPLY("s1") }); await startSession(tools); const snapshot = tools.get("browser_snapshot"); const controller = new AbortController(); controller.abort(); const exec = makeExec({ signal: controller.signal }); await expect(snapshot?.execute({}, exec)).rejects.toMatchObject({ name: "AbortError" }); }); it("maps a missing bsk binary onto install guidance", async () => { const { ctx, tools } = makeCtx(); const registry = new SessionRegistry(5); const runner = { async run(): Promise { throw Object.assign(new Error("spawn bsk ENOENT"), { code: "ENOENT" }); }, killAll() {}, killFor: () => 0, }; registerTools({ ctx: ctx as never, runner, registry, config: CONFIG, observation: disabledObservation({ ctx, runner, registry }), queue: new KeyedExecutor(), }); const start = tools.get("browser_session_start"); await expect(start?.execute({}, makeExec())).rejects.toThrow(/BrowserSkill must be installed/); }); }); describe("presentation", () => { it("presents calls as terminal cards with the bsk command line", () => { const { tools } = setup({}); const click = tools.get("browser_click"); const view = click?.presentCall?.({ target: "@e1" }) as { card: string; title: string; }; expect(view.card).toBe("terminal"); expect(view.title).toContain("bsk click @e1"); }); it("presents results as terminal cards carrying the rendered text", () => { const { tools } = setup({}); const click = tools.get("browser_click"); const view = click?.presentResult?.({ target: "@e1" }, { content: [{ type: "text", text: "clicked" }], isError: false, } as never) as { card: string; output: string; exitCode: number }; expect(view).toMatchObject({ card: "terminal", output: "clicked", exitCode: 0 }); }); it("projects the text block of multi-block results (screenshot text + image)", () => { const { tools } = setup({}); const screenshot = tools.get("browser_screenshot"); const view = screenshot?.presentResult?.({}, { content: [ { type: "text", text: "[session s1] screenshot of tab 7" }, { type: "image", attachment: { attachmentId: "a1" } }, ], isError: false, } as never) as { card: string; output: string }; expect(view).toMatchObject({ card: "terminal", output: "[session s1] screenshot of tab 7" }); }); }); describe("screenshot scratch file lifecycle", () => { /** Runner that honors --out like the real bsk (writes the PNG there). */ function outWritingRunner() { const calls: FakeCall[] = []; return { calls, runner: { async run(args: string[], options: BskRunOptions = {}): Promise { calls.push({ args, options }); if (args[0] === "session") { return { code: 0, stdout: JSON.stringify({ session_id: "s1", browser_instance_id: "chrome-1" }), stderr: "", timedOut: false, aborted: false, }; } if (args[0] === "screenshot") { const out = args[args.indexOf("--out") + 1]; writeFileSync(out, Buffer.from([0x89, 0x50, 0x4e, 0x47])); return { code: 0, stdout: JSON.stringify({ tab_id: 7, width: 4, height: 4, format: "png", path: out, byte_size: 4, }), stderr: "", timedOut: false, aborted: false, }; } return { code: 2, stdout: "{}", stderr: "", timedOut: false, aborted: false }; }, killAll() {}, killFor: () => 0, } as BskRunner, }; } function setupWithRunner(runner: BskRunner, services: Record = {}) { const { ctx, tools } = makeCtx(services); const registry = new SessionRegistry(5); registerTools({ ctx: ctx as never, runner, registry, config: CONFIG, observation: disabledObservation({ ctx, runner, registry }), queue: new KeyedExecutor(), }); return { tools, registry }; } const IMAGE_SERVICES = { attachments: { imageLimits: { mediaTypes: ["image/png"], maxImageBytes: 1e7, maxMessageImageBytes: 1e7 }, saveImage: async (input: { data: Uint8Array; mediaType: string }) => ({ attachmentId: "att-1", mediaType: input.mediaType, bytes: input.data.byteLength, width: 4, height: 4, }), }, llm: { resolveModelInfo: async () => ({ inputModalities: ["text", "image"] }) }, }; it("deletes the scratch PNG once the bytes are in the attachment store", async () => { const { runner, calls } = outWritingRunner(); const { tools } = setupWithRunner(runner, IMAGE_SERVICES); await startSession(tools); const exec = makeExec({ agent: { session: { requestHeader: () => ({ config: { provider: "p", model: "vl" } }) }, options: {}, }, }); const screenshot = tools.get("browser_screenshot"); const value = (await screenshot?.execute({}, exec)) as { image?: unknown; path: string }; expect(value.image).toBeDefined(); const out = calls.find((c) => c.args[0] === "screenshot")?.args.at(-1); expect(out).toBeDefined(); expect(existsSync(out ?? "")).toBe(false); }); it("keeps the PNG only when it is the model-facing artifact (no store)", async () => { const { runner, calls } = outWritingRunner(); const { tools } = setupWithRunner(runner); await startSession(tools); const screenshot = tools.get("browser_screenshot"); const value = (await screenshot?.execute({}, makeExec())) as { image?: unknown; path: string }; expect(value.image).toBeUndefined(); const out = calls.find((c) => c.args[0] === "screenshot")?.args.at(-1); expect(existsSync(out ?? "")).toBe(true); }); }); describe("observation action instrumentation timing", () => { it("a queued call never overwrites the in-flight action label", async () => { const { ctx, tools } = makeCtx(); const registry = new SessionRegistry(5); let releaseNavigate!: () => void; const navigateGate = new Promise((resolve) => { releaseNavigate = resolve; }); let releaseSnapshot!: () => void; const snapshotGate = new Promise((resolve) => { releaseSnapshot = resolve; }); const runner: BskRunner = { async run(args: string[]): Promise { if (args[0] === "session") { return { code: 0, stdout: JSON.stringify({ session_id: "s1", browser_instance_id: "chrome-1" }), stderr: "", timedOut: false, aborted: false, }; } if (args[0] === "navigate") { await navigateGate; return { code: 0, stdout: JSON.stringify({ tab_id: 7, url: "http://x" }), stderr: "", timedOut: false, aborted: false, }; } if (args[0] === "snapshot") { await snapshotGate; return { code: 0, stdout: JSON.stringify({ text: "ok", ref_count: 0, tab_id: 7, truncated: false }), stderr: "", timedOut: false, aborted: false, }; } // Observation captures share the session queue; fail them fast so they // cannot stall the action-label assertions. if (args[0] === "screenshot") { return { code: 1, stdout: "", stderr: "skip", timedOut: false, aborted: false }; } return { code: 0, stdout: JSON.stringify({ text: "ok", ref_count: 0, tab_id: 7, truncated: false }), stderr: "", timedOut: false, aborted: false, }; }, killAll() {}, killFor: () => 0, }; const queue = new KeyedExecutor(); const observation = new ObservationService({ ctx: ctx as never, runner, registry, queue, options: { enabled: true, thumbnailIntervalMs: 1500, idleIntervalMs: 8000 }, }); registerTools({ ctx: ctx as never, runner, registry, config: CONFIG, observation, queue }); await startSession(tools); const navigate = tools.get("browser_navigate"); const snapshot = tools.get("browser_snapshot"); const a = navigate?.execute({ url: "http://x" }, makeExec()) as Promise; // A starts immediately (empty queue) and blocks on the gate. await vi.waitFor(() => expect(observation.getState().find((s) => s.sessionId === "s1")?.action).toBe("navigating"), ); // B queues behind A: the label must stay "navigating" until A settles. const b = snapshot?.execute({}, makeExec()) as Promise; await new Promise((resolve) => setTimeout(resolve, 30)); expect(observation.getState().find((s) => s.sessionId === "s1")?.action).toBe("navigating"); releaseNavigate(); await a; await vi.waitFor(() => expect(observation.getState().find((s) => s.sessionId === "s1")?.action).toBe("snapshotting"), ); releaseSnapshot(); await b; await vi.waitFor(() => expect(observation.getState().find((s) => s.sessionId === "s1")?.action).toBe("idle"), ); observation.dispose(); }); });