fix(desktop): tolerate unparsed display_metadata from an older backend

The desktop and the Hermes backend it talks to version independently — a
remote VM running an older build still serves display_metadata as JSON text.
Indexing into that string with `in` threw and failed the whole resume, so
narrow the type to admit a string and parse it before reading task_count.
Falling back to the generic label keeps a delegation event renderable even
when the metadata is unusable.

Co-authored-by: xxxigm <xxxigm@users.noreply.github.com>
Co-authored-by: Studio729 <Studio729@users.noreply.github.com>
This commit is contained in:
Brooklyn Nicholson 2026-07-24 19:53:32 -05:00
parent 19dc35cf57
commit 0c40f00ad1
3 changed files with 54 additions and 5 deletions

View file

@ -1,5 +1,7 @@
import { describe, expect, it } from 'vitest'
import type { SessionMessage } from '@/types/hermes'
import type { ChatMessage, ChatMessagePart } from './chat-messages'
import {
appendAssistantTextPart,
@ -191,6 +193,30 @@ describe('toChatMessages', () => {
'background agent work finished'
])
})
// A backend older than this app serves display_metadata as unparsed JSON
// text. Indexing into that string used to throw and fail the whole resume.
it.each([
['an object', { delegation_id: 'deleg_1', task_count: 2 }, '2 background agents finished'],
['JSON text', JSON.stringify({ delegation_id: 'deleg_1', task_count: 1 }), '1 background agent finished'],
['unparseable text', '{not-json', 'background agent work finished'],
['text that is not an object', '"deleg_1"', 'background agent work finished'],
['a missing task count', { delegation_id: 'deleg_1' }, 'background agent work finished']
])('labels a delegation event given %s', (_case, displayMetadata, expected) => {
const read = () =>
toChatMessages([
{
role: 'user',
content: 'opaque delegation context payload',
display_kind: 'async_delegation_complete',
display_metadata: displayMetadata as SessionMessage['display_metadata'],
timestamp: 1
}
])
expect(read).not.toThrow()
expect(chatMessageText(read()[0])).toBe(expected)
})
})
describe('renderMediaTags', () => {

View file

@ -311,16 +311,35 @@ function transcriptContent(displayKind: SessionMessage['display_kind'], content:
return displayKind === 'hidden' ? null : content
}
// A remote backend older than this app serves display_metadata as raw JSON text,
// and `in` throws on a primitive — which used to fail the whole session resume.
function timelineTaskCount(metadata: SessionMessage['display_metadata']): number | undefined {
let parsed: unknown = metadata
if (typeof parsed === 'string') {
try {
parsed = JSON.parse(parsed)
} catch {
return undefined
}
}
if (!parsed || typeof parsed !== 'object') {
return undefined
}
const count = (parsed as { task_count?: unknown }).task_count
return typeof count === 'number' ? count : undefined
}
function timelineDisplayContent(message: SessionMessage, content: string): string {
if (message.display_kind === 'model_switch') {
return 'model changed'
}
if (message.display_kind === 'async_delegation_complete') {
const count =
message.display_metadata && 'task_count' in message.display_metadata
? message.display_metadata.task_count
: undefined
const count = timelineTaskCount(message.display_metadata)
return count === undefined
? 'background agent work finished'

View file

@ -494,7 +494,11 @@ export interface SessionMessage {
reasoning_content?: null | string
reasoning_details?: unknown
display_kind?: 'async_delegation_complete' | 'hidden' | 'model_switch' | string
display_metadata?: TimelineDisplayMetadata
/**
* A backend older than this app can still serve this as unparsed JSON text,
* so readers must narrow before indexing into it.
*/
display_metadata?: string | TimelineDisplayMetadata
role: 'assistant' | 'system' | 'tool' | 'user'
text?: unknown
timestamp?: number