From 1ea0d84c186a19062a0f76551ad5cebf2c1180c1 Mon Sep 17 00:00:00 2001 From: BitFun Agent Date: Sun, 13 Sep 2026 14:26:35 +0800 Subject: [PATCH] fix(flow-chat): stop repainting finalized streamed text Finalizing active text items dropped the item registration but kept the accumulated text buffer, so the next chunk of the same stream key created a second item seeded with everything already painted (issue 2778). Release the buffer together with the registration; the item remains the source of truth for its own content, so a genuine late chunk still continues it. --- .../flow-chat-manager/TextChunkModule.test.ts | 56 ++++++++++++++++++- .../flow-chat-manager/TextChunkModule.ts | 14 ++++- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/TextChunkModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/TextChunkModule.test.ts index e6099fa8f5..a1830e017a 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/TextChunkModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/TextChunkModule.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest'; import type { AnyFlowItem, DialogTurn, FlowToolItem, ModelRound, Session } from '../../types/flow-chat'; -import { processNormalTextChunkInternal, processThinkingChunkInternal } from './TextChunkModule'; +import { + completeActiveTextItems, + processNormalTextChunkInternal, + processThinkingChunkInternal, +} from './TextChunkModule'; function makeContext(session: Session): any { return { @@ -31,7 +35,20 @@ function makeContext(session: Session): any { } } }, - batchUpdateModelRoundItems: () => {}, + batchUpdateModelRoundItems: ( + _sessionId: string, + _turnId: string, + updates: Array<{ itemId: string; changes: Partial }>, + ) => { + for (const round of session.dialogTurns[0].modelRounds) { + for (const update of updates) { + const item = round.items.find(candidate => candidate.id === update.itemId); + if (item) { + Object.assign(item, update.changes); + } + } + } + }, updateDialogTurn: ( _sessionId: string, _turnId: string, @@ -309,4 +326,39 @@ describe('processNormalTextChunkInternal', () => { 'summary', ]); }); + + // Issue 2778: the same assistant sentence was painted several times in a long + // turn. Finalizing active text items at a round boundary drops the item + // registration but keeps the accumulated text buffer, so the next chunk in + // that round finds no reusable item and creates a second item seeded with the + // whole buffer. Painting must not repeat text an earlier item already shows. + it('does not repaint already shown text when a round continues after active text items were finalized', () => { + const session = makeSession(); + const context = makeContext(session); + + processNormalTextChunkInternal( + context, + 'session-1', + 'turn-1', + 'round-1', + '看来只有 part3 入队成功,', + ); + + // Exactly what the model-round-start handler does before building a round. + completeActiveTextItems(context, 'session-1', 'turn-1'); + + processNormalTextChunkInternal(context, 'session-1', 'turn-1', 'round-1', '补上 part1/2。'); + + const painted = session.dialogTurns[0].modelRounds[0].items + .filter(item => item.type === 'text') + .map(item => (item as any).content as string); + + const firstSegment = painted[0]; + const repaintedSegments = painted + .slice(1) + .filter(text => text.includes(firstSegment)); + + expect(repaintedSegments).toEqual([]); + expect(painted.join('')).toBe('看来只有 part3 入队成功,补上 part1/2。'); + }); }); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/TextChunkModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/TextChunkModule.ts index 155a64a862..542d08168c 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/TextChunkModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/TextChunkModule.ts @@ -290,7 +290,7 @@ export function completeActiveTextItems( if (sessionActiveTextItems && sessionActiveTextItems.size > 0) { const itemsToComplete = Array.from(sessionActiveTextItems.entries()); const batchUpdates = itemsToComplete - .map(([_roundId, itemId]) => ({ + .map(([_streamKey, itemId]) => ({ itemId, changes: { isStreaming: false, @@ -301,6 +301,18 @@ export function completeActiveTextItems( if (batchUpdates.length > 0) { context.flowChatStore.batchUpdateModelRoundItems(sessionId, turnId, batchUpdates); } + + // A finalized segment must release its accumulated text together with its + // item registration. Keeping the buffer made the next chunk of the same + // stream key create a second item seeded with everything already painted + // (issue 2778). The item stays the source of truth for its own content, so + // a genuine late chunk still continues it through the reuse path. + const sessionContentBuffer = context.contentBuffers.get(sessionId); + if (sessionContentBuffer) { + for (const [streamKey] of itemsToComplete) { + sessionContentBuffer.delete(streamKey); + } + } sessionActiveTextItems.clear(); }