Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-skill-listing-truncation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix truncated skill descriptions missing an ellipsis in the model's skill listing.
14 changes: 13 additions & 1 deletion packages/agent-core/src/skill/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}…`;
}
37 changes: 36 additions & 1 deletion packages/agent-core/test/skill/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/,
);
});
});

function makeRegistry(skills: readonly SkillDefinition[]): SessionSkillRegistry {
const registry = new SessionSkillRegistry();
for (const skill of skills) registry.register(skill);
Expand Down
Loading