feat: add console observation command

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
BB-fat
2026-07-07 11:08:12 +08:00
co-authored by Cursor
parent ce29ddaa53
commit 7f5093d97a
23 changed files with 1538 additions and 44 deletions
@@ -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<chrome.tabs.Tab>;
query?: (query: chrome.tabs.QueryInfo) => Promise<chrome.tabs.Tab[]>;
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();
});
});
@@ -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({
+108
View File
@@ -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<ConsoleResult | RpcError> {
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);
}
+13 -5
View File
@@ -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,
+11 -4
View File
@@ -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<void>;
consoleEntriesSince?(
tabId: number,
since: number | undefined,
limit: number,
maxTextChars: number,
includeStack: boolean,
): ConsoleResult;
}
/**