import { describe, expect, it } from "vitest"; import type { ThreadStreamItem } from "../../../../../src/features/chat/domain/thread-stream/items"; import { threadStreamLayoutBlocks } from "../../../../../src/features/chat/presentation/thread-stream/layout"; const DEFAULT_WORKSPACE_ROOT = "/vault"; function commandItem(id: string, text: string, turnId: string): ThreadStreamItem { return { id, kind: "command", role: "tool", turnId, commandAction: "command", commandTarget: { kind: "command", commandLine: text }, command: text, cwd: "/vault", status: "completed", executionState: "completed", }; } function fileChangeItem(id: string, turnId: string, path = "src/main.ts"): ThreadStreamItem { return { id, kind: "fileChange", role: "tool", turnId, status: "completed", executionState: "completed", changes: [{ kind: "update", path, diff: "" }], }; } function autoReviewResultItem(id: string, turnId: string, text = "Auto-review approved: npm test"): ThreadStreamItem { return { id, kind: "reviewResult", role: "tool", text, turnId, provenance: { source: "appServer", channel: "notification", event: "autoReview", sourceItemId: id }, executionState: "completed", }; } describe("display block grouping keeps thread stream details subordinate to conversation messages", () => { it("groups completed turn activities before the final assistant message", () => { const items: ThreadStreamItem[] = [ { id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "do it", turnId: "t1" }, { id: "r1", kind: "reasoning", role: "tool", text: "thinking", turnId: "t1" }, commandItem("c1", "npm test", "t1"), { id: "a1", kind: "dialogue", role: "assistant", text: "done", turnId: "t1", dialogueKind: "assistantResponse", dialogueState: "completed", }, ]; const blocks = threadStreamLayoutBlocks(items, null, DEFAULT_WORKSPACE_ROOT); expect(blocks.map((block) => block.type)).toEqual(["item", "activityGroup", "item"]); expect(blocks[1]).toMatchObject({ summary: "Work details" }); }); it("groups completed hook and review logs before the final assistant message", () => { const items: ThreadStreamItem[] = [ { id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "do it", turnId: "t1" }, { id: "hook-1", kind: "hook", role: "tool", text: "userPromptSubmit: Saving jj baseline", toolName: "hook", turnId: "t1", status: "completed", }, commandItem("c1", "npm test", "t1"), { id: "r1", kind: "reasoning", role: "tool", text: "thinking", turnId: "t1", status: "completed" }, autoReviewResultItem("review-1", "t1"), autoReviewResultItem("review-2", "t1"), { id: "a1", kind: "dialogue", role: "assistant", text: "done", turnId: "t1", dialogueKind: "assistantResponse", dialogueState: "completed", }, ]; const blocks = threadStreamLayoutBlocks(items, null, DEFAULT_WORKSPACE_ROOT); expect(blocks.map((block) => (block.type === "item" ? block.item.id : block.id))).toEqual(["u1", "turn-t1-activity", "a1"]); expect(blocks[1]).toMatchObject({ summary: "Work details", items: [ { item: { id: "hook-1" } }, { item: { id: "c1" } }, { item: { id: "r1" } }, { item: { id: "review-1" } }, { item: { id: "review-2" } }, ], }); expect(blocks[2]).toMatchObject({ item: { id: "a1" }, annotations: { autoReviewSummaries: ["Auto-review approved: npm test", "Auto-review approved: npm test"] }, }); }); it("hides empty completed reasoning items", () => { const items: ThreadStreamItem[] = [ { id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "do it", turnId: "t1" }, { id: "r1", kind: "reasoning", role: "tool", text: "", turnId: "t1", status: "completed", executionState: "completed" }, { id: "a1", kind: "dialogue", role: "assistant", text: "done", turnId: "t1", dialogueKind: "assistantResponse", dialogueState: "completed", }, ]; const blocks = threadStreamLayoutBlocks(items, null, DEFAULT_WORKSPACE_ROOT); expect(blocks.map((block) => (block.type === "item" ? block.item.id : block.id))).toEqual(["u1", "a1"]); }); it("collapses earlier assistant responses with their turn details", () => { const items: ThreadStreamItem[] = [ { id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "do it", turnId: "t1" }, { id: "a1", kind: "dialogue", role: "assistant", text: "I'll inspect it.", turnId: "t1", dialogueKind: "assistantResponse", dialogueState: "completed", }, commandItem("c1", "rg issue", "t1"), { id: "a2", kind: "dialogue", role: "assistant", text: "I found the cause.", turnId: "t1", dialogueKind: "assistantResponse", dialogueState: "completed", }, fileChangeItem("f1", "t1"), { id: "a3", kind: "dialogue", role: "assistant", text: "done", turnId: "t1", dialogueKind: "assistantResponse", dialogueState: "completed", }, ]; const blocks = threadStreamLayoutBlocks(items, null, DEFAULT_WORKSPACE_ROOT); expect(blocks.map((block) => block.type)).toEqual(["item", "activityGroup", "item"]); expect(blocks[1]).toMatchObject({ summary: "Work details", items: [{ item: { id: "a1" } }, { item: { id: "c1" } }, { item: { id: "a2" } }, { item: { id: "f1" } }], }); expect(blocks[2]).toMatchObject({ item: { id: "a3" } }); }); it("keeps completed turn work details attached to the final response even when system messages are interleaved", () => { const items: ThreadStreamItem[] = [ { id: "s1", kind: "system", role: "system", text: "debug before hook" }, commandItem("c1", "npm test", "t1"), { id: "s2", kind: "system", role: "system", text: "debug after hook" }, { id: "a1", kind: "dialogue", role: "assistant", text: "done", turnId: "t1", dialogueKind: "assistantResponse", dialogueState: "completed", }, ]; const blocks = threadStreamLayoutBlocks(items, null, DEFAULT_WORKSPACE_ROOT); expect(blocks.map((block) => (block.type === "item" ? block.item.id : block.id))).toEqual(["s1", "s2", "turn-t1-activity", "a1"]); }); it("adds steering markers to completed turn work details without hiding the steering message", () => { const items: ThreadStreamItem[] = [ { id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "do it", turnId: "t1" }, commandItem("c1", "rg issue", "t1"), { id: "u2", kind: "dialogue", dialogueKind: "user", role: "user", text: "also check tests", turnId: "t1", clientId: "local-steer-1" }, commandItem("c2", "npm test", "t1"), { id: "a1", kind: "dialogue", role: "assistant", text: "done", turnId: "t1", dialogueKind: "assistantResponse", dialogueState: "completed", }, ]; const blocks = threadStreamLayoutBlocks(items, null, DEFAULT_WORKSPACE_ROOT); expect(blocks.map((block) => (block.type === "item" ? block.item.id : block.id))).toEqual(["u1", "u2", "turn-t1-activity", "a1"]); expect(blocks[2]).toMatchObject({ summary: "Work details", items: [ { type: "item", item: { id: "c1" } }, { type: "steering", id: "steer-activity-u2", label: "steering", text: "also check tests", sourceItemId: "u2", }, { type: "item", item: { id: "c2" } }, ], }); }); it("keeps multiple steering markers in completed turn work details", () => { const items: ThreadStreamItem[] = [ { id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "do it", turnId: "t1" }, { id: "u2", kind: "dialogue", dialogueKind: "user", role: "user", text: "also check tests", turnId: "t1" }, { id: "u3", kind: "dialogue", dialogueKind: "user", role: "user", text: "and update docs", turnId: "t1" }, { id: "a1", kind: "dialogue", role: "assistant", text: "done", turnId: "t1", dialogueKind: "assistantResponse", dialogueState: "completed", }, ]; const blocks = threadStreamLayoutBlocks(items, null, DEFAULT_WORKSPACE_ROOT); const activityGroup = blocks.find((block) => block.type === "activityGroup"); expect(activityGroup).toMatchObject({ summary: "Work details", items: [ { type: "steering", id: "steer-activity-u2" }, { type: "steering", id: "steer-activity-u3" }, ], }); }); it("keeps active turn activities expanded", () => { const items: ThreadStreamItem[] = [ { id: "r1", kind: "reasoning", role: "tool", text: "thinking", turnId: "t1" }, { id: "a1", kind: "dialogue", role: "assistant", text: "done", turnId: "t1", dialogueKind: "assistantResponse", dialogueState: "completed", }, ]; expect(threadStreamLayoutBlocks(items, "t1", DEFAULT_WORKSPACE_ROOT).map((block) => block.type)).toEqual(["item", "item"]); }); it("keeps active task progress chronological in the display model", () => { const items: ThreadStreamItem[] = [ { id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "do it", turnId: "t1" }, { id: "plan-progress-t1", kind: "taskProgress", role: "tool", text: "[>] Patch UI", turnId: "t1", explanation: null, steps: [{ step: "Patch UI", status: "inProgress" }], status: "inProgress", }, { id: "a1", kind: "dialogue", role: "assistant", text: "working", turnId: "t1", dialogueKind: "assistantResponse", dialogueState: "completed", }, ]; const blocks = threadStreamLayoutBlocks(items, "t1", DEFAULT_WORKSPACE_ROOT); expect(blocks.map((block) => (block.type === "item" ? block.item.id : block.id))).toEqual(["u1", "plan-progress-t1", "a1"]); }); it("groups task progress and agent activity inside completed turn work details", () => { const items: ThreadStreamItem[] = [ { id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "do it", turnId: "t1" }, { id: "plan-progress-t1", kind: "taskProgress", role: "tool", text: "[>] Patch UI", turnId: "t1", explanation: null, steps: [{ step: "Patch UI", status: "inProgress" }], status: "inProgress", }, { id: "agent-1", kind: "agent", role: "tool", text: "Spawn agent", turnId: "t1", tool: "spawnAgent", status: "completed", senderThreadId: "parent", receiverThreadIds: ["child"], prompt: null, model: null, reasoningEffort: null, agents: [{ threadId: "child", status: "completed", executionState: "completed", message: null }], }, { id: "a1", kind: "dialogue", role: "assistant", text: "done", turnId: "t1", dialogueKind: "assistantResponse", dialogueState: "completed", }, ]; const blocks = threadStreamLayoutBlocks(items, null, DEFAULT_WORKSPACE_ROOT); expect(blocks[1]).toMatchObject({ summary: "Work details", items: [{ item: { id: "plan-progress-t1" } }, { item: { id: "agent-1" } }], }); }); it("adds edited files to the final assistant message", () => { const items: ThreadStreamItem[] = [ fileChangeItem("f1", "t1", "src/main.ts"), fileChangeItem("f2", "t1", "styles.css"), { id: "a1", kind: "dialogue", role: "assistant", text: "intermediate", turnId: "t1", dialogueKind: "assistantResponse", dialogueState: "completed", }, { id: "a2", kind: "dialogue", role: "assistant", text: "done", turnId: "t1", dialogueKind: "assistantResponse", dialogueState: "completed", }, ]; const assistantBlock = threadStreamLayoutBlocks(items, null, DEFAULT_WORKSPACE_ROOT).find( (block) => block.type === "item" && block.item.role === "assistant", ); expect(assistantBlock).toMatchObject({ annotations: { editedFiles: ["src/main.ts", "styles.css"] } }); }); it("adds turn diff metadata to the final assistant message only when aggregated diff exists", () => { const items: ThreadStreamItem[] = [ fileChangeItem("f1", "t1", "src/main.ts"), { id: "a1", kind: "dialogue", role: "assistant", text: "done", turnId: "t1", dialogueKind: "assistantResponse", dialogueState: "completed", }, ]; const withoutDiff = threadStreamLayoutBlocks(items, null, DEFAULT_WORKSPACE_ROOT).find( (block) => block.type === "item" && block.item.role === "assistant", ); expect(withoutDiff).toMatchObject({ annotations: { editedFiles: ["src/main.ts"] } }); expect(withoutDiff?.type === "item" ? withoutDiff.annotations : null).not.toHaveProperty("turnDiff"); const withDiff = threadStreamLayoutBlocks(items, null, DEFAULT_WORKSPACE_ROOT, new Map([["t1", "@@\n-old\n+new"]])).find( (block) => block.type === "item" && block.item.role === "assistant", ); expect(withDiff).toMatchObject({ annotations: { editedFiles: ["src/main.ts"], turnDiff: { diff: "@@\n-old\n+new" } } }); }); it("adds auto-review summaries to the final assistant message", () => { const items: ThreadStreamItem[] = [ autoReviewResultItem("review-1", "t1"), autoReviewResultItem("review-2", "t1"), { id: "a1", kind: "dialogue", role: "assistant", text: "done", turnId: "t1", dialogueKind: "assistantResponse", dialogueState: "completed", }, ]; const assistantBlock = threadStreamLayoutBlocks(items, null, DEFAULT_WORKSPACE_ROOT).find( (block) => block.type === "item" && block.item.role === "assistant", ); expect(assistantBlock).toMatchObject({ annotations: { autoReviewSummaries: ["Auto-review approved: npm test", "Auto-review approved: npm test"] }, }); }); it("does not add edited file or auto-review summaries to active turn messages", () => { const items: ThreadStreamItem[] = [ fileChangeItem("f1", "t1", "src/main.ts"), autoReviewResultItem("review-1", "t1"), { id: "a1", kind: "dialogue", role: "assistant", text: "still working", turnId: "t1", dialogueKind: "assistantResponse", dialogueState: "completed", }, ]; const assistantBlock = threadStreamLayoutBlocks(items, "t1", DEFAULT_WORKSPACE_ROOT).find( (block) => block.type === "item" && block.item.id === "a1", ); expect(assistantBlock?.type === "item" ? assistantBlock.annotations : null).toBeUndefined(); }); }); describe("workspace-relative presentation paths", () => { it("shows edited files relative to the workspace root", () => { const items: ThreadStreamItem[] = [ fileChangeItem("f1", "t1", "/vault/project/src/main.ts"), fileChangeItem("f2", "t1", "/vault/project/styles.css"), fileChangeItem("f3", "t1", "/tmp/outside.txt"), { id: "a1", kind: "dialogue", role: "assistant", text: "done", turnId: "t1", dialogueKind: "assistantResponse", dialogueState: "completed", }, ]; const assistantBlock = threadStreamLayoutBlocks(items, null, "/vault/project").find( (block) => block.type === "item" && block.item.role === "assistant", ); expect(assistantBlock).toMatchObject({ annotations: { editedFiles: ["/tmp/outside.txt", "src/main.ts", "styles.css"] } }); }); });