fix: prevent cross-turn assistant message merging in chat UI

appendAssistantTextMessage was merging consecutive assistant_text messages
regardless of whether they belonged to different conversation turns. Added
turnComplete flag that gets set on message_complete, error, and status(idle)
events, preventing subsequent turns from merging into the previous response.
This commit is contained in:
2026-05-18 23:03:17 +08:00
parent 5e362d6965
commit f72a7e6d87
2 changed files with 19 additions and 5 deletions
+18 -4
View File
@@ -174,7 +174,7 @@ function appendAssistantTextMessage(
if (!content.trim()) return messages
const last = messages[messages.length - 1]
if (last?.type === 'assistant_text') {
if (last?.type === 'assistant_text' && !last.turnComplete) {
const merged: UIMessage = {
...last,
content: last.content + content,
@@ -195,6 +195,17 @@ function appendAssistantTextMessage(
]
}
function markLastAssistantComplete(messages: UIMessage[]): UIMessage[] {
const lastIdx = messages.length - 1
const last = messages[lastIdx]
if (last?.type === 'assistant_text' && !last.turnComplete) {
const updated = [...messages]
updated[lastIdx] = { ...last, turnComplete: true }
return updated
}
return messages
}
/** Helper: immutably update a specific session within the sessions record */
function updateSessionIn(
sessions: Record<string, PerSessionState>,
@@ -562,7 +573,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
...(msg.tokens ? { tokenUsage: { ...session.tokenUsage, output_tokens: msg.tokens } } : {}),
...(msg.state === 'idle' ? { activeThinkingId: null, statusVerb: '' } : {}),
...(shouldFlush ? {
messages: appendAssistantTextMessage(session.messages, pendingText, Date.now()),
messages: markLastAssistantComplete(appendAssistantTextMessage(session.messages, pendingText, Date.now())),
streamingText: '',
} : pendingText !== session.streamingText ? { streamingText: pendingText } : {}),
}
@@ -727,11 +738,14 @@ export const useChatStore = create<ChatStore>((set, get) => ({
const text = `${session.streamingText}${consumePendingDelta(sessionId)}`
if (text.trim()) {
update((s) => ({
messages: appendAssistantTextMessage(s.messages, text, Date.now()),
messages: markLastAssistantComplete(appendAssistantTextMessage(s.messages, text, Date.now())),
streamingText: '',
}))
} else if (text !== session.streamingText) {
update(() => ({ streamingText: text }))
update((s) => ({ messages: markLastAssistantComplete(s.messages) }))
} else {
update((s) => ({ messages: markLastAssistantComplete(s.messages) }))
}
if (session.elapsedTimer) clearInterval(session.elapsedTimer)
update(() => ({
@@ -750,7 +764,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
const pendingText = `${s.streamingText}${consumePendingDelta(sessionId)}`
let newMessages = s.messages
if (pendingText.trim()) {
newMessages = appendAssistantTextMessage(newMessages, pendingText, Date.now())
newMessages = markLastAssistantComplete(appendAssistantTextMessage(newMessages, pendingText, Date.now()))
}
newMessages = [...newMessages, { id: nextId(), type: 'error', message: msg.message, code: msg.code, timestamp: Date.now() }]
return {
+1 -1
View File
@@ -158,7 +158,7 @@ export type TaskSummaryItem = {
export type UIMessage =
| { id: string; type: 'user_text'; content: string; timestamp: number; attachments?: UIAttachment[]; pending?: boolean }
| { id: string; type: 'assistant_text'; content: string; timestamp: number; model?: string }
| { id: string; type: 'assistant_text'; content: string; timestamp: number; model?: string; turnComplete?: boolean }
| { id: string; type: 'thinking'; content: string; timestamp: number }
| { id: string; type: 'tool_use'; toolName: string; toolUseId: string; input: unknown; timestamp: number; parentToolUseId?: string }
| { id: string; type: 'tool_result'; toolUseId: string; content: unknown; isError: boolean; timestamp: number; parentToolUseId?: string }