diff --git a/.changeset/fix-skill-listing-truncation.md b/.changeset/fix-skill-listing-truncation.md new file mode 100644 index 0000000000..08cc8abe98 --- /dev/null +++ b/.changeset/fix-skill-listing-truncation.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix truncated skill descriptions missing an ellipsis in the model's skill listing. diff --git a/packages/agent-core/src/skill/registry.ts b/packages/agent-core/src/skill/registry.ts index c3052491af..65b207e274 100644 --- a/packages/agent-core/src/skill/registry.ts +++ b/packages/agent-core/src/skill/registry.ts @@ -183,6 +183,18 @@ function formatModelSkill(skill: SkillDefinition): readonly string[] { return lines; } +const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); + function truncate(value: string, max: number): string { - return value.length > max ? value.slice(0, max) : value; + if (value.length <= max) return value; + // Reserve one code unit for the trailing ellipsis and walk whole grapheme + // clusters so we never split a surrogate pair or combining sequence. + let length = 0; + let result = ''; + for (const { segment } of graphemeSegmenter.segment(value)) { + if (length + segment.length > max - 1) break; + result += segment; + length += segment.length; + } + return `${result}…`; } diff --git a/packages/agent-core/test/skill/registry.test.ts b/packages/agent-core/test/skill/registry.test.ts index b7c680474d..7e9e26056b 100644 --- a/packages/agent-core/test/skill/registry.test.ts +++ b/packages/agent-core/test/skill/registry.test.ts @@ -73,7 +73,9 @@ describe('skill registry prompt rendering', () => { }); it('end-to-end: a project skill that shadows other scopes renders once under Project', () => { - const registry = makeRegistry([makeSkill('foo', 'project', 'project version', '/tmp/proj/foo/SKILL.md')]); + const registry = makeRegistry([ + makeSkill('foo', 'project', 'project version', '/tmp/proj/foo/SKILL.md'), + ]); const rendered = registry.getKimiSkillsDescription(); @@ -96,6 +98,39 @@ describe('skill registry prompt rendering', () => { }); }); +describe('getModelSkillListing description truncation', () => { + it('keeps descriptions at or below the 250-char limit unchanged', () => { + const description = 'a'.repeat(250); + const rendered = makeRegistry([makeSkill('demo', 'user', description)]).getModelSkillListing(); + + expect(rendered).toContain(`- demo: ${description}`); + expect(rendered).not.toContain('…'); + }); + + it('appends an ellipsis and stays within the limit when a description is truncated', () => { + const description = 'a'.repeat(300); + const rendered = makeRegistry([makeSkill('demo', 'user', description)]).getModelSkillListing(); + + expect(rendered).toContain(`- demo: ${'a'.repeat(249)}…`); + expect(rendered).not.toContain('a'.repeat(250)); + }); + + it('does not split a grapheme cluster at the truncation boundary', () => { + // The 250-char budget cuts at code-unit 249; the emoji spans 248-249, so a + // naive slice would leave a dangling surrogate. Grapheme-safe truncation + // must drop the whole emoji instead. + const description = `${'a'.repeat(248)}😀${'b'.repeat(100)}`; + const rendered = makeRegistry([makeSkill('demo', 'user', description)]).getModelSkillListing(); + + expect(rendered).toContain(`- demo: ${'a'.repeat(248)}…`); + expect(rendered).not.toContain('😀'); + // no lone high or low surrogate should remain in the rendered output + expect(rendered).not.toMatch( + /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?