diff --git a/apps/desktop/src/lib/chat-messages.test.ts b/apps/desktop/src/lib/chat-messages.test.ts index e86ba5593ec..ea9d5df436d 100644 --- a/apps/desktop/src/lib/chat-messages.test.ts +++ b/apps/desktop/src/lib/chat-messages.test.ts @@ -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', () => { diff --git a/apps/desktop/src/lib/chat-messages.ts b/apps/desktop/src/lib/chat-messages.ts index 8541c0bb809..0019c3d4b5e 100644 --- a/apps/desktop/src/lib/chat-messages.ts +++ b/apps/desktop/src/lib/chat-messages.ts @@ -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' diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index d811e07ed57..1d5f104f4cc 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -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