mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
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:
committed by
GitHub
parent
a948419141
commit
d39a29fcbe
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user