Files
paseo/packages/app/src/agent-stream/presentation.test.ts
T
geoqiao dc51a3b6a9 fix(app): preserve plugin-rendered replies when collapsing turns (#8811)
* fix(app): preserve plugin-rendered replies when collapsing turns

* test(app): exercise plugin reply folding in the browser
2026-11-06 13:12:44 +00:00

875 lines
32 KiB
TypeScript

import { PluginTranslationRuntime } from "@getpaseo/plugin/client/host";
import { runPluginClientBundle, type PluginClientRuntime } from "@/plugins/evaluate";
import { transformTimelineItem, type TimelineItemTransform } from "@/plugins/timeline/model";
import type { InstalledPlugin } from "@/plugins/types";
import {
applyStreamEvent,
getStreamItemMessageId,
hydrateStreamState,
type StreamItem,
type ToolCallItem,
type UserMessageItem,
} from "@/types/stream";
import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
import { QueryClient } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
import { buildAgentStreamRenderModel } from "./model";
import { createStreamPresentation } from "./presentation";
import { createReadingAnchor } from "./reading-anchor";
const runtime = {
paseo: {},
async rpc() {},
openSettings() {},
openSurface() {},
openPanel() {},
addHeaderButton() {
return { update() {}, remove() {} };
},
addComposerPill() {
return { update() {}, remove() {} };
},
} as unknown as PluginClientRuntime;
function installProbe(
itemType: "user_message" | "assistant_message" | "tool_call" | "reasoning",
condition = "true",
): InstalledPlugin {
const clientBundle = `(function() {
return { default: function(client) {
client.addTimelineTransformer({
id: "probe",
query: { itemType: ${JSON.stringify(itemType)} },
transform: function({ item, phase }) {
if (!(${condition})) return undefined;
return {
items: [{
type: "plugin",
kind: "probe",
version: 1,
data: item.type === "tool_call"
? { callId: item.callId, name: item.name, status: item.status,
detail: item.detail, ...(item.metadata ? { metadata: item.metadata } : {}), error: item.error, phase: phase }
: { text: item.text, phase: phase }
}]
};
}
});
return function() {};
} };
})`;
const evaluated = runPluginClientBundle("probe-plugin", clientBundle, runtime);
return {
...evaluated,
serverId: "host-1",
clientBundle,
lifetime: new AbortController(),
locale: "en",
i18n: new PluginTranslationRuntime({ getLocale: () => "en", subscribe: () => () => {} }),
queryClient: new QueryClient(),
paseo: {} as InstalledPlugin["paseo"],
invoke: async () => undefined,
subscribe: () => {
throw new Error("Subscriptions are not used here");
},
};
}
function installedTransform(plugin: InstalledPlugin): TimelineItemTransform {
return (input) => transformTimelineItem({ ...input, plugins: [plugin] });
}
function toolCall(callId: string, name: string): ToolCallItem {
return {
kind: "tool_call",
id: `tool-${callId}`,
timestamp: new Date("2026-01-01T00:00:00.000Z"),
payload: {
source: "agent",
data: {
provider: "codex",
callId,
name,
status: "completed",
error: null,
detail: { type: "unknown", input: { callId }, output: { callId } },
},
},
};
}
function pluginData(items: StreamItem[]): unknown[] {
return items.flatMap((item) => (item.kind === "plugin" ? [item.data] : []));
}
const presentationOptions = {
display: "collapseToolCalls" as const,
expandedTurnHostIds: new Set<string>(),
isTurnActive: true,
};
function streamHarness(transform?: TimelineItemTransform) {
let state: { tail: StreamItem[]; head: StreamItem[] } = { tail: [], head: [] };
const present = createStreamPresentation();
return {
send(event: AgentStreamEventPayload) {
state = applyStreamEvent({ ...state, event, timestamp: new Date(1000) });
return this.render();
},
render(nextTransform = transform) {
return present({ ...presentationOptions, ...state, transform: nextTransform });
},
source: () => state,
};
}
function assistant(text: string, messageId = "message-1"): AgentStreamEventPayload {
return {
type: "timeline",
provider: "claude",
item: { type: "assistant_message", messageId, text },
};
}
function rows(result: { tail: StreamItem[]; head: StreamItem[] }): StreamItem[] {
return [...result.tail, ...result.head];
}
describe("whole-message Markdown presentation", () => {
it("keeps paragraphs, fences and quotes in one progressively updated row", () => {
const present = createStreamPresentation();
const text = "First paragraph.\n\nSecond paragraph.\n\n```ts\nconst n = 1;\n```\n\n> Quote";
for (const length of [10, 30, text.length]) {
const source = hydrateStreamState([
{ event: assistant(text.slice(0, length)), timestamp: new Date(1000) },
]);
const result = present({
...presentationOptions,
tail: [],
head: source,
transform: undefined,
});
expect(rows(result)).toEqual(source);
}
});
});
describe("stream presentation through installed plugins", () => {
it("offers every source tool call to an installed transformer in Overview mode", () => {
const calls = [toolCall("call-1", "bash"), toolCall("call-2", "read")];
const rendered = createStreamPresentation()({
...presentationOptions,
tail: calls,
head: [],
transform: installedTransform(installProbe("tool_call")),
});
expect(pluginData([...rendered.tail, ...rendered.head])).toMatchObject([
{ callId: "call-1", name: "bash", phase: "complete" },
{ callId: "call-2", name: "read", phase: "complete" },
]);
});
it.each([
{ type: "turn_completed", provider: "claude" },
{ type: "turn_canceled", provider: "claude", reason: "test cancellation" },
{ type: "turn_failed", provider: "claude", error: "test failure" },
] satisfies AgentStreamEventPayload[])(
"streams accumulated assistant text with one identity through $type",
(completion) => {
const harness = streamHarness(installedTransform(installProbe("assistant_message")));
const first = rows(harness.send(assistant("Header")));
const fullText = "Header\n\n\n# Body\n\n```ts\nconst x = 1;\n\nconst y = 2;\n```\n";
const live = rows(harness.send(assistant(fullText.slice("Header".length))));
const completed = rows(harness.send(completion));
expect(pluginData(first)).toEqual([{ text: "Header", phase: "streaming" }]);
expect(pluginData(live)).toEqual([{ text: fullText, phase: "streaming" }]);
expect(pluginData(completed)).toEqual([{ text: fullText, phase: "complete" }]);
expect(live.map((item) => item.id)).toEqual(first.map((item) => item.id));
expect(completed.map((item) => item.id)).toEqual(first.map((item) => item.id));
expect(rows(harness.source())).toMatchObject([{ kind: "assistant_message", text: fullText }]);
},
);
it("addresses a prompt a plugin replaced by the prompt's own id", () => {
const harness = streamHarness(installedTransform(installProbe("user_message")));
const rendered = rows(
harness.send({
type: "timeline",
provider: "claude",
item: { type: "user_message", messageId: "prompt-1", text: "Earlier prompt" },
}),
);
const source = rows(harness.source());
expect(source).toMatchObject([{ kind: "user_message", id: "prompt-1" }]);
expect(rendered).toMatchObject([{ kind: "plugin", id: "probe-plugin/prompt-1/0" }]);
// The chat outline jumps to the source id; the row it lands on is the plugin's.
expect(rendered.map(getStreamItemMessageId)).toEqual(["prompt-1"]);
});
it("finishes one message before streaming a different message ID", () => {
const harness = streamHarness(installedTransform(installProbe("assistant_message")));
harness.send(assistant("First\n\nBody", "first"));
const result = harness.send(assistant("Second", "second"));
expect(pluginData(rows(result))).toEqual([
{ text: "First\n\nBody", phase: "complete" },
{ text: "Second", phase: "streaming" },
]);
expect(new Set(rows(result).map((item) => item.id)).size).toBe(2);
});
it("lets a plugin claim the accumulated message after earlier text used native Markdown", () => {
const harness = streamHarness();
harness.send(assistant("Header\n\nBody"));
const transform = installedTransform(
installProbe("assistant_message", 'item.text.includes("Body")'),
);
expect(pluginData(rows(harness.render(transform)))).toEqual([
{ text: "Header\n\nBody", phase: "streaming" },
]);
expect(rows(harness.render(transform))).toHaveLength(1);
expect(rows(harness.render()).map((item) => item.kind)).toEqual(["assistant_message"]);
});
it("retains the complete message when streaming finishes", () => {
const harness = streamHarness();
harness.send(assistant("Intro\n\n![Image](image.png)"));
const last = harness.send(assistant("\n\nAfter\n- item"));
const completed = harness.send({ type: "turn_completed", provider: "claude" });
expect(last.tail).toEqual([]);
expect(last.head).toMatchObject([{ text: "Intro\n\n![Image](image.png)\n\nAfter\n- item" }]);
expect(completed.tail).toEqual(last.head);
expect(completed.head).toEqual([]);
});
it("keeps blank lines inside an open native code fence", () => {
const harness = streamHarness();
harness.send(assistant("Intro\n\n```ts\nconst a = 1;"));
const result = harness.send(assistant("\n\nconst b = 2;"));
expect(result.tail).toEqual([]);
expect(result.head).toMatchObject([{ text: "Intro\n\n```ts\nconst a = 1;\n\nconst b = 2;" }]);
});
// Codex streams a line break and the next line's indent as their own chunks.
it("keeps a line break when a chunk ends in the next line's indent", () => {
const harness = streamHarness();
harness.send(assistant("```mermaid\nflowchart TD\n A[Start] --> B[Review]\n"));
harness.send(assistant(" "));
const result = harness.send(assistant(" B --> C[Finish]\n```"));
expect(result.head).toMatchObject([
{ text: "```mermaid\nflowchart TD\n A[Start] --> B[Review]\n B --> C[Finish]\n```" },
]);
});
// One rendering path: a fetched message is the same block group as a streamed one,
// which is what lets find, scroll-to-message and history reveal address it by id.
// A link reference definition stays with the paragraph that uses it, so the split
// never leaves an empty row behind and the reference still resolves.
it("preserves source identity through history and the live head", () => {
const source = hydrateStreamState([
{
event: assistant("[Link][docs]\n\n[docs]: https://example.com\n\nClosing paragraph."),
timestamp: new Date(1000),
},
]);
const present = (placement: "tail" | "head") =>
rows(
createStreamPresentation()({
...presentationOptions,
tail: placement === "tail" ? source : [],
head: placement === "head" ? source : [],
transform: undefined,
}),
);
expect(present("tail")).toEqual(source);
const blocks = (items: StreamItem[]) =>
items.map((item) => [item.id, item.kind === "assistant_message" ? item.text : null]);
expect(blocks(present("head"))).toEqual(blocks(present("tail")));
});
// Rows are addressed by id from outside presentation, so the same text has to land on
// the same ids whether the reader watched it arrive or opened the chat afterwards.
it("gives a streamed message the same id as its hydrated text", () => {
const text = "Intro paragraph.\n\n# Heading\n\n- first\n- second\n\nClosing paragraph.";
const harness = streamHarness();
for (let end = 8; end < text.length; end += 8) {
harness.send(assistant(text.slice(end - 8, end)));
}
harness.send(assistant(text.slice(text.length - (text.length % 8 || 8))));
const streamed = rows(harness.send({ type: "turn_completed", provider: "claude" }));
const hydrated = rows(
createStreamPresentation()({
...presentationOptions,
tail: hydrateStreamState([{ event: assistant(text), timestamp: new Date(1000) }]),
head: [],
transform: undefined,
}),
);
expect(streamed.map((item) => item.id)).toEqual(hydrated.map((item) => item.id));
expect(streamed.map((item) => item.kind === "assistant_message" && item.text)).toEqual(
hydrated.map((item) => item.kind === "assistant_message" && item.text),
);
});
it("uses the canonical source that replaced the streamed message", () => {
const harness = streamHarness();
harness.send(assistant("Streamed answer"));
const streamed = rows(harness.render());
expect(streamed[0]).not.toHaveProperty("timelineCursor");
const source = harness.source();
const live = source.head[0];
if (live?.kind !== "assistant_message") throw new Error("Expected a live assistant message");
const canonical = { ...live, timelineCursor: { epoch: "epoch-1", seq: 42 } };
const present = createStreamPresentation();
present({ ...presentationOptions, tail: [], head: [live], transform: undefined });
const repointed = rows(
present({ ...presentationOptions, tail: [], head: [canonical], transform: undefined }),
);
expect(repointed).toMatchObject([
{
id: streamed[0]!.id,
text: "Streamed answer",
timelineCursor: { epoch: "epoch-1", seq: 42 },
},
]);
});
it("keeps a single-paragraph message a one-block group", () => {
const source = hydrateStreamState([
{ event: assistant("Only one paragraph."), timestamp: new Date(1000) },
]);
const messageId = source[0]!.id;
const result = createStreamPresentation()({
...presentationOptions,
tail: source,
head: [],
transform: undefined,
});
expect(result.tail).toEqual(source);
expect(result.tail[0]!.id).toBe(messageId);
});
it("continues to stream inline reasoning with a stable plugin row", () => {
const harness = streamHarness(installedTransform(installProbe("reasoning")));
const first = harness.send({
type: "timeline",
provider: "claude",
item: { type: "reasoning", text: "First" },
});
const second = harness.send({
type: "timeline",
provider: "claude",
item: { type: "reasoning", text: "\n\nSecond" },
});
const done = harness.send({ type: "turn_completed", provider: "claude" });
expect(pluginData(rows(second))).toEqual([{ text: "First\n\nSecond", phase: "streaming" }]);
expect(pluginData(rows(done))).toEqual([{ text: "First\n\nSecond", phase: "complete" }]);
expect(rows(first)[0]?.id).toBe(rows(done)[0]?.id);
});
it("groups only unclaimed tools and passes unchanged tool details to the callback", () => {
const calls = [
toolCall("call-1", "read"),
toolCall("call-2", "read"),
toolCall("call-3", "bash"),
];
const result = createStreamPresentation()({
...presentationOptions,
isTurnActive: false,
tail: calls,
head: [],
transform: installedTransform(installProbe("tool_call", 'item.name === "bash"')),
});
expect(result.groupsByHostId.get(result.tail[0]!.id)?.run.calls).toEqual(calls.slice(0, 2));
expect(pluginData(rows(result))).toEqual([
{
callId: "call-3",
name: "bash",
status: "completed",
error: null,
detail: { type: "unknown", input: { callId: "call-3" }, output: { callId: "call-3" } },
phase: "complete",
},
]);
});
it.each([
{ status: "completed", error: null },
{ status: "failed", error: { message: "Command failed" } },
{ status: "canceled", error: null },
] as const)("keeps a tool card's identity and full payload through $status", (completion) => {
const harness = streamHarness(installedTransform(installProbe("tool_call")));
const source = {
type: "tool_call" as const,
callId: "call-1",
name: "bash",
detail: { type: "shell" as const, command: "pwd", cwd: "/repo" },
metadata: { sequence: 1 },
};
const running = rows(
harness.send({
type: "timeline",
provider: "claude",
item: {
...source,
status: "running",
error: null,
},
}),
);
const terminalItem = { ...source, ...completion };
const terminal = rows(
harness.send({
type: "timeline",
provider: "claude",
item: {
...terminalItem,
},
}),
);
expect(pluginData(running)).toMatchObject([
{ callId: "call-1", status: "running", phase: "streaming" },
]);
expect(pluginData(terminal)).toEqual([
{
callId: source.callId,
name: source.name,
detail: source.detail,
metadata: source.metadata,
phase: "complete",
status: completion.status,
error: terminalItem.error,
},
]);
expect(terminal.map((item) => item.id)).toEqual(running.map((item) => item.id));
});
it("offers one complete live source assistant message to an installed transformer", () => {
const sourceText =
'[IMPORTANT: User invoked the "diagnose" skill; follow its instructions. Full skill below.]\n\n# Diagnose\n\nFind the root cause.';
const event = {
type: "timeline" as const,
provider: "opencode" as const,
item: { type: "assistant_message" as const, messageId: "message-1", text: sourceText },
};
const timestamp = new Date("2026-01-01T00:00:00.000Z");
const transform = installedTransform(installProbe("assistant_message"));
const present = createStreamPresentation();
const fetched = hydrateStreamState([{ event, timestamp }]);
const history = present({ ...presentationOptions, tail: fetched, head: [], transform });
expect(pluginData(history.tail)).toEqual([{ text: sourceText, phase: "complete" }]);
const stream = applyStreamEvent({
tail: [],
head: [],
event,
timestamp,
});
const rendered = present({ ...presentationOptions, ...stream, transform });
expect(pluginData([...rendered.tail, ...rendered.head])).toEqual([
{ text: sourceText, phase: "streaming" },
]);
});
});
function createTimestamp(seed: number): Date {
return new Date(`2026-01-01T00:00:${seed.toString().padStart(2, "0")}.000Z`);
}
function userMessage(id: string, seed: number): UserMessageItem {
return {
kind: "user_message",
id,
text: id,
timestamp: createTimestamp(seed),
};
}
function assistantMessage(
id: string,
seed: number,
): Extract<StreamItem, { kind: "assistant_message" }> {
return {
kind: "assistant_message",
id,
text: id,
timestamp: createTimestamp(seed),
};
}
describe("timeline presentation", () => {
const present = createStreamPresentation();
function projectTimelineItems(items: StreamItem[], transform?: TimelineItemTransform) {
return present({ ...presentationOptions, tail: items, head: [], transform }).tail;
}
const envelope =
"<spoken-input>\nPlease fix the voice chat.\n</spoken-input>\n<instruction>This message was spoken by the user. Respond using the speak tool only, not normal messages, because the user may not be looking at the chat.</instruction>";
it.each(["live", "history"])(
"shows only spoken words from a %s user message without mutating its source",
(source) => {
const item: UserMessageItem = Object.freeze({
...userMessage(source, 1),
text: envelope,
messageId: "provider-message",
clientMessageId: "client-message",
turnId: "turn-1",
timelineCursor: { epoch: "epoch", seq: 12 },
});
const presentMessage = () =>
present({
...presentationOptions,
tail: source === "history" ? [item] : [],
head: source === "live" ? [item] : [],
transform: undefined,
});
const projected = rows(presentMessage());
expect(projected).toEqual([{ ...item, text: "Please fix the voice chat." }]);
const model = buildAgentStreamRenderModel({
tail: source === "history" ? projected : [],
head: source === "live" ? projected : [],
isTurnActive: source === "live",
activeTurnStartedAt: item.timestamp,
platform: "native",
isMobileBreakpoint: true,
});
const rendered = [...model.history, ...model.segments.liveHead];
expect(rendered).toContainEqual({ ...item, text: "Please fix the voice chat." });
expect(item.text).toBe(envelope);
expect(rows(presentMessage())[0]).toBe(projected[0]);
},
);
it("supports older envelopes and preserves multiline spoken content", () => {
const item = {
...userMessage("legacy", 1),
text: "<spoken-input>\nFirst line.\nSecond line with <example>XML</example>.\n</spoken-input>",
};
expect(projectTimelineItems([item])).toEqual([
{ ...item, text: "First line.\nSecond line with <example>XML</example>." },
]);
});
it("leaves ordinary messages, assistant examples and incomplete wrappers unchanged", () => {
const items: StreamItem[] = [
userMessage("ordinary", 1),
{ ...assistantMessage("example", 2), text: envelope },
{ ...userMessage("incomplete", 3), text: "<spoken-input>unfinished" },
{ ...userMessage("quoted", 4), text: "Explain this example: " + envelope },
{
...userMessage("xml", 5),
text: "<spoken-input>Example</spoken-input><instruction>Explain this XML.</instruction>",
},
];
expect(projectTimelineItems(items)).toEqual(items);
expect(projectTimelineItems(items)[0]).toBe(items[0]);
});
it("keeps plugin transforms on the original source and respects replacements", () => {
const source: StreamItem = { ...userMessage("spoken", 1), text: envelope };
const inputs: string[] = [];
const transformed = projectTimelineItems([source], ({ item, sourceId }) => {
if (item.type === "user_message") inputs.push(item.text);
return [
{
type: "plugin",
pluginId: "test",
id: sourceId,
kind: "voice",
version: 1,
data: { text: "Custom voice row" },
},
];
});
expect(inputs).toEqual([envelope]);
expect(transformed).toMatchObject([{ kind: "plugin", data: { text: "Custom voice row" } }]);
expect(projectTimelineItems([source], () => undefined)).toEqual([
{ ...source, text: "Please fix the voice chat." },
]);
expect(projectTimelineItems([source], () => [])).toEqual([]);
});
});
it("folds work before a plugin-rendered reply and leaves the reply visible", () => {
const timestamp = new Date("2026-01-01T00:00:00.000Z");
const tail: StreamItem[] = [
{ kind: "user_message", id: "prompt", text: "Calculate", timestamp },
toolCall("one", "Bash"),
{ kind: "assistant_message", id: "reply", text: "$$x^2$$", timestamp },
];
const result = createStreamPresentation()({
...presentationOptions,
transform: installedTransform(installProbe("assistant_message", "phase === 'complete'")),
tail,
head: [],
display: "collapseTurns",
isTurnActive: false,
});
expect(result.tail.map((item) => item.id)).toEqual(["prompt", "probe-plugin/reply/0"]);
expect([...result.turns.turnsByHostId.keys()]).toEqual(["prompt"]);
expect(pluginData(result.tail)).toEqual([{ text: "$$x^2$$", phase: "complete" }]);
});
it("keeps streaming replies native, then folds work without losing manual expansion", () => {
const timestamp = new Date("2026-01-01T00:00:00.000Z");
const work: StreamItem[] = [
{ kind: "user_message", id: "prompt", text: "Calculate", timestamp },
toolCall("one", "Bash"),
];
const reply: StreamItem = {
kind: "assistant_message",
id: "reply",
text: "$$x^2$$",
timestamp,
};
const input = {
...presentationOptions,
transform: installedTransform(installProbe("assistant_message", "phase === 'complete'")),
display: "collapseTurns" as const,
};
const present = createStreamPresentation();
const live = present({ ...input, tail: work, head: [reply], isTurnActive: true });
expect(rows(live).map((item) => item.id)).toEqual(["prompt", "tool-one", "reply"]);
const completed = { ...input, tail: [...work, reply], head: [], isTurnActive: false };
expect(present(completed).tail.map((item) => item.id)).toEqual([
"prompt",
"probe-plugin/reply/0",
]);
const expandedTurnHostIds = new Set(["prompt"]);
const opened = present({ ...completed, expandedTurnHostIds });
expect(opened.tail.map((item) => item.id)).toEqual([
"prompt",
"tool-one",
"probe-plugin/reply/0",
]);
expect(opened.turns.nestedItemIds.has("probe-plugin/reply/0")).toBe(false);
expect(present({ ...completed, expandedTurnHostIds }).tail).toEqual(opened.tail);
expect(present(completed).tail.map((item) => item.id)).toEqual([
"prompt",
"probe-plugin/reply/0",
]);
});
it.each(["full", "collapseToolCalls"] as const)(
"leaves plugin replies and individual work visible in %s",
(display) => {
const timestamp = new Date("2026-01-01T00:00:00.000Z");
const tail: StreamItem[] = [
{ kind: "user_message", id: "prompt", text: "Calculate", timestamp },
toolCall("one", "Bash"),
{ kind: "assistant_message", id: "reply", text: "$$x^2$$", timestamp },
];
const result = createStreamPresentation()({
...presentationOptions,
transform: installedTransform(installProbe("assistant_message")),
tail,
head: [],
display,
isTurnActive: false,
});
expect(result.tail.map((item) => item.id)).toEqual([
"prompt",
"tool-one",
"probe-plugin/reply/0",
]);
expect(result.turns.turnsByHostId.size).toBe(0);
},
);
it("opening a turn reveals individual calls rather than another tool group", () => {
const present = createStreamPresentation();
const timestamp = new Date();
const tail: StreamItem[] = [
{ kind: "user_message", id: "prompt", text: "Work", timestamp },
toolCall("one", "Bash"),
toolCall("two", "Bash"),
{ kind: "assistant_message", id: "reply", text: "Done", timestamp },
];
const result = present({
...presentationOptions,
transform: undefined,
tail,
head: [],
display: "collapseTurns",
isTurnActive: false,
expandedTurnHostIds: new Set(["prompt"]),
});
expect(result.tail.map((item) => item.id)).toEqual(tail.map((item) => item.id));
expect(result.groupsByHostId.size).toBe(0);
expect(result.readingRowReplacements.get("tool-two")).toBeUndefined();
});
it.each(["collapseToolCalls", "collapseTurns"] as const)(
"preserves the reader when a prepend extends a group in %s",
(display) => {
const present = createStreamPresentation();
const anchor = createReadingAnchor();
const calls = [toolCall("one", "Bash"), toolCall("two", "Bash")];
const input = {
...presentationOptions,
transform: undefined,
display,
head: [],
isTurnActive: false,
};
const partial = present({ ...input, tail: calls.slice(1) });
anchor.reconcile(0, [{ id: partial.tail[0]!.id, top: 48, height: 200 }]);
const completed = present({ ...input, tail: calls });
const groupId = completed.tail[0]!.id;
expect(anchor.getRowId(completed.readingRowReplacements)).toBe(groupId);
expect(
anchor.reconcile(
0,
[{ id: groupId, top: 448, height: 200 }],
false,
completed.readingRowReplacements,
),
).toBe(400);
expect(anchor.getRowId()).toBe(groupId);
const timestamp = new Date();
const folded = present({
...input,
display: "collapseTurns",
tail: [
{ kind: "user_message", id: "prompt", text: "Work", timestamp },
...calls,
{ kind: "assistant_message", id: "reply", text: "Done", timestamp },
],
});
// Both the original partial group and its replacement resolve directly to the turn.
expect(folded.readingRowReplacements.get(calls[1]!.id)).toBe("prompt");
expect(anchor.getRowId(folded.readingRowReplacements)).toBe("prompt");
const opened = present({ ...input, tail: calls, display: "full" });
expect(opened.readingRowReplacements.get(calls[1]!.id)).toBeUndefined();
},
);
it("presents received agent messages as attributed bubbles with the original turn identity", () => {
const call = toolCall("received", "agent_message");
if (call.payload.source !== "agent") throw new Error("Expected agent tool");
call.turnId = "turn";
call.payload.data.agentMessage = {
event: "message",
sender: { id: "sender", title: "Reviewer" },
text: "Ready",
};
const result = createStreamPresentation()({
...presentationOptions,
transform: undefined,
tail: [call],
head: [],
});
expect(result.tail[0]).toMatchObject({
kind: "user_message",
id: call.id,
turnId: "turn",
text: "Ready",
sender: { id: "sender", title: "Reviewer" },
});
});
it("does not reopen the previous turn while a new run waits for its first row", () => {
const timestamp = new Date();
const tail: StreamItem[] = [
{ kind: "user_message", id: "prompt", text: "Work", timestamp },
toolCall("one", "Bash"),
{ kind: "assistant_message", id: "reply", text: "Done", timestamp },
];
for (const item of tail) item.turnId = "completed";
const result = createStreamPresentation()({
...presentationOptions,
transform: undefined,
tail,
head: [],
display: "collapseTurns",
isTurnActive: true,
activeTurnId: "new",
});
expect(result.tail.map((item) => item.id)).toEqual(["prompt", "reply"]);
});
// A sent prompt counts as activity before the daemon opens its turn, so the only known turn is
// the completed one; it must stay folded until the new turn's rows arrive.
it("keeps the previous turn folded while a sent prompt waits for its turn to open", () => {
const timestamp = new Date();
const tail: StreamItem[] = [
{ kind: "user_message", id: "prompt", text: "Work", timestamp },
toolCall("one", "Bash"),
{ kind: "assistant_message", id: "reply", text: "Done", timestamp },
];
for (const item of tail) item.turnId = "completed";
const result = createStreamPresentation()({
...presentationOptions,
transform: undefined,
tail,
head: [],
display: "collapseTurns",
isTurnActive: true,
activeTurnId: null,
});
expect(result.tail.map((item) => item.id)).toEqual(["prompt", "reply"]);
});
it("groups reasoning with tools unless Expand thinking is enabled, including setting changes", () => {
const present = createStreamPresentation();
const timestamp = new Date();
const thought: StreamItem = {
kind: "thought",
id: "thought",
text: "Consider this",
status: "ready",
timestamp,
};
const tail: StreamItem[] = [toolCall("one", "Bash"), thought, toolCall("two", "Bash")];
for (const item of tail) item.turnId = "run";
const input = {
...presentationOptions,
transform: undefined,
tail,
head: [],
display: "collapseToolCalls" as const,
isTurnActive: false,
};
const grouped = present({ ...input, autoExpandReasoning: false });
expect(grouped.tail).toHaveLength(1);
expect(grouped.groupsByHostId.get("tool-one")?.run.calls).toEqual(tail);
const separate = present({ ...input, autoExpandReasoning: true });
expect(separate.tail.map((item) => item.id)).toEqual(["tool-one", "thought", "tool-two"]);
expect(separate.groupsByHostId.has("thought")).toBe(false);
const next = { ...thought, id: "next", turnId: "next-run" };
const bounded = present({ ...input, tail: [...tail, next], autoExpandReasoning: false });
expect(bounded.tail.map((item) => item.id)).toEqual(["tool-one", "next"]);
});
it.each([false, true])(
"reveals raw reasoning when opening a turn (Expand thinking %s)",
(autoExpandReasoning) => {
const timestamp = new Date();
const tail: StreamItem[] = [
{ kind: "user_message", id: "prompt", text: "Work", timestamp },
{ kind: "thought", id: "thought", text: "Consider this", status: "ready", timestamp },
toolCall("one", "Bash"),
{ kind: "assistant_message", id: "reply", text: "Done", timestamp },
];
const input = {
...presentationOptions,
transform: undefined,
tail,
head: [],
display: "collapseTurns" as const,
isTurnActive: false,
autoExpandReasoning,
};
const present = createStreamPresentation();
const closed = present(input);
expect(closed.tail.some((item) => item.id === "thought")).toBe(false);
const opened = present({ ...input, expandedTurnHostIds: new Set(["prompt"]) });
expect(opened.tail).toEqual(tail);
expect(opened.turns.nestedItemIds.has("thought")).toBe(true);
expect(opened.groupsByHostId.size).toBe(0);
},
);