fix(web): merge assistant snapshot turns without prompt ids

This commit is contained in:
qer 2026-06-12 03:50:39 +08:00
parent 478ba49b3c
commit cbd9796dfb
2 changed files with 96 additions and 22 deletions

View file

@ -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<string>(),
};
} 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);

View file

@ -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([