diff --git a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/__tests__/builder.test.ts b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/__tests__/builder.test.ts index 78aaa40d..92c10463 100644 --- a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/__tests__/builder.test.ts +++ b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/__tests__/builder.test.ts @@ -156,6 +156,90 @@ describe('chat timeline builder', () => { } }); + it('keeps completed memory compression rows immutable across repeated attempts', () => { + const items: ChatTimelineItem[] = []; + const metadata = { + roundId: 'round-1', + statusKey: 'memory-compression:round-1', + }; + + ChatTimelineBuilder.upsertMemoryCompressionStatus(items, { + ...metadata, + phase: 'started', + status: 'running', + }); + ChatTimelineBuilder.upsertMemoryCompressionStatus(items, { + ...metadata, + compressed: true, + phase: 'completed', + status: 'done', + }); + ChatTimelineBuilder.appendThinkingDelta(items, '继续处理', metadata); + ChatTimelineBuilder.upsertMemoryCompressionStatus(items, { + ...metadata, + phase: 'started', + status: 'running', + }); + + const runningStatuses = items.filter((item) => item.type === 'status'); + expect(runningStatuses).toHaveLength(2); + expect(runningStatuses[0]).toMatchObject({ + label: '已整理上下文', + status: 'done', + statusKey: 'memory-compression:round-1', + }); + expect(runningStatuses[1]).toMatchObject({ + label: '正在整理上下文', + status: 'running', + statusKey: 'memory-compression:round-1::occurrence:2', + }); + const thinkingIndex = items.findIndex( + (item) => + item.type === 'message' && + item.parts.some( + (part) => part.type === 'thinking' && part.content === '继续处理', + ), + ); + const currentStatus = runningStatuses[1]; + if (!currentStatus) { + throw new Error('缺少第二次上下文整理状态'); + } + expect(items.indexOf(currentStatus)).toBeGreaterThan(thinkingIndex); + + ChatTimelineBuilder.upsertMemoryCompressionStatus(items, { + ...metadata, + compressed: true, + phase: 'completed', + status: 'done', + }); + ChatTimelineBuilder.upsertMemoryCompressionStatus(items, { + ...metadata, + phase: 'started', + status: 'running', + }); + ChatTimelineBuilder.upsertMemoryCompressionStatus(items, { + ...metadata, + compressed: false, + phase: 'completed', + status: 'done', + }); + + const completedStatuses = items.filter((item) => item.type === 'status'); + expect(completedStatuses).toHaveLength(2); + expect(completedStatuses).toEqual([ + expect.objectContaining({ + label: '已整理上下文', + status: 'done', + statusKey: 'memory-compression:round-1', + }), + expect.objectContaining({ + label: '已整理上下文', + status: 'done', + statusKey: 'memory-compression:round-1::occurrence:2', + }), + ]); + }); + it('updates one Skill invocation row in place and preserves Skill order', () => { const items: ChatTimelineItem[] = []; diff --git a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/builder.ts b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/builder.ts index a5e88ceb..adbfe002 100644 --- a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/builder.ts +++ b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/builder.ts @@ -228,6 +228,31 @@ function findStatusItem(items: ChatTimelineItem[], statusKey: string) { ); } +function resolveStatusOccurrenceKey( + items: ChatTimelineItem[], + statusKey: string, + roundId?: string, +) { + // 同一 Turn 可多次触发整理;只复用仍在运行的实例,避免改写已完成的历史状态。 + const occurrencePrefix = `${statusKey}::occurrence:`; + const occurrences = items.filter( + (item): item is ChatTimelineStatusItem => + item.type === 'status' && + (item.statusKey === statusKey || + item.statusKey.startsWith(occurrencePrefix)) && + (!roundId || item.roundId === roundId), + ); + const running = [...occurrences] + .reverse() + .find((item) => item.status === 'running'); + if (running) { + return running.statusKey; + } + return occurrences.length === 0 + ? statusKey + : `${occurrencePrefix}${occurrences.length + 1}`; +} + function removeStatusItem(items: ChatTimelineItem[], statusKey: string) { const index = items.findIndex( (item) => item.type === 'status' && item.statusKey === statusKey, @@ -680,7 +705,11 @@ export const ChatTimelineBuilder = { payload?.status === 'done' || payload?.phase === 'completed' ? 'done' : 'running'; - const statusKey = payload?.statusKey || 'memory-compression'; + const statusKey = resolveStatusOccurrenceKey( + items, + payload?.statusKey || 'memory-compression', + payload?.roundId, + ); finishAssistantMessage(items, false, payload?.roundId); if (status === 'done' && payload?.compressed === false) { removeStatusItem(items, statusKey);