Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -31,7 +35,20 @@ function makeContext(session: Session): any {
}
}
},
batchUpdateModelRoundItems: () => {},
batchUpdateModelRoundItems: (
_sessionId: string,
_turnId: string,
updates: Array<{ itemId: string; changes: Partial<AnyFlowItem> }>,
) => {
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,
Expand Down Expand Up @@ -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。');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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();
}
Expand Down