mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-25 08:34:39 +00:00
fix(web): merge assistant snapshot turns without prompt ids
This commit is contained in:
parent
478ba49b3c
commit
cbd9796dfb
2 changed files with 96 additions and 22 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue