From d39a29fcbe6391b7be3aaa97397750026095a86c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 16 Aug 2026 14:03:43 -0700 Subject: [PATCH] fix(browser): stop local actions when agent turns are cancelled (#124795) * fix(browser): cancel local actions with agent signal Amp-Thread-ID: https://ampcode.com/threads/T-01a00b6b-e4e9-74af-bb31-30363fae6c89 * fix(agent-harness): expose authored context cap Amp-Thread-ID: https://ampcode.com/threads/T-01a00b6b-e4e9-74af-bb31-30363fae6c89 * chore(agent-harness): drop redundant context-cap override Amp-Thread-ID: https://ampcode.com/threads/T-01a00b6b-e4e9-74af-bb31-30363fae6c89 --------- Co-authored-by: Amp --- .../browser/src/browser-tool.actions.ts | 24 ++- .../browser/src/browser-tool.snapshot.ts | 5 + extensions/browser/src/browser-tool.test.ts | 170 ++++++++++++++++-- extensions/browser/src/browser-tool.ts | 60 ++++--- .../client-actions-core.cancel.test.ts | 90 ++++++++++ .../src/browser/client-actions-core.ts | 11 +- .../src/browser/client-actions-observe.ts | 7 +- extensions/browser/src/browser/client.ts | 50 ++++-- 8 files changed, 359 insertions(+), 58 deletions(-) create mode 100644 extensions/browser/src/browser/client-actions-core.cancel.test.ts diff --git a/extensions/browser/src/browser-tool.actions.ts b/extensions/browser/src/browser-tool.actions.ts index 1bb6788dc5cb..bf5788cb86f1 100644 --- a/extensions/browser/src/browser-tool.actions.ts +++ b/extensions/browser/src/browser-tool.actions.ts @@ -269,6 +269,7 @@ export async function executeTabsAction(params: { timeoutMs?: number; proxyRequest: BrowserProxyRequest | null; targetId?: string; + signal?: AbortSignal; }): Promise> { const { baseUrl, profile, timeoutMs, proxyRequest } = params; if (proxyRequest) { @@ -285,9 +286,13 @@ export async function executeTabsAction(params: { ); return formatTabsToolResult(tabs); } - const tabs = (await browserToolActionDeps.browserTabs(baseUrl, { profile, timeoutMs })).filter( - (tab) => !params.targetId || readStringValue(tab.targetId) === params.targetId, - ); + const tabs = ( + await browserToolActionDeps.browserTabs(baseUrl, { + profile, + timeoutMs, + signal: params.signal, + }) + ).filter((tab) => !params.targetId || readStringValue(tab.targetId) === params.targetId); return formatTabsToolResult(tabs); } @@ -334,6 +339,7 @@ export async function executeConsoleAction(params: { baseUrl?: string; profile?: string; proxyRequest: BrowserProxyRequest | null; + signal?: AbortSignal; }): Promise> { const { input, baseUrl, profile, proxyRequest } = params; const level = normalizeOptionalString(input.level); @@ -354,6 +360,7 @@ export async function executeConsoleAction(params: { level, targetId, profile, + signal: params.signal, }); return formatConsoleToolResult(result); } @@ -439,6 +446,7 @@ export async function executeActAction(params: { baseUrl?: string; profile?: string; proxyRequest: BrowserProxyRequest | null; + signal?: AbortSignal; onTabActivity?: (targetId: string | undefined) => void; onTabClose?: (targetId: string | undefined) => void; }): Promise> { @@ -465,6 +473,7 @@ export async function executeActAction(params: { baseUrl, profile, proxyRequest, + signal: params.signal, }); }; try { @@ -478,6 +487,7 @@ export async function executeActAction(params: { }) : await browserToolActionDeps.browserAct(baseUrl, effectiveRequest, { profile, + signal: params.signal, }); return await finishActResult( result, @@ -494,7 +504,12 @@ export async function executeActAction(params: { profile, })) as { tabs?: unknown[] } ).tabs ?? []) - : await browserToolActionDeps.browserTabs(baseUrl, { profile }).catch(() => []); + : await browserToolActionDeps + .browserTabs(baseUrl, { profile, signal: params.signal }) + .catch(() => { + params.signal?.throwIfAborted(); + return []; + }); const freshTargetId = tabs.length === 1 ? readStringValue((tabs[0] as { targetId?: unknown } | undefined)?.targetId) @@ -520,6 +535,7 @@ export async function executeActAction(params: { }) : await browserToolActionDeps.browserAct(baseUrl, retryRequest, { profile, + signal: params.signal, }); return await finishActResult( retryResult, diff --git a/extensions/browser/src/browser-tool.snapshot.ts b/extensions/browser/src/browser-tool.snapshot.ts index bf17998ea1fe..690298edeb67 100644 --- a/extensions/browser/src/browser-tool.snapshot.ts +++ b/extensions/browser/src/browser-tool.snapshot.ts @@ -90,6 +90,7 @@ export async function executeSnapshotAction(params: { baseUrl?: string; profile?: string; proxyRequest: BrowserProxyRequest | null; + signal?: AbortSignal; onTabActivity?: (targetId: string | undefined) => void; }): Promise> { const { input, baseUrl, profile, proxyRequest } = params; @@ -168,6 +169,7 @@ export async function executeSnapshotAction(params: { : await browserSnapshot(baseUrl, { ...query, profile, + signal: params.signal, }); let snapshot: Awaited>; try { @@ -308,6 +310,7 @@ export async function appendNavigatedPageState(params: { baseUrl?: string; profile?: string; proxyRequest: BrowserProxyRequest | null; + signal?: AbortSignal; }): Promise> { const hostFallbackWasActive = params.proxyRequest?.isHostFallbackActive?.() ?? false; let snapshot: AgentToolResult; @@ -317,11 +320,13 @@ export async function appendNavigatedPageState(params: { baseUrl: params.baseUrl, profile: params.profile, proxyRequest: params.proxyRequest, + signal: params.signal, }); } catch (err) { // Cancellation must keep aborting the whole tool call; only genuine // snapshot failures degrade, because page state is feedback on an // already-successful mutation and must not fail the action. + params.signal?.throwIfAborted(); if (err instanceof Error && err.name === "AbortError") { throw err; } diff --git a/extensions/browser/src/browser-tool.test.ts b/extensions/browser/src/browser-tool.test.ts index 94b7e5ae5c71..5ea54eca4b33 100644 --- a/extensions/browser/src/browser-tool.test.ts +++ b/extensions/browser/src/browser-tool.test.ts @@ -792,6 +792,20 @@ describe("browser tool snapshot maxChars", () => { expect(opts.timeoutMs).toBeUndefined(); }); + it("preserves cancellation while listing host system profiles", async () => { + const controller = new AbortController(); + const abortError = new Error("agent turn cancelled"); + browserClientMocks.browserSystemProfiles.mockImplementationOnce(async () => { + controller.abort(abortError); + throw abortError; + }); + + await expect( + createBrowserTool().execute?.("call-1", { action: "profiles" }, controller.signal), + ).rejects.toBe(abortError); + expect(browserClientMocks.browserProfiles).not.toHaveBeenCalled(); + }); + it("uses a longer default timeout for existing-session profile status through node proxy", async () => { mockSingleBrowserProxyNode(); setResolvedBrowserProfiles({ @@ -1251,6 +1265,7 @@ describe("browser tool snapshot maxChars", () => { it("compensates durable tracking failure on the automatic host fallback", async () => { const trackingError = new Error("sqlite unavailable"); + const controller = new AbortController(); mockSingleBrowserProxyNode(); gatewayMocks.callGatewayTool.mockRejectedValueOnce( new Error("Browser control host is not reachable on 127.0.0.1:18791."), @@ -1267,25 +1282,36 @@ describe("browser tool snapshot maxChars", () => { }, }); sessionTabRegistryMocks.trackSessionBrowserTab.mockImplementationOnce(() => { + controller.abort(new Error("agent turn cancelled")); throw trackingError; }); const tool = createBrowserTool({ agentSessionKey: "agent:main:main" }); await expect( - tool.execute?.("call-1", { - action: "open", - profile: "work", - url: "https://example.com", - }), + tool.execute?.( + "call-1", + { + action: "open", + profile: "work", + url: "https://example.com", + }, + controller.signal, + ), ).rejects.toBe(trackingError); - expect(toolCommonMocks.fetchBrowserJson).toHaveBeenLastCalledWith( - "/tabs/host-tab-compensate?profile=work-actual", + expect(browserClientMocks.browserCloseTab).toHaveBeenCalledWith( + undefined, + "host-tab-compensate", { - method: "DELETE", - body: undefined, + profile: "work-actual", timeoutMs: undefined, }, ); + expect(toolCommonMocks.fetchBrowserJson).toHaveBeenLastCalledWith("/tabs/open?profile=work", { + method: "POST", + body: JSON.stringify({ url: "https://example.com" }), + timeoutMs: undefined, + signal: controller.signal, + }); }); it("touches tabs used after automatic host fallback", async () => { @@ -1521,6 +1547,92 @@ describe("browser tool snapshot maxChars", () => { expect(opts.timeoutMs).toBe(12_345); }); + it.each([ + ["doctor", { action: "doctor", target: "host" }, browserClientMocks.browserDoctor, 1], + ["status", { action: "status", target: "host" }, browserClientMocks.browserStatus, 1], + ["start", { action: "start", target: "host" }, browserClientMocks.browserStart, 1], + ["stop", { action: "stop", target: "host" }, browserClientMocks.browserStop, 1], + ["profiles", { action: "profiles", target: "host" }, browserClientMocks.browserProfiles, 1], + [ + "importprofile", + { action: "importprofile", target: "host" }, + browserClientMocks.browserImportProfile, + 1, + ], + ["tabs", { action: "tabs", target: "host" }, browserClientMocks.browserTabs, 1], + [ + "open", + { action: "open", target: "host", url: "about:blank" }, + browserClientMocks.browserOpenTab, + 2, + ], + [ + "focus", + { action: "focus", target: "host", targetId: "tab-1" }, + browserClientMocks.browserFocusTab, + 2, + ], + [ + "close", + { action: "close", target: "host", targetId: "tab-1" }, + browserClientMocks.browserCloseTab, + 2, + ], + ["snapshot", { action: "snapshot", target: "host" }, browserClientMocks.browserSnapshot, 1], + [ + "screenshot", + { action: "screenshot", target: "host" }, + browserActionsMocks.browserScreenshotAction, + 1, + ], + [ + "navigate", + { action: "navigate", target: "host", url: "about:blank" }, + browserActionsMocks.browserNavigate, + 1, + ], + ["pdf", { action: "pdf", target: "host" }, browserActionsMocks.browserPdfSave, 1], + [ + "upload", + { action: "upload", target: "host", paths: ["/tmp/report.pdf"] }, + browserActionsMocks.browserArmFileChooser, + 1, + ], + [ + "dialog", + { action: "dialog", target: "host", accept: true }, + browserActionsMocks.browserArmDialog, + 1, + ], + [ + "console", + { action: "console", target: "host" }, + browserActionsMocks.browserConsoleMessages, + 1, + ], + [ + "act", + { action: "act", target: "host", request: { kind: "click", ref: "e1" } }, + browserActionsMocks.browserAct, + 2, + ], + ] as const)( + "forwards the agent signal to local %s actions", + async (_name, args, mock, optionsIndex) => { + const controller = new AbortController(); + pathValidationMocks.resolveExistingUploadPaths.mockResolvedValue({ + ok: true, + paths: ["/tmp/report.pdf"], + }); + + await createBrowserTool().execute?.("call-1", args, controller.signal); + + expect(lastMockCallArg<{ signal?: AbortSignal }>(mock, optionsIndex).signal).toBe( + controller.signal, + ); + }, + ); + it("parses string screenshot timeoutMs values", async () => { const tool = createBrowserTool(); await tool.execute?.("call-1", { @@ -2679,14 +2791,21 @@ describe("browser tool url alias support", () => { targetId: "nav-tab", url: "https://example.com/next", }); - const abortError = new Error("This operation was aborted"); - abortError.name = "AbortError"; - browserClientMocks.browserSnapshot.mockRejectedValueOnce(abortError); + const controller = new AbortController(); + const abortError = new Error("agent turn cancelled"); + browserClientMocks.browserSnapshot.mockImplementationOnce(async () => { + controller.abort(abortError); + throw abortError; + }); const tool = createBrowserTool(); await expect( - tool.execute?.("call-1", { action: "navigate", url: "https://example.com/next" }), - ).rejects.toMatchObject({ name: "AbortError" }); + tool.execute?.( + "call-1", + { action: "navigate", url: "https://example.com/next" }, + controller.signal, + ), + ).rejects.toBe(abortError); }); it("degrades inline page state when the node proxy falls back to the host mid-call", async () => { @@ -3736,6 +3855,29 @@ describe("browser tool act stale target recovery", () => { expect(browserActionsMocks.browserAct).toHaveBeenCalledTimes(2); }); + it("preserves cancellation while refreshing a stale target", async () => { + const controller = new AbortController(); + const abortError = new Error("agent turn cancelled"); + browserActionsMocks.browserAct.mockRejectedValueOnce(new Error("404: tab not found")); + browserClientMocks.browserTabs.mockImplementationOnce(async () => { + controller.abort(abortError); + throw abortError; + }); + const tool = createBrowserTool(); + + await expect( + tool.execute?.( + "call-1", + { + action: "act", + profile: "user", + request: { kind: "wait", targetId: "stale-tab", timeMs: 1 }, + }, + controller.signal, + ), + ).rejects.toBe(abortError); + }); + it("retries stale targetIds returned through the node browser proxy", async () => { mockSingleBrowserProxyNode(); setResolvedBrowserProfiles({ diff --git a/extensions/browser/src/browser-tool.ts b/extensions/browser/src/browser-tool.ts index 6bffa7beb28e..18f4e579de81 100644 --- a/extensions/browser/src/browser-tool.ts +++ b/extensions/browser/src/browser-tool.ts @@ -287,6 +287,7 @@ async function readHostSystemProfiles(params: { allowHostControl?: boolean; sandboxBridgeUrl?: string; timeoutMs?: number; + signal?: AbortSignal; }) { if (params.allowHostControl === false) { return []; @@ -302,8 +303,11 @@ async function readHostSystemProfiles(params: { return []; } return await browserToolDeps - .browserSystemProfiles(hostBaseUrl, { timeoutMs: params.timeoutMs }) - .catch(() => []); + .browserSystemProfiles(hostBaseUrl, { timeoutMs: params.timeoutMs, signal: params.signal }) + .catch(() => { + params.signal?.throwIfAborted(); + return []; + }); } function shouldPreferHostForProfile(profileName: string | undefined) { @@ -496,7 +500,11 @@ export function createBrowserTool(opts?: { profile, timeoutMs: toolTimeoutMs, }) - : await browserToolDeps.browserStatus(baseUrl, { profile, timeoutMs: toolTimeoutMs }); + : await browserToolDeps.browserStatus(baseUrl, { + profile, + timeoutMs: toolTimeoutMs, + signal, + }); const executeTrackedTabRequest = async ( path: string, body: Record, @@ -517,7 +525,7 @@ export function createBrowserTool(opts?: { return jsonResult( proxyRequest ? await proxyRequest({ method: "GET", path: "/doctor", profile }) - : await browserToolDeps.browserDoctor(baseUrl, { profile }), + : await browserToolDeps.browserDoctor(baseUrl, { profile, signal }), ); case "status": return jsonResult(await readBrowserStatus()); @@ -533,7 +541,7 @@ export function createBrowserTool(opts?: { } else { const updateBrowser = action === "start" ? browserToolDeps.browserStart : browserToolDeps.browserStop; - await updateBrowser(baseUrl, { profile, timeoutMs: toolTimeoutMs }); + await updateBrowser(baseUrl, { profile, timeoutMs: toolTimeoutMs, signal }); } return jsonResult(await readBrowserStatus()); } @@ -545,6 +553,7 @@ export function createBrowserTool(opts?: { allowHostControl: opts?.allowHostControl, sandboxBridgeUrl: opts?.sandboxBridgeUrl, timeoutMs: toolTimeoutMs, + signal, }); if (proxyRequest) { const result = await proxyRequest({ @@ -558,7 +567,10 @@ export function createBrowserTool(opts?: { }); } return jsonResult({ - profiles: await browserToolDeps.browserProfiles(baseUrl, { timeoutMs: toolTimeoutMs }), + profiles: await browserToolDeps.browserProfiles(baseUrl, { + timeoutMs: toolTimeoutMs, + signal, + }), systemProfiles, }); } @@ -573,6 +585,7 @@ export function createBrowserTool(opts?: { systemProfile: normalizeOptionalString(params.systemProfile) ?? "Default", into: normalizeOptionalString(params.into) ?? "imported", domains, + signal, }), ); } @@ -583,6 +596,7 @@ export function createBrowserTool(opts?: { timeoutMs: toolTimeoutMs, proxyRequest, targetId: bindingResult?.ok ? bindingResult.binding.targetId : undefined, + signal, }); case "open": { const targetUrl = readTargetUrlParam(params); @@ -599,21 +613,13 @@ export function createBrowserTool(opts?: { profile, label, timeoutMs: toolTimeoutMs, + signal, }); const closeOpenedTab = async (targetId: string, openedProfile?: string) => { - if (proxyRequest) { - await proxyRequest({ - method: "DELETE", - path: `/tabs/${encodeURIComponent(targetId)}`, - profile: openedProfile, - timeoutMs: toolTimeoutMs, - }); - } else { - await browserToolDeps.browserCloseTab(baseUrl, targetId, { - profile: openedProfile, - timeoutMs: toolTimeoutMs, - }); - } + await browserToolDeps.browserCloseTab(baseUrl, targetId, { + profile: openedProfile, + timeoutMs: toolTimeoutMs, + }); }; await sessionTabs.trackOpened(opened, closeOpenedTab); return formatBrowserExternalToolResult({ @@ -636,6 +642,7 @@ export function createBrowserTool(opts?: { : await browserToolDeps.browserFocusTab(baseUrl, targetId, { profile, timeoutMs: toolTimeoutMs, + signal, }); sessionTabs.touch( readStringValue((result as { targetId?: unknown }).targetId) ?? targetId, @@ -668,6 +675,7 @@ export function createBrowserTool(opts?: { await browserToolDeps.browserCloseTab(baseUrl, targetId, { profile, timeoutMs: toolTimeoutMs, + signal, }); sessionTabs.untrack(targetId); } else { @@ -677,6 +685,7 @@ export function createBrowserTool(opts?: { { profile, timeoutMs: toolTimeoutMs, + signal, }, ); sessionTabs.untrack(readStringValue(result.targetId)); @@ -689,6 +698,7 @@ export function createBrowserTool(opts?: { baseUrl, profile, proxyRequest, + signal, onTabActivity: sessionTabs.touch, }); case "screenshot": { @@ -724,6 +734,7 @@ export function createBrowserTool(opts?: { labels, timeoutMs: effectiveTimeoutMs, profile, + signal, }); sessionTabs.touch(readStringValue(result.targetId) ?? targetId); if (opts?.screenshotResultMode === "path") { @@ -873,6 +884,7 @@ export function createBrowserTool(opts?: { targetId, timeoutMs, profile, + signal, }); const navigatedTargetId = readStringValue((result as { targetId?: unknown }).targetId) ?? targetId; @@ -892,6 +904,7 @@ export function createBrowserTool(opts?: { baseUrl, profile, proxyRequest, + signal, }); } case "console": { @@ -900,6 +913,7 @@ export function createBrowserTool(opts?: { baseUrl, profile, proxyRequest, + signal, }); const targetId = readStringParam(params, "targetId"); const canonicalTargetId = readStringValue( @@ -917,7 +931,7 @@ export function createBrowserTool(opts?: { profile, body: { targetId }, })) as Awaited>) - : await browserToolDeps.browserPdfSave(baseUrl, { targetId, profile }); + : await browserToolDeps.browserPdfSave(baseUrl, { targetId, profile, signal }); sessionTabs.touch(readStringValue(result.targetId) ?? targetId); return { content: [{ type: "text" as const, text: `FILE:${result.path}` }], @@ -961,7 +975,7 @@ export function createBrowserTool(opts?: { "/hooks/file-chooser", request, async () => - await browserToolDeps.browserArmFileChooser(baseUrl, { ...request, profile }), + await browserToolDeps.browserArmFileChooser(baseUrl, { ...request, profile, signal }), ); } case "dialog": { @@ -973,7 +987,8 @@ export function createBrowserTool(opts?: { return await executeTrackedTabRequest( "/hooks/dialog", request, - async () => await browserToolDeps.browserArmDialog(baseUrl, { ...request, profile }), + async () => + await browserToolDeps.browserArmDialog(baseUrl, { ...request, profile, signal }), ); } case "act": { @@ -986,6 +1001,7 @@ export function createBrowserTool(opts?: { baseUrl, profile, proxyRequest, + signal, onTabActivity: sessionTabs.touch, onTabClose: sessionTabs.untrack, }); diff --git a/extensions/browser/src/browser/client-actions-core.cancel.test.ts b/extensions/browser/src/browser/client-actions-core.cancel.test.ts new file mode 100644 index 000000000000..c72137e0197f --- /dev/null +++ b/extensions/browser/src/browser/client-actions-core.cancel.test.ts @@ -0,0 +1,90 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const clientFetchMocks = vi.hoisted(() => ({ + fetchBrowserJson: vi.fn( + async (_url: string, init?: RequestInit): Promise> => + await new Promise((_resolve, reject) => { + const signal = init?.signal; + if (!signal) { + reject(new Error("missing agent abort signal")); + return; + } + const onAbort = () => + reject(signal.reason instanceof Error ? signal.reason : new Error("aborted")); + if (signal?.aborted) { + onAbort(); + return; + } + signal?.addEventListener("abort", onAbort, { once: true }); + }), + ), +})); + +vi.mock("./client-fetch.js", () => clientFetchMocks); + +import { + browserAct, + browserArmDialog, + browserArmFileChooser, + browserNavigate, + browserScreenshotAction, +} from "./client-actions-core.js"; +import { browserConsoleMessages, browserPdfSave } from "./client-actions-observe.js"; +import { + browserCloseTab, + browserDoctor, + browserFocusTab, + browserImportProfile, + browserOpenTab, + browserProfiles, + browserSnapshot, + browserStart, + browserStatus, + browserStop, + browserSystemProfiles, + browserTabs, +} from "./client.js"; + +describe("local browser action cancellation", () => { + beforeEach(() => vi.clearAllMocks()); + + it.each([ + ["doctor", (signal: AbortSignal) => browserDoctor(undefined, { signal })], + ["status", (signal: AbortSignal) => browserStatus(undefined, { signal })], + ["start", (signal: AbortSignal) => browserStart(undefined, { signal })], + ["stop", (signal: AbortSignal) => browserStop(undefined, { signal })], + ["profiles", (signal: AbortSignal) => browserProfiles(undefined, { signal })], + ["system profiles", (signal: AbortSignal) => browserSystemProfiles(undefined, { signal })], + ["import profile", (signal: AbortSignal) => browserImportProfile(undefined, { signal })], + ["tabs", (signal: AbortSignal) => browserTabs(undefined, { signal })], + ["open", (signal: AbortSignal) => browserOpenTab(undefined, "about:blank", { signal })], + ["focus", (signal: AbortSignal) => browserFocusTab(undefined, "tab-1", { signal })], + ["close", (signal: AbortSignal) => browserCloseTab(undefined, "tab-1", { signal })], + ["snapshot", (signal: AbortSignal) => browserSnapshot(undefined, { signal })], + [ + "navigate", + (signal: AbortSignal) => browserNavigate(undefined, { url: "about:blank", signal }), + ], + ["screenshot", (signal: AbortSignal) => browserScreenshotAction(undefined, { signal })], + ["pdf", (signal: AbortSignal) => browserPdfSave(undefined, { signal })], + ["upload", (signal: AbortSignal) => browserArmFileChooser(undefined, { paths: ["a"], signal })], + ["dialog", (signal: AbortSignal) => browserArmDialog(undefined, { accept: true, signal })], + ["console", (signal: AbortSignal) => browserConsoleMessages(undefined, { signal })], + [ + "act", + (signal: AbortSignal) => browserAct(undefined, { kind: "click", ref: "e1" }, { signal }), + ], + ] as const)("promptly cancels an in-flight %s transport", async (_name, run) => { + const controller = new AbortController(); + const reason = new Error("agent turn cancelled"); + const pending = run(controller.signal); + + await vi.waitFor(() => expect(clientFetchMocks.fetchBrowserJson).toHaveBeenCalledOnce()); + controller.abort(reason); + + await expect(pending).rejects.toBe(reason); + expect(clientFetchMocks.fetchBrowserJson.mock.calls[0]?.[1]).toMatchObject({ + signal: controller.signal, + }); + }); +}); diff --git a/extensions/browser/src/browser/client-actions-core.ts b/extensions/browser/src/browser/client-actions-core.ts index 9c2278f22eea..bbb4de5e2d39 100644 --- a/extensions/browser/src/browser/client-actions-core.ts +++ b/extensions/browser/src/browser/client-actions-core.ts @@ -84,6 +84,7 @@ export async function browserNavigate( targetId?: string; timeoutMs?: number; profile?: string; + signal?: AbortSignal; }, ): Promise { const q = buildProfileQuery(opts.profile); @@ -95,6 +96,7 @@ export async function browserNavigate( body: JSON.stringify({ url: opts.url, targetId: opts.targetId, timeoutMs }), timeoutMs: timeoutMs === undefined ? 20_000 : resolveBrowserOperationRequestTimeoutMs(timeoutMs), + signal: opts.signal, }); } @@ -108,6 +110,7 @@ export async function browserArmDialog( targetId?: string; timeoutMs?: number; profile?: string; + signal?: AbortSignal; }, ): Promise { const q = buildProfileQuery(opts.profile); @@ -122,6 +125,7 @@ export async function browserArmDialog( timeoutMs: opts.timeoutMs, }), timeoutMs: resolveBrowserOperationRequestTimeoutMs(opts.timeoutMs), + signal: opts.signal, }); } @@ -136,6 +140,7 @@ export async function browserArmFileChooser( targetId?: string; timeoutMs?: number; profile?: string; + signal?: AbortSignal; }, ): Promise { const q = buildProfileQuery(opts.profile); @@ -151,6 +156,7 @@ export async function browserArmFileChooser( timeoutMs: opts.timeoutMs, }), timeoutMs: resolveBrowserOperationRequestTimeoutMs(opts.timeoutMs), + signal: opts.signal, }); } @@ -210,7 +216,7 @@ export async function browserDownload( export async function browserAct( baseUrl: string | undefined, req: BrowserActRequest, - opts?: { profile?: string; timeoutMs?: number }, + opts?: { profile?: string; timeoutMs?: number; signal?: AbortSignal }, ): Promise { const q = buildProfileQuery(opts?.profile); return await fetchBrowserJson(withBaseUrl(baseUrl, `/act${q}`), { @@ -218,6 +224,7 @@ export async function browserAct( headers: { "Content-Type": "application/json" }, body: JSON.stringify(req), timeoutMs: resolveTimerTimeoutMs(opts?.timeoutMs, resolveBrowserActRequestTimeoutMs(req)), + signal: opts?.signal, }); } @@ -233,6 +240,7 @@ export async function browserScreenshotAction( labels?: boolean; timeoutMs?: number; profile?: string; + signal?: AbortSignal; }, ): Promise { const q = buildProfileQuery(opts.profile); @@ -251,5 +259,6 @@ export async function browserScreenshotAction( timeoutMs: effectiveTimeoutMs, }), timeoutMs: effectiveTimeoutMs, + signal: opts.signal, }); } diff --git a/extensions/browser/src/browser/client-actions-observe.ts b/extensions/browser/src/browser/client-actions-observe.ts index 48d965643b9e..e8a4f5720ecf 100644 --- a/extensions/browser/src/browser/client-actions-observe.ts +++ b/extensions/browser/src/browser/client-actions-observe.ts @@ -27,7 +27,7 @@ function buildQuerySuffix(params: Array<[string, string | boolean | undefined]>) /** Read browser console messages for a tab. */ export async function browserConsoleMessages( baseUrl: string | undefined, - opts: { level?: string; targetId?: string; profile?: string } = {}, + opts: { level?: string; targetId?: string; profile?: string; signal?: AbortSignal } = {}, ): Promise<{ ok: true; messages: BrowserConsoleMessage[]; targetId: string; url?: string }> { const suffix = buildQuerySuffix([ ["level", opts.level], @@ -39,13 +39,13 @@ export async function browserConsoleMessages( messages: BrowserConsoleMessage[]; targetId: string; url?: string; - }>(withBaseUrl(baseUrl, `/console${suffix}`), { timeoutMs: 20000 }); + }>(withBaseUrl(baseUrl, `/console${suffix}`), { timeoutMs: 20000, signal: opts.signal }); } /** Save the current page as PDF through browser control. */ export async function browserPdfSave( baseUrl: string | undefined, - opts: { targetId?: string; profile?: string } = {}, + opts: { targetId?: string; profile?: string; signal?: AbortSignal } = {}, ): Promise { const q = buildProfileQuery(opts.profile); return await fetchBrowserJson(withBaseUrl(baseUrl, `/pdf${q}`), { @@ -53,5 +53,6 @@ export async function browserPdfSave( headers: { "Content-Type": "application/json" }, body: JSON.stringify({ targetId: opts.targetId }), timeoutMs: 20000, + signal: opts.signal, }); } diff --git a/extensions/browser/src/browser/client.ts b/extensions/browser/src/browser/client.ts index 8011d01ad9b6..08193cb02c13 100644 --- a/extensions/browser/src/browser/client.ts +++ b/extensions/browser/src/browser/client.ts @@ -31,6 +31,7 @@ const JSON_HEADERS = { "Content-Type": "application/json" }; type BrowserClientTimeoutOptions = { timeoutMs?: number; + signal?: AbortSignal; }; type BrowserClientProfileOptions = BrowserClientTimeoutOptions & { @@ -62,6 +63,7 @@ async function sendProfilePost( await fetchBrowserJson(withProfilePath(baseUrl, path, opts?.profile), { method: "POST", timeoutMs: resolveBrowserClientTimeoutMs(opts, fallbackTimeoutMs), + signal: opts?.signal, }); } @@ -78,6 +80,7 @@ async function sendTabTargetRequest(params: { method: params.method, ...(params.body ? { headers: JSON_HEADERS, body: JSON.stringify(params.body) } : {}), timeoutMs: resolveBrowserClientTimeoutMs(params.opts, 5000), + signal: params.opts?.signal, }, ); } @@ -166,17 +169,18 @@ export type SnapshotResult = /** Read browser-control status for the selected profile. */ export async function browserStatus( baseUrl?: string, - opts?: { profile?: string; timeoutMs?: number }, + opts?: BrowserClientProfileOptions, ): Promise { return await fetchBrowserJson(withProfilePath(baseUrl, "/", opts?.profile), { timeoutMs: resolveBrowserClientTimeoutMs(opts, BROWSER_STATUS_REQUEST_TIMEOUT_MS), + signal: opts?.signal, }); } /** Run browser doctor checks for the selected profile. */ export async function browserDoctor( baseUrl?: string, - opts?: { profile?: string; deep?: boolean }, + opts?: { profile?: string; deep?: boolean; signal?: AbortSignal }, ): Promise { const params = new URLSearchParams(); if (opts?.profile) { @@ -190,18 +194,20 @@ export async function browserDoctor( timeoutMs: opts?.deep ? BROWSER_DEEP_DOCTOR_REQUEST_TIMEOUT_MS : BROWSER_DOCTOR_REQUEST_TIMEOUT_MS, + signal: opts?.signal, }); } /** List configured browser profiles and their current status. */ export async function browserProfiles( baseUrl?: string, - opts?: { timeoutMs?: number }, + opts?: BrowserClientTimeoutOptions, ): Promise { const res = await fetchBrowserJson<{ profiles: ProfileStatus[] }>( withBaseUrl(baseUrl, `/profiles`), { timeoutMs: resolveBrowserClientTimeoutMs(opts, 3000), + signal: opts?.signal, }, ); return res.profiles ?? []; @@ -210,12 +216,12 @@ export async function browserProfiles( /** List Chrome-family profiles available on the local macOS host. */ export async function browserSystemProfiles( baseUrl?: string, - opts?: { browser?: string; timeoutMs?: number }, + opts?: { browser?: string; timeoutMs?: number; signal?: AbortSignal }, ): Promise { const query = opts?.browser ? `?browser=${encodeURIComponent(opts.browser)}` : ""; const res = await fetchBrowserJson<{ systemProfiles: SystemProfileInfo[] }>( withBaseUrl(baseUrl, `/system-profiles${query}`), - { timeoutMs: resolveBrowserClientTimeoutMs(opts, 3000) }, + { timeoutMs: resolveBrowserClientTimeoutMs(opts, 3000), signal: opts?.signal }, ); return res.systemProfiles ?? []; } @@ -223,15 +229,27 @@ export async function browserSystemProfiles( /** Import system-profile cookies into a managed browser profile. */ export async function browserImportProfile( baseUrl: string | undefined, - opts: { browser?: string; systemProfile?: string; into?: string; domains?: string[] }, + opts: { + browser?: string; + systemProfile?: string; + into?: string; + domains?: string[]; + signal?: AbortSignal; + }, ): Promise { return await fetchBrowserJson( withBaseUrl(baseUrl, "/profiles/import"), { method: "POST", headers: JSON_HEADERS, - body: JSON.stringify(opts), + body: JSON.stringify({ + browser: opts.browser, + systemProfile: opts.systemProfile, + into: opts.into, + domains: opts.domains, + }), timeoutMs: 120_000, + signal: opts.signal, }, ); } @@ -239,7 +257,7 @@ export async function browserImportProfile( /** Start the selected browser profile. */ export async function browserStart( baseUrl?: string, - opts?: { profile?: string; timeoutMs?: number }, + opts?: BrowserClientProfileOptions, ): Promise { await sendProfilePost(baseUrl, "/start", opts, 15000); } @@ -247,7 +265,7 @@ export async function browserStart( /** Stop the selected browser profile. */ export async function browserStop( baseUrl?: string, - opts?: { profile?: string; timeoutMs?: number }, + opts?: BrowserClientProfileOptions, ): Promise { await sendProfilePost(baseUrl, "/stop", opts, 15000); } @@ -331,12 +349,13 @@ export async function browserDeleteProfile( /** List tabs for the selected browser profile. */ export async function browserTabs( baseUrl?: string, - opts?: { profile?: string; timeoutMs?: number }, + opts?: BrowserClientProfileOptions, ): Promise { const res = await fetchBrowserJson<{ running: boolean; tabs: BrowserTab[] }>( withProfilePath(baseUrl, "/tabs", opts?.profile), { timeoutMs: resolveBrowserClientTimeoutMs(opts, 3000), + signal: opts?.signal, }, ); return res.tabs ?? []; @@ -346,7 +365,7 @@ export async function browserTabs( export async function browserOpenTab( baseUrl: string | undefined, url: string, - opts?: { profile?: string; label?: string; timeoutMs?: number }, + opts?: { profile?: string; label?: string; timeoutMs?: number; signal?: AbortSignal }, ): Promise { return await fetchBrowserJson( withProfilePath(baseUrl, "/tabs/open", opts?.profile), @@ -355,6 +374,7 @@ export async function browserOpenTab( headers: JSON_HEADERS, body: JSON.stringify({ url, ...(opts?.label ? { label: opts.label } : {}) }), timeoutMs: resolveBrowserClientTimeoutMs(opts, 15000), + signal: opts?.signal, }, ); } @@ -363,7 +383,7 @@ export async function browserOpenTab( export async function browserFocusTab( baseUrl: string | undefined, targetId: string, - opts?: { profile?: string; timeoutMs?: number }, + opts?: BrowserClientProfileOptions, ): Promise<{ ok: true; targetId?: string }> { const body = { targetId }; return await sendTabTargetRequest({ baseUrl, path: "/tabs/focus", method: "POST", opts, body }); @@ -373,7 +393,7 @@ export async function browserFocusTab( export async function browserCloseTab( baseUrl: string | undefined, targetId: string, - opts?: { profile?: string; timeoutMs?: number }, + opts?: BrowserClientProfileOptions, ): Promise { const path = `/tabs/${encodeURIComponent(targetId)}`; await sendTabTargetRequest({ baseUrl, path, method: "DELETE", opts }); @@ -383,7 +403,7 @@ export async function browserCloseTab( export async function browserCloseTabByRawTargetId( baseUrl: string | undefined, targetId: string, - opts?: { profile?: string; timeoutMs?: number }, + opts?: BrowserClientProfileOptions, ): Promise { const path = `/tabs/${encodeURIComponent(targetId)}?targetIdMode=raw`; await sendTabTargetRequest({ baseUrl, path, method: "DELETE", opts }); @@ -429,6 +449,7 @@ export async function browserSnapshot( mode?: "efficient"; profile?: string; timeoutMs?: number; + signal?: AbortSignal; }, ): Promise { const q = new URLSearchParams(); @@ -479,6 +500,7 @@ export async function browserSnapshot( q.set("timeoutMs", String(resolvedTimeoutMs)); return await fetchBrowserJson(withBaseUrl(baseUrl, `/snapshot?${q.toString()}`), { timeoutMs: resolvedTimeoutMs, + signal: opts.signal, }); }