Files
learnharness/tests/tool-abort.test.ts
T
amosblomqvist 36c5d19437 fix(tools): the abort cascade — kill running tool calls, honest activity labels
Root cause of the live-test bugs (2026-09-19): pi passes every run's
AbortSignal to tool execute, but toPiTool dropped it — agent.abort()
only landed at the next step boundary, so a long local grep held its
turn hostage until the dev server's memory restart killed the run
mid-tool, freezing the record at its write-ahead state (the 'went
back in time' rollback).

- lh-tool: execute ctx gains signal (parent + timeout combined); every
  call races a 30s system-level timeout with a 250ms cooperative grace —
  abort kills a stuck tool but lets a poll-the-signal tool deliver its
  partial results. Pre-aborted signals never fire listeners: checked
  upfront. toolTimeoutMs is the test seam (pdfModelOverride idiom).
- fs-shared: walkFiles returns {timedOut, aborted} instead of throwing —
  abort yields partial results.
- grep: the scan phase was unbounded (the walk's 10s deadline only
  bounded collection) — same budget now checked per file; find/grep
  check the signal and return partials with a notice.
- pending/chat: streamingToolActivity names the ACTUAL running tool —
  a part sits input-streaming through its whole execution, so the old
  shimmer called minutes-long greps 'Writing a question…'.

Tests: toPiTool guard (forward, abort, timeout, grace, question
bypass, ToolInputError passthrough), walk mid-abort, find/grep
pre-abort + mid-run abort, activity labels. Gates: 617/617.
2026-09-19 17:26:57 +03:00

133 lines
5.0 KiB
TypeScript

/**
* The abort-cascade fix (2026-09-19): pi passes every run's AbortSignal to
* tool execute — the bridge used to drop it, so agent.abort() only landed
* at the NEXT step boundary and a long-running local tool (the grep that
* outlived its turn by minutes) held the turn hostage until the dev
* server's memory restart killed the run mid-tool, freezing the record at
* its write-ahead state. toPiTool now threads the signal and races every
* execute against the system-level timeout; the fs tools honor it with
* partial results (fs-tools.test.ts covers their side).
*/
import assert from "node:assert/strict";
import { afterEach, describe, it } from "node:test";
import { z } from "zod";
import { lhTool, toPiTool, toolTimeoutMs, ToolInputError } from "../src/agent/lh-tool.ts";
const DEFAULT_TIMEOUT_MS = 30_000;
afterEach(() => {
toolTimeoutMs.current = DEFAULT_TIMEOUT_MS;
});
/** A tool that captures ctx.signal, then either hangs or resolves `value`. */
function probeTool(captured: { signal?: AbortSignal }, opts: { hang?: boolean; value?: string } = {}) {
return lhTool({
name: "probe",
description: "test probe",
schema: z.object({}),
execute: (_input, ctx) => {
captured.signal = ctx?.signal;
if (opts.hang) return new Promise<string>(() => {}); // never settles, no timer
return opts.value ?? "ok";
},
});
}
describe("toPiTool abort + timeout guard", () => {
it("forwards a live abort signal to the tool's ctx", async () => {
// Even with no parent signal, the tool still gets the guard's combined
// signal (parent+timeout) — a stable reference tools can poll.
const captured: { signal?: AbortSignal } = {};
const pi = toPiTool(probeTool(captured));
await pi.execute("c1", {}, undefined as never);
assert.ok(captured.signal, "the tool received a signal");
assert.equal(captured.signal!.aborted, false);
// The forwarded signal is the guard's COMBINED one — aborting the
// parent flips it, which is what long-running tools poll.
const ac = new AbortController();
const captured2: { signal?: AbortSignal } = {};
const pi2 = toPiTool(probeTool(captured2, { value: "done" }));
const pending = pi2.execute("c1b", {}, ac.signal);
ac.abort();
await pending;
assert.equal(captured2.signal!.aborted, true);
});
it("a signal-wired tool observes the run's abort", async () => {
const ac = new AbortController();
const captured: { signal?: AbortSignal } = {};
const pi = toPiTool(probeTool(captured, { hang: true }));
const pending = pi.execute("c2", {}, ac.signal).catch((e: Error) => e.message);
ac.abort();
assert.match((await pending) as string, /Operation aborted/);
assert.equal(captured.signal!.aborted, true);
});
it("rejects a hanging tool at the system-level timeout", async () => {
toolTimeoutMs.current = 50;
const pi = toPiTool(probeTool({}, { hang: true }));
await assert.rejects(pi.execute("c3", {}, undefined as never), /timed out/);
});
it("a pre-aborted run rejects immediately with pi's wording", async () => {
const ac = new AbortController();
ac.abort();
const pi = toPiTool(probeTool({}, { hang: true }));
await assert.rejects(pi.execute("c4", {}, ac.signal), /Operation aborted/);
});
it("a tool that honors the signal settles before the timeout wins", async () => {
// The race resolves with the tool's own (partial) result — the fs
// tools' contract: abort yields partial results, not an error throw.
toolTimeoutMs.current = 2_000;
const tool = lhTool({
name: "partial",
description: "test",
schema: z.object({}),
execute: (_input, ctx) =>
new Promise((resolve) => {
// The fs-tools contract: poll upfront (a listener on an
// already-aborted signal never fires), else listen.
const signal = ctx!.signal!;
if (signal.aborted) return resolve("partial results");
signal.addEventListener("abort", () => resolve("partial results"), { once: true });
}),
});
const ac = new AbortController();
const pi = toPiTool(tool);
const pending = pi.execute("c5", {}, ac.signal);
ac.abort();
const result = (await pending) as { content: Array<{ text?: string }> };
assert.equal(result.content[0]!.text, "partial results");
});
it("question tools bypass the guard — the placeholder is instant", async () => {
const ac = new AbortController();
ac.abort();
const tool = lhTool({
name: "quiz",
description: "test",
schema: z.object({}),
clientQuestion: true,
});
const pi = toPiTool(tool);
const result = (await pi.execute("c6", {}, ac.signal)) as { content: Array<{ text?: string }> };
assert.match(result.content[0]!.text!, /Question posed/);
});
it("validation errors pass through the guard unchanged", async () => {
const tool = lhTool({
name: "strict",
description: "test",
schema: z.object({ n: z.number() }),
execute: () => "never",
});
const pi = toPiTool(tool);
await assert.rejects(pi.execute("c7", { n: "not a number" }, undefined as never), (e: unknown) => {
assert.ok(e instanceof ToolInputError);
assert.equal(e.toolName, "strict");
return true;
});
});
});