From 3e5b24101a6135673ab57bb6d5b2e159b03f1e9e Mon Sep 17 00:00:00 2001 From: Derek Higgins Date: Tue, 24 Mar 2026 17:50:24 +0000 Subject: [PATCH] fix(frontend): export chat handles compacted MESSAGES_SNAPSHOT events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When users export a completed session's chat (as Markdown, PDF, or to Google Drive), only the initial prompt is included — the rest of the conversation is missing. This makes the export feature essentially unusable for reviewing or sharing finished sessions. The root cause is that the backend compacts finished sessions by replacing streaming events (TEXT_MESSAGE_*, TOOL_CALL_*) with a single MESSAGES_SNAPSHOT event. The export code only knew how to process streaming events, so all conversation content was silently dropped after compaction. Add blocksFromSnapshot() to extract conversation blocks from MESSAGES_SNAPSHOT messages, including tool calls and results. When a snapshot is present it takes priority over streaming events since it is the canonical source after compaction. Co-Authored-By: Claude Opus 4.6 --- .../src/utils/__tests__/export-chat.test.ts | 159 ++++++++++++++++++ components/frontend/src/utils/export-chat.ts | 99 +++++++++++ 2 files changed, 258 insertions(+) diff --git a/components/frontend/src/utils/__tests__/export-chat.test.ts b/components/frontend/src/utils/__tests__/export-chat.test.ts index f57f221f1c..772b8b4c06 100644 --- a/components/frontend/src/utils/__tests__/export-chat.test.ts +++ b/components/frontend/src/utils/__tests__/export-chat.test.ts @@ -541,6 +541,165 @@ describe('markdownToHtml via exportAsPdf', () => { }); }); +// ── MESSAGES_SNAPSHOT (compacted sessions) ── +describe('MESSAGES_SNAPSHOT support (compacted sessions)', () => { + beforeEach(() => { + vi.stubGlobal('window', { + location: { origin: 'https://app.example.com' }, + }); + }); + + it('renders messages from a MESSAGES_SNAPSHOT event', () => { + const events = [ + { type: 'RUN_STARTED' }, + { + type: 'MESSAGES_SNAPSHOT', + messages: [ + { id: 'm1', role: 'user', content: 'What is 2+2?' }, + { id: 'm2', role: 'assistant', content: 'The answer is 4.' }, + ], + }, + { type: 'RUN_FINISHED' }, + ]; + const md = convertEventsToMarkdown(makeExport(events), makeSession()); + expect(md).toContain('What is 2+2?'); + expect(md).toContain('The answer is 4.'); + expect(md).toContain('User'); + expect(md).toContain('Assistant'); + }); + + it('renders tool calls from snapshot assistant messages', () => { + const events = [ + { + type: 'MESSAGES_SNAPSHOT', + messages: [ + { id: 'm1', role: 'user', content: 'Read the file' }, + { + id: 'm2', role: 'assistant', content: 'Let me read it.', + toolCalls: [ + { + id: 'tc1', + function: { name: 'Read', arguments: '{"path":"/foo.ts"}' }, + result: 'file contents here', + }, + ], + }, + { id: 'm3', role: 'assistant', content: 'Here is the file content.' }, + ], + }, + ]; + const md = convertEventsToMarkdown(makeExport(events), makeSession()); + expect(md).toContain('Read the file'); + expect(md).toContain('Let me read it.'); + expect(md).toContain('Read'); + expect(md).toContain('"path"'); + expect(md).toContain('file contents here'); + expect(md).toContain('Here is the file content.'); + }); + + it('renders tool call errors from snapshot', () => { + const events = [ + { + type: 'MESSAGES_SNAPSHOT', + messages: [ + { + id: 'm1', role: 'assistant', content: 'Running command.', + toolCalls: [ + { + id: 'tc1', + function: { name: 'Bash', arguments: '{"cmd":"fail"}' }, + error: 'command not found', + }, + ], + }, + ], + }, + ]; + const md = convertEventsToMarkdown(makeExport(events), makeSession()); + expect(md).toContain('**Error:**'); + expect(md).toContain('command not found'); + }); + + it('prepends initial prompt with snapshot messages', () => { + const session = makeSession({ + spec: { + initialPrompt: 'Fix the bug', + llmSettings: { model: 'claude-sonnet-4-20250514', temperature: 0, maxTokens: 4096 }, + timeout: 3600, + }, + }); + const events = [ + { + type: 'MESSAGES_SNAPSHOT', + messages: [ + { id: 'm1', role: 'assistant', content: 'Done!' }, + ], + }, + ]; + const md = convertEventsToMarkdown(makeExport(events), session); + const promptIdx = md.indexOf('Fix the bug'); + const doneIdx = md.indexOf('Done!'); + expect(promptIdx).toBeGreaterThan(-1); + expect(doneIdx).toBeGreaterThan(promptIdx); + }); + + it('prefers snapshot over streaming events when both present', () => { + const events = [ + // Streaming events (should be ignored when snapshot is present) + { type: 'TEXT_MESSAGE_START', role: 'user' }, + { type: 'TEXT_MESSAGE_CONTENT', delta: 'streaming msg' }, + { type: 'TEXT_MESSAGE_END' }, + // Snapshot (canonical source) + { + type: 'MESSAGES_SNAPSHOT', + messages: [ + { id: 'm1', role: 'user', content: 'snapshot msg' }, + { id: 'm2', role: 'assistant', content: 'snapshot reply' }, + ], + }, + ]; + const md = convertEventsToMarkdown(makeExport(events), makeSession()); + expect(md).toContain('snapshot msg'); + expect(md).toContain('snapshot reply'); + expect(md).not.toContain('streaming msg'); + }); + + it('handles snapshot with empty messages array', () => { + const events = [ + { type: 'MESSAGES_SNAPSHOT', messages: [] }, + ]; + const md = convertEventsToMarkdown(makeExport(events), makeSession()); + expect(md).toContain('*No conversation content found.*'); + }); + + it('handles multi-turn conversation in snapshot', () => { + const events = [ + { + type: 'MESSAGES_SNAPSHOT', + messages: [ + { id: 'm1', role: 'user', content: 'Question 1' }, + { id: 'm2', role: 'assistant', content: 'Answer 1' }, + { id: 'm3', role: 'user', content: 'Question 2' }, + { id: 'm4', role: 'assistant', content: 'Answer 2' }, + ], + }, + ]; + const md = convertEventsToMarkdown(makeExport(events), makeSession()); + expect(md).toContain('Question 1'); + expect(md).toContain('Answer 1'); + expect(md).toContain('Question 2'); + expect(md).toContain('Answer 2'); + // Verify ordering + const q1 = md.indexOf('Question 1'); + const a1 = md.indexOf('Answer 1'); + const q2 = md.indexOf('Question 2'); + const a2 = md.indexOf('Answer 2'); + expect(q1).toBeLessThan(a1); + expect(a1).toBeLessThan(q2); + expect(q2).toBeLessThan(a2); + }); +}); + // ── assembleBlocks additional branches ── describe('assembleBlocks — TOOL_CALL_END with result and error', () => { beforeEach(() => { diff --git a/components/frontend/src/utils/export-chat.ts b/components/frontend/src/utils/export-chat.ts index 84983a6279..c83c6f050a 100644 --- a/components/frontend/src/utils/export-chat.ts +++ b/components/frontend/src/utils/export-chat.ts @@ -19,6 +19,22 @@ type ExportEvent = { timestamp?: string; }; +/** Shape of a message inside a MESSAGES_SNAPSHOT event. */ +type SnapshotMessage = { + id?: string; + role: string; + content?: string; + timestamp?: string; + toolCallId?: string; + name?: string; + toolCalls?: { + id?: string; + function?: { name?: string; arguments?: string }; + result?: string; + error?: string; + }[]; +}; + function isExportEvent(raw: unknown): raw is ExportEvent { if (typeof raw !== 'object' || raw === null || !('type' in raw)) return false; const obj = raw as Record; @@ -33,10 +49,93 @@ type ConversationBlock = | { kind: 'message'; role: string; content: string; timestamp?: string } | { kind: 'tool'; name: string; args: string; result?: string; error?: string; timestamp?: string }; +/** + * Extract conversation blocks from a MESSAGES_SNAPSHOT event. + * + * After a run finishes, the backend compacts streaming events + * (TEXT_MESSAGE_*, TOOL_CALL_*) into a single MESSAGES_SNAPSHOT + * containing full message objects. This function converts those + * snapshot messages into ConversationBlocks for export. + */ +function blocksFromSnapshot(messages: SnapshotMessage[]): ConversationBlock[] { + const blocks: ConversationBlock[] = []; + + for (const msg of messages) { + const role = msg.role; + const timestamp = msg.timestamp; + + if (role === 'user' || role === 'assistant') { + const content = typeof msg.content === 'string' ? msg.content.trim() : ''; + if (content) { + blocks.push({ kind: 'message', role, content, timestamp }); + } + + // Emit tool call blocks from assistant messages + if (role === 'assistant' && msg.toolCalls) { + for (const tc of msg.toolCalls) { + const fnArgs = tc.function?.arguments ?? ''; + blocks.push({ + kind: 'tool', + name: tc.function?.name ?? 'unknown', + args: fnArgs, + result: tc.result, + error: tc.error, + timestamp, + }); + } + } + } else if (role === 'tool') { + // Tool result messages — attach as result to preceding tool block if possible + const content = typeof msg.content === 'string' ? msg.content : ''; + const toolCallId = msg.toolCallId; + if (toolCallId && content) { + // Find the matching tool block by scanning backwards + for (let i = blocks.length - 1; i >= 0; i--) { + const b = blocks[i]; + // Match tool blocks that don't have a result yet — the most recent + // unfinished tool block for the same toolCallId is the match. + // Since we don't store IDs on ConversationBlock, match by name if + // the snapshot provides msg.name, otherwise attach to last tool block. + if (b.kind === 'tool' && !b.result && !b.error) { + b.result = content; + break; + } + } + } + } + } + + return blocks; +} + /** * Walk the raw AG-UI event array and assemble conversation blocks. + * + * Handles both streaming events (TEXT_MESSAGE_*, TOOL_CALL_*) and + * compacted MESSAGES_SNAPSHOT events. After a session finishes, the + * backend replaces streaming events with a MESSAGES_SNAPSHOT — so + * exports must support both formats. */ function assembleBlocks(events: unknown[]): ConversationBlock[] { + // First pass: check for MESSAGES_SNAPSHOT events (compacted sessions). + // If present, extract blocks from snapshots — they are the canonical + // source and supersede any streaming events. + const snapshotBlocks: ConversationBlock[] = []; + for (const raw of events) { + if (!isExportEvent(raw)) continue; + if (raw.type === EventType.MESSAGES_SNAPSHOT) { + const snap = raw as unknown as { messages?: SnapshotMessage[] }; + if (Array.isArray(snap.messages)) { + snapshotBlocks.push(...blocksFromSnapshot(snap.messages)); + } + } + } + + if (snapshotBlocks.length > 0) { + return snapshotBlocks; + } + + // Fallback: assemble from streaming events (active/uncompacted sessions) const blocks: ConversationBlock[] = []; let currentRole: string | null = null; let currentContent = '';