import { describe, expect, it } from "vitest"; import { normalizeAgentActivitySession } from "@tutti-os/agent-activity-core"; import type { WorkspaceAgentSessionDetailViewModel } from "../../workspaceAgentSessionDetailViewModel"; import { projectAgentConversationVM, reconcileProjectedAgentConversationVM } from "./agentConversationProjection"; describe("projectAgentConversationVM", () => { it("interleaves guidance with earlier and later assistant output in the same turn", () => { const sourceItem = ( seq: number, role: "user" | "assistant", content: string ) => ({ id: seq, workspaceId: "workspace-1", agentSessionId: "session-1", seq, turnId: "turn-1", eventId: `event-${seq}`, actorType: role === "user" ? "user" : "agent", actorId: role === "user" ? "user" : "session-1", itemType: role === "user" ? "message.user" : "message.assistant", role, content, occurredAtUnixMs: seq * 100 }); const initialUser = { id: "user-1", body: "Build the game", turnId: "turn-1", occurredAtUnixMs: 100, sourceTimelineItems: [sourceItem(1, "user", "Build the game")] }; const earlierAssistant = { id: "assistant-1", body: "I will inspect the workspace first.", turnId: "turn-1", occurredAtUnixMs: 200, sourceTimelineItems: [ sourceItem(2, "assistant", "I will inspect the workspace first.") ] }; const guidance = { id: "user-2", body: "Make it colorful", turnId: "turn-1", occurredAtUnixMs: 300, sourceTimelineItems: [sourceItem(3, "user", "Make it colorful")] }; const laterAssistant = { id: "assistant-2", body: "I will update the palette.", turnId: "turn-1", occurredAtUnixMs: 400, sourceTimelineItems: [ sourceItem(4, "assistant", "I will update the palette.") ] }; const agentItems = [ { kind: "message" as const, message: earlierAssistant }, { kind: "message" as const, message: laterAssistant } ]; const conversation = projectAgentConversationVM( detailViewModel({ turns: [ { id: "turn-1", userMessage: initialUser, userMessages: [initialUser, guidance], agentMessages: [earlierAssistant, laterAssistant], toolCalls: [], toolCallCount: 0, hasFailedToolCall: false, rawAgentItems: agentItems, agentItems } ], showProcessingIndicator: false }) ); expect( conversation.rows.flatMap((row) => row.kind === "message" ? row.messages.map((message) => message.body) : [] ) ).toEqual([ "Build the game", "I will inspect the workspace first.", "Make it colorful", "I will update the palette." ]); }); it("groups trailing tools while the session is still processing without appending summary rows", () => { const detail = detailViewModel(); const conversation = projectAgentConversationVM(detail); expect(conversation.rows.map((row) => row.kind)).toEqual([ "message", "message", "tool-group", "processing" ]); const toolRows = conversation.rows.filter( ( row ): row is Extract< (typeof conversation.rows)[number], { kind: "tool-group" } > => row.kind === "tool-group" ); expect(toolRows).toHaveLength(1); expect(toolRows[0]?.grouped).toBe(true); expect(toolRows[0]?.calls).toHaveLength(2); expect(toolRows[0]?.entries.map((entry) => entry.kind)).toEqual([ "tool-call", "thinking", "tool-call" ]); }); it("promotes a completed generated image beside its retained tool-call group", () => { const imageCall = { id: "call:image-1", name: "Generate image", toolName: "image_generation", callType: "tool", status: "Completed", statusKind: "completed" as const, summary: "Generated image", payload: { input: { prompt: "A friendly corgi" }, output: { savedPath: "/workspace/output/generated-corgi.png", imageMimeType: "image/png" } } }; const conversation = projectAgentConversationVM( detailViewModel({ turns: [ { id: "turn-image", userMessage: { id: "user-image", body: "Generate a corgi" }, userMessages: [{ id: "user-image", body: "Generate a corgi" }], agentMessages: [], toolCalls: [imageCall], toolCallCount: 1, hasFailedToolCall: false, agentItems: [ { kind: "tool-calls", id: "tools-image", toolCalls: [imageCall], toolCallCount: 1, hasFailedToolCall: false } ] } ], showProcessingIndicator: false }) ); expect( conversation.rows.filter((row) => row.kind === "tool-group") ).toHaveLength(1); expect( conversation.rows.find((row) => row.kind === "generated-image") ).toMatchObject({ kind: "generated-image", id: "generated-image:call:image-1", turnId: "turn-image", sourceCallId: "call:image-1", uri: "/workspace/output/generated-corgi.png", mimeType: "image/png", prompt: "A friendly corgi" }); }); it("does not promote an image before generation completes", () => { const imageCall = { id: "call:image-running", name: "Generate image", toolName: "image_generation", callType: "tool", status: "Running", statusKind: "working" as const, summary: "Generating image", payload: { input: { prompt: "A friendly corgi" }, output: { savedPath: "/workspace/output/generated-corgi.png" } } }; const conversation = projectAgentConversationVM( detailViewModel({ turns: [ { id: "turn-image-running", userMessage: { id: "user-image", body: "Generate a corgi" }, userMessages: [{ id: "user-image", body: "Generate a corgi" }], agentMessages: [], toolCalls: [imageCall], toolCallCount: 1, hasFailedToolCall: false, agentItems: [ { kind: "tool-calls", id: "tools-image", toolCalls: [imageCall], toolCallCount: 1, hasFailedToolCall: false } ] } ], showProcessingIndicator: false }) ); expect( conversation.rows.some((row) => row.kind === "generated-image") ).toBe(false); }); it("removes final Markdown that repeats a generated-image artifact", () => { const savedPath = "/workspace/output/generated-corgi.png"; const imageCall = { id: "call:image-deduplicated", name: "Generate image", toolName: "image_generation", callType: "tool", status: "Completed", statusKind: "completed" as const, summary: "Generated image", payload: { input: { prompt: "A friendly corgi" }, output: { savedPath, content: [ { type: "image", uri: savedPath, mimeType: "image/png" } ] } } }; const conversation = projectAgentConversationVM( detailViewModel({ turns: [ { id: "turn-image-deduplicated", userMessage: { id: "user-image", body: "Generate a corgi" }, userMessages: [{ id: "user-image", body: "Generate a corgi" }], agentMessages: [ { id: "assistant-image", body: `Done.\n\n![generated corgi](${savedPath})` } ], toolCalls: [imageCall], toolCallCount: 1, hasFailedToolCall: false, agentItems: [ { kind: "tool-calls", id: "tools-image", toolCalls: [imageCall], toolCallCount: 1, hasFailedToolCall: false }, { kind: "message", message: { id: "assistant-image", body: `Done.\n\n![generated corgi](${savedPath})` } } ] } ], showProcessingIndicator: false }) ); expect( conversation.rows.filter((row) => row.kind === "generated-image") ).toHaveLength(1); expect( conversation.rows .filter( (row): row is Extract => row.kind === "message" && row.speaker === "assistant" ) .flatMap((row) => row.messages.map((message) => message.body)) ).toEqual(["Done."]); }); it("keeps assistant Markdown images that are not generated artifacts", () => { const generatedPath = "/workspace/output/generated-corgi.png"; const otherPath = "/workspace/reference/reference-corgi.png"; const imageCall = { id: "call:image-keep-other", name: "Generate image", toolName: "image_generation", callType: "tool", status: "Completed", statusKind: "completed" as const, summary: "Generated image", payload: { output: { savedPath: generatedPath, content: [ { type: "image", uri: generatedPath, mimeType: "image/png" } ] } } }; const body = `![generated](${generatedPath})\n\n![reference](${otherPath})`; const conversation = projectAgentConversationVM( detailViewModel({ turns: [ { id: "turn-image-keep-other", userMessage: { id: "user-image", body: "Generate a corgi" }, userMessages: [{ id: "user-image", body: "Generate a corgi" }], agentMessages: [{ id: "assistant-image", body }], toolCalls: [imageCall], toolCallCount: 1, hasFailedToolCall: false, agentItems: [ { kind: "tool-calls", id: "tools-image", toolCalls: [imageCall], toolCallCount: 1, hasFailedToolCall: false }, { kind: "message", message: { id: "assistant-image", body } } ] } ], showProcessingIndicator: false }) ); expect( conversation.rows .filter( (row): row is Extract => row.kind === "message" && row.speaker === "assistant" ) .flatMap((row) => row.messages.map((message) => message.body)) ).toEqual([`![reference](${otherPath})`]); }); const tailChainConversation = (latestTail: { status: string; statusKind: "completed" | "working" | "waiting"; }) => { const calls = [ { id: "call:1", name: "Read web page", toolName: "web_fetch", callType: "tool", status: "Completed", statusKind: "completed" as const, summary: "https://example.com/a", payload: null }, { id: "call:2", name: "Read web page", toolName: "web_fetch", callType: "tool", status: "Completed", statusKind: "completed" as const, summary: "https://example.com/b", payload: null }, { id: "call:3", name: "Read web page", toolName: "web_fetch", callType: "tool", status: latestTail.status, statusKind: latestTail.statusKind, summary: "https://example.com/c", payload: null } ]; const conversation = projectAgentConversationVM( detailViewModel({ session: { ...detailViewModel().session, activeTurn: { agentSessionId: "session-1", outcome: null, origin: "user_prompt", phase: "running", settledAtUnixMs: null, startedAtUnixMs: 1, turnId: "turn-2", updatedAtUnixMs: 10 }, activeTurnId: "turn-2" }, turns: [ { id: "turn-1", userMessage: { id: "user-1", body: "Read pages" }, userMessages: [{ id: "user-1", body: "Read pages" }], agentMessages: [], toolCalls: calls, toolCallCount: calls.length, hasFailedToolCall: false, agentItems: [ { kind: "tool-calls", id: "tools-1", toolCalls: calls, toolCallCount: calls.length, hasFailedToolCall: false } ] } ] }) ); return conversation.rows.filter( ( row ): row is Extract< (typeof conversation.rows)[number], { kind: "tool-group" } > => row.kind === "tool-group" ); }; it("keeps completed tail tools in one stable group", () => { const toolRows = tailChainConversation({ status: "Completed", statusKind: "completed" }); expect(toolRows).toHaveLength(1); expect(toolRows[0]?.calls.map((call) => call.id)).toEqual([ "call:1", "call:2", "call:3" ]); expect(toolRows[0]?.grouped).toBe(true); }); it("keeps the latest running tool in the same stable group", () => { const toolRows = tailChainConversation({ status: "Running", statusKind: "working" }); expect(toolRows).toHaveLength(1); expect(toolRows[0]?.calls.map((call) => call.id)).toEqual([ "call:1", "call:2", "call:3" ]); expect(toolRows[0]?.calls.at(-1)?.statusKind).toBe("working"); expect(toolRows[0]?.grouped).toBe(true); }); it("keeps Codex transport retry notices out of the working processing label", () => { const base = detailViewModel(); const conversation = projectAgentConversationVM( detailViewModel({ turns: base.turns.map((turn) => ({ ...turn, agentItems: [ ...turn.agentItems, { kind: "message", message: { id: "assistant-retry-1", body: "Codex connection interrupted. Reconnecting...", systemNotice: { noticeKind: "transport_retry", severity: "warning", title: "Codex connection interrupted. Reconnecting...", detail: "Handled error during turn: Reconnecting... 1/5", retryable: true } } } ] })) }) ); const processing = conversation.rows.find( (row) => row.kind === "processing" ); expect(processing?.kind).toBe("processing"); expect(processing).not.toHaveProperty("noticeKind"); }); it("merges only adjacent Codex transport retry notice rows", () => { const retryNotice = (id: string, detail: string, sourceId: number) => ({ id, body: "Codex connection interrupted. Reconnecting...", occurredAtUnixMs: sourceId, sourceTimelineItems: [ { id: sourceId, agentSessionId: "session-1", eventId: `event-${sourceId}`, actorType: "agent", actorId: "codex", itemType: "message", role: "assistant", payload: { kind: "agent_system_notice", noticeKind: "transport_retry" } } ], systemNotice: { noticeKind: "transport_retry", severity: "warning", title: "Codex connection interrupted. Reconnecting...", detail, retryable: true } }); const conversation = projectAgentConversationVM( detailViewModel({ session: { ...detailViewModel().session }, turns: [ { id: "turn-1", userMessage: { id: "user-1", body: "Ship it" }, userMessages: [{ id: "user-1", body: "Ship it" }], agentMessages: [], toolCalls: [], toolCallCount: 0, hasFailedToolCall: false, agentItems: [ { kind: "message", message: retryNotice( "assistant-retry-1", "Handled error during turn: Reconnecting... 1/5", 11 ) }, { kind: "message", message: retryNotice( "assistant-retry-2", "Handled error during turn: Reconnecting... 2/5", 12 ) }, { kind: "message", message: { id: "assistant-1", body: "Still working" } }, { kind: "message", message: retryNotice( "assistant-retry-3", "Handled error during turn: Reconnecting... 3/5", 13 ) } ] } ], showProcessingIndicator: false }) ); const assistantRows = conversation.rows.filter( ( row ): row is Extract< (typeof conversation.rows)[number], { kind: "message" } > => row.kind === "message" && row.speaker === "assistant" ); expect(assistantRows).toHaveLength(3); expect(assistantRows.map((row) => row.messages[0]?.id)).toEqual([ "assistant-retry-1", "assistant-1", "assistant-retry-3" ]); expect( assistantRows[0]?.messages[0]?.sourceTimelineItems?.map((item) => item.id) ).toEqual([11, 12]); expect(assistantRows[0]?.messages[0]?.systemNotice?.detail).toBe( "Handled error during turn: Reconnecting... 2/5" ); expect(assistantRows[2]?.messages[0]?.sourceTimelineItems?.[0]?.id).toBe( 13 ); }); it("drops redundant Codex error warning notices when a visible error is present", () => { const conversation = projectAgentConversationVM( detailViewModel({ session: { ...detailViewModel().session }, turns: [ { id: "turn-1", userMessage: { id: "user-1", body: "Ship it" }, userMessages: [{ id: "user-1", body: "Ship it" }], agentMessages: [], toolCalls: [], toolCallCount: 0, hasFailedToolCall: false, agentItems: [ { kind: "message", message: { id: "assistant-warning-1", body: "Codex reported an error.", systemNotice: { noticeKind: "warning", severity: "warning", title: "Codex reported an error.", detail: "stream disconnected", retryable: false } } }, { kind: "message", message: { id: "assistant-visible-error-1", body: "Codex request failed.", visibleError: { code: "request_failed", phase: "turn", provider: "codex", detail: "stream disconnected", retryable: false } } } ] } ] }) ); const assistantRows = conversation.rows.filter( ( row ): row is Extract< (typeof conversation.rows)[number], { kind: "message" } > => row.kind === "message" && row.speaker === "assistant" ); expect(assistantRows).toHaveLength(1); expect(assistantRows[0]?.messages[0]?.visibleError?.provider).toBe("codex"); expect(assistantRows[0]?.messages[0]?.systemNotice).toBeNull(); }); it("drops Codex skills context budget runtime warning notices", () => { const conversation = projectAgentConversationVM( detailViewModel({ turns: [ { id: "turn-1", userMessage: { id: "user-1", body: "Start" }, userMessages: [{ id: "user-1", body: "Start" }], agentMessages: [], toolCalls: [], toolCallCount: 0, hasFailedToolCall: false, agentItems: [ { kind: "message", message: { id: "assistant-warning-1", body: "Skill descriptions were shortened to fit the 2% skills context budget. Codex can still see every skill, but some descriptions are shorter.", systemNotice: { noticeKind: "warning", severity: "warning", source: "runtime", title: "Skill descriptions were shortened to fit the 2% skills context budget. Codex can still see every skill, but some descriptions are shorter.", detail: "Skill descriptions were shortened to fit the 2% skills context budget. Codex can still see every skill, but some descriptions are shorter.", retryable: null } } }, { kind: "message", message: { id: "assistant-1", body: "I will inspect the logs." } } ] } ], showProcessingIndicator: false }) ); const assistantRows = conversation.rows.filter( ( row ): row is Extract< (typeof conversation.rows)[number], { kind: "message" } > => row.kind === "message" && row.speaker === "assistant" ); expect(assistantRows).toHaveLength(1); expect(assistantRows[0]?.messages[0]?.id).toBe("assistant-1"); expect(assistantRows[0]?.messages[0]?.systemNotice).toBeNull(); }); it("keeps processing when normalized rows cannot host canonical live timing", () => { const baseDetail = detailViewModel(); const activeTurn = { agentSessionId: "session-1", turnId: "turn-1", phase: "running" as const, origin: "user_prompt" as const, startedAtUnixMs: 1, updatedAtUnixMs: 10 }; const runtimeWarning = "Skill descriptions were shortened to fit the 2% skills context budget. Codex can still see every skill, but some descriptions are shorter."; const conversation = projectAgentConversationVM( detailViewModel({ session: { ...baseDetail.session, activeTurnId: "turn-1", activeTurn, latestTurn: activeTurn }, sessionTurns: [activeTurn], turns: [ { id: "turn-1", userMessage: null, userMessages: [], agentMessages: [], toolCalls: [], toolCallCount: 0, hasFailedToolCall: false, agentItems: [ { kind: "message", message: { id: "assistant-warning-1", body: runtimeWarning, systemNotice: { noticeKind: "warning", severity: "warning", source: "runtime", title: runtimeWarning, detail: runtimeWarning, retryable: null } } } ] } ], showProcessingIndicator: true }) ); expect(conversation.rows).toEqual([ expect.objectContaining({ kind: "processing", id: "processing:turn-1", turnId: "turn-1" }) ]); }); it("drops Codex model metadata fallback runtime warning notices", () => { const metadataWarning = "Model metadata for `minimax/minimax-m2.5` not found. Defaulting to fallback metadata; this can degrade performance and cause issues."; const conversation = projectAgentConversationVM( detailViewModel({ turns: [ { id: "turn-1", userMessage: { id: "user-1", body: "你好" }, userMessages: [{ id: "user-1", body: "你好" }], agentMessages: [], toolCalls: [], toolCallCount: 0, hasFailedToolCall: false, agentItems: [ { kind: "message", message: { id: "assistant-warning-1", body: metadataWarning, systemNotice: { noticeKind: "warning", severity: "warning", source: "runtime", title: metadataWarning, detail: metadataWarning, retryable: null } } }, { kind: "message", message: { id: "assistant-1", body: "你好!有什么我可以帮你的吗?" } } ] } ], showProcessingIndicator: false }) ); const assistantRows = conversation.rows.filter( ( row ): row is Extract< (typeof conversation.rows)[number], { kind: "message" } > => row.kind === "message" && row.speaker === "assistant" ); expect(assistantRows).toHaveLength(1); expect(assistantRows[0]?.messages[0]?.id).toBe("assistant-1"); expect(assistantRows[0]?.messages[0]?.systemNotice).toBeNull(); }); it("groups bridge thinking inside completed tool disclosures", () => { const conversation = projectAgentConversationVM( detailViewModel({ session: { ...detailViewModel().session }, showProcessingIndicator: false }) ); expect(conversation.rows.map((row) => row.kind)).toEqual([ "message", "message", "tool-group", "turn-summary" ]); const groupedRow = conversation.rows.find( ( row ): row is Extract< (typeof conversation.rows)[number], { kind: "tool-group" } > => row.kind === "tool-group" && row.grouped ); expect(groupedRow?.entries.map((entry) => entry.kind)).toEqual([ "tool-call", "thinking", "tool-call" ]); expect(groupedRow?.entries[1]?.kind).toBe("thinking"); expect( groupedRow?.entries[1]?.kind === "thinking" ? groupedRow.entries[1].thinking.body : null ).toBe("Need to inspect before editing."); }); it("places each canonical file summary directly after its settled turn", () => { const conversation = projectAgentConversationVM( detailViewModel({ turns: [ { id: "turn-1", userMessage: { id: "user-1", body: "First" }, userMessages: [{ id: "user-1", body: "First" }], agentMessages: [{ id: "assistant-1", body: "Done first" }], toolCalls: [], toolCallCount: 0, hasFailedToolCall: false, agentItems: [ { kind: "message", message: { id: "assistant-1", body: "Done first" } } ] }, { id: "turn-2", userMessage: { id: "user-2", body: "Second" }, userMessages: [{ id: "user-2", body: "Second" }], agentMessages: [{ id: "assistant-2", body: "Done second" }], toolCalls: [], toolCallCount: 0, hasFailedToolCall: false, agentItems: [ { kind: "message", message: { id: "assistant-2", body: "Done second" } } ] } ], sessionTurns: [ { agentSessionId: "session-1", turnId: "turn-1", phase: "settled", origin: "user_prompt", outcome: "completed", startedAtUnixMs: 1, settledAtUnixMs: 10, updatedAtUnixMs: 10, fileChanges: { files: [{ path: "/workspace/demo/first.ts", change: "added" }] } }, { agentSessionId: "session-1", turnId: "turn-2", phase: "settled", origin: "user_prompt", outcome: "completed", startedAtUnixMs: 11, settledAtUnixMs: 20, updatedAtUnixMs: 20, fileChanges: { files: [{ path: "/workspace/demo/second.ts", change: "deleted" }] } } ], showProcessingIndicator: false }) ); expect(conversation.rows.map((row) => `${row.turnId}:${row.kind}`)).toEqual( [ "turn-1:message", "turn-1:message", "turn-1:turn-summary", "turn-2:message", "turn-2:message", "turn-2:turn-summary" ] ); }); it("uses the session cwd for turn summaries when no workspace root is selected", () => { const conversation = projectAgentConversationVM( detailViewModel({ workspaceRoot: null, showProcessingIndicator: false }) ); const summary = conversation.rows.find( (row) => row.kind === "turn-summary" ); expect(summary).toMatchObject({ kind: "turn-summary", files: expect.arrayContaining([ expect.objectContaining({ path: "/workspace/demo/src/App.tsx" }) ]) }); }); it("derives pending approval and ask-user prompts from typed tool rows", () => { const detail = detailViewModel({ turns: [ { id: "turn-1", userMessage: { id: "user-1", body: "Ship it" }, userMessages: [{ id: "user-1", body: "Ship it" }], agentMessages: [], toolCalls: [], toolCallCount: 2, hasFailedToolCall: false, agentItems: [ { kind: "tool-calls", id: "tools-1", toolCalls: [ { id: "call:approval-1", name: "Run command", toolName: "Approval", callType: "approval", status: "waiting_approval", statusKind: "waiting", summary: "Approval required", payload: { input: { requestId: "approval-request-1", options: [ { id: "allow_once", label: "Legacy allow label", name: "Allow once", kind: "allow_once" } ] } } }, { id: "call:ask-1", name: "Ask user", toolName: "AskUserQuestion", callType: "tool", status: "waiting_input", statusKind: "waiting", summary: "", payload: { input: { requestId: "ask-request-1", questions: [ { id: "approach", header: "Approach", question: "Which path should we take?", options: [ { label: "Typed renderer", description: "Keep going" } ] } ] } } } ], toolCallCount: 2, hasFailedToolCall: false } ] } ] }); const conversation = projectAgentConversationVM(detail); expect("pendingApproval" in conversation).toBe(false); expect("pendingInteractivePrompt" in conversation).toBe(false); expect( conversation.rows.flatMap((row) => row.kind === "tool-group" ? row.calls : [] ) ).not.toEqual( expect.arrayContaining([ expect.objectContaining({ toolName: "Approval" }) ]) ); }); it("hides a pending approval nested inside a delegated subagent's steps", () => { const detail = detailViewModel({ turns: [ { id: "turn-1", userMessage: { id: "user-1", body: "Delegate it" }, userMessages: [{ id: "user-1", body: "Delegate it" }], agentMessages: [], toolCalls: [], toolCallCount: 1, hasFailedToolCall: false, agentItems: [ { kind: "tool-calls", id: "tools-1", toolCalls: [ { id: "call:task-1", name: "Run subagent", toolName: "Task", callType: "tool", status: "in_progress", statusKind: "working", summary: "Delegating work", payload: { metadata: { steps: [ { toolUseId: "step-approval-1", toolName: "Bash", callType: "approval", status: "waiting_approval", toolInput: { requestId: "nested-approval-1", options: [ { id: "allow_once", name: "Allow once", kind: "allow_once" } ] } } ] } } } ], toolCallCount: 1, hasFailedToolCall: false } ] } ] }); const conversation = projectAgentConversationVM(detail); expect("pendingApproval" in conversation).toBe(false); const taskCall = conversation.rows .flatMap((row) => (row.kind === "tool-group" ? row.calls : [])) .find((call) => call.toolName === "Task"); expect(taskCall?.task?.steps).toEqual([]); }); it.each([ ["working", "working"], ["completed", "completed"], ["failed", "failed"] ] as const)( "hides %s approval tool calls without hiding neighboring tools", (status, statusKind) => { const conversation = projectAgentConversationVM( detailViewModel({ turns: [ { id: "turn-1", userMessage: { id: "user-1", body: "Write it" }, userMessages: [{ id: "user-1", body: "Write it" }], agentMessages: [], toolCalls: [], toolCallCount: 2, hasFailedToolCall: statusKind === "failed", agentItems: [ { kind: "tool-calls", id: "tools-1", toolCalls: [ { id: "call:write-1", name: "Write file", toolName: "Write", callType: "tool", status: "completed", statusKind: "completed", summary: "hello.md", payload: null }, { id: `call:approval-${status}`, name: "Approval", toolName: "Approval", callType: "approval", status, statusKind, summary: "/workspace/hello.md", payload: null } ], toolCallCount: 2, hasFailedToolCall: statusKind === "failed" } ] } ] }) ); const visibleCalls = conversation.rows.flatMap((row) => row.kind === "tool-group" ? row.calls : [] ); expect(visibleCalls.map((call) => call.toolName)).toEqual(["Write"]); } ); it("surfaces a pending ask-user prompt nested inside a delegated subagent's steps", () => { const detail = detailViewModel({ turns: [ { id: "turn-1", userMessage: { id: "user-1", body: "Delegate it" }, userMessages: [{ id: "user-1", body: "Delegate it" }], agentMessages: [], toolCalls: [], toolCallCount: 1, hasFailedToolCall: false, agentItems: [ { kind: "tool-calls", id: "tools-1", toolCalls: [ { id: "call:task-1", name: "Run subagent", toolName: "Task", callType: "tool", status: "in_progress", statusKind: "working", summary: "Delegating work", payload: { metadata: { steps: [ { toolUseId: "step-ask-1", toolName: "AskUserQuestion", name: "Ask user", callType: "tool", status: "waiting_input", toolInput: { requestId: "nested-ask-1", questions: [ { id: "approach", header: "Approach", question: "Which path should we take?", options: [ { label: "Typed renderer", description: "Keep going" } ] } ] } } ] } } } ], toolCallCount: 1, hasFailedToolCall: false } ] } ] }); const conversation = projectAgentConversationVM(detail); expect("pendingInteractivePrompt" in conversation).toBe(false); }); it("carries runtime exit-plan options through the pending interactive prompt", () => { const detail = detailViewModel({ turns: [ { id: "turn-1", userMessage: { id: "user-1", body: "Plan it" }, userMessages: [{ id: "user-1", body: "Plan it" }], agentMessages: [], toolCalls: [], toolCallCount: 1, hasFailedToolCall: false, agentItems: [ { kind: "tool-calls", id: "tools-1", toolCalls: [ { id: "call:plan-1", name: "Exit plan mode", toolName: "Approval", callType: "approval", status: "waiting_approval", statusKind: "waiting", summary: "", payload: { input: { requestId: "plan-request-1", toolCall: { kind: "switch_mode", title: "Exit plan mode" }, options: [ { optionId: "acceptEdits", name: "Yes, and auto-accept edits", kind: "acceptEdits" }, { optionId: "auto", name: "Yes, and use auto mode", kind: "auto" }, { optionId: "plan", name: "No, keep planning", kind: "plan" } ] } } } ], toolCallCount: 1, hasFailedToolCall: false } ] } ] }); const conversation = projectAgentConversationVM(detail); expect("pendingInteractivePrompt" in conversation).toBe(false); }); it("does not append the processing row when canonical detail suppresses it", () => { const conversation = projectAgentConversationVM( detailViewModel({ showProcessingIndicator: false }) ); expect(conversation.rows.some((row) => row.kind === "processing")).toBe( false ); }); it("preserves the latest transcript turn as processing fallback identity", () => { const firstTurn = detailViewModel().turns[0]!; const secondTurn = { id: "turn-2", userMessage: { id: "user-2", body: "Follow-up request", turnId: "turn-2" }, userMessages: [ { id: "user-2", body: "Follow-up request", turnId: "turn-2" } ], agentMessages: [], toolCalls: [], toolCallCount: 0, hasFailedToolCall: false, agentItems: [] }; const conversation = projectAgentConversationVM( detailViewModel({ turns: [firstTurn, secondTurn], showProcessingIndicator: true }) ); const processing = conversation.rows.find( (row) => row.kind === "processing" ); expect(processing).toEqual( expect.objectContaining({ id: "processing:turn-2", turnId: "turn-2" }) ); }); it("lets latest-turn semantic progress suppress legacy processing fallback", () => { const firstTurn = detailViewModel().turns[0]!; const compactNotice = compactNoticeMessage("turn-2", "running"); const conversation = projectAgentConversationVM( detailViewModel({ turns: [ firstTurn, { id: "turn-2", userMessage: { id: "user-2", body: "Follow-up request", turnId: "turn-2" }, userMessages: [ { id: "user-2", body: "Follow-up request", turnId: "turn-2" } ], agentMessages: [compactNotice], toolCalls: [], toolCallCount: 0, hasFailedToolCall: false, agentItems: [{ kind: "message", message: compactNotice }] } ], showProcessingIndicator: true }) ); expect(conversation.rows.some((row) => row.kind === "processing")).toBe( false ); }); it("scopes transient processing to the canonical active turn", () => { const baseDetail = detailViewModel(); const conversation = projectAgentConversationVM( detailViewModel({ session: { ...baseDetail.session, activeTurnId: "turn-2" }, turns: [ baseDetail.turns[0]!, { id: "turn-2", userMessage: null, userMessages: [], agentMessages: [], toolCalls: [], toolCallCount: 0, hasFailedToolCall: false, agentItems: [] } ], showProcessingIndicator: true }) ); expect(conversation.rows.find((row) => row.kind === "processing")).toEqual( expect.objectContaining({ id: "processing:turn-2", turnId: "turn-2" }) ); }); it("uses canonical live turn timing instead of generic processing", () => { const baseDetail = detailViewModel(); const conversation = projectAgentConversationVM( detailViewModel({ session: { ...baseDetail.session, activeTurnId: "turn-1" }, sessionTurns: [ { agentSessionId: "session-1", origin: "user_prompt", phase: "running", startedAtUnixMs: 5_000, turnId: "turn-1", updatedAtUnixMs: 6_000 } ], showProcessingIndicator: true }) ); expect(conversation.rows.some((row) => row.kind === "processing")).toBe( false ); }); it("uses active semantic progress instead of appending generic processing", () => { const baseDetail = detailViewModel(); const baseTurn = baseDetail.turns[0]!; const compactNotice = compactNoticeMessage("turn-1", "running"); const conversation = projectAgentConversationVM( detailViewModel({ session: { ...baseDetail.session, activeTurnId: "turn-1" }, turns: [ { ...baseTurn, agentMessages: [compactNotice], toolCalls: [], toolCallCount: 0, agentItems: [{ kind: "message", message: compactNotice }] } ], showProcessingIndicator: true }) ); expect(conversation.rows.some((row) => row.kind === "processing")).toBe( false ); expect( conversation.rows.some( (row) => row.kind === "message" && row.messages.some( (message) => message.presentationKind === "specific-progress" ) ) ).toBe(true); }); it.each(["completed", "failed", "canceled"] as const)( "fails open to generic processing after compact is %s", (commandStatus) => { const baseTurn = detailViewModel().turns[0]!; const compactNotice = compactNoticeMessage("turn-1", commandStatus); const conversation = projectAgentConversationVM( detailViewModel({ turns: [ { ...baseTurn, agentMessages: [compactNotice], toolCalls: [], toolCallCount: 0, agentItems: [{ kind: "message", message: compactNotice }] } ], showProcessingIndicator: true }) ); expect(conversation.rows.some((row) => row.kind === "processing")).toBe( true ); } ); it("does not let progress from an older turn suppress current processing", () => { const baseDetail = detailViewModel(); const firstTurn = baseDetail.turns[0]!; const compactNotice = compactNoticeMessage("turn-1", "running"); const secondTurn = { id: "turn-2", userMessage: { id: "user-2", body: "Follow-up", turnId: "turn-2" }, userMessages: [{ id: "user-2", body: "Follow-up", turnId: "turn-2" }], agentMessages: [], toolCalls: [], toolCallCount: 0, hasFailedToolCall: false, agentItems: [] }; const conversation = projectAgentConversationVM( detailViewModel({ session: { ...baseDetail.session, activeTurnId: "turn-2" }, turns: [ { ...firstTurn, agentMessages: [compactNotice], toolCalls: [], toolCallCount: 0, agentItems: [{ kind: "message", message: compactNotice }] }, secondTurn ], showProcessingIndicator: true }) ); expect(conversation.rows.find((row) => row.kind === "processing")).toEqual( expect.objectContaining({ turnId: "turn-2" }) ); }); it("keeps Edit and Write tool calls as standalone rows when avoidGroupingEdits is enabled", () => { const detail = detailViewModel(); const conversation = projectAgentConversationVM(detail, { avoidGroupingEdits: true }); expect(conversation.rows.map((row) => row.kind)).toEqual([ "message", "message", "tool-group", "message", "tool-group", "processing" ]); const toolRows = conversation.rows.filter( ( row ): row is Extract< (typeof conversation.rows)[number], { kind: "tool-group" } > => row.kind === "tool-group" ); expect(toolRows).toHaveLength(2); expect(toolRows.map((row) => row.grouped)).toEqual([true, false]); expect( conversation.rows.some( ( row ): row is Extract< (typeof conversation.rows)[number], { kind: "message" } > => row.kind === "message" && row.speaker === "assistant" && row.thinking.some( (thinking) => thinking.body === "Need to inspect before editing." ) ) ).toBe(true); }); it("merges adjacent assistant message rows within the same turn when no boundary item separates them", () => { const conversation = projectAgentConversationVM( detailViewModel({ turns: [ { id: "turn-1", userMessage: { id: "user-1", body: "Launch the app" }, userMessages: [{ id: "user-1", body: "Launch the app" }], agentMessages: [ { id: "assistant-1", body: "现在可直接访问:`http://" }, { id: "assistant-2", body: "0.0.0.0:4173`" } ], toolCalls: [], toolCallCount: 0, hasFailedToolCall: false, agentItems: [ { kind: "message", message: { id: "assistant-1", body: "现在可直接访问:`http://" } }, { kind: "message", message: { id: "assistant-2", body: "0.0.0.0:4173`", statusKind: "working" } } ] } ], showProcessingIndicator: false }) ); const assistantRows = conversation.rows.filter( ( row ): row is Extract< (typeof conversation.rows)[number], { kind: "message" } > => row.kind === "message" && row.speaker === "assistant" ); expect(assistantRows).toHaveLength(1); expect(assistantRows[0]?.messages.map((message) => message.body)).toEqual([ "现在可直接访问:`http://0.0.0.0:4173`" ]); }); it("keeps a line boundary when merging settled adjacent assistant messages so Markdown fences never fuse", () => { const conversation = projectAgentConversationVM( detailViewModel({ turns: [ { id: "turn-1", userMessage: { id: "user-1", body: "Explain the fix" }, userMessages: [{ id: "user-1", body: "Explain the fix" }], agentMessages: [ { id: "assistant-1", body: "```ts\nconst x = 1;\n```" }, { id: "assistant-2", body: "现在运行 pnpm test" } ], toolCalls: [], toolCallCount: 0, hasFailedToolCall: false, agentItems: [ { kind: "message", message: { id: "assistant-1", body: "```ts\nconst x = 1;\n```" } }, { kind: "message", message: { id: "assistant-2", body: "现在运行 pnpm test", statusKind: "completed" } } ] } ], showProcessingIndicator: false }) ); const assistantRows = conversation.rows.filter( ( row ): row is Extract< (typeof conversation.rows)[number], { kind: "message" } > => row.kind === "message" && row.speaker === "assistant" ); expect(assistantRows).toHaveLength(1); expect(assistantRows[0]?.messages.map((message) => message.body)).toEqual([ "```ts\nconst x = 1;\n```\n现在运行 pnpm test" ]); }); it("keeps a line boundary when merging settled adjacent messages so list markers stay on their own lines", () => { const conversation = projectAgentConversationVM( detailViewModel({ turns: [ { id: "turn-1", userMessage: { id: "user-1", body: "List the steps" }, userMessages: [{ id: "user-1", body: "List the steps" }], agentMessages: [ { id: "assistant-1", body: "- step one\n- step two" }, { id: "assistant-2", body: "- step three" } ], toolCalls: [], toolCallCount: 0, hasFailedToolCall: false, agentItems: [ { kind: "message", message: { id: "assistant-1", body: "- step one\n- step two" } }, { kind: "message", message: { id: "assistant-2", body: "- step three", statusKind: "completed" } } ] } ], showProcessingIndicator: false }) ); const assistantRows = conversation.rows.filter( ( row ): row is Extract< (typeof conversation.rows)[number], { kind: "message" } > => row.kind === "message" && row.speaker === "assistant" ); expect(assistantRows).toHaveLength(1); expect(assistantRows[0]?.messages.map((message) => message.body)).toEqual([ "- step one\n- step two\n- step three" ]); }); it("merges adjacent assistant messages without inserting a boundary when one side already provides a newline", () => { const conversation = projectAgentConversationVM( detailViewModel({ turns: [ { id: "turn-1", userMessage: { id: "user-1", body: "Go" }, userMessages: [{ id: "user-1", body: "Go" }], agentMessages: [ { id: "assistant-1", body: "First line.\n" }, { id: "assistant-2", body: "Second line." } ], toolCalls: [], toolCallCount: 0, hasFailedToolCall: false, agentItems: [ { kind: "message", message: { id: "assistant-1", body: "First line.\n" } }, { kind: "message", message: { id: "assistant-2", body: "Second line.", statusKind: "completed" } } ] } ], showProcessingIndicator: false }) ); const assistantRows = conversation.rows.filter( ( row ): row is Extract< (typeof conversation.rows)[number], { kind: "message" } > => row.kind === "message" && row.speaker === "assistant" ); expect(assistantRows).toHaveLength(1); expect(assistantRows[0]?.messages.map((message) => message.body)).toEqual([ "First line.\nSecond line." ]); }); it("does not keep a previous working status when the merged follow-up carries no status", () => { const conversation = projectAgentConversationVM( detailViewModel({ turns: [ { id: "turn-1", userMessage: { id: "user-1", body: "Go" }, userMessages: [{ id: "user-1", body: "Go" }], agentMessages: [ { id: "assistant-1", body: "Part one" }, { id: "assistant-2", body: "Part two" } ], toolCalls: [], toolCallCount: 0, hasFailedToolCall: false, agentItems: [ { kind: "message", message: { id: "assistant-1", body: "Part one", statusKind: "working" } }, { kind: "message", message: { id: "assistant-2", body: "Part two" } } ] } ], showProcessingIndicator: false }) ); const assistantRows = conversation.rows.filter( ( row ): row is Extract< (typeof conversation.rows)[number], { kind: "message" } > => row.kind === "message" && row.speaker === "assistant" ); expect(assistantRows).toHaveLength(1); const mergedMessage = assistantRows[0]?.messages[0]; expect(mergedMessage?.body).toBe("Part one\nPart two"); expect(mergedMessage?.statusKind).toBeNull(); }); it("marks user text and each settled turn's latest assistant text reply as copyable", () => { const conversation = projectAgentConversationVM( detailViewModel({ session: { ...detailViewModel().session, activeTurn: null, activeTurnId: null, latestTurn: { agentSessionId: "session-1", outcome: "completed", origin: "user_prompt", phase: "settled", settledAtUnixMs: 10, startedAtUnixMs: 1, turnId: "turn-1", updatedAtUnixMs: 10 } }, turns: [ { id: "turn-1", userMessage: { id: "user-1", body: "First request" }, userMessages: [{ id: "user-1", body: "First request" }], agentMessages: [{ id: "assistant-1", body: "Older answer" }], toolCalls: [], toolCallCount: 0, hasFailedToolCall: false, agentItems: [ { kind: "message", message: { id: "assistant-1", body: "Older answer" } } ] }, { id: "turn-2", userMessage: { id: "user-2", body: "Second request" }, userMessages: [{ id: "user-2", body: "Second request" }], agentMessages: [{ id: "assistant-2", body: "Latest answer" }], toolCalls: [], toolCallCount: 0, hasFailedToolCall: false, agentItems: [ { kind: "message", message: { id: "assistant-2", body: "Latest answer" } } ] } ], showProcessingIndicator: false }) ); const messageRows = conversation.rows.filter( ( row ): row is Extract< (typeof conversation.rows)[number], { kind: "message" } > => row.kind === "message" ); const userMessages = messageRows .filter((row) => row.speaker === "user") .flatMap((row) => row.messages); const assistantMessages = messageRows .filter((row) => row.speaker === "assistant") .flatMap((row) => row.messages); expect(userMessages.map((message) => message.copyText)).toEqual([ "First request", "Second request" ]); expect( assistantMessages.map((message) => ({ id: message.id, copyText: message.copyText ?? null, isTurnFinalText: message.isTurnFinalText === true })) ).toEqual([ { id: "assistant-1", copyText: "Older answer", isTurnFinalText: true }, { id: "assistant-2", copyText: "Latest answer", isTurnFinalText: true } ]); }); it("marks prior turn assistant replies copyable while the latest turn is still working", () => { const conversation = projectAgentConversationVM( detailViewModel({ session: { ...detailViewModel().session, activeTurn: { agentSessionId: "session-1", outcome: null, origin: "user_prompt", phase: "running", settledAtUnixMs: null, startedAtUnixMs: 1, turnId: "turn-2", updatedAtUnixMs: 10 }, activeTurnId: "turn-2" }, turns: [ { id: "turn-1", userMessage: { id: "user-1", body: "First request" }, userMessages: [{ id: "user-1", body: "First request" }], agentMessages: [{ id: "assistant-1", body: "Prior answer" }], toolCalls: [], toolCallCount: 0, hasFailedToolCall: false, agentItems: [ { kind: "message", message: { id: "assistant-1", body: "Prior answer" } } ] }, { id: "turn-2", userMessage: { id: "user-2", body: "Keep going" }, userMessages: [{ id: "user-2", body: "Keep going" }], agentMessages: [{ id: "assistant-2", body: "Still working" }], toolCalls: [], toolCallCount: 0, hasFailedToolCall: false, agentItems: [ { kind: "message", message: { id: "assistant-2", body: "Still working" } } ] } ], showProcessingIndicator: false }) ); const assistantMessages = conversation.rows .filter( ( row ): row is Extract< (typeof conversation.rows)[number], { kind: "message" } > => row.kind === "message" && row.speaker === "assistant" ) .flatMap((row) => row.messages); expect( assistantMessages.map((message) => ({ id: message.id, copyText: message.copyText ?? null, isTurnFinalText: message.isTurnFinalText === true })) ).toEqual([ { id: "assistant-1", copyText: "Prior answer", isTurnFinalText: true }, { id: "assistant-2", copyText: null, isTurnFinalText: false } ]); }); it("does not mark assistant replies copyable while the session is working", () => { const conversation = projectAgentConversationVM( detailViewModel({ session: { ...detailViewModel().session, activeTurn: { agentSessionId: "session-1", outcome: null, origin: "user_prompt", phase: "running", settledAtUnixMs: null, startedAtUnixMs: 1, turnId: "turn-1", updatedAtUnixMs: 10 }, activeTurnId: "turn-1" }, turns: [ { id: "turn-1", userMessage: { id: "user-1", body: "Stream a response" }, userMessages: [{ id: "user-1", body: "Stream a response" }], agentMessages: [ { id: "assistant-working", body: "Still streaming" } ], toolCalls: [], toolCallCount: 0, hasFailedToolCall: false, agentItems: [ { kind: "message", message: { id: "assistant-working", body: "Still streaming" } } ] } ], showProcessingIndicator: false }) ); const assistantRow = conversation.rows.find( ( row ): row is Extract< (typeof conversation.rows)[number], { kind: "message" } > => row.kind === "message" && row.speaker === "assistant" ); expect(assistantRow?.messages[0]?.copyText).toBeUndefined(); }); it("projects user prompt images before text and keeps the session workspace id", () => { const conversation = projectAgentConversationVM( detailViewModel({ session: { ...detailViewModel().session, workspaceId: "room-1" }, turns: [ { id: "turn-1", userMessage: null, userMessages: [ { id: "user-1", body: "你能看到这个图吗", sourceTimelineItems: [ { id: 1, agentSessionId: "session-1", eventId: "event-1", actorType: "user", actorId: "user", itemType: "message", role: "user", payload: { content: [ { type: "text", text: "你能看到这个图吗" }, { type: "image", mimeType: "image/png", attachmentId: "attachment-1", url: "https://objects.example.test/signed/screen.png", name: "screen.png" } ] } } ] } ], agentMessages: [], toolCalls: [], toolCallCount: 0, hasFailedToolCall: false, agentItems: [] } ], showProcessingIndicator: false }) ); const userRow = conversation.rows.find( ( row ): row is Extract< (typeof conversation.rows)[number], { kind: "message" } > => row.kind === "message" && row.speaker === "user" ); expect(userRow?.messages.map((message) => message.contentKind)).toEqual([ "image-grid", "text" ]); expect(userRow?.messages[0]?.images?.[0]).toMatchObject({ workspaceId: "room-1", agentSessionId: "session-1", attachmentId: "attachment-1", url: "https://objects.example.test/signed/screen.png", mimeType: "image/png", name: "screen.png" }); }); it("keeps a signed image URL when its MIME type is absent", () => { const conversation = projectAgentConversationVM( detailViewModel({ session: { ...detailViewModel().session, workspaceId: "room-1" }, turns: [ { id: "turn-1", userMessage: null, userMessages: [ { id: "user-1", body: "", sourceTimelineItems: [ { id: 1, agentSessionId: "session-1", eventId: "event-1", actorType: "user", actorId: "user", itemType: "message", role: "user", payload: { content: [ { type: "image", url: "https://objects.example.test/signed/screen.png" } ] } } ] } ], agentMessages: [], toolCalls: [], toolCallCount: 0, hasFailedToolCall: false, agentItems: [] } ], showProcessingIndicator: false }) ); const userRow = conversation.rows.find( ( row ): row is Extract< (typeof conversation.rows)[number], { kind: "message" } > => row.kind === "message" && row.speaker === "user" ); expect(userRow?.messages.map((message) => message.contentKind)).toEqual([ "image-grid" ]); expect(userRow?.messages[0]?.images?.[0]).toMatchObject({ url: "https://objects.example.test/signed/screen.png", mimeType: "" }); }); it("replaces rich user prompt text blocks with displayPrompt while preserving images", () => { const conversation = projectAgentConversationVM( detailViewModel({ session: { ...detailViewModel().session, workspaceId: "room-1" }, turns: [ { id: "turn-1", userMessage: null, userMessages: [ { id: "user-1", body: "Run Automation", sourceTimelineItems: [ { id: 1, agentSessionId: "session-1", eventId: "event-1", actorType: "user", actorId: "user", itemType: "message", role: "user", payload: { displayPrompt: "Run Automation", content: [ { type: "text", text: "long automation prompt" }, { type: "image", mimeType: "image/png", attachmentId: "attachment-1", name: "screen.png" } ] } } ] } ], agentMessages: [], toolCalls: [], toolCallCount: 0, hasFailedToolCall: false, agentItems: [] } ], showProcessingIndicator: false }) ); const userRow = conversation.rows.find( ( row ): row is Extract< (typeof conversation.rows)[number], { kind: "message" } > => row.kind === "message" && row.speaker === "user" ); expect(userRow?.messages.map((message) => message.contentKind)).toEqual([ "image-grid", "text" ]); expect(userRow?.messages[0]?.images?.[0]?.attachmentId).toBe( "attachment-1" ); expect(userRow?.messages[1]?.body).toBe("Run Automation"); expect(userRow?.messages.map((message) => message.body)).not.toContain( "long automation prompt" ); }); it("projects selected prompt text as a reference part instead of flattening it into the bubble", () => { const conversation = projectAgentConversationVM( detailViewModel({ turns: [ { id: "turn-1", userMessage: null, userMessages: [ { id: "user-1", body: "这里说了什么\n> 对 tutti-lab/tutti 的影响是:它需要把 Go 依赖从旧的 tsh-server module 更新到新的 tutti-server module。", sourceTimelineItems: [ { id: 1, agentSessionId: "session-1", eventId: "event-1", actorType: "user", actorId: "user", itemType: "message", role: "user", payload: { displayPrompt: "这里说了什么\n> 对 tutti-lab/tutti 的影响是:它需要把 Go 依赖从旧的 tsh-server module 更新到新的 tutti-server module。", content: [ { type: "text", text: "这里说了什么" }, { type: "text", text: "> 对 tutti-lab/tutti 的影响是:它需要把 Go 依赖从旧的 tsh-server module 更新到新的 tutti-server module。" } ] } } ] } ], agentMessages: [], toolCalls: [], toolCallCount: 0, hasFailedToolCall: false, agentItems: [] } ], showProcessingIndicator: false }) ); const userRow = conversation.rows.find( ( row ): row is Extract< (typeof conversation.rows)[number], { kind: "message" } > => row.kind === "message" && row.speaker === "user" ); expect(userRow?.messages.map((message) => message.contentKind)).toEqual([ "selected-text", "text" ]); expect(userRow?.messages[0]).toMatchObject({ body: "", selectedText: { count: 1, texts: [ "对 tutti-lab/tutti 的影响是:它需要把 Go 依赖从旧的 tsh-server module 更新到新的 tutti-server module。" ] } }); expect(userRow?.messages[1]?.body).toBe("这里说了什么"); }); it("projects every legacy quote after a typed prompt into one reference part", () => { const conversation = projectUserPromptForTest({ body: "Ask about both excerpts\n> first excerpt\n> second excerpt", displayPrompt: "Ask about both excerpts\n> first excerpt\n> second excerpt", content: [ { type: "text", text: "Ask about both excerpts" }, { type: "text", text: "> first excerpt" }, { type: "text", text: "> second excerpt" } ] }); const userRow = conversation.rows.find( ( row ): row is Extract< (typeof conversation.rows)[number], { kind: "message" } > => row.kind === "message" && row.speaker === "user" ); expect(userRow?.messages.map((message) => message.contentKind)).toEqual([ "selected-text", "text" ]); expect(userRow?.messages[0]?.selectedText).toEqual({ count: 2, texts: ["first excerpt", "second excerpt"] }); expect(userRow?.messages[1]?.body).toBe("Ask about both excerpts"); }); it("keeps ambiguous quote-only legacy content as ordinary Markdown", () => { const conversation = projectUserPromptForTest({ body: "> first authored line\n> second authored line", displayPrompt: "> first authored line\n> second authored line", content: [ { type: "text", text: "> first authored line" }, { type: "text", text: "> second authored line" } ] }); const userRow = conversation.rows.find( ( row ): row is Extract< (typeof conversation.rows)[number], { kind: "message" } > => row.kind === "message" && row.speaker === "user" ); expect(userRow?.messages.map((message) => message.contentKind)).toEqual([ "text" ]); expect(userRow?.messages[0]?.body).toBe( "> first authored line\n> second authored line" ); }); it("preserves an explicit selected-text kind already present in persisted content", () => { const conversation = projectAgentConversationVM( detailViewModel({ turns: [ { id: "turn-1", userMessage: null, userMessages: [ { id: "user-1", body: "Ask about the selection", sourceTimelineItems: [ { id: 1, agentSessionId: "session-1", eventId: "event-1", actorType: "user", actorId: "user", itemType: "message", role: "user", payload: { displayPrompt: "Ask about the selection", content: [ { type: "text", text: "Ask about the selection" }, { type: "text", kind: "selected-text", text: "Selected source text" } ] } } ] } ], agentMessages: [], toolCalls: [], toolCallCount: 0, hasFailedToolCall: false, agentItems: [] } ], showProcessingIndicator: false }) ); const userRow = conversation.rows.find( ( row ): row is Extract< (typeof conversation.rows)[number], { kind: "message" } > => row.kind === "message" && row.speaker === "user" ); expect(userRow?.messages[0]?.selectedText).toEqual({ count: 1, texts: ["Selected source text"] }); expect(userRow?.messages[1]?.body).toBe("Ask about the selection"); }); it("prefers materialized content text over legacy composer-file displayPrompt", () => { const conversation = projectAgentConversationVM( detailViewModel({ session: { ...detailViewModel().session, workspaceId: "room-1" }, turns: [ { id: "turn-1", userMessage: null, userMessages: [ { id: "user-1", body: "summarize", sourceTimelineItems: [ { id: 1, agentSessionId: "session-1", eventId: "event-1", actorType: "user", actorId: "user", itemType: "message", role: "user", payload: { displayPrompt: "summarize [diagnostics.md](mention://composer-file/file-1?status=ready)", content: [ { type: "text", text: "summarize [@diagnostics.md](/runtime/diagnostics.md)" } ] } } ] } ], agentMessages: [], toolCalls: [], toolCallCount: 0, hasFailedToolCall: false, agentItems: [] } ], showProcessingIndicator: false }) ); const userRow = conversation.rows.find( ( row ): row is Extract< (typeof conversation.rows)[number], { kind: "message" } > => row.kind === "message" && row.speaker === "user" ); expect(userRow?.messages[0]?.body).toBe( "summarize [@diagnostics.md](/runtime/diagnostics.md)" ); expect(userRow?.messages[0]?.body).not.toContain( "mention://composer-file/" ); }); it("does not render a synthetic image-only displayPrompt below the image", () => { const conversation = projectAgentConversationVM( detailViewModel({ session: { ...detailViewModel().session, workspaceId: "room-1" }, turns: [ { id: "turn-1", userMessage: null, userMessages: [ { id: "user-1", body: "[Image]", sourceTimelineItems: [ { id: 1, agentSessionId: "session-1", eventId: "event-1", actorType: "user", actorId: "user", itemType: "message", role: "user", payload: { displayPrompt: "[Image]", content: [ { type: "image", mimeType: "image/png", attachmentId: "attachment-1", name: "screen.png" } ] } } ] } ], agentMessages: [], toolCalls: [], toolCallCount: 0, hasFailedToolCall: false, agentItems: [] } ], showProcessingIndicator: false }) ); const userRow = conversation.rows.find( ( row ): row is Extract< (typeof conversation.rows)[number], { kind: "message" } > => row.kind === "message" && row.speaker === "user" ); expect(userRow?.messages.map((message) => message.contentKind)).toEqual([ "image-grid" ]); expect(userRow?.messages.map((message) => message.body)).not.toContain( "[Image]" ); }); it("reuses unchanged transcript row references across incremental updates", () => { const firstTurn = detailViewModel().turns[0]!; const secondTurn = { id: "turn-2", userMessage: { id: "user-2", body: "Follow-up request", turnId: "turn-2" }, userMessages: [ { id: "user-2", body: "Follow-up request", turnId: "turn-2" } ], agentMessages: [ { id: "assistant-2", body: "Working on it", turnId: "turn-2" } ], toolCalls: [], toolCallCount: 0, hasFailedToolCall: false, agentItems: [ { kind: "message" as const, message: { id: "assistant-2", body: "Working on it", turnId: "turn-2" } } ] }; const previous = projectAgentConversationVM( detailViewModel({ session: { ...detailViewModel().session, latestTurn: { ...detailViewModel().session.latestTurn!, turnId: "turn-2", fileChanges: null } }, turns: [firstTurn, secondTurn], showProcessingIndicator: false }) ); const next = projectAgentConversationVM( detailViewModel({ session: { ...detailViewModel().session, latestTurn: { ...detailViewModel().session.latestTurn!, turnId: "turn-2", fileChanges: null } }, turns: [ firstTurn, { ...secondTurn, agentMessages: [ ...secondTurn.agentMessages, { id: "assistant-3", body: "Done now", turnId: "turn-2" } ], agentItems: [ ...secondTurn.agentItems, { kind: "message" as const, message: { id: "assistant-3", body: "Done now", turnId: "turn-2" } } ] } ], showProcessingIndicator: false }) ); const reconciled = reconcileProjectedAgentConversationVM(previous, next); const previousFirstTurnRows = previous.rows.filter( (row) => row.turnId === firstTurn.id ); const reconciledFirstTurnRows = reconciled.rows.filter( (row) => row.turnId === firstTurn.id ); expect(reconciledFirstTurnRows).toHaveLength(previousFirstTurnRows.length); reconciledFirstTurnRows.forEach((row, index) => { expect(row).toBe(previousFirstTurnRows[index]); }); expect(reconciled.rows.at(-1)).not.toBe(previous.rows.at(-1)); }); it("reuses transcript rows when only render-irrelevant timestamps change", () => { const baseDetail = detailViewModel(); const previous = projectAgentConversationVM(baseDetail); const next = projectAgentConversationVM( detailViewModel({ session: { ...baseDetail.session, updatedAtUnixMs: 99 } }) ); const reconciled = reconcileProjectedAgentConversationVM(previous, next); expect(reconciled.rows).toBe(previous.rows); }); }); function detailViewModel( overrides: Partial = {} ): WorkspaceAgentSessionDetailViewModel { return { activity: { id: "activity-1", sessionId: "session-1", agentName: "Codex", agentProvider: "codex", title: "Codex", latestActivitySummary: "Working", status: "working", sortTimeUnixMs: 10, changedFiles: [{ path: "src/App.tsx", label: "src/App.tsx" }], userId: "user-1", userName: "Taylor", userAvatarUrl: "" }, session: normalizeAgentActivitySession({ ...{ activeTurnId: null, latestTurnInteractions: [], pendingInteractions: [] }, workspaceId: "workspace-1", agentSessionId: "session-1", userId: "user-1", provider: "codex", providerSessionId: "provider-session-1", cwd: "/workspace/demo", title: "Codex", createdAtUnixMs: 1, updatedAtUnixMs: 10, latestTurn: { agentSessionId: "session-1", turnId: "turn-1", phase: "settled", origin: "user_prompt", outcome: "completed", startedAtUnixMs: 1, settledAtUnixMs: 10, updatedAtUnixMs: 10, fileChanges: { files: [ { path: "/workspace/demo/src/App.tsx", change: "modified" } ] } } }), cwd: "/workspace/demo", workspaceRoot: "/workspace/demo", sessionTurns: [ { agentSessionId: "session-1", turnId: "turn-1", phase: "settled", origin: "user_prompt", outcome: "completed", startedAtUnixMs: 1, settledAtUnixMs: 10, updatedAtUnixMs: 10, fileChanges: { files: [ { path: "/workspace/demo/src/App.tsx", change: "modified" } ] } } ], turns: [ { id: "turn-1", userMessage: { id: "user-1", body: "Ship it" }, userMessages: [{ id: "user-1", body: "Ship it" }], agentMessages: [{ id: "assistant-1", body: "On it" }], toolCalls: [], toolCallCount: 2, hasFailedToolCall: false, agentItems: [ { kind: "message", message: { id: "assistant-1", body: "On it" } }, { kind: "tool-calls", id: "tools-1", toolCalls: [ { id: "call:1", name: "Read file", toolName: "read_file", callType: "tool", status: "Completed", statusKind: "completed", summary: "/workspace/demo/README.md", payload: null }, { id: "call:2", name: "Edit file", toolName: "edit_file", callType: "tool", status: "Completed", statusKind: "completed", summary: "/workspace/demo/src/App.tsx", payload: null } ], toolCallCount: 2, hasFailedToolCall: false, summary: "Read and edited files", groupEntries: [ { kind: "tool-call", call: { id: "call:1", name: "Read file", toolName: "read_file", callType: "tool", status: "Completed", statusKind: "completed", summary: "/workspace/demo/README.md", payload: null } }, { kind: "thinking", thinking: { id: "thinking-1", body: "Need to inspect before editing." } }, { kind: "tool-call", call: { id: "call:2", name: "Edit file", toolName: "edit_file", callType: "tool", status: "Completed", statusKind: "completed", summary: "/workspace/demo/src/App.tsx", payload: null } } ] } ] } ], showProcessingIndicator: true, ...overrides }; } function projectUserPromptForTest(input: { body: string; displayPrompt: string; content: readonly unknown[]; }): ReturnType { const baseTurn = detailViewModel().turns[0]!; return projectAgentConversationVM( detailViewModel({ turns: [ { ...baseTurn, id: "turn-user-prompt", userMessage: null, userMessages: [ { id: "user-prompt", body: input.body, sourceTimelineItems: [ { id: 1, agentSessionId: "session-1", eventId: "event-1", actorType: "user", actorId: "user", itemType: "message", role: "user", payload: { displayPrompt: input.displayPrompt, content: input.content } } ] } ], agentMessages: [], toolCalls: [], toolCallCount: 0, hasFailedToolCall: false, agentItems: [] } ], showProcessingIndicator: false }) ); } function compactNoticeMessage( turnId: string, commandStatus: "running" | "completed" | "failed" | "canceled" ): WorkspaceAgentSessionDetailViewModel["turns"][number]["agentMessages"][number] { return { id: `compact:${turnId}`, body: "Compact notice", turnId, systemNotice: { noticeKind: "system_notice", severity: null, command: "compact", commandStatus, title: "Compact notice", detail: null, retryable: null } }; }