import { describe, expect, it } from "vitest"; import { normalizeAgentActivitySession, type AgentActivitySession } from "@tutti-os/agent-activity-core"; import type { WorkspaceAgentActivityTimelineItem } from "../../workspaceAgentTimelineTypes"; import type { WorkspaceAgentActivityCard } from "../../workspaceAgentActivityListViewModel"; import { buildWorkspaceAgentSessionDetailViewModel } from "../../workspaceAgentSessionDetailViewModel"; import { buildCanonicalWorkspaceAgentDetailView, projectWorkspaceAgentTimelineToConversationVM } from "./workspaceAgentTimelineProjection"; describe("projectWorkspaceAgentTimelineToConversationVM", () => { it("groups adjacent tool calls and bridge thinking directly from timeline items", () => { const conversation = projectWorkspaceAgentTimelineToConversationVM({ activity: activity(), session: session(), workspaceRoot: "/workspace/demo", timelineItems: timelineItems() }); expect(conversation.rows.map((row) => row.kind)).toEqual([ "message", "message", "tool-group", "tool-group" ]); const groupedRows = conversation.rows.filter( ( row ): row is Extract< (typeof conversation.rows)[number], { kind: "tool-group" } > => row.kind === "tool-group" && row.grouped ); expect(groupedRows).toHaveLength(1); expect(groupedRows[0]?.entries.map((entry) => entry.kind)).toEqual([ "tool-call", "thinking", "tool-call" ]); expect(groupedRows[0]?.entries[1]?.kind).toBe("thinking"); expect( groupedRows[0]?.entries[1]?.kind === "thinking" ? groupedRows[0].entries[1].thinking.body : null ).toBe("Need to inspect before editing."); expect("pendingApproval" in conversation).toBe(false); expect("pendingInteractivePrompt" in conversation).toBe(false); const summaryRow = conversation.rows.find( ( row ): row is Extract< (typeof conversation.rows)[number], { kind: "turn-summary" } > => row.kind === "turn-summary" ); expect(summaryRow).toBeUndefined(); }); it("builds canonical detail turns directly from timeline items without the legacy detail builder", () => { const detail = buildCanonicalWorkspaceAgentDetailView({ activity: activity(), session: session({ effectiveStatus: "working", turnPhase: "working" }), workspaceRoot: "/workspace/demo", timelineItems: timelineItems() }); expect(detail.turns).toHaveLength(1); expect(detail.turns[0]?.rawAgentItems?.map((item) => item.kind)).toEqual([ "message", "tool-calls", "thinking", "tool-calls", "tool-calls", "tool-calls" ]); expect(detail.turns[0]?.agentItems.map((item) => item.kind)).toEqual([ "message", "tool-calls", "thinking", "tool-calls", "tool-calls", "tool-calls" ]); expect(detail.showProcessingIndicator).toBe(false); }); it("omits Claude synthetic interrupt messages from left detail conversation rows", () => { const conversation = projectWorkspaceAgentTimelineToConversationVM({ activity: activity(), session: session({ provider: "claude-code", title: "[Request interrupted by user]" }), workspaceRoot: "/workspace/demo", timelineItems: [ { ...timelineItems()[0]!, content: "[Request interrupted by user]" }, { ...timelineItems()[0]!, id: 11, eventId: "event-11", content: "[Request interrupted by user for tool use]", occurredAtUnixMs: 11, createdAtUnixMs: 11 }, { ...timelineItems()[0]!, id: 12, eventId: "event-12", content: "继续正常请求", occurredAtUnixMs: 12, createdAtUnixMs: 12 }, timelineItems()[1]! ] }); const userMessages = conversation.rows .filter( ( row ): row is Extract< (typeof conversation.rows)[number], { kind: "message" } > => row.kind === "message" && row.speaker === "user" ) .flatMap((row) => row.messages.map((message) => message.body)); expect(userMessages).toEqual(["继续正常请求"]); }); it("prefers durable sequence order over drifted timestamps", () => { const conversation = projectWorkspaceAgentTimelineToConversationVM({ activity: activity(), session: session(), workspaceRoot: "/workspace/demo", timelineItems: timelineItems().map((item) => ({ ...item, occurredAtUnixMs: item.seq === 3 ? 300 : item.seq === 4 ? 40 : item.seq === 5 ? 50 : item.seq, createdAtUnixMs: item.seq === 3 ? 300 : item.seq === 4 ? 40 : item.seq === 5 ? 50 : item.seq })) }); expect(conversation.rows.map((row) => row.kind)).toEqual([ "message", "message", "tool-group", "tool-group" ]); }); it("keeps the legacy detail builder aligned with the canonical timeline projection helper", () => { const input = { activity: activity(), session: session({ effectiveStatus: "working", turnPhase: "working" }), workspaceRoot: "/workspace/demo", timelineItems: timelineItems() }; const canonical = buildCanonicalWorkspaceAgentDetailView(input); const legacy = buildWorkspaceAgentSessionDetailViewModel(input); expect(legacy).toEqual(canonical); }); it("respects avoidGroupingEdits when rebuilding rows directly from timeline items", () => { const conversation = projectWorkspaceAgentTimelineToConversationVM( { activity: activity(), session: session({ effectiveStatus: "completed", turnPhase: "completed" }), workspaceRoot: "/workspace/demo", timelineItems: timelineItems() }, { avoidGroupingEdits: true } ); const groupedRows = conversation.rows.filter( ( row ): row is Extract< (typeof conversation.rows)[number], { kind: "tool-group" } > => row.kind === "tool-group" && row.grouped ); expect(groupedRows).toHaveLength(1); expect(groupedRows[0]?.calls.map((call) => call.id)).toEqual([ "call:read-1" ]); 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("projects transport notices without merging them into assistant content", () => { const conversation = projectWorkspaceAgentTimelineToConversationVM({ activity: activity(), session: session(), workspaceRoot: "/workspace/demo", timelineItems: [ timelineItems()[0]!, { id: 98, workspaceId: "room-1", agentSessionId: "session-1", turnId: "turn-1", seq: 98, eventId: "notice:event-98", actorType: "agent", actorId: "codex", itemType: "message.assistant", role: "assistant", status: "completed", payload: { kind: "agent_system_notice", noticeKind: "transport_retry", severity: "warning", title: "Codex connection interrupted. Reconnecting...", detail: "ResponseStreamDisconnected: websocket IO error: Broken pipe", retryable: true, content: "Codex connection interrupted. Reconnecting...", text: "Codex connection interrupted. Reconnecting..." }, occurredAtUnixMs: 98, createdAtUnixMs: 98 }, { id: 99, workspaceId: "room-1", agentSessionId: "session-1", turnId: "turn-1", seq: 99, eventId: "event-99", actorType: "agent", actorId: "codex", itemType: "message.assistant", role: "assistant", status: "completed", content: "Done.", occurredAtUnixMs: 99, createdAtUnixMs: 99 } ] }); const assistantRows = conversation.rows.filter( ( row ): row is Extract< (typeof conversation.rows)[number], { kind: "message" } > => row.kind === "message" && row.speaker === "assistant" ); expect(assistantRows).toHaveLength(2); expect(assistantRows[0]?.messages[0]?.systemNotice).toEqual({ noticeKind: "transport_retry", severity: "warning", title: "Codex connection interrupted. Reconnecting...", detail: "ResponseStreamDisconnected: websocket IO error: Broken pipe", retryable: true }); expect(assistantRows[1]?.messages[0]?.body).toBe("Done."); }); it("maps review-process thinking timeline items to assistant message rows", () => { const detail = buildCanonicalWorkspaceAgentDetailView({ activity: activity(), session: session(), workspaceRoot: "/workspace/demo", timelineItems: [ { id: 1, workspaceId: "room-1", agentSessionId: "session-1", turnId: "turn-review", seq: 1, eventId: "event-1", actorType: "user", actorId: "user-1", itemType: "message.user", role: "user", content: "/review", occurredAtUnixMs: 1, createdAtUnixMs: 1 }, { id: 2, workspaceId: "room-1", agentSessionId: "session-1", turnId: "turn-review", seq: 2, eventId: "event-2", actorType: "agent", actorId: "codex", itemType: "message.assistant_thinking", role: "assistant_thinking", status: "completed", content: "**Considering workspace registration order**\n\nInspecting the auth flow.", payload: { messageKind: "review-process" }, occurredAtUnixMs: 2, createdAtUnixMs: 2 } ] }); expect(detail.turns[0]?.agentItems).toEqual([ expect.objectContaining({ kind: "message", message: expect.objectContaining({ body: "Inspecting the auth flow." }) }) ]); }); it("does not append processing after a terminal assistant message even if the session patch is still working", () => { const completedReplyTimelineItems: WorkspaceAgentActivityTimelineItem[] = [ timelineItems()[0]!, { id: 99, workspaceId: "room-1", agentSessionId: "session-1", turnId: "turn-1", seq: 99, eventId: "event-99", actorType: "agent", actorId: "codex", itemType: "message.assistant", role: "assistant", status: "completed", content: "Done.", occurredAtUnixMs: 99, createdAtUnixMs: 99 } ]; const detail = buildCanonicalWorkspaceAgentDetailView({ activity: activity(), session: session({ effectiveStatus: "working", turnPhase: "working" }), workspaceRoot: "/workspace/demo", timelineItems: completedReplyTimelineItems }); const conversation = projectWorkspaceAgentTimelineToConversationVM({ activity: activity(), session: session({ effectiveStatus: "working", turnPhase: "working" }), workspaceRoot: "/workspace/demo", timelineItems: completedReplyTimelineItems }); expect(detail.showProcessingIndicator).toBe(false); expect(conversation.rows.some((row) => row.kind === "processing")).toBe( false ); }); it("keeps processing after an interim assistant message while the turn lifecycle reports an active turn", () => { const interimReplyTimelineItems: WorkspaceAgentActivityTimelineItem[] = [ timelineItems()[0]!, { id: 99, workspaceId: "room-1", agentSessionId: "session-1", turnId: "turn-1", seq: 99, eventId: "event-99", actorType: "agent", actorId: "codex", itemType: "message.assistant", role: "assistant", status: "completed", content: "I will now dispatch the sub-agents.", occurredAtUnixMs: 99, createdAtUnixMs: 99 } ]; const workingSession = { ...session({ effectiveStatus: "working", turnPhase: "working" }), activeTurn: activeTurn("running") }; const detail = buildCanonicalWorkspaceAgentDetailView({ activity: activity(), session: workingSession, workspaceRoot: "/workspace/demo", timelineItems: interimReplyTimelineItems }); const conversation = projectWorkspaceAgentTimelineToConversationVM({ activity: activity(), session: workingSession, workspaceRoot: "/workspace/demo", timelineItems: interimReplyTimelineItems }); expect(detail.showProcessingIndicator).toBe(true); expect(conversation.rows.some((row) => row.kind === "processing")).toBe( true ); }); it("keeps processing when session status is stale ready but turn lifecycle is still running", () => { const completedToolTimelineItems = timelineItems().slice(0, 5); const staleReadySession = { ...session({ effectiveStatus: "ready", turnPhase: "idle" }), activeTurn: activeTurn("running") }; const detail = buildCanonicalWorkspaceAgentDetailView({ activity: activity(), session: staleReadySession, workspaceRoot: "/workspace/demo", timelineItems: completedToolTimelineItems }); const conversation = projectWorkspaceAgentTimelineToConversationVM({ activity: activity(), session: staleReadySession, workspaceRoot: "/workspace/demo", timelineItems: completedToolTimelineItems }); expect(detail.showProcessingIndicator).toBe(true); expect(conversation.rows.some((row) => row.kind === "processing")).toBe( true ); }); it("does not append processing after a terminal assistant message once the turn lifecycle is settling", () => { const settlingReplyTimelineItems: WorkspaceAgentActivityTimelineItem[] = [ timelineItems()[0]!, { id: 99, workspaceId: "room-1", agentSessionId: "session-1", turnId: "turn-1", seq: 99, eventId: "event-99", actorType: "agent", actorId: "codex", itemType: "message.assistant", role: "assistant", status: "completed", content: "Done.", occurredAtUnixMs: 99, createdAtUnixMs: 99 } ]; const settlingSession = { ...session({ effectiveStatus: "working", turnPhase: "working" }), activeTurn: activeTurn("settled") }; const detail = buildCanonicalWorkspaceAgentDetailView({ activity: activity(), session: settlingSession, workspaceRoot: "/workspace/demo", timelineItems: settlingReplyTimelineItems }); expect(detail.showProcessingIndicator).toBe(false); }); it("keeps processing after settle until authoritative terminal transcript content arrives", () => { const userOnlyTimelineItems: WorkspaceAgentActivityTimelineItem[] = [ timelineItems()[0]! ]; const settledWithoutFinal = { ...session({ effectiveStatus: "working", turnPhase: "working" }), activeTurn: activeTurn("settled") }; const detail = buildCanonicalWorkspaceAgentDetailView({ activity: activity(), session: settledWithoutFinal, workspaceRoot: "/workspace/demo", timelineItems: userOnlyTimelineItems }); const conversation = projectWorkspaceAgentTimelineToConversationVM({ activity: activity(), session: settledWithoutFinal, workspaceRoot: "/workspace/demo", timelineItems: userOnlyTimelineItems }); expect(detail.showProcessingIndicator).toBe(true); expect(conversation.rows.some((row) => row.kind === "processing")).toBe( true ); }); }); function activity( overrides: Partial = {} ): WorkspaceAgentActivityCard { return { id: "activity-1", sessionId: "session-1", agentName: "Codex", agentProvider: "codex", status: "working", title: "Codex", latestActivitySummary: "Working", sortTimeUnixMs: 10, changedFiles: [], userId: "user-1", userName: "Taylor", userAvatarUrl: "", ...overrides }; } function session( overrides: Partial & { effectiveStatus?: string; turnPhase?: string; } = {} ): AgentActivitySession { const { effectiveStatus: _effectiveStatus, turnPhase: _turnPhase, ...canonical } = overrides; return 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" } ] } }, ...canonical }); } function activeTurn(phase: "running" | "settled") { return { agentSessionId: "session-1", origin: "user_prompt" as const, phase, startedAtUnixMs: 1, turnId: "turn-1", updatedAtUnixMs: 10 }; } function timelineItems(): WorkspaceAgentActivityTimelineItem[] { return [ { id: 1, workspaceId: "room-1", agentSessionId: "session-1", turnId: "turn-1", seq: 1, eventId: "event-1", actorType: "user", actorId: "user-1", itemType: "message.user", role: "user", content: "Ship the patch", occurredAtUnixMs: 1, createdAtUnixMs: 1 }, { id: 2, workspaceId: "room-1", agentSessionId: "session-1", turnId: "turn-1", seq: 2, eventId: "event-2", actorType: "agent", actorId: "codex", itemType: "message.assistant", role: "assistant", content: "On it", occurredAtUnixMs: 2, createdAtUnixMs: 2 }, { id: 3, workspaceId: "room-1", agentSessionId: "session-1", turnId: "turn-1", seq: 3, eventId: "event-3", actorType: "agent", actorId: "codex", itemType: "call.completed", role: "assistant", callType: "tool", callId: "read-1", name: "read_file", status: "completed", payload: { tool_state: { name: "read_file", input: { path: "/workspace/demo/README.md" }, output: { text: "Read README.md" } } }, occurredAtUnixMs: 3, createdAtUnixMs: 3 }, { id: 4, workspaceId: "room-1", agentSessionId: "session-1", turnId: "turn-1", seq: 4, eventId: "event-4", actorType: "agent", actorId: "codex", itemType: "message.assistant_thinking", role: "assistant_thinking", content: "Need to inspect before editing.", occurredAtUnixMs: 4, createdAtUnixMs: 4 }, { id: 5, workspaceId: "room-1", agentSessionId: "session-1", turnId: "turn-1", seq: 5, eventId: "event-5", actorType: "agent", actorId: "codex", itemType: "call.completed", role: "assistant", callType: "tool", callId: "edit-1", name: "edit_file", status: "completed", payload: { tool_state: { name: "edit_file", input: { file_path: "/workspace/demo/src/App.tsx", old_string: "old", new_string: "new" }, output: {} } }, occurredAtUnixMs: 5, createdAtUnixMs: 5 }, { id: 6, workspaceId: "room-1", agentSessionId: "session-1", turnId: "turn-1", seq: 6, eventId: "event-6", actorType: "agent", actorId: "codex", itemType: "approval.requested", role: "assistant", callType: "approval", callId: "approval-1", name: "Approval", status: "waiting_approval", payload: { input: { requestId: "approval-request-1", options: [ { id: "allow_once", label: "Allow once", kind: "allow_once" } ] } }, occurredAtUnixMs: 6, createdAtUnixMs: 6 }, { id: 7, workspaceId: "room-1", agentSessionId: "session-1", turnId: "turn-1", seq: 7, eventId: "event-7", actorType: "agent", actorId: "codex", itemType: "interactive.requested", role: "assistant", callType: "interactive", callId: "ask-1", name: "AskUserQuestion", status: "waiting_input", payload: { input: { requestId: "ask-request-1", questions: [ { id: "approach", header: "Approach", question: "Which direction should we take?", options: [ { label: "Use typed renderer", description: "Keep going" } ] } ] } }, occurredAtUnixMs: 7, createdAtUnixMs: 7 } ]; }