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
This commit is contained in:
sal
2026-09-03 11:27:30 +05:30
committed by kshitij
parent 4f6ef8806f
commit 0b87e89d40
3 changed files with 303 additions and 0 deletions
@@ -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)
})
})
@@ -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<string, unknown>
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<string, unknown>
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)
+3
View File
@@ -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