From 54085516c17f42e3c88ac5cc9fc9c62a5eea2342 Mon Sep 17 00:00:00 2001 From: Michel Moreira Date: Sun, 6 Sep 2026 16:11:47 -0300 Subject: [PATCH] fix(#4211): materialize Kimi's agent tree recursively during surface apply (#4371) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .changeset/happy-sloths-rest.md | 5 ++ src/surface.cts | 54 +++++++++++- .../runtime-artifact-layout-surface.test.cjs | 87 +++++++++++++++++++ 3 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 .changeset/happy-sloths-rest.md diff --git a/.changeset/happy-sloths-rest.md b/.changeset/happy-sloths-rest.md new file mode 100644 index 000000000..cc6b5636c --- /dev/null +++ b/.changeset/happy-sloths-rest.md @@ -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) diff --git a/src/surface.cts b/src/surface.cts index eaea03951..ac4bdf300 100644 --- a/src/surface.cts +++ b/src/surface.cts @@ -435,13 +435,24 @@ function applySurface(runtimeConfigDir: string, layout: Layout, manifest: Map(); + 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}. diff --git a/tests/runtime-artifact-layout-surface.test.cjs b/tests/runtime-artifact-layout-surface.test.cjs index 9d4f11a89..f6d9446bc 100644 --- a/tests/runtime-artifact-layout-surface.test.cjs +++ b/tests/runtime-artifact-layout-surface.test.cjs @@ -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'); + }); +});