diff --git a/web/src/lib/assistant-runtime.test.ts b/web/src/lib/assistant-runtime.test.ts index f30f065a..58a3c952 100644 --- a/web/src/lib/assistant-runtime.test.ts +++ b/web/src/lib/assistant-runtime.test.ts @@ -3,7 +3,9 @@ import { type BlockWithThreadMessageId, aggregateResponseGroups, assignThreadMessageIds, - assignThreadMessageIdsWithStableWrappers + assignThreadMessageIdsWithStableWrappers, + getBlockPresentationTimestamp, + getResponseGroupTimestamps } from './assistant-runtime' import type { AgentEventBlock, AgentTextBlock, CliOutputBlock, ToolCallBlock, UserTextBlock } from '@/chat/types' import type { ToolGroupBlock, VisibleChatBlock } from '@/chat/toolGroups' @@ -133,6 +135,79 @@ describe('assignThreadMessageIds', () => { }) }) +describe('message presentation timestamps', () => { + it('uses invocation time for user-role messages and falls back to creation time', () => { + expect(getBlockPresentationTimestamp(userText('queued', { + createdAt: 100, + invokedAt: 200 + }))).toBe(200) + expect(getBlockPresentationTimestamp(userText('immediate', { + createdAt: 300 + }))).toBe(300) + expect(getBlockPresentationTimestamp(cliOutput('terminal', 'user', { + createdAt: 400, + invokedAt: 500 + }))).toBe(500) + }) + + it('keeps a joined response timestamp stable when older assistant blocks are prepended', () => { + const middle = agentText('middle', { createdAt: 200 }) + const tail = agentText('tail', { createdAt: 300 }) + + const initial = getResponseGroupTimestamps([middle, tail]) + expect(initial.get(middle)).toBe(300) + + const older = agentText('older', { createdAt: 100 }) + const prepended = getResponseGroupTimestamps([older, middle, tail]) + expect(prepended.get(older)).toBe(300) + }) + + it('splits response timestamps at user and system boundaries', () => { + const first = agentText('a1', { createdAt: 100 }) + const second = agentText('a2', { createdAt: 200 }) + const third = agentText('a3', { createdAt: 400 }) + const fourth = agentText('a4', { createdAt: 600 }) + const timestamps = getResponseGroupTimestamps([ + first, + second, + userText('u1', { createdAt: 300 }), + third, + agentEvent('e1', { type: 'ready' }), + fourth + ]) + + expect(timestamps.get(first)).toBe(200) + expect(timestamps.get(third)).toBe(400) + expect(timestamps.get(fourth)).toBe(600) + expect(timestamps.size).toBe(3) + }) + + it('uses the latest grouped tool completion as response activity time', () => { + const first = toolCall('t1', { + createdAt: 100, + tool: { + ...toolCall('seed').tool, + id: 't1', + createdAt: 100, + completedAt: 300 + } + }) + const last = toolCall('t2', { + createdAt: 200, + tool: { + ...toolCall('seed').tool, + id: 't2', + createdAt: 200, + completedAt: 250 + } + }) + const group = toolGroup('g1', [first, last], { createdAt: 100 }) + + expect(getBlockPresentationTimestamp(group)).toBe(300) + expect(getResponseGroupTimestamps([group]).get(group)).toBe(300) + }) +}) + describe('aggregateResponseGroups', () => { it('1. sums usage and dedups model across distinct localIds in a single response group', () => { // user (no aggregate) → agent-text L1 → tool-call L1 → tool-call L2 → agent-text L3 diff --git a/web/src/lib/assistant-runtime.ts b/web/src/lib/assistant-runtime.ts index 7769ead3..04d0dccd 100644 --- a/web/src/lib/assistant-runtime.ts +++ b/web/src/lib/assistant-runtime.ts @@ -83,6 +83,56 @@ function visibleBlockRole(block: VisibleChatBlock): VisibleChatBlockRole { return 'assistant' } +export function getBlockPresentationTimestamp(block: VisibleChatBlock): number { + if (visibleBlockRole(block) === 'user') { + return block.invokedAt ?? block.createdAt + } + if (block.kind === 'tool-group') { + return block.tools.reduce( + (latest, tool) => Math.max(latest, tool.tool.completedAt ?? tool.createdAt), + block.createdAt + ) + } + if (block.kind === 'tool-call') { + return Math.max(block.createdAt, block.tool.completedAt ?? block.createdAt) + } + return block.createdAt +} + +/** + * `@assistant-ui/react` joins adjacent assistant-role blocks into one card and + * takes its timestamp from the first block. Use the response's final activity + * time so prepending an older page cannot change an existing card's timestamp. + */ +export function getResponseGroupTimestamps( + blocks: readonly VisibleChatBlock[] +): Map { + const timestamps = new Map() + let first: VisibleChatBlock | null = null + let latestTimestamp = 0 + + const flush = () => { + if (first) timestamps.set(first, latestTimestamp) + first = null + } + + for (const block of blocks) { + if (visibleBlockRole(block) !== 'assistant') { + flush() + continue + } + const timestamp = getBlockPresentationTimestamp(block) + if (!first) { + first = block + latestTimestamp = timestamp + } else { + latestTimestamp = Math.max(latestTimestamp, timestamp) + } + } + flush() + return timestamps +} + type TurnSource = { localId: string | null invokedAt: number | null @@ -318,12 +368,16 @@ export function assignThreadMessageIds( return assignThreadMessageIdsWithStableWrappers(blocks, new WeakMap()) } -function toThreadMessageLike(block: VisibleChatBlock, threadMessageId: string): ThreadMessageLike { +function toThreadMessageLike( + block: VisibleChatBlock, + threadMessageId: string, + timestamp: number +): ThreadMessageLike { if (block.kind === 'user-text') { return { role: 'user', id: threadMessageId, - createdAt: new Date(block.createdAt), + createdAt: new Date(timestamp), content: [{ type: 'text', text: block.text }], metadata: { custom: { @@ -342,7 +396,7 @@ function toThreadMessageLike(block: VisibleChatBlock, threadMessageId: string): return { role: 'assistant', id: threadMessageId, - createdAt: new Date(block.createdAt), + createdAt: new Date(timestamp), content: [{ type: 'text', text: block.text }], metadata: { custom: { @@ -360,7 +414,7 @@ function toThreadMessageLike(block: VisibleChatBlock, threadMessageId: string): return { role: 'assistant', id: threadMessageId, - createdAt: new Date(block.createdAt), + createdAt: new Date(timestamp), content: [{ type: 'tool-call', toolCallId: block.id, @@ -382,7 +436,7 @@ function toThreadMessageLike(block: VisibleChatBlock, threadMessageId: string): return { role: 'assistant', id: threadMessageId, - createdAt: new Date(block.createdAt), + createdAt: new Date(timestamp), content: [{ type: 'reasoning', text: block.text }], metadata: { custom: { @@ -400,7 +454,7 @@ function toThreadMessageLike(block: VisibleChatBlock, threadMessageId: string): return { role: 'assistant', id: threadMessageId, - createdAt: new Date(block.createdAt), + createdAt: new Date(timestamp), content: [{ type: 'text', text: formatCodexReviewText(block.review) }], metadata: { custom: { @@ -419,7 +473,7 @@ function toThreadMessageLike(block: VisibleChatBlock, threadMessageId: string): return { role: 'system', id: threadMessageId, - createdAt: new Date(block.createdAt), + createdAt: new Date(timestamp), content: [{ type: 'text', text: renderEventLabel(block.event) }], metadata: { custom: { @@ -436,7 +490,7 @@ function toThreadMessageLike(block: VisibleChatBlock, threadMessageId: string): return { role: block.source === 'user' ? 'user' : 'assistant', id: threadMessageId, - createdAt: new Date(block.createdAt), + createdAt: new Date(timestamp), content: [{ type: 'text', text: block.text }], metadata: { custom: { @@ -456,7 +510,7 @@ function toThreadMessageLike(block: VisibleChatBlock, threadMessageId: string): return { role: 'assistant', id: threadMessageId, - createdAt: new Date(groupBlock.createdAt), + createdAt: new Date(timestamp), content: [{ type: 'tool-call', toolCallId: groupBlock.id, @@ -480,7 +534,7 @@ function toThreadMessageLike(block: VisibleChatBlock, threadMessageId: string): return { role: 'assistant', id: threadMessageId, - createdAt: new Date(toolBlock.createdAt), + createdAt: new Date(timestamp), content: [{ type: 'tool-call', toolCallId: toolBlock.id, @@ -591,10 +645,18 @@ export function useHappyRuntime(props: { () => aggregateResponseGroups(props.blocks), [props.blocks] ) + const responseGroupTimestamps = useMemo( + () => getResponseGroupTimestamps(props.blocks), + [props.blocks] + ) const convertBlock = useCallback( ({ block, threadMessageId }: BlockWithThreadMessageId): ThreadMessageLike => { - const message = toThreadMessageLike(block, threadMessageId) + const message = toThreadMessageLike( + block, + threadMessageId, + responseGroupTimestamps.get(block) ?? getBlockPresentationTimestamp(block) + ) const aggregate = aggregates.get(block.id) if (!aggregate) return message const existing = message.metadata?.custom as HappyChatMessageMetadata | undefined @@ -613,7 +675,7 @@ export function useHappyRuntime(props: { } } }, - [aggregates] + [aggregates, responseGroupTimestamps] ) // Use cached message converter for performance optimization