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.
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* streamingToolActivity (2026-09-19, the "Writing a question…" fix): a
|
||||
* tool part stays input-streaming for its WHOLE execution (the bridge
|
||||
* defers input-available to tool_execution_end), so the transcript
|
||||
* activity line must name the ACTUAL running tool — the old code shimmered
|
||||
* "Writing a question…" over minutes-long greps.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import type { UIMessage } from "ai";
|
||||
import { streamingToolActivity } from "../src/core/pending.ts";
|
||||
|
||||
function msg(role: string, parts: unknown[]): UIMessage {
|
||||
return { id: "m1", role, parts } as unknown as UIMessage;
|
||||
}
|
||||
|
||||
function toolPart(tool: string, state: string) {
|
||||
return { type: `tool-${tool}`, toolCallId: "c1", state, input: {} };
|
||||
}
|
||||
|
||||
describe("streamingToolActivity (the turn-activity label)", () => {
|
||||
it("question tools keep their question shimmer", () => {
|
||||
assert.equal(streamingToolActivity([msg("assistant", [toolPart("quiz", "input-streaming")])]), "Writing a question…");
|
||||
assert.equal(
|
||||
streamingToolActivity([msg("assistant", [toolPart("ask_user_question", "input-streaming")])]),
|
||||
"Writing a question…",
|
||||
);
|
||||
});
|
||||
|
||||
it("local tools name their actual activity", () => {
|
||||
assert.equal(streamingToolActivity([msg("assistant", [toolPart("grep", "input-streaming")])]), "Searching file contents…");
|
||||
assert.equal(streamingToolActivity([msg("assistant", [toolPart("read", "input-streaming")])]), "Reading a file…");
|
||||
assert.equal(streamingToolActivity([msg("assistant", [toolPart("find", "input-streaming")])]), "Finding files…");
|
||||
assert.equal(streamingToolActivity([msg("assistant", [toolPart("ls", "input-streaming")])]), "Listing a directory…");
|
||||
});
|
||||
|
||||
it("job dispatch tools get their own labels; unknown tools stay generic", () => {
|
||||
assert.equal(streamingToolActivity([msg("assistant", [toolPart("pdf", "input-streaming")])]), "Reading a PDF…");
|
||||
assert.equal(
|
||||
streamingToolActivity([msg("assistant", [toolPart("mystery_tool", "input-streaming")])]),
|
||||
"Working…",
|
||||
);
|
||||
});
|
||||
|
||||
it("no activity once the call settles or the turn ends", () => {
|
||||
// output-available = done executing → no shimmer (the transcript
|
||||
// shows the settled card instead).
|
||||
assert.equal(streamingToolActivity([msg("assistant", [toolPart("grep", "output-available")])]), null);
|
||||
// the last message being the user's = nothing running.
|
||||
assert.equal(streamingToolActivity([msg("user", [{ type: "text", text: "hi" }])]), null);
|
||||
assert.equal(streamingToolActivity([]), null);
|
||||
});
|
||||
|
||||
it("the SECOND streaming part wins only if the first settles — parts scan forward", () => {
|
||||
// A settled call followed by a live one: the live one is the activity.
|
||||
const parts = [toolPart("read", "output-available"), toolPart("grep", "input-streaming")];
|
||||
assert.equal(streamingToolActivity([msg("assistant", parts)]), "Searching file contents…");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user