import { describe, expect, it } from "vitest"; import type { Agent } from "@paperclipai/shared"; import { buildAssistantPartsFromTranscript, buildIssueChatMessages, type IssueChatComment, type IssueChatLinkedRun, } from "./issue-chat-messages"; import type { IssueTimelineEvent } from "./issue-timeline-events"; import type { LiveRunForIssue } from "../api/heartbeats"; function createAgent(id: string, name: string): Agent { return { id, companyId: "company-1", name, role: "engineer", title: null, icon: "code", status: "active", reportsTo: null, capabilities: null, adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, budgetMonthlyCents: 0, spentMonthlyCents: 0, lastHeartbeatAt: null, metadata: {}, createdAt: new Date(), updatedAt: new Date(), pauseReason: null, pausedAt: null, urlKey: "codexcoder", permissions: { canCreateAgents: false }, } as Agent; } function createComment(overrides: Partial = {}): IssueChatComment { return { id: "comment-1", companyId: "company-1", issueId: "issue-1", authorAgentId: null, authorUserId: "user-1", body: "Hello", createdAt: new Date("2026-04-06T12:00:00.000Z"), updatedAt: new Date("2026-04-06T12:00:00.000Z"), ...overrides, }; } describe("buildAssistantPartsFromTranscript", () => { it("maps assistant text, reasoning, and tool activity while omitting noisy stderr", () => { const result = buildAssistantPartsFromTranscript([ { kind: "assistant", ts: "2026-04-06T12:00:00.000Z", text: "Working on it. " }, { kind: "assistant", ts: "2026-04-06T12:00:01.000Z", text: "Done." }, { kind: "thinking", ts: "2026-04-06T12:00:02.000Z", text: "Need to inspect files." }, { kind: "tool_call", ts: "2026-04-06T12:00:03.000Z", name: "read_file", toolUseId: "tool-1", input: { path: "ui/src/pages/IssueDetail.tsx" }, }, { kind: "tool_result", ts: "2026-04-06T12:00:04.000Z", toolUseId: "tool-1", content: "file contents", isError: false, }, { kind: "stderr", ts: "2026-04-06T12:00:05.000Z", text: "warn: noisy setup output" }, ]); expect(result.parts).toHaveLength(3); expect(result.parts[0]).toMatchObject({ type: "text", text: "Working on it. Done." }); expect(result.parts[1]).toMatchObject({ type: "reasoning", text: "Need to inspect files." }); expect(result.parts[2]).toMatchObject({ type: "tool-call", toolCallId: "tool-1", toolName: "read_file", result: "file contents", isError: false, }); expect(result.notices).toEqual([]); }); it("preserves transcript ordering when text and tool activity are interleaved", () => { const result = buildAssistantPartsFromTranscript([ { kind: "assistant", ts: "2026-04-06T12:00:00.000Z", text: "First." }, { kind: "tool_call", ts: "2026-04-06T12:00:01.000Z", name: "read_file", toolUseId: "tool-1", input: { path: "ui/src/components/IssueChatThread.tsx" }, }, { kind: "assistant", ts: "2026-04-06T12:00:02.000Z", text: "Second." }, { kind: "tool_result", ts: "2026-04-06T12:00:03.000Z", toolUseId: "tool-1", content: "ok", isError: false, }, { kind: "thinking", ts: "2026-04-06T12:00:04.000Z", text: "Need one more check." }, { kind: "tool_call", ts: "2026-04-06T12:00:05.000Z", name: "write_file", toolUseId: "tool-2", input: { path: "ui/src/lib/issue-chat-messages.ts" }, }, { kind: "tool_result", ts: "2026-04-06T12:00:06.000Z", toolUseId: "tool-2", content: "saved", isError: false, }, ]); expect(result.parts).toMatchObject([ { type: "text", text: "First." }, { type: "tool-call", toolCallId: "tool-1", toolName: "read_file", result: "ok" }, { type: "text", text: "Second." }, { type: "reasoning", text: "Need one more check." }, { type: "tool-call", toolCallId: "tool-2", toolName: "write_file", result: "saved" }, ]); }); it("treats a completed tool-only segment as resolved once a tool_result arrives", () => { const result = buildAssistantPartsFromTranscript([ { kind: "thinking", ts: "2026-04-06T12:00:00.000Z", text: "Checking the task." }, { kind: "tool_call", ts: "2026-04-06T12:00:01.000Z", name: "search", toolUseId: "tool-1", input: { query: "paperclip" }, }, { kind: "tool_result", ts: "2026-04-06T12:00:02.000Z", toolUseId: "tool-1", content: "search completed", isError: false, }, { kind: "assistant", ts: "2026-04-06T12:00:03.000Z", text: "Found the relevant code." }, ]); expect(result.parts).toMatchObject([ { type: "reasoning", text: "Checking the task." }, { type: "tool-call", toolCallId: "tool-1", toolName: "search", result: "search completed", isError: false, }, { type: "text", text: "Found the relevant code." }, ]); expect(result.segments).toEqual([{ startMs: new Date("2026-04-06T12:00:00.000Z").getTime(), endMs: new Date("2026-04-06T12:00:02.000Z").getTime(), }]); }); it("keeps run errors while suppressing init and system transcript noise", () => { const result = buildAssistantPartsFromTranscript([ { kind: "init", ts: "2026-04-06T12:00:00.000Z", model: "gpt-5.4", sessionId: "session-123", }, { kind: "system", ts: "2026-04-06T12:00:01.000Z", text: "item started: planning_step (id=step-1)", }, { kind: "system", ts: "2026-04-06T12:00:02.000Z", text: "item completed: planning_step (id=step-1)", }, { kind: "result", ts: "2026-04-06T12:00:03.000Z", text: "Tool crashed during execution", inputTokens: 0, outputTokens: 0, cachedTokens: 0, costUsd: 0, subtype: "error", isError: true, errors: ["ENOENT: missing file"], }, ]); expect(result.parts).toMatchObject([ { type: "reasoning", text: "Run error: ENOENT: missing file", }, ]); expect(result.notices).toEqual([]); }); it("preserves diff transcript output as a fenced diff block", () => { const result = buildAssistantPartsFromTranscript([ { kind: "assistant", ts: "2026-04-06T12:00:00.000Z", text: "Applied the patch." }, { kind: "diff", ts: "2026-04-06T12:00:01.000Z", changeType: "file_header", text: "ui/src/lib/issue-chat-messages.ts" }, { kind: "diff", ts: "2026-04-06T12:00:02.000Z", changeType: "add", text: "+function formatDiffBlock(lines: string[]) {" }, { kind: "diff", ts: "2026-04-06T12:00:03.000Z", changeType: "add", text: "+ return ````diff`;" }, ]); expect(result.parts).toMatchObject([ { type: "text", text: "Applied the patch." }, { type: "text", text: [ "```diff", "ui/src/lib/issue-chat-messages.ts", "+function formatDiffBlock(lines: string[]) {", "+ return ````diff`;", "```", ].join("\n"), }, ]); }); }); describe("buildIssueChatMessages", () => { it("orders events before comments and appends active live runs as running assistant messages", () => { const agentMap = new Map([["agent-1", createAgent("agent-1", "CodexCoder")]]); const comments = [ createComment(), createComment({ id: "comment-2", authorAgentId: "agent-1", authorUserId: null, body: "I made the change.", createdAt: new Date("2026-04-06T12:03:00.000Z"), updatedAt: new Date("2026-04-06T12:03:00.000Z"), runId: "run-1", runAgentId: "agent-1", }), ]; const timelineEvents: IssueTimelineEvent[] = [ { id: "event-1", createdAt: new Date("2026-04-06T11:59:00.000Z"), actorType: "user", actorId: "user-1", statusChange: { from: "done", to: "todo", }, }, ]; const linkedRuns: IssueChatLinkedRun[] = [ { runId: "run-history-1", status: "succeeded", agentId: "agent-1", createdAt: new Date("2026-04-06T12:01:00.000Z"), startedAt: new Date("2026-04-06T12:01:00.000Z"), finishedAt: new Date("2026-04-06T12:02:00.000Z"), }, ]; const liveRuns: LiveRunForIssue[] = [ { id: "run-live-1", status: "running", invocationSource: "manual", triggerDetail: null, startedAt: "2026-04-06T12:04:00.000Z", finishedAt: null, createdAt: "2026-04-06T12:04:00.000Z", agentId: "agent-1", agentName: "CodexCoder", adapterType: "codex_local", }, ]; const messages = buildIssueChatMessages({ comments, timelineEvents, linkedRuns, liveRuns, transcriptsByRunId: new Map([ [ "run-live-1", [{ kind: "assistant", ts: "2026-04-06T12:04:01.000Z", text: "Streaming reply" }], ], ]), hasOutputForRun: (runId) => runId === "run-live-1", companyId: "company-1", projectId: "project-1", agentMap, currentUserId: "user-1", }); expect(messages.map((message) => `${message.role}:${message.id}`)).toEqual([ "system:activity:event-1", "user:comment-1", "assistant:comment-2", "assistant:run-assistant:run-live-1", ]); const liveRunMessage = messages.at(-1); expect(liveRunMessage).toMatchObject({ role: "assistant", status: { type: "running" }, }); expect(liveRunMessage?.content[0]).toMatchObject({ type: "text", text: "Streaming reply", }); }); it("keeps succeeded runs as assistant messages when transcript output exists", () => { const agentMap = new Map([["agent-1", createAgent("agent-1", "CodexCoder")]]); const messages = buildIssueChatMessages({ comments: [], timelineEvents: [], linkedRuns: [ { runId: "run-history-1", status: "succeeded", agentId: "agent-1", createdAt: new Date("2026-04-06T12:01:00.000Z"), startedAt: new Date("2026-04-06T12:01:00.000Z"), finishedAt: new Date("2026-04-06T12:03:00.000Z"), }, ], liveRuns: [], transcriptsByRunId: new Map([ [ "run-history-1", [ { kind: "thinking", ts: "2026-04-06T12:01:10.000Z", text: "Checking the current issue thread." }, { kind: "assistant", ts: "2026-04-06T12:02:30.000Z", text: "Updated the thread renderer." }, ], ], ]), hasOutputForRun: (runId) => runId === "run-history-1", agentMap, currentUserId: "user-1", }); expect(messages).toHaveLength(1); expect(messages[0]).toMatchObject({ id: "run-assistant:run-history-1", role: "assistant", status: { type: "complete", reason: "stop" }, metadata: { custom: { kind: "historical-run", runId: "run-history-1", chainOfThoughtLabel: "Worked for 2 minutes", }, }, }); expect(messages[0]?.content).toMatchObject([ { type: "reasoning", text: "Checking the current issue thread." }, { type: "text", text: "Updated the thread renderer." }, ]); }); it("keeps the same assistant message id when a live run becomes a cancelled historical run", () => { const liveMessages = buildIssueChatMessages({ comments: [], timelineEvents: [], linkedRuns: [], liveRuns: [ { id: "run-1", status: "running", invocationSource: "manual", triggerDetail: null, startedAt: "2026-04-06T12:01:00.000Z", finishedAt: null, createdAt: "2026-04-06T12:01:00.000Z", agentId: "agent-1", agentName: "CodexCoder", adapterType: "codex_local", }, ], transcriptsByRunId: new Map([ ["run-1", [{ kind: "assistant", ts: "2026-04-06T12:01:05.000Z", text: "Working on it." }]], ]), hasOutputForRun: (runId) => runId === "run-1", currentUserId: "user-1", }); const cancelledMessages = buildIssueChatMessages({ comments: [], timelineEvents: [], linkedRuns: [ { runId: "run-1", status: "cancelled", agentId: "agent-1", agentName: "CodexCoder", createdAt: new Date("2026-04-06T12:01:00.000Z"), startedAt: new Date("2026-04-06T12:01:00.000Z"), finishedAt: new Date("2026-04-06T12:01:08.000Z"), }, ], liveRuns: [], transcriptsByRunId: new Map([ ["run-1", [{ kind: "assistant", ts: "2026-04-06T12:01:05.000Z", text: "Working on it." }]], ]), hasOutputForRun: (runId) => runId === "run-1", currentUserId: "user-1", }); expect(liveMessages).toHaveLength(1); expect(cancelledMessages).toHaveLength(1); expect(liveMessages[0]).toMatchObject({ id: "run-assistant:run-1", status: { type: "running" } }); expect(cancelledMessages[0]).toMatchObject({ id: "run-assistant:run-1", status: { type: "complete", reason: "stop" }, metadata: { custom: { runStatus: "cancelled" } }, }); }); it("can keep succeeded runs without transcript output for embedded run feeds", () => { const messages = buildIssueChatMessages({ comments: [], timelineEvents: [], linkedRuns: [ { runId: "run-history-2", status: "succeeded", agentId: "agent-1", agentName: "CodexCoder", createdAt: new Date("2026-04-06T12:01:00.000Z"), startedAt: new Date("2026-04-06T12:01:00.000Z"), finishedAt: new Date("2026-04-06T12:03:00.000Z"), }, ], liveRuns: [], includeSucceededRunsWithoutOutput: true, currentUserId: "user-1", }); expect(messages).toHaveLength(1); expect(messages[0]).toMatchObject({ id: "run:run-history-2", role: "system", metadata: { custom: { kind: "run", runId: "run-history-2", runAgentName: "CodexCoder", runStatus: "succeeded", }, }, }); }); });