Files
learnharness/tests/stream-activity.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

60 lines
2.8 KiB
TypeScript

/**
* 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…");
});
});