Server: ui-stream.ts is verbatim pi AgentEvent passthrough (SSE data lines + [DONE]); only app-level events are the loop-level error and turn-settled terminal; onError carries raw provider text (owner ruling). Client: useChat + the AI-SDK transport replaced by components/session-core.ts (SessionModel: 409 identity-retry, optimistic c_ appends, absorbed() adoption, needsContinuation, shared consume for POST + reattach) and the session.ts hook; the golden-master grouping canon moved client-side into core/view.ts (projectView); scanners read the native record (phaseAt3Lh, danglingQuestionOf, graph toolParts, plan-blocks, runner, extraction). Coverage: the native message is the unit (fork 3) with the call+result inseparability bond rule; view-line-less messages (markers, error results) are invisible to coverage and never checkpointed. Deletes: chat-transport, projection, ui-message, their tests (ported to tests/session-core.test.ts), and the ai + @ai-sdk/react dependencies — the Vercel SDK is fully out of the tree. Wire pins in chat-flow rewritten onto the AgentEvent dialect (eventsOf parser). Gates: tsc clean, 994/994 tests, eslint clean, build green; live smoke on PORT=3004 (verbatim wire → native record).
60 lines
2.9 KiB
TypeScript
60 lines
2.9 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 { LhViewMessage } from "../src/core/view.ts";
|
|
import { streamingToolActivity } from "../src/core/pending.ts";
|
|
|
|
function msg(role: string, parts: unknown[]): LhViewMessage {
|
|
return { id: "m1", role, parts } as unknown as LhViewMessage;
|
|
}
|
|
|
|
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…");
|
|
});
|
|
});
|