fix: address network command review feedback

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
BB-fat
2026-07-18 22:52:53 +08:00
co-authored by Cursor
parent 259b4826b7
commit 9799b98937
16 changed files with 488 additions and 289 deletions
@@ -139,6 +139,25 @@ describe("ChromiumCdp", () => {
expect(enableCalls).toHaveLength(2);
});
it("retries and surfaces Network.enable failures for explicit capture", async () => {
const { api } = fakeApi();
(api.sendCommand as ReturnType<typeof vi.fn>).mockImplementation(
async (_target, method: string) => {
if (method === "Network.enable") throw new Error("network restricted");
return {};
},
);
const cdp = new ChromiumCdp(api);
await expect(cdp.ensureAttached(13)).resolves.toBeUndefined();
await expect(cdp.ensureNetworkCapture(13)).rejects.toThrow("network restricted");
const enableCalls = (api.sendCommand as ReturnType<typeof vi.fn>).mock.calls.filter(
([, method]) => method === "Network.enable",
);
expect(enableCalls).toHaveLength(2);
});
it("records javascriptDialogOpening and auto-accepts", async () => {
const { api, onEvent } = fakeApi();
const cdp = new ChromiumCdp(api);
@@ -321,6 +340,30 @@ describe("ChromiumCdp", () => {
truncated: true,
entries: [{ sequence: 2, text: "message 2" }],
});
expect(cdp.consoleEntriesSince(23, undefined, 1, 1000, false)).toMatchObject({
next_since: 3,
truncated: true,
entries: [{ sequence: 3, text: "message 3" }],
});
});
it("reports dropped buffered entries only when they affect the cursor", async () => {
const { api, onEvent } = fakeApi();
const cdp = new ChromiumCdp(api);
await cdp.ensureAttached(25);
for (let i = 1; i <= 201; i += 1) {
onEvent.fire({ tabId: 25 }, "Runtime.consoleAPICalled", {
type: "log",
args: [{ value: `message ${i}` }],
});
}
expect(cdp.consoleEntriesSince(25, 0, 200, 1000, false).truncated).toBe(true);
expect(cdp.consoleEntriesSince(25, 200, 200, 1000, false)).toMatchObject({
next_since: 201,
truncated: false,
entries: [{ sequence: 201, text: "message 201" }],
});
});
it("clears console state on detach", async () => {
@@ -336,6 +379,103 @@ describe("ChromiumCdp", () => {
expect(cdp.consoleEntriesSince(24, 0, 50, 1000, false).entries).toHaveLength(0);
});
it("isolates network request metadata by tab and consumes it", async () => {
const { api, onEvent } = fakeApi();
const cdp = new ChromiumCdp(api);
await cdp.ensureAttached(31);
await cdp.ensureAttached(32);
onEvent.fire({ tabId: 31 }, "Network.requestWillBeSent", {
requestId: "shared-id",
request: { url: "https://one.test/api", method: "GET" },
type: "Fetch",
});
onEvent.fire({ tabId: 32 }, "Network.requestWillBeSent", {
requestId: "shared-id",
request: { url: "https://two.test/script.js", method: "POST" },
type: "Script",
});
onEvent.fire({ tabId: 31 }, "Network.responseReceived", {
requestId: "shared-id",
response: { status: 204 },
type: "Fetch",
});
onEvent.fire({ tabId: 32 }, "Network.loadingFailed", {
requestId: "shared-id",
errorText: "net::ERR_FAILED",
type: "Script",
});
onEvent.fire({ tabId: 31 }, "Network.loadingFailed", {
requestId: "shared-id",
errorText: "net::ERR_ABORTED",
});
expect(cdp.networkEntriesSince(31, 0, 50, 1000).entries).toMatchObject([
{
kind: "response",
method: "GET",
url: "https://one.test/api",
status: 204,
},
{
kind: "failure",
url: undefined,
error_text: "net::ERR_ABORTED",
},
]);
expect(cdp.networkEntriesSince(32, 0, 50, 1000).entries).toMatchObject([
{
kind: "failure",
method: "POST",
url: "https://two.test/script.js",
error_text: "net::ERR_FAILED",
},
]);
});
it("bounds request metadata and clears it on detach", async () => {
const { api, onEvent } = fakeApi();
const cdp = new ChromiumCdp(api);
await cdp.ensureAttached(33);
const longUrl = `https://example.test/${"x".repeat(5000)}`;
onEvent.fire({ tabId: 33 }, "Network.requestWillBeSent", {
requestId: "long",
request: { url: longUrl, method: "GET" },
});
onEvent.fire({ tabId: 33 }, "Network.loadingFailed", {
requestId: "long",
errorText: "net::ERR_FAILED",
});
const bounded = cdp.networkEntriesSince(33, 0, 50, 10_000).entries[0];
expect(bounded.url).toHaveLength(4096);
expect(bounded.truncated).toBe(true);
onEvent.fire({ tabId: 33 }, "Network.requestWillBeSent", {
requestId: "long-method",
request: { url: "https://example.test/ok", method: "M".repeat(5000) },
});
onEvent.fire({ tabId: 33 }, "Network.responseReceived", {
requestId: "long-method",
response: { url: "https://example.test/ok", status: 200 },
});
const boundedResponse = cdp.networkEntriesSince(33, 1, 50, 10_000).entries[0];
expect(boundedResponse.method).toHaveLength(4096);
expect(boundedResponse.truncated).toBe(true);
onEvent.fire({ tabId: 33 }, "Network.requestWillBeSent", {
requestId: "stale",
request: { url: "https://example.test/stale", method: "GET" },
});
await cdp.detach(33);
await cdp.ensureAttached(33);
onEvent.fire({ tabId: 33 }, "Network.loadingFailed", {
requestId: "stale",
errorText: "net::ERR_ABORTED",
});
expect(cdp.networkEntriesSince(33, 0, 50, 1000).entries[0].url).toBeUndefined();
});
it("detachSession only detaches tabs no other session owns", async () => {
const { api } = fakeApi();
const cdp = new ChromiumCdp(api);
+159 -105
View File
@@ -106,11 +106,12 @@ interface ParsedConsoleEntry extends Omit<ConsoleEntry, "sequence"> {}
interface ParsedNetworkEntry extends Omit<NetworkEntry, "sequence"> {}
/** Request metadata remembered from `requestWillBeSent`, keyed by requestId. */
/** Bounded request metadata remembered from `requestWillBeSent`. */
interface NetworkRequestMeta {
url: string;
method?: string;
resourceType?: string;
truncated: boolean;
}
/**
@@ -129,8 +130,8 @@ export class ChromiumCdp {
private readonly consoleDomainsAttemptedTabs = new Set<number>();
private readonly networkBuffers = new Map<number, NetworkEntry[]>();
private readonly networkSequences = new Map<number, number>();
private readonly networkDomainsAttemptedTabs = new Set<number>();
private readonly networkRequestMeta = new Map<string, NetworkRequestMeta>();
private readonly networkDomainsEnabledTabs = new Set<number>();
private readonly networkRequestMeta = new Map<number, Map<string, NetworkRequestMeta>>();
private detachSubscription: { dispose(): void } | null = null;
private dialogSubscription: { dispose(): void } | null = null;
private consoleSubscription: { dispose(): void } | null = null;
@@ -156,7 +157,7 @@ export class ChromiumCdp {
await this.api.attach({ tabId }, CDP_PROTOCOL_VERSION);
await this.enablePageDomain(tabId);
await this.enableConsoleDomains(tabId);
await this.enableNetworkDomains(tabId);
await this.enableNetworkDomainBestEffort(tabId);
this.attachedTabs.add(tabId);
})()
.catch((err) => {
@@ -217,19 +218,13 @@ export class ChromiumCdp {
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);
const { entries, nextSince, truncated } = readBufferedEntries(
buf,
this.consoleSequences.get(tabId) ?? 0,
since,
limit,
(entry) => projectConsoleEntry(entry, maxTextChars, includeStack),
);
return {
tab_id: tabId,
entries,
@@ -242,9 +237,8 @@ export class ChromiumCdp {
async ensureNetworkCapture(tabId: number): Promise<void> {
if (!this.attachedTabs.has(tabId)) {
await this.ensureAttached(tabId);
return;
}
await this.enableNetworkDomains(tabId);
await this.enableNetworkDomain(tabId);
}
/** Network entries observed on `tabId`, bounded for agent context safety. */
@@ -255,19 +249,13 @@ export class ChromiumCdp {
maxTextChars: number,
): NetworkResult {
const buf = this.networkBuffers.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) => projectNetworkEntry(entry, maxTextChars));
const lastReturned = entries.at(-1)?.sequence;
const nextSince = lastReturned ?? this.networkSequences.get(tabId) ?? 0;
const droppedEntries = (this.networkSequences.get(tabId) ?? 0) > buf.length;
const truncated =
droppedEntries ||
candidates.length > limited.length ||
entries.some((entry) => entry.truncated);
const { entries, nextSince, truncated } = readBufferedEntries(
buf,
this.networkSequences.get(tabId) ?? 0,
since,
limit,
(entry) => projectNetworkEntry(entry, maxTextChars),
);
return {
tab_id: tabId,
entries,
@@ -328,7 +316,7 @@ export class ChromiumCdp {
this.consoleDomainsAttemptedTabs.clear();
this.networkBuffers.clear();
this.networkSequences.clear();
this.networkDomainsAttemptedTabs.clear();
this.networkDomainsEnabledTabs.clear();
this.networkRequestMeta.clear();
await Promise.all(
tabs.map(async (tabId) => {
@@ -357,11 +345,15 @@ export class ChromiumCdp {
}
}
private async enableNetworkDomains(tabId: number): Promise<void> {
if (this.networkDomainsAttemptedTabs.has(tabId)) return;
this.networkDomainsAttemptedTabs.add(tabId);
private async enableNetworkDomain(tabId: number): Promise<void> {
if (this.networkDomainsEnabledTabs.has(tabId)) return;
await this.api.sendCommand({ tabId }, "Network.enable", {});
this.networkDomainsEnabledTabs.add(tabId);
}
private async enableNetworkDomainBestEffort(tabId: number): Promise<void> {
try {
await this.api.sendCommand({ tabId }, "Network.enable", {});
await this.enableNetworkDomain(tabId);
} catch (err) {
console.debug("[bsk cdp] network domain enable failed", { tabId, err });
}
@@ -447,12 +439,7 @@ export class ChromiumCdp {
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);
appendBoundedEntry(this.consoleBuffers, tabId, { ...entry, sequence }, MAX_CONSOLE_BUFFER);
}
private clearConsoleState(tabId: number): void {
@@ -468,13 +455,19 @@ export class ChromiumCdp {
if (typeof tabId !== "number") return;
switch (method) {
case "Network.requestWillBeSent":
this.rememberNetworkRequest(params);
this.rememberNetworkRequest(tabId, params);
break;
case "Network.responseReceived":
this.appendNetwork(tabId, parseNetworkResponse(this.networkRequestMeta, params));
this.appendNetwork(
tabId,
parseNetworkResponse(this.takeNetworkRequest(tabId, params), params),
);
break;
case "Network.loadingFailed":
this.appendNetwork(tabId, parseNetworkFailure(this.networkRequestMeta, params));
this.appendNetwork(
tabId,
parseNetworkFailure(this.takeNetworkRequest(tabId, params), params),
);
break;
}
};
@@ -488,36 +481,58 @@ export class ChromiumCdp {
if (!entry) return;
const sequence = (this.networkSequences.get(tabId) ?? 0) + 1;
this.networkSequences.set(tabId, sequence);
const buf = this.networkBuffers.get(tabId) ?? [];
buf.push({ ...entry, sequence });
while (buf.length > MAX_NETWORK_BUFFER) {
buf.shift();
}
this.networkBuffers.set(tabId, buf);
appendBoundedEntry(this.networkBuffers, tabId, { ...entry, sequence }, MAX_NETWORK_BUFFER);
}
/** Remember `requestWillBeSent` metadata so responses/failures can be attributed. */
private rememberNetworkRequest(params: unknown): void {
private rememberNetworkRequest(tabId: number, params: unknown): void {
const raw = (params ?? {}) as Record<string, unknown>;
const id = typeof raw.requestId === "string" ? raw.requestId : undefined;
const request = (raw.request ?? {}) as Record<string, unknown>;
if (!id || typeof request.url !== "string") return;
this.networkRequestMeta.set(id, {
url: request.url,
method: typeof request.method === "string" ? request.method : undefined,
resourceType: typeof raw.type === "string" ? raw.type : undefined,
const url = truncateText(request.url, MAX_NETWORK_FIELD_LENGTH);
const method = truncateOptionalText(
typeof request.method === "string" ? request.method : undefined,
MAX_NETWORK_FIELD_LENGTH,
);
const resourceType = truncateOptionalText(
typeof raw.type === "string" ? raw.type : undefined,
MAX_NETWORK_FIELD_LENGTH,
);
const tabMeta = this.networkRequestMeta.get(tabId) ?? new Map<string, NetworkRequestMeta>();
tabMeta.delete(id);
tabMeta.set(id, {
url: url.text,
method: method?.text,
resourceType: resourceType?.text,
truncated:
url.truncated || (method?.truncated ?? false) || (resourceType?.truncated ?? false),
});
while (this.networkRequestMeta.size > MAX_NETWORK_REQUEST_META) {
const oldest = this.networkRequestMeta.keys().next().value;
while (tabMeta.size > MAX_NETWORK_REQUEST_META) {
const oldest = tabMeta.keys().next().value;
if (oldest === undefined) break;
this.networkRequestMeta.delete(oldest);
tabMeta.delete(oldest);
}
this.networkRequestMeta.set(tabId, tabMeta);
}
/** Consume request metadata once its response or failure has been observed. */
private takeNetworkRequest(tabId: number, params: unknown): NetworkRequestMeta | undefined {
const raw = (params ?? {}) as Record<string, unknown>;
const id = typeof raw.requestId === "string" ? raw.requestId : undefined;
if (!id) return undefined;
const tabMeta = this.networkRequestMeta.get(tabId);
const info = tabMeta?.get(id);
tabMeta?.delete(id);
if (tabMeta?.size === 0) this.networkRequestMeta.delete(tabId);
return info;
}
private clearNetworkState(tabId: number): void {
this.networkBuffers.delete(tabId);
this.networkSequences.delete(tabId);
this.networkDomainsAttemptedTabs.delete(tabId);
this.networkDomainsEnabledTabs.delete(tabId);
this.networkRequestMeta.delete(tabId);
}
private bindAutoDetach(): void {
@@ -564,6 +579,45 @@ export class ChromiumCdp {
}
}
function appendBoundedEntry<T>(
buffers: Map<number, T[]>,
tabId: number,
entry: T,
maxEntries: number,
): void {
const buffer = buffers.get(tabId) ?? [];
buffer.push(entry);
while (buffer.length > maxEntries) buffer.shift();
buffers.set(tabId, buffer);
}
function readBufferedEntries<
TEntry extends { sequence: number },
TProjected extends { sequence: number; truncated: boolean },
>(
buffer: TEntry[],
currentSequence: number,
since: number | undefined,
limit: number,
project: (entry: TEntry) => TProjected,
): { entries: TProjected[]; nextSince: number; truncated: boolean } {
const hasCursor = typeof since === "number";
const candidates = hasCursor ? buffer.filter((entry) => entry.sequence > since) : buffer;
const limited = hasCursor ? candidates.slice(0, limit) : candidates.slice(-limit);
const entries = limited.map(project);
const oldestSequence = buffer[0]?.sequence ?? currentSequence + 1;
const droppedEntries =
currentSequence > buffer.length && (!hasCursor || (since ?? 0) < oldestSequence - 1);
return {
entries,
nextSince: entries.at(-1)?.sequence ?? currentSequence,
truncated:
droppedEntries ||
candidates.length > limited.length ||
entries.some((entry) => entry.truncated),
};
}
function parseDialogOpeningParams(params: unknown): ParsedDialogOpening {
const raw = (params ?? {}) as Record<string, unknown>;
const type = normalizeDialogType(raw.type);
@@ -655,7 +709,7 @@ function makeConsoleEntry(input: {
timestamp?: number;
stack_trace: ConsoleStackFrame[];
}): ParsedConsoleEntry {
const projected = truncateConsoleText(input.text, MAX_CONSOLE_FIELD_LENGTH);
const projected = truncateText(input.text, MAX_CONSOLE_FIELD_LENGTH);
return {
kind: input.kind,
level: input.level,
@@ -679,7 +733,7 @@ function projectConsoleEntry(
maxTextChars: number,
includeStack: boolean,
): ConsoleEntry {
const projected = truncateConsoleText(entry.text, maxTextChars);
const projected = truncateText(entry.text, maxTextChars);
return {
sequence: entry.sequence,
kind: entry.kind,
@@ -728,28 +782,30 @@ function firstLine(value: string | undefined): string | undefined {
return value.split(/\r?\n/, 1)[0] || undefined;
}
function truncateConsoleText(
value: string,
maxChars: number,
): { text: string; truncated: boolean } {
function truncateText(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 truncateOptionalText(
value: string | undefined,
maxChars: number,
): { text: string; truncated: boolean } | undefined {
return value === undefined ? undefined : truncateText(value, maxChars);
}
function truncateOptionalConsoleField(value: string | undefined): string | undefined {
if (value === undefined) return undefined;
return truncateConsoleText(value, MAX_CONSOLE_FIELD_LENGTH).text;
return truncateOptionalText(value, MAX_CONSOLE_FIELD_LENGTH)?.text;
}
function parseNetworkResponse(
meta: Map<string, NetworkRequestMeta>,
info: NetworkRequestMeta | undefined,
params: unknown,
): ParsedNetworkEntry | null {
const raw = (params ?? {}) as Record<string, unknown>;
const id = typeof raw.requestId === "string" ? raw.requestId : undefined;
const response = (raw.response ?? {}) as Record<string, unknown>;
const info = id ? meta.get(id) : undefined;
const url = typeof response.url === "string" ? response.url : info?.url;
const hasResponseUrl = typeof response.url === "string";
const url = hasResponseUrl ? (response.url as string) : info?.url;
if (!url) return null;
return makeNetworkEntry({
kind: "response",
@@ -760,90 +816,88 @@ function parseNetworkResponse(
mime_type: typeof response.mimeType === "string" ? response.mimeType : undefined,
resource_type: typeof raw.type === "string" ? raw.type : info?.resourceType,
timestamp: typeof raw.timestamp === "number" ? raw.timestamp : undefined,
truncated: info?.truncated === true,
});
}
function parseNetworkFailure(
meta: Map<string, NetworkRequestMeta>,
info: NetworkRequestMeta | undefined,
params: unknown,
): ParsedNetworkEntry | null {
const raw = (params ?? {}) as Record<string, unknown>;
const id = typeof raw.requestId === "string" ? raw.requestId : undefined;
const info = id ? meta.get(id) : undefined;
return makeNetworkEntry({
kind: "failure",
method: info?.method,
url: info?.url ?? "(unknown)",
url: info?.url,
error_text: typeof raw.errorText === "string" ? raw.errorText : undefined,
resource_type: typeof raw.type === "string" ? raw.type : info?.resourceType,
timestamp: typeof raw.timestamp === "number" ? raw.timestamp : undefined,
truncated: info?.truncated === true,
});
}
function makeNetworkEntry(input: {
kind: NetworkEntryKind;
method?: string;
url: string;
url?: string;
status?: number;
status_text?: string;
mime_type?: string;
resource_type?: string;
error_text?: string;
timestamp?: number;
truncated?: boolean;
}): ParsedNetworkEntry {
const projectedUrl = truncateNetworkText(input.url, MAX_NETWORK_FIELD_LENGTH);
const projectedMethod = truncateOptionalText(input.method, MAX_NETWORK_FIELD_LENGTH);
const projectedUrl = truncateOptionalText(input.url, MAX_NETWORK_FIELD_LENGTH);
const projectedStatusText = truncateOptionalText(input.status_text, MAX_NETWORK_FIELD_LENGTH);
const projectedMimeType = truncateOptionalText(input.mime_type, MAX_NETWORK_FIELD_LENGTH);
const projectedResourceType = truncateOptionalText(input.resource_type, MAX_NETWORK_FIELD_LENGTH);
const projectedError =
input.error_text !== undefined
? truncateNetworkText(input.error_text, MAX_NETWORK_FIELD_LENGTH)
? truncateText(input.error_text, MAX_NETWORK_FIELD_LENGTH)
: undefined;
return {
kind: input.kind,
method: input.method,
url: projectedUrl.text,
method: projectedMethod?.text,
url: projectedUrl?.text,
status: input.status,
status_text: truncateOptionalNetworkField(input.status_text),
mime_type: truncateOptionalNetworkField(input.mime_type),
resource_type: input.resource_type,
status_text: projectedStatusText?.text,
mime_type: projectedMimeType?.text,
resource_type: projectedResourceType?.text,
error_text: projectedError?.text,
timestamp: input.timestamp,
truncated: projectedUrl.truncated || (projectedError?.truncated ?? false),
truncated:
input.truncated === true ||
(projectedMethod?.truncated ?? false) ||
(projectedUrl?.truncated ?? false) ||
(projectedStatusText?.truncated ?? false) ||
(projectedMimeType?.truncated ?? false) ||
(projectedResourceType?.truncated ?? false) ||
(projectedError?.truncated ?? false),
};
}
function projectNetworkEntry(entry: NetworkEntry, maxTextChars: number): NetworkEntry {
const projectedUrl = truncateNetworkText(entry.url, maxTextChars);
const projectedUrl = truncateOptionalText(entry.url, maxTextChars);
const projectedError =
entry.error_text !== undefined
? truncateNetworkText(entry.error_text, maxTextChars)
: undefined;
entry.error_text !== undefined ? truncateText(entry.error_text, maxTextChars) : undefined;
return {
sequence: entry.sequence,
kind: entry.kind,
method: entry.method,
url: projectedUrl.text,
url: projectedUrl?.text,
status: entry.status,
status_text: entry.status_text,
mime_type: entry.mime_type,
resource_type: entry.resource_type,
error_text: projectedError?.text,
timestamp: entry.timestamp,
truncated: entry.truncated || projectedUrl.truncated || (projectedError?.truncated ?? false),
truncated:
entry.truncated || (projectedUrl?.truncated ?? false) || (projectedError?.truncated ?? false),
};
}
function truncateNetworkText(
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 truncateOptionalNetworkField(value: string | undefined): string | undefined {
if (value === undefined) return undefined;
return truncateNetworkText(value, MAX_NETWORK_FIELD_LENGTH).text;
}
function normalizeError(err: unknown): Error {
if (err instanceof Error) return err;
if (typeof err === "string") return new Error(err);
@@ -72,7 +72,7 @@ describe("handleConsole", () => {
expect(deps.consoleEntriesSince).toHaveBeenCalledWith(7, undefined, 50, 1000, false);
});
it("reads an explicit tab and forwards bounded options", async () => {
it("reads an explicit tab and forwards cursor options", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const deps = makeDeps({
@@ -85,27 +85,15 @@ describe("handleConsole", () => {
session_id: "aa11",
tab_id: 9,
since: 12,
limit: 500,
max_text_chars: 9999,
limit: 75,
max_text_chars: 1500,
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();
expect(deps.consoleEntriesSince).toHaveBeenCalledWith(9, 12, 75, 1500, true);
});
it("hides other sessions' Agent Window tabs", async () => {
@@ -106,6 +106,13 @@ describe("ToolDispatcher", () => {
send: vi.fn(),
detachSession: vi.fn(async () => {}),
ensureConsoleCapture: vi.fn(async () => {}),
ensureNetworkCapture: vi.fn(async () => {}),
networkEntriesSince: vi.fn(() => ({
tab_id: 7,
entries: [],
next_since: 0,
truncated: false,
})),
consoleEntriesSince: vi.fn(
() =>
({
@@ -149,6 +156,13 @@ describe("ToolDispatcher", () => {
const cdp = {
send: vi.fn(),
detachSession: vi.fn(async () => {}),
ensureNetworkCapture: vi.fn(async () => {}),
networkEntriesSince: vi.fn(() => ({
tab_id: 7,
entries: [],
next_since: 0,
truncated: false,
})),
};
const dispatcher = new ToolDispatcher({ transport, sessions, cdp });
dispatcher.start();
@@ -1,8 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { SessionManager } from "@/session-manager/manager";
import type { CdpRunner } from "@/tools/shared";
import type { NetworkResult } from "@/transport/types";
import { handleNetwork } from "../network";
import { handleNetwork, type NetworkCdpRunner } from "../network";
function fakeAgentWindow(ids: number[]) {
let i = 0;
@@ -22,6 +21,7 @@ function makeDeps(
get?: (tabId: number) => Promise<chrome.tabs.Tab>;
query?: (query: chrome.tabs.QueryInfo) => Promise<chrome.tabs.Tab[]>;
result?: NetworkResult;
captureError?: Error;
} = {},
) {
const result =
@@ -32,13 +32,16 @@ function makeDeps(
next_since: 0,
truncated: false,
} satisfies NetworkResult);
const ensureNetworkCapture = vi.fn(async () => {});
const ensureNetworkCapture = vi.fn(async () => {
if (opts.captureError) throw opts.captureError;
});
const networkEntriesSince = vi.fn(() => result);
const trackSessionTab = vi.fn();
const cdp = {
send: vi.fn(),
trackSessionTab,
ensureNetworkCapture,
networkEntriesSince,
} as unknown as CdpRunner;
} satisfies NetworkCdpRunner;
const tabsApi = {
get:
opts.get ??
@@ -48,7 +51,7 @@ function makeDeps(
query:
opts.query ?? vi.fn(async () => [{ id: 7, windowId: 100, active: true } as chrome.tabs.Tab]),
};
return { cdp, tabsApi, ensureNetworkCapture, networkEntriesSince };
return { cdp, tabsApi, ensureNetworkCapture, networkEntriesSince, trackSessionTab };
}
describe("handleNetwork", () => {
@@ -74,7 +77,7 @@ describe("handleNetwork", () => {
expect(deps.networkEntriesSince).toHaveBeenCalledWith(7, undefined, 50, 1000);
});
it("reads an explicit tab and forwards bounded options", async () => {
it("reads an explicit tab and forwards cursor options", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const deps = makeDeps({
@@ -87,25 +90,25 @@ describe("handleNetwork", () => {
session_id: "aa11",
tab_id: 9,
since: 12,
limit: 500,
max_text_chars: 9999,
limit: 75,
max_text_chars: 1500,
},
deps,
);
expect(deps.ensureNetworkCapture).toHaveBeenCalledWith(9);
expect(deps.networkEntriesSince).toHaveBeenCalledWith(9, 12, 200, 4096);
expect(deps.networkEntriesSince).toHaveBeenCalledWith(9, 12, 75, 1500);
});
it("rejects invalid bounds before touching CDP", async () => {
it("surfaces network capture failures as cdp_failed", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const deps = makeDeps();
const deps = makeDeps({ captureError: new Error("Network.enable denied") });
const res = await handleNetwork(sm, { session_id: "aa11", limit: 0 }, deps);
const res = await handleNetwork(sm, { session_id: "aa11" }, deps);
expect(res).toMatchObject({ code: "invalid_params", message: /limit/ });
expect(deps.ensureNetworkCapture).not.toHaveBeenCalled();
expect(res).toEqual({ code: "cdp_failed", message: "Network.enable denied" });
expect(deps.trackSessionTab).toHaveBeenCalledWith("aa11", 7);
expect(deps.networkEntriesSince).not.toHaveBeenCalled();
});
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { SessionManager } from "@/session-manager/manager";
import { lookupSession } from "../shared";
import { lookupSession, parseBufferedReadBounds } from "../shared";
function fakeAgentWindow() {
return {
@@ -40,3 +40,30 @@ describe("lookupSession", () => {
expect(result).toBe(ctx);
});
});
describe("parseBufferedReadBounds", () => {
it("applies shared defaults and caps", () => {
expect(parseBufferedReadBounds({})).toEqual({
since: undefined,
limit: 50,
maxTextChars: 1000,
});
expect(parseBufferedReadBounds({ since: 12, limit: 500, max_text_chars: 9999 })).toEqual({
since: 12,
limit: 200,
maxTextChars: 4096,
});
});
it.each([
[{ since: -1 }, "since"],
[{ since: 1.5 }, "since"],
[{ limit: 0 }, "limit"],
[{ max_text_chars: 0 }, "max_text_chars"],
])("rejects invalid bounds in %o", (params, field) => {
expect(parseBufferedReadBounds(params)).toMatchObject({
code: "invalid_params",
message: expect.stringContaining(field),
});
});
});
+3 -54
View File
@@ -7,14 +7,10 @@ import {
chromeTabsApi,
isRpcError,
lookupSession,
parseBufferedReadBounds,
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;
@@ -34,7 +30,7 @@ export async function handleConsole(
): Promise<ConsoleResult | RpcError> {
const ctxOrErr = lookupSession(manager, params, "console");
if (isRpcError(ctxOrErr)) return ctxOrErr;
const bounds = parseConsoleBounds(params);
const bounds = parseBufferedReadBounds(params);
if (isRpcError(bounds)) return bounds;
const target = await resolveTargetTab(manager, ctxOrErr, params.tab_id, deps.tabsApi);
if (isRpcError(target)) return target;
@@ -50,7 +46,7 @@ export async function handleConsole(
bounds.since,
bounds.limit,
bounds.maxTextChars,
bounds.includeStack,
params.include_stack === true,
);
} catch (err) {
return {
@@ -59,50 +55,3 @@ export async function handleConsole(
};
}
}
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);
}
+8 -7
View File
@@ -34,7 +34,7 @@ import {
handleNavigateForward,
handleReload,
} from "./navigation";
import { handleNetwork } from "./network";
import { handleNetwork, type NetworkCdpRunner } from "./network";
import {
type CdpRunner,
chromeTabsCaptureApi,
@@ -66,12 +66,15 @@ import {
} from "./tabs";
import { handleWaitForNavigation } from "./waits";
type DispatcherCdpRunner = CdpRunner &
NetworkCdpRunner & {
detachSession(sessionId: string): Promise<void>;
};
export interface DispatcherDeps {
transport: Transport;
sessions: SessionManager;
cdp?: CdpRunner & {
detachSession(sessionId: string): Promise<void>;
};
cdp?: DispatcherCdpRunner;
/**
* Invoked whenever a dispatched RPC may have changed the live
* session set (currently `tool.session_start` and
@@ -105,9 +108,7 @@ export interface DispatcherDeps {
export class ToolDispatcher {
private readonly transport: Transport;
private readonly sessions: SessionManager;
private readonly cdp?: CdpRunner & {
detachSession(sessionId: string): Promise<void>;
};
private readonly cdp?: DispatcherCdpRunner;
private readonly onSessionsChanged?: () => void;
private readonly approveBorrow?: BorrowConfirmationApprover;
private readonly helpNotificationCopy?: () => { title: string; body: string };
+14 -56
View File
@@ -2,21 +2,27 @@ import { ChromiumCdp } from "@/browser-driver/chromium-cdp";
import type { SessionManager } from "@/session-manager/manager";
import type { NetworkParams, NetworkResult, RpcError } from "@/transport/types";
import {
type CdpRunner,
type ChromeTabsApi,
chromeTabsApi,
isRpcError,
lookupSession,
parseBufferedReadBounds,
resolveTargetTab,
} from "./shared";
const DEFAULT_NETWORK_LIMIT = 50;
const MAX_NETWORK_LIMIT = 200;
const DEFAULT_MAX_TEXT_CHARS = 1000;
const MAX_TEXT_CHARS = 4096;
export interface NetworkCdpRunner {
trackSessionTab?(sessionId: string, tabId: number): void;
ensureNetworkCapture(tabId: number): Promise<void>;
networkEntriesSince(
tabId: number,
since: number | undefined,
limit: number,
maxTextChars: number,
): NetworkResult;
}
export interface NetworkDeps {
cdp: CdpRunner;
cdp: NetworkCdpRunner;
tabsApi: ChromeTabsApi;
}
@@ -34,17 +40,14 @@ export async function handleNetwork(
): Promise<NetworkResult | RpcError> {
const ctxOrErr = lookupSession(manager, params, "network");
if (isRpcError(ctxOrErr)) return ctxOrErr;
const bounds = parseNetworkBounds(params);
const bounds = parseBufferedReadBounds(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.ensureNetworkCapture || !deps.cdp.networkEntriesSince) {
return { code: "cdp_failed", message: "network capture requires CDP network support" };
}
try {
await deps.cdp.ensureNetworkCapture(target.tabId);
deps.cdp.trackSessionTab?.(ctxOrErr.sessionId, target.tabId);
await deps.cdp.ensureNetworkCapture(target.tabId);
return deps.cdp.networkEntriesSince(
target.tabId,
bounds.since,
@@ -58,48 +61,3 @@ export async function handleNetwork(
};
}
}
function parseNetworkBounds(params: NetworkParams):
| {
since: number | undefined;
limit: number;
maxTextChars: number;
}
| 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_NETWORK_LIMIT,
MAX_NETWORK_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,
};
}
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);
}
+52 -13
View File
@@ -9,14 +9,14 @@
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,
NetworkResult,
RpcError,
} from "@/transport/types";
import type { ConsoleResult, JavaScriptDialogInfo, RpcError } from "@/transport/types";
import { rpcError } from "./errors";
const DEFAULT_BUFFERED_READ_LIMIT = 50;
const MAX_BUFFERED_READ_LIMIT = 200;
const DEFAULT_MAX_TEXT_CHARS = 1000;
const MAX_TEXT_CHARS = 4096;
/**
* Subset of `chrome.tabs` we depend on across tool handlers. Kept on
* a thin interface so vitest can inject a fake without monkey-patching
@@ -58,13 +58,52 @@ export interface CdpRunner {
maxTextChars: number,
includeStack: boolean,
): ConsoleResult;
ensureNetworkCapture?(tabId: number): Promise<void>;
networkEntriesSince?(
tabId: number,
since: number | undefined,
limit: number,
maxTextChars: number,
): NetworkResult;
}
export interface BufferedReadBounds {
since: number | undefined;
limit: number;
maxTextChars: number;
}
/** Parse the common cursor and output bounds used by buffered read tools. */
export function parseBufferedReadBounds(params: {
since?: number;
limit?: number;
max_text_chars?: number;
}): BufferedReadBounds | 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_BUFFERED_READ_LIMIT,
MAX_BUFFERED_READ_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 };
}
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);
}
/**
+1 -1
View File
@@ -178,7 +178,7 @@ export interface NetworkEntry {
sequence: number;
kind: NetworkEntryKind;
method?: string;
url: string;
url?: string;
status?: number;
status_text?: string;
mime_type?: string;
+31 -2
View File
@@ -89,17 +89,46 @@ fn render(reply: &NetworkResult, format: Format) -> Result<(), CliError> {
fn render_entry(entry: &NetworkEntry) -> String {
let method = entry.method.as_deref().unwrap_or("?");
let url = entry.url.as_deref().unwrap_or("(unknown)");
match entry.kind {
bsk_protocol::tools::NetworkEntryKind::Failure => {
let err = entry.error_text.as_deref().unwrap_or("failed");
format!("#{} FAILED {method} {} — {err}", entry.sequence, entry.url)
format!("#{} FAILED {method} {url} - {err}", entry.sequence)
}
bsk_protocol::tools::NetworkEntryKind::Response => {
let status = entry
.status
.map(|s| s.to_string())
.unwrap_or_else(|| "?".to_string());
format!("#{} {status} {method} {}", entry.sequence, entry.url)
format!("#{} {status} {method} {url}", entry.sequence)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use bsk_protocol::tools::NetworkEntryKind;
#[test]
fn human_failure_output_uses_ascii_and_displays_unknown_url() {
let entry = NetworkEntry {
sequence: 7,
kind: NetworkEntryKind::Failure,
method: Some("GET".into()),
url: None,
status: None,
status_text: None,
mime_type: None,
resource_type: Some("Fetch".into()),
error_text: Some("net::ERR_FAILED".into()),
timestamp: None,
truncated: false,
};
assert_eq!(
render_entry(&entry),
"#7 FAILED GET (unknown) - net::ERR_FAILED"
);
}
}
+1 -1
View File
@@ -386,7 +386,7 @@ async fn network_returns_buffered_entries() {
sequence: 4,
kind: NetworkEntryKind::Response,
method: Some("GET".into()),
url: "https://example.test/api".into(),
url: Some("https://example.test/api".into()),
status: Some(404),
status_text: Some("Not Found".into()),
mime_type: Some("application/json".into()),
@@ -4,8 +4,7 @@
"type": "object",
"required": [
"kind",
"sequence",
"url"
"sequence"
],
"properties": {
"error_text": {
@@ -68,7 +67,10 @@
"type": "boolean"
},
"url": {
"type": "string"
"type": [
"string",
"null"
]
}
},
"definitions": {
@@ -32,8 +32,7 @@
"type": "object",
"required": [
"kind",
"sequence",
"url"
"sequence"
],
"properties": {
"error_text": {
@@ -96,7 +95,10 @@
"type": "boolean"
},
"url": {
"type": "string"
"type": [
"string",
"null"
]
}
}
},
+5 -12
View File
@@ -31,22 +31,14 @@ pub enum NetworkEntryKind {
Failure,
}
impl NetworkEntryKind {
pub fn as_str(self) -> &'static str {
match self {
Self::Response => "response",
Self::Failure => "failure",
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct NetworkEntry {
pub sequence: u64,
pub kind: NetworkEntryKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub method: Option<String>,
pub url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
/// HTTP status code (`response` entries only).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<u32>,
@@ -108,7 +100,7 @@ mod tests {
sequence: 3,
kind: NetworkEntryKind::Response,
method: Some("GET".into()),
url: "https://example.test/api".into(),
url: Some("https://example.test/api".into()),
status: Some(404),
status_text: Some("Not Found".into()),
mime_type: Some("application/json".into()),
@@ -121,7 +113,7 @@ mod tests {
sequence: 4,
kind: NetworkEntryKind::Failure,
method: Some("GET".into()),
url: "https://example.test/blocked".into(),
url: None,
status: None,
status_text: None,
mime_type: None,
@@ -138,6 +130,7 @@ mod tests {
assert_eq!(value["entries"][0]["kind"], json!("response"));
assert_eq!(value["entries"][0]["status"], json!(404));
assert_eq!(value["entries"][1]["kind"], json!("failure"));
assert!(value["entries"][1].get("url").is_none());
assert_eq!(
value["entries"][1]["error_text"],
json!("net::ERR_BLOCKED_BY_CLIENT")