From cbd9796dfb3be8e2faf81cdcb6e38e8833e415eb Mon Sep 17 00:00:00 2001 From: qer Date: Fri, 12 Jun 2026 03:50:39 +0800 Subject: [PATCH] fix(web): merge assistant snapshot turns without prompt ids --- .../src/composables/messagesToTurns.ts | 55 +++++++++------- .../test/thinking-multi-segment.test.ts | 63 +++++++++++++++++++ 2 files changed, 96 insertions(+), 22 deletions(-) diff --git a/apps/kimi-web/src/composables/messagesToTurns.ts b/apps/kimi-web/src/composables/messagesToTurns.ts index bb31c153d..58d4ea059 100644 --- a/apps/kimi-web/src/composables/messagesToTurns.ts +++ b/apps/kimi-web/src/composables/messagesToTurns.ts @@ -1,14 +1,13 @@ // apps/kimi-web/src/composables/messagesToTurns.ts // Converts a flat list of AppMessages into ChatTurn[] for rendering. // -// Key rule: consecutive ASSISTANT messages that share the same non-undefined -// promptId are merged into ONE ChatTurn. This prevents a multi-step agent -// turn (think → tool → result → text) from appearing as several "kimi >" -// blocks. TOOL-role messages fold their toolResult content into the -// preceding assistant group rather than becoming separate turns. -// -// Fallback: if promptId is undefined on both the pending group and the -// incoming message they are NOT merged (one turn per message, old behaviour). +// Key rule: consecutive ASSISTANT messages are merged into ONE ChatTurn unless +// two known promptIds prove that they belong to different prompts. This +// prevents a multi-step agent turn (think → tool → result → text) from appearing +// as several "kimi >" blocks. Snapshot messages may omit promptId, so user +// messages and compaction summaries are the hard turn boundaries. +// TOOL-role messages fold their toolResult content into the preceding assistant +// group rather than becoming separate turns. import type { AppMessage, AppApprovalRequest, CompactionMarkerMetadata } from '../api/types'; import { COMPACTION_MARKER_METADATA_KEY } from '../api/types'; @@ -247,8 +246,8 @@ function buildApprovalBlock(a: AppApprovalRequest): ApprovalBlock { interface Group { /** id of the first assistant message in the group — used as the turn id */ id: string; - /** The shared promptId (never undefined inside a group; empty string = no promptId) */ - promptId: string; + /** Known promptId for this assistant group, if the protocol supplied one. */ + promptId: string | undefined; textParts: string[]; thinkingParts: string[]; tools: ToolCall[]; @@ -297,6 +296,15 @@ function isCompactionSummaryMessage(msg: AppMessage): boolean { return origin?.kind === 'compaction_summary'; } +function continuesAssistantGroup(group: Group | null, promptId: string | undefined): group is Group { + if (group === null) return false; + return ( + group.promptId === undefined || + promptId === undefined || + group.promptId === promptId + ); +} + export function messagesToTurns( messages: AppMessage[], approvals: AppApprovalRequest[], @@ -471,22 +479,20 @@ export function messagesToTurns( // Assistant messages: decide whether to extend the current group or start a new one. // - // Merge rule: both the pending group and the incoming message must have a - // defined, equal promptId. If either is undefined → start a new group - // (fallback to old one-turn-per-message behaviour). + // Merge rule: user messages and compaction summaries are hard boundaries. + // Inside an assistant segment, split only when both sides have known, + // different promptIds. The daemon's REST snapshot is allowed to omit + // prompt_id, so "missing promptId" must not fragment one model reply into + // many chat children. const pid = msg.promptId; - const continuesGroup = - pendingGroup !== null && - pid !== undefined && - pendingGroup.promptId !== '' && - pendingGroup.promptId === pid; + const continuesGroup = continuesAssistantGroup(pendingGroup, pid); if (!continuesGroup) { flushGroup(); pendingGroup = { id: msg.id, - promptId: pid ?? '', // empty string = "no promptId" sentinel + promptId: pid, textParts: [], thinkingParts: [], tools: [], @@ -495,16 +501,21 @@ export function messagesToTurns( approvalId: undefined, seenSigs: new Set(), }; + } else if (pendingGroup !== null && pendingGroup.promptId === undefined && pid !== undefined) { + pendingGroup.promptId = pid; } + const group = pendingGroup; + if (group === null) continue; + // Drop an assistant message whose content was already folded into this group // (a duplicate streamed-vs-persisted copy sharing the promptId), so the turn // doesn't render the same text + tools twice. const sig = JSON.stringify(msg.content); - if (pendingGroup!.seenSigs.has(sig)) continue; - pendingGroup!.seenSigs.add(sig); + if (group.promptId !== undefined && group.seenSigs.has(sig)) continue; + group.seenSigs.add(sig); - absorbContent(pendingGroup!, msg.content); + absorbContent(group, msg.content); } flushGroup(true); diff --git a/apps/kimi-web/test/thinking-multi-segment.test.ts b/apps/kimi-web/test/thinking-multi-segment.test.ts index 2c7cb2146..764812b97 100644 --- a/apps/kimi-web/test/thinking-multi-segment.test.ts +++ b/apps/kimi-web/test/thinking-multi-segment.test.ts @@ -10,6 +10,7 @@ import { describe, expect, it } from 'vitest'; import { createAgentProjector } from '../src/api/daemon/agentEventProjector'; import { createInitialState, reduceAppEvent, type KimiClientState } from '../src/api/daemon/eventReducer'; import { messagesToTurns } from '../src/composables/messagesToTurns'; +import type { AppMessage } from '../src/api/types'; const SESSION = 'sess_1'; @@ -124,6 +125,68 @@ describe('multi-segment thinking', () => { }); }); +describe('snapshot turn grouping', () => { + function message( + id: string, + role: AppMessage['role'], + content: AppMessage['content'], + promptId?: string, + ): AppMessage { + return { + id, + sessionId: SESSION, + role, + content, + createdAt: '2026-06-12T00:00:00.000Z', + promptId, + }; + } + + it('merges adjacent assistant snapshot messages when promptId is missing', () => { + const turns = messagesToTurns( + [ + message('u1', 'user', [{ type: 'text', text: 'hi' }]), + message('a1', 'assistant', [{ type: 'thinking', thinking: 'inspect' }]), + message('a2', 'assistant', [ + { type: 'toolUse', toolCallId: 't1', toolName: 'Read', input: { path: 'a.ts' } }, + ]), + message('t1-result', 'tool', [ + { type: 'toolResult', toolCallId: 't1', output: 'file body' }, + ]), + message('a3', 'assistant', [{ type: 'text', text: 'done' }]), + ], + [], + ); + + expect(turns.map((turn) => turn.role)).toEqual(['user', 'assistant']); + const assistant = turns[1]!; + expect(assistant.blocks?.map((block) => block.kind)).toEqual(['thinking', 'tool', 'text']); + expect(assistant.blocks?.[1]).toMatchObject({ + kind: 'tool', + tool: { + id: 't1', + status: 'ok', + output: ['file body'], + }, + }); + expect(assistant.text).toBe('done'); + expect(assistant.thinking).toBe('inspect'); + }); + + it('keeps adjacent assistant messages separate when promptIds disagree', () => { + const turns = messagesToTurns( + [ + message('a1', 'assistant', [{ type: 'text', text: 'first' }], 'prompt_1'), + message('a2', 'assistant', [{ type: 'text', text: 'second' }], 'prompt_2'), + ], + [], + ); + + expect(turns).toHaveLength(2); + expect(turns.map((turn) => turn.text)).toEqual(['first', 'second']); + }); +}); + describe('prompt.submitted projection', () => { it('creates the user message for a prompt sent by another client', () => { const state = play([