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:
qer 2026-07-29 13:18:11 +08:00 committed by GitHub
parent 088cc00186
commit a1702a54ba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 170 additions and 11 deletions

View file

@ -0,0 +1,5 @@
---
"kimi-code-app": patch
---
修复调用 skill 会把「已工作」记录拆成两段的问题。

View file

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

View file

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

View file

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

View file

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