mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
fix(web): stabilize message timestamps (#1152)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user