mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-09-02 22:14:19 +00:00
fix(chat): keep a Skill tool call inside the turn's activity fold (#146)
A Skill invocation persists the loaded skill body as a user-role message (origin skill_activation, trigger model-tool / nested-skill) between the tool call and its result. messagesToTurns treated it as a hard turn boundary: it flushed the pending assistant group, then hid the message. The turn split in two and each half rendered its own "worked Ns" fold row, with the Skill card stranded between them. Upstream's isAgentReplayUserTurnRecord already counts only user-slash activations as turn anchors; mirror that here by skipping non-user-slash skill activations the same way hidden injections are skipped, so the Skill call folds into the same activity run as the rest of the turn.
This commit is contained in:
parent
088cc00186
commit
a1702a54ba
5 changed files with 170 additions and 11 deletions
5
.changeset/skill-call-splits-worked-row.md
Normal file
5
.changeset/skill-call-splits-worked-row.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"kimi-code-app": patch
|
||||
---
|
||||
|
||||
修复调用 skill 会把「已工作」记录拆成两段的问题。
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: '<kimi-skill-loaded skill="kimi-webbridge">\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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: '<kimi-skill-loaded skill="kimi-webbridge">\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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue