Q98: external calls have a time limit and stop with the run

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-08 16:12:45 -04:00
1 parent 989127bed3
commit 552dfcb6ce
4 files changed
+113 -28

No files matched your search

+40 -1
View File
@@ -1,7 +1,8 @@
import { randomUUID } from "node:crypto";
import { describe, expect, test } from "vitest";
import { describe, expect, test, vi } from "vitest";
import { executeWebTool, htmlToText, isWebTool, parseSearchResults, WEB_TOOLS, type WebFetcher } from "../../src/main/services/agent-web-tools";
import type { ToolCall } from "../../src/main/services/provider-tool-turn";
import { stopRun, withRunControl } from "../../src/main/services/run-control";
function call(name: string, args: Record<string, unknown>): ToolCall {
return { id: `call_${randomUUID()}`, name, arguments: args };
@@ -62,6 +63,44 @@ describe("agent web tools", () => {
expect(output).toContain("status 200");
});
/** A page that sends its first bytes and then never finishes, until the request is aborted. */
const tricklingFetcher: WebFetcher = async (_url, init) => new Response(new ReadableStream({
start(stream) {
stream.enqueue(new TextEncoder().encode("<p>first"));
init.signal.addEventListener("abort", () => stream.error(init.signal.reason));
},
}), { status: 200, headers: { "content-type": "text/html" } });
test("a page that never finishes is given up after the time limit", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
try {
const outcome = executeWebTool(call("fetch_url", { url: "http://93.184.216.34/slow" }), tricklingFetcher);
await vi.advanceTimersByTimeAsync(30_000);
expect(await outcome).toBe("ERROR: Fetching 93.184.216.34 failed: no answer within 30s.");
} finally {
vi.useRealTimers();
}
});
test("a redirect whose body never finishes is still followed", async () => {
const requested: string[] = [];
const fetcher: WebFetcher = async (url, init) => {
requested.push(url);
if (requested.length === 2) return new Response("<p>landed</p>", { status: 200, headers: { "content-type": "text/html" } });
const stalled = await tricklingFetcher(url, init);
return new Response(stalled.body, { status: 302, headers: { location: "http://93.184.216.35/final" } });
};
expect(await executeWebTool(call("fetch_url", { url: "http://93.184.216.34/start" }), fetcher)).toContain("landed");
});
test("stopping the run stops a fetch in progress", async () => {
const runId = `run_${randomUUID()}`;
const outcome = withRunControl(runId, () => executeWebTool(call("fetch_url", { url: "http://93.184.216.34/slow" }), tricklingFetcher));
await new Promise((resolve) => setTimeout(resolve, 20));
stopRun(runId);
expect(await outcome).toBe("ERROR: Fetching 93.184.216.34 failed: Stopped by the operator.");
});
test("a page or search answered with an error status is a failed call, not data", async () => {
const missing = await executeWebTool(call("fetch_url", { url: "http://93.184.216.34/gone" }), fetcherReturning([{ status: 404, body: "<h1>Not Found</h1>" }]).fetcher);
expect(missing).toBe("ERROR: http://93.184.216.34/gone answered with status 404: Not Found");
+20 -1
View File
@@ -8,6 +8,7 @@ const requested: string[] = [];
const written: string[] = [];
const pages = new Map<string, Record<string, unknown>>();
const executions = new Map<string, Record<string, unknown>>();
const aborted: string[] = [];
vi.mock("electron", () => ({
net: {
@@ -15,9 +16,10 @@ vi.mock("electron", () => ({
const request = new EventEmitter() as EventEmitter & { setHeader: () => void; write: (body: string) => void; end: () => void; abort: () => void };
request.setHeader = () => undefined;
request.write = (body: string) => { written.push(`${url} ${body}`); };
request.abort = () => undefined;
request.abort = () => { aborted.push(url); };
request.end = () => {
requested.push(url);
if (url.includes("/tools/execute/GITHUB_HANG")) return;
const query = new URL(url).searchParams;
const page = url.includes("/tools/execute/") ? executions.get(url.split("/tools/execute/")[1]) ?? { data: { number: 42 }, error: null, successful: true } : pages.get(`${query.get("toolkit_slug")}:${query.get("cursor") ?? ""}`);
const response = new EventEmitter() as EventEmitter & { statusCode: number };
@@ -54,6 +56,23 @@ describe("a toolkit's tools are all listed", () => {
});
});
describe("a Composio call that never answers", () => {
test("is given up after its time limit and its request is aborted", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
try {
const pending = new ComposioClient("ak_hang").executeTool({ toolName: "GITHUB_HANG", arguments: {}, userId: "u" });
const outcome = pending.then(() => "answered", (error: Error) => error.message);
await vi.advanceTimersByTimeAsync(119_000);
expect(aborted.some((url) => url.includes("GITHUB_HANG"))).toBe(false);
await vi.advanceTimersByTimeAsync(1_000);
expect(await outcome).toBe("Composio API POST https://backend.composio.dev/api/v3/tools/execute/GITHUB_HANG gave no answer within 120s.");
expect(aborted.some((url) => url.includes("GITHUB_HANG"))).toBe(true);
} finally {
vi.useRealTimers();
}
});
});
describe("agents reach every action through search and run", () => {
const vault = { encryptSecret: (secret: string) => Buffer.from(secret, "utf8"), decryptSecret: (ciphertext: Buffer) => ciphertext.toString("utf8") };