diff --git a/.changeset/skill-call-splits-worked-row.md b/.changeset/skill-call-splits-worked-row.md new file mode 100644 index 000000000..ed93ed351 --- /dev/null +++ b/.changeset/skill-call-splits-worked-row.md @@ -0,0 +1,5 @@ +--- +"kimi-code-app": patch +--- + +修复调用 skill 会把「已工作」记录拆成两段的问题。 diff --git a/apps/desktop/src/renderer/composables/messagesToTurns.ts b/apps/desktop/src/renderer/composables/messagesToTurns.ts index 2648e4aeb..d9b49d3ed 100644 --- a/apps/desktop/src/renderer/composables/messagesToTurns.ts +++ b/apps/desktop/src/renderer/composables/messagesToTurns.ts @@ -821,11 +821,16 @@ export function messagesToTurns( // Hidden injections (todo-list reminders …) are stream noise, NOT turn // boundaries: they land mid-turn between assistant messages, so flushing // on them would fragment one agent turn into several chat turns (visible - // as repeated folded rows with nothing in between). Only this origin - // kind is skipped — every other hidden user message (hook results, - // retries, system triggers, …) keeps its boundary. Cron injections - // become their own turn below. - if (cronKind === undefined && userOriginKind === 'injection') { + // as repeated folded rows with nothing in between). A Skill tool call's + // loaded-skill message (trigger model-tool / nested-skill) is the same + // mid-turn noise — only a user-slash activation opens a real user turn + // (isAgentReplayUserTurnRecord parity). Every other hidden user message + // (hook results, retries, system triggers, …) keeps its boundary. Cron + // injections become their own turn below. + const isToolSkillActivation = + userOriginKind === 'skill_activation' && + (msg.metadata?.['origin'] as { trigger?: string } | undefined)?.trigger !== 'user-slash'; + if (cronKind === undefined && (userOriginKind === 'injection' || isToolSkillActivation)) { continue; } // Task notifications are the same mid-turn noise boundary-wise, but they diff --git a/apps/desktop/tests/renderer/turn-injection-boundary.test.ts b/apps/desktop/tests/renderer/turn-injection-boundary.test.ts index 6a78993d8..43f76a6f0 100644 --- a/apps/desktop/tests/renderer/turn-injection-boundary.test.ts +++ b/apps/desktop/tests/renderer/turn-injection-boundary.test.ts @@ -148,6 +148,78 @@ describe('messagesToTurns hidden injections', () => { }); }); +describe('messagesToTurns skill activation', () => { + /** The user message a Skill tool call injects with the loaded skill body. */ + function toolSkillMessage(id: string, trigger = 'model-tool'): AppMessage { + return message(id, 'user', [{ type: 'text', text: '\n…' }], { + metadata: { + origin: { kind: 'skill_activation', skillName: 'kimi-webbridge', trigger }, + }, + }); + } + + it('does not split the assistant turn on a Skill tool call', () => { + const turns = messagesToTurns( + [ + message('a1', 'assistant', [ + { type: 'toolUse', toolCallId: 'tool-1', toolName: 'bash', input: { command: 'ls' } }, + ]), + message('t1', 'tool', [{ type: 'toolResult', toolCallId: 'tool-1', output: 'x' }]), + message('a2', 'assistant', [ + { type: 'toolUse', toolCallId: 'tool-2', toolName: 'Skill', input: { skill: 'kimi-webbridge' } }, + ]), + toolSkillMessage('sk-1'), + message('t2', 'tool', [ + { type: 'toolResult', toolCallId: 'tool-2', output: 'Skill "kimi-webbridge" loaded inline.' }, + ]), + message('a3', 'assistant', [{ type: 'text', text: 'done' }]), + ], + [], + undefined, + false, + ); + expect(turns).toHaveLength(1); + expect(turns[0]?.tools?.map((t) => t.id)).toEqual(['tool-1', 'tool-2']); + // The Skill call folds with the rest of the activity behind ONE fold row. + const { folded, visible } = splitAssistantFold(turns[0]!); + expect(folded.map((b) => b.kind)).toEqual(['activity-run']); + expect(visible.map((b) => b.kind)).toEqual(['text']); + }); + + it('treats a nested-skill activation the same way', () => { + const turns = messagesToTurns( + [ + message('a1', 'assistant', [ + { type: 'toolUse', toolCallId: 'tool-1', toolName: 'Skill', input: { skill: 'a' } }, + ]), + toolSkillMessage('sk-1', 'nested-skill'), + message('t1', 'tool', [{ type: 'toolResult', toolCallId: 'tool-1', output: 'ok' }]), + message('a2', 'assistant', [{ type: 'text', text: 'done' }]), + ], + [], + undefined, + false, + ); + expect(turns).toHaveLength(1); + expect(turns[0]?.tools?.map((t) => t.id)).toEqual(['tool-1']); + }); + + it('keeps a user-slash skill activation as a user-turn boundary', () => { + const turns = messagesToTurns( + [ + message('a1', 'assistant', [{ type: 'text', text: 'one' }]), + toolSkillMessage('sk-1', 'user-slash'), + message('a2', 'assistant', [{ type: 'text', text: 'two' }]), + ], + [], + undefined, + false, + ); + expect(turns.map((t) => t.role)).toEqual(['assistant', 'user', 'assistant']); + expect(turns[1]?.skillActivation).toEqual({ name: 'kimi-webbridge', args: undefined }); + }); +}); + describe('messagesToTurns task notifications', () => { it('renders a mid-turn notification as a block without splitting the turn', () => { const turns = messagesToTurns( diff --git a/apps/web/src/composables/messagesToTurns.ts b/apps/web/src/composables/messagesToTurns.ts index 2648e4aeb..d9b49d3ed 100644 --- a/apps/web/src/composables/messagesToTurns.ts +++ b/apps/web/src/composables/messagesToTurns.ts @@ -821,11 +821,16 @@ export function messagesToTurns( // Hidden injections (todo-list reminders …) are stream noise, NOT turn // boundaries: they land mid-turn between assistant messages, so flushing // on them would fragment one agent turn into several chat turns (visible - // as repeated folded rows with nothing in between). Only this origin - // kind is skipped — every other hidden user message (hook results, - // retries, system triggers, …) keeps its boundary. Cron injections - // become their own turn below. - if (cronKind === undefined && userOriginKind === 'injection') { + // as repeated folded rows with nothing in between). A Skill tool call's + // loaded-skill message (trigger model-tool / nested-skill) is the same + // mid-turn noise — only a user-slash activation opens a real user turn + // (isAgentReplayUserTurnRecord parity). Every other hidden user message + // (hook results, retries, system triggers, …) keeps its boundary. Cron + // injections become their own turn below. + const isToolSkillActivation = + userOriginKind === 'skill_activation' && + (msg.metadata?.['origin'] as { trigger?: string } | undefined)?.trigger !== 'user-slash'; + if (cronKind === undefined && (userOriginKind === 'injection' || isToolSkillActivation)) { continue; } // Task notifications are the same mid-turn noise boundary-wise, but they diff --git a/apps/web/test/turn-injection-boundary.test.ts b/apps/web/test/turn-injection-boundary.test.ts index c1641fa6b..9cf2e2bf3 100644 --- a/apps/web/test/turn-injection-boundary.test.ts +++ b/apps/web/test/turn-injection-boundary.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { AppMessage, AppMessageContent } from '../src/api/types'; import { messagesToTurns } from '../src/composables/messagesToTurns'; -import { assistantRenderBlocks } from '../src/components/chatTurnRendering'; +import { assistantRenderBlocks, splitAssistantFold } from '../src/components/chatTurnRendering'; function message( id: string, @@ -125,6 +125,78 @@ describe('messagesToTurns hidden injections', () => { }); }); +describe('messagesToTurns skill activation', () => { + /** The user message a Skill tool call injects with the loaded skill body. */ + function toolSkillMessage(id: string, trigger = 'model-tool'): AppMessage { + return message(id, 'user', [{ type: 'text', text: '\n…' }], { + metadata: { + origin: { kind: 'skill_activation', skillName: 'kimi-webbridge', trigger }, + }, + }); + } + + it('does not split the assistant turn on a Skill tool call', () => { + const turns = messagesToTurns( + [ + message('a1', 'assistant', [ + { type: 'toolUse', toolCallId: 'tool-1', toolName: 'bash', input: { command: 'ls' } }, + ]), + message('t1', 'tool', [{ type: 'toolResult', toolCallId: 'tool-1', output: 'x' }]), + message('a2', 'assistant', [ + { type: 'toolUse', toolCallId: 'tool-2', toolName: 'Skill', input: { skill: 'kimi-webbridge' } }, + ]), + toolSkillMessage('sk-1'), + message('t2', 'tool', [ + { type: 'toolResult', toolCallId: 'tool-2', output: 'Skill "kimi-webbridge" loaded inline.' }, + ]), + message('a3', 'assistant', [{ type: 'text', text: 'done' }]), + ], + [], + undefined, + false, + ); + expect(turns).toHaveLength(1); + expect(turns[0]?.tools?.map((t) => t.id)).toEqual(['tool-1', 'tool-2']); + // The Skill call folds with the rest of the activity behind ONE fold row. + const { folded, visible } = splitAssistantFold(turns[0]!); + expect(folded.map((b) => b.kind)).toEqual(['activity-run']); + expect(visible.map((b) => b.kind)).toEqual(['text']); + }); + + it('treats a nested-skill activation the same way', () => { + const turns = messagesToTurns( + [ + message('a1', 'assistant', [ + { type: 'toolUse', toolCallId: 'tool-1', toolName: 'Skill', input: { skill: 'a' } }, + ]), + toolSkillMessage('sk-1', 'nested-skill'), + message('t1', 'tool', [{ type: 'toolResult', toolCallId: 'tool-1', output: 'ok' }]), + message('a2', 'assistant', [{ type: 'text', text: 'done' }]), + ], + [], + undefined, + false, + ); + expect(turns).toHaveLength(1); + expect(turns[0]?.tools?.map((t) => t.id)).toEqual(['tool-1']); + }); + + it('keeps a user-slash skill activation as a user-turn boundary', () => { + const turns = messagesToTurns( + [ + message('a1', 'assistant', [{ type: 'text', text: 'one' }]), + toolSkillMessage('sk-1', 'user-slash'), + message('a2', 'assistant', [{ type: 'text', text: 'two' }]), + ], + [], + undefined, + false, + ); + expect(turns.map((t) => t.role)).toEqual(['assistant', 'user', 'assistant']); + expect(turns[1]?.skillActivation).toEqual({ name: 'kimi-webbridge', args: undefined }); + }); +}); + describe('messagesToTurns turn stamps', () => { it('leaves endedAt undefined for a single-message turn (no Worked-0s span)', () => { const turns = messagesToTurns(