fix(web): stabilize message timestamps (#1152)

This commit is contained in:
SSU-WEI HUANG
2026-07-26 15:08:15 +08:00
committed by GitHub
parent 351ebafe54
commit 44390af35c
2 changed files with 150 additions and 13 deletions
+76 -1
View File
@@ -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
+74 -12
View File
@@ -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<VisibleChatBlock, number> {
const timestamps = new Map<VisibleChatBlock, number>()
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