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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user