diff --git a/apps/extension/src/browser-driver/__tests__/chromium-cdp.test.ts b/apps/extension/src/browser-driver/__tests__/chromium-cdp.test.ts index 8d2d92d..88b8735 100644 --- a/apps/extension/src/browser-driver/__tests__/chromium-cdp.test.ts +++ b/apps/extension/src/browser-driver/__tests__/chromium-cdp.test.ts @@ -119,27 +119,41 @@ describe("ChromiumCdp", () => { expect(api.sendCommand).toHaveBeenCalledWith({ tabId: 9 }, "Page.enable", {}); }); + it("enables console capture domains best-effort during attach", async () => { + const { api } = fakeApi(); + (api.sendCommand as ReturnType).mockImplementation( + async (_target, method: string) => { + if (method === "Log.enable") throw new Error("restricted"); + return {}; + }, + ); + const cdp = new ChromiumCdp(api); + await expect(cdp.ensureAttached(12)).resolves.toBeUndefined(); + expect(cdp.isAttached(12)).toBe(true); + expect(api.sendCommand).toHaveBeenCalledWith({ tabId: 12 }, "Runtime.enable", {}); + expect(api.sendCommand).toHaveBeenCalledWith({ tabId: 12 }, "Log.enable", {}); + await cdp.ensureConsoleCapture(12); + const enableCalls = (api.sendCommand as ReturnType).mock.calls.filter( + ([, method]) => method === "Runtime.enable" || method === "Log.enable", + ); + expect(enableCalls).toHaveLength(2); + }); + it("records javascriptDialogOpening and auto-accepts", async () => { const { api, onEvent } = fakeApi(); const cdp = new ChromiumCdp(api); await cdp.ensureAttached(3); const cursor = cdp.dialogCursor(3); - onEvent.fire( - { tabId: 3 }, - "Page.javascriptDialogOpening", - { - type: "alert", - message: "hello", - url: "https://example.com/", - hasBrowserHandler: false, - }, - ); + onEvent.fire({ tabId: 3 }, "Page.javascriptDialogOpening", { + type: "alert", + message: "hello", + url: "https://example.com/", + hasBrowserHandler: false, + }); await Promise.resolve(); - expect(api.sendCommand).toHaveBeenCalledWith( - { tabId: 3 }, - "Page.handleJavaScriptDialog", - { accept: true }, - ); + expect(api.sendCommand).toHaveBeenCalledWith({ tabId: 3 }, "Page.handleJavaScriptDialog", { + accept: true, + }); const dialogs = cdp.dialogsSince(3, cursor); expect(dialogs).toHaveLength(1); expect(dialogs[0]).toMatchObject({ @@ -170,11 +184,11 @@ describe("ChromiumCdp", () => { await cdp.ensureAttached(5); const pending = cdp.send(5, "Runtime.evaluate", { expression: "1+1" }); await Promise.resolve(); - onEvent.fire( - { tabId: 5 }, - "Page.javascriptDialogOpening", - { type: "alert", message: "blocked", url: "https://example.com/" }, - ); + onEvent.fire({ tabId: 5 }, "Page.javascriptDialogOpening", { + type: "alert", + message: "blocked", + url: "https://example.com/", + }); releaseEvaluate(); await expect(pending).resolves.toEqual({ result: { value: 2 } }); }); @@ -183,17 +197,145 @@ describe("ChromiumCdp", () => { const { api, onEvent } = fakeApi(); const cdp = new ChromiumCdp(api); await cdp.ensureAttached(11); - onEvent.fire( - { tabId: 11 }, - "Page.javascriptDialogOpening", - { type: "alert", message: "x", url: "https://example.com/" }, - ); + onEvent.fire({ tabId: 11 }, "Page.javascriptDialogOpening", { + type: "alert", + message: "x", + url: "https://example.com/", + }); await Promise.resolve(); expect(cdp.dialogsSince(11, 0)).toHaveLength(1); await cdp.detach(11); expect(cdp.dialogsSince(11, 0)).toHaveLength(0); }); + it("records Runtime console calls with bounded text and optional stack frames", async () => { + const { api, onEvent } = fakeApi(); + const cdp = new ChromiumCdp(api); + await cdp.ensureAttached(21); + onEvent.fire({ tabId: 21 }, "Runtime.consoleAPICalled", { + type: "warn", + args: [{ value: "abcdefghi" }, { description: "Object { ok: true }" }], + timestamp: 1234.5, + stackTrace: { + callFrames: [ + { + functionName: "render", + url: "https://example.test/app.js", + lineNumber: 4, + columnNumber: 8, + }, + ], + }, + }); + + const withoutStack = cdp.consoleEntriesSince(21, 0, 50, 5, false); + expect(withoutStack).toMatchObject({ + next_since: 1, + truncated: true, + entries: [ + { + sequence: 1, + kind: "console", + level: "warn", + text: "abcde", + url: "https://example.test/app.js", + line: 5, + column: 9, + truncated: true, + }, + ], + }); + expect(withoutStack.entries[0].stack_trace).toBeUndefined(); + + const withStack = cdp.consoleEntriesSince(21, 0, 50, 1000, true); + expect(withStack.entries[0].stack_trace).toEqual([ + { + function_name: "render", + url: "https://example.test/app.js", + line: 5, + column: 9, + }, + ]); + }); + + it("records exceptions and engine log entries", async () => { + const { api, onEvent } = fakeApi(); + const cdp = new ChromiumCdp(api); + await cdp.ensureAttached(22); + onEvent.fire({ tabId: 22 }, "Runtime.exceptionThrown", { + timestamp: 2000, + exceptionDetails: { + text: "Uncaught", + url: "https://example.test/app.js", + lineNumber: 40, + columnNumber: 6, + exception: { description: "TypeError: boom\n at render (app.js:41:7)" }, + }, + }); + onEvent.fire({ tabId: 22 }, "Log.entryAdded", { + entry: { + level: "error", + text: "Failed to load resource: 404", + url: "https://example.test/missing.png", + lineNumber: 0, + timestamp: 2100, + }, + }); + + const result = cdp.consoleEntriesSince(22, 0, 50, 1000, false); + expect(result.next_since).toBe(2); + expect(result.entries).toMatchObject([ + { + sequence: 1, + kind: "exception", + level: "error", + text: "TypeError: boom", + url: "https://example.test/app.js", + line: 41, + column: 7, + }, + { + sequence: 2, + kind: "log", + level: "error", + text: "Failed to load resource: 404", + url: "https://example.test/missing.png", + }, + ]); + }); + + it("filters console entries by cursor and caps result size", async () => { + const { api, onEvent } = fakeApi(); + const cdp = new ChromiumCdp(api); + await cdp.ensureAttached(23); + for (let i = 1; i <= 3; i += 1) { + onEvent.fire({ tabId: 23 }, "Runtime.consoleAPICalled", { + type: "log", + args: [{ value: `message ${i}` }], + }); + } + + const result = cdp.consoleEntriesSince(23, 1, 1, 1000, false); + expect(result).toMatchObject({ + next_since: 2, + truncated: true, + entries: [{ sequence: 2, text: "message 2" }], + }); + }); + + it("clears console state on detach", async () => { + const { api, onEvent } = fakeApi(); + const cdp = new ChromiumCdp(api); + await cdp.ensureAttached(24); + onEvent.fire({ tabId: 24 }, "Runtime.consoleAPICalled", { + type: "log", + args: [{ value: "x" }], + }); + expect(cdp.consoleEntriesSince(24, 0, 50, 1000, false).entries).toHaveLength(1); + await cdp.detach(24); + expect(cdp.consoleEntriesSince(24, 0, 50, 1000, false).entries).toHaveLength(0); + }); + it("detachSession only detaches tabs no other session owns", async () => { const { api } = fakeApi(); const cdp = new ChromiumCdp(api); diff --git a/apps/extension/src/browser-driver/chromium-cdp.ts b/apps/extension/src/browser-driver/chromium-cdp.ts index c402457..3d149a7 100644 --- a/apps/extension/src/browser-driver/chromium-cdp.ts +++ b/apps/extension/src/browser-driver/chromium-cdp.ts @@ -20,7 +20,14 @@ // record the payload for tool results, and auto-accept so automation // can continue. -import type { JavaScriptDialogInfo, JavaScriptDialogType } from "@/transport/types"; +import type { + ConsoleEntry, + ConsoleEntryKind, + ConsoleResult, + ConsoleStackFrame, + JavaScriptDialogInfo, + JavaScriptDialogType, +} from "@/transport/types"; /** * Minimal slice of `chrome.debugger` the rest of the extension @@ -77,6 +84,9 @@ export type DialogCursor = number; const MAX_DIALOG_BUFFER = 32; const MAX_DIALOG_FIELD_LENGTH = 4096; +const MAX_CONSOLE_BUFFER = 200; +const MAX_CONSOLE_FIELD_LENGTH = 4096; +const MAX_CONSOLE_STACK_FRAMES = 20; interface ParsedDialogOpening { type: JavaScriptDialogType; @@ -86,6 +96,8 @@ interface ParsedDialogOpening { hasBrowserHandler?: boolean; } +interface ParsedConsoleEntry extends Omit {} + /** * Wrapper around `chrome.debugger` that owns the "attach once per * tabId" cache and exposes typed `send()`. @@ -97,13 +109,18 @@ export class ChromiumCdp { private readonly tabOwners = new Map>(); private readonly dialogBuffers = new Map(); private readonly dialogSequences = new Map(); + private readonly consoleBuffers = new Map(); + private readonly consoleSequences = new Map(); + private readonly consoleDomainsAttemptedTabs = new Set(); private detachSubscription: { dispose(): void } | null = null; private dialogSubscription: { dispose(): void } | null = null; + private consoleSubscription: { dispose(): void } | null = null; constructor(api: CdpDebuggerApi = chromeDebuggerApi) { this.api = api; this.bindAutoDetach(); this.bindDialogHandler(); + this.bindConsoleHandler(); } /** Attach to `tabId` if we haven't already in this driver. */ @@ -117,6 +134,7 @@ export class ChromiumCdp { const attach = (async () => { await this.api.attach({ tabId }, CDP_PROTOCOL_VERSION); await this.enablePageDomain(tabId); + await this.enableConsoleDomains(tabId); this.attachedTabs.add(tabId); })() .catch((err) => { @@ -159,12 +177,52 @@ export class ChromiumCdp { return buf.filter((entry) => entry.sequence > cursor); } + /** Ensure CDP domains for console capture are enabled for this tab. */ + async ensureConsoleCapture(tabId: number): Promise { + if (!this.attachedTabs.has(tabId)) { + await this.ensureAttached(tabId); + return; + } + await this.enableConsoleDomains(tabId); + } + + /** Console entries observed on `tabId`, bounded for agent context safety. */ + consoleEntriesSince( + tabId: number, + since: number | undefined, + limit: number, + maxTextChars: number, + includeStack: boolean, + ): ConsoleResult { + const buf = this.consoleBuffers.get(tabId) ?? []; + const hasCursor = typeof since === "number"; + const candidates = hasCursor + ? buf.filter((entry) => entry.sequence > since) + : buf.slice(Math.max(0, buf.length - limit)); + const limited = hasCursor ? candidates.slice(0, limit) : candidates; + const entries = limited.map((entry) => projectConsoleEntry(entry, maxTextChars, includeStack)); + const lastReturned = entries.at(-1)?.sequence; + const nextSince = lastReturned ?? this.consoleSequences.get(tabId) ?? 0; + const droppedEntries = (this.consoleSequences.get(tabId) ?? 0) > buf.length; + const truncated = + droppedEntries || + candidates.length > limited.length || + entries.some((entry) => entry.truncated); + return { + tab_id: tabId, + entries, + next_since: nextSince, + truncated, + }; + } + /** Detach if attached; never throws. */ async detach(tabId: number): Promise { this.attachInFlight.delete(tabId); if (!this.attachedTabs.has(tabId)) return; this.attachedTabs.delete(tabId); this.clearDialogState(tabId); + this.clearConsoleState(tabId); try { await this.api.detach({ tabId }); } catch (err) { @@ -204,6 +262,9 @@ export class ChromiumCdp { this.attachedTabs.clear(); this.dialogBuffers.clear(); this.dialogSequences.clear(); + this.consoleBuffers.clear(); + this.consoleSequences.clear(); + this.consoleDomainsAttemptedTabs.clear(); await Promise.all( tabs.map(async (tabId) => { try { @@ -219,6 +280,18 @@ export class ChromiumCdp { await this.api.sendCommand({ tabId }, "Page.enable", {}); } + private async enableConsoleDomains(tabId: number): Promise { + if (this.consoleDomainsAttemptedTabs.has(tabId)) return; + this.consoleDomainsAttemptedTabs.add(tabId); + for (const method of ["Runtime.enable", "Log.enable"]) { + try { + await this.api.sendCommand({ tabId }, method, {}); + } catch (err) { + console.debug("[bsk cdp] console domain enable failed", { tabId, method, err }); + } + } + } + private bindDialogHandler(): void { if (this.dialogSubscription) return; const listener = (source: chrome.debugger.Debuggee, method: string, params: unknown) => { @@ -272,6 +345,47 @@ export class ChromiumCdp { this.dialogSequences.delete(tabId); } + private bindConsoleHandler(): void { + if (this.consoleSubscription) return; + const listener = (source: chrome.debugger.Debuggee, method: string, params: unknown) => { + const tabId = source.tabId; + if (typeof tabId !== "number") return; + switch (method) { + case "Runtime.consoleAPICalled": + this.appendConsole(tabId, parseConsoleApiCalled(params)); + break; + case "Runtime.exceptionThrown": + this.appendConsole(tabId, parseExceptionThrown(params)); + break; + case "Log.entryAdded": + this.appendConsole(tabId, parseLogEntry(params)); + break; + } + }; + this.api.onEvent.addListener(listener); + this.consoleSubscription = { + dispose: () => this.api.onEvent.removeListener(listener), + }; + } + + private appendConsole(tabId: number, entry: ParsedConsoleEntry | null): void { + if (!entry) return; + const sequence = (this.consoleSequences.get(tabId) ?? 0) + 1; + this.consoleSequences.set(tabId, sequence); + const buf = this.consoleBuffers.get(tabId) ?? []; + buf.push({ ...entry, sequence }); + while (buf.length > MAX_CONSOLE_BUFFER) { + buf.shift(); + } + this.consoleBuffers.set(tabId, buf); + } + + private clearConsoleState(tabId: number): void { + this.consoleBuffers.delete(tabId); + this.consoleSequences.delete(tabId); + this.consoleDomainsAttemptedTabs.delete(tabId); + } + private bindAutoDetach(): void { if (this.detachSubscription) return; const listener = (source: chrome.debugger.Debuggee, _reason: string) => { @@ -280,6 +394,7 @@ export class ChromiumCdp { this.attachInFlight.delete(source.tabId); this.tabOwners.delete(source.tabId); this.clearDialogState(source.tabId); + this.clearConsoleState(source.tabId); } }; this.api.onDetach.addListener(listener); @@ -294,6 +409,8 @@ export class ChromiumCdp { this.detachSubscription = null; this.dialogSubscription?.dispose(); this.dialogSubscription = null; + this.consoleSubscription?.dispose(); + this.consoleSubscription = null; } /** Detach tabs only when no other live session has claimed them. */ @@ -339,6 +456,154 @@ function truncateDialogField(value: string): string { return `${value.slice(0, MAX_DIALOG_FIELD_LENGTH)}... [truncated]`; } +function parseConsoleApiCalled(params: unknown): ParsedConsoleEntry | null { + const raw = (params ?? {}) as Record; + const args = Array.isArray(raw.args) ? raw.args : []; + const text = args.map(remoteObjectToText).filter(Boolean).join(" "); + const stackTrace = parseStackTrace(raw.stackTrace); + const top = stackTrace[0]; + return makeConsoleEntry({ + kind: "console", + level: typeof raw.type === "string" ? raw.type : "log", + text, + url: top?.url, + line: top?.line, + column: top?.column, + timestamp: typeof raw.timestamp === "number" ? raw.timestamp : undefined, + stack_trace: stackTrace, + }); +} + +function parseExceptionThrown(params: unknown): ParsedConsoleEntry | null { + const raw = (params ?? {}) as Record; + const details = (raw.exceptionDetails ?? {}) as Record; + const exception = (details.exception ?? {}) as Record; + const text = + firstLine(typeof exception.description === "string" ? exception.description : undefined) || + firstLine(typeof details.text === "string" ? details.text : undefined) || + "Uncaught (unknown error)"; + return makeConsoleEntry({ + kind: "exception", + level: "error", + text, + url: typeof details.url === "string" ? details.url : undefined, + line: typeof details.lineNumber === "number" ? details.lineNumber + 1 : undefined, + column: typeof details.columnNumber === "number" ? details.columnNumber + 1 : undefined, + timestamp: typeof raw.timestamp === "number" ? raw.timestamp : undefined, + stack_trace: parseStackTrace(details.stackTrace), + }); +} + +function parseLogEntry(params: unknown): ParsedConsoleEntry | null { + const raw = (params ?? {}) as Record; + const entry = (raw.entry ?? {}) as Record; + return makeConsoleEntry({ + kind: "log", + level: typeof entry.level === "string" ? entry.level : "info", + text: typeof entry.text === "string" ? entry.text : "", + url: typeof entry.url === "string" ? entry.url : undefined, + line: typeof entry.lineNumber === "number" ? entry.lineNumber : undefined, + timestamp: typeof entry.timestamp === "number" ? entry.timestamp : undefined, + stack_trace: [], + }); +} + +function makeConsoleEntry(input: { + kind: ConsoleEntryKind; + level: string; + text: string; + url?: string; + line?: number; + column?: number; + timestamp?: number; + stack_trace: ConsoleStackFrame[]; +}): ParsedConsoleEntry { + const projected = truncateConsoleText(input.text, MAX_CONSOLE_FIELD_LENGTH); + return { + kind: input.kind, + level: input.level, + text: projected.text, + url: truncateOptionalConsoleField(input.url), + line: input.line, + column: input.column, + timestamp: input.timestamp, + stack_trace: input.stack_trace.slice(0, MAX_CONSOLE_STACK_FRAMES).map((frame) => ({ + function_name: truncateOptionalConsoleField(frame.function_name), + url: truncateOptionalConsoleField(frame.url), + line: frame.line, + column: frame.column, + })), + truncated: projected.truncated || input.stack_trace.length > MAX_CONSOLE_STACK_FRAMES, + }; +} + +function projectConsoleEntry( + entry: ConsoleEntry, + maxTextChars: number, + includeStack: boolean, +): ConsoleEntry { + const projected = truncateConsoleText(entry.text, maxTextChars); + return { + sequence: entry.sequence, + kind: entry.kind, + level: entry.level, + text: projected.text, + url: entry.url, + line: entry.line, + column: entry.column, + timestamp: entry.timestamp, + ...(includeStack && entry.stack_trace && entry.stack_trace.length > 0 + ? { stack_trace: entry.stack_trace } + : {}), + truncated: entry.truncated || projected.truncated, + }; +} + +function remoteObjectToText(value: unknown): string { + const raw = (value ?? {}) as Record; + if ("value" in raw && raw.value !== undefined) return String(raw.value); + if (typeof raw.description === "string") return raw.description; + if (typeof raw.unserializableValue === "string") return raw.unserializableValue; + if (typeof raw.type === "string") return `[${raw.type}]`; + return ""; +} + +function parseStackTrace(value: unknown): ConsoleStackFrame[] { + const raw = (value ?? {}) as Record; + const frames = Array.isArray(raw.callFrames) ? raw.callFrames : []; + return frames + .filter( + (frame): frame is Record => frame !== null && typeof frame === "object", + ) + .map((frame) => ({ + function_name: + typeof frame.functionName === "string" && frame.functionName.length > 0 + ? frame.functionName + : undefined, + url: typeof frame.url === "string" && frame.url.length > 0 ? frame.url : undefined, + line: typeof frame.lineNumber === "number" ? frame.lineNumber + 1 : undefined, + column: typeof frame.columnNumber === "number" ? frame.columnNumber + 1 : undefined, + })); +} + +function firstLine(value: string | undefined): string | undefined { + if (!value) return undefined; + return value.split(/\r?\n/, 1)[0] || undefined; +} + +function truncateConsoleText( + value: string, + maxChars: number, +): { text: string; truncated: boolean } { + if (value.length <= maxChars) return { text: value, truncated: false }; + return { text: value.slice(0, maxChars), truncated: true }; +} + +function truncateOptionalConsoleField(value: string | undefined): string | undefined { + if (value === undefined) return undefined; + return truncateConsoleText(value, MAX_CONSOLE_FIELD_LENGTH).text; +} + function normalizeError(err: unknown): Error { if (err instanceof Error) return err; if (typeof err === "string") return new Error(err); diff --git a/apps/extension/src/tools/__tests__/console.test.ts b/apps/extension/src/tools/__tests__/console.test.ts new file mode 100644 index 0000000..a219ded --- /dev/null +++ b/apps/extension/src/tools/__tests__/console.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it, vi } from "vitest"; +import { SessionManager } from "@/session-manager/manager"; +import type { CdpRunner } from "@/tools/shared"; +import type { ConsoleResult } from "@/transport/types"; +import { handleConsole } from "../console"; + +function fakeAgentWindow(ids: number[]) { + let i = 0; + return { + create: vi.fn(async () => { + const id = ids[i++]; + if (id === undefined) throw new Error("ran out of fake ids"); + return id; + }), + remove: vi.fn(async () => {}), + ensureActiveTab: vi.fn(async () => {}), + }; +} + +function makeDeps( + opts: { + get?: (tabId: number) => Promise; + query?: (query: chrome.tabs.QueryInfo) => Promise; + result?: ConsoleResult; + } = {}, +) { + const result = + opts.result ?? + ({ + tab_id: 7, + entries: [], + next_since: 0, + truncated: false, + } satisfies ConsoleResult); + const ensureConsoleCapture = vi.fn(async () => {}); + const consoleEntriesSince = vi.fn(() => result); + const cdp = { + send: vi.fn(), + ensureConsoleCapture, + consoleEntriesSince, + } as unknown as CdpRunner; + const tabsApi = { + get: + opts.get ?? + vi.fn( + async (tabId: number) => ({ id: tabId, windowId: 100, active: true }) as chrome.tabs.Tab, + ), + query: + opts.query ?? vi.fn(async () => [{ id: 7, windowId: 100, active: true } as chrome.tabs.Tab]), + }; + return { cdp, tabsApi, ensureConsoleCapture, consoleEntriesSince }; +} + +describe("handleConsole", () => { + it("reads the Agent Window active tab with safe defaults", async () => { + const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) }); + await sm.start("aa11"); + const deps = makeDeps({ + result: { + tab_id: 7, + entries: [{ sequence: 3, kind: "console", level: "log", text: "hello", truncated: false }], + next_since: 3, + truncated: false, + }, + }); + + const res = await handleConsole(sm, { session_id: "aa11" }, deps); + + if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`); + expect(res.entries[0].text).toBe("hello"); + expect(deps.ensureConsoleCapture).toHaveBeenCalledWith(7); + expect(deps.consoleEntriesSince).toHaveBeenCalledWith(7, undefined, 50, 1000, false); + }); + + it("reads an explicit tab and forwards bounded options", async () => { + const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) }); + await sm.start("aa11"); + const deps = makeDeps({ + get: vi.fn(async () => ({ id: 9, windowId: 200, active: true }) as chrome.tabs.Tab), + }); + + await handleConsole( + sm, + { + session_id: "aa11", + tab_id: 9, + since: 12, + limit: 500, + max_text_chars: 9999, + include_stack: true, + }, + deps, + ); + + expect(deps.ensureConsoleCapture).toHaveBeenCalledWith(9); + expect(deps.consoleEntriesSince).toHaveBeenCalledWith(9, 12, 200, 4096, true); + }); + + it("rejects invalid bounds before touching CDP", async () => { + const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) }); + await sm.start("aa11"); + const deps = makeDeps(); + + const res = await handleConsole(sm, { session_id: "aa11", limit: 0 }, deps); + + expect(res).toMatchObject({ code: "invalid_params", message: /limit/ }); + expect(deps.ensureConsoleCapture).not.toHaveBeenCalled(); + expect(deps.consoleEntriesSince).not.toHaveBeenCalled(); + }); + + it("hides other sessions' Agent Window tabs", async () => { + const sm = new SessionManager({ agentWindow: fakeAgentWindow([100, 101]) }); + await sm.start("aa11"); + await sm.start("bb22"); + const deps = makeDeps({ + get: vi.fn(async () => ({ id: 9, windowId: 101, active: true }) as chrome.tabs.Tab), + }); + + const res = await handleConsole(sm, { session_id: "aa11", tab_id: 9 }, deps); + + expect(res).toMatchObject({ code: "not_found" }); + expect(deps.ensureConsoleCapture).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/extension/src/tools/__tests__/dispatcher.test.ts b/apps/extension/src/tools/__tests__/dispatcher.test.ts index 3b7e02c..8241e61 100644 --- a/apps/extension/src/tools/__tests__/dispatcher.test.ts +++ b/apps/extension/src/tools/__tests__/dispatcher.test.ts @@ -1,7 +1,12 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { SessionManager } from "@/session-manager/manager"; import type { ConnectionStateHandler, FrameHandler, Transport } from "@/transport/transport"; -import type { ConnectionState, ProtocolFrame, RequestFrame } from "@/transport/types"; +import type { + ConnectionState, + ConsoleResult, + ProtocolFrame, + RequestFrame, +} from "@/transport/types"; import { ToolDispatcher } from "../dispatcher"; function fakeTransport() { @@ -36,6 +41,10 @@ function makeRequest(method: string, params: unknown): RequestFrame { } describe("ToolDispatcher", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + it("routes tool.session_start to the SessionManager and replies with the window id", async () => { const { transport, sent, deliver } = fakeTransport(); const sessions = new SessionManager({ @@ -78,6 +87,55 @@ describe("ToolDispatcher", () => { expect(sent[0]).toEqual({ id: "r-1", result: {} }); }); + it("routes tool.console through the CDP console buffer", async () => { + vi.stubGlobal("chrome", { + tabs: { + query: vi.fn(async () => [{ id: 7, windowId: 4242, active: true }]), + }, + }); + const { transport, sent, deliver } = fakeTransport(); + const sessions = new SessionManager({ + agentWindow: { + create: vi.fn(async () => 4242), + remove: vi.fn(async () => {}), + ensureActiveTab: vi.fn(async () => {}), + }, + }); + await sessions.start("aa11"); + const cdp = { + send: vi.fn(), + detachSession: vi.fn(async () => {}), + ensureConsoleCapture: vi.fn(async () => {}), + consoleEntriesSince: vi.fn( + () => + ({ + tab_id: 7, + entries: [ + { sequence: 1, kind: "console", level: "log", text: "hello", truncated: false }, + ], + next_since: 1, + truncated: false, + }) satisfies ConsoleResult, + ), + }; + const dispatcher = new ToolDispatcher({ transport, sessions, cdp }); + dispatcher.start(); + + deliver(makeRequest("tool.console", { session_id: "aa11" })); + await flushMicrotasks(); + + expect(cdp.ensureConsoleCapture).toHaveBeenCalledWith(7); + expect(sent[0]).toEqual({ + id: "r-1", + result: { + tab_id: 7, + entries: [{ sequence: 1, kind: "console", level: "log", text: "hello", truncated: false }], + next_since: 1, + truncated: false, + }, + }); + }); + it("detaches CDP state before stopping a session", async () => { const { transport, sent, deliver } = fakeTransport(); const sessions = new SessionManager({ diff --git a/apps/extension/src/tools/console.ts b/apps/extension/src/tools/console.ts new file mode 100644 index 0000000..efd4784 --- /dev/null +++ b/apps/extension/src/tools/console.ts @@ -0,0 +1,108 @@ +import { ChromiumCdp } from "@/browser-driver/chromium-cdp"; +import type { SessionManager } from "@/session-manager/manager"; +import type { ConsoleParams, ConsoleResult, RpcError } from "@/transport/types"; +import { + type CdpRunner, + type ChromeTabsApi, + chromeTabsApi, + isRpcError, + lookupSession, + resolveTargetTab, +} from "./shared"; + +const DEFAULT_CONSOLE_LIMIT = 50; +const MAX_CONSOLE_LIMIT = 200; +const DEFAULT_MAX_TEXT_CHARS = 1000; +const MAX_TEXT_CHARS = 4096; + +export interface ConsoleDeps { + cdp: CdpRunner; + tabsApi: ChromeTabsApi; +} + +function defaultConsoleDeps(): ConsoleDeps { + return { + cdp: new ChromiumCdp(), + tabsApi: chromeTabsApi, + }; +} + +export async function handleConsole( + manager: SessionManager, + params: ConsoleParams, + deps: ConsoleDeps = defaultConsoleDeps(), +): Promise { + const ctxOrErr = lookupSession(manager, params, "console"); + if (isRpcError(ctxOrErr)) return ctxOrErr; + const bounds = parseConsoleBounds(params); + if (isRpcError(bounds)) return bounds; + const target = await resolveTargetTab(manager, ctxOrErr, params.tab_id, deps.tabsApi); + if (isRpcError(target)) return target; + if (!deps.cdp.ensureConsoleCapture || !deps.cdp.consoleEntriesSince) { + return { code: "cdp_failed", message: "console capture requires CDP console support" }; + } + + try { + await deps.cdp.ensureConsoleCapture(target.tabId); + deps.cdp.trackSessionTab?.(ctxOrErr.sessionId, target.tabId); + return deps.cdp.consoleEntriesSince( + target.tabId, + bounds.since, + bounds.limit, + bounds.maxTextChars, + bounds.includeStack, + ); + } catch (err) { + return { + code: "cdp_failed", + message: err instanceof Error ? err.message : String(err), + }; + } +} + +function parseConsoleBounds(params: ConsoleParams): + | { + since: number | undefined; + limit: number; + maxTextChars: number; + includeStack: boolean; + } + | RpcError { + const since = params.since; + if (since !== undefined && (!Number.isSafeInteger(since) || since < 0)) { + return { code: "invalid_params", message: "since must be a non-negative integer" }; + } + const limit = boundedOptionalInteger( + params.limit, + DEFAULT_CONSOLE_LIMIT, + MAX_CONSOLE_LIMIT, + "limit", + ); + if (isRpcError(limit)) return limit; + const maxTextChars = boundedOptionalInteger( + params.max_text_chars, + DEFAULT_MAX_TEXT_CHARS, + MAX_TEXT_CHARS, + "max_text_chars", + ); + if (isRpcError(maxTextChars)) return maxTextChars; + return { + since, + limit, + maxTextChars, + includeStack: params.include_stack === true, + }; +} + +function boundedOptionalInteger( + value: number | undefined, + defaultValue: number, + maxValue: number, + field: string, +): number | RpcError { + if (value === undefined) return defaultValue; + if (!Number.isSafeInteger(value) || value <= 0) { + return { code: "invalid_params", message: `${field} must be a positive integer` }; + } + return Math.min(value, maxValue); +} diff --git a/apps/extension/src/tools/dispatcher.ts b/apps/extension/src/tools/dispatcher.ts index 7975b05..ca4c053 100644 --- a/apps/extension/src/tools/dispatcher.ts +++ b/apps/extension/src/tools/dispatcher.ts @@ -1,7 +1,9 @@ +import { OVERLAY_AUTOMATION_BYPASS } from "@/lib/overlay-bridge"; import type { SessionManager } from "@/session-manager/manager"; import type { Transport } from "@/transport/transport"; import type { ClickParams, + ConsoleParams, EvaluateParams, FillParams, GetHtmlParams, @@ -11,17 +13,19 @@ import type { PressParams, ProtocolFrame, ReloadParams, - RequestHelpParams, RequestFrame, + RequestHelpParams, ResponseFrame, RpcError, - SelectParams, ScreenshotParams, + SelectParams, SnapshotParams, WaitForNavigationParams, } from "@/transport/types"; import { isRequestFrame } from "@/transport/types"; +import { handleConsole } from "./console"; import { handleEvaluate } from "./evaluate"; +import { defaultWatchTabNavigation, handleRequestHelp } from "./human-loop"; import { handleClick, handleFill, handlePress, handleSelect } from "./interaction"; import { handleNavigate, @@ -42,16 +46,15 @@ import { type SessionStartParams, type SessionStopParams, } from "./session"; -import { OVERLAY_AUTOMATION_BYPASS } from "@/lib/overlay-bridge"; import { chromeTabsApi } from "./shared"; import { + type BorrowConfirmationApprover, handleTabBorrow, handleTabClose, handleTabCreate, handleTabList, handleTabReturn, handleTabSelect, - type BorrowConfirmationApprover, type TabBorrowParams, type TabCloseParams, type TabCreateParams, @@ -59,7 +62,6 @@ import { type TabReturnParams, type TabSelectParams, } from "./tabs"; -import { defaultWatchTabNavigation, handleRequestHelp } from "./human-loop"; import { handleWaitForNavigation } from "./waits"; export interface DispatcherDeps { @@ -277,6 +279,12 @@ export class ToolDispatcher { ? { cdp: this.cdp, tabsApi: chromeTabsCaptureApi, captureApi: chromeTabsCaptureApi } : undefined, ); + case "tool.console": + return handleConsole( + this.sessions, + req.params as ConsoleParams, + this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi } : undefined, + ); case "tool.snapshot": return handleSnapshot( this.sessions, diff --git a/apps/extension/src/tools/shared.ts b/apps/extension/src/tools/shared.ts index c6f3733..d9eb661 100644 --- a/apps/extension/src/tools/shared.ts +++ b/apps/extension/src/tools/shared.ts @@ -6,11 +6,10 @@ // exactly the same sandbox + visibility rules as the M6 observation // handlers (review parity). -import { normaliseRef } from "@/session-manager/ref-store"; -import type { SessionContext, SessionManager } from "@/session-manager/manager"; -import type { RpcError } from "@/transport/types"; -import type { JavaScriptDialogInfo } from "@/transport/types"; import type { DialogCursor } from "@/browser-driver/chromium-cdp"; +import type { SessionContext, SessionManager } from "@/session-manager/manager"; +import { normaliseRef } from "@/session-manager/ref-store"; +import type { ConsoleResult, JavaScriptDialogInfo, RpcError } from "@/transport/types"; import { rpcError } from "./errors"; /** @@ -46,6 +45,14 @@ export interface CdpRunner { }; dialogCursor?(tabId: number): DialogCursor; dialogsSince?(tabId: number, cursor: DialogCursor): JavaScriptDialogInfo[]; + ensureConsoleCapture?(tabId: number): Promise; + consoleEntriesSince?( + tabId: number, + since: number | undefined, + limit: number, + maxTextChars: number, + includeStack: boolean, + ): ConsoleResult; } /** diff --git a/apps/extension/src/transport/types.ts b/apps/extension/src/transport/types.ts index d60c833..a3f12db 100644 --- a/apps/extension/src/transport/types.ts +++ b/apps/extension/src/transport/types.ts @@ -134,6 +134,44 @@ export interface JavaScriptDialogInfo { sequence: number; } +export type ConsoleEntryKind = "console" | "exception" | "log"; + +export interface ConsoleStackFrame { + function_name?: string; + url?: string; + line?: number; + column?: number; +} + +export interface ConsoleEntry { + sequence: number; + kind: ConsoleEntryKind; + level: string; + text: string; + url?: string; + line?: number; + column?: number; + timestamp?: number; + stack_trace?: ConsoleStackFrame[]; + truncated: boolean; +} + +export interface ConsoleParams { + session_id: string; + tab_id?: number; + since?: number; + limit?: number; + max_text_chars?: number; + include_stack?: boolean; +} + +export interface ConsoleResult { + tab_id: number; + entries: ConsoleEntry[]; + next_since: number; + truncated: boolean; +} + export type TabScopeFilter = "user" | "agent" | "all"; export interface TabInfo { diff --git a/crates/bsk-cli/src/cli/console.rs b/crates/bsk-cli/src/cli/console.rs new file mode 100644 index 0000000..cd2b75a --- /dev/null +++ b/crates/bsk-cli/src/cli/console.rs @@ -0,0 +1,131 @@ +//! `bsk console` โ€” read buffered page console/log/exception messages. + +use std::path::PathBuf; + +use anyhow::Context; +use bsk_protocol::Method; +use bsk_protocol::tools::{ConsoleEntry, ConsoleParams, ConsoleResult}; +use clap::Args; + +use crate::cli::TOOL_IPC_TIMEOUT; +use crate::cli::ensure_daemon::ensure_daemon; +use crate::cli::error::{CliError, Format}; + +#[derive(Debug, Clone, Args)] +pub struct ConsoleArgs { + /// Session id (must be active). + #[arg(long)] + pub session: String, + + /// Target tab. Defaults to the Agent Window's active tab. + #[arg(long = "tab-id")] + pub tab_id: Option, + + /// Return entries with sequence greater than this cursor. + #[arg(long)] + pub since: Option, + + /// Maximum number of entries to return. Defaults to 50; extension caps at 200. + #[arg(long, value_parser = clap::value_parser!(u32).range(1..))] + pub limit: Option, + + /// Maximum characters per entry text. Defaults to 1000; extension caps at 4096. + #[arg(long = "max-text-chars", value_parser = clap::value_parser!(u32).range(1..))] + pub max_text_chars: Option, + + /// Include structured stack frames in JSON/human output. + #[arg(long = "include-stack", default_value_t = false)] + pub include_stack: bool, +} + +pub fn dispatch(args: ConsoleArgs, format: Format) -> Result<(), CliError> { + let info = ensure_daemon().context("ensure daemon is running")?; + run(info.sock_path, args, format) +} + +fn run(sock: PathBuf, args: ConsoleArgs, format: Format) -> Result<(), CliError> { + let params = ConsoleParams { + session_id: args.session, + tab_id: args.tab_id, + since: args.since, + limit: args.limit, + max_text_chars: args.max_text_chars, + include_stack: args.include_stack.then_some(true), + }; + let reply: ConsoleResult = call(sock, params)?; + render(&reply, format) +} + +fn call(sock: PathBuf, params: ConsoleParams) -> Result { + crate::cli::business_rpc::call::( + sock, + "console", + Method::ToolConsole, + Some(params), + TOOL_IPC_TIMEOUT, + ) +} + +fn render(reply: &ConsoleResult, format: Format) -> Result<(), CliError> { + match format { + Format::Json => { + let json = serde_json::to_string_pretty(reply) + .map_err(|e| CliError::Local(anyhow::anyhow!(e)))?; + println!("{json}"); + } + Format::Human => { + if reply.entries.is_empty() { + println!("(no console messages captured)"); + } else { + for entry in &reply.entries { + println!("{}", render_entry(entry)); + for frame in &entry.stack_trace { + let loc = render_location(frame.url.as_deref(), frame.line, frame.column); + let name = frame.function_name.as_deref().unwrap_or(""); + println!(" at {name} {loc}"); + } + } + } + if reply.truncated { + eprintln!( + "warning: console output truncated (next_since={}). Use --since / --limit / --max-text-chars to request a different slice.", + reply.next_since + ); + } + } + } + Ok(()) +} + +fn render_entry(entry: &ConsoleEntry) -> String { + let loc = render_location(entry.url.as_deref(), entry.line, entry.column); + if loc.is_empty() { + format!( + "#{} {} {} {}", + entry.sequence, + entry.level, + entry.kind.as_str(), + entry.text + ) + } else { + format!( + "#{} {} {} {} {}", + entry.sequence, + entry.level, + entry.kind.as_str(), + loc, + entry.text + ) + } +} + +fn render_location(url: Option<&str>, line: Option, column: Option) -> String { + let Some(url) = url else { + return String::new(); + }; + match (line, column) { + (Some(line), Some(column)) => format!("{url}:{line}:{column}"), + (Some(line), None) => format!("{url}:{line}"), + _ => url.to_string(), + } +} diff --git a/crates/bsk-cli/src/cli/mod.rs b/crates/bsk-cli/src/cli/mod.rs index 0b57d34..85b1ffb 100644 --- a/crates/bsk-cli/src/cli/mod.rs +++ b/crates/bsk-cli/src/cli/mod.rs @@ -5,6 +5,7 @@ use std::time::Duration; pub mod browser_wait; pub mod browsers; pub mod business_rpc; +pub mod console; pub mod daemon; pub mod dialogs; pub mod doctor; @@ -27,6 +28,7 @@ pub mod waits; use clap::{Args, Parser, Subcommand}; +use crate::cli::console::ConsoleArgs; use crate::cli::daemon::DaemonCmd; use crate::cli::evaluate::EvaluateArgs; use crate::cli::get_html::GetHtmlArgs; @@ -110,6 +112,9 @@ pub enum Command { /// Produce an indented aria-snapshot with @eN refs. Snapshot(SnapshotArgs), + /// Read buffered console/log/exception messages. + Console(ConsoleArgs), + /// Dump raw HTML for a tab or a snapshot ref. #[command(name = "get-html")] GetHtml(GetHtmlArgs), diff --git a/crates/bsk-cli/src/daemon/ipc.rs b/crates/bsk-cli/src/daemon/ipc.rs index 0399d63..9c66e56 100644 --- a/crates/bsk-cli/src/daemon/ipc.rs +++ b/crates/bsk-cli/src/daemon/ipc.rs @@ -228,6 +228,7 @@ pub fn full_handler(status: DaemonStatus, state: Arc) -> RpcHandler | Method::ToolTabBorrow | Method::ToolTabReturn | Method::ToolScreenshot + | Method::ToolConsole | Method::ToolSnapshot | Method::ToolGetHtml | Method::ToolNavigate diff --git a/crates/bsk-cli/src/main.rs b/crates/bsk-cli/src/main.rs index fcda8ff..c5f461a 100644 --- a/crates/bsk-cli/src/main.rs +++ b/crates/bsk-cli/src/main.rs @@ -77,6 +77,7 @@ fn dispatch(cli: Cli, format: Format) -> Result<(), CliError> { Command::Tab(cmd) => cli::tab::dispatch(cmd, format), Command::Screenshot(args) => cli::screenshot::dispatch(args, format), Command::Snapshot(args) => cli::snapshot::dispatch(args, format), + Command::Console(args) => cli::console::dispatch(args, format), Command::GetHtml(args) => cli::get_html::dispatch(args, format), Command::Navigate(args) => cli::navigate::dispatch_navigate_command(args, format), Command::NavigateBack(args) => cli::navigate::dispatch_navigate_back(args, format), diff --git a/crates/bsk-cli/tests/cli_parse.rs b/crates/bsk-cli/tests/cli_parse.rs index 5d545b8..1111607 100644 --- a/crates/bsk-cli/tests/cli_parse.rs +++ b/crates/bsk-cli/tests/cli_parse.rs @@ -67,6 +67,43 @@ fn parses_top_level_status_and_doctor() { assert!(matches!(cli.command, Command::Doctor)); } +#[test] +fn parses_console_command_with_context_safety_flags() { + let cli = parse(&[ + "bsk", + "console", + "--session", + "s1", + "--tab-id", + "9", + "--since", + "12", + "--limit", + "75", + "--max-text-chars", + "2048", + "--include-stack", + ]); + let Command::Console(args) = cli.command else { + panic!("expected console command"); + }; + assert_eq!(args.session, "s1"); + assert_eq!(args.tab_id, Some(9)); + assert_eq!(args.since, Some(12)); + assert_eq!(args.limit, Some(75)); + assert_eq!(args.max_text_chars, Some(2048)); + assert!(args.include_stack); +} + +#[test] +fn rejects_zero_console_bounds() { + assert!(Cli::try_parse_from(["bsk", "console", "--session", "s1", "--limit", "0"]).is_err()); + assert!( + Cli::try_parse_from(["bsk", "console", "--session", "s1", "--max-text-chars", "0"]) + .is_err() + ); +} + #[test] fn parses_install_skill_subcommand() { let cli = parse(&["bsk", "install-skill", "--list"]); diff --git a/crates/bsk-cli/tests/session_user_interrupt.rs b/crates/bsk-cli/tests/session_user_interrupt.rs index 0dff08e..2960814 100644 --- a/crates/bsk-cli/tests/session_user_interrupt.rs +++ b/crates/bsk-cli/tests/session_user_interrupt.rs @@ -428,8 +428,8 @@ async fn read_only_tool_passes_through_without_consuming_interrupt_marker() { let (ws_sink, ws_stream) = ws.split(); let ws_sink = Arc::new(tokio::sync::Mutex::new(ws_sink)); - // Fake extension: replies to session_start AND to snapshot - // (snapshot must succeed transparently). Records click count + // Fake extension: replies to session_start, console, and snapshot + // (read-only tools must succeed transparently). Records click count // โ€” must remain 0 because the daemon should reject the click // without forwarding. let ws_sink_for_responder = Arc::clone(&ws_sink); @@ -454,6 +454,12 @@ async fn read_only_tool_passes_through_without_consuming_interrupt_marker() { }) .unwrap(), ), + Method::ToolConsole => ResponseBody::Ok(json!({ + "tab_id": 7, + "entries": [], + "next_since": 0, + "truncated": false + })), Method::ToolSnapshot => ResponseBody::Ok(json!({"ok": true})), Method::ToolClick => { observed_click_count_clone @@ -511,8 +517,24 @@ async fn read_only_tool_passes_through_without_consuming_interrupt_marker() { let state = handle.state(); wait_for_session_interrupt_pending(&state, &start.session_id).await; - // Read-only snapshot must succeed AND must not consume the - // marker. + // Read-only console must succeed AND must not consume the marker. + let console_outcome = ipc + .call::<_, serde_json::Value>( + "console-after-interrupt", + Method::ToolConsole, + Some(json!({"session_id": start.session_id.clone()})), + Duration::from_secs(3), + ) + .await + .unwrap(); + assert!( + console_outcome.is_ok(), + "read-only tool.console must pass through transparently (got {:?})", + console_outcome + ); + + // Existing read-only snapshot must still succeed after console, + // proving console did not consume the marker. let snapshot_outcome = ipc .call::<_, serde_json::Value>( "snap-after-interrupt", diff --git a/crates/bsk-cli/tests/tools_ipc.rs b/crates/bsk-cli/tests/tools_ipc.rs index 4ce0d88..32b7732 100644 --- a/crates/bsk-cli/tests/tools_ipc.rs +++ b/crates/bsk-cli/tests/tools_ipc.rs @@ -10,9 +10,9 @@ use bsk::daemon::{self, DaemonConfig}; use bsk::ipc_client::IpcClient; use bsk_protocol::system::{HandshakeParams, HandshakeResult}; use bsk_protocol::tools::{ - GetHtmlParams, GetHtmlResult, ScreenshotParams, ScreenshotResult, SessionStartParams, - SessionStartResult, SnapshotParams, SnapshotResult, TabInfo, TabListParams, TabListResult, - TabScope, + ConsoleEntry, ConsoleEntryKind, ConsoleParams, ConsoleResult, GetHtmlParams, GetHtmlResult, + ScreenshotParams, ScreenshotResult, SessionStartParams, SessionStartResult, SnapshotParams, + SnapshotResult, TabInfo, TabListParams, TabListResult, TabScope, }; use bsk_protocol::{ BrowserPeerInfo, ErrorCode, Frame, Method, RequestFrame, ResponseBody, ResponseFrame, RpcError, @@ -311,6 +311,63 @@ async fn screenshot_forwards_ref_to_extension() { handle.shutdown().await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn console_returns_buffered_entries() { + let (handle, sock) = spawn_daemon().await; + let mut ws = connect_ext(handle.ws_addr()).await; + let _ = do_handshake(&mut ws).await; + + run_extension(ws, |req| { + assert_eq!(req.method, Method::ToolConsole); + let params: ConsoleParams = serde_json::from_value(req.params.clone().unwrap()).unwrap(); + assert_eq!(params.tab_id, Some(7)); + assert_eq!(params.since, Some(3)); + assert_eq!(params.limit, Some(50)); + assert_eq!(params.max_text_chars, Some(1000)); + assert_eq!(params.include_stack, Some(false)); + ResponseBody::Ok( + serde_json::to_value(ConsoleResult { + tab_id: 7, + entries: vec![ConsoleEntry { + sequence: 4, + kind: ConsoleEntryKind::Console, + level: "warn".into(), + text: "deprecated API".into(), + url: Some("https://example.test/app.js".into()), + line: Some(10), + column: Some(2), + timestamp: None, + stack_trace: vec![], + truncated: false, + }], + next_since: 4, + truncated: false, + }) + .unwrap(), + ) + }); + + let session_id = ipc_session_start(&sock).await; + let result: ConsoleResult = ipc_tool_call( + &sock, + Method::ToolConsole, + ConsoleParams { + session_id, + tab_id: Some(7), + since: Some(3), + limit: Some(50), + max_text_chars: Some(1000), + include_stack: Some(false), + }, + ) + .await + .expect("console ok"); + assert_eq!(result.tab_id, 7); + assert_eq!(result.next_since, 4); + assert_eq!(result.entries[0].text, "deprecated API"); + handle.shutdown().await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn snapshot_returns_text_and_ref_count() { let (handle, sock) = spawn_daemon().await; diff --git a/crates/bsk-protocol/schema/tool_console_entry.json b/crates/bsk-protocol/schema/tool_console_entry.json new file mode 100644 index 0000000..fdab718 --- /dev/null +++ b/crates/bsk-protocol/schema/tool_console_entry.json @@ -0,0 +1,105 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ConsoleEntry", + "type": "object", + "required": [ + "kind", + "level", + "sequence", + "text" + ], + "properties": { + "column": { + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "kind": { + "$ref": "#/definitions/ConsoleEntryKind" + }, + "level": { + "type": "string" + }, + "line": { + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "sequence": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "stack_trace": { + "type": "array", + "items": { + "$ref": "#/definitions/ConsoleStackFrame" + } + }, + "text": { + "type": "string" + }, + "timestamp": { + "type": [ + "number", + "null" + ], + "format": "double" + }, + "truncated": { + "default": false, + "type": "boolean" + }, + "url": { + "type": [ + "string", + "null" + ] + } + }, + "definitions": { + "ConsoleEntryKind": { + "type": "string", + "enum": [ + "console", + "exception", + "log" + ] + }, + "ConsoleStackFrame": { + "type": "object", + "properties": { + "column": { + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "function_name": { + "type": [ + "string", + "null" + ] + }, + "line": { + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "url": { + "type": [ + "string", + "null" + ] + } + } + } + } +} diff --git a/crates/bsk-protocol/schema/tool_console_params.json b/crates/bsk-protocol/schema/tool_console_params.json new file mode 100644 index 0000000..ff66a2c --- /dev/null +++ b/crates/bsk-protocol/schema/tool_console_params.json @@ -0,0 +1,55 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ConsoleParams", + "type": "object", + "required": [ + "session_id" + ], + "properties": { + "include_stack": { + "description": "Include structured stack frames. Defaults to false.", + "type": [ + "boolean", + "null" + ] + }, + "limit": { + "description": "Maximum number of entries to return. Extension applies safe defaults and caps.", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 1.0 + }, + "max_text_chars": { + "description": "Maximum characters returned per entry text. Extension applies safe defaults and caps.", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 1.0 + }, + "session_id": { + "type": "string" + }, + "since": { + "description": "Return entries with sequence strictly greater than this cursor.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "tab_id": { + "description": "Optional target tab. Defaults to the Agent Window's currently active tab.", + "type": [ + "integer", + "null" + ], + "format": "int64" + } + } +} diff --git a/crates/bsk-protocol/schema/tool_console_result.json b/crates/bsk-protocol/schema/tool_console_result.json new file mode 100644 index 0000000..73f96a6 --- /dev/null +++ b/crates/bsk-protocol/schema/tool_console_result.json @@ -0,0 +1,133 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ConsoleResult", + "type": "object", + "required": [ + "next_since", + "tab_id" + ], + "properties": { + "entries": { + "type": "array", + "items": { + "$ref": "#/definitions/ConsoleEntry" + } + }, + "next_since": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "tab_id": { + "type": "integer", + "format": "int64" + }, + "truncated": { + "default": false, + "type": "boolean" + } + }, + "definitions": { + "ConsoleEntry": { + "type": "object", + "required": [ + "kind", + "level", + "sequence", + "text" + ], + "properties": { + "column": { + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "kind": { + "$ref": "#/definitions/ConsoleEntryKind" + }, + "level": { + "type": "string" + }, + "line": { + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "sequence": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "stack_trace": { + "type": "array", + "items": { + "$ref": "#/definitions/ConsoleStackFrame" + } + }, + "text": { + "type": "string" + }, + "timestamp": { + "type": [ + "number", + "null" + ], + "format": "double" + }, + "truncated": { + "default": false, + "type": "boolean" + }, + "url": { + "type": [ + "string", + "null" + ] + } + } + }, + "ConsoleEntryKind": { + "type": "string", + "enum": [ + "console", + "exception", + "log" + ] + }, + "ConsoleStackFrame": { + "type": "object", + "properties": { + "column": { + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "function_name": { + "type": [ + "string", + "null" + ] + }, + "line": { + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "url": { + "type": [ + "string", + "null" + ] + } + } + } + } +} diff --git a/crates/bsk-protocol/schema/tool_console_stack_frame.json b/crates/bsk-protocol/schema/tool_console_stack_frame.json new file mode 100644 index 0000000..a362800 --- /dev/null +++ b/crates/bsk-protocol/schema/tool_console_stack_frame.json @@ -0,0 +1,33 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ConsoleStackFrame", + "type": "object", + "properties": { + "column": { + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "function_name": { + "type": [ + "string", + "null" + ] + }, + "line": { + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "url": { + "type": [ + "string", + "null" + ] + } + } +} diff --git a/crates/bsk-protocol/src/bin/dump-schema.rs b/crates/bsk-protocol/src/bin/dump-schema.rs index 2e09fe0..29defeb 100644 --- a/crates/bsk-protocol/src/bin/dump-schema.rs +++ b/crates/bsk-protocol/src/bin/dump-schema.rs @@ -82,6 +82,10 @@ fn main() { dump!(GetHtmlResult, "tool_get_html_result"); dump!(ScreenshotParams, "tool_screenshot_params"); dump!(ScreenshotResult, "tool_screenshot_result"); + dump!(ConsoleParams, "tool_console_params"); + dump!(ConsoleResult, "tool_console_result"); + dump!(ConsoleEntry, "tool_console_entry"); + dump!(ConsoleStackFrame, "tool_console_stack_frame"); dump!(EvaluateParams, "tool_evaluate_params"); dump!(EvaluateResult, "tool_evaluate_result"); diff --git a/crates/bsk-protocol/src/method.rs b/crates/bsk-protocol/src/method.rs index 100f390..746149b 100644 --- a/crates/bsk-protocol/src/method.rs +++ b/crates/bsk-protocol/src/method.rs @@ -62,6 +62,8 @@ pub enum Method { ToolGetHtml, #[serde(rename = "tool.screenshot")] ToolScreenshot, + #[serde(rename = "tool.console")] + ToolConsole, #[serde(rename = "tool.evaluate")] ToolEvaluate, #[serde(rename = "tool.wait_for_navigation")] @@ -124,6 +126,7 @@ impl Method { | Method::ToolSnapshot | Method::ToolGetHtml | Method::ToolScreenshot + | Method::ToolConsole | Method::ToolWaitForNavigation | Method::ToolWaitMs | Method::ToolRequestHelp => false, @@ -159,6 +162,13 @@ mod tests { assert_eq!(serde_json::to_value(method).unwrap(), json!("cancel")); } + #[test] + fn console_method_round_trips() { + let method: Method = serde_json::from_value(json!("tool.console")).unwrap(); + assert_eq!(method, Method::ToolConsole); + assert_eq!(serde_json::to_value(method).unwrap(), json!("tool.console")); + } + #[test] fn cancel_params_and_result_round_trip() { let params: CancelParams = serde_json::from_value(json!({ "rpc_id": "wait-1" })).unwrap(); @@ -176,6 +186,7 @@ mod tests { assert!(!Method::ToolSnapshot.is_mutating()); assert!(!Method::ToolGetHtml.is_mutating()); assert!(!Method::ToolScreenshot.is_mutating()); + assert!(!Method::ToolConsole.is_mutating()); assert!(!Method::ToolWaitForNavigation.is_mutating()); assert!(!Method::ToolWaitMs.is_mutating()); } diff --git a/crates/bsk-protocol/src/tools/console.rs b/crates/bsk-protocol/src/tools/console.rs new file mode 100644 index 0000000..54d843a --- /dev/null +++ b/crates/bsk-protocol/src/tools/console.rs @@ -0,0 +1,147 @@ +//! `tool.console` โ€” read buffered console/log/exception messages. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct ConsoleParams { + pub session_id: String, + /// Optional target tab. Defaults to the Agent Window's currently active tab. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tab_id: Option, + /// Return entries with sequence strictly greater than this cursor. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub since: Option, + /// Maximum number of entries to return. Extension applies safe defaults and caps. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 1))] + pub limit: Option, + /// Maximum characters returned per entry text. Extension applies safe defaults and caps. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 1))] + pub max_text_chars: Option, + /// Include structured stack frames. Defaults to false. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub include_stack: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum ConsoleEntryKind { + Console, + Exception, + Log, +} + +impl ConsoleEntryKind { + pub fn as_str(self) -> &'static str { + match self { + Self::Console => "console", + Self::Exception => "exception", + Self::Log => "log", + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct ConsoleStackFrame { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub function_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub line: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub column: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct ConsoleEntry { + pub sequence: u64, + pub kind: ConsoleEntryKind, + pub level: String, + pub text: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub line: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub column: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timestamp: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub stack_trace: Vec, + #[serde(default)] + pub truncated: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct ConsoleResult { + pub tab_id: i64, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub entries: Vec, + pub next_since: u64, + #[serde(default)] + pub truncated: bool, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn console_params_omit_optional_fields() { + let params = ConsoleParams { + session_id: "aa11".into(), + tab_id: None, + since: None, + limit: None, + max_text_chars: None, + include_stack: None, + }; + let value = serde_json::to_value(¶ms).unwrap(); + assert_eq!(value["session_id"], "aa11"); + assert!(value.get("tab_id").is_none()); + assert!(value.get("since").is_none()); + assert!(value.get("limit").is_none()); + assert!(value.get("max_text_chars").is_none()); + assert!(value.get("include_stack").is_none()); + let round: ConsoleParams = serde_json::from_value(value).unwrap(); + assert_eq!(round, params); + } + + #[test] + fn console_result_round_trips_with_stack_and_truncation() { + let result = ConsoleResult { + tab_id: 7, + entries: vec![ConsoleEntry { + sequence: 12, + kind: ConsoleEntryKind::Exception, + level: "error".into(), + text: "Uncaught TypeError: x is undefined".into(), + url: Some("https://example.test/app.js".into()), + line: Some(42), + column: Some(7), + timestamp: Some(1234.5), + stack_trace: vec![ConsoleStackFrame { + function_name: Some("render".into()), + url: Some("https://example.test/app.js".into()), + line: Some(42), + column: Some(7), + }], + truncated: true, + }], + next_since: 12, + truncated: true, + }; + let value = serde_json::to_value(&result).unwrap(); + assert_eq!(value["entries"][0]["kind"], json!("exception")); + assert_eq!( + value["entries"][0]["stack_trace"][0]["function_name"], + json!("render") + ); + let round: ConsoleResult = serde_json::from_value(value).unwrap(); + assert_eq!(round, result); + } +} diff --git a/crates/bsk-protocol/src/tools/mod.rs b/crates/bsk-protocol/src/tools/mod.rs index 54b2d8e..5ec8385 100644 --- a/crates/bsk-protocol/src/tools/mod.rs +++ b/crates/bsk-protocol/src/tools/mod.rs @@ -1,5 +1,6 @@ //! Typed params/results for the 21 `tool.*` RPC methods (ยง7). +pub mod console; pub mod dialog; pub mod human_loop; pub mod interaction; @@ -10,6 +11,7 @@ pub mod session; pub mod tabs; pub mod waits; +pub use console::*; pub use dialog::*; pub use human_loop::*; pub use interaction::*;