/** * A stored conversation, read back into the shape the transcript renders. * * The store keeps one row per THING that happened — a question, a tool call, a * proposed change, an answer — because that is what an append-only ledger has * to do to survive a turn that dies half-way through. The transcript renders one * block per TURN, with its tools and its approval cards inside it. Folding the * rows back into turns is therefore not a formality; it is the difference * between reopening a conversation and reopening a log file. * * The wire shapes below mirror `PiggyConversationDetail` in * apps/api/src/services/piggy-conversations.ts. They are restated rather than * imported because the browser cannot import from the API package, and every * field is optional-tolerant on read for the same reason: a row written by an * older build must reopen as a slightly plainer message, never as a blank pane. * * Standing caveat, stated where it will be found: NOTHING WRITES THESE ROWS YET. * `PiggyConversationService.appendMessage` exists and is tested, and the chat * relay does not call it — see the report. So today every stored conversation * reopens empty, and this module is the half of the loop that is ready. */ import type { PiggyChatContext, PiggyMode, PiggyProposedChange } from '@pig/core'; import type { ApprovalStep, ToolStep, TranscriptMessage } from '@/lib/piggy-chat'; export interface StoredPiggyMessage { id: string; seq: number; role: 'user' | 'assistant' | 'tool'; content: string; reasoning: string | null; model: string | null; mode: PiggyMode | null; inputTokens: number | null; outputTokens: number | null; costMicroCents: number | null; finishReason: string | null; tool: { callId: string; name: string; arguments: Record | null; result: Record | null; ok: boolean | null; } | null; approval: { id: string; change: PiggyProposedChange; decision: 'apply' | 'reject' | null; decidedAt: string | null; } | null; error: string | null; createdAt: string; } export interface StoredPiggyConversation { id: string; title: string; model: string | null; mode: PiggyMode | null; context: PiggyChatContext | null; createdAt: string; updatedAt: string; messages: StoredPiggyMessage[]; } /** * A change that was proposed and never answered. * * It is not offered as pending on reopening, and that is deliberate rather than * cautious: the agent holds a proposal for the length of its own turn, so by the * time a transcript is read back from the database there is nothing left at the * other end for an Apply button to reach. Showing the buttons would collect an * error; showing the card settled says what happened. */ const UNANSWERED = 'This change was never answered, and the turn that proposed it has ended.'; export function toTranscript(messages: StoredPiggyMessage[]): TranscriptMessage[] { const transcript: TranscriptMessage[] = []; // The assistant turn currently being assembled. Tool rows and approval rows // belong to whichever answer they were streamed alongside, and they arrive // BEFORE its text — the answer is written last. let open: TranscriptMessage | null = null; for (const row of [...messages].sort((a, b) => a.seq - b.seq)) { if (row.role === 'user') { if (open) transcript.push(open); open = null; transcript.push({ id: row.id, role: 'user', content: row.content }); continue; } if (row.tool) { const turn: TranscriptMessage = open ?? newTurn(row.id); turn.tools = [...(turn.tools ?? []), toToolStep(row.tool)]; open = turn; continue; } if (row.approval) { const turn: TranscriptMessage = open ?? newTurn(row.id); turn.approvals = [...(turn.approvals ?? []), toApprovalStep(row.approval)]; open = turn; continue; } // A second answer inside one turn cannot happen on the wire, but a repaired // or re-run conversation could hold one; starting a fresh block is the only // reading that does not silently concatenate two answers into one. if (open && open.content) { transcript.push(open); open = null; } const turn: TranscriptMessage = open ?? newTurn(row.id); turn.id = row.id; turn.content = row.content; turn.reasoning = row.reasoning ?? undefined; turn.model = row.model ?? undefined; turn.mode = row.mode ?? undefined; turn.inputTokens = row.inputTokens; turn.outputTokens = row.outputTokens; turn.costMicroCents = row.costMicroCents; turn.finishReason = row.finishReason ?? undefined; turn.error = row.error ?? undefined; transcript.push(turn); open = null; } if (open) transcript.push(open); return transcript; } function newTurn(id: string): TranscriptMessage { return { id, role: 'assistant', content: '', tools: [], approvals: [], pending: false }; } function toToolStep(tool: NonNullable): ToolStep { return { id: tool.callId, name: tool.name, arguments: tool.arguments ?? {}, // `ok: null` is a call the store never saw finish. It is drawn as succeeded // rather than running: a spinner in a transcript read back from disk would // never stop, and the payload beside it is the evidence either way. state: tool.ok === false ? 'failed' : 'succeeded', result: tool.result ?? undefined, /* * No clock. `startedAt` is `performance.now()` on the live path, which is * milliseconds since this document loaded and means nothing for a call made * last Tuesday. Zero with no `durationMs` renders as a step with no timing, * which is honest; a computed one would be fiction. */ startedAt: 0, }; } function toApprovalStep(approval: NonNullable): ApprovalStep { if (approval.decision === 'apply') { return { change: approval.change, state: 'applied', decision: 'apply' }; } if (approval.decision === 'reject') { return { change: approval.change, state: 'rejected', decision: 'reject' }; } return { change: approval.change, state: 'failed', error: UNANSWERED }; }