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 <amp@ampcode.com>
This commit is contained in:
Peter Steinberger
2026-08-16 14:03:43 -07:00
committed by GitHub
parent a948419141
commit d39a29fcbe
8 changed files with 359 additions and 58 deletions
+20 -4
View File
@@ -269,6 +269,7 @@ export async function executeTabsAction(params: {
timeoutMs?: number;
proxyRequest: BrowserProxyRequest | null;
targetId?: string;
signal?: AbortSignal;
}): Promise<AgentToolResult<unknown>> {
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<AgentToolResult<unknown>> {
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<AgentToolResult<unknown>> {
@@ -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,
@@ -90,6 +90,7 @@ export async function executeSnapshotAction(params: {
baseUrl?: string;
profile?: string;
proxyRequest: BrowserProxyRequest | null;
signal?: AbortSignal;
onTabActivity?: (targetId: string | undefined) => void;
}): Promise<AgentToolResult<unknown>> {
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<ReturnType<typeof browserSnapshot>>;
try {
@@ -308,6 +310,7 @@ export async function appendNavigatedPageState(params: {
baseUrl?: string;
profile?: string;
proxyRequest: BrowserProxyRequest | null;
signal?: AbortSignal;
}): Promise<AgentToolResult<unknown>> {
const hostFallbackWasActive = params.proxyRequest?.isHostFallbackActive?.() ?? false;
let snapshot: AgentToolResult<unknown>;
@@ -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;
}
+156 -14
View File
@@ -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({
+38 -22
View File
@@ -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<string, unknown>,
@@ -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<ReturnType<typeof browserPdfSave>>)
: 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,
});
@@ -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<Record<string, unknown>> =>
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,
});
});
});
@@ -84,6 +84,7 @@ export async function browserNavigate(
targetId?: string;
timeoutMs?: number;
profile?: string;
signal?: AbortSignal;
},
): Promise<BrowserActionTabResult> {
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<BrowserActionOk> {
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<BrowserActionOk> {
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<BrowserActResponse> {
const q = buildProfileQuery(opts?.profile);
return await fetchBrowserJson<BrowserActResponse>(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<BrowserActionPathResult> {
const q = buildProfileQuery(opts.profile);
@@ -251,5 +259,6 @@ export async function browserScreenshotAction(
timeoutMs: effectiveTimeoutMs,
}),
timeoutMs: effectiveTimeoutMs,
signal: opts.signal,
});
}
@@ -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<BrowserActionPathResult> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionPathResult>(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,
});
}
+36 -14
View File
@@ -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<BrowserStatus> {
return await fetchBrowserJson<BrowserStatus>(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<BrowserDoctorReport> {
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<ProfileStatus[]> {
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<SystemProfileInfo[]> {
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<BrowserImportProfileResult> {
return await fetchBrowserJson<BrowserImportProfileResult>(
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<void> {
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<void> {
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<BrowserTab[]> {
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<BrowserOpenResult> {
return await fetchBrowserJson<BrowserOpenResult>(
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<void> {
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<void> {
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<SnapshotResult> {
const q = new URLSearchParams();
@@ -479,6 +500,7 @@ export async function browserSnapshot(
q.set("timeoutMs", String(resolvedTimeoutMs));
return await fetchBrowserJson<SnapshotResult>(withBaseUrl(baseUrl, `/snapshot?${q.toString()}`), {
timeoutMs: resolvedTimeoutMs,
signal: opts.signal,
});
}