* fix(#4211): materialize Kimi's agent tree recursively during surface apply kimiAgentsKind stages `gsd.yaml` + `gsd.md` + `subagents/gsd-*.{yaml,md}`, and install copies that tree recursively (_copyStaged). Surface apply fell through to _syncGsdDir's flat command/agent branch, which reads only top-level `*.md`: the YAML half and the whole subagents/ subtree were ignored, and `gsd.md` was written as `gsdgsd.md` because the flat branch re-applies kind.prefix to a name that already carries it. A surface change could therefore corrupt Kimi's installed artifacts while still reporting success. Three divergences from the install path, all in src/surface.cts: - _syncGsdDir gains a kimi-agents branch: recursive copy, then a prune scoped to exactly what install's _removeGsdEntries owns for this kind (the two root files, and gsd-*.{yaml,md} under subagents/). Everything else is user-owned and preserved. - applySurface stages kimi-agents WITH agentCtx and the `skills: '*'` rule for an unmodified full profile, as it already does for the agents kind and as createRuntimeArtifactInstallPlan does for every kind — without it Kimi's generated subagents lost their path-prefix rewrites and attribution trailer, and an unmodified full profile staged only the skill-referenced subset. - applySurface runs rewriteStagedSkillBodies for kimi-agents, which the install plan routes through it alongside skills. * chore: add changeset for #4211 --------- Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
This commit is contained in:
5
.changeset/happy-sloths-rest.md
Normal file
5
.changeset/happy-sloths-rest.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 4371
|
||||
---
|
||||
**A Kimi surface change no longer corrupts the installed agent tree** — `applySurface` now materializes the `kimi-agents` kind recursively (`gsd.yaml`, `gsd.md`, `subagents/gsd-*.{yaml,md}`) instead of writing `gsdgsd.md` and dropping the YAML and subagents, prunes only GSD-owned Kimi files, and stages with the same context a fresh install uses. (#4211)
|
||||
@@ -435,13 +435,24 @@ function applySurface(runtimeConfigDir: string, layout: Layout, manifest: Map<st
|
||||
try {
|
||||
for (const kind of layout.kinds) {
|
||||
let staged: string;
|
||||
if (kind.kind === 'agents') {
|
||||
// #4211: kimi-agents is an AGENT kind — kimiAgentsKind.stage() forwards
|
||||
// agentCtx into stageAgentsForRuntimeWithConverter exactly as agentsKind
|
||||
// does, and createRuntimeArtifactInstallPlan hands every kind the
|
||||
// context. Staging it bare here dropped the path-prefix rewrites and the
|
||||
// attribution trailer from Kimi's generated subagents, and (under an
|
||||
// unmodified `full` profile) staged only the skill-referenced subset the
|
||||
// install path stages with `skills: '*'`.
|
||||
if (kind.kind === 'agents' || kind.kind === 'kimi-agents') {
|
||||
const agentProfile = _isUnmodifiedFull ? { ...resolved, skills: '*' as const } : resolved;
|
||||
staged = kind.stage(agentProfile, agentCtx);
|
||||
} else {
|
||||
staged = kind.stage(resolved);
|
||||
}
|
||||
if (kind.kind === 'skills') {
|
||||
// #4211: kimi-agents takes the skill-body rewrite too —
|
||||
// createRuntimeArtifactInstallPlan routes `skills` and `kimi-agents`
|
||||
// through rewriteStagedSkillBodies together, so omitting it here left
|
||||
// Kimi's surface-materialized prompts with unrewritten paths.
|
||||
if (kind.kind === 'skills' || kind.kind === 'kimi-agents') {
|
||||
runtimeArtifactConversion.rewriteStagedSkillBodies(staged, {
|
||||
runtime: layout.runtime,
|
||||
configDir: layout.configDir,
|
||||
@@ -638,6 +649,45 @@ function _syncGsdDir(stagedDir: string, destDir: string, kind: ArtifactKind | st
|
||||
const _agentExt = runtime ? runtimeArtifactConversion.agentFileExtensionFor(runtime) : undefined;
|
||||
const isRenamedAgents = !!_agentExt && kindName === 'agents';
|
||||
|
||||
if (kindName === 'kimi-agents') {
|
||||
// #4211: Kimi's managed tree is `gsd.yaml` + `gsd.md` + `subagents/gsd-*.{yaml,md}`
|
||||
// (runtime-artifact-layout.cts kimiAgentsKind), and install copies it
|
||||
// RECURSIVELY (_copyStaged in src/install-engine.cts). Surface apply fell
|
||||
// through to the flat command/agent branch below, which reads only `*.md`
|
||||
// at the top level: the YAML half and the whole subagents/ subtree were
|
||||
// dropped, and `gsd.md` was written as `gsdgsd.md` (the flat branch
|
||||
// re-applies kind.prefix to a name that already carries it). A surface
|
||||
// change could therefore corrupt Kimi's installed artifacts while still
|
||||
// reporting success.
|
||||
fs.cpSync(stagedDir, destDir, { recursive: true });
|
||||
|
||||
// Prune GSD-owned files the new surface no longer stages, with exactly the
|
||||
// ownership rule install's _removeGsdEntries applies to this kind: the two
|
||||
// root files, and `gsd-`-prefixed .yaml/.md under subagents/. Everything
|
||||
// else in the directory is user-owned and is preserved.
|
||||
const _rootStaged = new Set(fs.readdirSync(stagedDir));
|
||||
for (const fileName of ['gsd.yaml', 'gsd.md']) {
|
||||
if (!_rootStaged.has(fileName)) {
|
||||
try { fs.rmSync(path.join(destDir, fileName), { force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
const _stagedSubagentsDir = path.join(stagedDir, 'subagents');
|
||||
const _destSubagentsDir = path.join(destDir, 'subagents');
|
||||
const _stagedSubagents = fs.existsSync(_stagedSubagentsDir)
|
||||
? new Set(fs.readdirSync(_stagedSubagentsDir))
|
||||
: new Set<string>();
|
||||
if (fs.existsSync(_destSubagentsDir)) {
|
||||
for (const entry of fs.readdirSync(_destSubagentsDir, { withFileTypes: true })) {
|
||||
if (!entry.isFile()) continue;
|
||||
if (!entry.name.startsWith('gsd-')) continue;
|
||||
if (!entry.name.endsWith('.yaml') && !entry.name.endsWith('.md')) continue;
|
||||
if (_stagedSubagents.has(entry.name)) continue;
|
||||
try { fs.rmSync(path.join(_destSubagentsDir, entry.name), { force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (kindName === 'skills') {
|
||||
// Skills kind: work with directories, not files.
|
||||
// Each staged entry is a directory named ${prefix}${stem}.
|
||||
|
||||
@@ -1730,3 +1730,90 @@ describe('issue-69: applySurface preserves nested skill layout (no re-flatten)',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─── #4211: kimi-agents materialization ─────────────────────────────────────
|
||||
//
|
||||
// Kimi's managed tree is `agents/gsd.yaml` + `agents/gsd.md` +
|
||||
// `agents/subagents/gsd-*.{yaml,md}` (runtime-artifact-layout.cts
|
||||
// kimiAgentsKind), and install copies it recursively (_copyStaged in
|
||||
// src/install-engine.cts). Surface apply fell through to the flat
|
||||
// command/agent branch of _syncGsdDir, which reads only top-level `*.md`: it
|
||||
// ignored the YAML half and the subagents/ subtree, and rewrote `gsd.md` as
|
||||
// `gsdgsd.md` (the flat branch re-applies kind.prefix to a name that already
|
||||
// carries it) — corrupting Kimi's installed artifacts while still exiting 0.
|
||||
|
||||
describe('#4211: applySurface materializes the kimi-agents kind like a fresh install', () => {
|
||||
function kimiTree(dir, base = dir) {
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
let out = [];
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) out = out.concat(kimiTree(full, base));
|
||||
else out.push(path.relative(base, full).split(path.sep).join('/'));
|
||||
}
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
function installKimi(t) {
|
||||
const installed = runMinimalInstall({ runtime: 'kimi', scope: 'global' });
|
||||
t.after(() => { try { cleanup(installed.root); } catch { /* best-effort */ } });
|
||||
return { configDir: installed.configDir, agentsDir: path.join(installed.configDir, 'agents') };
|
||||
}
|
||||
|
||||
test('the materialized tree is identical to the installed one — no gsdgsd.md, no dropped YAML', (t) => {
|
||||
const { configDir, agentsDir } = installKimi(t);
|
||||
|
||||
const before = kimiTree(agentsDir);
|
||||
assert.ok(before.includes('gsd.yaml'), 'precondition: install writes agents/gsd.yaml');
|
||||
assert.ok(before.includes('gsd.md'), 'precondition: install writes agents/gsd.md');
|
||||
assert.ok(before.some((f) => f.startsWith('subagents/gsd-') && f.endsWith('.yaml')),
|
||||
'precondition: install writes agents/subagents/gsd-*.yaml');
|
||||
const rootPromptBefore = fs.readFileSync(path.join(agentsDir, 'gsd.md'), 'utf8');
|
||||
|
||||
const layout = resolveRuntimeArtifactLayout('kimi', configDir, 'global');
|
||||
applySurface(configDir, layout, realManifest(), CLUSTERS);
|
||||
|
||||
const after = kimiTree(agentsDir);
|
||||
assert.deepEqual(after, before,
|
||||
'surface apply must produce the same managed artifact tree as the install it re-stages');
|
||||
assert.ok(!after.includes('gsdgsd.md'), 'the root prompt must not be re-prefixed into gsdgsd.md');
|
||||
assert.equal(fs.readFileSync(path.join(agentsDir, 'gsd.md'), 'utf8'), rootPromptBefore,
|
||||
'the root prompt content must survive re-materialization');
|
||||
});
|
||||
|
||||
test('user-owned files under agents/ are preserved', (t) => {
|
||||
const { configDir, agentsDir } = installKimi(t);
|
||||
|
||||
const userRoot = path.join(agentsDir, 'my-own-agent.yaml');
|
||||
const userSub = path.join(agentsDir, 'subagents', 'my-own-subagent.yaml');
|
||||
const userNote = path.join(agentsDir, 'subagents', 'notes.txt');
|
||||
fs.writeFileSync(userRoot, 'name: mine\n');
|
||||
fs.writeFileSync(userSub, 'name: mine-sub\n');
|
||||
fs.writeFileSync(userNote, 'scratch\n');
|
||||
|
||||
const layout = resolveRuntimeArtifactLayout('kimi', configDir, 'global');
|
||||
applySurface(configDir, layout, realManifest(), CLUSTERS);
|
||||
|
||||
for (const file of [userRoot, userSub, userNote]) {
|
||||
assert.ok(fs.existsSync(file), `${path.basename(file)} is user-owned and must survive surface apply`);
|
||||
}
|
||||
});
|
||||
|
||||
test('a GSD subagent the surface no longer stages is pruned', (t) => {
|
||||
const { configDir, agentsDir } = installKimi(t);
|
||||
|
||||
// Shaped exactly like a subagent an earlier version staged and this one
|
||||
// does not — the case install's _removeGsdEntries prunes for this kind.
|
||||
const retiredYaml = path.join(agentsDir, 'subagents', 'gsd-retired-agent.yaml');
|
||||
const retiredPrompt = path.join(agentsDir, 'subagents', 'gsd-retired-agent.md');
|
||||
fs.writeFileSync(retiredYaml, 'name: gsd-retired-agent\n');
|
||||
fs.writeFileSync(retiredPrompt, '# retired\n');
|
||||
|
||||
const layout = resolveRuntimeArtifactLayout('kimi', configDir, 'global');
|
||||
applySurface(configDir, layout, realManifest(), CLUSTERS);
|
||||
|
||||
assert.equal(fs.existsSync(retiredYaml), false, 'a stale GSD subagent must be pruned');
|
||||
assert.equal(fs.existsSync(retiredPrompt), false, 'a stale GSD subagent prompt must be pruned');
|
||||
assert.ok(fs.existsSync(path.join(agentsDir, 'gsd.yaml')), 'the live root agent must remain');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user