fix(chat): refresh history when non-text streamed content changes

This commit is contained in:
ibelick
2026-02-13 14:08:13 +01:00
parent 10a50e5a4a
commit 41671f8b0b
3 changed files with 483 additions and 100 deletions
@@ -267,7 +267,7 @@ export function ChatScreen({
sessionKey,
friendlyId,
message: body,
thinking: 'low',
thinking: 'high',
idempotencyKey: crypto.randomUUID(),
attachments: attachmentsPayload,
}),
@@ -93,7 +93,8 @@ export function useChatHistory({
const lastId = typeof last?.id === 'string' ? last.id : ''
const lastRole = typeof last?.role === 'string' ? last.role : ''
const lastText = last ? textFromMessage(last) : ''
const signature = `${messages.length}:${lastRole}:${lastId}:${lastText.slice(-32)}`
const lastContentSignature = last ? contentSignatureFromMessage(last) : ''
const signature = `${messages.length}:${lastRole}:${lastId}:${lastText.slice(-32)}:${lastContentSignature}`
if (signature === stableHistorySignatureRef.current) {
return stableHistoryMessagesRef.current
}
@@ -125,6 +126,24 @@ export function useChatHistory({
}
}
function contentSignatureFromMessage(message: GatewayMessage): string {
const content = Array.isArray(message.content) ? message.content : []
return content
.map((part) => {
if (part.type === 'text') {
return `text:${String(part.text ?? '').length}`
}
if (part.type === 'thinking') {
return `thinking:${String(part.thinking ?? '').length}`
}
const id = 'id' in part ? String(part.id ?? '') : ''
const name = 'name' in part ? String(part.name ?? '') : ''
const partialJson = 'partialJson' in part ? String(part.partialJson ?? '') : ''
return `toolCall:${id}:${name}:${partialJson.length}`
})
.join('|')
}
function mergeStreamingHistoryMessages(
serverMessages: Array<GatewayMessage>,
streamingMessages: Array<GatewayMessage>,
@@ -139,20 +158,31 @@ function mergeStreamingHistoryMessages(
const hasMatch = merged.some((serverMessage) => {
const serverRunId = (serverMessage as { __streamRunId?: unknown })
.__streamRunId
if (serverMessage.role !== streamingMessage.role) return false
const streamingTime = getMessageTimestamp(streamingMessage)
const serverTime = getMessageTimestamp(serverMessage)
if (Math.abs(streamingTime - serverTime) > 15000) return false
if (
typeof serverRunId === 'string' &&
serverRunId.trim().length > 0 &&
serverRunId === runId
) {
return true
return messageCoversStreamingMessage(serverMessage, streamingMessage)
}
if (serverMessage.role !== streamingMessage.role) return false
const streamingText = textFromMessage(streamingMessage)
if (!streamingText) return false
if (streamingText !== textFromMessage(serverMessage)) return false
const streamingTime = getMessageTimestamp(streamingMessage)
const serverTime = getMessageTimestamp(serverMessage)
return Math.abs(streamingTime - serverTime) <= 15000
const serverText = textFromMessage(serverMessage)
if (
streamingText &&
streamingText !== serverText &&
!serverText.startsWith(streamingText)
) {
return false
}
return messageCoversStreamingMessage(serverMessage, streamingMessage)
})
if (!hasMatch) {
@@ -163,6 +193,37 @@ function mergeStreamingHistoryMessages(
return merged
}
function messageCoversStreamingMessage(
serverMessage: GatewayMessage,
streamingMessage: GatewayMessage,
): boolean {
const serverSignatures = nonTextPartSignatures(serverMessage)
const streamingSignatures = nonTextPartSignatures(streamingMessage)
if (streamingSignatures.size === 0) return true
for (const signature of streamingSignatures) {
if (!serverSignatures.has(signature)) {
return false
}
}
return true
}
function nonTextPartSignatures(message: GatewayMessage): Set<string> {
const signatures = new Set<string>()
const parts = Array.isArray(message.content) ? message.content : []
for (const part of parts) {
if (part.type === 'text') continue
try {
signatures.add(`${part.type}:${JSON.stringify(part)}`)
} catch {
signatures.add(`${part.type}:unserializable`)
}
}
return signatures
}
function mergeOptimisticHistoryMessages(
serverMessages: Array<GatewayMessage>,
optimisticMessages: Array<GatewayMessage>,
@@ -7,7 +7,7 @@ import {
updateSessionLastMessage,
} from '../chat-queries'
import type { QueryClient } from '@tanstack/react-query'
import type { GatewayMessage } from '../types'
import type { GatewayMessage, MessageContent } from '../types'
type UseChatStreamInput = {
activeFriendlyId: string
@@ -25,6 +25,14 @@ type UseChatStreamInput = {
}) => void
}
type StreamChatPayload = {
runId?: string
sessionKey?: string
state?: string
message?: GatewayMessage
seq?: number
}
export function useChatStream({
activeFriendlyId,
isNewChat,
@@ -37,10 +45,14 @@ export function useChatStream({
}: UseChatStreamInput) {
const streamSourceRef = useRef<EventSource | null>(null)
const streamReconnectTimer = useRef<number | null>(null)
const streamAgentRefreshTimer = useRef<number | null>(null)
const streamHistoryPollTimer = useRef<number | null>(null)
const streamActiveRunsRef = useRef(new Set<string>())
const streamReconnectAttempt = useRef(0)
const streamRunTextRef = useRef(new Map<string, string>())
const streamRunSeqRef = useRef(new Map<string, number>())
const streamRunStateVersionRef = useRef(new Map<string, number>())
const streamRunSourceRef = useRef(new Map<string, 'agent' | 'chat'>())
const streamSeenEventKeysRef = useRef(new Set<string>())
const refreshHistoryRef = useRef(refreshHistory)
useEffect(() => {
@@ -56,9 +68,59 @@ export function useChatStream({
streamSourceRef.current.close()
streamSourceRef.current = null
}
streamRunTextRef.current.clear()
if (streamAgentRefreshTimer.current) {
window.clearTimeout(streamAgentRefreshTimer.current)
streamAgentRefreshTimer.current = null
}
if (streamHistoryPollTimer.current) {
window.clearInterval(streamHistoryPollTimer.current)
streamHistoryPollTimer.current = null
}
streamActiveRunsRef.current.clear()
streamRunSeqRef.current.clear()
streamRunStateVersionRef.current.clear()
streamRunSourceRef.current.clear()
streamSeenEventKeysRef.current.clear()
}, [])
const scheduleHistoryRefresh = useCallback(() => {
if (streamAgentRefreshTimer.current) return
streamAgentRefreshTimer.current = window.setTimeout(() => {
streamAgentRefreshTimer.current = null
refreshHistoryRef.current()
}, 500)
}, [])
const ensureHistoryPolling = useCallback(() => {
if (streamHistoryPollTimer.current) return
streamHistoryPollTimer.current = window.setInterval(() => {
if (streamActiveRunsRef.current.size === 0) {
if (streamHistoryPollTimer.current) {
window.clearInterval(streamHistoryPollTimer.current)
streamHistoryPollTimer.current = null
}
return
}
refreshHistoryRef.current()
}, 800)
}, [])
const markRunActive = useCallback(
(runId: string) => {
if (!runId) return
streamActiveRunsRef.current.add(runId)
ensureHistoryPolling()
},
[ensureHistoryPolling],
)
const markRunDone = useCallback((runId: string) => {
if (!runId) return
streamActiveRunsRef.current.delete(runId)
if (streamActiveRunsRef.current.size === 0 && streamHistoryPollTimer.current) {
window.clearInterval(streamHistoryPollTimer.current)
streamHistoryPollTimer.current = null
}
}, [])
useEffect(() => {
@@ -101,26 +163,64 @@ export function useChatStream({
return
}
if (!parsed.event) return
if (parsed.event === 'chat') {
const payload = parsed.payload as
| {
runId?: string
sessionKey?: string
state?: string
message?: GatewayMessage
if (parsed.event === 'chat' || parsed.event === 'agent') {
const payloads: Array<StreamChatPayload | null> =
parsed.event === 'chat'
? [parsed.payload as StreamChatPayload | null]
: extractChatPayloadsFromAgentPayload(parsed.payload)
if (parsed.event === 'agent' && payloads.length === 0) {
scheduleHistoryRefresh()
return
}
for (const payload of payloads) {
if (!payload) continue
const streamRunId =
typeof payload.runId === 'string' ? payload.runId : ''
const payloadSource: 'agent' | 'chat' =
parsed.event === 'agent' ? 'agent' : 'chat'
if (streamRunId) {
const currentSource = streamRunSourceRef.current.get(streamRunId)
if (
payloadSource === 'chat' &&
currentSource === 'agent' &&
payload.state === 'delta'
) {
continue
}
| null
const streamRunId =
typeof payload?.runId === 'string' ? payload.runId : ''
const eventSeq =
typeof parsed.seq === 'number' && Number.isFinite(parsed.seq)
? parsed.seq
: undefined
const eventStateVersion =
typeof parsed.stateVersion === 'number' &&
Number.isFinite(parsed.stateVersion)
? parsed.stateVersion
: undefined
if (payloadSource === 'agent' || !currentSource) {
streamRunSourceRef.current.set(streamRunId, payloadSource)
}
}
const payloadSeq =
typeof payload.seq === 'number' && Number.isFinite(payload.seq)
? payload.seq
: undefined
const eventSeq =
payloadSource === 'agent'
? payloadSeq
: typeof parsed.seq === 'number' && Number.isFinite(parsed.seq)
? parsed.seq
: undefined
const eventStateVersion =
typeof parsed.stateVersion === 'number' &&
Number.isFinite(parsed.stateVersion)
? parsed.stateVersion
: undefined
if (
shouldSkipDuplicateEvent(
streamSeenEventKeysRef.current,
payloadSource,
streamRunId,
payload.state,
eventSeq,
)
) {
continue
}
if (
shouldSkipStaleRunEvent(
@@ -131,13 +231,23 @@ export function useChatStream({
streamRunStateVersionRef.current,
)
) {
return
continue
}
if (payload) {
onChatEvent?.(payload)
}
if (payload?.message && typeof payload.message === 'object') {
onChatEvent?.(payload)
const payloadState =
typeof payload.state === 'string' ? payload.state : ''
if (
payloadState === 'final' ||
payloadState === 'error' ||
payloadState === 'aborted'
) {
markRunDone(streamRunId)
refreshHistoryRef.current()
} else if (payloadState === 'delta') {
markRunActive(streamRunId)
}
if (payload.message && typeof payload.message === 'object') {
const payloadSessionKey = payload.sessionKey
if (
payloadSessionKey &&
@@ -145,51 +255,23 @@ export function useChatStream({
payloadSessionKey !== resolvedSessionKey &&
payloadSessionKey !== sessionKeyForHistory
) {
return
continue
}
const state = typeof payload.state === 'string' ? payload.state : ''
let nextMessage: GatewayMessage = {
const nextMessage: GatewayMessage = {
...payload.message,
__streamRunId: streamRunId || undefined,
}
if (streamRunId && state === 'delta') {
const deltaText = rawTextFromMessage(nextMessage)
const previousText = streamRunTextRef.current.get(streamRunId) ?? ''
const cumulativeText = mergeDeltaText(previousText, deltaText)
if (cumulativeText.length > 0) {
streamRunTextRef.current.set(streamRunId, cumulativeText)
nextMessage = {
...nextMessage,
content: [{ type: 'text', text: cumulativeText }],
}
if (
streamRunId &&
(state === 'final' || state === 'error' || state === 'aborted')
) {
markRunDone(streamRunId)
streamRunSeqRef.current.delete(streamRunId)
streamRunStateVersionRef.current.delete(streamRunId)
streamRunSourceRef.current.delete(streamRunId)
}
}
if (streamRunId && state === 'final') {
const finalText = rawTextFromMessage(nextMessage)
if (!finalText) {
const bufferedText = streamRunTextRef.current.get(streamRunId)
if (bufferedText) {
nextMessage = {
...nextMessage,
content: [{ type: 'text', text: bufferedText }],
}
}
}
streamRunTextRef.current.delete(streamRunId)
streamRunSeqRef.current.delete(streamRunId)
streamRunStateVersionRef.current.delete(streamRunId)
}
if (
streamRunId &&
(state === 'error' || state === 'aborted')
) {
streamRunTextRef.current.delete(streamRunId)
streamRunSeqRef.current.delete(streamRunId)
streamRunStateVersionRef.current.delete(streamRunId)
}
function upsert(messages: Array<GatewayMessage>) {
const lastUserIndex = [...messages]
@@ -199,15 +281,15 @@ export function useChatStream({
lastUserIndex >= 0 ? messages.length - 1 - lastUserIndex : -1
if (streamRunId) {
const index = messages.findIndex(
(message) =>
(message as { __streamRunId?: string }).__streamRunId ===
streamRunId,
const index = findStreamMessageIndex(
messages,
nextMessage,
streamRunId,
)
if (index >= 0) {
if (index > resolvedLastUserIndex) {
const next = [...messages]
next[index] = nextMessage
next[index] = mergeStreamMessage(messages[index], nextMessage)
return next
}
return [...messages, nextMessage]
@@ -224,7 +306,7 @@ export function useChatStream({
const targetTime = getMessageTimestamp(messages[target])
if (Math.abs(nextTime - targetTime) <= 15000) {
const next = [...messages]
next[target] = nextMessage
next[target] = mergeStreamMessage(messages[target], nextMessage)
return next
}
}
@@ -261,6 +343,7 @@ export function useChatStream({
)
}
}
}
return
}
if (!parsed.event.startsWith('chat.')) {
@@ -314,29 +397,74 @@ export function useChatStream({
return { stopStream }
}
function rawTextFromMessage(message: GatewayMessage): string {
const parts = Array.isArray(message.content) ? message.content : []
return parts
.map((part) => (part.type === 'text' ? String(part.text ?? '') : ''))
.join('')
}
function mergeStreamMessage(
previousMessage: GatewayMessage,
nextMessage: GatewayMessage,
): GatewayMessage {
const previousContent = Array.isArray(previousMessage.content)
? previousMessage.content
: []
const nextContent = Array.isArray(nextMessage.content) ? nextMessage.content : []
function mergeDeltaText(previousText: string, nextText: string): string {
if (!previousText) return nextText
if (!nextText) return previousText
if (nextText.startsWith(previousText)) return nextText
if (previousText.endsWith(nextText)) return previousText
const maxOverlap = Math.min(previousText.length, nextText.length)
for (let overlap = maxOverlap; overlap > 0; overlap -= 1) {
const previousSuffix = previousText.slice(-overlap)
const nextPrefix = nextText.slice(0, overlap)
if (previousSuffix === nextPrefix) {
return `${previousText}${nextText.slice(overlap)}`
}
if (previousContent.length === 0) {
return nextMessage
}
return `${previousText}${nextText}`
if (nextContent.length === 0) {
return { ...previousMessage, ...nextMessage }
}
return {
...previousMessage,
...nextMessage,
content: mergeMessageContent(previousContent, nextContent),
}
}
function mergeMessageContent(
previousContent: Array<MessageContent>,
nextContent: Array<MessageContent>,
): Array<MessageContent> {
const mergedByIdentity = new Map<string, MessageContent>()
const orderedKeys: Array<string> = []
function upsertPart(part: MessageContent) {
const identity = partIdentity(part)
if (!mergedByIdentity.has(identity)) {
orderedKeys.push(identity)
}
mergedByIdentity.set(identity, part)
}
for (const part of previousContent) {
upsertPart(part)
}
for (const part of nextContent) {
upsertPart(part)
}
return orderedKeys
.map((key) => mergedByIdentity.get(key))
.filter((part): part is MessageContent => Boolean(part))
}
function partIdentity(part: MessageContent): string {
switch (part.type) {
case 'text':
return 'text'
case 'thinking':
return 'thinking'
case 'toolCall': {
const toolCallId = normalizeString((part as { id?: unknown }).id)
const toolName = normalizeString((part as { name?: unknown }).name)
if (toolCallId || toolName) {
return `toolCall:${toolCallId}:${toolName}`
}
return `toolCall:${JSON.stringify(part)}`
}
default:
return 'unknown'
}
}
function shouldSkipStaleRunEvent(
@@ -369,3 +497,197 @@ function shouldSkipStaleRunEvent(
return false
}
function findStreamMessageIndex(
messages: Array<GatewayMessage>,
targetMessage: GatewayMessage,
streamRunId: string,
): number {
const targetId = getMessageId(targetMessage)
if (targetId) {
const byId = messages.findIndex((message) => getMessageId(message) === targetId)
if (byId >= 0) return byId
}
const targetRole = normalizeString(targetMessage.role)
const targetToolCallId = normalizeString(targetMessage.toolCallId)
let index = -1
messages.forEach((message, currentIndex) => {
const runId = normalizeString((message as { __streamRunId?: unknown }).__streamRunId)
if (!runId || runId !== streamRunId) return
if (normalizeString(message.role) !== targetRole) return
const messageToolCallId = normalizeString(message.toolCallId)
if (targetToolCallId || messageToolCallId) {
if (targetToolCallId !== messageToolCallId) return
}
index = currentIndex
})
return index
}
function getMessageId(message: GatewayMessage): string {
return normalizeString((message as { id?: unknown }).id)
}
function normalizeString(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function shouldSkipDuplicateEvent(
seen: Set<string>,
source: 'agent' | 'chat',
runId: string,
state: string | undefined,
seq: number | undefined,
): boolean {
if (!runId || typeof seq !== 'number') return false
const key = `${source}:${runId}:${state ?? ''}:${seq}`
if (seen.has(key)) return true
seen.add(key)
if (seen.size > 4000) {
seen.clear()
}
return false
}
function extractChatPayloadsFromAgentPayload(
payload: unknown,
): Array<StreamChatPayload | null> {
if (!payload || typeof payload !== 'object') return []
const value = payload as Record<string, unknown>
const runId = normalizeString(value.runId)
const sessionKey = normalizeString(value.sessionKey)
const stream = normalizeString(value.stream)
const seq =
typeof value.seq === 'number' && Number.isFinite(value.seq)
? value.seq
: undefined
const data =
value.data && typeof value.data === 'object'
? (value.data as Record<string, unknown>)
: null
if (stream === 'assistant') {
const text =
normalizeString(data?.text) || normalizeString(data?.delta) || ''
if (!text) return []
return [
{
runId,
sessionKey,
state: 'delta',
seq,
message: {
role: 'assistant',
content: [{ type: 'text', text }],
},
},
]
}
if (stream === 'thinking') {
const thinking =
normalizeString(data?.thinking) || normalizeString(data?.text) || ''
if (!thinking) return []
return [
{
runId,
sessionKey,
state: 'delta',
seq,
message: {
role: 'assistant',
content: [{ type: 'thinking', thinking }],
},
},
]
}
if (stream === 'lifecycle') {
const phase = normalizeString(data?.phase)
if (phase === 'end') {
return [
{
runId,
sessionKey,
state: 'final',
seq,
},
]
}
return []
}
if (stream.includes('tool')) {
const toolCallId =
normalizeString(data?.toolCallId) ||
normalizeString(data?.id) ||
normalizeString(data?.callId)
const toolName =
normalizeString(data?.toolName) || normalizeString(data?.name)
if (stream.includes('call')) {
const partialJson = normalizeString(data?.partialJson)
const input =
data && typeof data.input === 'object'
? (data.input as Record<string, unknown>)
: data && typeof data.arguments === 'object'
? (data.arguments as Record<string, unknown>)
: undefined
return [
{
runId,
sessionKey,
state: 'delta',
seq,
message: {
role: 'assistant',
content: [
{
type: 'toolCall',
id: toolCallId || undefined,
name: toolName || undefined,
partialJson: partialJson || undefined,
arguments: input,
},
],
},
},
]
}
if (stream.includes('result') || stream.includes('output')) {
const output = data?.output
const errorText = normalizeString(data?.error)
return [
{
runId,
sessionKey,
state: 'delta',
seq,
message: {
role: 'toolResult',
toolCallId: toolCallId || undefined,
toolName: toolName || undefined,
details:
output && typeof output === 'object'
? (output as Record<string, unknown>)
: undefined,
isError: Boolean(errorText),
content: [
{
type: 'text',
text: errorText || (typeof output === 'string' ? output : ''),
},
],
},
},
]
}
return []
}
return []
}