From 0b87e89d40bbbfef6324b6f5bbca00162d0a3c9f Mon Sep 17 00:00:00 2001 From: sal Date: Thu, 3 Sep 2026 11:27:30 +0530 Subject: [PATCH] fix(desktop): assistant rows whose text persisted to sidecars no longer vanish on rehydrate (#68321) Field-level reproduction (2026-09-02, v0.21.0, in the issue thread): an assistant row with content length 0 whose user-visible response exists only in reasoning / reasoning_content / codex_message_items renders live, then disappears from the transcript after a session/profile switch-back. DB intact - the row is still there on every re-read; only the rehydrated render loses it. Six independent confirmations across macOS and Windows since 2026-07-22; all prior fixes (#68329 envelope normalization, closed implemented_on_main; #77644 mid-turn reconcile; #101470 compaction-display projections) addressed adjacent producers, not this one. Two producers in toChatMessages hydration, both pinned by the new chat-messages.reasoning-survival.test.ts (fails 9/9 on current main): 1. codex_message_items never read. Responses-API turns can persist with `content` empty while the reply the user saw lives only in the message-items sidecar ({type:'message', role:'assistant', phase, content:[{type:'output_text', text}]}). The live stream painted that text; hydration ignored the sidecar, so the rehydrated bubble came back blank - and the blank chatMessageText at the same role-ordinal then made reconcileResumeMessages drop the cached row's parts (sameText/extension/isLiveTailRow all fail), erasing the reply on every switch-back. Fix: codexMessageItemText() extracts non-commentary assistant output_text as the bubble's text when content and reasoning produced no parts. Persisted content still wins when present; commentary narration is never promoted. 2. the zero-parts drop. An assistant row hydrating to no parts at all returned early and vanished from the transcript entirely - the "all assistant messages gone; user messages remain" shape. Fix: a wholly empty assistant row (display_kind != 'hidden') paints a _(assistant response not persisted)_ placeholder instead of disappearing. Hidden scaffolding rows keep dropping as designed (pinned by a test). Also adds codex_message_items to the SessionMessage type (the gateway has shipped it since the branch-copy lane; the type never declared it). Verification (native Windows, node 24.17): - new file: 9 passed (fails 9/9 without the hydration changes) - src/lib full dir: 1243 passed - reconcile suites (utils + resume-structural-parts): 115 passed - streaming/timeline reasoning-part suites: 25 passed - tsc --build tsconfig.json: clean Fixes #68321 --- .../chat-messages.reasoning-survival.test.ts | 188 ++++++++++++++++++ .../src/lib/chat-messages/hydration.ts | 112 +++++++++++ apps/desktop/src/types/hermes.ts | 3 + 3 files changed, 303 insertions(+) create mode 100644 apps/desktop/src/lib/chat-messages.reasoning-survival.test.ts diff --git a/apps/desktop/src/lib/chat-messages.reasoning-survival.test.ts b/apps/desktop/src/lib/chat-messages.reasoning-survival.test.ts new file mode 100644 index 0000000000..0086d4d86a --- /dev/null +++ b/apps/desktop/src/lib/chat-messages.reasoning-survival.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it } from 'vitest' +import type { SessionMessage } from '@/types/hermes' +import { chatMessageText, toChatMessages } from './chat-messages' + +// #68321 / GregKM 2026-09-02 v0.21.0 DB-level evidence: an assistant row +// whose persisted `content` is empty but whose user-visible response text +// lives only in `reasoning` / `reasoning_content` is dropped by hydration +// after a session/profile switch-back — the live stream rendered it, the +// rehydrated transcript loses it. This file pins that no reasoning-carrying +// assistant row may vanish. + +const reasoningOnlyRow: SessionMessage = { + id: 71, + role: 'assistant', + content: '', + reasoning: + 'Here is the plan: first inspect the repo, then propose a fix, then run the tests.', + timestamp: 2 +} + +describe('#68321 reasoning-only assistant rows survive hydration', () => { + it('does not drop an assistant row whose text lives only in reasoning', () => { + const messages = toChatMessages([ + { id: 70, role: 'user', content: 'go', timestamp: 1 }, + reasoningOnlyRow, + { id: 72, role: 'user', content: 'thanks', timestamp: 3 } + ]) + + expect(messages.map((m) => m.role)).toEqual(['user', 'assistant', 'user']) + // The reasoning text rides as a reasoning part (rendered in the + // collapsible), keeping the row and its content paintable. + const reasoningParts = messages[1].parts.filter((p) => p.type === 'reasoning') + expect(reasoningParts.map((p) => ('text' in p ? p.text : '')).join('')).toContain('Here is the plan') + }) + + it('renders the reasoning part for an empty-content assistant row', () => { + const [assistant] = toChatMessages([reasoningOnlyRow]) + expect(assistant).toBeDefined() + expect(assistant.parts.some((p) => p.type === 'reasoning')).toBe(true) + }) + + it('keeps reasoning_content fallback rows visible', () => { + const row: SessionMessage = { + id: 80, + role: 'assistant', + content: '', + reasoning: null, + reasoning_content: 'Fallback reasoning text from reasoning_content', + timestamp: 2 + } + const [assistant] = toChatMessages([row]) + expect(assistant).toBeDefined() + const reasoningText = assistant.parts + .filter((p) => p.type === 'reasoning') + .map((p) => ('text' in p ? p.text : '')) + .join('') + expect(reasoningText).toContain('Fallback reasoning text') + }) + + it('keeps a row with empty reasoning string AND empty content as a visible placeholder instead of dropping it silently', () => { + // A wholly empty assistant row (e.g. a torn persist) must not silently + // vanish from the transcript — the zero-parts drop is the reported + // "all assistant messages gone" shape. Keep the row paintable. + const row: SessionMessage = { + id: 90, + role: 'assistant', + content: '', + reasoning: '', + reasoning_content: null, + timestamp: 2 + } + const messages = toChatMessages([ + { id: 89, role: 'user', content: 'q', timestamp: 1 }, + row, + { id: 91, role: 'user', content: 'next', timestamp: 3 } + ]) + // The assistant row survives with a placeholder part rather than + // disappearing between the two user rows. + expect(messages.map((m) => m.role)).toEqual(['user', 'assistant', 'user']) + expect(messages[1].parts.length).toBeGreaterThan(0) + }) + + it('renders reasoning alongside tool calls for a row with both and no content', () => { + const row: SessionMessage = { + id: 100, + role: 'assistant', + content: '', + reasoning: 'Thought about the approach first', + tool_calls: [ + { id: 'call_1', type: 'function', function: { name: 'inspect', arguments: '{}' } } + ], + timestamp: 2 + } + const [assistant] = toChatMessages([row]) + expect(assistant).toBeDefined() + const types = assistant.parts.map((p) => p.type) + expect(types).toContain('reasoning') + }) + + it('restores the reply text from codex_message_items when content persisted empty (#68321 GregKM repro)', () => { + // Field-level evidence from the 2026-09-02 v0.21.0 reproduction: + // role=assistant, content length 0, the exact user-visible response + // exists only in reasoning / reasoning_content / codex_message_items. + // The live stream painted it; the rehydrate dropped it. + const row: SessionMessage = { + id: 110, + role: 'assistant', + content: '', + reasoning: null, + reasoning_content: null, + codex_message_items: [ + { + type: 'message', + id: 'msg_abc', + role: 'assistant', + phase: 'commentary', + content: [{ type: 'output_text', text: 'Working through the approach...' }] + }, + { + type: 'message', + id: 'msg_def', + role: 'assistant', + phase: 'final_answer', + content: [{ type: 'output_text', text: 'Here is the full response you saw live.' }] + } + ], + timestamp: 2 + } + const messages = toChatMessages([ + { id: 109, role: 'user', content: 'go', timestamp: 1 }, + row, + { id: 111, role: 'user', content: 'next', timestamp: 3 } + ]) + expect(messages.map((m) => m.role)).toEqual(['user', 'assistant', 'user']) + // The final-answer text is painted as the bubble's reply text... + expect(chatMessageText(messages[1])).toContain('Here is the full response you saw live.') + // ...and the commentary narration is NOT promoted into the reply. + expect(chatMessageText(messages[1])).not.toContain('Working through the approach...') + }) + + it('prefers persisted content over the codex sidecar when both exist', () => { + const row: SessionMessage = { + id: 120, + role: 'assistant', + content: 'Canonical persisted reply', + codex_message_items: [ + { + type: 'message', + role: 'assistant', + phase: 'final_answer', + content: [{ type: 'output_text', text: 'Sidecar-only reply' }] + } + ], + timestamp: 2 + } + const [assistant] = toChatMessages([row]) + expect(chatMessageText(assistant)).toBe('Canonical persisted reply') + }) + + it('paints a placeholder for a wholly empty assistant row instead of dropping it', () => { + const row: SessionMessage = { + id: 130, + role: 'assistant', + content: '', + reasoning: '', + reasoning_content: null, + codex_message_items: null, + timestamp: 2 + } + const messages = toChatMessages([row]) + expect(messages).toHaveLength(1) + expect(messages[0].role).toBe('assistant') + expect(chatMessageText(messages[0]).length).toBeGreaterThan(0) + }) + + it('still drops hidden display_kind rows (interrupt scaffolding stays invisible)', () => { + const row: SessionMessage = { + id: 140, + role: 'assistant', + content: '', + reasoning: '', + display_kind: 'hidden', + timestamp: 2 + } + const messages = toChatMessages([row]) + expect(messages).toHaveLength(0) + }) +}) diff --git a/apps/desktop/src/lib/chat-messages/hydration.ts b/apps/desktop/src/lib/chat-messages/hydration.ts index 69bc09a174..1ec17ecdb6 100644 --- a/apps/desktop/src/lib/chat-messages/hydration.ts +++ b/apps/desktop/src/lib/chat-messages/hydration.ts @@ -19,6 +19,82 @@ const ATTACHED_CONTEXT_MARKER_RE = /(?:^|\n)--- Attached Context ---\s*\n/ const CONTEXT_WARNINGS_MARKER_RE = /(?:^|\n)--- Context Warnings ---[\s\S]*$/ const CONTEXT_REF_RE = /@(file|folder|url|image|tool|terminal):(?:"[^"\n]+"|'[^'\n]+'|`[^`\n]+`|\S+)/g +// Painted for a persisted assistant row whose content, reasoning, tool +// calls, and attachments are ALL empty (#68321) - see the zero-parts guard +// in toChatMessages. It must read as a system fact about the row, never as +// model output: it renders inside the normal assistant bubble, so the +// phrasing is about the record, not a fabricated reply. +const EMPTY_ASSISTANT_PLACEHOLDER = '_(assistant response not persisted)_' + +export const EMPTY_ASSISTANT_PLACEHOLDER_TEXT = EMPTY_ASSISTANT_PLACEHOLDER + +/** + * Extract the user-visible assistant reply text from a Responses-API + * `codex_message_items` sidecar (#68321). + * + * Some Responses-API turns persist with `content` empty while the text the + * user saw lives only in the message-items sidecar (each item: + * `{type:'message', role:'assistant', phase, content:[{type:'output_text', text}]}`). + * The live stream renders that text; a transcript rehydrate that ignores the + * sidecar blanks the bubble - and a blank assistant text at the same + * role-ordinal then makes reconcileResumeMessages drop the cached row's + * parts entirely (the "assistant messages vanish on switch-back" repro). + * Only `phase !== 'commentary'` text is prose; commentary items are the + * provider's narration stream. + */ +function codexMessageItemText(message: SessionMessage): string { + const items = message.codex_message_items + + if (!Array.isArray(items)) { + return '' + } + + const texts: string[] = [] + + for (const item of items) { + if (!item || typeof item !== 'object' || Array.isArray(item)) { + continue + } + + const record = item as Record + + if (record.type !== 'message' || record.role !== 'assistant') { + continue + } + + if (record.phase === 'commentary') { + continue + } + + const content = record.content + + if (!Array.isArray(content)) { + continue + } + + for (const part of content) { + if (!part || typeof part !== 'object' || Array.isArray(part)) { + continue + } + + const partRecord = part as Record + const partType = partRecord.type + + if (partType !== 'output_text' && partType !== 'text') { + continue + } + + const text = partRecord.text + + if (typeof text === 'string' && text.length > 0) { + texts.push(text) + } + } + } + + return texts.join('') +} + function displayContentForMessage(role: SessionMessage['role'], content: unknown): string { const textContent = textFromUnknown(content) @@ -243,6 +319,42 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] { ) } + // #68321 second producer: Responses-API turns can persist with `content` + // empty while the reply the user saw lives only in the codex_message_items + // sidecar. The live stream rendered it; without this fallback the + // rehydrated bubble blanks - and the empty chatMessageText at the same + // role-ordinal then makes reconcileResumeMessages strip the cached row's + // parts, so the reply disappears on every switch-back. + if (message.role === 'assistant' && !displayContent && !parts.length) { + const codexText = codexMessageItemText(message) + + if (codexText) { + parts.push(assistantTextPart(codexText, message.timestamp)) + } + } + + // #68321: a persisted assistant row that hydrates to zero parts is + // invisible in two compounding ways. Directly, it vanishes from the + // painted transcript ("all assistant messages gone; user messages + // remain; DB intact"). Indirectly - and this is the reproducible + // switch-back loss - its role-ordinal slot in reconcileResumeMessages + // carries empty text, so `sameText`/`isStrictAnswerTextExtension`/ + // `isLiveTailRow` all fail against the cached structured row and the + // reconcile REPLACES that row's reasoning/tool parts with the empty + // shell. Both failures trace to one invariant violation: a persisted + // assistant row must never hydrate to nothing. Rows whose only text + // lives in reasoning already survive (the reasoning part keeps them); + // this guards the torn/empty tail - a wholly empty assistant row + // paints a placeholder instead of disappearing. + if ( + message.role === 'assistant' && + !parts.length && + !extractedAttachmentRefs?.length && + message.display_kind !== 'hidden' + ) { + parts.push(assistantTextPart(EMPTY_ASSISTANT_PLACEHOLDER, message.timestamp)) + } + if (!parts.length && !extractedAttachmentRefs?.length) { if (message.role !== 'assistant') { flushPendingTools(index) diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index 5402baa746..b29774d97d 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -591,6 +591,9 @@ export interface SessionMessage { */ args?: unknown codex_reasoning_items?: unknown + /** Responses-API assistant message items; text parts here are the + * user-visible reply when `content` persisted empty (#68321). */ + codex_message_items?: unknown content: unknown /** Backend-projected user-visible content when a physical row also carries internal model scaffolding. */ display_content?: unknown