From 36a3b2a85190b091b185a418b9030738c6587b0b Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Wed, 2 Sep 2026 10:52:04 +0800 Subject: [PATCH 1/2] fix(migration-legacy): faithfully migrate kimi-cli user data into kimi-code From 612b432be7c48faaea25e139424159e8c48e0ed7 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Tue, 1 Sep 2026 12:16:57 +0800 Subject: [PATCH 2/2] fix(migration-legacy): faithfully migrate kimi-cli user data into kimi-code --- apps/kimi-code/scripts/postinstall.mjs | 212 ++---- .../kimi-code/scripts/postinstall/migrate.mjs | 71 +- .../scripts/postinstall/platform.mjs | 29 + apps/kimi-code/scripts/postinstall/reach.mjs | 41 +- .../scripts/postinstall/takeover.mjs | 98 +++ apps/kimi-code/scripts/postinstall/ui.mjs | 91 +++ apps/kimi-code/src/cli/commands.ts | 4 +- apps/kimi-code/src/cli/run-shell.ts | 27 +- apps/kimi-code/src/main.ts | 24 +- apps/kimi-code/src/migration/command.ts | 33 +- .../kimi-code/src/migration/detect-pending.ts | 30 +- apps/kimi-code/src/migration/index.ts | 8 +- apps/kimi-code/src/migration/legacy-source.ts | 29 + .../src/migration/migration-screen.ts | 25 +- apps/kimi-code/src/migration/run-headless.ts | 211 ++++++ apps/kimi-code/test/cli/run-shell.test.ts | 18 +- apps/kimi-code/test/migration/command.test.ts | 33 +- .../test/migration/detect-pending.test.ts | 91 ++- .../test/migration/legacy-source.test.ts | 57 ++ .../test/migration/migration-screen.test.ts | 36 +- .../test/migration/run-headless.test.ts | 153 +++++ .../test/postinstall/takeover.test.ts | 316 +++++++++ .../test/tui/kimi-tui-startup.test.ts | 2 + .../src/migration/legacy-migration.manager.ts | 66 +- .../test/legacy-migration.manager.test.ts | 15 +- flake.nix | 2 +- packages/migration-legacy/package.json | 4 +- packages/migration-legacy/src/detect.ts | 133 +++- packages/migration-legacy/src/index.ts | 2 + .../migration-legacy/src/kimi-cli-schema.ts | 16 + packages/migration-legacy/src/paths.ts | 1 + .../migration-legacy/src/run-migration.ts | 41 +- .../migration-legacy/src/sessions/classify.ts | 40 +- .../src/sessions/content-part.ts | 13 + .../migration-legacy/src/sessions/index.ts | 85 +-- .../src/sessions/migrate-one.ts | 133 +++- .../src/sessions/repair-imported.ts | 297 +++++++++ .../migration-legacy/src/sessions/source.ts | 124 ++++ .../src/sessions/state-writer.ts | 58 +- .../src/sessions/subagents.ts | 317 +++++++++ .../src/sessions/tool-call-display.ts | 2 +- .../src/sessions/translator.ts | 30 +- .../src/sessions/turn-structure.ts | 92 +++ .../src/sessions/wire-writer.ts | 96 ++- .../src/sessions/workdir-bucket.ts | 10 +- .../migration-legacy/src/source-config.ts | 57 ++ packages/migration-legacy/src/steps/config.ts | 229 +++++-- packages/migration-legacy/src/steps/mcp.ts | 32 +- packages/migration-legacy/src/steps/plans.ts | 67 ++ packages/migration-legacy/src/types.ts | 36 +- packages/migration-legacy/test/detect.test.ts | 67 +- .../test/fixtures/gen/generate_fixtures.py | 372 +++++++++++ .../.kimi-historical-config/config.json | 57 ++ .../test/fixtures/golden/.kimi/config.toml | 81 +++ .../golden/.kimi/credentials/kimi-code.json | 6 + .../test/fixtures/golden/.kimi/kimi.json | 14 + .../.kimi/mcp-oauth/mangled-store-entry | 1 + .../test/fixtures/golden/.kimi/mcp.json | 18 + .../context.jsonl | 7 + .../state.json | 27 + .../wire.jsonl | 2 + .../context.jsonl | 1 + .../metadata.json | 10 + .../state.json | 20 + .../context.jsonl | 0 .../state.json | 20 + ...44444444-aaaa-4bbb-8ccc-444444444444.jsonl | 2 + .../context.jsonl | 1 + .../state.json | 20 + .../golden/.kimi/skills/golden-skill/SKILL.md | 6 + .../c1c0997494d4df46ac89f99f813c077c.jsonl | 2 + .../test/fixtures/title-only/context.jsonl | 0 .../test/fixtures/title-only/state.json | 1 + packages/migration-legacy/test/golden.test.ts | 237 +++++++ .../migration-legacy/test/integration.test.ts | 78 ++- packages/migration-legacy/test/marker.test.ts | 2 + packages/migration-legacy/test/report.test.ts | 7 +- .../test/resume.integration.test.ts | 272 ++++---- .../fixtures.snapshot.test.ts.snap | 126 +++- .../test/sessions/classify.test.ts | 150 +++-- .../test/sessions/content-part.test.ts | 40 ++ .../test/sessions/fixtures.snapshot.test.ts | 11 +- .../test/sessions/migrate-one.test.ts | 615 +++++++++++++++++- .../test/sessions/sessions-step.test.ts | 62 +- .../test/sessions/source.test.ts | 50 ++ .../test/sessions/state-writer.test.ts | 47 +- .../test/sessions/translator.test.ts | 28 + .../test/sessions/wire-writer.test.ts | 137 +++- .../test/sessions/workdir-bucket.test.ts | 2 +- .../test/steps/config.test.ts | 128 +++- .../migration-legacy/test/steps/mcp.test.ts | 23 +- .../test/steps/user-history.test.ts | 29 + .../migration-legacy/test/v2-session-scan.ts | 29 + pnpm-lock.yaml | 47 +- 94 files changed, 5666 insertions(+), 826 deletions(-) create mode 100644 apps/kimi-code/scripts/postinstall/platform.mjs create mode 100644 apps/kimi-code/scripts/postinstall/takeover.mjs create mode 100644 apps/kimi-code/src/migration/legacy-source.ts create mode 100644 apps/kimi-code/src/migration/run-headless.ts create mode 100644 apps/kimi-code/test/migration/legacy-source.test.ts create mode 100644 apps/kimi-code/test/migration/run-headless.test.ts create mode 100644 apps/kimi-code/test/postinstall/takeover.test.ts create mode 100644 packages/migration-legacy/src/sessions/repair-imported.ts create mode 100644 packages/migration-legacy/src/sessions/source.ts create mode 100644 packages/migration-legacy/src/sessions/subagents.ts create mode 100644 packages/migration-legacy/src/sessions/turn-structure.ts create mode 100644 packages/migration-legacy/src/source-config.ts create mode 100644 packages/migration-legacy/src/steps/plans.ts create mode 100644 packages/migration-legacy/test/fixtures/gen/generate_fixtures.py create mode 100644 packages/migration-legacy/test/fixtures/golden/.kimi-historical-config/config.json create mode 100644 packages/migration-legacy/test/fixtures/golden/.kimi/config.toml create mode 100644 packages/migration-legacy/test/fixtures/golden/.kimi/credentials/kimi-code.json create mode 100644 packages/migration-legacy/test/fixtures/golden/.kimi/kimi.json create mode 100644 packages/migration-legacy/test/fixtures/golden/.kimi/mcp-oauth/mangled-store-entry create mode 100644 packages/migration-legacy/test/fixtures/golden/.kimi/mcp.json create mode 100644 packages/migration-legacy/test/fixtures/golden/.kimi/sessions/c1c0997494d4df46ac89f99f813c077c/11111111-aaaa-4bbb-8ccc-111111111111/context.jsonl create mode 100644 packages/migration-legacy/test/fixtures/golden/.kimi/sessions/c1c0997494d4df46ac89f99f813c077c/11111111-aaaa-4bbb-8ccc-111111111111/state.json create mode 100644 packages/migration-legacy/test/fixtures/golden/.kimi/sessions/c1c0997494d4df46ac89f99f813c077c/11111111-aaaa-4bbb-8ccc-111111111111/wire.jsonl create mode 100644 packages/migration-legacy/test/fixtures/golden/.kimi/sessions/c1c0997494d4df46ac89f99f813c077c/22222222-aaaa-4bbb-8ccc-222222222222/context.jsonl create mode 100644 packages/migration-legacy/test/fixtures/golden/.kimi/sessions/c1c0997494d4df46ac89f99f813c077c/22222222-aaaa-4bbb-8ccc-222222222222/metadata.json create mode 100644 packages/migration-legacy/test/fixtures/golden/.kimi/sessions/c1c0997494d4df46ac89f99f813c077c/22222222-aaaa-4bbb-8ccc-222222222222/state.json create mode 100644 packages/migration-legacy/test/fixtures/golden/.kimi/sessions/c1c0997494d4df46ac89f99f813c077c/33333333-aaaa-4bbb-8ccc-333333333333/context.jsonl create mode 100644 packages/migration-legacy/test/fixtures/golden/.kimi/sessions/c1c0997494d4df46ac89f99f813c077c/33333333-aaaa-4bbb-8ccc-333333333333/state.json create mode 100644 packages/migration-legacy/test/fixtures/golden/.kimi/sessions/c1c0997494d4df46ac89f99f813c077c/44444444-aaaa-4bbb-8ccc-444444444444.jsonl create mode 100644 packages/migration-legacy/test/fixtures/golden/.kimi/sessions/ssh_cc0fc5445bfc0662b9c89cf7c6896ebb/55555555-aaaa-4bbb-8ccc-555555555555/context.jsonl create mode 100644 packages/migration-legacy/test/fixtures/golden/.kimi/sessions/ssh_cc0fc5445bfc0662b9c89cf7c6896ebb/55555555-aaaa-4bbb-8ccc-555555555555/state.json create mode 100644 packages/migration-legacy/test/fixtures/golden/.kimi/skills/golden-skill/SKILL.md create mode 100644 packages/migration-legacy/test/fixtures/golden/.kimi/user-history/c1c0997494d4df46ac89f99f813c077c.jsonl create mode 100644 packages/migration-legacy/test/fixtures/title-only/context.jsonl create mode 100644 packages/migration-legacy/test/fixtures/title-only/state.json create mode 100644 packages/migration-legacy/test/golden.test.ts create mode 100644 packages/migration-legacy/test/sessions/source.test.ts create mode 100644 packages/migration-legacy/test/v2-session-scan.ts diff --git a/apps/kimi-code/scripts/postinstall.mjs b/apps/kimi-code/scripts/postinstall.mjs index 4662c43cfa5..054f3bf8d7c 100644 --- a/apps/kimi-code/scripts/postinstall.mjs +++ b/apps/kimi-code/scripts/postinstall.mjs @@ -37,9 +37,10 @@ * - `./postinstall/migrate.mjs` — legacy detection, * `kimi`-vs-`kimi-legacy` classification, the rename / unlink * primitives. + * - `./postinstall/takeover.mjs` — the plan → execute → verify + * state machine this orchestrator drives. * - `./postinstall/ui.mjs` — `notify()` (with `/dev/tty` fallback), - * ANSI styling, the fixed-width box, and the five outcome - * renderers. + * ANSI styling, the fixed-width box, and the outcome renderers. * * ## Workflow * @@ -60,41 +61,26 @@ * shell can't be probed). Sharing one probe keeps detection * and reachability symmetric and avoids running `$SHELL -l` * twice. - * 5. Detect EVERY previous Python `kimi-cli` shim on the detection - * PATH (`detectLegacyShims`). Returns `[]` for fresh-install / - * no-op. Multiple results happen when the user has installed - * `kimi-cli` through more than one Python tool (uv + pipx, or - * sudo-pip + pip-user). PATH order is preserved. - * 6. Pre-flight classify each shim (`classifyShim`) — pure - * filesystem inspection, no writes. Each shim ends up - * `renameable`, `consolidate`, `delete-only`, or `blocked`. - * 7. Decide abort vs proceed against the WHOLE set: - * `findFirstResolvableKimi` walks PATH treating the actionable - * shims as gone and reports what wins: - * - `own` → proceed to execute. - * - `blocked-legacy` → a legacy we can't remove still wins. - * Surface `logMigrationBlocked` with sudo / admin - * instructions; touch nothing. - * - `foreign` → some `kimi` we don't recognize (a user's own - * file) wins. Surface `logForeignKimiInTheWay` asking the - * user to delete or rename their own file; touch nothing. - * - `none` → no `kimi` on PATH at all (our shim's bin dir - * isn't in the shell's PATH). Surface - * `logNewCliNotOnPath`; touch nothing. - * 8. Execute. The FIRST classification in PATH order that we can - * touch becomes `kimi-legacy` (preserves what `kimi` referred - * to before this install). Each subsequent shim is `unlink`ed — - * keeping it as a dormant duplicate adds no value. If the - * first shim's `kimi-legacy` target is already user-managed, - * we delete `kimi` anyway (still achieves takeover) and tell - * the user we couldn't preserve a fallback. Extension is - * preserved on Windows (`kimi.exe` → `kimi-legacy.exe`). - * 9. One end-of-orchestration notice (`logMigrationDone`) - * summarizes every action — renames, consolidates, - * delete-only, deletes, and harmless blocked leftovers. The - * takeover-success line only fires on this path because Step 7 - * already certified it. - * 10. The manager completes the install with its usual summary. + * 5. `planTakeover`: detect EVERY previous Python `kimi-cli` shim + * on the detection PATH, pre-flight classify each (no writes), + * and simulate PATH resolution with the actionable shims gone: + * - `own` wins → proceed. + * - a blocked legacy still wins → `logMigrationBlocked`. + * - a foreign `kimi` wins → `logForeignKimiInTheWay`. + * - nothing resolves → `logNewCliNotOnPath`. + * The abort branches touch NOTHING. + * 6. `executeTakeover`: the FIRST shim in PATH order that can be + * preserved becomes `kimi-legacy`; each subsequent shim is + * `unlink`ed. A failed preserve attempt does not promote the + * next shim to deletion — it gets its own preserve attempt, so + * a usable legacy fallback survives whenever one is possible. + * 7. `verifyTakeover`: walk the reachability PATH as it actually + * is AFTER execution. Only `{ kind: 'own' }` renders the + * success box (`logMigrationDone`); anything else renders + * `logMigrationIncomplete` — what changed, what still blocks, + * and how to finish by hand. The pre-flight simulation in + * step 5 is never reported as proof of success. + * 8. The manager completes the install with its usual summary. * This script always exits 0; any uncaught error is swallowed * by the top-level `catch` so the install never fails because * of the migration. @@ -102,21 +88,20 @@ import { detectPackageManager, - findFirstResolvableKimi, isGlobalInstall, ownPackageRoot, postinstallPaths, } from './postinstall/reach.mjs'; import { - classifyShim, - deleteShim, - detectLegacyShims, - renameInPlace, -} from './postinstall/migrate.mjs'; + executeTakeover, + planTakeover, + verifyTakeover, +} from './postinstall/takeover.mjs'; import { logForeignKimiInTheWay, logMigrationBlocked, logMigrationDone, + logMigrationIncomplete, logNewCliNotOnPath, notify, } from './postinstall/ui.mjs'; @@ -143,124 +128,45 @@ async function main() { // installer's env). const paths = await postinstallPaths(); - // Step 4: detect EVERY previous Python `kimi-cli` shim on the - // detection PATH. A user with both `uv tool install` and `pipx - // install` would have two; we must address all of them or the - // survivor still shadows the new CLI. - const detections = await detectLegacyShims(ownRoot, paths.detection); - if (detections.length === 0) return; - - // Step 5: pre-flight classify every shim WITHOUT touching the - // filesystem yet. The orchestrator decides abort-or-proceed against - // the whole set rather than discovering mid-loop that we got partway - // and have to backtrack. - const classifications = await Promise.all( - detections.map(async (detection) => { - const c = await classifyShim(detection.shimPath); - return { ...c, detection }; - }), - ); - - // Step 6: figure out what wins PATH resolution once every shim we - // CAN touch is treated as gone. Three possible blockers: - // - a legacy shim we couldn't classify as actionable (sudo/admin - // needed) - // - an unrelated `kimi` we don't recognize (a user's own wrapper - // script — they own the decision) - // - nothing resolves (our shim isn't on PATH at all) - // For each we render a different notice and touch NOTHING. The - // common-case fourth result is "our shim wins" — we proceed. - const actionable = classifications.filter((c) => c.kind !== 'blocked'); - const blocked = classifications.filter((c) => c.kind === 'blocked'); - const actionableShimPaths = actionable.map((c) => c.shimPath); - const allDetectedShimPaths = classifications.map((c) => c.shimPath); - - const blocker = await findFirstResolvableKimi( + // Step 4: plan against the whole detected shim set without writing. + const plan = await planTakeover( ownRoot, + paths.detection, paths.reachability, - actionableShimPaths, - allDetectedShimPaths, + process.platform, ); - if (blocker.kind !== 'own') { - if (blocker.kind === 'blocked-legacy') { - logMigrationBlocked(blocked, actionable, pm); - } else if (blocker.kind === 'foreign') { - logForeignKimiInTheWay(blocker.path, pm); - } else { - // 'none' — our shim isn't on PATH at all. - logNewCliNotOnPath(detections[0], pm); - } + if (plan.kind === 'noop') return; + if (plan.kind === 'blocked') { + logMigrationBlocked(plan.blocked, plan.actionable, pm); return; } - - // Step 7: execute. The FIRST classification in PATH order that - // we can touch becomes `kimi-legacy` (preserves what the user's - // `kimi` used to refer to). Every subsequent shim is just - // deleted — keeping it as a dormant duplicate adds no value. - const renames = []; - const consolidates = []; - const skippedForeignTarget = []; - const deletes = []; - const errors = []; - let preservedFirst = false; - - for (const c of classifications) { - if (c.kind === 'blocked') continue; // already established harmless - - if (!preservedFirst) { - preservedFirst = true; - if (c.kind === 'renameable') { - const r = await renameInPlace(c.shimPath, c.target); - if (r.success) { - renames.push(c); - } else { - errors.push({ ...c, ...r }); - } - continue; - } - if (c.kind === 'consolidate') { - const r = await deleteShim(c.shimPath); - if (r.success) { - consolidates.push(c); - } else { - errors.push({ ...c, ...r }); - } - continue; - } - if (c.kind === 'delete-only') { - const r = await deleteShim(c.shimPath); - if (r.success) { - skippedForeignTarget.push(c); - } else { - errors.push({ ...c, ...r }); - } - continue; - } - } else { - // Not the first actionable shim. Just delete it. - const r = await deleteShim(c.shimPath); - if (r.success) { - deletes.push(c); - } else { - errors.push({ ...c, ...r }); - } - } + if (plan.kind === 'foreign') { + logForeignKimiInTheWay(plan.path, pm); + return; + } + if (plan.kind === 'not-on-path') { + logNewCliNotOnPath(plan.detection, pm); + return; } - // Step 8: one notice summarizing everything that happened. The - // takeover-success language is only emitted when we know it's true - // (we already passed the reachability gate above). - logMigrationDone( - { - renames, - consolidates, - skippedForeignTarget, - deletes, - blockedHarmless: blocked, - errors, - }, - pm, + // Step 5: execute (preserve the first preservable shim as + // `kimi-legacy`, delete the rest). + const outcomes = await executeTakeover(plan.classifications); + + // Step 6: post-execution verification — the ONLY ground for a + // success claim. If reality diverged from the step-4 simulation + // (a rename failed, a new shim appeared), report it honestly. + const verify = await verifyTakeover( + ownRoot, + paths.reachability, + plan.classifications.map((c) => c.shimPath), + process.platform, ); + if (verify.kind === 'own') { + logMigrationDone({ ...outcomes, blockedHarmless: plan.blocked }, pm); + return; + } + logMigrationIncomplete({ outcomes, verify, blocked: plan.blocked }, pm); } main().catch((err) => { diff --git a/apps/kimi-code/scripts/postinstall/migrate.mjs b/apps/kimi-code/scripts/postinstall/migrate.mjs index b1ae695a331..b134ab39909 100644 --- a/apps/kimi-code/scripts/postinstall/migrate.mjs +++ b/apps/kimi-code/scripts/postinstall/migrate.mjs @@ -35,15 +35,21 @@ * with a misleading "kimi now launches the new CLI" notice in front of * a "permission denied" notice. Uses `fs.lstat` (not `fs.access`) to * detect dangling symlinks at the target so we don't clobber them. + * + * Every helper accepts an optional trailing `platform` argument + * (`'posix' | 'win32'`, defaulting to the real `process.platform`) so + * Windows forms (PATHEXT expansion, extension-preserving rename + * targets, system-dir heuristics) can be exercised in tests on any + * host OS. */ import { constants as fsConstants, promises as fs } from 'node:fs'; -import { delimiter, dirname, extname, join, sep } from 'node:path'; + +import { executableCandidates, pathFlavor } from './platform.mjs'; const LEGACY_BIN = 'kimi'; const LEGACY_RENAME = 'kimi-legacy'; const PYTHON_MARKER = 'kimi_cli'; -const IS_WINDOWS = process.platform === 'win32'; // Read window for the marker sniff. // POSIX: setuptools entry-point scripts are a few hundred bytes — @@ -57,7 +63,8 @@ const IS_WINDOWS = process.platform === 'win32'; const SHIM_SNIFF_BYTES_POSIX = 4096; const SHIM_SNIFF_BYTES_WINDOWS_MAX = 256 * 1024; -function pathEntries(pathString) { +function pathEntries(pathString, platform) { + const { delimiter } = pathFlavor(platform); if (!pathString) return []; const seen = new Set(); const out = []; @@ -69,41 +76,27 @@ function pathEntries(pathString) { return out; } -/** - * Expand `kimi` into the set of filenames that resolve as executables - * on this platform. POSIX → just `['kimi']`. Windows → adds every - * `PATHEXT` extension (so we find `kimi.exe`, `kimi.cmd`, etc). - */ -function executableCandidates(basename) { - if (!IS_WINDOWS) return [basename]; - const pathext = (process.env['PATHEXT'] ?? '.EXE;.CMD;.BAT;.COM') - .toLowerCase() - .split(';') - .map((e) => e.trim()) - .filter(Boolean); - return [basename, ...pathext.map((ext) => basename + ext)]; -} - -async function isExecutableFile(filePath) { +async function isExecutableFile(filePath, platform) { try { const info = await fs.stat(filePath); if (!info.isFile()) return false; // Windows: stat().mode doesn't reflect ACLs in any useful way. // Callers already restrict to PATHEXT candidates, so existence // suffices. - if (IS_WINDOWS) return true; + if (platform === 'win32') return true; return (info.mode & 0o111) !== 0; } catch { return false; } } -async function readShimHead(filePath) { +async function readShimHead(filePath, platform) { let handle; try { handle = await fs.open(filePath, 'r'); const stat = await handle.stat(); - const limit = IS_WINDOWS ? SHIM_SNIFF_BYTES_WINDOWS_MAX : SHIM_SNIFF_BYTES_POSIX; + const limit = + platform === 'win32' ? SHIM_SNIFF_BYTES_WINDOWS_MAX : SHIM_SNIFF_BYTES_POSIX; const target = Math.min(stat.size, limit); const buffer = Buffer.alloc(target); const { bytesRead } = await handle.read(buffer, 0, target, 0); @@ -130,17 +123,18 @@ async function readShimHead(filePath) { * value: `{ shimPath, realPath }`. The empty array means * "fresh-install / no-op". */ -export async function detectLegacyShims(ownRoot, pathString) { +export async function detectLegacyShims(ownRoot, pathString, platform = process.platform) { + const { sep, join } = pathFlavor(platform); const ownRootPrefix = ownRoot ? ownRoot + sep : null; - const candidates = executableCandidates(LEGACY_BIN); + const candidates = executableCandidates(LEGACY_BIN, platform); const results = []; const seenShims = new Set(); - for (const dir of pathEntries(pathString)) { + for (const dir of pathEntries(pathString, platform)) { for (const name of candidates) { const shimPath = join(dir, name); if (seenShims.has(shimPath)) continue; - if (!(await isExecutableFile(shimPath))) continue; + if (!(await isExecutableFile(shimPath, platform))) continue; let realPath; try { @@ -161,7 +155,7 @@ export async function detectLegacyShims(ownRoot, pathString) { continue; } - const head = await readShimHead(realPath); + const head = await readShimHead(realPath, platform); if (!head || !head.includes(PYTHON_MARKER)) continue; seenShims.add(shimPath); @@ -181,14 +175,14 @@ export async function detectLegacyShims(ownRoot, pathString) { * drop the duplicate `kimi`) or a user-managed file we must not * clobber. */ -export async function isLegacyShim(p) { +export async function isLegacyShim(p, platform = process.platform) { let real; try { real = await fs.realpath(p); } catch { return false; } - const head = await readShimHead(real); + const head = await readShimHead(real, platform); return Boolean(head && head.includes(PYTHON_MARKER)); } @@ -211,8 +205,9 @@ async function pathExists(p) { * rather than an extension-less `kimi-legacy` that `kimi.exe -- legacy` * shells won't run. */ -function renameTargetFor(shimPath) { - const ext = extname(shimPath); // "" on POSIX, ".exe" on Windows +export function renameTargetFor(shimPath, platform = process.platform) { + const { dirname, extname, join } = pathFlavor(platform); + const ext = extname(shimPath); // "" on POSIX, ".exe" on Windows return join(dirname(shimPath), LEGACY_RENAME + ext); } @@ -233,8 +228,9 @@ function renameTargetFor(shimPath) { * uses this to switch from a bare "rename it manually" message to a * sudo-aware / admin-aware explanation. */ -async function isSystemOwnedDir(shimPath) { - if (IS_WINDOWS) { +export async function isSystemOwnedDir(shimPath, platform = process.platform) { + const { dirname } = pathFlavor(platform); + if (platform === 'win32') { const dir = dirname(shimPath).toLowerCase(); const systemRoots = [ 'c:\\program files', @@ -292,8 +288,9 @@ async function canWriteDir(dir) { * `isSystemPath` so the renderer can suggest * sudo (POSIX) or admin PowerShell (Windows). */ -export async function classifyShim(shimPath) { - const target = renameTargetFor(shimPath); +export async function classifyShim(shimPath, platform = process.platform) { + const { dirname } = pathFlavor(platform); + const target = renameTargetFor(shimPath, platform); const dir = dirname(shimPath); if (!(await canWriteDir(dir))) { @@ -301,12 +298,12 @@ export async function classifyShim(shimPath) { kind: 'blocked', shimPath, target, - isSystemPath: await isSystemOwnedDir(shimPath), + isSystemPath: await isSystemOwnedDir(shimPath, platform), }; } if (await pathExists(target)) { - if (await isLegacyShim(target)) { + if (await isLegacyShim(target, platform)) { return { kind: 'consolidate', shimPath, target }; } return { kind: 'delete-only', shimPath, target }; diff --git a/apps/kimi-code/scripts/postinstall/platform.mjs b/apps/kimi-code/scripts/postinstall/platform.mjs new file mode 100644 index 00000000000..52c77b8f0f0 --- /dev/null +++ b/apps/kimi-code/scripts/postinstall/platform.mjs @@ -0,0 +1,29 @@ +import { posix, win32 } from 'node:path'; + +export function pathFlavor(platform) { + return platform === 'win32' + ? { + delimiter: ';', + sep: '\\', + join: win32.join, + dirname: win32.dirname, + extname: win32.extname, + } + : { + delimiter: ':', + sep: '/', + join: posix.join, + dirname: posix.dirname, + extname: posix.extname, + }; +} + +export function executableCandidates(basename, platform = process.platform) { + if (platform !== 'win32') return [basename]; + const pathext = (process.env['PATHEXT'] ?? '.EXE;.CMD;.BAT;.COM') + .toLowerCase() + .split(';') + .map((e) => e.trim()) + .filter(Boolean); + return [basename, ...pathext.map((ext) => basename + ext)]; +} diff --git a/apps/kimi-code/scripts/postinstall/reach.mjs b/apps/kimi-code/scripts/postinstall/reach.mjs index e6a4c93859a..66640499591 100644 --- a/apps/kimi-code/scripts/postinstall/reach.mjs +++ b/apps/kimi-code/scripts/postinstall/reach.mjs @@ -28,32 +28,13 @@ import { spawn } from 'node:child_process'; import { promises as fs } from 'node:fs'; -import { delimiter, dirname, join, sep } from 'node:path'; +import { delimiter, dirname, join } from 'node:path'; + +import { executableCandidates, pathFlavor } from './platform.mjs'; const LEGACY_BIN = 'kimi'; const IS_WINDOWS = process.platform === 'win32'; -/** - * Expand a basename like `kimi` into the set of filenames the OS - * would actually match on PATH. - * - * On POSIX: just `['kimi']`. - * - * On Windows: `['kimi', 'kimi.exe', 'kimi.cmd', …]` — every - * extension in `PATHEXT`. Without this, our PATH walk would miss - * the typical `kimi.exe` shim produced by `uv tool install` on - * Windows. - */ -export function executableCandidates(basename) { - if (!IS_WINDOWS) return [basename]; - const pathext = (process.env['PATHEXT'] ?? '.EXE;.CMD;.BAT;.COM') - .toLowerCase() - .split(';') - .map((e) => e.trim()) - .filter(Boolean); - return [basename, ...pathext.map((ext) => basename + ext)]; -} - /** * Identify which package manager ran us. `npm_config_user_agent` is * set by npm, yarn (classic + berry), and pnpm, and starts with the @@ -223,7 +204,7 @@ export async function ownPackageRoot(startDir) { return null; } -async function isExecutableFile(filePath) { +async function isExecutableFile(filePath, platform) { try { const info = await fs.stat(filePath); if (!info.isFile()) return false; @@ -231,7 +212,7 @@ async function isExecutableFile(filePath) { // existence + a recognized extension is what PATHEXT-style lookup // checks. Callers only pass us candidates that already match an // extension in `executableCandidates()`, so "is a file" suffices. - if (IS_WINDOWS) return true; + if (platform === 'win32') return true; return (info.mode & 0o111) !== 0; } catch { return false; @@ -304,20 +285,22 @@ export async function findFirstResolvableKimi( pathString, actionableShimPaths, allDetectedShimPaths, + platform = process.platform, ) { if (!ownRoot || !pathString) return { kind: 'none' }; - const ownPrefix = ownRoot + sep; - const candidates = executableCandidates(LEGACY_BIN); + const { delimiter: flavorDelimiter, join: flavorJoin, sep: flavorSep } = pathFlavor(platform); + const ownPrefix = ownRoot + flavorSep; + const candidates = executableCandidates(LEGACY_BIN, platform); const skipSet = new Set(actionableShimPaths ?? []); const knownLegacySet = new Set(allDetectedShimPaths ?? []); const seenDirs = new Set(); - for (const dir of pathString.split(delimiter)) { + for (const dir of pathString.split(flavorDelimiter)) { if (!dir || seenDirs.has(dir)) continue; seenDirs.add(dir); for (const name of candidates) { - const shim = join(dir, name); + const shim = flavorJoin(dir, name); if (skipSet.has(shim)) continue; - if (!(await isExecutableFile(shim))) continue; + if (!(await isExecutableFile(shim, platform))) continue; const kind = await classifyShim(shim, ownRoot, ownPrefix); if (kind === 'unreadable') continue; if (kind === 'own') return { kind: 'own' }; diff --git a/apps/kimi-code/scripts/postinstall/takeover.mjs b/apps/kimi-code/scripts/postinstall/takeover.mjs new file mode 100644 index 00000000000..f04ae8115f7 --- /dev/null +++ b/apps/kimi-code/scripts/postinstall/takeover.mjs @@ -0,0 +1,98 @@ +import { findFirstResolvableKimi } from './reach.mjs'; +import { + classifyShim, + deleteShim, + detectLegacyShims, + renameInPlace, +} from './migrate.mjs'; + +export async function planTakeover(ownRoot, detectionPath, reachabilityPath, platform) { + const detections = await detectLegacyShims(ownRoot, detectionPath, platform); + if (detections.length === 0) return { kind: /** @type {const} */ ('noop') }; + + const classifications = await Promise.all( + detections.map(async (detection) => { + const c = await classifyShim(detection.shimPath, platform); + return { ...c, detection }; + }), + ); + + const actionable = classifications.filter((c) => c.kind !== 'blocked'); + const blocked = classifications.filter((c) => c.kind === 'blocked'); + const blocker = await findFirstResolvableKimi( + ownRoot, + reachabilityPath, + actionable.map((c) => c.shimPath), + classifications.map((c) => c.shimPath), + platform, + ); + + if (blocker.kind === 'own') { + return { kind: /** @type {const} */ ('proceed'), detections, classifications, actionable, blocked }; + } + if (blocker.kind === 'blocked-legacy') { + return { kind: /** @type {const} */ ('blocked'), blocked, actionable }; + } + if (blocker.kind === 'foreign') { + return { kind: /** @type {const} */ ('foreign'), path: blocker.path }; + } + return { kind: /** @type {const} */ ('not-on-path'), detection: detections[0] }; +} + +export async function executeTakeover(classifications) { + const renames = []; + const consolidates = []; + const skippedForeignTarget = []; + const deletes = []; + const errors = []; + let preserved = false; + + for (const c of classifications) { + if (c.kind === 'blocked') continue; + + if (!preserved) { + if (c.kind === 'renameable') { + const r = await renameInPlace(c.shimPath, c.target); + if (r.success) { + renames.push(c); + preserved = true; + } else { + errors.push({ ...c, ...r }); + } + continue; + } + if (c.kind === 'consolidate') { + const r = await deleteShim(c.shimPath); + if (r.success) { + consolidates.push(c); + preserved = true; + } else { + errors.push({ ...c, ...r }); + } + continue; + } + if (c.kind === 'delete-only') { + const r = await deleteShim(c.shimPath); + if (r.success) { + skippedForeignTarget.push(c); + } else { + errors.push({ ...c, ...r }); + } + continue; + } + } else { + const r = await deleteShim(c.shimPath); + if (r.success) { + deletes.push(c); + } else { + errors.push({ ...c, ...r }); + } + } + } + + return { renames, consolidates, skippedForeignTarget, deletes, errors, preserved }; +} + +export async function verifyTakeover(ownRoot, reachabilityPath, allDetectedShimPaths, platform) { + return findFirstResolvableKimi(ownRoot, reachabilityPath, [], allDetectedShimPaths, platform); +} diff --git a/apps/kimi-code/scripts/postinstall/ui.mjs b/apps/kimi-code/scripts/postinstall/ui.mjs index 84be992f50a..a8763b7a895 100644 --- a/apps/kimi-code/scripts/postinstall/ui.mjs +++ b/apps/kimi-code/scripts/postinstall/ui.mjs @@ -406,6 +406,97 @@ export function logForeignKimiInTheWay(foreignPath, pm) { ); } +/** + * A takeover that was executed but did NOT hold up under post-execution + * verification: `kimi` still resolves to a legacy/foreign file (or to + * nothing at all). The heading must not claim success — list what was + * actually changed, what still blocks resolution, and how to finish + * the job by hand. + */ +export function logMigrationIncomplete(input, pm) { + const { outcomes, verify } = input; + const { renames, consolidates, skippedForeignTarget, deletes, errors } = outcomes; + const isWindows = process.platform === 'win32'; + const reinstallCmd = pmGlobalInstallCommand(pm, '@moonshot-ai/kimi-code'); + + const lines = [warningHeading('Couldn\'t finish switching to the new kimi'), '']; + + if (verify.kind === 'blocked-legacy') { + lines.push( + pad(' Typing `kimi` still runs the old version. This file is in'), + pad(' the way and we couldn\'t change it:'), + pathInBox(verify.shim), + '', + ); + } else if (verify.kind === 'foreign') { + lines.push( + pad(' Typing `kimi` runs a file we don\'t recognize (not the new'), + pad(' CLI, not the old one). It\'s still in the way:'), + pathInBox(verify.path), + '', + ); + } else { + lines.push( + pad(' Typing `kimi` currently finds nothing at all — the old'), + pad(' shim was removed but the new one isn\'t reachable yet.'), + '', + ); + } + + const changed = [ + ...renames.map((c) => c.shimPath + ' -> ' + c.target), + ...consolidates.map((c) => c.shimPath + ' (removed; kimi-legacy kept)'), + ...skippedForeignTarget.map((c) => c.shimPath + ' (removed)'), + ...deletes.map((c) => c.shimPath + ' (removed)'), + ]; + if (changed.length > 0) { + lines.push(pad(' Changes already made:')); + for (const line of changed) lines.push(pathInBox(line)); + lines.push(''); + } + + if (errors.length > 0) { + lines.push(pad(' Changes that didn\'t go through:')); + for (const e of errors) { + lines.push(pathInBox(e.shimPath + ' (' + (e.message ?? e.code ?? 'error') + ')')); + } + lines.push(''); + } + + if (verify.kind === 'blocked-legacy') { + const c = verify; + lines.push(pad(' Delete it yourself, then install again:')); + if (isWindows) { + lines.push(pathInBox('Remove-Item ' + quotePowerShellPath(c.shim))); + } else { + lines.push(pathInBox('rm ' + quotePosixPath(c.shim))); + lines.push(pad(' (use sudo if it\'s in a system directory)')); + } + lines.push(pathInBox(reinstallCmd), ''); + } else if (verify.kind === 'foreign') { + lines.push( + pad(' Delete or rename that file, then install again:'), + pathInBox(reinstallCmd), + '', + ); + } else { + lines.push( + pad(' Open a new terminal and check `which kimi`. If it finds'), + pad(' nothing, reinstall to restore a working shim:'), + pathInBox(reinstallCmd), + '', + ); + } + + if (renames.length > 0 || consolidates.length > 0) { + lines.push( + pad(' The old version is still available as `kimi-legacy`.'), + ); + } + + emit(renderBox(lines)); +} + /** * The legacy `kimi` was found, but the directory where the package * manager placed the new `kimi` shim is not on the user's PATH. diff --git a/apps/kimi-code/src/cli/commands.ts b/apps/kimi-code/src/cli/commands.ts index dc5cc006edb..4ee0f26f4cb 100644 --- a/apps/kimi-code/src/cli/commands.ts +++ b/apps/kimi-code/src/cli/commands.ts @@ -1,5 +1,5 @@ import { CLI_COMMAND_NAME } from '#/constant/app'; -import { registerMigrateCommand } from '#/migration/index'; +import { registerMigrateCommand, type MigrateCommandOptions } from '#/migration/index'; import { Command, InvalidArgumentError, Option } from 'commander'; import type { CLIOptions } from './options'; @@ -14,7 +14,7 @@ import { registerVisCommand } from './sub/vis'; import { registerWebCommand } from './sub/web'; export type MainCommandHandler = (opts: CLIOptions) => void; -export type MigrateCommandHandler = () => void; +export type MigrateCommandHandler = (options: MigrateCommandOptions) => void; export type PluginNodeRunnerHandler = (entry: string, args: readonly string[]) => void; export type UpgradeCommandHandler = () => void | Promise; export type UpdateDownloadHandler = (version: string, manual: boolean) => void; diff --git a/apps/kimi-code/src/cli/run-shell.ts b/apps/kimi-code/src/cli/run-shell.ts index ceade4c8135..dc32fe2af1a 100644 --- a/apps/kimi-code/src/cli/run-shell.ts +++ b/apps/kimi-code/src/cli/run-shell.ts @@ -1,6 +1,5 @@ import { execFileSync, spawnSync } from 'node:child_process'; import { homedir } from 'node:os'; -import { join } from 'node:path'; import { createKimiHarness, @@ -20,7 +19,7 @@ import { } from '@moonshot-ai/kimi-telemetry'; import { CLI_SHUTDOWN_TIMEOUT_MS, CLI_UI_MODE } from '#/constant/app'; -import { detectPendingMigration } from '#/migration/index'; +import { detectPendingMigration, resolveLegacySourceHome, sameLegacyPath } from '#/migration/index'; import type { TuiConfig } from '#/tui/config'; import { loadTuiConfig, TuiConfigParseError } from '#/tui/config'; import { CHROME_GUTTER } from '#/tui/constant/rendering'; @@ -99,13 +98,25 @@ export async function runShell( }); await harness.ensureConfigFile(); - const migrationPlan = await detectPendingMigration({ - sourceHome: join(homedir(), '.kimi'), - targetHome: harness.homeDir, - ignoreMarker: runOptions.migrateOnly, - }); + const legacySource = resolveLegacySourceHome(process.env, homedir(), process.cwd()); + const sourceIsTarget = sameLegacyPath(legacySource.sourceHome, harness.homeDir); + if (sourceIsTarget) { + process.stderr.write( + ` KIMI_SHARE_DIR (${legacySource.sourceHome}) points at the Kimi Code home; legacy migration is disabled. Unset it or point it at the kimi-cli data directory to migrate.\n`, + ); + } + const migrationPlan = sourceIsTarget + ? null + : await detectPendingMigration({ + sourceHome: legacySource.sourceHome, + skillsSourceHome: legacySource.skillsSourceHome, + targetHome: harness.homeDir, + ignoreMarker: runOptions.migrateOnly, + }); if (runOptions.migrateOnly === true && migrationPlan === null) { - process.stdout.write(' Nothing to migrate from ~/.kimi/.\n'); + if (!sourceIsTarget) { + process.stdout.write(` Nothing to migrate from ${legacySource.sourceHome}.\n`); + } await harness.close(); return; } diff --git a/apps/kimi-code/src/main.ts b/apps/kimi-code/src/main.ts index 37ec0a88272..a956d46f597 100644 --- a/apps/kimi-code/src/main.ts +++ b/apps/kimi-code/src/main.ts @@ -39,6 +39,7 @@ import { detectNativeInstall } from './cli/update/source'; import { maybeRelaunchWithStagedNativeUpdate } from './cli/update/native-swap'; import { createKimiCodeHostIdentity, getVersion } from './cli/version'; import { CLI_SHUTDOWN_TIMEOUT_MS, CLI_UI_MODE, PROCESS_NAME } from './constant/app'; +import { runHeadlessMigrate, type MigrateCommandOptions } from './migration/index'; import { cleanupStaleNativeCacheForCurrent } from './native/native-assets'; import { installMinidbTextBuildWorker } from './native/minidb-worker'; import { installKapSearchWorker } from './native/search-worker'; @@ -93,8 +94,23 @@ export async function handleMainCommand( return { headlessCompleted: false }; } -/** `kimi migrate`: launch the migration screen only, then exit. */ -async function handleMigrateCommand(version: string): Promise { +/** `kimi migrate`: launch the migration screen only, then exit. `--run` runs the full migration headlessly with step logs instead. */ +async function handleMigrateCommand( + version: string, + options: MigrateCommandOptions, +): Promise { + if (options.configOnly && !options.run) { + process.stderr.write('error: --config-only requires --run\n'); + process.exitCode = 2; + return; + } + if (options.run) { + // Set the exit code and return normally — an immediate process.exit here + // could terminate before buffered step/report output is flushed when the + // command is piped or redirected. + process.exitCode = await runHeadlessMigrate({ configOnly: options.configOnly }); + return; + } await runShell(MIGRATE_CLI_OPTIONS, version, { migrateOnly: true }); } @@ -244,8 +260,8 @@ function bootstrap(): void { process.exit(1); }); }, - () => { - void handleMigrateCommand(version).catch(async (error: unknown) => { + (migrateOptions) => { + void handleMigrateCommand(version, migrateOptions).catch(async (error: unknown) => { await logStartupFailure('run migration', error); process.stderr.write(formatStartupError(error, { operation: 'run migration' })); process.stderr.write(`See log: ${resolveGlobalLogPath(resolveKimiHome())}\n`); diff --git a/apps/kimi-code/src/migration/command.ts b/apps/kimi-code/src/migration/command.ts index 13d02f885b5..528873cf5f8 100644 --- a/apps/kimi-code/src/migration/command.ts +++ b/apps/kimi-code/src/migration/command.ts @@ -1,19 +1,28 @@ -/** - * `kimi migrate` sub-command. - * - * A bare, flagless subcommand: it launches the native pi-tui migration screen - * (the same one shown on first launch), then exits. The screen collects the - * migration scope interactively, so there are no CLI options. The actual - * launch is delegated to a host-provided handler. - */ - import type { Command } from 'commander'; -export function registerMigrateCommand(parent: Command, onMigrate: () => void): void { +export interface MigrateCommandOptions { + readonly run: boolean; + readonly configOnly: boolean; +} + +export function registerMigrateCommand( + parent: Command, + onMigrate: (options: MigrateCommandOptions) => void, +): void { parent .command('migrate') .description('Migrate data from a legacy kimi-cli installation into kimi-code.') - .action(() => { - onMigrate(); + .option( + '--run', + 'Run the migration non-interactively and print step-by-step logs. Migrates everything unless --config-only is also given.', + false, + ) + .option( + '--config-only', + 'With --run: migrate config, MCP servers, REPL history and skills, but skip chat sessions.', + false, + ) + .action((options: { run?: boolean; configOnly?: boolean }) => { + onMigrate({ run: options.run === true, configOnly: options.configOnly === true }); }); } diff --git a/apps/kimi-code/src/migration/detect-pending.ts b/apps/kimi-code/src/migration/detect-pending.ts index cf121bf604c..a56a331858d 100644 --- a/apps/kimi-code/src/migration/detect-pending.ts +++ b/apps/kimi-code/src/migration/detect-pending.ts @@ -7,6 +7,7 @@ import { existsSync } from 'node:fs'; import { detectMigration, + countImportedSessionsNeedingRepair, shouldSuppressMigration, type MigrationPlan, } from '@moonshot-ai/migration-legacy'; @@ -14,6 +15,13 @@ import { export interface DetectPendingInput { readonly sourceHome: string; readonly targetHome: string; + readonly skillsSourceHome?: string; + /** + * kimi-cli keeps plan files at `~/.kimi/plans` regardless of KIMI_SHARE_DIR, + * so detection reads them from the user's home by default. Injectable for + * isolated tests. + */ + readonly plansSourceHome?: string; /** * When true, skip the marker-based suppression (`.migrated-to-kimi-code` / * `.skip-migration-from-kimi-cli`). The explicit `kimi migrate` command sets @@ -27,8 +35,16 @@ export async function detectPendingMigration( ): Promise { const { sourceHome, targetHome } = input; if (!existsSync(sourceHome)) return null; + // Imported sessions an older migrator left without turn-structure records + // are unfinished migration work the completion marker must not hide — a + // repair need lifts the suppression. The scan is cheap (one state.json plus + // a wire-head read per imported session) and failure-tolerant. + const sessionsNeedingRepair = await countImportedSessionsNeedingRepair(targetHome).catch( + () => 0, + ); if ( input.ignoreMarker !== true && + sessionsNeedingRepair === 0 && shouldSuppressMigration({ sourceHome, targetHome }) ) { return null; @@ -36,7 +52,11 @@ export async function detectPendingMigration( let plan: MigrationPlan; try { - plan = await detectMigration({ sourcePath: sourceHome }); + plan = await detectMigration({ + sourcePath: sourceHome, + skillsSourcePath: input.skillsSourceHome, + plansSourcePath: input.plansSourceHome, + }); } catch { // Detection failure must never block startup; skip the screen. return null; @@ -50,8 +70,12 @@ export async function detectPendingMigration( plan.totalSessions === 0 && !plan.hasConfig && !plan.hasMcp && - !plan.hasUserHistory; + !plan.hasUserHistory && + !plan.hasSkills && + !plan.hasPlans && + sessionsNeedingRepair === 0 && + (plan.sessionScanFailures?.length ?? 0) === 0; if (nothingToMigrate) return null; - return plan; + return { ...plan, sessionsNeedingRepair }; } diff --git a/apps/kimi-code/src/migration/index.ts b/apps/kimi-code/src/migration/index.ts index fa4a385d279..d84b467ee1e 100644 --- a/apps/kimi-code/src/migration/index.ts +++ b/apps/kimi-code/src/migration/index.ts @@ -6,7 +6,13 @@ * badge helper. Migration logic itself lives in * `@moonshot-ai/migration-legacy`. */ -export { registerMigrateCommand } from './command'; +export { registerMigrateCommand, type MigrateCommandOptions } from './command'; export { formatSessionLabel, isImportedSession, type SessionLabelInput } from './badge'; export { detectPendingMigration } from './detect-pending'; +export { MIGRATE_HEADLESS_EXIT, runHeadlessMigrate } from './run-headless'; +export { + resolveLegacySourceHome, + sameLegacyPath, + type LegacySourceResolution, +} from './legacy-source'; export { MigrationScreenComponent, type MigrationScreenResult } from './migration-screen'; diff --git a/apps/kimi-code/src/migration/legacy-source.ts b/apps/kimi-code/src/migration/legacy-source.ts new file mode 100644 index 00000000000..268d11e1ade --- /dev/null +++ b/apps/kimi-code/src/migration/legacy-source.ts @@ -0,0 +1,29 @@ +import { isAbsolute, join, resolve, win32 } from 'node:path'; + +export interface LegacySourceResolution { + readonly sourceHome: string; + readonly origin: 'default' | 'share-dir'; + readonly skillsSourceHome?: string; +} + +export function resolveLegacySourceHome( + env: NodeJS.ProcessEnv, + home: string, + cwd: string, +): LegacySourceResolution { + const defaultHome = join(home, '.kimi'); + const shareDir = env['KIMI_SHARE_DIR']; + if (shareDir === undefined || shareDir.trim() === '') { + return { sourceHome: defaultHome, origin: 'default' }; + } + const sourceHome = isAbsolute(shareDir) ? resolve(shareDir) : resolve(cwd, shareDir); + const skillsSourceHome = sourceHome === defaultHome ? undefined : defaultHome; + return { sourceHome, origin: 'share-dir', skillsSourceHome }; +} + +export function sameLegacyPath(left: string, right: string): boolean { + if (process.platform === 'win32') { + return win32.resolve(left).toLowerCase() === win32.resolve(right).toLowerCase(); + } + return resolve(left) === resolve(right); +} diff --git a/apps/kimi-code/src/migration/migration-screen.ts b/apps/kimi-code/src/migration/migration-screen.ts index d4c4fee7ec1..6477185f375 100644 --- a/apps/kimi-code/src/migration/migration-screen.ts +++ b/apps/kimi-code/src/migration/migration-screen.ts @@ -304,6 +304,14 @@ export class MigrationScreenComponent extends Container implements Focusable { chalk.hex(colors.success)(` ✓ ${sum.sessions.sessionsMigrated} sessions migrated`), ); } + if (sum.sessions.sessionsRepaired > 0) { + lines.push( + chalk.hex(colors.success)(` ✓ ${sum.sessions.sessionsRepaired} sessions repaired`), + ); + } + if (sum.plans.copied > 0) { + lines.push(chalk.hex(colors.success)(` ✓ ${sum.plans.copied} plan files copied`)); + } // Only claim a data class was migrated when the summary says it was — // a skipped/failed step (e.g. malformed config.toml) must not show ✓. const migratedKinds: string[] = []; @@ -315,7 +323,12 @@ export class MigrationScreenComponent extends Container implements Focusable { if (migratedKinds.length > 0) { lines.push(chalk.hex(colors.success)(` ✓ ${migratedKinds.join(' · ')}`)); } - if (sum.sessions.sessionsMigrated === 0 && migratedKinds.length === 0) { + if ( + sum.sessions.sessionsMigrated === 0 && + sum.sessions.sessionsRepaired === 0 && + sum.plans.copied === 0 && + migratedKinds.length === 0 + ) { lines.push(chalk.hex(colors.textMuted)(' Nothing needed migrating.')); } if (r.notices.detectedPlugins.length > 0) { @@ -325,6 +338,9 @@ export class MigrationScreenComponent extends Container implements Focusable { ), ); } + if (r.notices.plansCopiedNotice !== null) { + lines.push(chalk.hex(colors.textMuted)(` ⓘ ${r.notices.plansCopiedNotice}`)); + } // OAuth credentials are deliberately not migrated (refresh tokens cannot // safely be held by two installs at once). kimi-code's normal auth flow // will prompt for /login when the user first picks a model — surfacing a @@ -419,7 +435,7 @@ export class MigrationScreenComponent extends Container implements Focusable { } lines.push(''); lines.push( - chalk.hex(colors.textMuted)(' Old data kept at ~/.kimi/ — kimi-cli still works.'), + chalk.hex(colors.textMuted)(` Old data kept at ${this.opts.sourceHome} — kimi-cli still works.`), ); } lines.push(''); @@ -536,9 +552,12 @@ function formatMigrationFailureReason(error: unknown): string | undefined { function summarizePlan(plan: MigrationPlan): string { const parts: string[] = []; if (plan.totalSessions > 0) parts.push(`${plan.totalSessions} sessions`); - if (plan.hasConfig) parts.push('config.toml'); + if (plan.hasConfig) parts.push('config'); if (plan.hasMcp) parts.push('mcp.json'); if (plan.hasUserHistory) parts.push('REPL history'); + if (plan.hasSkills) parts.push('skills'); + const scanFailures = plan.sessionScanFailures?.length ?? 0; + if (scanFailures > 0) parts.push(`${scanFailures} unreadable`); return parts.join(' · '); } diff --git a/apps/kimi-code/src/migration/run-headless.ts b/apps/kimi-code/src/migration/run-headless.ts new file mode 100644 index 00000000000..396045d5a3b --- /dev/null +++ b/apps/kimi-code/src/migration/run-headless.ts @@ -0,0 +1,211 @@ +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +import { resolveKimiHome } from '@moonshot-ai/kimi-code-sdk'; +import { + runMigration, + type MigrationPlan, + type MigrationReport, + type MigrationScope, +} from '@moonshot-ai/migration-legacy'; + +import { detectPendingMigration } from './detect-pending'; +import { resolveLegacySourceHome, sameLegacyPath } from './legacy-source'; + +export const MIGRATE_HEADLESS_EXIT = { + success: 0, + incomplete: 1, + error: 2, +} as const; + +export interface HeadlessMigrateDeps { + readonly env: NodeJS.ProcessEnv; + readonly userHome: string; + readonly cwd: string; + readonly targetHome: string; + readonly write: (line: string) => void; +} + +export interface HeadlessMigrateInput { + readonly configOnly: boolean; +} + +function defaultWrite(line: string): void { + process.stdout.write(`${line}\n`); +} + +function timestamp(): string { + return new Date().toISOString().slice(11, 19); +} + +export async function runHeadlessMigrate( + input: HeadlessMigrateInput, + deps?: Partial, +): Promise { + const resolved: HeadlessMigrateDeps = { + env: deps?.env ?? process.env, + userHome: deps?.userHome ?? homedir(), + cwd: deps?.cwd ?? process.cwd(), + targetHome: deps?.targetHome ?? resolveKimiHome(), + write: deps?.write ?? defaultWrite, + }; + const log = (msg: string): void => { + resolved.write(`[kimi-migrate ${timestamp()}] ${msg}`); + }; + + const source = resolveLegacySourceHome(resolved.env, resolved.userHome, resolved.cwd); + log(`source: ${source.sourceHome} (${source.origin === 'share-dir' ? 'KIMI_SHARE_DIR' : 'default ~/.kimi'})`); + if (source.skillsSourceHome !== undefined) { + log(`skills source: ${source.skillsSourceHome} (kimi-cli skills are not relocated by KIMI_SHARE_DIR)`); + } + log(`target: ${resolved.targetHome}`); + + if (sameLegacyPath(source.sourceHome, resolved.targetHome)) { + log('error: source and target are the same directory; refusing to migrate'); + return MIGRATE_HEADLESS_EXIT.error; + } + + const scope: MigrationScope = { + config: true, + mcp: true, + userHistory: true, + skills: true, + sessions: !input.configOnly, + }; + log(`scope: ${input.configOnly ? 'config-only (config, mcp, user-history, skills)' : 'full (config, mcp, user-history, skills, sessions)'}`); + + log('detecting legacy data…'); + const plansSourceHome = join(resolved.userHome, '.kimi', 'plans'); + const plan = await detectPendingMigration({ + sourceHome: source.sourceHome, + skillsSourceHome: source.skillsSourceHome, + targetHome: resolved.targetHome, + plansSourceHome, + ignoreMarker: true, + }); + if (plan === null) { + log(`nothing to migrate from ${source.sourceHome}`); + return MIGRATE_HEADLESS_EXIT.success; + } + logPlan(plan, log); + + let report: MigrationReport; + try { + report = await runMigration({ + plan, + scope, + source: source.sourceHome, + target: resolved.targetHome, + plansSourceDir: plansSourceHome, + onProgress: (msg) => log(`step: ${msg}`), + onSessionProgress: (done, total) => log(`sessions: translating ${done}/${total}`), + }); + } catch (error) { + log(`error: migration crashed: ${error instanceof Error ? error.message : String(error)}`); + return MIGRATE_HEADLESS_EXIT.error; + } + + const complete = logReport(report, scope, log); + log(`report written to ${resolved.targetHome}/migration-report.json`); + log(`run log appended to ${resolved.targetHome}/migration-errors.log`); + if (complete) { + log('result: complete — completion marker written; future launches will not re-prompt'); + return MIGRATE_HEADLESS_EXIT.success; + } + log('result: incomplete — no completion marker; the next launch will offer migration again'); + return MIGRATE_HEADLESS_EXIT.incomplete; +} + +function logPlan(plan: MigrationPlan, log: (msg: string) => void): void { + const scanFailures = plan.sessionScanFailures?.length ?? 0; + log( + `detected: ${plan.totalSessions} sessions across ${plan.workdirs.length} workdirs` + + ` · config=${plan.hasConfig} · mcp=${plan.hasMcp} · user-history=${plan.hasUserHistory} · skills=${plan.hasSkills}` + + (scanFailures > 0 ? ` · ${scanFailures} unreadable session stores` : ''), + ); + for (const failure of plan.sessionScanFailures ?? []) { + log(` unreadable: ${failure.sourcePath} — ${failure.reason}`); + } + if (plan.oauthCredentials.length > 0) { + log(`oauth logins requiring re-login after migration: ${plan.oauthCredentials.join(', ')}`); + } + if (plan.detectedMcpOauthServers.length > 0) { + log(`MCP servers requiring re-authentication: ${plan.detectedMcpOauthServers.join(', ')}`); + } + if (plan.detectedPlugins.length > 0) { + log(`kimi-cli plugins (not migrated): ${plan.detectedPlugins.join(', ')}`); + } +} + +function logReport( + report: MigrationReport, + scope: MigrationScope, + log: (msg: string) => void, +): boolean { + const sum = report.summary; + const c = sum.config; + log( + `config: migrated=${c.migrated} tui-extracted=${c.tuiExtracted}` + + ` hooks-migrated=${c.migratedHooks} hooks-dropped=${c.droppedHooks}` + + (c.droppedProviders.length > 0 ? ` dropped-providers=[${c.droppedProviders.join(', ')}]` : '') + + (c.droppedModels.length > 0 ? ` dropped-models=[${c.droppedModels.join(', ')}]` : '') + + (c.droppedKeys.length > 0 ? ` dropped-keys=[${c.droppedKeys.join(', ')}]` : '') + + (c.configConflicts.length > 0 ? ` conflicts-kept-yours=[${c.configConflicts.join(', ')}]` : '') + + (c.sourceUnreadable ? ' SOURCE-UNREADABLE' : ''), + ); + if (c.wroteSiblingDueToConflict) { + log(`config: live config.toml unparseable — migrated copy at config.migrated-from-kimi-cli.toml (${c.siblingContents.providers.length} providers, ${c.siblingContents.models.length} models, ${c.siblingContents.hooks} hooks)`); + } + if (c.wroteTuiSibling) { + log('config: tui.toml conflicted — migrated copy at tui.migrated-from-kimi-cli.toml'); + } + const m = sum.mcp; + log( + `mcp: merged=[${m.mergedServers.join(', ')}]` + + (m.keptNewForConflicts.length > 0 ? ` kept-existing=[${m.keptNewForConflicts.join(', ')}]` : '') + + (m.droppedServers.length > 0 ? ` dropped=[${m.droppedServers.join(', ')}]` : '') + + (m.wroteSiblingDueToConflict ? ' wrote mcp.migrated-from-kimi-cli.json' : '') + + (m.sourceUnreadable ? ' SOURCE-UNREADABLE' : ''), + ); + log(`user-history: copied=${sum.userHistory.copied} skipped-existing=${sum.userHistory.skippedExisting}`); + log(`skills: copied=${sum.skills.copied} skipped-existing=${sum.skills.skippedExisting}`); + log(`plans: copied=${sum.plans.copied} skipped-existing=${sum.plans.skippedExisting}`); + const s = sum.sessions; + if (scope.sessions) { + log( + `sessions: scanned=${s.bucketsScanned} attempted=${s.sessionsAttempted} migrated=${s.sessionsMigrated}` + + ` already-migrated=${s.sessionsAlreadyMigrated} repaired=${s.sessionsRepaired} skipped-empty=${s.sessionsSkippedEmpty}` + + ` skipped-malformed=${s.sessionsSkippedMalformed} skipped-placeholder=${s.sessionsSkippedPlaceholder}` + + ` failed=${s.sessionsFailed.length} conflicts=${s.sessionsConflicts.length}` + + (s.bucketsSkippedNonlocalKaos > 0 ? ` buckets-skipped-nonlocal-kaos=${s.bucketsSkippedNonlocalKaos}` : '') + + (s.bucketsSkippedNoWorkdirFound > 0 ? ` buckets-skipped-no-workdir=${s.bucketsSkippedNoWorkdirFound}` : ''), + ); + for (const failure of s.sessionsFailed) { + log(` failed: ${failure.sourcePath} — ${failure.reason}`); + } + for (const conflict of s.sessionsConflicts) { + log(` conflict: ${conflict.sourcePath} — target occupied: ${conflict.targetPath}`); + } + } + if (report.notices.oauthLoginsRequiringRelogin.length > 0) { + log(`notice: run /login for: ${report.notices.oauthLoginsRequiringRelogin.join(', ')}`); + } + if (report.notices.mcpOauthServersRequiringReauth.length > 0) { + log(`notice: re-authenticate MCP servers: ${report.notices.mcpOauthServersRequiringReauth.join(', ')}`); + } + if (report.notices.configConflictNotice !== null) { + log(`notice: ${report.notices.configConflictNotice}`); + } + if (report.notices.tuiConflictNotice !== null) { + log(`notice: ${report.notices.tuiConflictNotice}`); + } + if (report.notices.plansCopiedNotice !== null) { + log(`notice: ${report.notices.plansCopiedNotice}`); + } + return ( + s.sessionsFailed.length === 0 && + s.sessionsConflicts.length === 0 && + !(scope.config && c.sourceUnreadable) && + !(scope.mcp && m.sourceUnreadable) + ); +} diff --git a/apps/kimi-code/test/cli/run-shell.test.ts b/apps/kimi-code/test/cli/run-shell.test.ts index 51909bde9a2..e572deb06f1 100644 --- a/apps/kimi-code/test/cli/run-shell.test.ts +++ b/apps/kimi-code/test/cli/run-shell.test.ts @@ -152,7 +152,8 @@ vi.mock('../../src/tui/theme/detect', () => ({ detectTerminalTheme: mocks.detectTerminalTheme, })); -vi.mock('../../src/migration/index', () => ({ +vi.mock('../../src/migration/index', async (importOriginal) => ({ + ...(await importOriginal()), detectPendingMigration: mocks.detectPendingMigration, })); @@ -981,4 +982,19 @@ describe('runShell', () => { ).rejects.toThrow('Invalid configuration'); expect(mocks.tuiStart).not.toHaveBeenCalled(); }); + + it('refuses migration when KIMI_SHARE_DIR resolves to the Kimi Code home', async () => { + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + try { + await withEnv({ KIMI_SHARE_DIR: '/tmp/kimi-code-test-home' }, async () => { + await runShell(minimalCliOptions, '1.2.3-test', { migrateOnly: true }); + }); + expect(mocks.detectPendingMigration).not.toHaveBeenCalled(); + expect(mocks.harnessClose).toHaveBeenCalledOnce(); + expect(mocks.tuiStart).not.toHaveBeenCalled(); + expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('KIMI_SHARE_DIR')); + } finally { + stderrSpy.mockRestore(); + } + }); }); diff --git a/apps/kimi-code/test/migration/command.test.ts b/apps/kimi-code/test/migration/command.test.ts index 2124dd70b6f..ab70534b019 100644 --- a/apps/kimi-code/test/migration/command.test.ts +++ b/apps/kimi-code/test/migration/command.test.ts @@ -1,29 +1,42 @@ -/** - * `kimi migrate` — a bare, flagless subcommand that delegates to a host - * handler. The migration UI is the native pi-tui screen, covered separately - * by `migration-screen.test.ts`. - */ - import { Command } from 'commander'; import { describe, expect, it, vi } from 'vitest'; import { registerMigrateCommand } from '#/migration/command'; describe('registerMigrateCommand', () => { - it('adds a flagless migrate subcommand to the program', () => { + it('adds a migrate subcommand with --run and --config-only options', () => { const program = new Command('kimi'); registerMigrateCommand(program, () => {}); const sub = program.commands.find((c) => c.name() === 'migrate'); expect(sub).toBeDefined(); expect(sub!.description()).toContain('Migrate'); - expect(sub!.options).toHaveLength(0); + const flags = sub!.options.map((o) => o.long); + expect(flags).toEqual(['--run', '--config-only']); }); - it('invokes the host handler when `migrate` runs', () => { + it('invokes the host handler with both flags false by default', () => { const program = new Command('kimi'); const onMigrate = vi.fn(); registerMigrateCommand(program, onMigrate); program.parse(['migrate'], { from: 'user' }); - expect(onMigrate).toHaveBeenCalledTimes(1); + expect(onMigrate).toHaveBeenCalledWith({ run: false, configOnly: false }); + }); + + it('parses --run and --config-only', () => { + const program = new Command('kimi'); + const onMigrate = vi.fn(); + registerMigrateCommand(program, onMigrate); + program.parse(['migrate', '--run', '--config-only'], { from: 'user' }); + expect(onMigrate).toHaveBeenCalledWith({ run: true, configOnly: true }); + }); + + it('is not shadowed by a same-named parent option', () => { + const program = new Command('kimi'); + program.option('--yes', 'legacy alias', false); + program.option('-y, --yolo', 'yolo', false); + const onMigrate = vi.fn(); + registerMigrateCommand(program, onMigrate); + program.parse(['migrate', '--run'], { from: 'user' }); + expect(onMigrate).toHaveBeenCalledWith({ run: true, configOnly: false }); }); }); diff --git a/apps/kimi-code/test/migration/detect-pending.test.ts b/apps/kimi-code/test/migration/detect-pending.test.ts index 10087bbcf71..bafa91a27cc 100644 --- a/apps/kimi-code/test/migration/detect-pending.test.ts +++ b/apps/kimi-code/test/migration/detect-pending.test.ts @@ -36,7 +36,11 @@ describe('detectPendingMigration', () => { it('returns null when source has nothing worth migrating', async () => { // empty source dir, no config/mcp/credentials/sessions - const plan = await detectPendingMigration({ sourceHome: src, targetHome: tgt }); + const plan = await detectPendingMigration({ + sourceHome: src, + targetHome: tgt, + plansSourceHome: tgt, + }); expect(plan).toBeNull(); }); @@ -57,7 +61,11 @@ describe('detectPendingMigration', () => { }), 'utf-8', ); - const plan = await detectPendingMigration({ sourceHome: src, targetHome: tgt }); + const plan = await detectPendingMigration({ + sourceHome: src, + targetHome: tgt, + plansSourceHome: tgt, + }); expect(plan).toBeNull(); }); @@ -97,4 +105,83 @@ describe('detectPendingMigration', () => { const plan = await detectPendingMigration({ sourceHome: src, targetHome: tgt }); expect(plan).toBeNull(); }); + + it('returns a MigrationPlan when source has only skills', async () => { + await mkdir(join(src, 'skills', 'mine'), { recursive: true }); + await writeFile(join(src, 'skills', 'mine', 'SKILL.md'), '# skill', 'utf-8'); + const plan = await detectPendingMigration({ sourceHome: src, targetHome: tgt }); + expect(plan).not.toBeNull(); + expect(plan?.hasSkills).toBe(true); + }); + + it('returns a MigrationPlan when source has only session scan failures', async () => { + const bucket = join(src, 'sessions', '11111111111111111111111111111111'); + await mkdir(join(bucket, 'legacy-session'), { recursive: true }); + const plan = await detectPendingMigration({ sourceHome: src, targetHome: tgt }); + expect(plan).not.toBeNull(); + expect(plan?.sessionScanFailures?.length).toBeGreaterThan(0); + }); + + it('detects skills from skillsSourceHome when it differs from the source home', async () => { + const skillsHome = await mkdtemp(join(tmpdir(), 'detect-pending-skills-')); + try { + await mkdir(join(skillsHome, 'skills', 'mine'), { recursive: true }); + await writeFile(join(skillsHome, 'skills', 'mine', 'SKILL.md'), '# skill', 'utf-8'); + const plan = await detectPendingMigration({ + sourceHome: src, + skillsSourceHome: skillsHome, + targetHome: tgt, + }); + expect(plan).not.toBeNull(); + expect(plan?.hasSkills).toBe(true); + expect(plan?.skillsSourceHome).toBe(skillsHome); + } finally { + await rm(skillsHome, { recursive: true, force: true }); + } + }); + + async function seedImportedSession(wireSecondLine: string, importFormatVersion?: number): Promise { + const dir = join(tgt, 'sessions', 'wd_test', 'ses_old-import', 'agents', 'main'); + await mkdir(dir, { recursive: true }); + await writeFile( + join(dir, 'wire.jsonl'), + '{"type":"metadata","protocol_version":"1.0","created_at":1}\n' + wireSecondLine + '\n', + ); + await writeFile( + join(tgt, 'sessions', 'wd_test', 'ses_old-import', 'state.json'), + JSON.stringify({ + custom: { imported_from_kimi_cli: true, import_format_version: importFormatVersion }, + }), + ); + } + + it('lifts marker suppression when an imported session still lacks turn structure', async () => { + await writeFile(join(src, 'config.toml'), 'default_thinking = true\n', 'utf-8'); + await writeFile( + join(src, '.migrated-to-kimi-code'), + JSON.stringify({ version: 1, target_path: tgt }), + 'utf-8', + ); + await seedImportedSession( + '{"type":"context.append_message","message":{"role":"user","content":[{"type":"text","text":"x"}],"toolCalls":[]}}', + ); + const plan = await detectPendingMigration({ sourceHome: src, targetHome: tgt }); + expect(plan).not.toBeNull(); + expect(plan?.sessionsNeedingRepair).toBe(1); + }); + + it('stays suppressed when imported sessions already carry the current import format', async () => { + await writeFile(join(src, 'config.toml'), 'default_thinking = true\n', 'utf-8'); + await writeFile( + join(src, '.migrated-to-kimi-code'), + JSON.stringify({ version: 1, target_path: tgt }), + 'utf-8', + ); + await seedImportedSession( + '{"type":"turn.prompt","agentId":"main","input":[],"origin":{"kind":"user"},"time":1}', + 2, + ); + const plan = await detectPendingMigration({ sourceHome: src, targetHome: tgt }); + expect(plan).toBeNull(); + }); }); diff --git a/apps/kimi-code/test/migration/legacy-source.test.ts b/apps/kimi-code/test/migration/legacy-source.test.ts new file mode 100644 index 00000000000..73e49243005 --- /dev/null +++ b/apps/kimi-code/test/migration/legacy-source.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; +import { join } from 'node:path'; +import { resolveLegacySourceHome, sameLegacyPath } from '#/migration/legacy-source'; + +const HOME = '/home/user'; +const CWD = '/work/project'; + +describe('resolveLegacySourceHome', () => { + it('defaults to ~/.kimi when KIMI_SHARE_DIR is unset', () => { + const r = resolveLegacySourceHome({}, HOME, CWD); + expect(r).toEqual({ sourceHome: join(HOME, '.kimi'), origin: 'default' }); + }); + + it('defaults to ~/.kimi when KIMI_SHARE_DIR is empty or blank', () => { + expect(resolveLegacySourceHome({ KIMI_SHARE_DIR: '' }, HOME, CWD).origin).toBe('default'); + expect(resolveLegacySourceHome({ KIMI_SHARE_DIR: ' ' }, HOME, CWD).origin).toBe('default'); + }); + + it('uses an absolute KIMI_SHARE_DIR verbatim', () => { + const r = resolveLegacySourceHome({ KIMI_SHARE_DIR: '/data/kimi' }, HOME, CWD); + expect(r.sourceHome).toBe('/data/kimi'); + expect(r.origin).toBe('share-dir'); + }); + + it('resolves a relative KIMI_SHARE_DIR against the process CWD (old-CLI rule)', () => { + const r = resolveLegacySourceHome({ KIMI_SHARE_DIR: 'relative/kimi' }, HOME, CWD); + expect(r.sourceHome).toBe(join(CWD, 'relative', 'kimi')); + expect(r.origin).toBe('share-dir'); + }); + + it('does not expand ~ in KIMI_SHARE_DIR (old-CLI rule)', () => { + const r = resolveLegacySourceHome({ KIMI_SHARE_DIR: '~/custom' }, HOME, CWD); + expect(r.sourceHome).toBe(join(CWD, '~/custom')); + }); + + it('resolves skills from ~/.kimi when the share dir is redirected', () => { + const r = resolveLegacySourceHome({ KIMI_SHARE_DIR: '/data/kimi' }, HOME, CWD); + expect(r.skillsSourceHome).toBe(join(HOME, '.kimi')); + }); + + it('keeps a single source when KIMI_SHARE_DIR points at ~/.kimi itself', () => { + const r = resolveLegacySourceHome({ KIMI_SHARE_DIR: join(HOME, '.kimi') }, HOME, CWD); + expect(r.skillsSourceHome).toBeUndefined(); + }); +}); + +describe('sameLegacyPath', () => { + it('matches identical and redundant forms', () => { + expect(sameLegacyPath('/a/b', '/a/b')).toBe(true); + expect(sameLegacyPath('/a/b/', '/a/b')).toBe(true); + expect(sameLegacyPath('/a/./b', '/a/b')).toBe(true); + }); + + it('rejects different paths', () => { + expect(sameLegacyPath('/a/b', '/a/c')).toBe(false); + }); +}); diff --git a/apps/kimi-code/test/migration/migration-screen.test.ts b/apps/kimi-code/test/migration/migration-screen.test.ts index da70d530971..3be35d2fef2 100644 --- a/apps/kimi-code/test/migration/migration-screen.test.ts +++ b/apps/kimi-code/test/migration/migration-screen.test.ts @@ -16,7 +16,9 @@ function makePlan(over: Partial = {}): MigrationPlan { hasConfig: true, hasMcp: true, hasUserHistory: true, - oauthCredentials: ['kimi-code.json'], + hasSkills: false, + hasPlans: false, + oauthCredentials: ['kimi-code'], workdirs: [], detectedPlugins: [], detectedMcpOauthServers: [], @@ -261,11 +263,14 @@ function makeReport( wroteTuiSibling: false, migratedHooks: 0, droppedHooks: 0, + sourceUnreadable: false, + deviceIdCopied: false, siblingContents: { providers: [], models: [], hooks: 0 }, }, - mcp: { mergedServers: [], keptNewForConflicts: [], droppedServers: [], wroteSiblingDueToConflict: false }, + mcp: { mergedServers: [], keptNewForConflicts: [], droppedServers: [], wroteSiblingDueToConflict: false, sourceUnreadable: false }, userHistory: { copied: 12, skippedExisting: 0 }, skills: { copied: 0, skippedExisting: 0 }, + plans: { copied: 0, skippedExisting: 0 }, sessions: { scope: 'all', bucketsScanned: 0, @@ -274,6 +279,7 @@ function makeReport( sessionsAttempted: 50, sessionsMigrated: 50, sessionsAlreadyMigrated: 0, + sessionsRepaired: 0, sessionsSkippedPlaceholder: 0, sessionsSkippedEmpty: 0, sessionsSkippedMalformed: 0, @@ -289,6 +295,7 @@ function makeReport( detectedPlugins: ['p1', 'p2'], configConflictNotice: null, tuiConflictNotice: null, + plansCopiedNotice: null, ...noticesOver, }, }; @@ -309,6 +316,19 @@ describe('MigrationScreenComponent — result phase', () => { expect(out).toContain('2 kimi-cli plugins'); }); + it('renders a repaired-sessions line and never claims nothing-to-migrate for repairs', () => { + const c = new MigrationScreenComponent({ + plan: makePlan(), + sourceHome: '/x/.kimi', + targetHome: '/y/.kimi-code', + onComplete: () => {}, + }); + c._testShowResult(makeReport({ sessionsMigrated: 0, sessionsRepaired: 7 })); + const out = c.render(80).join('\n'); + expect(out).toContain('7 sessions repaired'); + expect(out).not.toContain('Nothing needed migrating'); + }); + it('renders migrated hooks in the ✓ line and dropped hooks as a warning', () => { const c = new MigrationScreenComponent({ plan: makePlan(), @@ -331,6 +351,8 @@ describe('MigrationScreenComponent — result phase', () => { wroteTuiSibling: false, migratedHooks: 2, droppedHooks: 1, + sourceUnreadable: false, + deviceIdCopied: false, siblingContents: { providers: [], models: [], hooks: 0 }, }, }, @@ -380,6 +402,8 @@ describe('MigrationScreenComponent — result phase', () => { wroteTuiSibling: false, migratedHooks: 0, droppedHooks: 0, + sourceUnreadable: false, + deviceIdCopied: false, siblingContents: { providers: [], models: [], hooks: 0 }, }, }, @@ -414,9 +438,11 @@ describe('MigrationScreenComponent — result phase', () => { wroteTuiSibling: false, migratedHooks: 0, droppedHooks: 0, + sourceUnreadable: false, + deviceIdCopied: false, siblingContents: { providers: [], models: [], hooks: 0 }, }, - mcp: { mergedServers: ['m'], keptNewForConflicts: [], droppedServers: [], wroteSiblingDueToConflict: true }, + mcp: { mergedServers: ['m'], keptNewForConflicts: [], droppedServers: [], wroteSiblingDueToConflict: true, sourceUnreadable: false }, }, ), ); @@ -454,6 +480,8 @@ describe('MigrationScreenComponent — result phase', () => { wroteTuiSibling: false, migratedHooks: 0, droppedHooks: 0, + sourceUnreadable: false, + deviceIdCopied: false, siblingContents: { providers: ['openai', 'managed:kimi-code'], models: ['gpt4'], @@ -509,6 +537,8 @@ describe('MigrationScreenComponent — result phase', () => { wroteTuiSibling: false, migratedHooks: 0, droppedHooks: 0, + sourceUnreadable: false, + deviceIdCopied: false, siblingContents: { providers: [], models: [], hooks: 0 }, }, }, diff --git a/apps/kimi-code/test/migration/run-headless.test.ts b/apps/kimi-code/test/migration/run-headless.test.ts new file mode 100644 index 00000000000..87ed5c7f778 --- /dev/null +++ b/apps/kimi-code/test/migration/run-headless.test.ts @@ -0,0 +1,153 @@ +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createHash } from 'node:crypto'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { MIGRATE_HEADLESS_EXIT, runHeadlessMigrate } from '#/migration/run-headless'; + +let home: string; +let target: string; +let lines: string[]; + +const write = (line: string): void => { + lines.push(line); +}; + +beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'migrate-headless-home-')); + target = await mkdtemp(join(tmpdir(), 'migrate-headless-target-')); + lines = []; +}); + +afterEach(async () => { + await rm(home, { recursive: true, force: true }); + await rm(target, { recursive: true, force: true }); +}); + +function sourceHome(): string { + return join(home, '.kimi'); +} + +async function writeLegacyConfig(): Promise { + await mkdir(sourceHome(), { recursive: true }); + await writeFile(join(sourceHome(), 'config.toml'), 'default_thinking = true\n', 'utf-8'); +} + +async function writeRealSession(workdir: string, uuid: string): Promise { + await mkdir(workdir, { recursive: true }); + const bucket = createHash('md5').update(workdir).digest('hex'); + const sessionDir = join(sourceHome(), 'sessions', bucket, uuid); + await mkdir(sessionDir, { recursive: true }); + await writeFile( + join(sourceHome(), 'kimi.json'), + JSON.stringify({ work_dirs: [{ path: workdir, kaos: 'local', last_session_id: uuid }] }), + 'utf-8', + ); + await writeFile( + join(sessionDir, 'context.jsonl'), + '{"role":"_system_prompt","content":"hi"}\n{"role":"user","content":"hello"}\n', + 'utf-8', + ); +} + +describe('runHeadlessMigrate', () => { + it('reports nothing to migrate for an empty source', async () => { + await mkdir(sourceHome(), { recursive: true }); + const code = await runHeadlessMigrate( + { configOnly: false }, + { env: {}, userHome: home, targetHome: target, write }, + ); + expect(code).toBe(MIGRATE_HEADLESS_EXIT.success); + expect(lines.join('\n')).toContain('nothing to migrate'); + }); + + it('refuses when source and target are the same directory', async () => { + const code = await runHeadlessMigrate( + { configOnly: false }, + { env: {}, userHome: home, targetHome: sourceHome(), write }, + ); + expect(code).toBe(MIGRATE_HEADLESS_EXIT.error); + expect(lines.join('\n')).toContain('refusing to migrate'); + }); + + it('migrates config and sessions, writes the report and the completion marker', async () => { + await writeLegacyConfig(); + await writeRealSession(join(home, 'proj'), '11111111-aaaa-4bbb-8ccc-111111111111'); + const code = await runHeadlessMigrate( + { configOnly: false }, + { env: {}, userHome: home, targetHome: target, write }, + ); + expect(code).toBe(MIGRATE_HEADLESS_EXIT.success); + const out = lines.join('\n'); + expect(out).toContain('detected: 1 sessions'); + expect(out).toContain('step: config done'); + expect(out).toContain('sessions: translating 1/1'); + expect(out).toContain('migrated=1'); + expect(out).toContain('result: complete'); + const report = JSON.parse(await readFile(join(target, 'migration-report.json'), 'utf-8')); + expect(report.summary.sessions.sessionsMigrated).toBe(1); + expect(report.summary.config.migrated).toBe(true); + const marker = JSON.parse( + await readFile(join(sourceHome(), '.migrated-to-kimi-code'), 'utf-8'), + ); + expect(marker.target_path).toBe(target); + }); + + it('skips sessions in config-only mode', async () => { + await writeLegacyConfig(); + await writeRealSession(join(home, 'proj'), '11111111-aaaa-4bbb-8ccc-111111111111'); + const code = await runHeadlessMigrate( + { configOnly: true }, + { env: {}, userHome: home, targetHome: target, write }, + ); + expect(code).toBe(MIGRATE_HEADLESS_EXIT.success); + expect(lines.join('\n')).toContain('scope: config-only'); + const report = JSON.parse(await readFile(join(target, 'migration-report.json'), 'utf-8')); + expect(report.summary.sessions.scope).toBe('config-only'); + expect(report.summary.sessions.sessionsMigrated).toBe(0); + expect(report.summary.config.migrated).toBe(true); + }); + + it('exits incomplete and writes no marker when a session fails', async () => { + await writeLegacyConfig(); + const workdir = join(home, 'proj'); + await writeRealSession(workdir, '11111111-aaaa-4bbb-8ccc-111111111111'); + const bucket = createHash('md5').update(workdir).digest('hex'); + await writeFile( + join(sourceHome(), 'sessions', bucket, '11111111-aaaa-4bbb-8ccc-111111111111', 'context.jsonl'), + '"broken\x00line\nnot json at all\n', + 'utf-8', + ); + const code = await runHeadlessMigrate( + { configOnly: false }, + { env: {}, userHome: home, targetHome: target, write }, + ); + expect(code).toBe(MIGRATE_HEADLESS_EXIT.incomplete); + const out = lines.join('\n'); + expect(out).toContain('failed='); + expect(out).toContain('result: incomplete'); + await expect( + readFile(join(sourceHome(), '.migrated-to-kimi-code'), 'utf-8'), + ).rejects.toThrow(); + }); + + it('honors KIMI_SHARE_DIR as the source and keeps skills on the default home', async () => { + const shareDir = join(home, 'share'); + await mkdir(join(shareDir), { recursive: true }); + await writeFile(join(shareDir, 'config.toml'), 'default_thinking = true\n', 'utf-8'); + await mkdir(join(sourceHome(), 'skills', 'mine'), { recursive: true }); + await writeFile(join(sourceHome(), 'skills', 'mine', 'SKILL.md'), '# skill', 'utf-8'); + const code = await runHeadlessMigrate( + { configOnly: false }, + { env: { KIMI_SHARE_DIR: shareDir }, userHome: home, targetHome: target, write }, + ); + expect(code).toBe(MIGRATE_HEADLESS_EXIT.success); + const out = lines.join('\n'); + expect(out).toContain(`source: ${shareDir} (KIMI_SHARE_DIR)`); + expect(out).toContain('skills: copied=1'); + const report = JSON.parse(await readFile(join(target, 'migration-report.json'), 'utf-8')); + expect(report.summary.config.migrated).toBe(true); + }); +}); diff --git a/apps/kimi-code/test/postinstall/takeover.test.ts b/apps/kimi-code/test/postinstall/takeover.test.ts new file mode 100644 index 00000000000..1919aa70b2f --- /dev/null +++ b/apps/kimi-code/test/postinstall/takeover.test.ts @@ -0,0 +1,316 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { chmod, mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + executeTakeover, + planTakeover, + verifyTakeover, +} from '../../scripts/postinstall/takeover.mjs'; +import { + detectPackageManager, + isGlobalInstall, + ownPackageRoot, +} from '../../scripts/postinstall/reach.mjs'; +import { renameTargetFor, isSystemOwnedDir } from '../../scripts/postinstall/migrate.mjs'; +import { executableCandidates } from '../../scripts/postinstall/platform.mjs'; + +const POSIX = process.platform !== 'win32'; +const DELIM = POSIX ? ':' : ';'; +const PLATFORM = process.platform; + +let root: string; +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'postinstall-')); +}); +afterEach(async () => { + await rm(root, { recursive: true, force: true }); + for (const key of [ + 'npm_config_global', + 'pnpm_config_global', + 'npm_config_location', + 'npm_config_user_agent', + 'npm_config_argv', + ]) { + delete process.env[key]; + } +}); + +interface TestEnv { + ownRoot: string; + ownBin: string; + path: (...dirs: string[]) => string; +} + +async function makeEnv(): Promise { + const ownRoot = join(root, 'ownpkg'); + const ownBin = join(root, 'ownbin'); + await mkdir(ownRoot, { recursive: true }); + await mkdir(ownBin, { recursive: true }); + await writeFile(join(ownRoot, 'package.json'), '{"name":"@moonshot-ai/kimi-code"}', 'utf-8'); + await writeFile(join(ownRoot, 'main.mjs'), '// kimi-code\n', 'utf-8'); + await chmod(join(ownRoot, 'main.mjs'), 0o755); + await symlink(join(ownRoot, 'main.mjs'), join(ownBin, 'kimi')); + return { + ownRoot: await realpath(ownRoot), + ownBin, + path: (...dirs) => dirs.join(DELIM), + }; +} + +async function makeLegacyShim(dirName: string): Promise<{ dir: string; shim: string }> { + const dir = join(root, dirName); + await mkdir(dir, { recursive: true }); + const shim = join(dir, 'kimi'); + await writeFile(shim, '#!/bin/sh\n# setuptools entry point for kimi_cli\n', 'utf-8'); + await chmod(shim, 0o755); + return { dir, shim }; +} + +describe.runIf(POSIX)('shim takeover (POSIX fixtures)', () => { + it('renames a single legacy shim to kimi-legacy and verifies the takeover', async () => { + const env = await makeEnv(); + const legacy = await makeLegacyShim('uvbin'); + const detection = env.path(env.ownBin, legacy.dir); + const reachability = env.path(env.ownBin, legacy.dir); + + const plan = await planTakeover(env.ownRoot, detection, reachability, PLATFORM); + expect(plan.kind).toBe('proceed'); + if (plan.kind !== 'proceed') return; + + const outcomes = await executeTakeover(plan.classifications); + expect(outcomes.renames).toHaveLength(1); + expect(outcomes.errors).toHaveLength(0); + expect(existsSync(join(legacy.dir, 'kimi-legacy'))).toBe(true); + expect(existsSync(legacy.shim)).toBe(false); + await expect(readFile(join(legacy.dir, 'kimi-legacy'), 'utf-8')).resolves.toContain( + 'kimi_cli', + ); + + const verify = await verifyTakeover( + env.ownRoot, + reachability, + plan.classifications.map((c) => c.shimPath), + PLATFORM, + ); + expect(verify.kind).toBe('own'); + }); + + it('preserves the first of two legacy shims and deletes the second', async () => { + const env = await makeEnv(); + const first = await makeLegacyShim('uvbin'); + const second = await makeLegacyShim('pipxbin'); + const detection = env.path(env.ownBin, first.dir, second.dir); + const reachability = env.path(env.ownBin, first.dir, second.dir); + + const plan = await planTakeover(env.ownRoot, detection, reachability, PLATFORM); + expect(plan.kind).toBe('proceed'); + if (plan.kind !== 'proceed') return; + + const outcomes = await executeTakeover(plan.classifications); + expect(outcomes.renames.map((c) => c.shimPath)).toEqual([first.shim]); + expect(outcomes.deletes.map((c) => c.shimPath)).toEqual([second.shim]); + expect(existsSync(join(first.dir, 'kimi-legacy'))).toBe(true); + expect(existsSync(join(second.dir, 'kimi'))).toBe(false); + expect(existsSync(join(second.dir, 'kimi-legacy'))).toBe(false); + }); + + it('consolidates onto an existing legacy kimi-legacy', async () => { + const env = await makeEnv(); + const legacy = await makeLegacyShim('uvbin'); + await writeFile( + join(legacy.dir, 'kimi-legacy'), + '#!/bin/sh\n# older kimi_cli entry point\n', + 'utf-8', + ); + + const plan = await planTakeover(env.ownRoot, env.path(env.ownBin, legacy.dir), env.path(env.ownBin, legacy.dir), PLATFORM); + expect(plan.kind).toBe('proceed'); + if (plan.kind !== 'proceed') return; + + const outcomes = await executeTakeover(plan.classifications); + expect(outcomes.consolidates).toHaveLength(1); + expect(existsSync(legacy.shim)).toBe(false); + await expect(readFile(join(legacy.dir, 'kimi-legacy'), 'utf-8')).resolves.toContain( + 'older kimi_cli', + ); + }); + + it('leaves a user-managed kimi-legacy untouched (delete-only)', async () => { + const env = await makeEnv(); + const legacy = await makeLegacyShim('uvbin'); + await writeFile(join(legacy.dir, 'kimi-legacy'), 'my own wrapper\n', 'utf-8'); + + const plan = await planTakeover(env.ownRoot, env.path(env.ownBin, legacy.dir), env.path(env.ownBin, legacy.dir), PLATFORM); + expect(plan.kind).toBe('proceed'); + if (plan.kind !== 'proceed') return; + + const outcomes = await executeTakeover(plan.classifications); + expect(outcomes.skippedForeignTarget).toHaveLength(1); + expect(outcomes.preserved).toBe(false); + expect(existsSync(legacy.shim)).toBe(false); + await expect(readFile(join(legacy.dir, 'kimi-legacy'), 'utf-8')).resolves.toBe( + 'my own wrapper\n', + ); + }); + + it('a failed preserve attempt gives the next shim its own preserve attempt', async () => { + const env = await makeEnv(); + const first = await makeLegacyShim('uvbin'); + const second = await makeLegacyShim('pipxbin'); + + const outcomes = await executeTakeover([ + { + kind: 'renameable', + shimPath: join(root, 'gone', 'kimi'), + target: join(root, 'gone', 'kimi-legacy'), + detection: { shimPath: join(root, 'gone', 'kimi'), realPath: '' }, + }, + { + kind: 'renameable', + shimPath: second.shim, + target: join(second.dir, 'kimi-legacy'), + detection: { shimPath: second.shim, realPath: second.shim }, + }, + ]); + + expect(outcomes.errors).toHaveLength(1); + expect(outcomes.renames.map((c) => c.shimPath)).toEqual([second.shim]); + expect(outcomes.deletes).toHaveLength(0); + expect(outcomes.preserved).toBe(true); + expect(existsSync(join(second.dir, 'kimi-legacy'))).toBe(true); + }); + + it('reports the takeover as not held when a shim survives ahead of ours', async () => { + const env = await makeEnv(); + const legacy = await makeLegacyShim('uvbin'); + const reachability = env.path(legacy.dir, env.ownBin); + + const plan = await planTakeover(env.ownRoot, env.path(legacy.dir, env.ownBin), reachability, PLATFORM); + expect(plan.kind).toBe('proceed'); + if (plan.kind !== 'proceed') return; + + const verify = await verifyTakeover( + env.ownRoot, + reachability, + plan.classifications.map((c) => c.shimPath), + PLATFORM, + ); + expect(verify.kind).toBe('blocked-legacy'); + }); + + it('aborts with kind=blocked when the shim dir is not writable', async () => { + if (process.getuid?.() === 0) return; + const env = await makeEnv(); + const legacy = await makeLegacyShim('sysbin'); + await chmod(legacy.dir, 0o555); + try { + const plan = await planTakeover(env.ownRoot, env.path(env.ownBin, legacy.dir), env.path(legacy.dir, env.ownBin), PLATFORM); + expect(plan.kind).toBe('blocked'); + expect(existsSync(legacy.shim)).toBe(true); + } finally { + await chmod(legacy.dir, 0o755); + } + }); + + it('aborts with kind=foreign when an unrecognized kimi wins resolution', async () => { + const env = await makeEnv(); + const foreignDir = join(root, 'homebin'); + await mkdir(foreignDir, { recursive: true }); + await writeFile(join(foreignDir, 'kimi'), '#!/bin/sh\necho mine\n', 'utf-8'); + await chmod(join(foreignDir, 'kimi'), 0o755); + const legacy = await makeLegacyShim('uvbin'); + + const plan = await planTakeover( + env.ownRoot, + env.path(foreignDir, legacy.dir, env.ownBin), + env.path(foreignDir, legacy.dir, env.ownBin), + PLATFORM, + ); + expect(plan.kind).toBe('foreign'); + expect(existsSync(legacy.shim)).toBe(true); + }); + + it('aborts with kind=not-on-path when our shim is not reachable', async () => { + const env = await makeEnv(); + const legacy = await makeLegacyShim('uvbin'); + + const plan = await planTakeover(env.ownRoot, env.path(env.ownBin, legacy.dir), env.path(legacy.dir), PLATFORM); + expect(plan.kind).toBe('not-on-path'); + expect(existsSync(legacy.shim)).toBe(true); + }); + + it('returns noop when no legacy shim exists', async () => { + const env = await makeEnv(); + const plan = await planTakeover(env.ownRoot, env.path(env.ownBin), env.path(env.ownBin), PLATFORM); + expect(plan.kind).toBe('noop'); + }); + + it('verifies none when every kimi is gone after execution', async () => { + const env = await makeEnv(); + const verify = await verifyTakeover(env.ownRoot, env.path(join(root, 'emptybin')), [], PLATFORM); + expect(verify.kind).toBe('none'); + }); +}); + +describe('package-manager and own-root detection', () => { + it('detects the package manager from npm_config_user_agent', () => { + process.env['npm_config_user_agent'] = 'pnpm/9.1.0 npm/? node/v22.0.0 darwin arm64'; + expect(detectPackageManager()).toBe('pnpm'); + process.env['npm_config_user_agent'] = 'yarn/1.22.22 npm/? node/v22.0.0 darwin arm64'; + expect(detectPackageManager()).toBe('yarn'); + process.env['npm_config_user_agent'] = 'npm/11.0.0 node/v22.0.0 darwin arm64'; + expect(detectPackageManager()).toBe('npm'); + }); + + it('gates on the documented global-install signals', () => { + expect(isGlobalInstall()).toBe(false); + process.env['npm_config_global'] = 'true'; + expect(isGlobalInstall()).toBe(true); + delete process.env['npm_config_global']; + process.env['npm_config_location'] = 'global'; + expect(isGlobalInstall()).toBe(true); + delete process.env['npm_config_location']; + process.env['pnpm_config_global'] = 'true'; + expect(isGlobalInstall()).toBe(true); + }); + + it('locates the own package root from a nested start dir', async () => { + const pkg = join(root, 'pkgroot'); + await mkdir(join(pkg, 'scripts', 'postinstall'), { recursive: true }); + await writeFile(join(pkg, 'package.json'), '{}', 'utf-8'); + expect(await ownPackageRoot(join(pkg, 'scripts', 'postinstall'))).toBe(await realpath(pkg)); + }); +}); + +describe('windows forms (platform injection, host-agnostic)', () => { + it('expands PATHEXT candidates for kimi', () => { + const candidates = executableCandidates('kimi', 'win32'); + expect(candidates).toContain('kimi'); + expect(candidates).toContain('kimi.exe'); + expect(candidates).toContain('kimi.cmd'); + expect(executableCandidates('kimi', 'linux')).toEqual(['kimi']); + }); + + it('preserves the extension in the rename target', () => { + expect(renameTargetFor('C:\\Users\\me\\.local\\bin\\kimi.exe', 'win32')).toBe( + 'C:\\Users\\me\\.local\\bin\\kimi-legacy.exe', + ); + expect(renameTargetFor('C:\\Users\\me\\.local\\bin\\kimi', 'win32')).toBe( + 'C:\\Users\\me\\.local\\bin\\kimi-legacy', + ); + expect(renameTargetFor('/home/me/.local/bin/kimi', 'linux')).toBe( + '/home/me/.local/bin/kimi-legacy', + ); + }); + + it('classifies system-owned dirs from drive-letter and UNC forms', async () => { + await expect(isSystemOwnedDir('C:\\Program Files\\kimi\\kimi.exe', 'win32')).resolves.toBe(true); + await expect(isSystemOwnedDir('c:\\programdata\\uv\\kimi.exe', 'win32')).resolves.toBe(true); + await expect(isSystemOwnedDir('C:\\Users\\me\\.local\\bin\\kimi.exe', 'win32')).resolves.toBe(false); + await expect(isSystemOwnedDir('D:\\tools\\kimi.exe', 'win32')).resolves.toBe(false); + await expect(isSystemOwnedDir('\\\\server\\share\\tools\\kimi.exe', 'win32')).resolves.toBe(false); + }); +}); diff --git a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts index 0aea0a76da0..d53ffeeca89 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -63,6 +63,8 @@ const MIGRATION_PLAN: MigrationPlan = { hasConfig: false, hasMcp: false, hasUserHistory: false, + hasSkills: false, + hasPlans: false, oauthCredentials: [], workdirs: [], detectedPlugins: [], diff --git a/apps/vscode/src/migration/legacy-migration.manager.ts b/apps/vscode/src/migration/legacy-migration.manager.ts index 70d2e8c692c..496c7faf6d7 100644 --- a/apps/vscode/src/migration/legacy-migration.manager.ts +++ b/apps/vscode/src/migration/legacy-migration.manager.ts @@ -1,10 +1,12 @@ -import { readdir, readFile, stat } from "node:fs/promises"; +import { readdir, stat } from "node:fs/promises"; import { homedir } from "node:os"; import { isAbsolute, join, resolve, win32 } from "node:path"; import { detectMigration, runMigration, + countImportedSessionsNeedingRepair, + defaultPlansSourceDir, shouldSuppressMigration, type MigrationPlan, type MigrationReport, @@ -53,6 +55,7 @@ export interface LegacyMigrationSourcePreview { readonly hasMcp: boolean; readonly hasUserHistory: boolean; readonly hasSkills: boolean; + readonly hasPlans: boolean; readonly totalSessions: number; readonly sessionIssues: number; } @@ -80,6 +83,8 @@ export interface LegacyMigrationManagerOptions { readonly targetHome: string; /** Defaults to the legacy kimi-cli home (`~/.kimi`). Injectable for isolated tests. */ readonly defaultSourceHome?: string; + /** Defaults to the legacy kimi-cli plans dir (`~/.kimi/plans`). Injectable for isolated tests. */ + readonly plansSourceDir?: string; /** First workspace root. Used only to resolve a relative legacy KIMI_SHARE_DIR. */ readonly workspaceRoot?: string | null; /** The removed `kimi.environmentVariables` VS Code setting, read once for migration. */ @@ -104,6 +109,7 @@ export interface LegacyMigrationTotals { readonly mcpServers: number; readonly userHistoryEntries: number; readonly skills: number; + readonly planFiles: number; readonly sessions: number; readonly alreadyMigratedSessions: number; readonly skippedItems: number; @@ -133,7 +139,6 @@ export interface LegacyMigrationRunResult { interface InspectedSource { readonly preview: LegacyMigrationSourcePreview; readonly plan: MigrationPlan; - readonly legacyMcpJsonValid: boolean; } interface InspectionResult { @@ -158,6 +163,7 @@ export class LegacyMigrationManager { private readonly defaultSourceHome: string; private readonly workspaceRoot: string | null; private readonly legacyEnvironmentVariables: unknown; + private readonly plansSourceDir: string; constructor(options: LegacyMigrationManagerOptions) { if (options.targetHome.trim().length === 0) { @@ -170,6 +176,7 @@ export class LegacyMigrationManager { ? null : resolve(options.workspaceRoot); this.legacyEnvironmentVariables = options.legacyEnvironmentVariables; + this.plansSourceDir = options.plansSourceDir ?? defaultPlansSourceDir(); } /** Detect first-launch work without changing the source or target. */ @@ -224,6 +231,7 @@ export class LegacyMigrationManager { scope: FULL_MIGRATION_SCOPE, source: source.preview.sourceHome, target: this.targetHome, + plansSourceDir: this.plansSourceDir, }); const failures = failuresFromReport(source, report); sourceResults.push({ @@ -270,6 +278,12 @@ export class LegacyMigrationManager { const oauthLoginsRequiringRelogin: LegacyMigrationReauthItem[] = []; const mcpOauthServersRequiringReauth: LegacyMigrationReauthItem[] = []; + // Sessions an older migrator left without turn-structure records are + // unfinished migration work; a repair need lifts marker suppression. + const sessionsNeedingRepair = await countImportedSessionsNeedingRepair( + this.targetHome, + ).catch(() => 0); + for (const candidate of candidates) { const sourceCheck = await checkSourceDirectory(candidate.sourceHome); if (sourceCheck === "missing") continue; @@ -292,7 +306,10 @@ export class LegacyMigrationManager { let plan: MigrationPlan; try { - plan = await detectMigration({ sourcePath: candidate.sourceHome }); + plan = await detectMigration({ + sourcePath: candidate.sourceHome, + plansSourcePath: this.plansSourceDir, + }); } catch (error) { warnings.push({ code: "detection-failed", @@ -312,7 +329,7 @@ export class LegacyMigrationManager { })), ); - const hasSkills = await directoryHasEntries(join(candidate.sourceHome, "skills")); + const hasSkills = plan.hasSkills; const sessionScanFailures = plan.sessionScanFailures ?? []; warnings.push( ...sessionScanFailures.map((failure) => ({ @@ -328,6 +345,7 @@ export class LegacyMigrationManager { hasMcp: plan.hasMcp, hasUserHistory: plan.hasUserHistory, hasSkills, + hasPlans: plan.hasPlans, totalSessions: plan.totalSessions, sessionIssues: sessionScanFailures.length, }; @@ -335,6 +353,7 @@ export class LegacyMigrationManager { if ( !ignoreMarker && + sessionsNeedingRepair === 0 && shouldSuppressMigration({ sourceHome: candidate.sourceHome, targetHome: this.targetHome, @@ -346,8 +365,7 @@ export class LegacyMigrationManager { pending.push({ preview, - plan, - legacyMcpJsonValid: await isLegacyMcpJsonValid(plan, candidate.sourceHome), + plan: { ...plan, sessionsNeedingRepair }, }); } @@ -472,33 +490,13 @@ function isMissingError(error: unknown): boolean { ); } -async function directoryHasEntries(path: string): Promise { - try { - return (await readdir(path)).length > 0; - } catch { - return false; - } -} - -async function isLegacyMcpJsonValid( - plan: MigrationPlan, - sourceHome: string, -): Promise { - if (!plan.hasMcp) return true; - try { - JSON.parse(await readFile(join(sourceHome, "mcp.json"), "utf-8")); - return true; - } catch { - return false; - } -} - function hasMigratableData(source: LegacyMigrationSourcePreview): boolean { return ( source.hasConfig || source.hasMcp || source.hasUserHistory || source.hasSkills || + source.hasPlans || source.totalSessions > 0 || source.sessionIssues > 0 ); @@ -509,15 +507,15 @@ function failuresFromReport( report: MigrationReport, ): LegacyMigrationFailure[] { const failures: LegacyMigrationFailure[] = []; - if (source.plan.hasConfig && !report.summary.config.migrated) { + if (report.summary.config.sourceUnreadable) { failures.push({ code: "legacy-config-unreadable", sourceHome: source.preview.sourceHome, item: "config.toml", - message: "The legacy config.toml could not be read or parsed; review it manually.", + message: "The legacy config could not be read or parsed; review it manually.", }); } - if (source.plan.hasMcp && !source.legacyMcpJsonValid) { + if (report.summary.mcp.sourceUnreadable) { failures.push({ code: "legacy-mcp-unreadable", sourceHome: source.preview.sourceHome, @@ -550,6 +548,7 @@ function aggregateTotals(sources: readonly LegacyMigrationSourceResult[]): Legac let mcpServers = 0; let userHistoryEntries = 0; let skills = 0; + let planFiles = 0; let sessions = 0; let alreadyMigratedSessions = 0; let skippedItems = 0; @@ -564,8 +563,10 @@ function aggregateTotals(sources: readonly LegacyMigrationSourceResult[]): Legac mcpServers += summary.mcp.mergedServers.length; userHistoryEntries += summary.userHistory.copied; skills += summary.skills.copied; + planFiles += summary.plans.copied; sessions += summary.sessions.sessionsMigrated; - alreadyMigratedSessions += summary.sessions.sessionsAlreadyMigrated; + alreadyMigratedSessions += + summary.sessions.sessionsAlreadyMigrated + summary.sessions.sessionsRepaired; skippedItems += summary.userHistory.skippedExisting + summary.skills.skippedExisting + @@ -583,6 +584,7 @@ function aggregateTotals(sources: readonly LegacyMigrationSourceResult[]): Legac mcpServers, userHistoryEntries, skills, + planFiles, sessions, alreadyMigratedSessions, skippedItems, @@ -676,7 +678,7 @@ function runMessage( if (status === "failed") { return "Legacy migration failed. Fix the reported path or data error, then retry from the command palette."; } - const migrated = `${totals.configFiles} config, ${totals.mcpServers} MCP server(s), ${totals.userHistoryEntries} history item(s), ${totals.skills} skill(s), and ${totals.sessions} session(s)`; + const migrated = `${totals.configFiles} config, ${totals.mcpServers} MCP server(s), ${totals.userHistoryEntries} history item(s), ${totals.skills} skill(s), ${totals.planFiles} plan file(s), and ${totals.sessions} session(s)`; if (status === "partial") { return `Legacy migration completed with ${totals.failures} failure(s): ${migrated}. Review the details and retry from the command palette.`; } diff --git a/apps/vscode/test/legacy-migration.manager.test.ts b/apps/vscode/test/legacy-migration.manager.test.ts index d246759c60e..8f607dd2f00 100644 --- a/apps/vscode/test/legacy-migration.manager.test.ts +++ b/apps/vscode/test/legacy-migration.manager.test.ts @@ -387,14 +387,24 @@ describe("legacy migration manager (discovery and migration coordination)", () = expect(discovery.prompt).toBeNull(); expect(discovery.notices.oauthLoginsRequiringRelogin).toEqual([ - { sourceHome: rig.sourceHome, name: "kimi-code.json" }, + { sourceHome: rig.sourceHome, name: "kimi-code" }, ]); }); it("reports legacy MCP OAuth state as requiring reauthorization", async () => { const rig = await createRig(); + await mkdir(rig.sourceHome, { recursive: true }); + await writeFile( + join(rig.sourceHome, "mcp.json"), + JSON.stringify({ + mcpServers: { + "example-server": { url: "https://example.test/mcp", auth: "oauth" }, + plain: { command: "npx" }, + }, + }), + ); await mkdir(join(rig.sourceHome, "mcp-oauth"), { recursive: true }); - await writeFile(join(rig.sourceHome, "mcp-oauth", "example-server"), "{}"); + await writeFile(join(rig.sourceHome, "mcp-oauth", "mangled-store-entry"), "{}"); const discovery = await rig.manager.discover(); @@ -441,6 +451,7 @@ async function createRig(options: RigOptions = {}): Promise<{ defaultSourceHome: sourceHome, workspaceRoot: options.workspaceRoot === undefined ? workspaceRoot : options.workspaceRoot, legacyEnvironmentVariables: options.legacyEnvironmentVariables, + plansSourceDir: join(root, "plans"), }); return { root, sourceHome, targetHome, workspaceRoot, manager }; } diff --git a/flake.nix b/flake.nix index 3db4ce492bb..9ff9de15623 100644 --- a/flake.nix +++ b/flake.nix @@ -160,7 +160,7 @@ inherit (finalAttrs) pname version src pnpmWorkspaces; inherit pnpm; fetcherVersion = 3; - hash = "sha256-h5vaRjPdBRQqBVl/vFAl1GexSMH0VVR92JEMu61+2U4="; + hash = "sha256-Fi5tYo53mOO6HV2melJ2OP2xtmOTP9mEnyOA6L70IY8="; }; nativeBuildInputs = [ diff --git a/packages/migration-legacy/package.json b/packages/migration-legacy/package.json index 52448df8704..313a360dcc3 100644 --- a/packages/migration-legacy/package.json +++ b/packages/migration-legacy/package.json @@ -24,11 +24,11 @@ "clean": "rm -rf dist" }, "dependencies": { - "@moonshot-ai/agent-core": "workspace:^", + "@moonshot-ai/agent-core-v2": "workspace:^", "smol-toml": "^1.6.1", "zod": "^4.3.6" }, "devDependencies": { - "@moonshot-ai/kaos": "workspace:^" + "@moonshot-ai/transcript": "workspace:^" } } diff --git a/packages/migration-legacy/src/detect.ts b/packages/migration-legacy/src/detect.ts index e46fb78c51e..0986fa41a55 100644 --- a/packages/migration-legacy/src/detect.ts +++ b/packages/migration-legacy/src/detect.ts @@ -2,16 +2,17 @@ import { readFile, readdir, stat } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { join } from 'node:path'; -import { OldKimiJsonSchema, OldSessionStateSchema } from './kimi-cli-schema.js'; +import { OldKimiJsonSchema } from './kimi-cli-schema.js'; +import { readSourceConfig } from './source-config.js'; +import { defaultPlansSourceDir } from './steps/plans.js'; import { - sourceConfigToml, sourceMcpJson, sourceCredentialsDir, sourceUserHistoryDir, sourcePluginsDir, - sourceMcpOauthDir, sourceSessionsDir, sourceKimiJson, + sourceSkillsDir, } from './paths.js'; import type { MigrationPlan, @@ -19,7 +20,12 @@ import type { SessionMigrationFailure, WorkDirEntry, } from './types.js'; -import { classifySessionDir } from './sessions/classify.js'; +import { classifyLegacySession } from './sessions/classify.js'; +import { + listBucketSessions, + readMergedSessionState, + type LegacySessionRef, +} from './sessions/source.js'; import { oldMd5BucketName } from './sessions/workdir-bucket.js'; const MD5_HEX_RE = /^[0-9a-f]{32}$/; @@ -29,18 +35,39 @@ interface WorkdirMeta { readonly kaos: string; } -export async function detectMigration(opts: { sourcePath: string }): Promise { +export async function detectMigration(opts: { sourcePath: string; skillsSourcePath?: string; plansSourcePath?: string }): Promise { const src = opts.sourcePath; - const hasConfig = existsSync(sourceConfigToml(src)); + const sourceConfig = await readSourceConfig(src); + const hasConfig = sourceConfig.kind !== 'missing'; const hasMcp = existsSync(sourceMcpJson(src)); const hasUserHistory = existsSync(sourceUserHistoryDir(src)); + const hasSkills = await dirHasEntries(opts.skillsSourcePath ?? sourceSkillsDir(src)); + const hasPlans = await dirHasEntries(opts.plansSourcePath ?? defaultPlansSourceDir()); - const oauthCredentials = await listDirSafe(sourceCredentialsDir(src), (n) => + const credentialFiles = await listDirSafe(sourceCredentialsDir(src), (n) => n.endsWith('.json'), ); + const oauthCredentials = new Set( + credentialFiles.map((n) => n.slice(0, -'.json'.length)).filter((n) => n.length > 0), + ); + if (sourceConfig.kind === 'toml' || sourceConfig.kind === 'json') { + const providers = sourceConfig.parsed['providers']; + if (isRecord(providers)) { + for (const prov of Object.values(providers)) { + if (!isRecord(prov)) continue; + const oauth = prov['oauth']; + if (!isRecord(oauth)) continue; + const key = oauth['key']; + if (typeof key !== 'string' || key === '') continue; + const name = key.split('/').pop(); + if (name !== undefined && name !== '') oauthCredentials.add(name); + } + } + } + const detectedPlugins = await listDirSafe(sourcePluginsDir(src), () => true); - const detectedMcpOauthServers = await listDirSafe(sourceMcpOauthDir(src), () => true); + const detectedMcpOauthServers = await detectMcpOauthServers(src); // Reverse-lookup workdir from kimi.json const workdirMap = new Map(); @@ -85,9 +112,9 @@ export async function detectMigration(opts: { sourcePath: string }): Promise { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +async function dirHasEntries(dir: string): Promise { + try { + return (await readdir(dir)).length > 0; + } catch { + return false; + } +} + +async function detectMcpOauthServers(src: string): Promise { + let text: string; + try { + text = await readFile(sourceMcpJson(src), 'utf-8'); + } catch { + return []; + } + try { + const parsed: unknown = JSON.parse(text); + if (!isRecord(parsed)) return []; + const servers = parsed['mcpServers']; + if (!isRecord(servers)) return []; + const out: string[] = []; + for (const [name, server] of Object.entries(servers)) { + if (isRecord(server) && server['auth'] === 'oauth') out.push(name); + } + return out; + } catch { + return []; + } +} + function unreadableSessionReason(): string { - return 'Legacy session could not be inspected because context.jsonl is missing or unreadable.'; + return 'Legacy session could not be inspected because its context is missing or unreadable.'; } function isMissingError(error: unknown): boolean { @@ -173,20 +240,24 @@ async function listDirSafe( } } -async function readWireMtime(sessionDir: string): Promise { - try { - const text = await readFile(join(sessionDir, 'state.json'), 'utf-8'); - const parsed = OldSessionStateSchema.parse(JSON.parse(text)); - if (parsed.wire_mtime !== null && parsed.wire_mtime !== undefined) { - return parsed.wire_mtime * 1000; +async function readWireMtime(ref: LegacySessionRef): Promise { + const state = await readMergedSessionState(ref.sessionDir); + if (state.wire_mtime !== null && state.wire_mtime !== undefined) { + return state.wire_mtime * 1000; + } + if (ref.sessionDir !== undefined) { + try { + return (await stat(join(ref.sessionDir, 'wire.jsonl'))).mtimeMs; + } catch { + // fall through to the context payload's mtime } - } catch { - // fall through to wire.jsonl mtime } - try { - const st = await stat(join(sessionDir, 'wire.jsonl')); - return st.mtimeMs; - } catch { - return 0; + if (ref.contextPath !== undefined) { + try { + return (await stat(ref.contextPath)).mtimeMs; + } catch { + // fall through + } } + return 0; } diff --git a/packages/migration-legacy/src/index.ts b/packages/migration-legacy/src/index.ts index 27b8a73e0a2..6689064a230 100644 --- a/packages/migration-legacy/src/index.ts +++ b/packages/migration-legacy/src/index.ts @@ -6,6 +6,8 @@ export { shouldSuppressMigration, type MigrationSuppressionInput, } from './marker.js'; +export { countImportedSessionsNeedingRepair } from './sessions/repair-imported.js'; +export { defaultPlansSourceDir } from './steps/plans.js'; export { runMigration, type RunMigrationInput } from './run-migration.js'; export { resolveMigrationScope, diff --git a/packages/migration-legacy/src/kimi-cli-schema.ts b/packages/migration-legacy/src/kimi-cli-schema.ts index 7c46bc3668f..caea690c3f5 100644 --- a/packages/migration-legacy/src/kimi-cli-schema.ts +++ b/packages/migration-legacy/src/kimi-cli-schema.ts @@ -40,6 +40,22 @@ export const OldSessionStateSchema = z }) .passthrough(); +// Mirrors kimi-cli's pre-state.json `metadata.json` (merged into SessionState +// by load_session_state, session_state.py:50–96). +export const OldSessionMetadataSchema = z + .object({ + session_id: z.string().optional(), + title: z.string().nullable().optional(), + title_generated: z.boolean().optional(), + title_generate_attempts: z.number().optional(), + wire_mtime: z.number().nullable().optional(), + archived: z.boolean().optional(), + archived_at: z.number().nullable().optional(), + auto_archive_exempt: z.boolean().optional(), + }) + .passthrough(); + export type OldKimiJson = z.infer; export type OldWorkDirMeta = z.infer; export type OldSessionState = z.infer; +export type OldSessionMetadata = z.infer; diff --git a/packages/migration-legacy/src/paths.ts b/packages/migration-legacy/src/paths.ts index 0b4c8bb0768..4308531ceeb 100644 --- a/packages/migration-legacy/src/paths.ts +++ b/packages/migration-legacy/src/paths.ts @@ -7,6 +7,7 @@ export const sourceUserHistoryDir = (src: string): string => join(src, 'user-his export const sourceSkillsDir = (src: string): string => join(src, 'skills'); export const sourceKimiJson = (src: string): string => join(src, 'kimi.json'); export const sourceConfigToml = (src: string): string => join(src, 'config.toml'); +export const sourceConfigJson = (src: string): string => join(src, 'config.json'); export const sourceMcpJson = (src: string): string => join(src, 'mcp.json'); export const sourceMcpOauthDir = (src: string): string => join(src, 'mcp-oauth'); export const sourcePluginsDir = (src: string): string => join(src, 'plugins'); diff --git a/packages/migration-legacy/src/run-migration.ts b/packages/migration-legacy/src/run-migration.ts index 870cbc250e8..fdbeddbd993 100644 --- a/packages/migration-legacy/src/run-migration.ts +++ b/packages/migration-legacy/src/run-migration.ts @@ -4,10 +4,12 @@ import type { MigrationScope, SessionsSummary, } from './types.js'; +import { join } from 'node:path'; import { migrateConfigStep } from './steps/config.js'; import { migrateMcpStep } from './steps/mcp.js'; import { migrateUserHistoryStep } from './steps/user-history.js'; import { migrateSkillsStep } from './steps/skills.js'; +import { migratePlansStep } from './steps/plans.js'; import { migrateSessionsStep } from './sessions/index.js'; import { writeReport } from './report.js'; import { writeMigrationErrorsLog } from './migration-errors-log.js'; @@ -26,6 +28,8 @@ export interface RunMigrationInput { readonly scope: MigrationScope; readonly source: string; readonly target: string; + /** Legacy plans dir; defaults to `~/.kimi/plans` per kimi-cli's hardcode. */ + readonly plansSourceDir?: string; readonly migratorVersion?: string; readonly onProgress?: (msg: string) => void; readonly onSessionProgress?: (done: number, total: number) => void; @@ -51,13 +55,15 @@ export async function runMigration(input: RunMigrationInput): Promise 0 + ? `${plans.copied} plan file(s) copied to ${join(input.target, 'plans')} — plain copies, not wired into plan mode; reuse or delete them as you see fit.` + : null; + const report: MigrationReport = { startedAt, completedAt, @@ -92,6 +111,7 @@ export async function runMigration(input: RunMigrationInput): Promise { - let entries: string[]; - try { - entries = await readdir(sessionDir); - } catch { +export async function classifyLegacySession(ref: LegacySessionRef): Promise { + if (ref.sessionDir !== undefined) { + let entries: string[]; + try { + entries = await readdir(ref.sessionDir); + } catch { + return 'malformed'; + } + if (entries.length === 0 && ref.flatContextFile === undefined) return 'empty'; + if (entries.length === 1 && entries[0] === 'test') return 'placeholder'; + } else if (ref.contextPath === undefined) { return 'malformed'; } - if (entries.length === 0) return 'empty'; - if (entries.length === 1 && entries[0] === 'test') return 'placeholder'; - // `migrateOneSession` hard-fails without `context.jsonl`, so a dir lacking it - // is not migratable. Classify as `malformed` so it is surfaced in the - // skipped-malformed counter rather than entering the migration pipeline. - if (!entries.includes('context.jsonl')) return 'malformed'; + + // `migrateOneSession` hard-fails without a context payload, so a session + // lacking one is not migratable. Classify as `malformed` so it is surfaced + // as a failure rather than entering the migration pipeline. + if (ref.contextPath === undefined) return 'malformed'; // Inspect the context payload to distinguish three cases: // - real: has user/assistant/tool rows → migratable. // - empty: parses but only carries markers (`_system_prompt` etc.) or is // blank → an unused session, or one the user cleared/reverted - // in kimi-cli. Reported as skipped, never enters the pipeline. + // in kimi-cli — UNLESS a custom title survives in state, which + // kimi-cli's Session.is_empty() honors as a listed session. // - corrupt: every non-blank line failed to parse → a real data problem // (truncated write, disk error). Route through `'real'` so the // migration step can run, fail with a diagnostic reason, and @@ -33,12 +39,16 @@ export async function classifySessionDir(sessionDir: string): Promise 0) { + return 'real'; + } return 'empty'; } diff --git a/packages/migration-legacy/src/sessions/content-part.ts b/packages/migration-legacy/src/sessions/content-part.ts index d86c7ebbb1b..851999adb1e 100644 --- a/packages/migration-legacy/src/sessions/content-part.ts +++ b/packages/migration-legacy/src/sessions/content-part.ts @@ -33,11 +33,24 @@ export function normalizeContentPart(part: unknown): NormalizedContentPart { case 'video': return convertMediaPart('video', 'video_url', 'videoUrl', p); + case 'image_url': + return convertMediaPart('image', 'image_url', 'imageUrl', asMediaRecord(p['image_url'])); + case 'audio_url': + return convertMediaPart('audio', 'audio_url', 'audioUrl', asMediaRecord(p['audio_url'])); + case 'video_url': + return convertMediaPart('video', 'video_url', 'videoUrl', asMediaRecord(p['video_url'])); + default: return { type: 'text', text: `[unsupported content: ${JSON.stringify(part)}]` }; } } +function asMediaRecord(value: unknown): Record { + return typeof value === 'object' && value !== null + ? (value as Record) + : {}; +} + /** Safely coerce an unknown value to string for text fields. Avoids * `[object Object]` from accidental object stringification — those become * JSON instead. Strings pass through unchanged; null/undefined → ''. */ diff --git a/packages/migration-legacy/src/sessions/index.ts b/packages/migration-legacy/src/sessions/index.ts index 93f24df49c1..e1c6d0a98b1 100644 --- a/packages/migration-legacy/src/sessions/index.ts +++ b/packages/migration-legacy/src/sessions/index.ts @@ -1,12 +1,13 @@ import { readFile, readdir, stat } from 'node:fs/promises'; import { join } from 'node:path'; -import { OldKimiJsonSchema, OldSessionStateSchema } from '../kimi-cli-schema.js'; +import { OldKimiJsonSchema } from '../kimi-cli-schema.js'; import { ensureSessionIndexEntry } from '../session-index.js'; import { sourceKimiJson, sourceSessionsDir } from '../paths.js'; import type { SessionsSummary } from '../types.js'; -import { classifySessionDir } from './classify.js'; +import { classifyLegacySession } from './classify.js'; import { migrateOneSession } from './migrate-one.js'; +import { listBucketSessions, readMergedSessionState, type LegacySessionRef } from './source.js'; import { oldMd5BucketName } from './workdir-bucket.js'; export interface SessionsStepInput { @@ -22,8 +23,7 @@ interface WorkdirMeta { } interface SessionCandidate { - readonly sourceSessionDir: string; - readonly oldSessionUuid: string; + readonly source: LegacySessionRef; readonly workdirPath: string; readonly wireMtime: number; } @@ -84,9 +84,9 @@ export async function migrateSessionsStep( continue; } // workdir.kind === 'local' - let sessionUuids: string[]; + let refs: LegacySessionRef[]; try { - sessionUuids = await readdir(bucketPath); + refs = await listBucketSessions(bucketPath); } catch (error) { sessionsFailed.push({ sourcePath: bucketPath, @@ -94,9 +94,8 @@ export async function migrateSessionsStep( }); continue; } - for (const uuid of sessionUuids) { - const sessionDir = join(bucketPath, uuid); - const cls = await classifySessionDir(sessionDir); + for (const ref of refs) { + const cls = await classifyLegacySession(ref); if (cls === 'placeholder') { sessionsSkippedPlaceholder++; continue; @@ -107,15 +106,14 @@ export async function migrateSessionsStep( } if (cls === 'malformed') { sessionsFailed.push({ - sourcePath: sessionDir, + sourcePath: sessionReportPath(ref, bucketPath), reason: unreadableSessionReason(), }); continue; } - const wireMtime = await readWireMtime(sessionDir); + const wireMtime = await readWireMtime(ref); candidates.push({ - sourceSessionDir: sessionDir, - oldSessionUuid: uuid, + source: ref, workdirPath: workdir.path, wireMtime, }); @@ -128,11 +126,11 @@ export async function migrateSessionsStep( let migrated = 0; let alreadyMigrated = 0; + let repaired = 0; let processedCount = 0; for (const c of candidates) { const result = await migrateOneSession({ - sourceSessionDir: c.sourceSessionDir, - oldSessionUuid: c.oldSessionUuid, + source: c.source, workdirPath: c.workdirPath, targetHome: input.targetHome, }); @@ -144,7 +142,7 @@ export async function migrateSessionsStep( // this session survived a deleted target dir, re-migrating it must not // append a second line for the same id. await ensureSessionIndexEntry(input.targetHome, { - sessionId: `ses_${c.oldSessionUuid}`, + sessionId: `ses_${c.source.uuid}`, sessionDir: result.targetDir, workDir: c.workdirPath, }); @@ -154,44 +152,45 @@ export async function migrateSessionsStep( // without it the session is unopenable. Record it as failed so the run // summary is honest; one bad index write must not abort the batch. sessionsFailed.push({ - sourcePath: c.sourceSessionDir, + sourcePath: sessionReportPath(c.source, ''), reason: `session migrated but index append failed: ${String(error)}`, }); } - } else if (result.outcome === 'already-migrated') { + } else if (result.outcome === 'already-migrated' || result.outcome === 'repaired') { // The session dir exists from a prior run, but that run may have crashed // before appending the index entry. `ensureSessionIndexEntry` is // idempotent — it adds the entry only when absent — so a rerun // self-heals an index that is missing this session. try { await ensureSessionIndexEntry(input.targetHome, { - sessionId: `ses_${c.oldSessionUuid}`, + sessionId: `ses_${c.source.uuid}`, sessionDir: result.targetDir, workDir: c.workdirPath, }); - alreadyMigrated++; + if (result.outcome === 'repaired') repaired++; + else alreadyMigrated++; } catch (error) { // The index entry is genuinely missing and could not be added — the // session stays unreachable by id, so record it as failed. sessionsFailed.push({ - sourcePath: c.sourceSessionDir, + sourcePath: sessionReportPath(c.source, ''), reason: `session already migrated but index entry could not be ensured: ${String(error)}`, }); } } else if (result.outcome === 'conflict') { sessionsConflicts.push({ - sourcePath: c.sourceSessionDir, + sourcePath: sessionReportPath(c.source, ''), targetPath: result.targetDir, }); } else if (result.outcome === 'empty') { // No migratable conversation (empty or user-cleared session). Counted - // as skipped, not failed — `classifySessionDir` usually catches these + // as skipped, not failed — `classifyLegacySession` usually catches these // before they become candidates, but a translator/classifier edge can // still land one here. sessionsSkippedEmpty++; } else { sessionsFailed.push({ - sourcePath: c.sourceSessionDir, + sourcePath: sessionReportPath(c.source, ''), reason: result.reason, }); } @@ -205,6 +204,7 @@ export async function migrateSessionsStep( sessionsAttempted: candidates.length, sessionsMigrated: migrated, sessionsAlreadyMigrated: alreadyMigrated, + sessionsRepaired: repaired, sessionsSkippedPlaceholder, sessionsSkippedEmpty, sessionsSkippedMalformed, @@ -252,22 +252,30 @@ async function loadWorkdirs(sourceHome: string): Promise { } } -async function readWireMtime(sessionDir: string): Promise { - try { - const text = await readFile(join(sessionDir, 'state.json'), 'utf-8'); - const parsed = OldSessionStateSchema.parse(JSON.parse(text)); - if (parsed.wire_mtime !== null && parsed.wire_mtime !== undefined) { - return parsed.wire_mtime * 1000; +async function readWireMtime(ref: LegacySessionRef): Promise { + const state = await readMergedSessionState(ref.sessionDir); + if (state.wire_mtime !== null && state.wire_mtime !== undefined) { + return state.wire_mtime * 1000; + } + if (ref.sessionDir !== undefined) { + try { + return (await stat(join(ref.sessionDir, 'wire.jsonl'))).mtimeMs; + } catch { + // fall through to the context payload's mtime } - } catch { - // fall through to wire.jsonl mtime } - try { - const st = await stat(join(sessionDir, 'wire.jsonl')); - return st.mtimeMs; - } catch { - return 0; + if (ref.contextPath !== undefined) { + try { + return (await stat(ref.contextPath)).mtimeMs; + } catch { + // fall through + } } + return 0; +} + +function sessionReportPath(ref: LegacySessionRef, fallback: string): string { + return ref.sessionDir ?? ref.flatContextFile ?? join(fallback, ref.uuid); } function emptySummary(): SessionsSummary { @@ -279,6 +287,7 @@ function emptySummary(): SessionsSummary { sessionsAttempted: 0, sessionsMigrated: 0, sessionsAlreadyMigrated: 0, + sessionsRepaired: 0, sessionsSkippedPlaceholder: 0, sessionsSkippedEmpty: 0, sessionsSkippedMalformed: 0, @@ -292,7 +301,7 @@ function unknownWorkdirReason(): string { } function unreadableSessionReason(): string { - return 'Legacy session could not be inspected because context.jsonl is missing or unreadable.'; + return 'Legacy session could not be inspected because its context is missing or unreadable.'; } function isMissingError(error: unknown): boolean { diff --git a/packages/migration-legacy/src/sessions/migrate-one.ts b/packages/migration-legacy/src/sessions/migrate-one.ts index f5ab2c42bcc..0cd621c6d4a 100644 --- a/packages/migration-legacy/src/sessions/migrate-one.ts +++ b/packages/migration-legacy/src/sessions/migrate-one.ts @@ -2,42 +2,62 @@ import { existsSync } from 'node:fs'; import { readFile, mkdir, rm, stat, utimes } from 'node:fs/promises'; import { join } from 'node:path'; -import { OldSessionStateSchema, type OldSessionState } from '../kimi-cli-schema.js'; +import type { OldSessionState } from '../kimi-cli-schema.js'; +import { readTodoItems } from '@moonshot-ai/agent-core-v2/features/todo/todoItem'; import { targetSessionsDir } from '../paths.js'; import { computeWorkdirBucket } from './workdir-bucket.js'; import { closeDanglingToolCalls } from './close-tool-calls.js'; import { analyzeContextContent, + extractLastUsageTokenCount, translateContextLines, type NormalizedMessage, } from './translator.js'; +import { readMergedSessionState, type LegacySessionRef } from './source.js'; import { writeMainAgentWire } from './wire-writer.js'; import { writeSessionState } from './state-writer.js'; import { extractToolCallDisplays } from './tool-call-display.js'; +import { repairImportedSessionWire } from './repair-imported.js'; +import { buildSubagentTaskRecords, migrateLegacySubagents } from './subagents.js'; +import { IMPORT_FORMAT_VERSION } from './turn-structure.js'; export type MigrateOneResult = | { readonly outcome: 'migrated'; readonly targetDir: string } | { readonly outcome: 'already-migrated'; readonly targetDir: string } + | { readonly outcome: 'repaired'; readonly targetDir: string } | { readonly outcome: 'conflict'; readonly targetDir: string } | { readonly outcome: 'empty' } | { readonly outcome: 'failed'; readonly reason: string }; export interface MigrateOneInput { - readonly sourceSessionDir: string; - readonly oldSessionUuid: string; + readonly source: LegacySessionRef; readonly workdirPath: string; readonly targetHome: string; } export async function migrateOneSession(input: MigrateOneInput): Promise { const bucket = computeWorkdirBucket(input.workdirPath); - const targetDir = join(targetSessionsDir(input.targetHome), bucket, `ses_${input.oldSessionUuid}`); + const targetDir = join(targetSessionsDir(input.targetHome), bucket, `ses_${input.source.uuid}`); if (existsSync(targetDir)) { const cls = await classifyExistingTarget(targetDir); - // A dir we wrote ourselves on a previous run — idempotent re-run. + // A dir we wrote ourselves on a previous run — idempotent re-run. Sessions + // imported before the current import format existed get repaired in place; + // a repair that cannot be applied is a real failure, not a silent skip: + // reporting success would leave the session permanently needing repair + // while every run misleadingly completes. if (cls === 'imported') { - return { outcome: 'already-migrated', targetDir }; + const formatVersion = await readImportFormatVersion(targetDir); + if (formatVersion >= IMPORT_FORMAT_VERSION) { + return { outcome: 'already-migrated', targetDir }; + } + const repaired = await repairImportedSessionWire(targetDir).catch(() => false); + if (repaired) return { outcome: 'repaired', targetDir }; + return { + outcome: 'failed', + reason: + 'imported session needs repair but its wire could not be repaired (missing, corrupt, or unwritable)', + }; } // A real, unrelated kimi-code session occupies the path — a true conflict. if (cls === 'foreign') { @@ -49,26 +69,25 @@ export async function migrateOneSession(input: MigrateOneInput): Promise = {}; - try { - const stateText = await readFile(join(input.sourceSessionDir, 'state.json'), 'utf-8'); - oldState = OldSessionStateSchema.parse(JSON.parse(stateText)); - } catch { - // missing or corrupt state — proceed with defaults - } + const oldState: Partial = await readMergedSessionState(input.source.sessionDir); let messages: NormalizedMessage[] = []; let lastUserPrompt = ''; let contextLines: readonly string[] = []; let oldWireText: string | undefined; try { - const contextText = await readFile(join(input.sourceSessionDir, 'context.jsonl'), 'utf-8'); + if (input.source.contextPath === undefined) { + return { outcome: 'failed', reason: 'cannot read context.jsonl' }; + } + const contextText = await readFile(input.source.contextPath, 'utf-8'); contextLines = contextText.split(/\r?\n/); - try { - oldWireText = await readFile(join(input.sourceSessionDir, 'wire.jsonl'), 'utf-8'); - } catch { - // A missing/corrupt wire must not prevent the model-facing context from - // migrating; it only means UI display enrichment is unavailable. + if (input.source.sessionDir !== undefined) { + try { + oldWireText = await readFile(join(input.source.sessionDir, 'wire.jsonl'), 'utf-8'); + } catch { + // A missing/corrupt wire must not prevent the model-facing context from + // migrating; it only means UI display enrichment is unavailable. + } } const toolCallDisplays = oldWireText === undefined ? undefined : extractToolCallDisplays(oldWireText); @@ -80,12 +99,15 @@ export async function migrateOneSession(input: MigrateOneInput): Promise 0; + + if (messages.length === 0 && !hasCustomTitle) { // No `user`/`assistant`/`tool` rows survived translation. Re-analyze the // raw lines to tell a genuinely empty/cleared session apart from one // whose every line failed to parse — the latter is a real data problem // and must show up in `migration-errors.log`, not get silently lumped in - // with skipped-empty. `classifySessionDir` normally catches both ahead + // with skipped-empty. `classifyLegacySession` normally catches both ahead // of time; this stays as a defense-in-depth safety net. if (analyzeContextContent(contextLines) === 'corrupt') { return { @@ -101,18 +123,12 @@ export async function migrateOneSession(input: MigrateOneInput): Promise m.role === 'assistant') ? 'completed' : undefined, + sourcePath: input.source.sessionDir ?? input.source.contextPath ?? '', + oldSessionUuid: input.source.uuid, wireProtocolFromOld, createdAtMs, + subagentIds: subagents.map((s) => s.agentId), }); } catch (error) { // A partially-written targetDir would trip the conflict guard on re-run @@ -167,6 +197,24 @@ export async function migrateOneSession(input: MigrateOneInput): Promise { + if (source.sessionDir !== undefined) { + try { + return Math.floor((await stat(join(source.sessionDir, 'wire.jsonl'))).mtimeMs); + } catch { + // fall through to the context payload's mtime + } + } + if (source.contextPath !== undefined) { + try { + return Math.floor((await stat(source.contextPath)).mtimeMs); + } catch { + // fall through + } + } + return undefined; +} + /** * Set the filesystem mtime of the migrated session artifacts to the session's * original timestamp. The session directory is stamped LAST, since writing @@ -183,6 +231,19 @@ async function applyOriginalMtime(targetDir: string, createdAtMs: number): Promi } } +async function readImportFormatVersion(targetDir: string): Promise { + try { + const parsed: unknown = JSON.parse(await readFile(join(targetDir, 'state.json'), 'utf-8')); + if (typeof parsed !== 'object' || parsed === null) return 0; + const custom = (parsed as { custom?: unknown }).custom; + if (typeof custom !== 'object' || custom === null) return 0; + const version = (custom as Record)['import_format_version']; + return typeof version === 'number' ? version : 0; + } catch { + return 0; + } +} + type ExistingTarget = 'imported' | 'foreign' | 'debris'; /** diff --git a/packages/migration-legacy/src/sessions/repair-imported.ts b/packages/migration-legacy/src/sessions/repair-imported.ts new file mode 100644 index 00000000000..5340c7bf22a --- /dev/null +++ b/packages/migration-legacy/src/sessions/repair-imported.ts @@ -0,0 +1,297 @@ +import { readFile, readdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { readTodoItems, type TodoItem } from '@moonshot-ai/agent-core-v2/features/todo/todoItem'; + +import { targetSessionsDir } from '../paths.js'; +import { readMergedSessionState } from './source.js'; +import { buildSubagentTaskRecords, migrateLegacySubagents } from './subagents.js'; +import { + IMPORT_FORMAT_VERSION, + buildTurnRecords, + splitIntoTurns, + type TurnMessage, + type WireRecord, +} from './turn-structure.js'; +import { insertSubagentTaskRecords } from './wire-writer.js'; + +/** + * In-place repair for sessions imported by an earlier migrator that lacks data + * the current migrator writes (turn-structure records, imported todo list). + * Only the imported prefix is rewritten; live records the user appended after + * the import are preserved verbatim. + * + * Returns `true` when anything changed. Returns `false` when there is nothing + * to repair (already current, or the target is unreadable/corrupt), and + * leaves every file untouched. + */ +export async function repairImportedSessionWire(targetDir: string): Promise { + const statePath = join(targetDir, 'state.json'); + let meta: Record | undefined; + try { + const parsed: unknown = JSON.parse(await readFile(statePath, 'utf-8')); + if (typeof parsed === 'object' && parsed !== null) meta = parsed as Record; + } catch { + return false; + } + if (meta === undefined) return false; + + const wirePath = join(targetDir, 'agents', 'main', 'wire.jsonl'); + let text: string; + try { + text = await readFile(wirePath, 'utf-8'); + } catch { + return false; + } + const records = parseWireRecords(text); + if (records === undefined) return false; + + let index = 0; + let metadata: WireRecord | undefined; + if (records[0]?.type === 'metadata') { + metadata = records[0]; + index = 1; + } + const createdAt = metadata?.['created_at']; + const time = typeof createdAt === 'number' ? createdAt : Date.now(); + + const hasTurnStructure = records + .slice(index, firstIndexOfType(records, index, 'context.append_message')) + .some((record) => record.type === 'turn.prompt'); + + let prefix: WireRecord[]; + if (hasTurnStructure) { + // Consume the imported turn groups so the boundary to live history is found. + const end = consumeImportedTurnGroups(records, index); + prefix = records.slice(index, end); + index = end; + } else { + // The imported prefix is the leading run of context.append_message records + // written by the old migrator; rebuild it with turn structure inserted. + const importedMessages: TurnMessage[] = []; + while (index < records.length && records[index]!.type === 'context.append_message') { + const message = records[index]!['message']; + if (typeof message !== 'object' || message === null) return false; + importedMessages.push(message as TurnMessage); + index += 1; + } + if (importedMessages.length === 0) return false; + prefix = buildTurnRecords(splitIntoTurns(importedMessages), { agentId: 'main', time }); + } + const liveSuffix = records.slice(index); + + let changed = !hasTurnStructure; + + const hasTodoRecord = records.some( + (record) => record.type === 'tools.update_store' && record['key'] === 'todo', + ); + const todoItems = hasTodoRecord ? [] : await readSourceTodos(meta); + if (todoItems.length > 0) { + prefix = [ + ...prefix, + { type: 'tools.update_store', agentId: 'main', key: 'todo', value: todoItems, time }, + ]; + changed = true; + } + + let metaChanged = false; + const sourceDir = readSourceDir(meta); + if (sourceDir !== undefined) { + const subagents = await migrateLegacySubagents(sourceDir, targetDir); + const missingTasks = subagents.filter( + (info) => + !records.some( + (record) => + record.type === 'task.started' && + (record['info'] as { agentId?: string } | undefined)?.agentId === info.agentId, + ), + ); + if (missingTasks.length > 0) { + prefix = insertSubagentTaskRecords(prefix, missingTasks.map(buildSubagentTaskRecords)); + changed = true; + } + if (ensureSubagentRegistrations(meta, subagents, targetDir)) metaChanged = true; + } + + if (ensureMetaFields(meta, records, prefix)) metaChanged = true; + if (!changed && !metaChanged) return false; + + if (changed) { + const rebuilt: WireRecord[] = [ + ...(metadata === undefined ? [] : [metadata]), + ...prefix, + ...liveSuffix, + ]; + await writeFile( + wirePath, + rebuilt.map((record) => JSON.stringify(record)).join('\n') + '\n', + 'utf-8', + ); + } + if (metaChanged) { + await writeFile(statePath, JSON.stringify(meta, null, 2), 'utf-8'); + } + return true; +} + +function parseWireRecords(text: string): WireRecord[] | undefined { + const records: WireRecord[] = []; + for (const rawLine of text.split('\n')) { + const line = rawLine.trim(); + if (line === '') continue; + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + // A corrupt line means we cannot safely re-emit the file — leave it alone. + return undefined; + } + if ( + typeof parsed !== 'object' || + parsed === null || + typeof (parsed as { type?: unknown }).type !== 'string' + ) { + return undefined; + } + records.push(parsed as WireRecord); + } + return records; +} + +function firstIndexOfType( + records: readonly WireRecord[], + from: number, + type: string, +): number { + const found = records.findIndex((record, i) => i >= from && record.type === type); + return found === -1 ? records.length : found; +} + +function consumeImportedTurnGroups(records: readonly WireRecord[], from: number): number { + let index = from; + while (index < records.length && records[index]!.type === 'turn.prompt') { + index += 1; + while (index < records.length && records[index]!.type === 'context.append_message') { + index += 1; + } + if (index < records.length && records[index]!.type === 'turn.ended') index += 1; + } + return index; +} + +async function readSourceTodos(meta: Record): Promise { + const sourceDir = readSourceDir(meta); + if (sourceDir === undefined) return []; + const oldState = await readMergedSessionState(sourceDir); + return readTodoItems(oldState.todos); +} + +function readSourceDir(meta: Record): string | undefined { + const custom = meta['custom']; + if (typeof custom !== 'object' || custom === null) return undefined; + const sourcePath = (custom as Record)['kimi_cli_source_path']; + return typeof sourcePath === 'string' && sourcePath.length > 0 ? sourcePath : undefined; +} + +// Register migrated subagents in meta.agents so the session roster exposes +// their transcripts. Existing entries are never overwritten. +function ensureSubagentRegistrations( + meta: Record, + subagents: readonly { readonly agentId: string }[], + targetDir: string, +): boolean { + if (subagents.length === 0) return false; + const agents = + typeof meta['agents'] === 'object' && meta['agents'] !== null + ? (meta['agents'] as Record) + : undefined; + const nextAgents: Record = { ...agents }; + let changed = false; + for (const info of subagents) { + if (nextAgents[info.agentId] !== undefined) continue; + nextAgents[info.agentId] = { + homedir: join(targetDir, 'agents', info.agentId), + type: 'sub', + parentAgentId: 'main', + labels: { parentAgentId: 'main' }, + }; + changed = true; + } + if (changed) meta['agents'] = nextAgents; + return changed; +} + +// Stamp the current import format version and backfill lastTurnReason (the +// session-outcome mirror clears a persisted reason when the wire has no ended +// turn). Returns whether meta was modified. +function ensureMetaFields( + meta: Record, + records: readonly WireRecord[], + prefix: readonly WireRecord[], +): boolean { + let changed = false; + const custom = meta['custom']; + if (typeof custom === 'object' && custom !== null) { + const record = custom as Record; + if (record['import_format_version'] !== IMPORT_FORMAT_VERSION) { + record['import_format_version'] = IMPORT_FORMAT_VERSION; + changed = true; + } + } + if ( + meta['lastTurnReason'] === undefined && + [...records, ...prefix].some((record) => record.type === 'turn.ended') + ) { + meta['lastTurnReason'] = 'completed'; + changed = true; + } + return changed; +} + +/** + * Count previously imported sessions under the target home whose import format + * predates the current migrator (see IMPORT_FORMAT_VERSION). Drives + * repair-aware detection: a completed migration marker must not permanently + * hide sessions an old migrator left unrepaired. One small state.json read + * per session, cheap enough to run on every startup. + */ +export async function countImportedSessionsNeedingRepair(targetHome: string): Promise { + const sessionsRoot = targetSessionsDir(targetHome); + let bucketNames: string[]; + try { + bucketNames = await readdir(sessionsRoot); + } catch { + return 0; + } + let count = 0; + for (const bucketName of bucketNames) { + let sessionNames: string[]; + try { + sessionNames = await readdir(join(sessionsRoot, bucketName)); + } catch { + continue; + } + for (const sessionName of sessionNames) { + if (await importedSessionNeedsRepair(join(sessionsRoot, bucketName, sessionName))) { + count++; + } + } + } + return count; +} + +async function importedSessionNeedsRepair(sessionDir: string): Promise { + let parsed: unknown; + try { + parsed = JSON.parse(await readFile(join(sessionDir, 'state.json'), 'utf-8')); + } catch { + return false; + } + if (typeof parsed !== 'object' || parsed === null) return false; + const custom = (parsed as { custom?: unknown }).custom; + if (typeof custom !== 'object' || custom === null) return false; + const record = custom as Record; + if (record['imported_from_kimi_cli'] !== true) return false; + const version = record['import_format_version']; + return typeof version !== 'number' || version < IMPORT_FORMAT_VERSION; +} diff --git a/packages/migration-legacy/src/sessions/source.ts b/packages/migration-legacy/src/sessions/source.ts new file mode 100644 index 00000000000..916d460fc98 --- /dev/null +++ b/packages/migration-legacy/src/sessions/source.ts @@ -0,0 +1,124 @@ +import { readdir, readFile, stat } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; + +import { + OldSessionMetadataSchema, + OldSessionStateSchema, + type OldSessionMetadata, + type OldSessionState, +} from '../kimi-cli-schema.js'; + +export interface LegacySessionRef { + readonly uuid: string; + readonly sessionDir?: string; + readonly flatContextFile?: string; + readonly contextPath?: string; +} + +export async function listBucketSessions(bucketPath: string): Promise { + const names = await readdir(bucketPath); + const byId = new Map(); + const remember = (uuid: string): { dir?: string; flat?: string } => { + const existing = byId.get(uuid); + if (existing !== undefined) return existing; + const created: { dir?: string; flat?: string } = {}; + byId.set(uuid, created); + return created; + }; + for (const name of names) { + const fullPath = join(bucketPath, name); + let st; + try { + st = await stat(fullPath); + } catch { + remember(name); + continue; + } + if (st.isDirectory()) { + remember(name).dir = fullPath; + continue; + } + if (st.isFile() && name.endsWith('.jsonl') && name.length > '.jsonl'.length) { + remember(name.slice(0, -'.jsonl'.length)).flat = fullPath; + } + } + return [...byId.entries()].map(([uuid, found]) => { + const dirContext = + found.dir !== undefined && existsSync(join(found.dir, 'context.jsonl')) + ? join(found.dir, 'context.jsonl') + : undefined; + return { + uuid, + sessionDir: found.dir, + flatContextFile: found.flat, + contextPath: dirContext ?? found.flat, + }; + }); +} + +export async function readMergedSessionState( + sessionDir: string | undefined, +): Promise> { + if (sessionDir === undefined) return {}; + let state: Partial = {}; + try { + state = OldSessionStateSchema.parse( + JSON.parse(await readFile(join(sessionDir, 'state.json'), 'utf-8')), + ); + } catch { + // missing or corrupt state — proceed with defaults + } + let metadata: OldSessionMetadata | undefined; + try { + metadata = OldSessionMetadataSchema.parse( + JSON.parse(await readFile(join(sessionDir, 'metadata.json'), 'utf-8')), + ); + } catch { + metadata = undefined; + } + if (metadata === undefined) return state; + return mergeLegacyMetadata(state, metadata); +} + +export function mergeLegacyMetadata( + state: Partial, + metadata: OldSessionMetadata, +): Partial { + const merged: Partial = { ...state }; + if ( + (merged.custom_title === null || merged.custom_title === undefined) && + typeof metadata.title === 'string' && + metadata.title !== '' && + metadata.title !== 'Untitled' + ) { + merged.custom_title = metadata.title; + } + if (merged.title_generated !== true && metadata.title_generated === true) { + merged.title_generated = true; + } + if ((merged.title_generate_attempts ?? 0) === 0 && (metadata.title_generate_attempts ?? 0) > 0) { + merged.title_generate_attempts = metadata.title_generate_attempts; + } + if ( + (merged.wire_mtime === null || merged.wire_mtime === undefined) && + metadata.wire_mtime !== null && + metadata.wire_mtime !== undefined + ) { + merged.wire_mtime = metadata.wire_mtime; + } + if (merged.archived !== true && metadata.archived === true) { + merged.archived = true; + } + if ( + (merged.archived_at === null || merged.archived_at === undefined) && + metadata.archived_at !== null && + metadata.archived_at !== undefined + ) { + merged.archived_at = metadata.archived_at; + } + if (merged.auto_archive_exempt !== true && metadata.auto_archive_exempt === true) { + merged.auto_archive_exempt = true; + } + return merged; +} diff --git a/packages/migration-legacy/src/sessions/state-writer.ts b/packages/migration-legacy/src/sessions/state-writer.ts index b7175b1b509..d25dfa3d2f6 100644 --- a/packages/migration-legacy/src/sessions/state-writer.ts +++ b/packages/migration-legacy/src/sessions/state-writer.ts @@ -1,38 +1,64 @@ import { writeFile, mkdir } from 'node:fs/promises'; import { join } from 'node:path'; + +import { + SESSION_META_VERSION, + type SessionTitleKind, +} from '@moonshot-ai/agent-core-v2/session/sessionMetadata/sessionMetadata'; + import type { OldSessionState } from '../kimi-cli-schema.js'; +import { IMPORT_FORMAT_VERSION } from './turn-structure.js'; export interface StateWriteInput { readonly oldState: Partial; + readonly sessionId: string; + readonly workdirPath: string; readonly lastUserPrompt: string; + readonly lastTurnReason?: 'completed' | 'cancelled' | 'failed'; readonly sourcePath: string; readonly oldSessionUuid: string; readonly wireProtocolFromOld: string | null; readonly createdAtMs: number; + readonly subagentIds?: readonly string[]; } export async function writeSessionState(sessionDir: string, input: StateWriteInput): Promise { await mkdir(sessionDir, { recursive: true, mode: 0o700 }); const customTitle = input.oldState.custom_title ?? null; - const isCustomTitle = - customTitle !== null && customTitle.length > 0 && !input.oldState.title_generated; + const titleGenerated = input.oldState.title_generated === true; + const isCustomTitle = customTitle !== null && customTitle.length > 0 && !titleGenerated; const fallbackTitle = input.lastUserPrompt.slice(0, 50).trim(); const candidateTitle = customTitle ?? fallbackTitle; const finalTitle = candidateTitle.length > 0 ? candidateTitle : 'Imported session'; + const titleKind: SessionTitleKind = isCustomTitle + ? 'custom' + : titleGenerated + ? 'generated' + : 'replaceable'; - const wireMtimeS = input.oldState.wire_mtime ?? null; - const updatedAt = - wireMtimeS !== null && wireMtimeS !== undefined - ? new Date(wireMtimeS * 1000).toISOString() - : new Date(input.createdAtMs).toISOString(); + const wireMtimeMs = + input.oldState.wire_mtime !== null && input.oldState.wire_mtime !== undefined + ? input.oldState.wire_mtime * 1000 + : undefined; + const archivedAtMs = + input.oldState.archived_at !== null && input.oldState.archived_at !== undefined + ? input.oldState.archived_at * 1000 + : undefined; const meta = { - createdAt: new Date(input.createdAtMs).toISOString(), - updatedAt, + id: input.sessionId, + version: SESSION_META_VERSION, + cwd: input.workdirPath, + createdAt: input.createdAtMs, + updatedAt: wireMtimeMs ?? input.createdAtMs, + archived: input.oldState.archived ?? false, + archivedAt: archivedAtMs, title: finalTitle, + titleKind, isCustomTitle, lastPrompt: input.lastUserPrompt.slice(0, 200), + lastTurnReason: input.lastTurnReason, additionalDirs: input.oldState.additional_dirs?.length === 0 ? undefined @@ -48,14 +74,26 @@ export async function writeSessionState(sessionDir: string, input: StateWriteInp type: 'main', parentAgentId: null, }, + ...Object.fromEntries( + (input.subagentIds ?? []).map((agentId) => [ + agentId, + { + homedir: join(sessionDir, 'agents', agentId), + type: 'sub', + parentAgentId: 'main', + labels: { parentAgentId: 'main' }, + }, + ]), + ), }, custom: { imported_from_kimi_cli: true, + import_format_version: IMPORT_FORMAT_VERSION, kimi_cli_source_path: input.sourcePath, kimi_cli_session_id: input.oldSessionUuid, kimi_cli_wire_protocol: input.wireProtocolFromOld, imported_at: new Date().toISOString(), - archived: input.oldState.archived ?? false, + auto_archive_exempt: input.oldState.auto_archive_exempt ?? false, vscode_legacy_approval: input.oldState.approval === undefined ? undefined diff --git a/packages/migration-legacy/src/sessions/subagents.ts b/packages/migration-legacy/src/sessions/subagents.ts new file mode 100644 index 00000000000..a6494e1895e --- /dev/null +++ b/packages/migration-legacy/src/sessions/subagents.ts @@ -0,0 +1,317 @@ +import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; + +import { readTodoItems } from '@moonshot-ai/agent-core-v2/features/todo/todoItem'; + +import { closeDanglingToolCalls } from './close-tool-calls.js'; +import { extractToolCallDisplays } from './tool-call-display.js'; +import { + extractLastUsageTokenCount, + translateContextLines, + type NormalizedMessage, +} from './translator.js'; +import { buildTurnRecords, splitIntoTurns, type WireRecord } from './turn-structure.js'; + +export interface LegacySubagentInfo { + readonly agentId: string; + readonly subagentType: string; + readonly description: string; + readonly status: 'completed' | 'failed' | 'lost'; + readonly startedAtMs: number; + readonly endedAtMs: number; + readonly model?: string; + readonly parentToolCallId?: string; +} + +const TERMINAL_SUBAGENT_STATUSES = new Set(['idle', 'completed', 'done']); +const FAILED_SUBAGENT_STATUSES = new Set(['error', 'failed']); + +interface SubagentEventLink { + readonly parentToolCallId?: string; + readonly firstPrompt?: string; +} + +/** + * Migrate every legacy `subagents//` under a source session dir into + * v2 per-agent wires at `/agents//wire.jsonl`, and return + * the info needed to register them in meta.agents and to synthesize matching + * task records in the main agent's wire. The legacy agent ids are kept as-is — + * they are the keys the main agent's records (SubagentEvent payloads, tool + * results) reference. A subagent whose wire already exists in the target is + * reported but not rewritten, so re-runs stay idempotent. + */ +export async function migrateLegacySubagents( + sourceSessionDir: string, + targetDir: string, +): Promise { + const subagentsRoot = join(sourceSessionDir, 'subagents'); + let entries: string[]; + try { + entries = await readdir(subagentsRoot); + } catch { + return []; + } + const links = await extractSubagentEventLinks(sourceSessionDir); + const out: LegacySubagentInfo[] = []; + for (const entry of entries) { + const info = await migrateOneSubagent(join(subagentsRoot, entry), links.get(entry), targetDir); + if (info !== undefined) out.push(info); + } + return out; +} + +async function migrateOneSubagent( + dir: string, + link: SubagentEventLink | undefined, + targetDir: string, +): Promise { + const meta = await readSubagentMeta(dir); + if (meta === undefined) return undefined; + + const wirePath = join(targetDir, 'agents', meta.agentId, 'wire.jsonl'); + if (existsSync(wirePath)) { + return { + agentId: meta.agentId, + subagentType: meta.subagentType, + description: meta.description ?? link?.firstPrompt ?? '', + status: mapSubagentStatus(meta.status), + startedAtMs: meta.createdAtMs ?? Date.now(), + endedAtMs: meta.updatedAtMs ?? meta.createdAtMs ?? Date.now(), + model: meta.model, + parentToolCallId: link?.parentToolCallId, + }; + } + + let messages: NormalizedMessage[] = []; + try { + const contextText = await readFile(join(dir, 'context.jsonl'), 'utf-8'); + const contextLines = contextText.split(/\r?\n/); + let displays; + try { + displays = extractToolCallDisplays(await readFile(join(dir, 'wire.jsonl'), 'utf-8')); + } catch { + displays = undefined; + } + messages = closeDanglingToolCalls(translateContextLines(contextLines, displays)); + if (messages.length === 0) return undefined; + const createdAtMs = meta.createdAtMs ?? Date.now(); + await writeSubagentWire(targetDir, meta.agentId, { + createdAtMs, + messages, + lastUsageTokenCount: extractLastUsageTokenCount(contextLines), + todoItems: await readSubagentTodos(dir), + }); + } catch { + return undefined; + } + + return { + agentId: meta.agentId, + subagentType: meta.subagentType, + description: meta.description ?? link?.firstPrompt ?? '', + status: mapSubagentStatus(meta.status), + startedAtMs: meta.createdAtMs ?? Date.now(), + endedAtMs: meta.updatedAtMs ?? meta.createdAtMs ?? Date.now(), + model: meta.model, + parentToolCallId: link?.parentToolCallId, + }; +} + +interface SubagentMeta { + readonly agentId: string; + readonly subagentType: string; + readonly status: string; + readonly description?: string; + readonly createdAtMs?: number; + readonly updatedAtMs?: number; + readonly model?: string; +} + +async function readSubagentMeta(dir: string): Promise { + let parsed: unknown; + try { + parsed = JSON.parse(await readFile(join(dir, 'meta.json'), 'utf-8')); + } catch { + return undefined; + } + if (typeof parsed !== 'object' || parsed === null) return undefined; + const record = parsed as Record; + const agentId = record['agent_id']; + if (typeof agentId !== 'string' || agentId.length === 0) return undefined; + const launchSpec = record['launch_spec']; + const model = + typeof launchSpec === 'object' && launchSpec !== null + ? ((launchSpec as Record)['effective_model'] ?? + (launchSpec as Record)['model_override']) + : undefined; + return { + agentId, + subagentType: typeof record['subagent_type'] === 'string' ? record['subagent_type'] : 'agent', + status: typeof record['status'] === 'string' ? record['status'] : 'idle', + description: typeof record['description'] === 'string' ? record['description'] : undefined, + createdAtMs: toMs(record['created_at']), + updatedAtMs: toMs(record['updated_at']), + model: typeof model === 'string' ? model : undefined, + }; +} + +function toMs(seconds: unknown): number | undefined { + return typeof seconds === 'number' && Number.isFinite(seconds) + ? Math.floor(seconds * 1000) + : undefined; +} + +async function readSubagentTodos(dir: string): Promise> { + try { + const parsed: unknown = JSON.parse(await readFile(join(dir, 'state.json'), 'utf-8')); + if (typeof parsed !== 'object' || parsed === null) return []; + return readTodoItems((parsed as Record)['todos']); + } catch { + return []; + } +} + +function mapSubagentStatus(status: string): 'completed' | 'failed' | 'lost' { + if (TERMINAL_SUBAGENT_STATUSES.has(status)) return 'completed'; + if (FAILED_SUBAGENT_STATUSES.has(status)) return 'failed'; + return 'lost'; +} + +// The main wire's SubagentEvent records are the only place that links a +// subagent run to the Agent tool call that spawned it (parent_tool_call_id) +// and carries its launch prompt (first TurnBegin user_input). +async function extractSubagentEventLinks( + sourceSessionDir: string, +): Promise> { + const links = new Map(); + let text: string; + try { + text = await readFile(join(sourceSessionDir, 'wire.jsonl'), 'utf-8'); + } catch { + return links; + } + for (const rawLine of text.split('\n')) { + const line = rawLine.trim(); + if (line === '') continue; + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + continue; + } + if (typeof parsed !== 'object' || parsed === null) continue; + const message = (parsed as Record)['message']; + if (typeof message !== 'object' || message === null) continue; + const m = message as Record; + if (m['type'] !== 'SubagentEvent') continue; + const payload = m['payload']; + if (typeof payload !== 'object' || payload === null) continue; + const p = payload as Record; + const agentId = p['agent_id']; + if (typeof agentId !== 'string' || agentId.length === 0) continue; + const link = links.get(agentId) ?? {}; + if (link.parentToolCallId === undefined && typeof p['parent_tool_call_id'] === 'string') { + link.parentToolCallId = p['parent_tool_call_id']; + } + const event = p['event']; + if ( + link.firstPrompt === undefined && + typeof event === 'object' && + event !== null && + (event as Record)['type'] === 'TurnBegin' + ) { + const eventPayload = (event as Record)['payload']; + const input = + typeof eventPayload === 'object' && eventPayload !== null + ? (eventPayload as Record)['user_input'] + : undefined; + if (typeof input === 'string' && input.length > 0) link.firstPrompt = input; + } + links.set(agentId, link); + } + return links; +} + +interface SubagentWireInput { + readonly createdAtMs: number; + readonly messages: readonly NormalizedMessage[]; + readonly lastUsageTokenCount?: number; + readonly todoItems: ReturnType; +} + +async function writeSubagentWire( + targetDir: string, + agentId: string, + input: SubagentWireInput, +): Promise { + const wireDir = join(targetDir, 'agents', agentId); + await mkdir(wireDir, { recursive: true, mode: 0o700 }); + const metadata = { + type: 'metadata', + protocol_version: '1.0', + created_at: input.createdAtMs, + }; + const lines: string[] = [JSON.stringify(metadata)]; + for (const record of buildTurnRecords(splitIntoTurns(input.messages), { + agentId, + time: input.createdAtMs, + })) { + lines.push(JSON.stringify(record)); + } + if (input.lastUsageTokenCount !== undefined) { + lines.push( + JSON.stringify({ + type: 'token_counting.measured', + agentId, + length: input.messages.length, + tokens: input.lastUsageTokenCount, + time: input.createdAtMs, + }), + ); + } + if (input.todoItems.length > 0) { + lines.push( + JSON.stringify({ + type: 'tools.update_store', + agentId, + key: 'todo', + value: input.todoItems, + time: input.createdAtMs, + }), + ); + } + await writeFile(join(wireDir, 'wire.jsonl'), lines.join('\n') + '\n', 'utf-8'); +} + +/** + * The task.started/task.terminated records that make a migrated subagent show + * up in the main agent's task list, exactly as a native run would leave them. + */ +export function buildSubagentTaskRecords( + info: LegacySubagentInfo, +): { readonly started: WireRecord; readonly terminated: WireRecord } { + const base = { + kind: 'agent', + taskId: info.agentId, + description: info.description, + agentId: info.agentId, + subagentType: info.subagentType, + parentToolCallId: info.parentToolCallId, + model: info.model, + }; + return { + started: { + type: 'task.started', + agentId: 'main', + info: { ...base, status: 'running', startedAt: info.startedAtMs, endedAt: null }, + time: info.startedAtMs, + }, + terminated: { + type: 'task.terminated', + agentId: 'main', + info: { ...base, status: info.status, startedAt: info.startedAtMs, endedAt: info.endedAtMs }, + time: info.endedAtMs, + }, + }; +} diff --git a/packages/migration-legacy/src/sessions/tool-call-display.ts b/packages/migration-legacy/src/sessions/tool-call-display.ts index f546fdd6c53..40f072680f5 100644 --- a/packages/migration-legacy/src/sessions/tool-call-display.ts +++ b/packages/migration-legacy/src/sessions/tool-call-display.ts @@ -1,4 +1,4 @@ -import type { ToolInputDisplay } from '@moonshot-ai/agent-core'; +import type { ToolInputDisplay } from '@moonshot-ai/agent-core-v2/tool/toolInputDisplay'; /** * Recover the UI display attached to a legacy top-level ToolResult. diff --git a/packages/migration-legacy/src/sessions/translator.ts b/packages/migration-legacy/src/sessions/translator.ts index cf85b47bac6..f579a49a4d7 100644 --- a/packages/migration-legacy/src/sessions/translator.ts +++ b/packages/migration-legacy/src/sessions/translator.ts @@ -1,4 +1,4 @@ -import type { ToolInputDisplay } from '@moonshot-ai/agent-core'; +import type { ToolInputDisplay } from '@moonshot-ai/agent-core-v2/tool/toolInputDisplay'; import { normalizeContentPart, type NormalizedContentPart } from './content-part.js'; @@ -70,6 +70,34 @@ export function containsUsableMessage(lines: readonly string[]): boolean { return analyzeContextContent(lines) === 'real'; } +/** + * The last `_usage` row's cumulative `token_count` — kimi-cli's own measured + * context size at the end of the session. Used to seed a + * `token_counting.measured` anchor so a resumed session shows a measured + * context size instead of an estimate until the engine re-measures. + */ +export function extractLastUsageTokenCount(lines: readonly string[]): number | undefined { + let last: number | undefined; + for (const rawLine of lines) { + const line = rawLine.trim(); + if (line === '') continue; + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + continue; + } + if (typeof parsed !== 'object' || parsed === null) continue; + const obj = parsed as Record; + if (obj['role'] !== '_usage') continue; + const tokenCount = obj['token_count']; + if (typeof tokenCount === 'number' && Number.isFinite(tokenCount) && tokenCount >= 0) { + last = tokenCount; + } + } + return last; +} + export function translateContextLines( lines: readonly string[], displaysByToolCallId: ReadonlyMap = new Map(), diff --git a/packages/migration-legacy/src/sessions/turn-structure.ts b/packages/migration-legacy/src/sessions/turn-structure.ts new file mode 100644 index 00000000000..2b5e398e573 --- /dev/null +++ b/packages/migration-legacy/src/sessions/turn-structure.ts @@ -0,0 +1,92 @@ +import type { NormalizedContentPart } from './content-part.js'; + +// Format version of an imported session, stamped into state.json +// `custom.import_format_version`. Absent means an old message-only import +// (treated as 0). Bump when the migrator starts writing new wire/meta data so +// detection can offer an in-place repair run instead of letting a completed +// marker hide the missing data forever. +// 1 = turn-structure records + token_counting.measured + lastTurnReason + +// imported todo list +// 2 = subagent wires + subagent task records + plans copy + device_id copy +export const IMPORT_FORMAT_VERSION = 2; + +export interface TurnMessage { + readonly role: string; + readonly content?: readonly NormalizedContentPart[]; +} + +export interface ImportedTurn { + readonly messages: readonly TurnMessage[]; + readonly opensWithUser: boolean; +} + +export interface WireRecord { + readonly type: string; + readonly [key: string]: unknown; +} + +// Turn boundaries mirror the transcript projector's grouping rule (one turn +// per user message, plus a fallback turn for a leading non-user run left over +// from a compaction-truncated context). Keeping this in lockstep with +// `groupMessagesIntoSnapshot` is what makes the restored turn clock line up +// with the cold transcript grouping — one synthesized `turn.prompt` per +// grouped turn, so the first live turn after resume never collides with an +// imported one. +export function splitIntoTurns(messages: readonly TurnMessage[]): ImportedTurn[] { + const turns: ImportedTurn[] = []; + let current: TurnMessage[] = []; + let opensWithUser = false; + const flush = (): void => { + if (current.length === 0) return; + turns.push({ messages: current, opensWithUser }); + current = []; + opensWithUser = false; + }; + for (const message of messages) { + if (message.role === 'user') { + flush(); + current = [message]; + opensWithUser = true; + continue; + } + current.push(message); + } + flush(); + return turns; +} + +export function turnHasAssistantContent(turn: ImportedTurn): boolean { + return turn.messages.some((message) => message.role === 'assistant'); +} + +export function buildTurnRecords( + turns: readonly ImportedTurn[], + opts: { readonly agentId: string; readonly time: number }, +): WireRecord[] { + const records: WireRecord[] = []; + turns.forEach((turn, turnId) => { + const opener = turn.opensWithUser ? turn.messages[0] : undefined; + records.push({ + type: 'turn.prompt', + agentId: opts.agentId, + input: opener?.content ?? [], + origin: turn.opensWithUser + ? { kind: 'user' } + : { kind: 'system_trigger', name: 'imported_orphan' }, + time: opts.time, + }); + for (const message of turn.messages) { + records.push({ type: 'context.append_message', message }); + } + if (turnHasAssistantContent(turn)) { + records.push({ + type: 'turn.ended', + agentId: opts.agentId, + turnId, + reason: 'completed', + time: opts.time, + }); + } + }); + return records; +} diff --git a/packages/migration-legacy/src/sessions/wire-writer.ts b/packages/migration-legacy/src/sessions/wire-writer.ts index dc7b1d482e1..4b0e2d164d4 100644 --- a/packages/migration-legacy/src/sessions/wire-writer.ts +++ b/packages/migration-legacy/src/sessions/wire-writer.ts @@ -1,12 +1,20 @@ import { writeFile, mkdir } from 'node:fs/promises'; import { join } from 'node:path'; +import type { TodoItem } from '@moonshot-ai/agent-core-v2/features/todo/todoItem'; import type { NormalizedMessage } from './translator.js'; +import { buildTurnRecords, splitIntoTurns, type WireRecord } from './turn-structure.js'; export const WIRE_PROTOCOL_VERSION = '1.0'; export interface WireWriteInput { readonly createdAtMs: number; readonly messages: readonly NormalizedMessage[]; + readonly lastUsageTokenCount?: number; + readonly todoItems?: readonly TodoItem[]; + readonly subagentTasks?: readonly { + readonly started: WireRecord; + readonly terminated: WireRecord; + }[]; } export async function writeMainAgentWire(sessionDir: string, input: WireWriteInput): Promise { @@ -19,8 +27,92 @@ export async function writeMainAgentWire(sessionDir: string, input: WireWriteInp created_at: input.createdAtMs, }; const lines: string[] = [JSON.stringify(metadata)]; - for (const msg of input.messages) { - lines.push(JSON.stringify({ type: 'context.append_message', message: msg })); + // Bare `context.append_message` records alone leave the engine's turn clock + // at zero on resume: the first live turn would be numbered t0 and collide + // with the imported history turn the transcript grouping also numbers t0. + // Interleaving synthesized turn.prompt/turn.ended records advances the clock + // past the imported turns, so live turns get fresh ids. + const turns = splitIntoTurns(input.messages); + const turnRecords = buildTurnRecords(turns, { agentId: 'main', time: input.createdAtMs }); + const withTasks = insertSubagentTaskRecords(turnRecords, input.subagentTasks ?? []); + for (const record of withTasks) { + lines.push(JSON.stringify(record)); + } + if (input.lastUsageTokenCount !== undefined) { + lines.push( + JSON.stringify({ + type: 'token_counting.measured', + agentId: 'main', + length: input.messages.length, + tokens: input.lastUsageTokenCount, + time: input.createdAtMs, + }), + ); + } + // kimi-cli keeps the session todo list in state.json; v2 replays it from a + // durable tools.update_store wire record, so a migrated session must carry + // its todos here or the todo panel shows up empty after resume. + if (input.todoItems !== undefined && input.todoItems.length > 0) { + lines.push( + JSON.stringify({ + type: 'tools.update_store', + agentId: 'main', + key: 'todo', + value: input.todoItems, + time: input.createdAtMs, + }), + ); } await writeFile(join(wireDir, 'wire.jsonl'), lines.join('\n') + '\n', 'utf-8'); } + +// task.started goes right before the assistant message carrying the Agent +// tool call, task.terminated right after the tool result message — the same +// positions a native run would have written them at. When the tool call is +// not found (protocol too old to carry SubagentEvent links), the pair is +// appended after the turn records instead of being dropped. +export function insertSubagentTaskRecords( + records: readonly WireRecord[], + tasks: readonly { readonly started: WireRecord; readonly terminated: WireRecord }[], +): WireRecord[] { + const out = [...records]; + for (const { started, terminated } of tasks) { + const parentToolCallId = (started['info'] as { parentToolCallId?: string } | undefined) + ?.parentToolCallId; + let callIndex = -1; + let resultIndex = -1; + if (parentToolCallId !== undefined && parentToolCallId.length > 0) { + for (let i = 0; i < out.length; i++) { + const record = out[i]!; + if (record.type !== 'context.append_message') continue; + const message = record['message'] as + | { role?: string; toolCallId?: string; toolCalls?: readonly { id?: string }[] } + | undefined; + if (message === undefined) continue; + if ( + callIndex === -1 && + message.role === 'assistant' && + (message.toolCalls ?? []).some((call) => call.id === parentToolCallId) + ) { + callIndex = i; + } + if (resultIndex === -1 && message.role === 'tool' && message.toolCallId === parentToolCallId) { + resultIndex = i; + } + } + } + if (callIndex !== -1 && resultIndex !== -1) { + out.splice(resultIndex + 1, 0, terminated); + out.splice(callIndex, 0, started); + } else if (callIndex !== -1) { + out.splice(callIndex, 0, started); + out.push(terminated); + } else if (resultIndex !== -1) { + out.splice(resultIndex, 0, started); + out.splice(resultIndex + 2, 0, terminated); + } else { + out.push(started, terminated); + } + } + return out; +} diff --git a/packages/migration-legacy/src/sessions/workdir-bucket.ts b/packages/migration-legacy/src/sessions/workdir-bucket.ts index a155754af17..151cbe3c129 100644 --- a/packages/migration-legacy/src/sessions/workdir-bucket.ts +++ b/packages/migration-legacy/src/sessions/workdir-bucket.ts @@ -1,18 +1,18 @@ import { createHash } from 'node:crypto'; -import { encodeWorkDirKey } from '@moonshot-ai/agent-core/session/store'; +import { encodeWorkDirKey } from '@moonshot-ai/agent-core-v2/_base/utils/workdir-slug'; /** * Bucket directory name `wd__` for a workdir path. * - * Aliases agent-core's `encodeWorkDirKey` so the migrator and the running app - * always produce byte-identical buckets. The session picker locates sessions - * purely by `readdir(encodeWorkDirKey(workDir))` (it never consults + * Aliases agent-core-v2's `encodeWorkDirKey` so the migrator and the running + * app always produce byte-identical buckets. The session picker locates + * sessions purely by `readdir(encodeWorkDirKey(workDir))` (it never consults * `session_index.jsonl`), so the two MUST stay in sync or migrated sessions * become invisible in the picker. * * This used to be a local re-implementation built on `node:path`'s `resolve`. - * On Windows `node:path` yields backslash-separated paths while agent-core's + * On Windows `node:path` yields backslash-separated paths while agent-core-v2's * `encodeWorkDirKey` uses `pathe` (forward slashes on every platform), so the * SHA-256 inputs diverged and migrated sessions landed in a bucket the picker * never reads. Delegating to `encodeWorkDirKey` removes that drift for good. diff --git a/packages/migration-legacy/src/source-config.ts b/packages/migration-legacy/src/source-config.ts new file mode 100644 index 00000000000..8c707ac8d56 --- /dev/null +++ b/packages/migration-legacy/src/source-config.ts @@ -0,0 +1,57 @@ +import { readFile } from 'node:fs/promises'; +import { parse as parseToml } from 'smol-toml'; + +import { sourceConfigJson, sourceConfigToml } from './paths.js'; + +export type SourceConfig = + | { readonly kind: 'toml'; readonly parsed: Record } + | { readonly kind: 'json'; readonly parsed: Record } + | { readonly kind: 'missing' } + | { readonly kind: 'unreadable'; readonly path: string }; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isEnoent(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + (error as { code?: unknown }).code === 'ENOENT' + ); +} + +export async function readSourceConfig(sourceHome: string): Promise { + const tomlPath = sourceConfigToml(sourceHome); + let tomlText: string | undefined; + try { + tomlText = await readFile(tomlPath, 'utf-8'); + } catch (error) { + // Only a missing file falls through to the JSON-era config; any other read + // failure (permissions, I/O) is a data problem the report must surface. + if (!isEnoent(error)) return { kind: 'unreadable', path: tomlPath }; + } + if (tomlText !== undefined) { + try { + const parsed: unknown = parseToml(tomlText); + return { kind: 'toml', parsed: isRecord(parsed) ? parsed : {} }; + } catch { + return { kind: 'unreadable', path: tomlPath }; + } + } + + const jsonPath = sourceConfigJson(sourceHome); + let jsonText: string | undefined; + try { + jsonText = await readFile(jsonPath, 'utf-8'); + } catch (error) { + if (!isEnoent(error)) return { kind: 'unreadable', path: jsonPath }; + return { kind: 'missing' }; + } + try { + const parsed: unknown = JSON.parse(jsonText); + return { kind: 'json', parsed: isRecord(parsed) ? parsed : {} }; + } catch { + return { kind: 'unreadable', path: jsonPath }; + } +} diff --git a/packages/migration-legacy/src/steps/config.ts b/packages/migration-legacy/src/steps/config.ts index 2cc83ac66eb..da91c181939 100644 --- a/packages/migration-legacy/src/steps/config.ts +++ b/packages/migration-legacy/src/steps/config.ts @@ -1,17 +1,38 @@ -import { readFile, mkdir } from 'node:fs/promises'; +import { readFile, mkdir, writeFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'; import { - HookDefSchema, - KimiConfigSchema, - ModelAliasSchema, + ModelRecordSchema, ProviderConfigSchema, - transformTomlData, -} from '@moonshot-ai/agent-core'; -import { FLAG_DEFINITIONS } from '@moonshot-ai/agent-core/flags/registry'; + modelsFromToml, + providersFromToml, +} from '@moonshot-ai/agent-core-v2/app/kosongConfig/configSection'; +import { HookDefSchema } from '@moonshot-ai/agent-core-v2/features/externalHooks/configSection'; +import { getConfigSectionContributions } from '@moonshot-ai/agent-core-v2/app/config/configSectionContributions'; +import { getContributedFlags } from '@moonshot-ai/agent-core-v2/app/flag/flagRegistry'; +import { camelToSnake } from '@moonshot-ai/agent-core-v2/app/config/toml'; + +import '@moonshot-ai/agent-core-v2/agent/loop/configSection'; +import '@moonshot-ai/agent-core-v2/agent/task/configSection'; +import '@moonshot-ai/agent-core-v2/agent/permissionMode/configSection'; +import '@moonshot-ai/agent-core-v2/app/mcpConfig/configSection'; +import '@moonshot-ai/agent-core-v2/app/auth/configSection'; +import '@moonshot-ai/agent-core-v2/app/flag/flag'; +import '@moonshot-ai/agent-core-v2/features/skill/catalog/configSection'; + +import '@moonshot-ai/agent-core-v2/session/subagent/flag'; +import '@moonshot-ai/agent-core-v2/session/sessionTitle/flag'; +import '@moonshot-ai/agent-core-v2/persistence/backends/minidb/flag'; +import '@moonshot-ai/agent-core-v2/features/tower/flag'; +import '@moonshot-ai/agent-core-v2/app/remoteControl/flag'; +import '@moonshot-ai/agent-core-v2/agent/toolSelect/flag'; +import '@moonshot-ai/agent-core-v2/agent/tools/task/task-wait/flag'; + import { atomicWrite } from '../atomic-write.js'; import { DEFAULT_CONFIG_FILE_TEXT, isTuiStubOrMissing } from '../stub-detect.js'; +import { readSourceConfig } from '../source-config.js'; import { - sourceConfigToml, targetConfigFile, targetTuiFile, siblingConfigToml, @@ -22,7 +43,6 @@ import { const TUI_TOP_LEVEL_KEYS = new Set(['theme', 'default_editor']); const TOP_LEVEL_KEYS_TO_DROP = new Set(['plan_mode', 'yolo']); const LOOP_CONTROL_FIELDS_TO_KEEP = new Set([ - 'max_retries_per_step', 'reserved_context_size', ]); const BACKGROUND_FIELDS_TO_KEEP = new Set([ @@ -30,7 +50,7 @@ const BACKGROUND_FIELDS_TO_KEEP = new Set([ 'keep_alive_on_exit', ]); const REGISTERED_EXPERIMENTAL_FLAGS: ReadonlySet = new Set( - (FLAG_DEFINITIONS as ReadonlyArray<{ readonly id: string }>).map((definition) => definition.id), + getContributedFlags().map((definition) => definition.id), ); // kimi-code's tui.toml `theme` enum (mirrors apps/kimi-code TuiThemeSchema). @@ -38,19 +58,29 @@ const REGISTERED_EXPERIMENTAL_FLAGS: ReadonlySet = new Set( // validation, taking the migrated editor command down with it — so drop it. const TUI_THEMES: ReadonlySet = new Set(['dark', 'light', 'auto']); -function camelToSnake(s: string): string { - return s.replaceAll(/[A-Z]/g, (c) => `_${c.toLowerCase()}`); -} +const SUPPORTED_PROVIDER_TYPES: ReadonlySet = new Set([ + 'anthropic', + 'openai', + 'kimi', + 'google-genai', + 'openai_responses', + 'vertexai', +]); -// The config.toml top-level keys kimi-code understands, derived from the live -// KimiConfigSchema so the set tracks kimi-code automatically. `raw` is internal -// — never migrate it. `providers` / `models` / `hooks` are filtered per-entry, -// not via this set. -const SUPPORTED_TOP_LEVEL_KEYS: ReadonlySet = new Set( - Object.keys(KimiConfigSchema.shape) - .filter((k) => k !== 'raw' && k !== 'providers' && k !== 'models' && k !== 'hooks') +// The config.toml top-level keys kimi-code understands, derived from the v2 +// config-section registry so the set tracks the v2 runtime. `providers` / +// `models` / `hooks` are filtered per-entry, not via this set. `default_model` +// / `default_provider` are unregistered-but-preserved v2 keys (the v2 +// ConfigRegistry passes unregistered domains through unchanged), so they are +// kept explicitly. +const SUPPORTED_TOP_LEVEL_KEYS: ReadonlySet = new Set([ + ...getConfigSectionContributions() + .map((contribution) => contribution.domain) + .filter((d) => d !== 'providers' && d !== 'models' && d !== 'hooks') .map(camelToSnake), -); + 'default_model', + 'default_provider', +]); export interface ConfigStepInput { readonly sourceHome: string; @@ -77,6 +107,9 @@ export interface ConfigStepResult { readonly migratedHooks: number; /** Count of kimi-cli hook entries dropped because kimi-code's schema rejects them. */ readonly droppedHooks: number; + readonly sourceUnreadable: boolean; + /** Legacy `device_id` was copied because the target had none of its own. */ + readonly deviceIdCopied: boolean; /** * When sibling mode kicks in (`wroteSiblingDueToConflict === true`), the * content that landed in `config.migrated-from-kimi-cli.toml` instead of @@ -106,6 +139,8 @@ function emptyResult(): ConfigStepResult { wroteTuiSibling: false, migratedHooks: 0, droppedHooks: 0, + sourceUnreadable: false, + deviceIdCopied: false, siblingContents: { providers: [], models: [], hooks: 0 }, }; } @@ -127,18 +162,32 @@ function filterRegisteredExperimentalFlags( return keptEntries.length > 0 ? Object.fromEntries(keptEntries) : undefined; } -/** True when the kimi-cli provider entry validates against kimi-code's schema. */ +/** True when the kimi-cli provider entry validates against kimi-code's v2 schema + * and its (already type-mapped) `type` is one the kosong runtime can construct. */ function providerIsSupported(prov: Record): boolean { - const transformed = transformTomlData({ providers: { x: prov } }); - const entry = isRecord(transformed['providers']) ? transformed['providers']['x'] : undefined; - return ProviderConfigSchema.safeParse(entry).success; + const transformed = providersFromToml({ x: prov }); + const entry = isRecord(transformed) ? transformed['x'] : undefined; + if (entry === undefined) return false; + try { + ProviderConfigSchema.parse(entry); + } catch { + return false; + } + const type = isRecord(entry) ? entry['type'] : undefined; + return typeof type === 'string' && SUPPORTED_PROVIDER_TYPES.has(type); } -/** True when the kimi-cli model entry validates against kimi-code's schema. */ +/** True when the kimi-cli model entry validates against kimi-code's v2 schema. */ function modelIsSupported(mod: Record): boolean { - const transformed = transformTomlData({ models: { x: mod } }); - const entry = isRecord(transformed['models']) ? transformed['models']['x'] : undefined; - return ModelAliasSchema.safeParse(entry).success; + const transformed = modelsFromToml({ x: mod }); + const entry = isRecord(transformed) ? transformed['x'] : undefined; + if (entry === undefined) return false; + try { + ModelRecordSchema.parse(entry); + return true; + } catch { + return false; + } } /** Order-insensitive deep-equality key, so re-ordered tables are not conflicts. */ @@ -157,6 +206,34 @@ function deepEqual(a: unknown, b: unknown): boolean { return stableKey(a) === stableKey(b); } +const LEGACY_PROVIDER_TYPE_MAP: Readonly> = { + openai_legacy: 'openai', + google_genai: 'google-genai', + gemini: 'google-genai', +}; + +function mapLegacyProviderTypes(parsed: Record): Record { + const providers = parsed['providers']; + if (!isRecord(providers)) return parsed; + let changed = false; + const mapped: Record = {}; + for (const [name, prov] of Object.entries(providers)) { + if (!isRecord(prov)) { + mapped[name] = prov; + continue; + } + const mappedType = + typeof prov['type'] === 'string' ? LEGACY_PROVIDER_TYPE_MAP[prov['type']] : undefined; + if (mappedType === undefined) { + mapped[name] = prov; + continue; + } + mapped[name] = { ...prov, type: mappedType }; + changed = true; + } + return changed ? { ...parsed, providers: mapped } : parsed; +} + /** * Additively merge the kimi-cli config into the existing target config: add * keys/providers/models the target lacks, keep the target's value on a real @@ -191,22 +268,13 @@ function mergeConfig( } export async function migrateConfigStep(input: ConfigStepInput): Promise { - let oldText: string; - try { - oldText = await readFile(sourceConfigToml(input.sourceHome), 'utf-8'); - } catch { - return emptyResult(); - } - - let parsedRaw: unknown; - try { - parsedRaw = parseToml(oldText); - } catch { - // Malformed legacy config.toml: skip config migration rather than aborting - // the whole run. sessions/MCP/history still migrate. - return emptyResult(); + const source = await readSourceConfig(input.sourceHome); + if (source.kind === 'missing') return emptyResult(); + if (source.kind === 'unreadable') { + return { ...emptyResult(), sourceUnreadable: true }; } - const parsed: Record = isRecord(parsedRaw) ? parsedRaw : {}; + const deviceIdCopied = await copyDeviceId(input.sourceHome, input.targetHome); + const parsed: Record = mapLegacyProviderTypes(source.parsed); // Decide how the target config.toml is handled: a missing or pristine-stub // target is overwritten; a parseable user config is merged into; an @@ -300,6 +368,17 @@ export async function migrateConfigStep(input: ConfigStepInput): Promise 0) migratedTop['models'] = keptModels; if (keptHooks.length > 0) migratedTop['hooks'] = keptHooks; - // 4b) Drop any supported top-level key whose VALUE kimi-code's config - // schema rejects (e.g. `telemetry = "false"`, `extra_skill_dirs = "/tmp"`). - // Providers/models are already validated per-entry above, so schema - // failures here can only come from plain top-level keys. - for (;;) { - const result = KimiConfigSchema.safeParse(transformTomlData(migratedTop)); - if (result.success) break; - const badKeys = new Set(); - for (const issue of result.error.issues) { - const top = issue.path[0]; - if (typeof top === 'string' && top !== 'providers' && top !== 'models') { - badKeys.add(camelToSnake(top)); - } - } - if (badKeys.size === 0) break; // cannot attribute — stop rather than loop - for (const k of badKeys) { - if (k in migratedTop) { - delete migratedTop[k]; - droppedKeys.push(k); - } + // 4b) Drop any supported top-level key whose VALUE the v2 config section + // rejects (e.g. `merge_all_available_skills = "yes"`). Providers/models + // are already validated per-entry above, so section failures here can + // only come from plain top-level keys. Unregistered-but-preserved keys + // (default_model / default_provider) have no section schema and pass. + const sectionsBySnake = new Map( + getConfigSectionContributions().map((contribution) => [ + camelToSnake(contribution.domain), + contribution, + ]), + ); + for (const [k, v] of Object.entries(migratedTop)) { + if (k === 'providers' || k === 'models' || k === 'hooks') continue; + const section = sectionsBySnake.get(k); + if (section === undefined) continue; + const transformed = + section.options.fromToml === undefined ? v : section.options.fromToml(v); + try { + section.schema.parse(transformed); + } catch { + delete migratedTop[k]; + droppedKeys.push(k); } } @@ -482,6 +563,26 @@ export async function migrateConfigStep(input: ConfigStepInput): Promise { + const targetPath = join(targetHome, 'device_id'); + if (existsSync(targetPath)) return false; + let content: string; + try { + content = await readFile(join(sourceHome, 'device_id'), 'utf-8'); + } catch { + return false; + } + if (content.trim().length === 0) return false; + await mkdir(targetHome, { recursive: true, mode: 0o700 }); + await writeFile(targetPath, content, { mode: 0o600 }); + return true; +} diff --git a/packages/migration-legacy/src/steps/mcp.ts b/packages/migration-legacy/src/steps/mcp.ts index f75e557de56..2bf357763c2 100644 --- a/packages/migration-legacy/src/steps/mcp.ts +++ b/packages/migration-legacy/src/steps/mcp.ts @@ -1,6 +1,6 @@ import { readFile, mkdir } from 'node:fs/promises'; import { dirname } from 'node:path'; -import { McpServerConfigSchema } from '@moonshot-ai/agent-core'; +import { McpServerConfigSchema } from '@moonshot-ai/agent-core-v2/mcpCore/config-schema'; import { atomicWrite } from '../atomic-write.js'; import { siblingMcpJson, sourceMcpJson, targetMcpFile } from '../paths.js'; @@ -16,6 +16,25 @@ export interface McpStepResult { readonly droppedServers: readonly string[]; /** Target `mcp.json` existed but was unparseable; output went to a sibling. */ readonly wroteSiblingDueToConflict: boolean; + readonly sourceUnreadable: boolean; +} + +function emptyResult(sourceUnreadable: boolean): McpStepResult { + return { + mergedServers: [], + keptNewForConflicts: [], + droppedServers: [], + wroteSiblingDueToConflict: false, + sourceUnreadable, + }; +} + +function isEnoent(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + (error as { code?: unknown }).code === 'ENOENT' + ); } function isRecord(value: unknown): value is Record { @@ -26,15 +45,18 @@ export async function migrateMcpStep(input: McpStepInput): Promise = {}; if (isRecord(sourceJson)) { @@ -98,5 +120,5 @@ export async function migrateMcpStep(input: McpStepInput): Promise { + const srcDir = input.plansSourceDir ?? defaultPlansSourceDir(); + const tgtDir = join(input.targetHome, 'plans'); + + let entries: string[]; + try { + entries = await readdir(srcDir); + } catch { + return { copied: 0, skippedExisting: 0 }; + } + + let copied = 0; + let skippedExisting = 0; + let targetDirReady = false; + for (const name of entries) { + const srcPath = join(srcDir, name); + const tgtPath = join(tgtDir, name); + let st: Stats; + try { + st = await stat(srcPath); + } catch { + continue; + } + if (!st.isFile()) continue; + if (existsSync(tgtPath)) { + skippedExisting++; + continue; + } + if (!targetDirReady) { + await mkdir(tgtDir, { recursive: true, mode: 0o700 }); + targetDirReady = true; + } + // Copy atomically: a crash mid-copy leaves only the temp file, never a + // truncated final file that the next run would skip as complete. + const tmpPath = `${tgtPath}.${process.pid}.tmp`; + await copyFile(srcPath, tmpPath); + await rename(tmpPath, tgtPath); + copied++; + } + + return { copied, skippedExisting }; +} diff --git a/packages/migration-legacy/src/types.ts b/packages/migration-legacy/src/types.ts index 3a99109f1eb..88f68d62d36 100644 --- a/packages/migration-legacy/src/types.ts +++ b/packages/migration-legacy/src/types.ts @@ -7,11 +7,27 @@ export interface MigrationPlan { readonly hasConfig: boolean; readonly hasMcp: boolean; readonly hasUserHistory: boolean; - readonly oauthCredentials: readonly string[]; // basenames found under credentials/ + readonly hasSkills: boolean; + readonly skillsSourceHome?: string; + /** Legacy `~/.kimi/plans/` (share-dir independent) holds plan files to copy. */ + readonly hasPlans: boolean; + /** + * OAuth login names that will require a fresh `/login` after migration, + * derived from config semantics (providers carrying an `oauth` ref) unioned + * with basenames found under credentials/. + */ + readonly oauthCredentials: readonly string[]; readonly workdirs: readonly WorkDirEntry[]; readonly detectedPlugins: readonly string[]; readonly detectedMcpOauthServers: readonly string[]; readonly totalSessions: number; // sum across workdirs (real, post-classify) + /** + * Previously imported sessions under the target home whose wire still lacks + * turn-structure records (an old migrator wrote message-only imports). + * Filled by callers via `countImportedSessionsNeedingRepair`; a value > 0 + * means a completed marker must not suppress a repair run. + */ + readonly sessionsNeedingRepair?: number; /** * Session storage that detection could see but could not safely inspect. * Optional for callers that persisted or constructed an older plan shape. @@ -88,6 +104,9 @@ export interface MigrationSummary { readonly migratedHooks: number; /** Count of kimi-cli hook entries dropped because kimi-code's schema rejects them. */ readonly droppedHooks: number; + readonly sourceUnreadable: boolean; + /** Legacy `device_id` was copied because the target had none of its own. */ + readonly deviceIdCopied: boolean; /** * When `wroteSiblingDueToConflict` is true, what landed in * `config.migrated-from-kimi-cli.toml` instead of the live `config.toml`. @@ -107,9 +126,11 @@ export interface MigrationSummary { readonly droppedServers: readonly string[]; /** Target `mcp.json` was unparseable; merged servers went to a sibling. */ readonly wroteSiblingDueToConflict: boolean; + readonly sourceUnreadable: boolean; }; readonly userHistory: { readonly copied: number; readonly skippedExisting: number }; readonly skills: { readonly copied: number; readonly skippedExisting: number }; + readonly plans: { readonly copied: number; readonly skippedExisting: number }; readonly sessions: SessionsSummary; } @@ -122,6 +143,11 @@ export interface SessionsSummary { readonly sessionsMigrated: number; /** Sessions already imported by a previous run (idempotent re-run). */ readonly sessionsAlreadyMigrated: number; + /** + * Previously imported sessions whose wire was repaired in place on this run + * (turn-structure records inserted into an old message-only import). + */ + readonly sessionsRepaired: number; readonly sessionsSkippedPlaceholder: number; readonly sessionsSkippedEmpty: number; readonly sessionsSkippedMalformed: number; @@ -132,7 +158,8 @@ export interface SessionsSummary { export interface MigrationNotices { readonly mcpOauthServersRequiringReauth: readonly string[]; /** - * Basenames of kimi-cli OAuth logins (`~/.kimi/credentials/.json`) + * Names of kimi-cli OAuth logins requiring re-login, derived from the legacy + * config's `oauth` provider refs plus any `~/.kimi/credentials/.json` * found at detection time. OAuth credentials are deliberately NOT migrated: * refresh tokens rotate server-side, so a copied credential breaks login for * whichever install refreshes second. The user must run `/login` in @@ -142,4 +169,9 @@ export interface MigrationNotices { readonly detectedPlugins: readonly string[]; readonly configConflictNotice: string | null; readonly tuiConflictNotice: string | null; + /** + * Tells the user that legacy plan files were copied as plain files (no + * plan-mode wiring) and where to find them. Null when nothing was copied. + */ + readonly plansCopiedNotice: string | null; } diff --git a/packages/migration-legacy/test/detect.test.ts b/packages/migration-legacy/test/detect.test.ts index 355be992c0e..a3d87455842 100644 --- a/packages/migration-legacy/test/detect.test.ts +++ b/packages/migration-legacy/test/detect.test.ts @@ -23,21 +23,80 @@ describe('detectMigration', () => { it('detects config/mcp/credentials/user-history/plugins/mcp-oauth presence', async () => { await writeFile(join(src, 'config.toml'), ''); - await writeFile(join(src, 'mcp.json'), '{"mcpServers":{}}'); + await writeFile( + join(src, 'mcp.json'), + '{"mcpServers":{"server-1":{"url":"https://example.test/mcp","auth":"oauth"},"server-2":{"command":"npx"}}}', + ); await mkdir(join(src, 'credentials'), { recursive: true }); await writeFile(join(src, 'credentials', 'kimi-code.json'), '{}'); await mkdir(join(src, 'user-history'), { recursive: true }); await mkdir(join(src, 'plugins', 'p1'), { recursive: true }); await mkdir(join(src, 'mcp-oauth'), { recursive: true }); - await writeFile(join(src, 'mcp-oauth', 'server-1'), ''); + await writeFile(join(src, 'mcp-oauth', 'mangled-store-entry'), ''); const plan = await detectMigration({ sourcePath: src }); expect(plan.hasConfig).toBe(true); expect(plan.hasMcp).toBe(true); expect(plan.hasUserHistory).toBe(true); - expect(plan.oauthCredentials).toEqual(['kimi-code.json']); + expect(plan.oauthCredentials).toEqual(['kimi-code']); expect(plan.detectedPlugins).toEqual(['p1']); - expect(plan.detectedMcpOauthServers).toContain('server-1'); + expect(plan.detectedMcpOauthServers).toEqual(['server-1']); + }); + + it('derives OAuth relogin notices from config oauth refs even without credential files', async () => { + await writeFile( + join(src, 'config.toml'), + '[providers."managed:kimi-code"]\ntype = "kimi"\nbase_url = "https://api.example.test/v1"\n\n[providers."managed:kimi-code".oauth]\nstorage = "keyring"\nkey = "oauth/kimi-code"\n', + ); + + const plan = await detectMigration({ sourcePath: src }); + expect(plan.oauthCredentials).toEqual(['kimi-code']); + }); + + it('treats a config.json-only source as having config', async () => { + await writeFile(join(src, 'config.json'), '{"default_model":"m"}'); + + const plan = await detectMigration({ sourcePath: src }); + expect(plan.hasConfig).toBe(true); + }); + + it('counts historical flat sessions and title-only sessions', async () => { + const workdir = '/workspace/flat-proj'; + const bucket = join(src, 'sessions', oldMd5BucketName(workdir)); + await mkdir(join(bucket, 'titled'), { recursive: true }); + await writeFile(join(src, 'kimi.json'), JSON.stringify({ work_dirs: [{ path: workdir }] })); + await writeFile(join(bucket, 'flat-1.jsonl'), '{"role":"user","content":"hi"}\n'); + await writeFile(join(bucket, 'titled', 'context.jsonl'), ''); + await writeFile( + join(bucket, 'titled', 'state.json'), + JSON.stringify({ custom_title: 'Named' }), + ); + + const plan = await detectMigration({ sourcePath: src }); + expect(plan.totalSessions).toBe(2); + expect(plan.sessionScanFailures).toEqual([]); + expect(plan.workdirs[0]?.sessions.map((s) => s.uuid).sort()).toEqual(['flat-1', 'titled']); + }); + + it('detects a skills-only source', async () => { + await mkdir(join(src, 'skills', 'my-skill'), { recursive: true }); + await writeFile(join(src, 'skills', 'my-skill', 'SKILL.md'), '# skill'); + + const plan = await detectMigration({ sourcePath: src }); + expect(plan.hasSkills).toBe(true); + }); + + it('detects legacy plan files via the injectable plans source dir', async () => { + const plansDir = await mkdtemp(join(tmpdir(), 'detect-plans-')); + try { + await writeFile(join(plansDir, 'hero.md'), '# plan'); + const plan = await detectMigration({ sourcePath: src, plansSourcePath: plansDir }); + expect(plan.hasPlans).toBe(true); + const empty = await detectMigration({ sourcePath: src, plansSourcePath: join(plansDir, 'nope') }); + expect(empty.hasPlans).toBe(false); + } finally { + await rm(plansDir, { recursive: true, force: true }); + } }); it('reports an unknown workdir bucket when kimi.json cannot map it', async () => { diff --git a/packages/migration-legacy/test/fixtures/gen/generate_fixtures.py b/packages/migration-legacy/test/fixtures/gen/generate_fixtures.py new file mode 100644 index 00000000000..55aa085ded3 --- /dev/null +++ b/packages/migration-legacy/test/fixtures/gen/generate_fixtures.py @@ -0,0 +1,372 @@ +"""Regenerate the golden migration fixtures using kimi-cli's real serializers. + +The committed fixtures under `../golden/` are produced by THIS script against a +kimi-cli checkout, so the migration tests exercise the exact byte shapes the +old CLI writes (config via `save_config`, sessions via kosong `Message` / +`save_session_state`, metadata via `Metadata`), plus the historical formats +kimi-cli itself still upgrades (`config.json`, flat `.jsonl` sessions, +`metadata.json`, title-only sessions). + +Usage (from the kimi-code repo): + + cd ../../../kimi-cli && uv run python \ + /path/to/kimi-code/packages/migration-legacy/test/fixtures/gen/generate_fixtures.py + +`--out` defaults to the sibling `golden/` directory next to this script. +Re-run and commit the result whenever kimi-cli's on-disk formats change. +""" + +import argparse +import json +from hashlib import md5 +from pathlib import Path + +from kimi_cli.config import ( + Config, + LLMModel, + LLMProvider, + LoopControl, + OAuthRef, + save_config, +) +from kimi_cli.hooks.config import HookDef +from kimi_cli.metadata import Metadata, WorkDirMeta +from kimi_cli.session_state import SessionState, TodoItemState, save_session_state +from kosong.message import ( + AudioURLPart, + ImageURLPart, + Message, + TextPart, + ThinkPart, + ToolCall, + VideoURLPart, +) + +WORK_DIR = "/work/golden-proj" +REMOTE_WORK_DIR = "/remote/golden-proj" + +UUID_RICH = "11111111-aaaa-4bbb-8ccc-111111111111" +UUID_METADATA = "22222222-aaaa-4bbb-8ccc-222222222222" +UUID_TITLE_ONLY = "33333333-aaaa-4bbb-8ccc-333333333333" +UUID_FLAT = "44444444-aaaa-4bbb-8ccc-444444444444" +UUID_REMOTE = "55555555-aaaa-4bbb-8ccc-555555555555" + +WIRE_MTIME = 1_735_689_600.0 + + +def msg(message: Message) -> str: + return message.model_dump_json(exclude_none=True) + + +def marker(payload: dict) -> str: + return json.dumps(payload, ensure_ascii=False) + + +def write_lines(path: Path, lines: list[str]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def build_config() -> Config: + return Config( + default_model="golden-main", + theme="dark", + default_editor="code --wait", + merge_all_available_skills=True, + extra_skill_dirs=["/work/extra-skills"], + providers={ + "managed:kimi-code": LLMProvider( + type="kimi", + base_url="https://api.example.test/coding/v1", + api_key="sk-golden-managed", + oauth=OAuthRef(storage="file", key="oauth/kimi-code"), + ), + "vllm": LLMProvider( + type="openai_legacy", + base_url="https://vllm.example.test/v1", + api_key="EMPTY", + reasoning_key="reasoning", + ), + "genai": LLMProvider( + type="google_genai", + base_url="https://genai.example.test/v1beta", + api_key="sk-golden-genai", + ), + "vx": LLMProvider( + type="vertexai", + base_url="https://vx.example.test/v1", + api_key="sk-golden-vx", + ), + }, + models={ + "golden-main": LLMModel( + provider="managed:kimi-code", + model="kimi-for-coding", + max_context_size=262144, + capabilities={"image_in", "thinking"}, + ), + "vllm-mooncake": LLMModel( + provider="vllm", + model="mooncake-v1", + max_context_size=131072, + ), + }, + loop_control=LoopControl(max_retries_per_step=5, reserved_context_size=60000), + hooks=[HookDef(event="PreToolUse", command="echo golden-hook", matcher="Shell")], + telemetry=True, + ) + + +def write_session_dir( + session_dir: Path, + context_lines: list[str], + wire_lines: list[str] | None, + state: SessionState, +) -> None: + write_lines(session_dir / "context.jsonl", context_lines) + if wire_lines is not None: + write_lines(session_dir / "wire.jsonl", wire_lines) + session_dir.mkdir(parents=True, exist_ok=True) + save_session_state(state, session_dir) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--out", type=Path, default=Path(__file__).resolve().parent.parent / "golden") + args = parser.parse_args() + out: Path = args.out + home = out / ".kimi" + home.mkdir(parents=True, exist_ok=True) + + save_config(build_config(), config_file=home / "config.toml") + + (home / "mcp.json").write_text( + json.dumps( + { + "mcpServers": { + "golden-stdio": { + "command": "npx", + "args": ["golden-mcp@latest"], + "env": {"GOLDEN_KEY": "golden-value"}, + }, + "golden-remote": { + "url": "https://mcp.example.test/mcp", + "transport": "http", + "auth": "oauth", + }, + } + }, + indent=2, + ensure_ascii=False, + ), + encoding="utf-8", + ) + + metadata = Metadata( + work_dirs=[ + WorkDirMeta(path=WORK_DIR, last_session_id=UUID_RICH), + WorkDirMeta(path=REMOTE_WORK_DIR, kaos="ssh"), + ] + ) + (home / "kimi.json").write_text( + json.dumps(metadata.model_dump(mode="json"), indent=2, ensure_ascii=False), + encoding="utf-8", + ) + + history_dir = home / "user-history" + write_lines( + history_dir / f"{md5(WORK_DIR.encode()).hexdigest()}.jsonl", + [marker({"content": "ls -la"}), marker({"content": "git status"})], + ) + + skill = home / "skills" / "golden-skill" + skill.mkdir(parents=True, exist_ok=True) + (skill / "SKILL.md").write_text( + "---\nname: golden-skill\ndescription: Golden fixture skill\n---\n\nBody.\n", + encoding="utf-8", + ) + + credentials = home / "credentials" + credentials.mkdir(parents=True, exist_ok=True) + (credentials / "kimi-code.json").write_text( + json.dumps( + { + "access_token": "golden-access", + "refresh_token": "golden-refresh", + "expires_at": 1_735_000_000.0, + "expires_in": 3600.0, + }, + indent=2, + ), + encoding="utf-8", + ) + mcp_oauth = home / "mcp-oauth" + mcp_oauth.mkdir(parents=True, exist_ok=True) + (mcp_oauth / "mangled-store-entry").write_text("{}", encoding="utf-8") + + bucket = home / "sessions" / md5(WORK_DIR.encode()).hexdigest() + + rich_context = [ + marker({"role": "_system_prompt", "content": "You are an AI agent."}), + msg( + Message( + role="user", + content=[ + TextPart(text="Look at these attachments."), + ImageURLPart( + image_url=ImageURLPart.ImageURL( + url="data:image/png;base64,iVBORw0KGgo=", id="img-1" + ) + ), + AudioURLPart( + audio_url=AudioURLPart.AudioURL(url="data:audio/wav;base64,UklGRg==") + ), + VideoURLPart( + video_url=VideoURLPart.VideoURL( + url="https://media.example.test/v.mp4", id="vid-1" + ) + ), + ], + ) + ), + marker({"role": "_checkpoint", "id": 1}), + msg( + Message( + role="assistant", + content=[ + ThinkPart(think="thinking about the attachments"), + TextPart(text="The diagram shows a pipeline."), + ], + tool_calls=[ + ToolCall( + id="call-1", + function=ToolCall.FunctionBody( + name="Shell", arguments='{"command": "ls -la"}' + ), + ) + ], + ) + ), + msg( + Message( + role="tool", + content=[TextPart(text="total 0")], + tool_call_id="call-1", + ) + ), + msg(Message(role="assistant", content=[TextPart(text="Done reviewing.")])), + marker({"role": "_usage", "token_count": 42}), + ] + rich_wire = [ + marker({"type": "metadata", "protocol_version": "1.10"}), + marker( + { + "timestamp": WIRE_MTIME, + "message": { + "type": "ToolResult", + "payload": { + "tool_call_id": "call-1", + "return_value": { + "is_error": False, + "output": "total 0", + "message": "", + "display": [ + {"type": "shell", "command": "ls -la", "language": "bash"} + ], + }, + }, + }, + } + ), + ] + write_session_dir( + bucket / UUID_RICH, + rich_context, + rich_wire, + SessionState( + custom_title="Golden rich session", + title_generated=False, + wire_mtime=WIRE_MTIME, + additional_dirs=["/work/golden-extra"], + todos=[TodoItemState(title="follow up on the review", status="pending")], + ), + ) + + metadata_session_dir = bucket / UUID_METADATA + write_session_dir( + metadata_session_dir, + [msg(Message(role="user", content=[TextPart(text="session with legacy metadata")]))], + None, + SessionState(), + ) + (metadata_session_dir / "metadata.json").write_text( + json.dumps( + { + "session_id": UUID_METADATA, + "title": "Golden legacy title", + "title_generated": True, + "title_generate_attempts": 2, + "wire_mtime": WIRE_MTIME - 100, + "archived": True, + "archived_at": WIRE_MTIME + 100, + "auto_archive_exempt": True, + }, + indent=2, + ), + encoding="utf-8", + ) + + title_only_dir = bucket / UUID_TITLE_ONLY + title_only_dir.mkdir(parents=True, exist_ok=True) + (title_only_dir / "context.jsonl").write_text("", encoding="utf-8") + save_session_state( + SessionState(custom_title="Golden title only", title_generated=False), + title_only_dir, + ) + + write_lines( + bucket / f"{UUID_FLAT}.jsonl", + [ + msg(Message(role="user", content=[TextPart(text="flat era hello")])), + msg(Message(role="assistant", content=[TextPart(text="flat era answer")])), + ], + ) + + remote_bucket = ( + home / "sessions" / f"ssh_{md5(REMOTE_WORK_DIR.encode()).hexdigest()}" + ) + write_session_dir( + remote_bucket / UUID_REMOTE, + [msg(Message(role="user", content=[TextPart(text="remote session")]))], + None, + SessionState(), + ) + + historical = out / ".kimi-historical-config" + historical.mkdir(parents=True, exist_ok=True) + save_config( + Config( + default_model="historical-main", + providers={ + "vllm": LLMProvider( + type="openai_legacy", + base_url="https://vllm.example.test/v1", + api_key="EMPTY", + ), + }, + models={ + "historical-main": LLMModel( + provider="vllm", + model="mooncake-v1", + max_context_size=131072, + ), + }, + ), + config_file=historical / "config.json", + ) + + print(f"golden fixtures written to {out}") + + +if __name__ == "__main__": + main() diff --git a/packages/migration-legacy/test/fixtures/golden/.kimi-historical-config/config.json b/packages/migration-legacy/test/fixtures/golden/.kimi-historical-config/config.json new file mode 100644 index 00000000000..d973249e4f2 --- /dev/null +++ b/packages/migration-legacy/test/fixtures/golden/.kimi-historical-config/config.json @@ -0,0 +1,57 @@ +{ + "default_model": "historical-main", + "default_thinking": false, + "default_yolo": false, + "skip_afk_prompt_injection": false, + "default_plan_mode": false, + "default_editor": "", + "theme": "dark", + "show_thinking_stream": true, + "models": { + "historical-main": { + "provider": "vllm", + "model": "mooncake-v1", + "max_context_size": 131072 + } + }, + "providers": { + "vllm": { + "type": "openai_legacy", + "base_url": "https://vllm.example.test/v1", + "api_key": "EMPTY" + } + }, + "loop_control": { + "max_steps_per_turn": 1000, + "max_retries_per_step": 3, + "max_ralph_iterations": 0, + "reserved_context_size": 50000, + "compaction_trigger_ratio": 0.85 + }, + "background": { + "max_running_tasks": 4, + "read_max_bytes": 30000, + "notification_tail_lines": 20, + "notification_tail_chars": 3000, + "wait_poll_interval_ms": 500, + "worker_heartbeat_interval_ms": 5000, + "worker_stale_after_ms": 15000, + "kill_grace_period_ms": 2000, + "keep_alive_on_exit": false, + "agent_task_timeout_s": 900, + "print_wait_ceiling_s": 3600 + }, + "notifications": { + "claim_stale_after_ms": 15000 + }, + "services": {}, + "mcp": { + "client": { + "tool_call_timeout_ms": 60000 + } + }, + "hooks": [], + "merge_all_available_skills": true, + "extra_skill_dirs": [], + "telemetry": true +} \ No newline at end of file diff --git a/packages/migration-legacy/test/fixtures/golden/.kimi/config.toml b/packages/migration-legacy/test/fixtures/golden/.kimi/config.toml new file mode 100644 index 00000000000..9523ab1cfc9 --- /dev/null +++ b/packages/migration-legacy/test/fixtures/golden/.kimi/config.toml @@ -0,0 +1,81 @@ +default_model = "golden-main" +default_thinking = false +default_yolo = false +skip_afk_prompt_injection = false +default_plan_mode = false +default_editor = "code --wait" +theme = "dark" +show_thinking_stream = true +merge_all_available_skills = true +extra_skill_dirs = ["/work/extra-skills"] +telemetry = true + +[models.golden-main] +provider = "managed:kimi-code" +model = "kimi-for-coding" +max_context_size = 262144 +capabilities = ["thinking", "image_in"] + +[models.vllm-mooncake] +provider = "vllm" +model = "mooncake-v1" +max_context_size = 131072 + +[providers."managed:kimi-code"] +type = "kimi" +base_url = "https://api.example.test/coding/v1" +api_key = "sk-golden-managed" + +[providers."managed:kimi-code".oauth] +storage = "file" +key = "oauth/kimi-code" + +[providers.vllm] +type = "openai_legacy" +base_url = "https://vllm.example.test/v1" +api_key = "EMPTY" +reasoning_key = "reasoning" + +[providers.genai] +type = "google_genai" +base_url = "https://genai.example.test/v1beta" +api_key = "sk-golden-genai" + +[providers.vx] +type = "vertexai" +base_url = "https://vx.example.test/v1" +api_key = "sk-golden-vx" + +[loop_control] +max_steps_per_turn = 1000 +max_retries_per_step = 5 +max_ralph_iterations = 0 +reserved_context_size = 60000 +compaction_trigger_ratio = 0.85 + +[background] +max_running_tasks = 4 +read_max_bytes = 30000 +notification_tail_lines = 20 +notification_tail_chars = 3000 +wait_poll_interval_ms = 500 +worker_heartbeat_interval_ms = 5000 +worker_stale_after_ms = 15000 +kill_grace_period_ms = 2000 +keep_alive_on_exit = false +agent_task_timeout_s = 900 +print_wait_ceiling_s = 3600 + +[notifications] +claim_stale_after_ms = 15000 + +[services] + +[mcp.client] +tool_call_timeout_ms = 60000 + +[[hooks]] +event = "PreToolUse" +command = "echo golden-hook" +matcher = "Shell" +timeout = 30 diff --git a/packages/migration-legacy/test/fixtures/golden/.kimi/credentials/kimi-code.json b/packages/migration-legacy/test/fixtures/golden/.kimi/credentials/kimi-code.json new file mode 100644 index 00000000000..2ee0bf2ff5c --- /dev/null +++ b/packages/migration-legacy/test/fixtures/golden/.kimi/credentials/kimi-code.json @@ -0,0 +1,6 @@ +{ + "access_token": "golden-access", + "refresh_token": "golden-refresh", + "expires_at": 1735000000.0, + "expires_in": 3600.0 +} \ No newline at end of file diff --git a/packages/migration-legacy/test/fixtures/golden/.kimi/kimi.json b/packages/migration-legacy/test/fixtures/golden/.kimi/kimi.json new file mode 100644 index 00000000000..963ee1432d6 --- /dev/null +++ b/packages/migration-legacy/test/fixtures/golden/.kimi/kimi.json @@ -0,0 +1,14 @@ +{ + "work_dirs": [ + { + "path": "/work/golden-proj", + "kaos": "local", + "last_session_id": "11111111-aaaa-4bbb-8ccc-111111111111" + }, + { + "path": "/remote/golden-proj", + "kaos": "ssh", + "last_session_id": null + } + ] +} \ No newline at end of file diff --git a/packages/migration-legacy/test/fixtures/golden/.kimi/mcp-oauth/mangled-store-entry b/packages/migration-legacy/test/fixtures/golden/.kimi/mcp-oauth/mangled-store-entry new file mode 100644 index 00000000000..9e26dfeeb6e --- /dev/null +++ b/packages/migration-legacy/test/fixtures/golden/.kimi/mcp-oauth/mangled-store-entry @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/packages/migration-legacy/test/fixtures/golden/.kimi/mcp.json b/packages/migration-legacy/test/fixtures/golden/.kimi/mcp.json new file mode 100644 index 00000000000..5da8152bc27 --- /dev/null +++ b/packages/migration-legacy/test/fixtures/golden/.kimi/mcp.json @@ -0,0 +1,18 @@ +{ + "mcpServers": { + "golden-stdio": { + "command": "npx", + "args": [ + "golden-mcp@latest" + ], + "env": { + "GOLDEN_KEY": "golden-value" + } + }, + "golden-remote": { + "url": "https://mcp.example.test/mcp", + "transport": "http", + "auth": "oauth" + } + } +} \ No newline at end of file diff --git a/packages/migration-legacy/test/fixtures/golden/.kimi/sessions/c1c0997494d4df46ac89f99f813c077c/11111111-aaaa-4bbb-8ccc-111111111111/context.jsonl b/packages/migration-legacy/test/fixtures/golden/.kimi/sessions/c1c0997494d4df46ac89f99f813c077c/11111111-aaaa-4bbb-8ccc-111111111111/context.jsonl new file mode 100644 index 00000000000..0be71bb5a47 --- /dev/null +++ b/packages/migration-legacy/test/fixtures/golden/.kimi/sessions/c1c0997494d4df46ac89f99f813c077c/11111111-aaaa-4bbb-8ccc-111111111111/context.jsonl @@ -0,0 +1,7 @@ +{"role": "_system_prompt", "content": "You are an AI agent."} +{"role":"user","content":[{"type":"text","text":"Look at these attachments."},{"type":"image_url","image_url":{"url":"data:image/png;base64,iVBORw0KGgo=","id":"img-1"}},{"type":"audio_url","audio_url":{"url":"data:audio/wav;base64,UklGRg==","id":null}},{"type":"video_url","video_url":{"url":"https://media.example.test/v.mp4","id":"vid-1"}}]} +{"role": "_checkpoint", "id": 1} +{"role":"assistant","content":[{"type":"think","think":"thinking about the attachments","encrypted":null},{"type":"text","text":"The diagram shows a pipeline."}],"tool_calls":[{"type":"function","id":"call-1","function":{"name":"Shell","arguments":"{\"command\": \"ls -la\"}"}}]} +{"role":"tool","content":"total 0","tool_call_id":"call-1"} +{"role":"assistant","content":"Done reviewing."} +{"role": "_usage", "token_count": 42} diff --git a/packages/migration-legacy/test/fixtures/golden/.kimi/sessions/c1c0997494d4df46ac89f99f813c077c/11111111-aaaa-4bbb-8ccc-111111111111/state.json b/packages/migration-legacy/test/fixtures/golden/.kimi/sessions/c1c0997494d4df46ac89f99f813c077c/11111111-aaaa-4bbb-8ccc-111111111111/state.json new file mode 100644 index 00000000000..c6e95d58eb2 --- /dev/null +++ b/packages/migration-legacy/test/fixtures/golden/.kimi/sessions/c1c0997494d4df46ac89f99f813c077c/11111111-aaaa-4bbb-8ccc-111111111111/state.json @@ -0,0 +1,27 @@ +{ + "version": 1, + "approval": { + "yolo": false, + "afk": false, + "auto_approve_actions": [] + }, + "additional_dirs": [ + "/work/golden-extra" + ], + "custom_title": "Golden rich session", + "title_generated": false, + "title_generate_attempts": 0, + "plan_mode": false, + "plan_session_id": null, + "plan_slug": null, + "wire_mtime": 1735689600.0, + "archived": false, + "archived_at": null, + "auto_archive_exempt": false, + "todos": [ + { + "title": "follow up on the review", + "status": "pending" + } + ] +} \ No newline at end of file diff --git a/packages/migration-legacy/test/fixtures/golden/.kimi/sessions/c1c0997494d4df46ac89f99f813c077c/11111111-aaaa-4bbb-8ccc-111111111111/wire.jsonl b/packages/migration-legacy/test/fixtures/golden/.kimi/sessions/c1c0997494d4df46ac89f99f813c077c/11111111-aaaa-4bbb-8ccc-111111111111/wire.jsonl new file mode 100644 index 00000000000..46243716842 --- /dev/null +++ b/packages/migration-legacy/test/fixtures/golden/.kimi/sessions/c1c0997494d4df46ac89f99f813c077c/11111111-aaaa-4bbb-8ccc-111111111111/wire.jsonl @@ -0,0 +1,2 @@ +{"type": "metadata", "protocol_version": "1.10"} +{"timestamp": 1735689600.0, "message": {"type": "ToolResult", "payload": {"tool_call_id": "call-1", "return_value": {"is_error": false, "output": "total 0", "message": "", "display": [{"type": "shell", "command": "ls -la", "language": "bash"}]}}}} diff --git a/packages/migration-legacy/test/fixtures/golden/.kimi/sessions/c1c0997494d4df46ac89f99f813c077c/22222222-aaaa-4bbb-8ccc-222222222222/context.jsonl b/packages/migration-legacy/test/fixtures/golden/.kimi/sessions/c1c0997494d4df46ac89f99f813c077c/22222222-aaaa-4bbb-8ccc-222222222222/context.jsonl new file mode 100644 index 00000000000..8c47ffd95cf --- /dev/null +++ b/packages/migration-legacy/test/fixtures/golden/.kimi/sessions/c1c0997494d4df46ac89f99f813c077c/22222222-aaaa-4bbb-8ccc-222222222222/context.jsonl @@ -0,0 +1 @@ +{"role":"user","content":"session with legacy metadata"} diff --git a/packages/migration-legacy/test/fixtures/golden/.kimi/sessions/c1c0997494d4df46ac89f99f813c077c/22222222-aaaa-4bbb-8ccc-222222222222/metadata.json b/packages/migration-legacy/test/fixtures/golden/.kimi/sessions/c1c0997494d4df46ac89f99f813c077c/22222222-aaaa-4bbb-8ccc-222222222222/metadata.json new file mode 100644 index 00000000000..1f695c246d5 --- /dev/null +++ b/packages/migration-legacy/test/fixtures/golden/.kimi/sessions/c1c0997494d4df46ac89f99f813c077c/22222222-aaaa-4bbb-8ccc-222222222222/metadata.json @@ -0,0 +1,10 @@ +{ + "session_id": "22222222-aaaa-4bbb-8ccc-222222222222", + "title": "Golden legacy title", + "title_generated": true, + "title_generate_attempts": 2, + "wire_mtime": 1735689500.0, + "archived": true, + "archived_at": 1735689700.0, + "auto_archive_exempt": true +} \ No newline at end of file diff --git a/packages/migration-legacy/test/fixtures/golden/.kimi/sessions/c1c0997494d4df46ac89f99f813c077c/22222222-aaaa-4bbb-8ccc-222222222222/state.json b/packages/migration-legacy/test/fixtures/golden/.kimi/sessions/c1c0997494d4df46ac89f99f813c077c/22222222-aaaa-4bbb-8ccc-222222222222/state.json new file mode 100644 index 00000000000..dbe86587311 --- /dev/null +++ b/packages/migration-legacy/test/fixtures/golden/.kimi/sessions/c1c0997494d4df46ac89f99f813c077c/22222222-aaaa-4bbb-8ccc-222222222222/state.json @@ -0,0 +1,20 @@ +{ + "version": 1, + "approval": { + "yolo": false, + "afk": false, + "auto_approve_actions": [] + }, + "additional_dirs": [], + "custom_title": null, + "title_generated": false, + "title_generate_attempts": 0, + "plan_mode": false, + "plan_session_id": null, + "plan_slug": null, + "wire_mtime": null, + "archived": false, + "archived_at": null, + "auto_archive_exempt": false, + "todos": [] +} \ No newline at end of file diff --git a/packages/migration-legacy/test/fixtures/golden/.kimi/sessions/c1c0997494d4df46ac89f99f813c077c/33333333-aaaa-4bbb-8ccc-333333333333/context.jsonl b/packages/migration-legacy/test/fixtures/golden/.kimi/sessions/c1c0997494d4df46ac89f99f813c077c/33333333-aaaa-4bbb-8ccc-333333333333/context.jsonl new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/migration-legacy/test/fixtures/golden/.kimi/sessions/c1c0997494d4df46ac89f99f813c077c/33333333-aaaa-4bbb-8ccc-333333333333/state.json b/packages/migration-legacy/test/fixtures/golden/.kimi/sessions/c1c0997494d4df46ac89f99f813c077c/33333333-aaaa-4bbb-8ccc-333333333333/state.json new file mode 100644 index 00000000000..584e2fbb838 --- /dev/null +++ b/packages/migration-legacy/test/fixtures/golden/.kimi/sessions/c1c0997494d4df46ac89f99f813c077c/33333333-aaaa-4bbb-8ccc-333333333333/state.json @@ -0,0 +1,20 @@ +{ + "version": 1, + "approval": { + "yolo": false, + "afk": false, + "auto_approve_actions": [] + }, + "additional_dirs": [], + "custom_title": "Golden title only", + "title_generated": false, + "title_generate_attempts": 0, + "plan_mode": false, + "plan_session_id": null, + "plan_slug": null, + "wire_mtime": null, + "archived": false, + "archived_at": null, + "auto_archive_exempt": false, + "todos": [] +} \ No newline at end of file diff --git a/packages/migration-legacy/test/fixtures/golden/.kimi/sessions/c1c0997494d4df46ac89f99f813c077c/44444444-aaaa-4bbb-8ccc-444444444444.jsonl b/packages/migration-legacy/test/fixtures/golden/.kimi/sessions/c1c0997494d4df46ac89f99f813c077c/44444444-aaaa-4bbb-8ccc-444444444444.jsonl new file mode 100644 index 00000000000..41d73a3a21b --- /dev/null +++ b/packages/migration-legacy/test/fixtures/golden/.kimi/sessions/c1c0997494d4df46ac89f99f813c077c/44444444-aaaa-4bbb-8ccc-444444444444.jsonl @@ -0,0 +1,2 @@ +{"role":"user","content":"flat era hello"} +{"role":"assistant","content":"flat era answer"} diff --git a/packages/migration-legacy/test/fixtures/golden/.kimi/sessions/ssh_cc0fc5445bfc0662b9c89cf7c6896ebb/55555555-aaaa-4bbb-8ccc-555555555555/context.jsonl b/packages/migration-legacy/test/fixtures/golden/.kimi/sessions/ssh_cc0fc5445bfc0662b9c89cf7c6896ebb/55555555-aaaa-4bbb-8ccc-555555555555/context.jsonl new file mode 100644 index 00000000000..ff360319083 --- /dev/null +++ b/packages/migration-legacy/test/fixtures/golden/.kimi/sessions/ssh_cc0fc5445bfc0662b9c89cf7c6896ebb/55555555-aaaa-4bbb-8ccc-555555555555/context.jsonl @@ -0,0 +1 @@ +{"role":"user","content":"remote session"} diff --git a/packages/migration-legacy/test/fixtures/golden/.kimi/sessions/ssh_cc0fc5445bfc0662b9c89cf7c6896ebb/55555555-aaaa-4bbb-8ccc-555555555555/state.json b/packages/migration-legacy/test/fixtures/golden/.kimi/sessions/ssh_cc0fc5445bfc0662b9c89cf7c6896ebb/55555555-aaaa-4bbb-8ccc-555555555555/state.json new file mode 100644 index 00000000000..dbe86587311 --- /dev/null +++ b/packages/migration-legacy/test/fixtures/golden/.kimi/sessions/ssh_cc0fc5445bfc0662b9c89cf7c6896ebb/55555555-aaaa-4bbb-8ccc-555555555555/state.json @@ -0,0 +1,20 @@ +{ + "version": 1, + "approval": { + "yolo": false, + "afk": false, + "auto_approve_actions": [] + }, + "additional_dirs": [], + "custom_title": null, + "title_generated": false, + "title_generate_attempts": 0, + "plan_mode": false, + "plan_session_id": null, + "plan_slug": null, + "wire_mtime": null, + "archived": false, + "archived_at": null, + "auto_archive_exempt": false, + "todos": [] +} \ No newline at end of file diff --git a/packages/migration-legacy/test/fixtures/golden/.kimi/skills/golden-skill/SKILL.md b/packages/migration-legacy/test/fixtures/golden/.kimi/skills/golden-skill/SKILL.md new file mode 100644 index 00000000000..d9592fccdf3 --- /dev/null +++ b/packages/migration-legacy/test/fixtures/golden/.kimi/skills/golden-skill/SKILL.md @@ -0,0 +1,6 @@ +--- +name: golden-skill +description: Golden fixture skill +--- + +Body. diff --git a/packages/migration-legacy/test/fixtures/golden/.kimi/user-history/c1c0997494d4df46ac89f99f813c077c.jsonl b/packages/migration-legacy/test/fixtures/golden/.kimi/user-history/c1c0997494d4df46ac89f99f813c077c.jsonl new file mode 100644 index 00000000000..36e903d2050 --- /dev/null +++ b/packages/migration-legacy/test/fixtures/golden/.kimi/user-history/c1c0997494d4df46ac89f99f813c077c.jsonl @@ -0,0 +1,2 @@ +{"content": "ls -la"} +{"content": "git status"} diff --git a/packages/migration-legacy/test/fixtures/title-only/context.jsonl b/packages/migration-legacy/test/fixtures/title-only/context.jsonl new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/migration-legacy/test/fixtures/title-only/state.json b/packages/migration-legacy/test/fixtures/title-only/state.json new file mode 100644 index 00000000000..08220cb04f7 --- /dev/null +++ b/packages/migration-legacy/test/fixtures/title-only/state.json @@ -0,0 +1 @@ +{"version":1,"custom_title":"My named session","title_generated":false} \ No newline at end of file diff --git a/packages/migration-legacy/test/golden.test.ts b/packages/migration-legacy/test/golden.test.ts new file mode 100644 index 00000000000..b0e7cae0f75 --- /dev/null +++ b/packages/migration-legacy/test/golden.test.ts @@ -0,0 +1,237 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mkdtemp, readdir, readFile, rm } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { detectMigration, runMigration } from '../src/index.js'; +import { listSessionsV2, readSessionSummaryV2 } from './v2-session-scan.js'; + +const GOLDEN = fileURLToPath(new URL('./fixtures/golden', import.meta.url)); +const GOLDEN_HOME = join(GOLDEN, '.kimi'); +const HISTORICAL_HOME = join(GOLDEN, '.kimi-historical-config'); +const MARKER = join(GOLDEN_HOME, '.migrated-to-kimi-code'); +const HISTORICAL_MARKER = join(HISTORICAL_HOME, '.migrated-to-kimi-code'); +const WORK_DIR = '/work/golden-proj'; + +const UUID_RICH = '11111111-aaaa-4bbb-8ccc-111111111111'; +const UUID_METADATA = '22222222-aaaa-4bbb-8ccc-222222222222'; +const UUID_TITLE_ONLY = '33333333-aaaa-4bbb-8ccc-333333333333'; +const UUID_FLAT = '44444444-aaaa-4bbb-8ccc-444444444444'; + +const FULL_SCOPE = { + config: true, + mcp: true, + userHistory: true, + skills: true, + sessions: true, +} as const; + +let tgt: string; +beforeEach(async () => { + tgt = await mkdtemp(join(tmpdir(), 'golden-')); + await rm(MARKER, { force: true }); + await rm(HISTORICAL_MARKER, { force: true }); +}); +afterEach(async () => { + await rm(tgt, { recursive: true, force: true }); + await rm(MARKER, { force: true }); + await rm(HISTORICAL_MARKER, { force: true }); +}); + +async function migrateGolden() { + const plan = await detectMigration({ sourcePath: GOLDEN_HOME }); + const report = await runMigration({ + plan, + scope: FULL_SCOPE, + source: GOLDEN_HOME, + target: tgt, + }); + return { plan, report }; +} + +async function readMigratedState(uuid: string): Promise<{ + title: string; + isCustomTitle: boolean; + titleKind: string; + archived: boolean; + archivedAt?: number; + additionalDirs?: string[]; + custom: Record; +}> { + const index = (await readFile(join(tgt, 'session_index.jsonl'), 'utf-8')) + .split('\n') + .filter((l) => l.length > 0) + .map((l) => JSON.parse(l) as { sessionId: string; sessionDir: string }); + const entry = index.find((e) => e.sessionId === `ses_${uuid}`); + expect(entry).toBeDefined(); + return JSON.parse(await readFile(join(entry!.sessionDir, 'state.json'), 'utf-8')); +} + +describe('golden fixtures (generated by kimi-cli serializers)', () => { + it('detects every data class, including historical layouts', async () => { + const { plan } = await migrateGolden(); + expect(plan.hasConfig).toBe(true); + expect(plan.hasMcp).toBe(true); + expect(plan.hasUserHistory).toBe(true); + expect(plan.hasSkills).toBe(true); + expect(plan.totalSessions).toBe(4); + expect(plan.sessionScanFailures).toEqual([]); + expect(plan.oauthCredentials).toEqual(['kimi-code']); + expect(plan.detectedMcpOauthServers).toEqual(['golden-remote']); + }); + + it('migrates config faithfully: provider types mapped, oauth ref kept, hooks kept', async () => { + const { report } = await migrateGolden(); + expect(report.summary.config.migrated).toBe(true); + expect(report.summary.config.droppedProviders).toEqual([]); + expect(report.summary.config.droppedModels).toEqual([]); + expect(report.summary.config.migratedHooks).toBe(1); + + const cfg = await readFile(join(tgt, 'config.toml'), 'utf-8'); + expect(cfg).toContain('default_model = "golden-main"'); + expect(cfg).toContain('type = "openai"'); + expect(cfg).not.toContain('openai_legacy'); + expect(cfg.match(/type = "google-genai"/g)).toHaveLength(1); + expect(cfg).toContain('type = "vertexai"'); + expect(cfg).toContain('key = "oauth/kimi-code"'); + expect(cfg).toContain('echo golden-hook'); + expect(cfg).toMatch(/\[models\.vllm-mooncake\][\s\S]*?reasoning_key = "reasoning"/); + expect(cfg).not.toMatch(/\[providers\.vllm\][^[]*reasoning_key/); + + const tui = await readFile(join(tgt, 'tui.toml'), 'utf-8'); + expect(tui).toContain('theme = "dark"'); + expect(tui).toContain('command = "code --wait"'); + }); + + it('merges MCP servers and reports the oauth server for reauth', async () => { + const { report } = await migrateGolden(); + expect([...report.summary.mcp.mergedServers].sort()).toEqual(['golden-remote', 'golden-stdio']); + expect(report.notices.mcpOauthServersRequiringReauth).toEqual(['golden-remote']); + const mcp = JSON.parse(await readFile(join(tgt, 'mcp.json'), 'utf-8')) as { + mcpServers: Record; + }; + expect(mcp.mcpServers['golden-remote']?.auth).toBe('oauth'); + }); + + it('never copies OAuth credentials or the MCP OAuth store', async () => { + const { report } = await migrateGolden(); + expect(existsSync(join(tgt, 'credentials'))).toBe(false); + expect(existsSync(join(tgt, 'mcp-oauth'))).toBe(false); + expect(report.notices.oauthLoginsRequiringRelogin).toContain('kimi-code'); + }); + + it('copies user history and skills, then writes the completion marker', async () => { + await migrateGolden(); + const historyDir = await readdir(join(tgt, 'user-history')); + expect(historyDir).toHaveLength(1); + expect(existsSync(join(tgt, 'skills', 'golden-skill', 'SKILL.md'))).toBe(true); + + const marker = JSON.parse(await readFile(MARKER, 'utf-8')) as { version: number }; + expect(marker.version).toBe(1); + }); + + it('migrates all four sessions and skips the non-local bucket', async () => { + const { report } = await migrateGolden(); + expect(report.summary.sessions.sessionsMigrated).toBe(4); + expect(report.summary.sessions.sessionsFailed).toEqual([]); + expect(report.summary.sessions.sessionsConflicts).toEqual([]); + expect(report.summary.sessions.bucketsSkippedNonlocalKaos).toBe(1); + expect(existsSync(join(tgt, 'sessions'))).toBe(true); + const index = await readFile(join(tgt, 'session_index.jsonl'), 'utf-8'); + expect(index).not.toContain('55555555'); + }); + + it('rich session: nested media, thinking, tool calls, display, title, dirs preserved', async () => { + await migrateGolden(); + const state = await readMigratedState(UUID_RICH); + expect(state.title).toBe('Golden rich session'); + expect(state.isCustomTitle).toBe(true); + expect(state.additionalDirs).toEqual(['/work/golden-extra']); + + const index = (await readFile(join(tgt, 'session_index.jsonl'), 'utf-8')) + .split('\n') + .filter((l) => l.length > 0) + .map((l) => JSON.parse(l) as { sessionId: string; sessionDir: string }); + const dir = index.find((e) => e.sessionId === `ses_${UUID_RICH}`)!.sessionDir; + const wire = await readFile(join(dir, 'agents', 'main', 'wire.jsonl'), 'utf-8'); + expect(wire).toContain('"image_url"'); + expect(wire).toContain('"imageUrl":{"url":"data:image/png;base64,iVBORw0KGgo=","id":"img-1"}'); + expect(wire).toContain('"audioUrl":{"url":"data:audio/wav;base64,UklGRg=="}'); + expect(wire).toContain('"videoUrl":{"url":"https://media.example.test/v.mp4","id":"vid-1"}'); + expect(wire).toContain('thinking about the attachments'); + expect(wire).toContain('"name":"Shell"'); + expect(wire).toContain('ls -la'); + expect(wire).not.toContain('unsupported content'); + }); + + it('metadata session: legacy metadata.json merged with state defaults', async () => { + await migrateGolden(); + const state = await readMigratedState(UUID_METADATA); + expect(state.title).toBe('Golden legacy title'); + expect(state.isCustomTitle).toBe(false); + expect(state.titleKind).toBe('generated'); + expect(state.archived).toBe(true); + expect(state.archivedAt).toBe(1_735_689_700_000); + expect(state.custom['auto_archive_exempt']).toBe(true); + }); + + it('title-only session: preserved with its custom title', async () => { + await migrateGolden(); + const state = await readMigratedState(UUID_TITLE_ONLY); + expect(state.title).toBe('Golden title only'); + expect(state.isCustomTitle).toBe(true); + }); + + it('flat historical session: migrated with title from the first prompt', async () => { + await migrateGolden(); + const state = await readMigratedState(UUID_FLAT); + expect(state.title).toBe('flat era hello'); + }); + + it('migrated sessions are discoverable by agent-core-v2 with titles and media', async () => { + await migrateGolden(); + const sessions = await listSessionsV2(tgt); + const titles = new Map(sessions.map((s) => [s.id, s.title])); + expect(titles.get(`ses_${UUID_RICH}`)).toBe('Golden rich session'); + expect(titles.get(`ses_${UUID_METADATA}`)).toBe('Golden legacy title'); + expect(titles.get(`ses_${UUID_TITLE_ONLY}`)).toBe('Golden title only'); + expect(titles.get(`ses_${UUID_FLAT}`)).toBe('flat era hello'); + + const rich = await readSessionSummaryV2(tgt, `ses_${UUID_RICH}`); + expect(rich?.custom?.['imported_from_kimi_cli']).toBe(true); + expect(rich?.cwd).toBe(WORK_DIR); + + const metadataSummary = await readSessionSummaryV2(tgt, `ses_${UUID_METADATA}`); + expect(metadataSummary?.archived).toBe(true); + expect(metadataSummary?.archivedAt).toBe(1_735_689_700_000); + + const index = (await readFile(join(tgt, 'session_index.jsonl'), 'utf-8')) + .split('\n') + .filter((l) => l.length > 0) + .map((l) => JSON.parse(l) as { sessionId: string; sessionDir: string }); + const richDir = index.find((e) => e.sessionId === `ses_${UUID_RICH}`)!.sessionDir; + const wire = await readFile(join(richDir, 'agents', 'main', 'wire.jsonl'), 'utf-8'); + const mediaKinds = ['"imageUrl"', '"audioUrl"', '"videoUrl"']; + for (const kind of mediaKinds) { + expect(wire).toContain(kind); + } + expect(wire).toContain('"id":"img-1"'); + }); + + it('migrates a historical config.json-only source', async () => { + const plan = await detectMigration({ sourcePath: HISTORICAL_HOME }); + expect(plan.hasConfig).toBe(true); + const report = await runMigration({ + plan, + scope: FULL_SCOPE, + source: HISTORICAL_HOME, + target: tgt, + }); + expect(report.summary.config.migrated).toBe(true); + const cfg = await readFile(join(tgt, 'config.toml'), 'utf-8'); + expect(cfg).toContain('default_model = "historical-main"'); + expect(cfg).toContain('type = "openai"'); + }); +}); diff --git a/packages/migration-legacy/test/integration.test.ts b/packages/migration-legacy/test/integration.test.ts index e7d45dfcccd..001988f294a 100644 --- a/packages/migration-legacy/test/integration.test.ts +++ b/packages/migration-legacy/test/integration.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { detectMigration, runMigration } from '../src/index.js'; +import { computeWorkdirBucket, oldMd5BucketName } from '../src/sessions/workdir-bucket.js'; const FIXTURES = fileURLToPath(new URL('./fixtures', import.meta.url)); const SOURCE_HOME = join(FIXTURES, 'multi-workdir', '.kimi'); @@ -159,6 +160,81 @@ describe('runMigration (end-to-end on multi-workdir fixture)', () => { } }); + it('does not write a completed marker when the legacy config cannot be parsed', async () => { + const src = await mkdtemp(join(tmpdir(), 'bad-config-marker-src-')); + try { + await writeFile(join(src, 'config.toml'), 'this is = = not valid toml [[['); + const plan = await detectMigration({ sourcePath: src }); + expect(plan.hasConfig).toBe(true); + + const report = await runMigration({ + plan, + scope: { config: true, mcp: true, userHistory: true, skills: true, sessions: true }, + source: src, + target: tgt, + }); + + expect(report.summary.config.sourceUnreadable).toBe(true); + await expect(readFile(join(src, '.migrated-to-kimi-code'), 'utf-8')).rejects.toThrow(); + } finally { + await rm(src, { recursive: true, force: true }); + } + }); + + it('does not write a completed marker when the legacy mcp.json cannot be parsed', async () => { + const src = await mkdtemp(join(tmpdir(), 'bad-mcp-marker-src-')); + try { + await writeFile(join(src, 'mcp.json'), 'not json {{{'); + const plan = await detectMigration({ sourcePath: src }); + + const report = await runMigration({ + plan, + scope: { config: true, mcp: true, userHistory: true, skills: true, sessions: true }, + source: src, + target: tgt, + }); + + expect(report.summary.mcp.sourceUnreadable).toBe(true); + await expect(readFile(join(src, '.migrated-to-kimi-code'), 'utf-8')).rejects.toThrow(); + } finally { + await rm(src, { recursive: true, force: true }); + } + }); + + it('does not write a completed marker when a session target is occupied by a foreign session', async () => { + const src = await mkdtemp(join(tmpdir(), 'conflict-marker-src-')); + try { + const workdir = '/workspace/conflict-proj'; + const uuid = 'conflict-session'; + const bucket = join(src, 'sessions', oldMd5BucketName(workdir)); + await mkdir(join(bucket, uuid), { recursive: true }); + await writeFile(join(src, 'kimi.json'), JSON.stringify({ work_dirs: [{ path: workdir }] })); + await writeFile(join(bucket, uuid, 'context.jsonl'), '{"role":"user","content":"hi"}\n'); + + const foreignDir = join( + tgt, + 'sessions', + computeWorkdirBucket(workdir), + `ses_${uuid}`, + ); + await mkdir(foreignDir, { recursive: true }); + await writeFile(join(foreignDir, 'state.json'), '{}'); + + const plan = await detectMigration({ sourcePath: src }); + const report = await runMigration({ + plan, + scope: { config: true, mcp: true, userHistory: true, skills: true, sessions: true }, + source: src, + target: tgt, + }); + + expect(report.summary.sessions.sessionsConflicts).toHaveLength(1); + await expect(readFile(join(src, '.migrated-to-kimi-code'), 'utf-8')).rejects.toThrow(); + } finally { + await rm(src, { recursive: true, force: true }); + } + }); + it('does not copy OAuth credentials into the target', async () => { // OAuth refresh tokens rotate server-side: they are single-use and // single-owner. Copying a credential to a second install breaks login @@ -190,7 +266,7 @@ describe('runMigration (end-to-end on multi-workdir fixture)', () => { readFile(join(tgt, 'credentials', 'kimi-code.json'), 'utf-8'), ).rejects.toThrow(); // The report tells the user to sign in again in kimi-code. - expect(report.notices.oauthLoginsRequiringRelogin).toContain('kimi-code.json'); + expect(report.notices.oauthLoginsRequiringRelogin).toContain('kimi-code'); } finally { await rm(src, { recursive: true, force: true }); } diff --git a/packages/migration-legacy/test/marker.test.ts b/packages/migration-legacy/test/marker.test.ts index f3269f9303c..c97d0d4fa2f 100644 --- a/packages/migration-legacy/test/marker.test.ts +++ b/packages/migration-legacy/test/marker.test.ts @@ -96,6 +96,8 @@ describe('marker', () => { hasConfig: false, hasMcp: false, hasUserHistory: false, + hasSkills: false, + hasPlans: false, oauthCredentials: [], workdirs: [], detectedPlugins: [], diff --git a/packages/migration-legacy/test/report.test.ts b/packages/migration-legacy/test/report.test.ts index d49605c4ff3..57263603dd7 100644 --- a/packages/migration-legacy/test/report.test.ts +++ b/packages/migration-legacy/test/report.test.ts @@ -33,11 +33,14 @@ describe('writeReport', () => { wroteTuiSibling: false, migratedHooks: 0, droppedHooks: 0, + sourceUnreadable: false, + deviceIdCopied: false, siblingContents: { providers: [], models: [], hooks: 0 }, }, - mcp: { mergedServers: [], keptNewForConflicts: [], droppedServers: [], wroteSiblingDueToConflict: false }, + mcp: { mergedServers: [], keptNewForConflicts: [], droppedServers: [], wroteSiblingDueToConflict: false, sourceUnreadable: false }, userHistory: { copied: 0, skippedExisting: 0 }, skills: { copied: 0, skippedExisting: 0 }, + plans: { copied: 0, skippedExisting: 0 }, sessions: { scope: 'all', bucketsScanned: 0, @@ -46,6 +49,7 @@ describe('writeReport', () => { sessionsAttempted: 0, sessionsMigrated: 0, sessionsAlreadyMigrated: 0, + sessionsRepaired: 0, sessionsSkippedPlaceholder: 0, sessionsSkippedEmpty: 0, sessionsSkippedMalformed: 0, @@ -59,6 +63,7 @@ describe('writeReport', () => { detectedPlugins: [], configConflictNotice: null, tuiConflictNotice: null, + plansCopiedNotice: null, }, }; await writeReport(tgt, report); diff --git a/packages/migration-legacy/test/resume.integration.test.ts b/packages/migration-legacy/test/resume.integration.test.ts index 06b00c6d175..cf515600093 100644 --- a/packages/migration-legacy/test/resume.integration.test.ts +++ b/packages/migration-legacy/test/resume.integration.test.ts @@ -1,53 +1,16 @@ -/** - * End-to-end check that a migrated session is actually visible to — and - * resumable by — real kimi-core. The migrator writes session buckets named by - * `computeWorkdirBucket`; kimi-core's session picker (`SessionStore.list`) - * locates sessions purely by `readdir(encodeWorkDirKey(workDir))`. If the two - * bucket algorithms diverge (see review item C1), migrated sessions become - * silently invisible — this test fails fast in that case. - * - * The resume test additionally drives a real `Session.resume()`: it reads the - * migrated `state.json`, instantiates the `main` agent from - * `agents.main.homedir`, and replays that agent's `wire.jsonl`. If - * `agents.main.homedir` does not point at `/agents/main` (where the - * migrator writes the translated history), the resumed agent's context is - * empty and the migrated history is lost. - * - * agent-core API used: - * - `SessionStore` (constructor: `new SessionStore(homeDir)`) - * - `SessionStore.list({ workDir })` - * - `encodeWorkDirKey` / `normalizeWorkDir` - * all from `@moonshot-ai/agent-core/session/store`. - * - `Session` (constructor + `resume()` + `getReadyAgent()`), from - * `@moonshot-ai/agent-core`; `localKaos` from `@moonshot-ai/kaos`. After - * `resume()`, `session.getReadyAgent('main').context.messages` exposes the - * replayed message history. - */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { mkdtemp, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { - SessionStore, - encodeWorkDirKey, - normalizeWorkDir, -} from '@moonshot-ai/agent-core/session/store/index'; -import { Session, type SDKSessionRPC } from '@moonshot-ai/agent-core'; -import { LocalKaos } from '@moonshot-ai/kaos'; +import { encodeWorkDirKey } from '@moonshot-ai/agent-core-v2/_base/utils/workdir-slug'; +import { reduceContextTranscript } from '@moonshot-ai/agent-core-v2'; +import { groupMessagesIntoSnapshot } from '@moonshot-ai/transcript'; import { migrateOneSession, type MigrateOneResult } from '../src/sessions/migrate-one.js'; import { computeWorkdirBucket } from '../src/sessions/workdir-bucket.js'; - -function createSessionRpc(): SDKSessionRPC { - return { - emitEvent: vi.fn(async () => {}), - requestApproval: vi.fn(async () => ({ decision: 'cancelled' })), - requestQuestion: vi.fn(async () => null), - toolCall: vi.fn(async () => ({ output: 'unused', isError: true })), - } as unknown as SDKSessionRPC; -} +import { listSessionsV2, readSessionSummaryV2 } from './v2-session-scan.js'; const FIXTURES = fileURLToPath(new URL('./fixtures', import.meta.url)); const WORK_DIR = '/Users/example/proj'; @@ -60,47 +23,42 @@ afterEach(async () => { await rm(targetHome, { recursive: true, force: true }); }); -describe('migrated session loads in real kimi-core', () => { - it('computeWorkdirBucket matches kimi-core encodeWorkDirKey', () => { - expect(computeWorkdirBucket(WORK_DIR)).toBe( - encodeWorkDirKey(normalizeWorkDir(WORK_DIR)), - ); +async function migrateFixture( + uuid: string, + fixture: string, +): Promise> { + const result = await migrateOneSession({ + source: { + uuid, + sessionDir: join(FIXTURES, fixture), + contextPath: join(join(FIXTURES, fixture), 'context.jsonl'), + }, + workdirPath: WORK_DIR, + targetHome, }); + expect(result.outcome).toBe('migrated'); + return result as Extract; +} - it('SessionStore.list() finds a migrated session under the same workDir', async () => { - const result = await migrateOneSession({ - sourceSessionDir: join(FIXTURES, 'with-tool-calls'), - oldSessionUuid: 'integ-uuid', - workdirPath: WORK_DIR, - targetHome, - }); - expect(result.outcome).toBe('migrated'); - - // `SessionStore(homeDir)` resolves sessions under `homeDir/sessions`, - // which is exactly where the migrator wrote. - const store = new SessionStore(targetHome); - const sessions = await store.list({ workDir: WORK_DIR }); +describe('migrated session is discoverable by agent-core-v2', () => { + it('computeWorkdirBucket matches v2 encodeWorkDirKey', () => { + expect(computeWorkdirBucket(WORK_DIR)).toBe(encodeWorkDirKey(WORK_DIR)); + }); - // This exercises kimi-core's bucket lookup end-to-end: list() does - // `readdir(encodeWorkDirKey(workDir))` and never consults the index. - expect(sessions.map((s) => s.id)).toContain('ses_integ-uuid'); + it('v2 authoritative scan finds a migrated session under the same workDir', async () => { + await migrateFixture('integ-uuid', 'with-tool-calls'); + const sessions = await listSessionsV2(targetHome); const migrated = sessions.find((s) => s.id === 'ses_integ-uuid'); - expect(migrated?.metadata?.['imported_from_kimi_cli']).toBe(true); + expect(migrated).toBeDefined(); + expect(migrated?.custom?.['imported_from_kimi_cli']).toBe(true); + expect(migrated?.cwd).toBe(WORK_DIR); }); - it('migrated wire history is non-empty and resumable', async () => { - const result = await migrateOneSession({ - sourceSessionDir: join(FIXTURES, 'tiny-hello-world'), - oldSessionUuid: 'tiny-resume', - workdirPath: WORK_DIR, - targetHome, - }); - expect(result.outcome).toBe('migrated'); - const targetDir = (result as Extract) - .targetDir; - - const wire = await readFile(join(targetDir, 'agents', 'main', 'wire.jsonl'), 'utf-8'); + it('migrated wire history is non-empty', async () => { + const result = await migrateFixture('tiny-resume', 'tiny-hello-world'); + + const wire = await readFile(join(result.targetDir, 'agents', 'main', 'wire.jsonl'), 'utf-8'); const events = wire .split('\n') .filter((l) => l.length > 0) @@ -109,93 +67,85 @@ describe('migrated session loads in real kimi-core', () => { expect(events.filter((e) => e.type === 'context.append_message').length).toBeGreaterThan(0); }); - it('real kimi-core Session.resume() loads the migrated message history', async () => { - const result = await migrateOneSession({ - sourceSessionDir: join(FIXTURES, 'tiny-hello-world'), - oldSessionUuid: 'tiny-resume', - workdirPath: WORK_DIR, - targetHome, - }); - expect(result.outcome).toBe('migrated'); - const targetDir = (result as Extract) - .targetDir; - - // Drive a real kimi-core resume: `Session.resume()` reads `state.json` - // from `homedir`, then instantiates the `main` agent from - // `agents.main.homedir` and replays *that directory's* `wire.jsonl`. - // If `agents.main.homedir` were the project workdir (the bug), the agent - // would replay an absent file and the history would be empty. - const session = new Session({ - kaos: (await LocalKaos.create()).withCwd(WORK_DIR), - id: 'ses_tiny-resume', - homedir: targetDir, - rpc: createSessionRpc(), - initializeMainAgent: false, - }); - try { - await session.resume(); - const mainAgent = session.getReadyAgent('main'); - expect(mainAgent).toBeDefined(); - - // The migrated wire carries no `config.update` bootstrap events, so a - // naive replay leaves the agent with an empty system prompt and no - // tools. `Session.resume()` re-applies the default profile when it - // detects this — assert it took effect so the resumed session is usable. - expect((mainAgent?.config.systemPrompt ?? '').length).toBeGreaterThan(0); - - const messages = mainAgent?.context.messages ?? []; - // The fixture has a user + assistant message — both must be replayed. - expect(messages.length).toBeGreaterThan(0); - const transcript = messages - .flatMap((m) => m.content) - .map((part) => (part.type === 'text' ? part.text : '')) - .join('\n'); - expect(transcript).toContain('hi'); - expect(transcript).toContain('Hello! How can I help?'); - } finally { - await session.close(); - } + it('v2 summary exposes the migrated title, metadata and message history', async () => { + const result = await migrateFixture('tiny-resume', 'tiny-hello-world'); + + const summary = await readSessionSummaryV2(targetHome, 'ses_tiny-resume'); + expect(summary).toBeDefined(); + expect(summary?.title).toBe('hi'); + expect(summary?.cwd).toBe(WORK_DIR); + expect(summary?.archived).toBe(false); + expect(summary?.createdAt).toBeGreaterThan(0); + + const wire = await readFile(join(result.targetDir, 'agents', 'main', 'wire.jsonl'), 'utf-8'); + expect(wire).toContain('hi'); + expect(wire).toContain('Hello! How can I help?'); }); - it('real Session.resume() preserves a legacy todo display', async () => { - const result = await migrateOneSession({ - sourceSessionDir: join(FIXTURES, 'large-100msgs'), - oldSessionUuid: 'todo-display', - workdirPath: WORK_DIR, - targetHome, - }); - expect(result.outcome).toBe('migrated'); - const targetDir = (result as Extract) - .targetDir; - - const session = new Session({ - kaos: (await LocalKaos.create()).withCwd(WORK_DIR), - id: 'ses_todo-display', - homedir: targetDir, - rpc: createSessionRpc(), - initializeMainAgent: false, - }); - try { - await session.resume(); - const assistant = session - .getReadyAgent('main') - ?.context.history.find((message) => - message.toolCalls.some( - (call) => call.id === 'tool_y3SXWWQIUysddnYoklaWhUeE', - ), - ); - - expect( - assistant?.toolCallDisplays?.['tool_y3SXWWQIUysddnYoklaWhUeE'], - ).toEqual({ - kind: 'todo_list', - items: expect.arrayContaining([ - { title: '准备测试环境(创建隔离 work-dir)', status: 'in_progress' }, - { title: '汇报结论', status: 'pending' }, - ]), + it('v2 scan lists a title-only migrated session with its custom title', async () => { + await migrateFixture('title-only-resume', 'title-only'); + + const summary = await readSessionSummaryV2(targetHome, 'ses_title-only-resume'); + expect(summary).toBeDefined(); + expect(summary?.title).toBe('My named session'); + }); + + it('migrated wire preserves a legacy todo display', async () => { + const result = await migrateFixture('todo-display', 'large-100msgs'); + + const wire = await readFile(join(result.targetDir, 'agents', 'main', 'wire.jsonl'), 'utf-8'); + expect(wire).toContain('tool_y3SXWWQIUysddnYoklaWhUeE'); + expect(wire).toContain('todo_list'); + expect(wire).toContain('准备测试环境(创建隔离 work-dir)'); + expect(wire).toContain('汇报结论'); + }); + + it('turn structure survives a v2 context-transcript round trip and aligns with transcript grouping', async () => { + const result = await migrateFixture('turn-structure', 'with-tool-calls'); + + const wire = await readFile(join(result.targetDir, 'agents', 'main', 'wire.jsonl'), 'utf-8'); + const records = wire + .split('\n') + .filter((l) => l.length > 0) + .map((l) => JSON.parse(l) as { type: string; [key: string]: unknown }); + + // Content round trip: the v2 context transcript sees exactly the imported + // messages — the synthesized turn records must not alter, duplicate, or + // drop any message. (`toolCallDisplays` is UI-only enrichment the context + // transcript deliberately does not carry, so strip it from both sides.) + const transcript = reduceContextTranscript(records); + const imported = records + .filter((r) => r.type === 'context.append_message') + .map((r) => r['message']); + const stripDisplays = ( + messages: readonly unknown[], + ): unknown[] => + messages.map((m) => { + const { toolCallDisplays: _dropped, ...rest } = m as Record; + return rest; }); - } finally { - await session.close(); - } + expect(stripDisplays([...transcript.entries])).toEqual(stripDisplays(imported)); + + // The invariant that keeps a live turn from hijacking an imported one: + // every turn.prompt advances the restored turn clock by one, so the number + // of synthesized turn.prompt records must equal the number of turns the + // transcript grouping derives from the same messages. The first live turn + // after resume then gets an id past every imported turn. + const promptCount = records.filter((r) => r.type === 'turn.prompt').length; + const groupedTurns = groupMessagesIntoSnapshot([...transcript.entries]).items.filter( + (item) => item.kind === 'turn', + ).length; + expect(promptCount).toBe(groupedTurns); + expect(promptCount).toBeGreaterThan(0); + + const endedTurnIds = records + .filter((r) => r.type === 'turn.ended') + .map((r) => r['turnId']) + .filter((id): id is number => typeof id === 'number'); + // Turns without assistant content (e.g. an unanswered user message) get no + // turn.ended; the rest carry sequential ids within the imported range. + expect(endedTurnIds).toEqual([...endedTurnIds].sort((a, b) => a - b)); + expect(new Set(endedTurnIds).size).toBe(endedTurnIds.length); + expect(Math.max(...endedTurnIds)).toBeLessThan(promptCount); }); }); diff --git a/packages/migration-legacy/test/sessions/__snapshots__/fixtures.snapshot.test.ts.snap b/packages/migration-legacy/test/sessions/__snapshots__/fixtures.snapshot.test.ts.snap index 537493c716a..baee1513579 100644 --- a/packages/migration-legacy/test/sessions/__snapshots__/fixtures.snapshot.test.ts.snap +++ b/packages/migration-legacy/test/sessions/__snapshots__/fixtures.snapshot.test.ts.snap @@ -3,11 +3,18 @@ exports[`migration snapshot: archived > migration succeeds and matches snapshot 1`] = ` { "state": "{ + "id": "ses_archived", + "version": 2, + "cwd": "/Users/example/proj", "createdAt": "", "updatedAt": "", + "archived": true, + "archivedAt": 1777355100904.803, "title": "You are a code translation assistant. Task: [...]", + "titleKind": "custom", "isCustomTitle": true, "lastPrompt": "\\nYou are running in non-interactive mode. The user cannot answer questions or provide feedback during execution.\\n- Do NOT call AskUserQuestion. If you need to make a decision, make yo", + "lastTurnReason": "completed", "agents": { "main": { "homedir": "/sessions/wd_proj_33c5ea5aa7eb/ses_archived/agents/main", @@ -17,11 +24,12 @@ exports[`migration snapshot: archived > migration succeeds and matches snapshot }, "custom": { "imported_from_kimi_cli": true, + "import_format_version": 2, "kimi_cli_source_path": "", "kimi_cli_session_id": "archived", "kimi_cli_wire_protocol": "1.8", "imported_at": "", - "archived": true, + "auto_archive_exempt": false, "vscode_legacy_approval": { "yolo": false, "afk": false @@ -29,10 +37,14 @@ exports[`migration snapshot: archived > migration succeeds and matches snapshot } }", "wire": "{"type":"metadata","protocol_version":"1.0","created_at":} +{"type":"turn.prompt","agentId":"main","input":[{"type":"text","text":"You are a code translation assistant.\\n\\nTask:\\n- Read the file \`sample.js\` in the current working directory.\\n- Translate it into idiomatic Python 3.\\n- Write the translated code to \`translated.py\` in the current working directory.\\n\\nRules:\\n- You must read the file from disk; do not guess its contents.\\n- Preserve behavior and output.\\n- Write only Python code in translated.py (no Markdown).\\n- Overwrite translated.py if it already exists.\\n- After writing, reply with a single short ASCII confirmation se... [truncated]"}],"origin":{"kind":"user"},"time":} {"type":"context.append_message","message":{"role":"user","content":[{"type":"text","text":"You are a code translation assistant.\\n\\nTask:\\n- Read the file \`sample.js\` in the current working directory.\\n- Translate it into idiomatic Python 3.\\n- Write the translated code to \`translated.py\` in the current working directory.\\n\\nRules:\\n- You must read the file from disk; do not guess its contents.\\n- Preserve behavior and output.\\n- Write only Python code in translated.py (no Markdown).\\n- Overwrite translated.py if it already exists.\\n- After writing, reply with a single short ASCII confirmation se... [truncated]"}],"toolCalls":[]}} +{"type":"turn.prompt","agentId":"main","input":[{"type":"text","text":"\\nYou are running in non-interactive mode. The user cannot answer questions or provide feedback during execution.\\n- Do NOT call AskUserQuestion. If you need to make a decision, make your best judgment and proceed.\\n- For EnterPlanMode / ExitPlanMode, they will be auto-approved. You can use them normally but expect no user feedback.\\n"}],"origin":{"kind":"user"},"time":} {"type":"context.append_message","message":{"role":"user","content":[{"type":"text","text":"\\nYou are running in non-interactive mode. The user cannot answer questions or provide feedback during execution.\\n- Do NOT call AskUserQuestion. If you need to make a decision, make your best judgment and proceed.\\n- For EnterPlanMode / ExitPlanMode, they will be auto-approved. You can use them normally but expect no user feedback.\\n"}],"toolCalls":[]}} {"type":"context.append_message","message":{"role":"assistant","content":[],"toolCalls":[{"type":"function","id":"ReadFile:0","function":{"name":"ReadFile","arguments":"{\\"path\\": \\"sample.js\\"}"}}]}} {"type":"context.append_message","message":{"role":"tool","content":[{"type":"text","text":"10 lines read from file starting from line 1. End of file reached."},{"type":"text","text":" 1\\tfunction add(a, b) {\\n 2\\t return a + b;\\n 3\\t}\\n 4\\t\\n 5\\tfunction main() {\\n 6\\t const result = add(2, 3);\\n 7\\t console.log(\`2 + 3 = \${result}\`);\\n 8\\t}\\n 9\\t\\n 10\\tmain();\\n"}],"toolCalls":[],"toolCallId":"ReadFile:0"}} +{"type":"turn.ended","agentId":"main","turnId":1,"reason":"completed","time":} +{"type":"token_counting.measured","agentId":"main","length":4,"tokens":21,"time":} ", } `; @@ -40,11 +52,17 @@ exports[`migration snapshot: archived > migration succeeds and matches snapshot exports[`migration snapshot: large-100msgs > migration succeeds and matches snapshot 1`] = ` { "state": "{ + "id": "ses_large-100msgs", + "version": 2, + "cwd": "/Users/example/proj", "createdAt": "", "updatedAt": "", + "archived": false, "title": "source /Users/example/proj/example-project/.venv/b", + "titleKind": "replaceable", "isCustomTitle": false, "lastPrompt": "source /Users/example/proj/example-project/.venv/bin/activate", + "lastTurnReason": "completed", "agents": { "main": { "homedir": "/sessions/wd_proj_33c5ea5aa7eb/ses_large-100msgs/agents/main", @@ -54,11 +72,12 @@ exports[`migration snapshot: large-100msgs > migration succeeds and matches snap }, "custom": { "imported_from_kimi_cli": true, + "import_format_version": 2, "kimi_cli_source_path": "", "kimi_cli_session_id": "large-100msgs", "kimi_cli_wire_protocol": "1.4", "imported_at": "", - "archived": false, + "auto_archive_exempt": false, "vscode_legacy_approval": { "yolo": false, "afk": false @@ -66,6 +85,7 @@ exports[`migration snapshot: large-100msgs > migration succeeds and matches snap } }", "wire": "{"type":"metadata","protocol_version":"1.0","created_at":} +{"type":"turn.prompt","agentId":"main","input":[{"type":"text","text":"请帮我探索一下当前这个仓库的整体结构,了解它的主要模块和功能。看完之后请写一份简短的小报告,列出你认为最重要的几个模块、它们的职责,以及彼此之间的依赖关系。报告控制在一页之内,便于团队成员快速了解项目。"}],"origin":{"kind":"user"},"time":} {"type":"context.append_message","message":{"role":"user","content":[{"type":"text","text":"请帮我探索一下当前这个仓库的整体结构,了解它的主要模块和功能。看完之后请写一份简短的小报告,列出你认为最重要的几个模块、它们的职责,以及彼此之间的依赖关系。报告控制在一页之内,便于团队成员快速了解项目。"}],"toolCalls":[]}} {"type":"context.append_message","message":{"role":"assistant","content":[{"type":"think","think":"用户想要我读取 example-task skill 的 SKILL.md 文件,但他们已经在 prompt 中提供了完整的 skill 内容。不过我需要了解这个 skill 的更多细节,特别是 references/prompt-patterns.md 文件。\\n\\n用户似乎在向我介绍这个 skill,让我熟悉它的用法。我应该先检查一下这个 skill 目录的结构,看看是否有其他有用的资源文件。\\n\\n让我先了解一下这个 skill 的完整结构,以便以后需要时可以使用它。"},{"type":"text","text":"我来熟悉一下 \`example-task\` skill 的完整结构和资源。"}],"toolCalls":[{"type":"function","id":"tool_3mtoDjRlqenM0GDSjv9xRzLS","function":{"name":"Glob","arguments":"{\\"pattern\\": \\".agents/skills/example-task/**/*\\"}"}}]}} {"type":"context.append_message","message":{"role":"tool","content":[{"type":"text","text":"Found 5 matches for pattern \`.agents/skills/example-task/**/*\`."},{"type":"text","text":".agents/skills/example-task/SKILL.md\\n.agents/skills/example-task/references\\n.agents/skills/example-task/references/prompt-patterns.md\\n.agents/skills/example-task/scripts\\n.agents/skills/example-task/scripts/inspect_session.py"}],"toolCalls":[],"toolCallId":"tool_3mtoDjRlqenM0GDSjv9xRzLS"}} @@ -73,6 +93,8 @@ exports[`migration snapshot: large-100msgs > migration succeeds and matches snap {"type":"context.append_message","message":{"role":"tool","content":[{"type":"text","text":"85 lines read from file starting from line 1. End of file reached."},{"type":"text","text":" 1\\t# Prompt 模板\\n 2\\t\\n 3\\t以下模板作为脚手架使用。运行前替换占位符。\\n 4\\t\\n 5\\t## 单轮还是多轮\\n 6\\t\\n 7\\t满足以下任一条件时使用多轮:\\n 8\\t\\n 9\\t- 功能有状态\\n 10\\t- 功能依赖时序或并发\\n 11\\t- 功能需要审批、清理或恢复\\n 12\\t- session 产物本身是证据的一部分\\n 13\\t- 工具接口可能近期发生过变化\\n 14\\t\\n 15\\t仅对无状态的窄范围检查使用单轮。\\n 16\\t\\n 17\\t## 变量\\n 18\\t\\n 19\\t起草 prompt 前填写以下字段:\\n 20\\t\\n 21\\t- \`\` — 被测功能名称\\n 22\\t- \`\` — 当前场景的目标\\n 23\\t- \`\` — 需要阅读的源码路径\\n 24\\t- \`\` — 执行约束\\n 25\\t- \`\` — 成功信号\\n 26\\t- \`321 lines read from file starting from line 1. End of file reached."},{"type":"text","text":" 1\\t#!/usr/bin/env python3\\n 2\\t\\"\\"\\"Locate and summarize a Kimi CLI session for smoke-test review.\\"\\"\\"\\n 3\\t\\n 4\\tfrom __future__ import annotations\\n 5\\t\\n 6\\timport argparse\\n 7\\timport json\\n 8\\timport sys\\n 9\\tfrom collections import Counter\\n 10\\tfrom pathlib import Path\\n 11\\tfrom typing import Any\\n 12\\t\\n 13\\t\\n 14\\tdef parse_args() -> argparse.Namespace:\\n 15\\t parser = argparse.ArgumentParser(\\n 16\\t description=\\"Locate and summarize a Kimi CLI session... [truncated]"}],"toolCalls":[],"toolCallId":"tool_300OLXjS5RZxFz9LvWJP6QyJ"}} {"type":"context.append_message","message":{"role":"assistant","content":[{"type":"think","think":"好的,我现在已经完整了解了 \`example-task\` skill 的内容。这个 skill 提供了:\\n\\n1. **SKILL.md** - 主文档,描述了如何针对 the example CLI 的新增或变更功能进行端到端冒烟测试的完整流程\\n2. **references/prompt-patterns.md** - 可复用的 prompt 模板\\n3. **scripts/inspect_session.py** - 用于检查和分析 session 产物的脚本\\n\\n用户似乎是在测试我是否能正确读取和使用这个 skill。既然用户已经在 prompt 中提供了完整的 skill 内容,我应该:\\n1. 确认我已经理解了 skill 的内容\\n2. 询问用户是否有具体的功能需要我使用这个 skill 来进行冒烟测试\\n\\n或者,用户可能是想了解这个 skill 的用法。让我给出一个总结并询问用户的具体需求。"},{"type":"text","text":"我已完整阅读 \`example-task\` skill。这是一个用于对 the example CLI 进行端到端冒烟测试的系统化流程。\\n\\n## Skill 要点总结\\n\\n**核心流程:**\\n1. **确定测试范围** - 从 \`git diff\` 推断功能边界\\n2. **读取事实来源** - 文档、代码、agent prompt、测试\\n3. **制定最小测试计划** - 正常路径、边界条件、中断/清理\\n4. **多轮执行** - 探索→执行→观察→清理\\n5. **非交互模式运行** - 使用 \`--print --work-dir /tmp/...\`\\n6. **检查产物** - \`context.jsonl\`, \`wire.jsonl\`, 后台任务等\\n7. **问题探查** - 并行多路追踪根因\\n\\n**可用资源:**\\n- \`references/prompt-patterns.md\` - 探索/执行/观察/复盘 prompt 模板\\n- \`scripts/inspect_session.py\` - Session 产物分析工具\\n\\n---\\n\\n**你想让我使用这个 skill 来测试... [truncated]"}],"toolCalls":[]}} +{"type":"turn.ended","agentId":"main","turnId":0,"reason":"completed","time":} +{"type":"turn.prompt","agentId":"main","input":[{"type":"text","text":"请基于 README 和主要源代码目录,给出一份简短的探索清单"}],"origin":{"kind":"user"},"time":} {"type":"context.append_message","message":{"role":"user","content":[{"type":"text","text":"请基于 README 和主要源代码目录,给出一份简短的探索清单"}],"toolCalls":[]}} {"type":"context.append_message","message":{"role":"assistant","content":[{"type":"think","think":"用户想让我直接使用 \`git diff main --name-only\` 来查看当前分支相对于 main 分支的变更,然后基于这些变更进行冒烟测试。\\n\\n让我先执行这个命令看看有哪些文件变更。"}],"toolCalls":[{"type":"function","id":"tool_jOGB6wicU1PmefwL15U57RUT","function":{"name":"Shell","arguments":"{\\"command\\": \\"git diff main --name-only && echo \\\\\\"---\\\\\\" && git diff main --stat\\"}"}}]}} {"type":"context.append_message","message":{"role":"tool","content":[{"type":"text","text":"Command executed successfully."},{"type":"text","text":".agents/skills/example-task/SKILL.md\\n.agents/skills/example-task/references/prompt-patterns.md\\n.agents/skills/example-task/scripts/inspect_session.py\\nCHANGELOG.md\\nfeature-overview.md\\ndocs/en/configuration/data-locations.md\\ndocs/en/customization/wire-mode.md\\ndocs/en/guides/interaction.md\\ndocs/en/guides/sessions.md\\ndocs/en/reference/keyboard.md\\ndocs/en/reference/kimi-command.md\\ndocs/en/reference/kimi-info.md\\ndocs/en/reference/kimi-vis.md\\ndocs/en/release-notes/changelog.md\\ndoc... [truncated]"}],"toolCalls":[],"toolCallId":"tool_jOGB6wicU1PmefwL15U57RUT"}} @@ -122,9 +144,13 @@ exports[`migration snapshot: large-100msgs > migration succeeds and matches snap {"type":"context.append_message","message":{"role":"assistant","content":[{"type":"think","think":"所有验证都通过了!现在让我总结冒烟测试的结果。"},{"type":"text","text":"所有验证完成!现在更新 todo 并汇报结论:"}],"toolCalls":[{"type":"function","id":"tool_CcJKFj7faabalZQ9X1gPrTbr","function":{"name":"SetTodoList","arguments":"{\\"todos\\": [{\\"title\\": \\"准备测试环境(创建隔离 work-dir)\\", \\"status\\": \\"done\\"}, {\\"title\\": \\"探索轮:验证工具可用性和接口\\", \\"status\\": \\"done\\"}, {\\"title\\": \\"执行轮1:正常路径 - 后台任务生命周期\\", \\"status\\": \\"done\\"}, {\\"title\\": \\"执行轮2:边界条件 - 超时和停止\\", \\"status\\": \\"done\\"}, {\\"title\\": \\"执行轮3:超时场景测试\\", \\"status\\": \\"done\\"}, {\\"title\\": \\"观察轮:检查 session 产物\\", \\"status\\": \\"done\\"}, {\\"title\\": \\"汇报结论\\", \\"status\\": \\"done\\"}]}"}}]}} {"type":"context.append_message","message":{"role":"tool","content":[{"type":"text","text":"Todo list updated"}],"toolCalls":[],"toolCallId":"tool_CcJKFj7faabalZQ9X1gPrTbr"}} {"type":"context.append_message","message":{"role":"assistant","content":[{"type":"think","think":"所有测试完成。现在整理完整的测试报告。"},{"type":"text","text":"---\\n\\n## 冒烟测试报告\\n\\n### 测试范围\\n**后台任务系统 + 通知系统** - 本次变更新增的核心功能\\n\\n### 已确认的行为 ✅\\n\\n| 功能 | 验证结果 |\\n|------|----------|\\n| **Shell 后台模式** | \`run_in_background=true\` + \`description\` 参数正常工作 |\\n| **TaskList** | 正确列出活跃/全部任务,显示 task_id/kind/status/description |\\n| **TaskOutput** | \`block=true/false\` 模式工作正常,返回完整任务状态和输出 |\\n| **TaskStop** | 成功发送 SIGTERM 停止任务,支持自定义 reason |\\n| **任务超时** | timeout 参数正确终止超时任务,exit_code=-15 |\\n| **Wire Notification** | \`Notification\` 事件正确发送,包含完整 payload |\\n| **Notification 持久化** | event.json + d... [truncated]"}],"toolCalls":[]}} +{"type":"turn.ended","agentId":"main","turnId":1,"reason":"completed","time":} +{"type":"turn.prompt","agentId":"main","input":[{"type":"text","text":"source /Users/example/proj/example-project/.venv/bin/activate"}],"origin":{"kind":"user"},"time":} {"type":"context.append_message","message":{"role":"user","content":[{"type":"text","text":"source /Users/example/proj/example-project/.venv/bin/activate"}],"toolCalls":[]}} {"type":"context.append_message","message":{"role":"assistant","content":[{"type":"think","think":"用户只是在执行激活虚拟环境的命令,这是一个 shell 命令。我应该执行它并确认环境已激活。"}],"toolCalls":[{"type":"function","id":"tool_oKHd9KBRZQ3pT7x7XgOWhcMJ","function":{"name":"Shell","arguments":"{\\"command\\": \\"source /Users/example/proj/example-project/.venv/bin/activate && echo \\\\\\"Virtual environment activated\\\\\\" && which python && python --version\\"}"}}]}} {"type":"context.append_message","message":{"role":"tool","content":[{"type":"text","text":"Command executed successfully."},{"type":"text","text":"Virtual environment activated\\n/Users/example/proj/example-project/.venv/bin/python\\nPython 3.14.2\\n"}],"toolCalls":[],"toolCallId":"tool_oKHd9KBRZQ3pT7x7XgOWhcMJ"}} +{"type":"turn.ended","agentId":"main","turnId":2,"reason":"completed","time":} +{"type":"token_counting.measured","agentId":"main","length":59,"tokens":62828,"time":} ", } `; @@ -132,11 +158,18 @@ exports[`migration snapshot: large-100msgs > migration succeeds and matches snap exports[`migration snapshot: legacy-protocol-1.3 > migration succeeds and matches snapshot 1`] = ` { "state": "{ + "id": "ses_legacy-protocol-1.3", + "version": 2, + "cwd": "/Users/example/proj", "createdAt": "", - "updatedAt": "", + "updatedAt": "".2852, + "archived": true, + "archivedAt": 1774779476844.07, "title": "帮我创建一个 plan,来优化我当前 diff 中的代码", + "titleKind": "custom", "isCustomTitle": true, "lastPrompt": "\\n\\nPlan mode is active. You are in a research and planning phase.\\n\\nIn plan mode, you should:\\n1. Thoroughly explore the codebase using Glob, Grep, and ReadFil", + "lastTurnReason": "completed", "agents": { "main": { "homedir": "/sessions/wd_proj_33c5ea5aa7eb/ses_legacy-protocol-1.3/agents/main", @@ -146,11 +179,12 @@ exports[`migration snapshot: legacy-protocol-1.3 > migration succeeds and matche }, "custom": { "imported_from_kimi_cli": true, + "import_format_version": 2, "kimi_cli_source_path": "", "kimi_cli_session_id": "legacy-protocol-1.3", "kimi_cli_wire_protocol": "1.3", "imported_at": "", - "archived": true, + "auto_archive_exempt": false, "vscode_legacy_approval": { "yolo": false, "afk": false @@ -158,10 +192,14 @@ exports[`migration snapshot: legacy-protocol-1.3 > migration succeeds and matche } }", "wire": "{"type":"metadata","protocol_version":"1.0","created_at":} +{"type":"turn.prompt","agentId":"main","input":[{"type":"text","text":"帮我写一个示例函数,处理字符串清理和分块"}],"origin":{"kind":"user"},"time":} {"type":"context.append_message","message":{"role":"user","content":[{"type":"text","text":"帮我写一个示例函数,处理字符串清理和分块"}],"toolCalls":[]}} +{"type":"turn.prompt","agentId":"main","input":[{"type":"text","text":"\\n\\nPlan mode is active. You are in a research and planning phase.\\n\\nIn plan mode, you should:\\n1. Thoroughly explore the codebase using Glob, Grep, and ReadFile tools.\\n2. Understand existing patterns and architecture.\\n3. Design an implementation approach.\\n4. Write your plan to the plan file using WriteFile.\\n5. Use AskUserQuestion if you need to clarify approaches.\\n6. When your plan is complete, call ExitPlanMode.\\n\\nRemember: DO NOT write or edit code files... [truncated]"}],"origin":{"kind":"user"},"time":} {"type":"context.append_message","message":{"role":"user","content":[{"type":"text","text":"\\n\\nPlan mode is active. You are in a research and planning phase.\\n\\nIn plan mode, you should:\\n1. Thoroughly explore the codebase using Glob, Grep, and ReadFile tools.\\n2. Understand existing patterns and architecture.\\n3. Design an implementation approach.\\n4. Write your plan to the plan file using WriteFile.\\n5. Use AskUserQuestion if you need to clarify approaches.\\n6. When your plan is complete, call ExitPlanMode.\\n\\nRemember: DO NOT write or edit code files... [truncated]"}],"toolCalls":[]}} {"type":"context.append_message","message":{"role":"assistant","content":[{"type":"think","think":"用户想要写一个示例函数,处理字符串清理和分块。我先看看当前 diff 中有哪些相关代码可以参考。\\n\\n让我先检查一下当前的 git diff。"}],"toolCalls":[{"type":"function","id":"Shell:0","function":{"name":"Shell","arguments":"{\\"command\\": \\"git diff --no-color\\"}"}}]}} {"type":"context.append_message","message":{"role":"tool","content":[{"type":"text","text":"Command executed successfully."},{"type":"text","text":"diff --git a/example.py b/example.py\\nindex 0000aaaa..1111bbbb 100644\\n--- a/example.py\\n+++ b/example.py\\n@@ -1,12 +1,16 @@\\n class Greeter:\\n def __init__(self, name: str) -> None:\\n self.name = name\\n+ self.greetings: list[str] = []\\n \\n def greet(self) -> str:\\n- return f\\"Hello, {self.name}!\\"\\n+ msg = f\\"Hello, {self.name}!\\"\\n+ self.greetings.append(msg)\\n+ return msg\\n \\n- def farewell(self) -> str:\\n- return f\\"Goodbye, {self.name}!\\"\\n+ def farewell(self, formal: bool = False) -> str:\\n+ prefix = \\"Farewell\\" if formal else \\"Goodbye\\"\\n+ return f\\"{prefix}, {self.name}!\\"\\n \\ndiff --git a/example/utils.py b/example/utils.py\\nindex 22223333..44445555 100644\\n--- a/example/utils.py\\n+++ b/example/utils.py\\n@@ -10,16 +10,20 @@ def slugify(text: str) -> str:\\n result = text.lower()\\n result = result.replace(\\" \\", \\"-\\")\\n- # Remove punctuation\\n- result = \\"\\".join(c for c in result if c.isalnum() or c == \\"-\\")\\n+ # Remove punctuation and collapse repeated dashes\\n+ cleaned = \\"\\".join(c for c in result if c.isalnum() or c == \\"-\\")\\n+ parts = [p for p in cleaned.split(\\"-\\") if p]\\n+ result = \\"-\\".join(parts)\\n return result\\n \\n \\n def chunk(items: list[str], size: int) -> list[list[str]]:\\n- if size <= 0:\\n- raise ValueError(\\"size must be positive\\")\\n- return [items[i:i + size] for i in range(0, len(items), size)]\\n+ if size <= 0:\\n+ raise ValueError(\\"chunk size must be a positive integer\\")\\n+ if not items:\\n+ return []\\n+ return [items[i : i + size] for i in range(0, len(items), size)]\\n \\n \\n def truncate(text: str, limit: int = 80) -> str:\\n@@ -32,8 +36,12 @@ def truncate(text: str, limit: int = 80) -> str:\\n return text\\n return text[: limit - 1] + \\"…\\"\\n \\n+\\n+def normalize_whitespace(text: str) -> str:\\n+ return \\" \\".join(text.split())\\n+\\n diff --git a/example/cli.py b/example/cli.py\\nindex 66667777..88889999 100644\\n--- a/example/cli.py\\n+++ b/example/cli.py\\n@@ -5,12 +5,18 @@ from .utils import slugify, truncate\\n \\n def build_parser() -> argparse.ArgumentParser:\\n parser = argparse.ArgumentParser(prog=\\"example\\")\\n- parser.add_argument(\\"--name\\", default=\\"world\\")\\n+ parser.add_argument(\\"--name\\", default=\\"world\\", help=\\"who to greet\\")\\n+ parser.add_argument(\\n+ \\"--formal\\",\\n+ action=\\"store_true\\",\\n+ help=\\"use a formal farewell tone\\",\\n+ )\\n return parser\\n \\n \\n def main(argv: list[str] | None = None) -> int:\\n parser = build_parser()\\n args = parser.parse_args(argv)\\n- print(f\\"Hello, {args.name}!\\")\\n+ greeter = Greeter(args.name)\\n+ print(greeter.greet())\\n+ print(greeter.farewell(formal=args.formal))\\n return 0\\ndiff --git a/example/config.py b/example/config.py\\nindex ccccdddd..eeeeffff 100644\\n--- a/example/config.py\\n+++ b/example/config.py\\n@@ -1,15 +1,22 @@\\n from dataclasses import dataclass, field\\n+from pathlib import Path\\n \\n \\n @dataclass\\n class AppConfig:\\n name: str = \\"example\\"\\n verbose: bool = False\\n- timeout: int = 30\\n+ timeout_seconds: int = 30\\n+ cache_dir: Path | None = None\\n features: list[str] = field(default_factory=list)\\n \\n def is_feature_enabled(self, feature: str) -> bool:\\n return feature in self.features\\n+\\n+ def with_feature(self, feature: str) -> \\"AppConfig\\":\\n+ if feature in self.features:\\n+ return self\\n+ return AppConfig(**{**self.__dict__, \\"features\\": [*self.features, feature]})\\n diff --git a/tests/test_example.py b/tests/test_example.py\\nindex aabbccdd..eeff0011 100644\\n--- a/tests/test_example.py\\n+++ b/tests/test_example.py\\n@@ -1,10 +1,22 @@\\n from example import Greeter\\n+from example.utils import slugify, chunk, normalize_whitespace\\n+from example.config import AppConfig\\n \\n \\n def test_greeter_greet() -> None:\\n g = Greeter(\\"Ada\\")\\n assert g.greet() == \\"Hello, Ada!\\"\\n+ assert g.greetings == [\\"Hello, Ada!\\"]\\n \\n \\n def test_greeter_farewell() -> None:\\n g = Greeter(\\"Ada\\")\\n assert g.farewell() == \\"Goodbye, Ada!\\"\\n+ assert g.farewell(formal=True) == \\"Farewell, Ada!\\"\\n+\\n+\\n+def test_slugify_collapses_dashes() -> None:\\n+ assert slugify(\\"hello world!!!\\") == \\"hello-world\\"\\n+ assert slugify(\\"--abc--def--\\") == \\"abc-def\\"\\n+\\n+\\n+def test_config_with_feature_is_idempotent() -> None:\\n+ cfg = AppConfig().with_feature(\\"beta\\").with_feature(\\"beta\\")\\n+ assert cfg.features == [\\"beta ... [truncated]"}],"toolCalls":[],"toolCallId":"Shell:0"}} +{"type":"turn.ended","agentId":"main","turnId":1,"reason":"completed","time":} +{"type":"token_counting.measured","agentId":"main","length":4,"tokens":11286,"time":} ", } `; @@ -169,11 +207,17 @@ exports[`migration snapshot: legacy-protocol-1.3 > migration succeeds and matche exports[`migration snapshot: recent-protocol-1.10 > migration succeeds and matches snapshot 1`] = ` { "state": "{ + "id": "ses_recent-protocol-1.10", + "version": 2, + "cwd": "/Users/example/proj", "createdAt": "", "updatedAt": "", + "archived": false, "title": "hi", + "titleKind": "custom", "isCustomTitle": true, "lastPrompt": "探索这个项目", + "lastTurnReason": "completed", "agents": { "main": { "homedir": "/sessions/wd_proj_33c5ea5aa7eb/ses_recent-protocol-1.10/agents/main", @@ -183,11 +227,12 @@ exports[`migration snapshot: recent-protocol-1.10 > migration succeeds and match }, "custom": { "imported_from_kimi_cli": true, + "import_format_version": 2, "kimi_cli_source_path": "", "kimi_cli_session_id": "recent-protocol-1.10", "kimi_cli_wire_protocol": "1.10", "imported_at": "", - "archived": false, + "auto_archive_exempt": false, "vscode_legacy_approval": { "yolo": false, "afk": false @@ -195,13 +240,17 @@ exports[`migration snapshot: recent-protocol-1.10 > migration succeeds and match } }", "wire": "{"type":"metadata","protocol_version":"1.0","created_at":} +{"type":"turn.prompt","agentId":"main","input":[{"type":"text","text":"hi"}],"origin":{"kind":"user"},"time":} {"type":"context.append_message","message":{"role":"user","content":[{"type":"text","text":"hi"}],"toolCalls":[]}} {"type":"context.append_message","message":{"role":"assistant","content":[{"type":"think","think":"The user said \\"hi\\". This is a simple greeting. I should respond in a friendly, helpful manner. Since there's no specific task or question, I'll just greet them back and ask how I can help."},{"type":"text","text":"Hello! How can I help you today?"}],"toolCalls":[]}} +{"type":"turn.ended","agentId":"main","turnId":0,"reason":"completed","time":} +{"type":"turn.prompt","agentId":"main","input":[{"type":"text","text":"探索这个项目"}],"origin":{"kind":"user"},"time":} {"type":"context.append_message","message":{"role":"user","content":[{"type":"text","text":"探索这个项目"}],"toolCalls":[]}} {"type":"context.append_message","message":{"role":"assistant","content":[{"type":"think","think":"用户要求探索这个项目。让我先了解一下项目的整体结构,然后提供一些关键信息。\\n\\n从目录结构来看,这是一个示例 Python 项目。让我先查看一些关键文件来更好地理解项目。"}],"toolCalls":[{"type":"function","id":"Shell:0","function":{"name":"Shell","arguments":"{\\"command\\": \\"find . -maxdepth 2 -type f -name \\\\\\"*.md\\\\\\" | grep -E \\\\\\"(README|CHANGELOG|CONTRIBUTING|AGENTS)\\\\\\" | sort\\"}"}},{"type":"function","id":"Shell:1","function":{"name":"Shell","arguments":"{\\"command\\": \\"ls -la pyproject.toml Makefile 2>/dev/null || echo \\\\\\"No pyproject.toml or Makefile found\\\\\\"\\"}"}},{"type":"function","id":"Shell:2","function":{"name":"Shell","arguments":"{\\"command\\": \\"git log --oneline -10 2>/dev/null || echo \\\\\\"Not a git repo or no commits\\\\\\"\\"}"}}]}} {"type":"context.append_message","message":{"role":"tool","toolCallId":"Shell:1","content":[{"type":"text","text":"[tool result unavailable — session imported from kimi-cli]"}],"toolCalls":[]}} {"type":"context.append_message","message":{"role":"tool","toolCallId":"Shell:2","content":[{"type":"text","text":"[tool result unavailable — session imported from kimi-cli]"}],"toolCalls":[]}} {"type":"context.append_message","message":{"role":"tool","content":[{"type":"text","text":"Command executed successfully."},{"type":"text","text":"./.pytest_cache/README.md\\n./AGENTS.md\\n./CHANGELOG.md\\n./CONTRIBUTING.md\\n./docs/AGENTS.md\\n./README.md\\n./tests_e2e/AGENTS.md\\n"}],"toolCalls":[],"toolCallId":"Shell:0"}} +{"type":"turn.ended","agentId":"main","turnId":1,"reason":"completed","time":} ", } `; @@ -209,11 +258,17 @@ exports[`migration snapshot: recent-protocol-1.10 > migration succeeds and match exports[`migration snapshot: tiny-hello-world > migration succeeds and matches snapshot 1`] = ` { "state": "{ + "id": "ses_tiny-hello-world", + "version": 2, + "cwd": "/Users/example/proj", "createdAt": "", "updatedAt": "", + "archived": false, "title": "hi", + "titleKind": "custom", "isCustomTitle": true, "lastPrompt": "hi", + "lastTurnReason": "completed", "agents": { "main": { "homedir": "/sessions/wd_proj_33c5ea5aa7eb/ses_tiny-hello-world/agents/main", @@ -223,11 +278,12 @@ exports[`migration snapshot: tiny-hello-world > migration succeeds and matches s }, "custom": { "imported_from_kimi_cli": true, + "import_format_version": 2, "kimi_cli_source_path": "", "kimi_cli_session_id": "tiny-hello-world", "kimi_cli_wire_protocol": "1.10", "imported_at": "", - "archived": false, + "auto_archive_exempt": false, "vscode_legacy_approval": { "yolo": false, "afk": false @@ -235,8 +291,11 @@ exports[`migration snapshot: tiny-hello-world > migration succeeds and matches s } }", "wire": "{"type":"metadata","protocol_version":"1.0","created_at":} +{"type":"turn.prompt","agentId":"main","input":[{"type":"text","text":"hi"}],"origin":{"kind":"user"},"time":} {"type":"context.append_message","message":{"role":"user","content":[{"type":"text","text":"hi"}],"toolCalls":[]}} {"type":"context.append_message","message":{"role":"assistant","content":[{"type":"text","text":"Hello! How can I help?"}],"toolCalls":[]}} +{"type":"turn.ended","agentId":"main","turnId":0,"reason":"completed","time":} +{"type":"token_counting.measured","agentId":"main","length":2,"tokens":9133,"time":} ", } `; @@ -244,11 +303,17 @@ exports[`migration snapshot: tiny-hello-world > migration succeeds and matches s exports[`migration snapshot: with-image > migration succeeds and matches snapshot 1`] = ` { "state": "{ + "id": "ses_with-image", + "version": 2, + "cwd": "/Users/example/proj", "createdAt": "", "updatedAt": "", + "archived": false, "title": "Describe this audio clip.", + "titleKind": "replaceable", "isCustomTitle": false, "lastPrompt": "Describe this audio clip.", + "lastTurnReason": "completed", "agents": { "main": { "homedir": "/sessions/wd_proj_33c5ea5aa7eb/ses_with-image/agents/main", @@ -258,18 +323,24 @@ exports[`migration snapshot: with-image > migration succeeds and matches snapsho }, "custom": { "imported_from_kimi_cli": true, + "import_format_version": 2, "kimi_cli_source_path": "", "kimi_cli_session_id": "with-image", "kimi_cli_wire_protocol": "1.7", "imported_at": "", - "archived": false + "auto_archive_exempt": false } }", "wire": "{"type":"metadata","protocol_version":"1.0","created_at":} +{"type":"turn.prompt","agentId":"main","input":[{"type":"text","text":"Describe this image."},{"type":"image_url","imageUrl":{"url":"data:image/png;base64,AAAA","id":"img-1"}}],"origin":{"kind":"user"},"time":} {"type":"context.append_message","message":{"role":"user","content":[{"type":"text","text":"Describe this image."},{"type":"image_url","imageUrl":{"url":"data:image/png;base64,AAAA","id":"img-1"}}],"toolCalls":[]}} +{"type":"turn.prompt","agentId":"main","input":[{"type":"text","text":"\\nYou are running in non-interactive mode. The user cannot answer questions or provide feedback during execution.\\n- Do NOT call AskUserQuestion. If you need to make a decision, make your best judgment and proceed.\\n- For EnterPlanMode / ExitPlanMode, they will be auto-approved. You can use them normally but expect no user feedback.\\n"}],"origin":{"kind":"user"},"time":} {"type":"context.append_message","message":{"role":"user","content":[{"type":"text","text":"\\nYou are running in non-interactive mode. The user cannot answer questions or provide feedback during execution.\\n- Do NOT call AskUserQuestion. If you need to make a decision, make your best judgment and proceed.\\n- For EnterPlanMode / ExitPlanMode, they will be auto-approved. You can use them normally but expect no user feedback.\\n"}],"toolCalls":[]}} {"type":"context.append_message","message":{"role":"assistant","content":[{"type":"think","think":"analyzing the image"},{"type":"text","text":"The image shows a simple scene."}],"toolCalls":[]}} +{"type":"turn.ended","agentId":"main","turnId":1,"reason":"completed","time":} +{"type":"turn.prompt","agentId":"main","input":[{"type":"text","text":"Describe this audio clip."},{"type":"audio_url","audioUrl":{"url":"data:audio/wav;base64,AAAA","id":"aud-1"}}],"origin":{"kind":"user"},"time":} {"type":"context.append_message","message":{"role":"user","content":[{"type":"text","text":"Describe this audio clip."},{"type":"audio_url","audioUrl":{"url":"data:audio/wav;base64,AAAA","id":"aud-1"}}],"toolCalls":[]}} +{"type":"token_counting.measured","agentId":"main","length":4,"tokens":16,"time":} ", } `; @@ -277,11 +348,17 @@ exports[`migration snapshot: with-image > migration succeeds and matches snapsho exports[`migration snapshot: with-subagent-collapsed > migration succeeds and matches snapshot 1`] = ` { "state": "{ + "id": "ses_with-subagent-collapsed", + "version": 2, + "cwd": "/Users/example/proj", "createdAt": "", - "updatedAt": "", + "updatedAt": "".7732, + "archived": false, "title": "你启动三个 sub agent,每个 sub,都要运行一些需要被批准的简单指令,运行两个以上", + "titleKind": "custom", "isCustomTitle": true, "lastPrompt": "你启动三个 sub agent,每个 sub,都要运行一些需要被批准的简单指令,运行两个以上", + "lastTurnReason": "completed", "agents": { "main": { "homedir": "/sessions/wd_proj_33c5ea5aa7eb/ses_with-subagent-collapsed/agents/main", @@ -291,11 +368,12 @@ exports[`migration snapshot: with-subagent-collapsed > migration succeeds and ma }, "custom": { "imported_from_kimi_cli": true, + "import_format_version": 2, "kimi_cli_source_path": "", "kimi_cli_session_id": "with-subagent-collapsed", "kimi_cli_wire_protocol": "1.6", "imported_at": "", - "archived": false, + "auto_archive_exempt": false, "vscode_legacy_approval": { "yolo": false, "afk": false @@ -303,11 +381,14 @@ exports[`migration snapshot: with-subagent-collapsed > migration succeeds and ma } }", "wire": "{"type":"metadata","protocol_version":"1.0","created_at":} +{"type":"turn.prompt","agentId":"main","input":[{"type":"text","text":"你启动三个 sub agent,每个 sub,都要运行一些需要被批准的简单指令,运行两个以上"}],"origin":{"kind":"user"},"time":} {"type":"context.append_message","message":{"role":"user","content":[{"type":"text","text":"你启动三个 sub agent,每个 sub,都要运行一些需要被批准的简单指令,运行两个以上"}],"toolCalls":[]}} {"type":"context.append_message","message":{"role":"assistant","content":[{"type":"think","think":"用户要求我启动三个 sub agent,每个 sub agent 都需要运行一些需要被批准的简单指令,并且要求运行两个以上。\\n\\n我需要使用 Agent 工具来创建三个 sub agent。每个 sub agent 都应该执行一些需要批准的操作,比如文件写入或 shell 命令。\\n\\n让我创建三个 sub agent,每个都执行不同的需要批准的任务:\\n1. 第一个 sub agent:创建一个测试文件\\n2. 第二个 sub agent:执行一个简单的 shell 命令\\n3. 第三个 sub agent:创建另一个测试文件\\n\\n我需要并行启动这三个 sub agent。"},{"type":"text","text":"我来启动三个 sub agent,每个都会执行需要批准的简单指令。"}],"toolCalls":[{"type":"function","id":"tool_Ct3NdbCjJ2Xbl7YzithE2Dy8","function":{"name":"Agent","arguments":"{\\"description\\": \\"Sub agent 1: 创建文件\\", \\"prompt\\": \\"你是一个测试用的 sub agent。请执行以下需要批准的操作:\\\\n\\\\n1. 使用 WriteFile 工具在当前目录创建一个名为 \`subagent1_test.txt\` 的文件,内容为 \\\\\\"Hello from Sub Agent 1!\\\\\\"\\\\n\\\\n这个操作需要用户批准,请等待批准并完成操作。\\\\n\\\\n完成后,报告操作结果。\\"}"}},{"type":"function","id":"tool_OtoGSYsrDQpEaav0hLYbXDhd","function":{"name":"Agent","arguments":"{\\"description\\": \\"Sub agent 2: Shell 命令\\", \\"prompt\\": \\"你是一个测试用的 sub agent。请执行以下需要批准的操作:\\\\n\\\\n1. 使用 Shell 工具执行命令 \`echo \\\\\\"Hello from Sub Agent 2!\\\\\\" > subagent2_test.txt\`\\\\n\\\\n这个操作需要用户批准,请等待批准并完成操作。\\\\n\\\\n完成后,报告操作结果。\\"}"}},{"type":"function","id":"tool_zi4b3Pfbiia2kxPMUvjL4cVN","function":{"name":"Agent","arguments":"{\\"description\\": \\"Sub agent 3: 创建文件\\", \\"prompt\\": \\"你是一个测试用的 sub agent。请执行以下需要批准的操作:\\\\n\\\\n1. 使用 WriteFile 工具在当前目录创建一个名为 \`subagent3_test.txt\` 的文件,内容为 \\\\\\"Hello from Sub Agent 3!\\\\\\"\\\\n\\\\n这个操作需要用户批准,请等待批准并完成操作。\\\\n\\\\n完成后,报告操作结果。\\"}"}}]}} {"type":"context.append_message","message":{"role":"tool","content":[{"type":"text","text":"agent_id: ac1f74988\\nresumed: false\\nactual_subagent_type: coder\\nstatus: completed\\n\\n[summary]\\n## 详细操作报告\\n\\n### 任务背景\\n作为测试用的 sub agent,我被要求执行一个需要用户批准的文件写入操作。这是一个测试场景,旨在验证带批准流程的工具调用机制。\\n\\n### 执行过程\\n\\n#### 第一次尝试(被拒绝)\\n- **操作**: 使用 \`WriteFile\` 工具创建文件 \`subagent1_test.txt\`\\n- **预期内容**: \`\\"Hello from Sub Agent 1!\\"\`\\n- **结果**: 用户拒绝了此操作\\n- **用户反馈**: 要求将文件名改为 \`kk\`\\n\\n#### 第二次尝试(成功)\\n- **操作**: 使用 \`WriteFile\` 工具创建文件 \`kk\`\\n- **内容**: \`\\"Hello from Sub Agent 1!\\"\`\\n- **结果**: ✅ 成功创建/覆盖文件\\n- **文件大小**: 23 字节\\n\\n### 技术细节\\n\\n1. **工具使用**: \`W... [truncated]"}],"toolCalls":[],"toolCallId":"tool_Ct3NdbCjJ2Xbl7YzithE2Dy8"}} {"type":"context.append_message","message":{"role":"tool","content":[{"type":"text","text":"agent_id: aa373b195\\nresumed: false\\nactual_subagent_type: coder\\nstatus: completed\\n\\n[summary]\\n## 详细操作结果报告\\n\\n### 1. 任务概述\\n\\n作为测试用 sub agent,我成功执行了需要用户批准的 Shell 命令操作,创建了测试文件并验证了其内容完整性。\\n\\n### 2. 执行详情\\n\\n#### 2.1 命令信息\\n- **命令类型**: Shell 命令执行\\n- **具体命令**: \`echo \\"Hello from Sub Agent 2!\\" > subagent2_test.txt\`\\n- **工作目录**: \`/Users/example/proj/example-project\`\\n- **执行时间**: 2026-03-23T21:42:43+08:00(系统参考时间)\\n\\n#### 2.2 技术实现\\n该命令使用了以下 Shell 特性:\\n- \`echo\` 命令:输出字符串到标准输出\\n- 重定向操作符 \`>\`:将标准输出重定向到文件\\n- 如果文件不存在则... [truncated]"}],"toolCalls":[],"toolCallId":"tool_OtoGSYsrDQpEaav0hLYbXDhd"}} {"type":"context.append_message","message":{"role":"tool","content":[{"type":"text","text":"agent_id: aadd98577\\nresumed: false\\nactual_subagent_type: coder\\nstatus: completed\\n\\n[summary]\\n**详细操作报告 - Sub Agent 3**\\n\\n---\\n\\n## 1. 任务概述\\n\\n本次任务要求以测试用的 sub agent 身份执行一个需要用户批准的操作:在指定目录创建一个包含特定内容的文本文件。\\n\\n---\\n\\n## 2. 操作执行详情\\n\\n### 2.1 第一次尝试\\n\\n| 属性 | 详情 |\\n|------|------|\\n| **目标文件路径** | \`subagent3_test.txt\` |\\n| **请求内容** | \`Hello from Sub Agent 3!\` |\\n| **操作类型** | WriteFile(覆盖模式,默认)|\\n| **执行状态** | 被拒绝 |\\n| **用户反馈** | \\"用另一个文件名再试一次\\" |\\n\\n**技术分析**:系统在第一次请求时触发了用户审批流程。用户拒绝了原始文件名 \`subagent3_test.txt\`,并明确要求使用另一个文件名再试。这表... [truncated]"}],"toolCalls":[],"toolCallId":"tool_zi4b3Pfbiia2kxPMUvjL4cVN"}} +{"type":"turn.ended","agentId":"main","turnId":0,"reason":"completed","time":} +{"type":"token_counting.measured","agentId":"main","length":5,"tokens":12507,"time":} ", } `; @@ -315,11 +396,17 @@ exports[`migration snapshot: with-subagent-collapsed > migration succeeds and ma exports[`migration snapshot: with-thinking > migration succeeds and matches snapshot 1`] = ` { "state": "{ + "id": "ses_with-thinking", + "version": 2, + "cwd": "/Users/example/proj", "createdAt": "", "updatedAt": "", + "archived": false, "title": "Describe this image.", + "titleKind": "custom", "isCustomTitle": true, "lastPrompt": "Describe this video.", + "lastTurnReason": "completed", "agents": { "main": { "homedir": "/sessions/wd_proj_33c5ea5aa7eb/ses_with-thinking/agents/main", @@ -329,11 +416,12 @@ exports[`migration snapshot: with-thinking > migration succeeds and matches snap }, "custom": { "imported_from_kimi_cli": true, + "import_format_version": 2, "kimi_cli_source_path": "", "kimi_cli_session_id": "with-thinking", "kimi_cli_wire_protocol": "1.9", "imported_at": "", - "archived": false, + "auto_archive_exempt": false, "vscode_legacy_approval": { "yolo": false, "afk": false @@ -341,10 +429,15 @@ exports[`migration snapshot: with-thinking > migration succeeds and matches snap } }", "wire": "{"type":"metadata","protocol_version":"1.0","created_at":} +{"type":"turn.prompt","agentId":"main","input":[{"type":"text","text":"Describe this image."}],"origin":{"kind":"user"},"time":} {"type":"context.append_message","message":{"role":"user","content":[{"type":"text","text":"Describe this image."}],"toolCalls":[]}} +{"type":"turn.prompt","agentId":"main","input":[{"type":"text","text":"\\nYou are running in non-interactive mode. The user cannot answer questions or provide feedback during execution.\\n- Do NOT call AskUserQuestion. If you need to make a decision, make your best judgment and proceed.\\n- For EnterPlanMode / ExitPlanMode, they will be auto-approved. You can use them normally but expect no user feedback.\\n"}],"origin":{"kind":"user"},"time":} {"type":"context.append_message","message":{"role":"user","content":[{"type":"text","text":"\\nYou are running in non-interactive mode. The user cannot answer questions or provide feedback during execution.\\n- Do NOT call AskUserQuestion. If you need to make a decision, make your best judgment and proceed.\\n- For EnterPlanMode / ExitPlanMode, they will be auto-approved. You can use them normally but expect no user feedback.\\n"}],"toolCalls":[]}} {"type":"context.append_message","message":{"role":"assistant","content":[{"type":"think","think":"analyzing the image"},{"type":"text","text":"The image shows a simple scene."}],"toolCalls":[]}} +{"type":"turn.ended","agentId":"main","turnId":1,"reason":"completed","time":} +{"type":"turn.prompt","agentId":"main","input":[{"type":"text","text":"Describe this video."}],"origin":{"kind":"user"},"time":} {"type":"context.append_message","message":{"role":"user","content":[{"type":"text","text":"Describe this video."}],"toolCalls":[]}} +{"type":"token_counting.measured","agentId":"main","length":4,"tokens":16,"time":} ", } `; @@ -352,11 +445,17 @@ exports[`migration snapshot: with-thinking > migration succeeds and matches snap exports[`migration snapshot: with-tool-calls > migration succeeds and matches snapshot 1`] = ` { "state": "{ + "id": "ses_with-tool-calls", + "version": 2, + "cwd": "/Users/example/proj", "createdAt": "", "updatedAt": "", + "archived": false, "title": "run echo hi", + "titleKind": "custom", "isCustomTitle": true, "lastPrompt": "\\nYou are running in non-interactive mode. The user cannot answer questions or provide feedback during execution.\\n- Do NOT call AskUserQuestion. If you need to make a decision, make yo", + "lastTurnReason": "completed", "agents": { "main": { "homedir": "/sessions/wd_proj_33c5ea5aa7eb/ses_with-tool-calls/agents/main", @@ -366,11 +465,12 @@ exports[`migration snapshot: with-tool-calls > migration succeeds and matches sn }, "custom": { "imported_from_kimi_cli": true, + "import_format_version": 2, "kimi_cli_source_path": "", "kimi_cli_session_id": "with-tool-calls", "kimi_cli_wire_protocol": "1.8", "imported_at": "", - "archived": false, + "auto_archive_exempt": false, "vscode_legacy_approval": { "yolo": false, "afk": false @@ -378,10 +478,14 @@ exports[`migration snapshot: with-tool-calls > migration succeeds and matches sn } }", "wire": "{"type":"metadata","protocol_version":"1.0","created_at":} +{"type":"turn.prompt","agentId":"main","input":[{"type":"text","text":"run echo hi"}],"origin":{"kind":"user"},"time":} {"type":"context.append_message","message":{"role":"user","content":[{"type":"text","text":"run echo hi"}],"toolCalls":[]}} +{"type":"turn.prompt","agentId":"main","input":[{"type":"text","text":"\\nYou are running in non-interactive mode. The user cannot answer questions or provide feedback during execution.\\n- Do NOT call AskUserQuestion. If you need to make a decision, make your best judgment and proceed.\\n- For EnterPlanMode / ExitPlanMode, they will be auto-approved. You can use them normally but expect no user feedback.\\n"}],"origin":{"kind":"user"},"time":} {"type":"context.append_message","message":{"role":"user","content":[{"type":"text","text":"\\nYou are running in non-interactive mode. The user cannot answer questions or provide feedback during execution.\\n- Do NOT call AskUserQuestion. If you need to make a decision, make your best judgment and proceed.\\n- For EnterPlanMode / ExitPlanMode, they will be auto-approved. You can use them normally but expect no user feedback.\\n"}],"toolCalls":[]}} {"type":"context.append_message","message":{"role":"assistant","content":[],"toolCalls":[{"type":"function","id":"tc1","function":{"name":"Shell","arguments":"{\\"command\\": \\"echo hi\\"}"}}],"toolCallDisplays":{"tc1":{"kind":"generic","summary":"Hook blocked"}}}} {"type":"context.append_message","message":{"role":"tool","content":[{"type":"text","text":"ERROR: Shell blocked by hook"}],"toolCalls":[],"toolCallId":"tc1"}} +{"type":"turn.ended","agentId":"main","turnId":1,"reason":"completed","time":} +{"type":"token_counting.measured","agentId":"main","length":4,"tokens":12,"time":} ", } `; diff --git a/packages/migration-legacy/test/sessions/classify.test.ts b/packages/migration-legacy/test/sessions/classify.test.ts index 06f3f8ddb61..25bf909992b 100644 --- a/packages/migration-legacy/test/sessions/classify.test.ts +++ b/packages/migration-legacy/test/sessions/classify.test.ts @@ -2,99 +2,165 @@ import { describe, expect, it, beforeEach, afterEach } from 'vitest'; import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { classifySessionDir } from '../../src/sessions/classify.js'; +import { classifyLegacySession } from '../../src/sessions/classify.js'; +import { listBucketSessions, type LegacySessionRef } from '../../src/sessions/source.js'; -let dir: string; +let bucket: string; beforeEach(async () => { - dir = await mkdtemp(join(tmpdir(), 'classify-')); + bucket = await mkdtemp(join(tmpdir(), 'classify-')); }); afterEach(async () => { - await rm(dir, { recursive: true, force: true }); + await rm(bucket, { recursive: true, force: true }); }); -async function makeSession(name: string, files: Record): Promise { - const path = join(dir, name); +async function makeSession(name: string, files: Record): Promise { + const path = join(bucket, name); await mkdir(path, { recursive: true }); for (const [k, v] of Object.entries(files)) { await writeFile(join(path, k), v, 'utf-8'); } - return path; } -describe('classifySessionDir', () => { +async function classify(name: string): Promise { + const refs = await listBucketSessions(bucket); + const ref = refs.find((r) => r.uuid === name); + expect(ref).toBeDefined(); + return classifyLegacySession(ref as LegacySessionRef); +} + +describe('classifyLegacySession', () => { it('placeholder: dir contains only a `test` file', async () => { - const p = await makeSession('uuid1', { test: 'test' }); - expect(await classifySessionDir(p)).toBe('placeholder'); + await makeSession('uuid1', { test: 'test' }); + expect(await classify('uuid1')).toBe('placeholder'); }); it('empty: dir has zero files', async () => { - const p = join(dir, 'uuid2'); - await mkdir(p, { recursive: true }); - expect(await classifySessionDir(p)).toBe('empty'); + await mkdir(join(bucket, 'uuid2'), { recursive: true }); + expect(await classify('uuid2')).toBe('empty'); }); it('malformed: dir missing both context.jsonl and state.json', async () => { - const p = await makeSession('uuid3', { 'wire.jsonl': '{}\n' }); - expect(await classifySessionDir(p)).toBe('malformed'); + await makeSession('uuid3', { 'wire.jsonl': '{}\n' }); + expect(await classify('uuid3')).toBe('malformed'); }); it('real: context.jsonl carries a user/assistant/tool message', async () => { - const p = await makeSession('uuid4', { + await makeSession('uuid4', { 'state.json': '{}', 'context.jsonl': '{"role":"_system_prompt","content":"hi"}\n{"role":"user","content":"hello"}\n', 'wire.jsonl': '', }); - expect(await classifySessionDir(p)).toBe('real'); + expect(await classify('uuid4')).toBe('real'); }); it('malformed: state.json only (no context.jsonl) is not migratable', async () => { - // `migrateOneSession` hard-fails without context.jsonl, so a state-only - // dir must not be classified as `real` — otherwise migration would enter - // its hard-fail path and surface the dir as a failure instead of a - // skipped-malformed entry. - const p = await makeSession('uuid5', { 'state.json': '{}' }); - expect(await classifySessionDir(p)).toBe('malformed'); + const path = join(bucket, 'uuid5'); + await mkdir(path, { recursive: true }); + await writeFile(join(path, 'state.json'), '{}', 'utf-8'); + expect(await classify('uuid5')).toBe('malformed'); }); it('real: context.jsonl alone is enough when it has a real message', async () => { - const p = await makeSession('uuid6', { + await makeSession('uuid6', { 'context.jsonl': '{"role":"assistant","content":[{"type":"text","text":"hi"}]}\n', }); - expect(await classifySessionDir(p)).toBe('real'); + expect(await classify('uuid6')).toBe('real'); }); it('empty: context.jsonl is a zero-byte file', async () => { - // The file exists but carries no conversation — an unused session. - const p = await makeSession('uuid7', { 'context.jsonl': '' }); - expect(await classifySessionDir(p)).toBe('empty'); + await makeSession('uuid7', { 'context.jsonl': '' }); + expect(await classify('uuid7')).toBe('empty'); }); it('empty: context.jsonl holds only a _system_prompt marker', async () => { - // A session the user cleared/reverted in kimi-cli: the live context is - // emptied, so it carries no migratable conversation. - const p = await makeSession('uuid8', { + await makeSession('uuid8', { 'context.jsonl': '{"role":"_system_prompt","content":"You are ..."}\n', }); - expect(await classifySessionDir(p)).toBe('empty'); + expect(await classify('uuid8')).toBe('empty'); }); it('empty: context.jsonl holds only _checkpoint / _usage markers', async () => { - const p = await makeSession('uuid9', { + await makeSession('uuid9', { 'context.jsonl': '{"role":"_checkpoint","id":0}\n{"role":"_usage","token_count":12}\n', }); - expect(await classifySessionDir(p)).toBe('empty'); + expect(await classify('uuid9')).toBe('empty'); }); it('real: context.jsonl is corrupt — migrateOneSession surfaces it as a failure', async () => { - // A corrupt context.jsonl must reach `migrateOneSession` so that the - // failure ends up in `sessionsFailed` and `migration-errors.log` — not - // silently absorbed by `sessionsSkippedMalformed` (which the result - // screen does not even render). Classify therefore routes corrupt - // contexts as `'real'` and lets the migration step report a real - // failure with diagnostic detail. - const p = await makeSession('uuid10', { + await makeSession('uuid10', { 'context.jsonl': 'not-json\n{broken\n}}}\n', }); - expect(await classifySessionDir(p)).toBe('real'); + expect(await classify('uuid10')).toBe('real'); + }); + + it('real: a title-only session (empty context + custom title) stays listed', async () => { + await makeSession('uuid11', { + 'context.jsonl': '', + 'state.json': JSON.stringify({ custom_title: 'My named session' }), + }); + expect(await classify('uuid11')).toBe('real'); + }); + + it('real: title from legacy metadata.json counts when state.json has none', async () => { + await makeSession('uuid12', { + 'context.jsonl': '{"role":"_checkpoint","id":0}\n', + 'state.json': '{}', + 'metadata.json': JSON.stringify({ title: 'Legacy Title', title_generated: true }), + }); + expect(await classify('uuid12')).toBe('real'); + }); + + it('empty: metadata.json title "Untitled" does not promote a session', async () => { + await makeSession('uuid13', { + 'context.jsonl': '', + 'metadata.json': JSON.stringify({ title: 'Untitled' }), + }); + expect(await classify('uuid13')).toBe('empty'); + }); + + it('real: a historical flat .jsonl session with real messages', async () => { + await writeFile( + join(bucket, 'uuid14.jsonl'), + '{"role":"user","content":"hello from the flat era"}\n', + 'utf-8', + ); + expect(await classify('uuid14')).toBe('real'); + }); + + it('empty: a flat .jsonl with only markers and no title', async () => { + await writeFile(join(bucket, 'uuid15.jsonl'), '{"role":"_checkpoint","id":0}\n', 'utf-8'); + expect(await classify('uuid15')).toBe('empty'); + }); + + it('real: dir context wins over a paired flat file', async () => { + await makeSession('uuid16', { + 'context.jsonl': '{"role":"user","content":"dir wins"}\n', + }); + await writeFile(join(bucket, 'uuid16.jsonl'), '{"role":"user","content":"flat"}\n', 'utf-8'); + const refs = await listBucketSessions(bucket); + const ref = refs.find((r) => r.uuid === 'uuid16') as LegacySessionRef; + expect(ref.contextPath).toBe(join(bucket, 'uuid16', 'context.jsonl')); + expect(await classifyLegacySession(ref)).toBe('real'); + }); + + it('real: a dir without context.jsonl falls back to the paired flat file', async () => { + const path = join(bucket, 'uuid17'); + await mkdir(path, { recursive: true }); + await writeFile(join(path, 'state.json'), JSON.stringify({ custom_title: 'x' }), 'utf-8'); + await writeFile(join(bucket, 'uuid17.jsonl'), '{"role":"user","content":"flat"}\n', 'utf-8'); + const refs = await listBucketSessions(bucket); + const ref = refs.find((r) => r.uuid === 'uuid17') as LegacySessionRef; + expect(ref.contextPath).toBe(join(bucket, 'uuid17.jsonl')); + expect(await classifyLegacySession(ref)).toBe('real'); + }); + + it('ignores non-.jsonl files in the bucket', async () => { + await writeFile(join(bucket, '.DS_Store'), 'junk', 'utf-8'); + await writeFile(join(bucket, 'notes.txt'), 'junk', 'utf-8'); + await makeSession('uuid18', { + 'context.jsonl': '{"role":"user","content":"hi"}\n', + }); + const refs = await listBucketSessions(bucket); + expect(refs.map((r) => r.uuid)).toEqual(['uuid18']); }); }); diff --git a/packages/migration-legacy/test/sessions/content-part.test.ts b/packages/migration-legacy/test/sessions/content-part.test.ts index e23cf7605af..005a9fc71bb 100644 --- a/packages/migration-legacy/test/sessions/content-part.test.ts +++ b/packages/migration-legacy/test/sessions/content-part.test.ts @@ -51,6 +51,46 @@ describe('normalizeContentPart', () => { expect((res as { type: 'text'; text: string }).text).toContain('image expired'); }); + it('nested image_url (current kimi-cli form): packs url and id', () => { + const part = { type: 'image_url', image_url: { url: 'data:...', id: 'img-1' } }; + expect(normalizeContentPart(part)).toEqual({ + type: 'image_url', + imageUrl: { url: 'data:...', id: 'img-1' }, + }); + }); + + it('nested image_url: null id is omitted', () => { + const part = { type: 'image_url', image_url: { url: 'data:...', id: null } }; + expect(normalizeContentPart(part)).toEqual({ + type: 'image_url', + imageUrl: { url: 'data:...' }, + }); + }); + + it('nested audio_url/video_url: same conversion', () => { + expect(normalizeContentPart({ type: 'audio_url', audio_url: { url: 'a' } })).toEqual({ + type: 'audio_url', + audioUrl: { url: 'a' }, + }); + expect(normalizeContentPart({ type: 'video_url', video_url: { url: 'v', id: 'vid-1' } })).toEqual({ + type: 'video_url', + videoUrl: { url: 'v', id: 'vid-1' }, + }); + }); + + it('nested media with expired local path: falls back to text placeholder', () => { + const part = { type: 'image_url', image_url: { url: '/nonexistent/foo.png' } }; + const res = normalizeContentPart(part); + expect(res.type).toBe('text'); + expect((res as { type: 'text'; text: string }).text).toContain('image expired'); + }); + + it('nested media with missing payload: falls back to missing-url text', () => { + const res = normalizeContentPart({ type: 'image_url' }); + expect(res.type).toBe('text'); + expect((res as { type: 'text'; text: string }).text).toContain('image missing url'); + }); + it('unknown type: falls back to text with stringified content', () => { const part = { type: 'weird', payload: { x: 1 } }; const res = normalizeContentPart(part); diff --git a/packages/migration-legacy/test/sessions/fixtures.snapshot.test.ts b/packages/migration-legacy/test/sessions/fixtures.snapshot.test.ts index 5597b9a6548..1929aa252e8 100644 --- a/packages/migration-legacy/test/sessions/fixtures.snapshot.test.ts +++ b/packages/migration-legacy/test/sessions/fixtures.snapshot.test.ts @@ -31,8 +31,7 @@ afterEach(async () => { describe.each(SCENARIOS)('migration snapshot: %s', (name) => { it('migration succeeds and matches snapshot', async () => { const result = await migrateOneSession({ - sourceSessionDir: join(FIXTURES, name), - oldSessionUuid: name, + source: { uuid: name, sessionDir: join(FIXTURES, name), contextPath: join(join(FIXTURES, name), 'context.jsonl') }, workdirPath: '/Users/example/proj', targetHome: target, }); @@ -51,15 +50,17 @@ describe.each(SCENARIOS)('migration snapshot: %s', (name) => { // hosts. `agents.main.homedir` is an absolute path under the temp target // dir — replace that prefix so only the stable suffix is snapshotted. const stableState = state - .replace(/"createdAt": ".+?"/, '"createdAt": ""') - .replace(/"updatedAt": ".+?"/, '"updatedAt": ""') + .replace(/"createdAt":\s*("[^"]*"|\d+)/, '"createdAt": ""') + .replace(/"updatedAt":\s*("[^"]*"|\d+)/, '"updatedAt": ""') .replace(/"imported_at": ".+?"/, '"imported_at": ""') .replace(/"kimi_cli_source_path": ".+?"/, '"kimi_cli_source_path": ""') .replaceAll('\\\\', '/') .split(target.replaceAll('\\', '/')) .join(''); // Redact wire created_at timestamp (derived from wire_mtime or Date.now()). - const stableWire = wire.replace(/"created_at":\s*\d+/, '"created_at":'); + const stableWire = wire + .replace(/"created_at":\s*\d+/, '"created_at":') + .replaceAll(/"time":\s*\d+/g, '"time":'); expect({ wire: stableWire, state: stableState }).toMatchSnapshot(); }); }); diff --git a/packages/migration-legacy/test/sessions/migrate-one.test.ts b/packages/migration-legacy/test/sessions/migrate-one.test.ts index 6a2194a9fcc..189438d684d 100644 --- a/packages/migration-legacy/test/sessions/migrate-one.test.ts +++ b/packages/migration-legacy/test/sessions/migrate-one.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { migrateOneSession, type MigrateOneResult } from '../../src/sessions/migrate-one.js'; +import { countImportedSessionsNeedingRepair } from '../../src/sessions/repair-imported.js'; import { computeWorkdirBucket } from '../../src/sessions/workdir-bucket.js'; import { targetSessionsDir } from '../../src/paths.js'; @@ -20,8 +21,7 @@ afterEach(async () => { describe('migrateOneSession (tiny-hello-world fixture)', () => { it('produces a valid v1.0 session dir', async () => { const result = await migrateOneSession({ - sourceSessionDir: join(FIXTURES, 'tiny-hello-world'), - oldSessionUuid: 'tiny-uuid', + source: { uuid: 'tiny-uuid', sessionDir: join(FIXTURES, 'tiny-hello-world'), contextPath: join(join(FIXTURES, 'tiny-hello-world'), 'context.jsonl') }, workdirPath: '/Users/me/proj', targetHome, }); @@ -29,23 +29,31 @@ describe('migrateOneSession (tiny-hello-world fixture)', () => { const targetDir = (result as Extract).targetDir; const state = JSON.parse(await readFile(join(targetDir, 'state.json'), 'utf-8')); expect(state.title).toBe('hi'); + expect(state.lastTurnReason).toBe('completed'); const wire = await readFile(join(targetDir, 'agents', 'main', 'wire.jsonl'), 'utf-8'); const lines = wire.split('\n').filter((l) => l.length > 0); expect(lines[0]).toContain('"protocol_version":"1.0"'); - // 2 messages (user + assistant); markers dropped - expect(lines).toHaveLength(3); + const records = lines.map((l) => JSON.parse(l) as { type: string }); + // metadata + turn.prompt + 2 messages + turn.ended + token_counting.measured + // (the fixture carries a `_usage` row with token_count 9133) + expect(records.map((r) => r.type)).toEqual([ + 'metadata', + 'turn.prompt', + 'context.append_message', + 'context.append_message', + 'turn.ended', + 'token_counting.measured', + ]); }); it('reports already-migrated on an idempotent re-run', async () => { await migrateOneSession({ - sourceSessionDir: join(FIXTURES, 'tiny-hello-world'), - oldSessionUuid: 'tiny-uuid', + source: { uuid: 'tiny-uuid', sessionDir: join(FIXTURES, 'tiny-hello-world'), contextPath: join(join(FIXTURES, 'tiny-hello-world'), 'context.jsonl') }, workdirPath: '/Users/me/proj', targetHome, }); const second = await migrateOneSession({ - sourceSessionDir: join(FIXTURES, 'tiny-hello-world'), - oldSessionUuid: 'tiny-uuid', + source: { uuid: 'tiny-uuid', sessionDir: join(FIXTURES, 'tiny-hello-world'), contextPath: join(join(FIXTURES, 'tiny-hello-world'), 'context.jsonl') }, workdirPath: '/Users/me/proj', targetHome, }); @@ -56,8 +64,7 @@ describe('migrateOneSession (tiny-hello-world fixture)', () => { it('reports conflict when an unrelated kimi-code session occupies the dir', async () => { const first = await migrateOneSession({ - sourceSessionDir: join(FIXTURES, 'tiny-hello-world'), - oldSessionUuid: 'tiny-uuid', + source: { uuid: 'tiny-uuid', sessionDir: join(FIXTURES, 'tiny-hello-world'), contextPath: join(join(FIXTURES, 'tiny-hello-world'), 'context.jsonl') }, workdirPath: '/Users/me/proj', targetHome, }); @@ -66,8 +73,7 @@ describe('migrateOneSession (tiny-hello-world fixture)', () => { // Overwrite state.json with a non-migrated (real) kimi-code session. await writeFile(join(targetDir, 'state.json'), JSON.stringify({ title: 'real' }), 'utf-8'); const second = await migrateOneSession({ - sourceSessionDir: join(FIXTURES, 'tiny-hello-world'), - oldSessionUuid: 'tiny-uuid', + source: { uuid: 'tiny-uuid', sessionDir: join(FIXTURES, 'tiny-hello-world'), contextPath: join(join(FIXTURES, 'tiny-hello-world'), 'context.jsonl') }, workdirPath: '/Users/me/proj', targetHome, }); @@ -90,8 +96,7 @@ describe('migrateOneSession (tiny-hello-world fixture)', () => { await writeFile(join(targetDir, 'agents', 'main', 'wire.jsonl'), '{"type":"metadata"}\n'); const result = await migrateOneSession({ - sourceSessionDir: join(FIXTURES, 'tiny-hello-world'), - oldSessionUuid: 'tiny-uuid', + source: { uuid: 'tiny-uuid', sessionDir: join(FIXTURES, 'tiny-hello-world'), contextPath: join(join(FIXTURES, 'tiny-hello-world'), 'context.jsonl') }, workdirPath, targetHome, }); @@ -116,8 +121,7 @@ describe('migrateOneSession (tiny-hello-world fixture)', () => { await writeFile(join(targetDir, 'state.json'), '{ "createdAt": "broke'); const result = await migrateOneSession({ - sourceSessionDir: join(FIXTURES, 'tiny-hello-world'), - oldSessionUuid: 'tiny-uuid', + source: { uuid: 'tiny-uuid', sessionDir: join(FIXTURES, 'tiny-hello-world'), contextPath: join(join(FIXTURES, 'tiny-hello-world'), 'context.jsonl') }, workdirPath, targetHome, }); @@ -132,8 +136,7 @@ describe('migrateOneSession (tiny-hello-world fixture)', () => { // migrated artifacts must carry the original timestamp — not write-time. const expectedMs = Math.floor(1772616338.93 * 1000); const result = await migrateOneSession({ - sourceSessionDir: join(FIXTURES, 'tiny-hello-world'), - oldSessionUuid: 'tiny-uuid', + source: { uuid: 'tiny-uuid', sessionDir: join(FIXTURES, 'tiny-hello-world'), contextPath: join(join(FIXTURES, 'tiny-hello-world'), 'context.jsonl') }, workdirPath: '/Users/me/proj', targetHome, }); @@ -169,8 +172,7 @@ describe('migrateOneSession (tiny-hello-world fixture)', () => { await utimes(join(srcDir, 'wire.jsonl'), wireTime, wireTime); const result = await migrateOneSession({ - sourceSessionDir: srcDir, - oldSessionUuid: 'no-wiremtime-uuid', + source: { uuid: 'no-wiremtime-uuid', sessionDir: srcDir, contextPath: join(srcDir, 'context.jsonl') }, workdirPath: '/Users/me/proj', targetHome, }); @@ -194,8 +196,7 @@ describe('migrateOneSession (tiny-hello-world fixture)', () => { await writeFile(join(srcDir, 'state.json'), '{}', 'utf-8'); const result = await migrateOneSession({ - sourceSessionDir: srcDir, - oldSessionUuid: 'empty-context-uuid', + source: { uuid: 'empty-context-uuid', sessionDir: srcDir, contextPath: join(srcDir, 'context.jsonl') }, workdirPath: '/Users/me/proj', targetHome, }); @@ -212,8 +213,7 @@ describe('migrateOneSession (tiny-hello-world fixture)', () => { await writeFile(join(srcDir, 'state.json'), '{}', 'utf-8'); const result = await migrateOneSession({ - sourceSessionDir: srcDir, - oldSessionUuid: 'corrupt-context-uuid', + source: { uuid: 'corrupt-context-uuid', sessionDir: srcDir, contextPath: join(srcDir, 'context.jsonl') }, workdirPath: '/Users/me/proj', targetHome, }); @@ -222,4 +222,571 @@ describe('migrateOneSession (tiny-hello-world fixture)', () => { expect(result.reason).toMatch(/corrupt|parseable/i); } }); + + it('migrates a historical flat context file with no session dir', async () => { + const flatFile = join(targetHome, 'flat-uuid.jsonl'); + await writeFile( + flatFile, + '{"role":"user","content":"hello from the flat era"}\n', + 'utf-8', + ); + + const result = await migrateOneSession({ + source: { uuid: 'flat-uuid', flatContextFile: flatFile, contextPath: flatFile }, + workdirPath: '/Users/me/proj', + targetHome, + }); + expect(result.outcome).toBe('migrated'); + const targetDir = (result as Extract).targetDir; + const wire = await readFile(join(targetDir, 'agents', 'main', 'wire.jsonl'), 'utf-8'); + expect(wire).toContain('hello from the flat era'); + const state = JSON.parse(await readFile(join(targetDir, 'state.json'), 'utf-8')) as { + title: string; + }; + expect(state.title).toBe('hello from the flat era'); + }); + + it('migrates a title-only session: empty wire, title preserved', async () => { + const srcDir = join(targetHome, 'src-title-only'); + await mkdir(srcDir, { recursive: true }); + await writeFile(join(srcDir, 'context.jsonl'), '', 'utf-8'); + await writeFile( + join(srcDir, 'state.json'), + JSON.stringify({ custom_title: 'My named session' }), + 'utf-8', + ); + + const result = await migrateOneSession({ + source: { uuid: 'title-only-uuid', sessionDir: srcDir, contextPath: join(srcDir, 'context.jsonl') }, + workdirPath: '/Users/me/proj', + targetHome, + }); + expect(result.outcome).toBe('migrated'); + const targetDir = (result as Extract).targetDir; + const wire = await readFile(join(targetDir, 'agents', 'main', 'wire.jsonl'), 'utf-8'); + expect(wire).not.toContain('append_message'); + const state = JSON.parse(await readFile(join(targetDir, 'state.json'), 'utf-8')) as { + title: string; + isCustomTitle: boolean; + }; + expect(state.title).toBe('My named session'); + expect(state.isCustomTitle).toBe(true); + }); + + it('merges legacy metadata.json into the migrated state (state fields win)', async () => { + const srcDir = join(targetHome, 'src-metadata-merge'); + await mkdir(srcDir, { recursive: true }); + await writeFile( + join(srcDir, 'context.jsonl'), + '{"role":"user","content":"hi"}\n', + 'utf-8', + ); + await writeFile( + join(srcDir, 'state.json'), + JSON.stringify({ archived: false, archived_at: null, custom_title: 'State Title' }), + 'utf-8', + ); + await writeFile( + join(srcDir, 'metadata.json'), + JSON.stringify({ + session_id: 'metadata-merge-uuid', + title: 'Legacy Title', + archived: true, + archived_at: 9999, + auto_archive_exempt: true, + }), + 'utf-8', + ); + + const result = await migrateOneSession({ + source: { uuid: 'metadata-merge-uuid', sessionDir: srcDir, contextPath: join(srcDir, 'context.jsonl') }, + workdirPath: '/Users/me/proj', + targetHome, + }); + expect(result.outcome).toBe('migrated'); + const targetDir = (result as Extract).targetDir; + const state = JSON.parse(await readFile(join(targetDir, 'state.json'), 'utf-8')) as { + title: string; + archived: boolean; + archivedAt?: number; + custom: { auto_archive_exempt: boolean }; + }; + expect(state.title).toBe('State Title'); + expect(state.archived).toBe(true); + expect(state.archivedAt).toBe(9999000); + expect(state.custom.auto_archive_exempt).toBe(true); + }); +}); + +describe('migrateOneSession repair of message-only imports', () => { + const workdirPath = '/Users/me/proj'; + + async function seedImportedTarget( + wireLines: string[], + stateExtra: Record = {}, + ): Promise { + const targetDir = join( + targetSessionsDir(targetHome), + computeWorkdirBucket(workdirPath), + 'ses_repair-uuid', + ); + await mkdir(join(targetDir, 'agents', 'main'), { recursive: true }); + await writeFile(join(targetDir, 'agents', 'main', 'wire.jsonl'), wireLines.join('\n') + '\n'); + await writeFile( + join(targetDir, 'state.json'), + JSON.stringify({ + id: 'ses_repair-uuid', + title: 'old import', + custom: { imported_from_kimi_cli: true, kimi_cli_session_id: 'repair-uuid' }, + ...stateExtra, + }), + ); + return targetDir; + } + + const importedWire = [ + '{"type":"metadata","protocol_version":"1.0","created_at":1700000000000}', + '{"type":"context.append_message","message":{"role":"user","content":[{"type":"text","text":"old question"}],"toolCalls":[]}}', + '{"type":"context.append_message","message":{"role":"assistant","content":[{"type":"text","text":"old answer"}],"toolCalls":[]}}', + ]; + + function runMigrate() { + return migrateOneSession({ + source: { uuid: 'repair-uuid', sessionDir: join(FIXTURES, 'tiny-hello-world'), contextPath: join(FIXTURES, 'tiny-hello-world', 'context.jsonl') }, + workdirPath, + targetHome, + }); + } + + it('inserts turn structure into a message-only imported wire, once', async () => { + const targetDir = await seedImportedTarget(importedWire); + + const first = await runMigrate(); + expect(first.outcome).toBe('repaired'); + + const records = (await readFile(join(targetDir, 'agents', 'main', 'wire.jsonl'), 'utf-8')) + .split('\n') + .filter((l) => l.length > 0) + .map((l) => JSON.parse(l) as { type: string }); + expect(records.map((r) => r.type)).toEqual([ + 'metadata', + 'turn.prompt', + 'context.append_message', + 'context.append_message', + 'turn.ended', + ]); + expect(records[1]).toMatchObject({ + agentId: 'main', + origin: { kind: 'user' }, + input: [{ type: 'text', text: 'old question' }], + time: 1700000000000, + }); + expect(records[4]).toMatchObject({ agentId: 'main', turnId: 0, reason: 'completed' }); + + const state = JSON.parse(await readFile(join(targetDir, 'state.json'), 'utf-8')); + expect(state.lastTurnReason).toBe('completed'); + expect(state.custom.import_format_version).toBe(2); + + const second = await runMigrate(); + expect(second.outcome).toBe('already-migrated'); + }); + + it('imports the legacy todo list as a tools.update_store record', async () => { + const sourceDir = join(targetHome, 'src-with-todos'); + await mkdir(sourceDir, { recursive: true }); + await writeFile( + join(sourceDir, 'state.json'), + JSON.stringify({ + todos: [ + { title: '创建 f1.txt', status: 'done' }, + { title: '创建 f2.txt', status: 'in_progress' }, + { title: 'bogus', status: 'weird' }, + ], + }), + ); + const liveSuffix = [ + '{"type":"prompt.completed","agentId":"main","promptId":"msg_live1","time":1800000000002}', + ]; + const targetDir = await seedImportedTarget([...importedWire, ...liveSuffix], { + custom: { + imported_from_kimi_cli: true, + kimi_cli_session_id: 'repair-uuid', + kimi_cli_source_path: sourceDir, + }, + }); + + const first = await runMigrate(); + expect(first.outcome).toBe('repaired'); + + const lines = (await readFile(join(targetDir, 'agents', 'main', 'wire.jsonl'), 'utf-8')) + .split('\n') + .filter((l) => l.length > 0); + expect(lines.map((l) => (JSON.parse(l) as { type: string }).type)).toEqual([ + 'metadata', + 'turn.prompt', + 'context.append_message', + 'context.append_message', + 'turn.ended', + 'tools.update_store', + 'prompt.completed', + ]); + const todoRecord = JSON.parse(lines[5]!); + // Invalid entries are filtered out; order is preserved. + expect(todoRecord.value).toEqual([ + { title: '创建 f1.txt', status: 'done' }, + { title: '创建 f2.txt', status: 'in_progress' }, + ]); + expect(todoRecord.time).toBe(1700000000000); + expect(lines[6]).toBe(liveSuffix[0]); + + const second = await runMigrate(); + expect(second.outcome).toBe('already-migrated'); + }); + + it('preserves a live suffix verbatim while repairing the imported prefix', async () => { + const liveSuffix = [ + '{"type":"turn.prompt","agentId":"main","input":[{"type":"text","text":"new question"}],"origin":{"kind":"user"},"time":1800000000000}', + '{"type":"context.append_message","message":{"role":"user","content":[{"type":"text","text":"new question"}],"toolCalls":[],"origin":{"kind":"user"},"id":"msg_live1"}}', + '{"type":"turn.ended","agentId":"main","turnId":0,"reason":"completed","time":1800000000001}', + ]; + const targetDir = await seedImportedTarget( + [...importedWire, ...liveSuffix], + { lastTurnReason: 'completed' }, + ); + + const first = await runMigrate(); + expect(first.outcome).toBe('repaired'); + + const lines = (await readFile(join(targetDir, 'agents', 'main', 'wire.jsonl'), 'utf-8')) + .split('\n') + .filter((l) => l.length > 0); + expect(lines.map((l) => (JSON.parse(l) as { type: string }).type)).toEqual([ + 'metadata', + 'turn.prompt', + 'context.append_message', + 'context.append_message', + 'turn.ended', + 'turn.prompt', + 'context.append_message', + 'turn.ended', + ]); + expect(lines.slice(5)).toEqual(liveSuffix); + + const state = JSON.parse(await readFile(join(targetDir, 'state.json'), 'utf-8')); + expect(state.lastTurnReason).toBe('completed'); + }); + + it('reports failed when a session needing repair has an unrepairable wire', async () => { + await seedImportedTarget(['{"type":"metadata","protocol_version":"1.0","created_at":1}', '{broken']); + const result = await runMigrate(); + expect(result.outcome).toBe('failed'); + if (result.outcome === 'failed') { + expect(result.reason).toMatch(/repair/i); + } + }); + + it('stays already-migrated for an import at the current format version', async () => { + const targetDir = join( + targetSessionsDir(targetHome), + computeWorkdirBucket(workdirPath), + 'ses_repair-uuid', + ); + await mkdir(join(targetDir, 'agents', 'main'), { recursive: true }); + await writeFile( + join(targetDir, 'state.json'), + JSON.stringify({ + id: 'ses_repair-uuid', + title: 'current import', + custom: { + imported_from_kimi_cli: true, + kimi_cli_session_id: 'repair-uuid', + import_format_version: 2, + }, + }), + ); + const result = await runMigrate(); + expect(result.outcome).toBe('already-migrated'); + }); +}); + +describe('countImportedSessionsNeedingRepair', () => { + it('counts imported sessions whose import format predates the current migrator', async () => { + const workdirPath = '/Users/me/proj'; + const bucket = join(targetSessionsDir(targetHome), computeWorkdirBucket(workdirPath)); + + const needsRepair = join(bucket, 'ses_old-import'); + await mkdir(join(needsRepair, 'agents', 'main'), { recursive: true }); + await writeFile( + join(needsRepair, 'agents', 'main', 'wire.jsonl'), + '{"type":"metadata","protocol_version":"1.0","created_at":1}\n' + + '{"type":"context.append_message","message":{"role":"user","content":[{"type":"text","text":"x"}],"toolCalls":[]}}\n', + ); + await writeFile( + join(needsRepair, 'state.json'), + JSON.stringify({ custom: { imported_from_kimi_cli: true } }), + ); + + const current = join(bucket, 'ses_current-import'); + await mkdir(join(current, 'agents', 'main'), { recursive: true }); + await writeFile( + join(current, 'agents', 'main', 'wire.jsonl'), + '{"type":"metadata","protocol_version":"1.0","created_at":1}\n' + + '{"type":"turn.prompt","agentId":"main","input":[],"origin":{"kind":"user"},"time":1}\n' + + '{"type":"context.append_message","message":{"role":"user","content":[{"type":"text","text":"x"}],"toolCalls":[]}}\n', + ); + await writeFile( + join(current, 'state.json'), + JSON.stringify({ custom: { imported_from_kimi_cli: true, import_format_version: 2 } }), + ); + + const native = join(bucket, 'ses_native'); + await mkdir(join(native, 'agents', 'main'), { recursive: true }); + await writeFile( + join(native, 'agents', 'main', 'wire.jsonl'), + '{"type":"metadata","protocol_version":"1.5","created_at":1}\n' + + '{"type":"context.append_message","message":{"role":"user","content":[{"type":"text","text":"x"}],"toolCalls":[]}}\n', + ); + await writeFile(join(native, 'state.json'), JSON.stringify({ title: 'real session' })); + + expect(await countImportedSessionsNeedingRepair(targetHome)).toBe(1); + }); + + it('returns 0 for a missing sessions root', async () => { + expect(await countImportedSessionsNeedingRepair(join(targetHome, 'nope'))).toBe(0); + }); +}); + +describe('migrateOneSession todo list migration', () => { + it('writes the legacy todos as a tools.update_store record in a fresh migration', async () => { + const srcDir = join(targetHome, 'src-fresh-todos'); + await mkdir(srcDir, { recursive: true }); + await writeFile( + join(srcDir, 'context.jsonl'), + '{"role":"user","content":"hi"}\n{"role":"assistant","content":[{"type":"text","text":"Hello"}]}\n', + ); + await writeFile( + join(srcDir, 'state.json'), + JSON.stringify({ + todos: [ + { title: '创建 f1.txt', status: 'done' }, + { title: '创建 f2.txt', status: 'pending' }, + ], + }), + ); + + const result = await migrateOneSession({ + source: { uuid: 'fresh-todos-uuid', sessionDir: srcDir, contextPath: join(srcDir, 'context.jsonl') }, + workdirPath: '/Users/me/proj', + targetHome, + }); + expect(result.outcome).toBe('migrated'); + const targetDir = (result as Extract).targetDir; + const lines = (await readFile(join(targetDir, 'agents', 'main', 'wire.jsonl'), 'utf-8')) + .split('\n') + .filter((l) => l.length > 0); + const last = JSON.parse(lines.at(-1)!); + expect(last).toMatchObject({ + type: 'tools.update_store', + agentId: 'main', + key: 'todo', + value: [ + { title: '创建 f1.txt', status: 'done' }, + { title: '创建 f2.txt', status: 'pending' }, + ], + }); + const state = JSON.parse(await readFile(join(targetDir, 'state.json'), 'utf-8')); + expect(state.custom.import_format_version).toBe(2); + }); +}); + +describe('migrateOneSession subagent migration', () => { + const workdirPath = '/Users/me/proj'; + + async function seedSourceWithSubagent(): Promise { + const srcDir = join(targetHome, 'src-with-subagent'); + await mkdir(join(srcDir, 'subagents', 'sub1'), { recursive: true }); + await writeFile( + join(srcDir, 'context.jsonl'), + [ + '{"role":"user","content":"run a subagent"}', + '{"role":"assistant","content":[],"tool_calls":[{"type":"function","id":"tool_X","function":{"name":"Agent","arguments":"{\\"description\\":\\"calc\\"}"}}]}', + '{"role":"tool","tool_call_id":"tool_X","content":"56088"}', + ].join('\n') + '\n', + ); + await writeFile(join(srcDir, 'state.json'), '{}'); + await writeFile( + join(srcDir, 'wire.jsonl'), + [ + '{"type":"metadata","protocol_version":"1.10"}', + '{"timestamp":1,"message":{"type":"SubagentEvent","payload":{"parent_tool_call_id":"tool_X","agent_id":"sub1","subagent_type":"coder","event":{"type":"TurnBegin","payload":{"user_input":"计算 123 乘以 456"}}}}}', + ].join('\n') + '\n', + ); + await writeFile( + join(srcDir, 'subagents', 'sub1', 'meta.json'), + JSON.stringify({ + agent_id: 'sub1', + subagent_type: 'coder', + status: 'idle', + description: 'Calculate 123*456', + created_at: 1700000000.0, + updated_at: 1700000007.0, + launch_spec: { effective_model: 'k2' }, + }), + ); + await writeFile( + join(srcDir, 'subagents', 'sub1', 'context.jsonl'), + [ + '{"role":"user","content":"计算 123 乘以 456"}', + '{"role":"assistant","content":[{"type":"text","text":"56088"}]}', + ].join('\n') + '\n', + ); + await writeFile( + join(srcDir, 'subagents', 'sub1', 'state.json'), + JSON.stringify({ todos: [{ title: 'calc', status: 'done' }] }), + ); + return srcDir; + } + + it('migrates subagent wire, task records and roster registration linked to the main history', async () => { + const srcDir = await seedSourceWithSubagent(); + const result = await migrateOneSession({ + source: { uuid: 'sub-session-uuid', sessionDir: srcDir, contextPath: join(srcDir, 'context.jsonl') }, + workdirPath, + targetHome, + }); + expect(result.outcome).toBe('migrated'); + const targetDir = (result as Extract).targetDir; + + const subWire = (await readFile(join(targetDir, 'agents', 'sub1', 'wire.jsonl'), 'utf-8')) + .split('\n') + .filter((l) => l.length > 0) + .map((l) => JSON.parse(l) as { type: string }); + expect(subWire.map((r) => r.type)).toEqual([ + 'metadata', + 'turn.prompt', + 'context.append_message', + 'context.append_message', + 'turn.ended', + 'tools.update_store', + ]); + expect(subWire[1]).toMatchObject({ agentId: 'sub1', origin: { kind: 'user' } }); + expect(subWire[5]).toMatchObject({ agentId: 'sub1', key: 'todo', value: [{ title: 'calc', status: 'done' }] }); + + const mainWire = (await readFile(join(targetDir, 'agents', 'main', 'wire.jsonl'), 'utf-8')) + .split('\n') + .filter((l) => l.length > 0) + .map((l) => JSON.parse(l) as { type: string }); + expect(mainWire.map((r) => r.type)).toEqual([ + 'metadata', + 'turn.prompt', + 'context.append_message', + 'task.started', + 'context.append_message', + 'context.append_message', + 'task.terminated', + 'turn.ended', + ]); + expect(mainWire[3]).toMatchObject({ + agentId: 'main', + info: { + kind: 'agent', + taskId: 'sub1', + agentId: 'sub1', + subagentType: 'coder', + parentToolCallId: 'tool_X', + description: 'Calculate 123*456', + status: 'running', + startedAt: 1700000000000, + endedAt: null, + model: 'k2', + }, + }); + expect(mainWire[6]).toMatchObject({ + agentId: 'main', + info: { taskId: 'sub1', status: 'completed', endedAt: 1700000007000 }, + }); + + const state = JSON.parse(await readFile(join(targetDir, 'state.json'), 'utf-8')); + expect(state.agents.sub1).toMatchObject({ + type: 'sub', + parentAgentId: 'main', + labels: { parentAgentId: 'main' }, + }); + expect(state.agents.sub1.homedir).toBe(join(targetDir, 'agents', 'sub1')); + + const second = await migrateOneSession({ + source: { uuid: 'sub-session-uuid', sessionDir: srcDir, contextPath: join(srcDir, 'context.jsonl') }, + workdirPath, + targetHome, + }); + expect(second.outcome).toBe('already-migrated'); + }); + + it('repairs a message-only import by adding subagent wires and task records', async () => { + const srcDir = await seedSourceWithSubagent(); + const targetDir = join( + targetSessionsDir(targetHome), + computeWorkdirBucket(workdirPath), + 'ses_repair-sub-uuid', + ); + await mkdir(join(targetDir, 'agents', 'main'), { recursive: true }); + await writeFile( + join(targetDir, 'agents', 'main', 'wire.jsonl'), + [ + '{"type":"metadata","protocol_version":"1.0","created_at":1700000000000}', + '{"type":"context.append_message","message":{"role":"user","content":[{"type":"text","text":"run a subagent"}],"toolCalls":[]}}', + '{"type":"context.append_message","message":{"role":"assistant","content":[],"toolCalls":[{"type":"function","id":"tool_X","function":{"name":"Agent","arguments":"{}"}}]}}', + '{"type":"context.append_message","message":{"role":"tool","content":[{"type":"text","text":"56088"}],"toolCalls":[],"toolCallId":"tool_X"}}', + ].join('\n') + '\n', + ); + await writeFile( + join(targetDir, 'state.json'), + JSON.stringify({ + id: 'ses_repair-sub-uuid', + title: 'old import', + custom: { + imported_from_kimi_cli: true, + kimi_cli_session_id: 'repair-sub-uuid', + kimi_cli_source_path: srcDir, + }, + }), + ); + + const result = await migrateOneSession({ + source: { uuid: 'repair-sub-uuid', sessionDir: srcDir, contextPath: join(srcDir, 'context.jsonl') }, + workdirPath, + targetHome, + }); + expect(result.outcome).toBe('repaired'); + + const subWire = await readFile(join(targetDir, 'agents', 'sub1', 'wire.jsonl'), 'utf-8'); + expect(subWire).toContain('"agentId":"sub1"'); + + const mainWire = (await readFile(join(targetDir, 'agents', 'main', 'wire.jsonl'), 'utf-8')) + .split('\n') + .filter((l) => l.length > 0) + .map((l) => JSON.parse(l) as { type: string }); + expect(mainWire.map((r) => r.type)).toEqual([ + 'metadata', + 'turn.prompt', + 'context.append_message', + 'task.started', + 'context.append_message', + 'context.append_message', + 'task.terminated', + 'turn.ended', + ]); + expect(mainWire[3]).toMatchObject({ info: { taskId: 'sub1', parentToolCallId: 'tool_X' } }); + + const state = JSON.parse(await readFile(join(targetDir, 'state.json'), 'utf-8')); + expect(state.agents.sub1).toBeDefined(); + expect(state.custom.import_format_version).toBe(2); + + const second = await migrateOneSession({ + source: { uuid: 'repair-sub-uuid', sessionDir: srcDir, contextPath: join(srcDir, 'context.jsonl') }, + workdirPath, + targetHome, + }); + expect(second.outcome).toBe('already-migrated'); + }); }); diff --git a/packages/migration-legacy/test/sessions/sessions-step.test.ts b/packages/migration-legacy/test/sessions/sessions-step.test.ts index bf7a9e5ee98..ae8f560ca3d 100644 --- a/packages/migration-legacy/test/sessions/sessions-step.test.ts +++ b/packages/migration-legacy/test/sessions/sessions-step.test.ts @@ -194,7 +194,7 @@ describe('migrateSessionsStep (multi-workdir fixture)', () => { expect(report.sessionsFailed).toEqual([ { sourcePath: sessionDir, - reason: expect.stringMatching(/context\.jsonl.*missing.*unreadable/i), + reason: expect.stringMatching(/context.*missing.*unreadable/i), }, ]); expect(report.sessionsSkippedMalformed).toBe(0); @@ -203,6 +203,64 @@ describe('migrateSessionsStep (multi-workdir fixture)', () => { } }); + it('migrates historical flat .jsonl sessions end-to-end', async () => { + const src = await mkdtemp(join(tmpdir(), 'flat-sessions-src-')); + try { + const workdir = '/Users/me/flat-project'; + const bucket = join(src, 'sessions', oldMd5BucketName(workdir)); + await mkdir(bucket, { recursive: true }); + await writeFile( + join(src, 'kimi.json'), + JSON.stringify({ work_dirs: [{ path: workdir, kaos: 'local' }] }), + ); + await writeFile( + join(bucket, 'flat-1.jsonl'), + '{"role":"user","content":"session one"}\n', + ); + await writeFile( + join(bucket, 'flat-2.jsonl'), + '{"role":"user","content":"session two"}\n', + ); + + const report = await migrateSessionsStep({ sourceHome: src, targetHome }); + + expect(report.sessionsMigrated).toBe(2); + expect(report.sessionsFailed).toEqual([]); + const index = await readFile(targetSessionIndex(targetHome), 'utf-8'); + expect(index).toContain('ses_flat-1'); + expect(index).toContain('ses_flat-2'); + } finally { + await rm(src, { recursive: true, force: true }); + } + }); + + it('migrates a title-only session found by the bucket scan', async () => { + const src = await mkdtemp(join(tmpdir(), 'title-only-src-')); + try { + const workdir = '/Users/me/title-project'; + const sessionDir = join(src, 'sessions', oldMd5BucketName(workdir), 'titled-1'); + await mkdir(sessionDir, { recursive: true }); + await writeFile( + join(src, 'kimi.json'), + JSON.stringify({ work_dirs: [{ path: workdir, kaos: 'local' }] }), + ); + await writeFile(join(sessionDir, 'context.jsonl'), ''); + await writeFile( + join(sessionDir, 'state.json'), + JSON.stringify({ custom_title: 'Named but empty' }), + ); + + const report = await migrateSessionsStep({ sourceHome: src, targetHome }); + + expect(report.sessionsMigrated).toBe(1); + expect(report.sessionsSkippedEmpty).toBe(0); + const index = await readFile(targetSessionIndex(targetHome), 'utf-8'); + expect(index).toContain('ses_titled-1'); + } finally { + await rm(src, { recursive: true, force: true }); + } + }); + it('reports a context.jsonl that cannot be read as a failure', async () => { const src = await mkdtemp(join(tmpdir(), 'unreadable-context-src-')); try { @@ -219,7 +277,7 @@ describe('migrateSessionsStep (multi-workdir fixture)', () => { expect(report.sessionsFailed).toEqual([ { sourcePath: sessionDir, - reason: expect.stringMatching(/context\.jsonl.*unreadable/i), + reason: expect.stringMatching(/context.*unreadable/i), }, ]); } finally { diff --git a/packages/migration-legacy/test/sessions/source.test.ts b/packages/migration-legacy/test/sessions/source.test.ts new file mode 100644 index 00000000000..c49e726c2d0 --- /dev/null +++ b/packages/migration-legacy/test/sessions/source.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { mergeLegacyMetadata } from '../../src/sessions/source.js'; + +describe('mergeLegacyMetadata', () => { + it('fills a missing custom title from metadata, skipping "Untitled"', () => { + expect(mergeLegacyMetadata({}, { title: 'Legacy Title' }).custom_title).toBe('Legacy Title'); + expect( + mergeLegacyMetadata({ custom_title: null }, { title: 'Untitled' }).custom_title, + ).toBe(null); + expect(mergeLegacyMetadata({}, { title: 'Untitled' }).custom_title).toBeUndefined(); + }); + + it('keeps the state.json title when both exist', () => { + expect( + mergeLegacyMetadata({ custom_title: 'State Title' }, { title: 'Legacy Title' }).custom_title, + ).toBe('State Title'); + }); + + it('fills archive fields only while state holds defaults', () => { + const merged = mergeLegacyMetadata( + { archived: false, archived_at: null, auto_archive_exempt: false }, + { archived: true, archived_at: 9999, auto_archive_exempt: true }, + ); + expect(merged).toMatchObject({ archived: true, archived_at: 9999, auto_archive_exempt: true }); + + const kept = mergeLegacyMetadata( + { archived: true, archived_at: 1 }, + { archived: false, archived_at: 9999 }, + ); + expect(kept).toMatchObject({ archived: true, archived_at: 1 }); + }); + + it('fills wire_mtime only when state has none', () => { + expect(mergeLegacyMetadata({}, { wire_mtime: 1234.5 }).wire_mtime).toBe(1234.5); + expect(mergeLegacyMetadata({ wire_mtime: 1 }, { wire_mtime: 1234.5 }).wire_mtime).toBe(1); + expect(mergeLegacyMetadata({ wire_mtime: null }, { wire_mtime: 1234.5 }).wire_mtime).toBe( + 1234.5, + ); + }); + + it('fills title-generation counters only while at defaults', () => { + const merged = mergeLegacyMetadata({}, { title_generated: true, title_generate_attempts: 2 }); + expect(merged).toMatchObject({ title_generated: true, title_generate_attempts: 2 }); + const kept = mergeLegacyMetadata( + { title_generated: false, title_generate_attempts: 1 }, + { title_generated: true, title_generate_attempts: 2 }, + ); + expect(kept).toMatchObject({ title_generated: true, title_generate_attempts: 1 }); + }); +}); diff --git a/packages/migration-legacy/test/sessions/state-writer.test.ts b/packages/migration-legacy/test/sessions/state-writer.test.ts index e33aa98f88a..42074014f33 100644 --- a/packages/migration-legacy/test/sessions/state-writer.test.ts +++ b/packages/migration-legacy/test/sessions/state-writer.test.ts @@ -1,5 +1,5 @@ /** - * Scenario: translating legacy session state into the v1 session metadata file. + * Scenario: translating legacy session state into the v2 session metadata file. * Responsibilities: user-visible metadata and legacy session-scoped fields survive migration. * Wiring: real state writer and filesystem; no collaborators are stubbed. * Run: pnpm exec vitest run packages/migration-legacy/test/sessions/state-writer.test.ts @@ -22,6 +22,8 @@ describe('writeSessionState', () => { it('uses custom_title when present', async () => { await writeSessionState(dir, { oldState: { custom_title: 'My chat', title_generated: false, wire_mtime: 1.5 }, + sessionId: 'ses_old-uuid', + workdirPath: '/Users/me/proj', lastUserPrompt: 'irrelevant', sourcePath: '/Users/me/.kimi/sessions/x/y', oldSessionUuid: 'old-uuid', @@ -41,6 +43,8 @@ describe('writeSessionState', () => { it('falls back to lastUserPrompt prefix when no custom_title', async () => { await writeSessionState(dir, { oldState: { wire_mtime: 1 }, + sessionId: 'ses_u', + workdirPath: '/a', lastUserPrompt: 'help me write a haiku about a duck swimming under the bridge', sourcePath: '/a', oldSessionUuid: 'u', @@ -56,6 +60,8 @@ describe('writeSessionState', () => { it('uses Imported session as fallback when no title source', async () => { await writeSessionState(dir, { oldState: { wire_mtime: 1 }, + sessionId: 'ses_u', + workdirPath: '/a', lastUserPrompt: '', sourcePath: '/a', oldSessionUuid: 'u', @@ -66,9 +72,11 @@ describe('writeSessionState', () => { expect(meta.title).toBe('Imported session'); }); - it('archived flag is preserved in custom', async () => { + it('archived flag is preserved at the top level of the v2 session meta', async () => { await writeSessionState(dir, { oldState: { archived: true, wire_mtime: 1 }, + sessionId: 'ses_u', + workdirPath: '/a', lastUserPrompt: 'x', sourcePath: '/a', oldSessionUuid: 'u', @@ -76,7 +84,7 @@ describe('writeSessionState', () => { createdAtMs: 1, }); const meta = JSON.parse(await readFile(join(dir, 'state.json'), 'utf-8')); - expect(meta.custom.archived).toBe(true); + expect(meta.archived).toBe(true); }); it('writes legacy additional dirs into session-scoped metadata', async () => { @@ -85,6 +93,8 @@ describe('writeSessionState', () => { additional_dirs: ['../shared', 'C:\\Projects\\reference'], wire_mtime: 1, }, + sessionId: 'ses_u', + workdirPath: '/a', lastUserPrompt: 'x', sourcePath: '/a', oldSessionUuid: 'u', @@ -102,6 +112,8 @@ describe('writeSessionState', () => { approval: { yolo: true, afk: false }, wire_mtime: 1, }, + sessionId: 'ses_u', + workdirPath: '/a', lastUserPrompt: 'x', sourcePath: '/a', oldSessionUuid: 'u', @@ -112,4 +124,33 @@ describe('writeSessionState', () => { const meta = JSON.parse(await readFile(join(dir, 'state.json'), 'utf-8')); expect(meta.custom.vscode_legacy_approval).toEqual({ yolo: true, afk: false }); }); + + it('persists lastTurnReason when provided and omits it otherwise', async () => { + await writeSessionState(dir, { + oldState: { wire_mtime: 1 }, + sessionId: 'ses_u', + workdirPath: '/a', + lastUserPrompt: 'x', + lastTurnReason: 'completed', + sourcePath: '/a', + oldSessionUuid: 'u', + wireProtocolFromOld: null, + createdAtMs: 1, + }); + const meta = JSON.parse(await readFile(join(dir, 'state.json'), 'utf-8')); + expect(meta.lastTurnReason).toBe('completed'); + + await writeSessionState(dir, { + oldState: { wire_mtime: 1 }, + sessionId: 'ses_u', + workdirPath: '/a', + lastUserPrompt: 'x', + sourcePath: '/a', + oldSessionUuid: 'u', + wireProtocolFromOld: null, + createdAtMs: 1, + }); + const without = JSON.parse(await readFile(join(dir, 'state.json'), 'utf-8')); + expect('lastTurnReason' in without).toBe(false); + }); }); diff --git a/packages/migration-legacy/test/sessions/translator.test.ts b/packages/migration-legacy/test/sessions/translator.test.ts index cd2ea789c41..a2a2d2ec83b 100644 --- a/packages/migration-legacy/test/sessions/translator.test.ts +++ b/packages/migration-legacy/test/sessions/translator.test.ts @@ -6,6 +6,7 @@ import { translateContextLines, containsUsableMessage, analyzeContextContent, + extractLastUsageTokenCount, } from '../../src/sessions/translator.js'; import { extractToolCallDisplays } from '../../src/sessions/tool-call-display.js'; @@ -190,3 +191,30 @@ describe('analyzeContextContent', () => { ).toBe('empty'); }); }); + +describe('extractLastUsageTokenCount', () => { + it('returns the token_count of the last _usage row', () => { + expect( + extractLastUsageTokenCount([ + '{"role":"_usage","token_count":100}', + '{"role":"user","content":"hi"}', + '{"role":"_usage","token_count":9133}', + ]), + ).toBe(9133); + }); + + it('returns undefined when no _usage row exists', () => { + expect(extractLastUsageTokenCount(['{"role":"user","content":"hi"}'])).toBeUndefined(); + }); + + it('ignores malformed lines and non-numeric token_count values', () => { + expect( + extractLastUsageTokenCount([ + 'not-json', + '{"role":"_usage","token_count":"many"}', + '{"role":"_usage","token_count":-5}', + '{"role":"_usage"}', + ]), + ).toBeUndefined(); + }); +}); diff --git a/packages/migration-legacy/test/sessions/wire-writer.test.ts b/packages/migration-legacy/test/sessions/wire-writer.test.ts index 60d938beec7..29fa5b5033e 100644 --- a/packages/migration-legacy/test/sessions/wire-writer.test.ts +++ b/packages/migration-legacy/test/sessions/wire-writer.test.ts @@ -12,6 +12,14 @@ afterEach(async () => { await rm(dir, { recursive: true, force: true }); }); +async function readWireRecords(): Promise> { + const content = await readFile(join(dir, 'agents', 'main', 'wire.jsonl'), 'utf-8'); + return content + .split('\n') + .filter((l) => l.length > 0) + .map((l) => JSON.parse(l) as { type: string }); +} + describe('writeMainAgentWire', () => { it('writes a metadata header at line 0 with protocol_version=1.0', async () => { await writeMainAgentWire(dir, { createdAtMs: 1700000000000, messages: [] }); @@ -25,21 +33,134 @@ describe('writeMainAgentWire', () => { }); }); - it('emits one context.append_message per message', async () => { + it('wraps each user turn in turn.prompt/turn.ended records', async () => { + await writeMainAgentWire(dir, { + createdAtMs: 1, + messages: [ + { role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] }, + { role: 'assistant', content: [{ type: 'text', text: 'hello' }], toolCalls: [] }, + ], + }); + const records = await readWireRecords(); + expect(records.map((r) => r.type)).toEqual([ + 'metadata', + 'turn.prompt', + 'context.append_message', + 'context.append_message', + 'turn.ended', + ]); + const prompt = records[1]!; + expect(prompt['agentId']).toBe('main'); + expect(prompt['origin']).toEqual({ kind: 'user' }); + expect(prompt['input']).toEqual([{ type: 'text', text: 'hi' }]); + expect(prompt['time']).toBe(1); + const ended = records[4]!; + expect(ended).toMatchObject({ agentId: 'main', turnId: 0, reason: 'completed' }); + }); + + it('numbers one turn.prompt per user message with sequential turnIds', async () => { + await writeMainAgentWire(dir, { + createdAtMs: 1, + messages: [ + { role: 'user', content: [{ type: 'text', text: 'one' }], toolCalls: [] }, + { role: 'assistant', content: [{ type: 'text', text: 'a1' }], toolCalls: [] }, + { role: 'user', content: [{ type: 'text', text: 'two' }], toolCalls: [] }, + { role: 'assistant', content: [{ type: 'text', text: 'a2' }], toolCalls: [] }, + ], + }); + const records = await readWireRecords(); + const prompts = records.filter((r) => r.type === 'turn.prompt'); + const endeds = records.filter((r) => r.type === 'turn.ended'); + expect(prompts).toHaveLength(2); + expect(endeds.map((r) => r['turnId'])).toEqual([0, 1]); + }); + + it('opens a system_trigger turn for a leading non-user orphan run', async () => { + await writeMainAgentWire(dir, { + createdAtMs: 1, + messages: [ + { role: 'assistant', content: [{ type: 'text', text: 'orphan' }], toolCalls: [] }, + { role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] }, + { role: 'assistant', content: [{ type: 'text', text: 'hello' }], toolCalls: [] }, + ], + }); + const records = await readWireRecords(); + const prompts = records.filter((r) => r.type === 'turn.prompt'); + expect(prompts).toHaveLength(2); + expect(prompts[0]?.['origin']).toEqual({ kind: 'system_trigger', name: 'imported_orphan' }); + expect(prompts[0]?.['input']).toEqual([]); + expect(prompts[1]?.['origin']).toEqual({ kind: 'user' }); + expect(records.filter((r) => r.type === 'turn.ended').map((r) => r['turnId'])).toEqual([0, 1]); + }); + + it('omits turn.ended for a trailing unanswered user turn', async () => { + await writeMainAgentWire(dir, { + createdAtMs: 1, + messages: [ + { role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] }, + { role: 'assistant', content: [{ type: 'text', text: 'hello' }], toolCalls: [] }, + { role: 'user', content: [{ type: 'text', text: 'anyone?' }], toolCalls: [] }, + ], + }); + const records = await readWireRecords(); + expect(records.filter((r) => r.type === 'turn.prompt')).toHaveLength(2); + expect(records.filter((r) => r.type === 'turn.ended').map((r) => r['turnId'])).toEqual([0]); + }); + + it('appends a token_counting.measured record when a legacy usage count exists', async () => { + await writeMainAgentWire(dir, { + createdAtMs: 1, + messages: [ + { role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] }, + { role: 'assistant', content: [{ type: 'text', text: 'hello' }], toolCalls: [] }, + ], + lastUsageTokenCount: 9133, + }); + const records = await readWireRecords(); + const measured = records.at(-1)!; + expect(measured).toMatchObject({ + type: 'token_counting.measured', + agentId: 'main', + length: 2, + tokens: 9133, + }); + }); + + it('appends a tools.update_store record for the imported todo list', async () => { + await writeMainAgentWire(dir, { + createdAtMs: 1, + messages: [ + { role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] }, + { role: 'assistant', content: [{ type: 'text', text: 'hello' }], toolCalls: [] }, + ], + todoItems: [ + { title: 'task one', status: 'done' }, + { title: 'task two', status: 'pending' }, + ], + }); + const records = await readWireRecords(); + expect(records.at(-1)).toMatchObject({ + type: 'tools.update_store', + agentId: 'main', + key: 'todo', + value: [ + { title: 'task one', status: 'done' }, + { title: 'task two', status: 'pending' }, + ], + }); + }); + + it('omits the tools.update_store record when the todo list is empty', async () => { await writeMainAgentWire(dir, { createdAtMs: 1, messages: [ { role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] }, { role: 'assistant', content: [{ type: 'text', text: 'hello' }], toolCalls: [] }, ], + todoItems: [], }); - const lines = (await readFile(join(dir, 'agents', 'main', 'wire.jsonl'), 'utf-8')) - .split('\n') - .filter((l) => l.length > 0); - expect(lines).toHaveLength(3); - const second = JSON.parse(lines[1]!); - expect(second.type).toBe('context.append_message'); - expect(second.message.role).toBe('user'); + const records = await readWireRecords(); + expect(records.some((r) => r.type === 'tools.update_store')).toBe(false); }); it('creates agents/main directory tree if missing', async () => { diff --git a/packages/migration-legacy/test/sessions/workdir-bucket.test.ts b/packages/migration-legacy/test/sessions/workdir-bucket.test.ts index 6768e035f98..691479d2b7e 100644 --- a/packages/migration-legacy/test/sessions/workdir-bucket.test.ts +++ b/packages/migration-legacy/test/sessions/workdir-bucket.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { computeWorkdirBucket, oldMd5BucketName } from '../../src/sessions/workdir-bucket.js'; -import { encodeWorkDirKey } from '@moonshot-ai/agent-core/session/store'; +import { encodeWorkDirKey } from '@moonshot-ai/agent-core-v2/_base/utils/workdir-slug'; import { createHash } from 'node:crypto'; /** diff --git a/packages/migration-legacy/test/steps/config.test.ts b/packages/migration-legacy/test/steps/config.test.ts index 8cd62d02534..5c8979598ec 100644 --- a/packages/migration-legacy/test/steps/config.test.ts +++ b/packages/migration-legacy/test/steps/config.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, beforeEach, afterEach } from 'vitest'; -import { mkdtemp, writeFile, readFile, rm } from 'node:fs/promises'; +import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { migrateConfigStep } from '../../src/steps/config.js'; @@ -57,14 +57,41 @@ describe('migrateConfigStep', () => { expect(r.wroteSiblingDueToConflict).toBe(false); const cfg = await readFile(join(tgt, 'config.toml'), 'utf-8'); expect(cfg).toContain('merge_all_available_skills = true'); - expect(cfg).not.toContain('"vllm"'); // dropped provider - expect(cfg).not.toContain('"internal-vibe"'); // dropped model + expect(cfg).toContain('"vllm"'); + expect(cfg).toContain('"internal-vibe"'); + expect(cfg).toContain('default_model = "internal-vibe"'); + expect(cfg).not.toContain('openai_legacy'); expect(cfg).not.toContain('theme'); // moved to tui const tui = await readFile(join(tgt, 'tui.toml'), 'utf-8'); expect(tui).toContain('theme = "dark"'); expect(tui).toContain('command = "code --wait"'); - expect(r.droppedProviders).toContain('vllm'); - expect(r.droppedModels).toContain('internal-vibe'); + expect(r.droppedProviders).not.toContain('vllm'); + expect(r.droppedModels).not.toContain('internal-vibe'); + }); + + it('maps legacy provider types onto kimi-code types', async () => { + await writeFile( + join(src, 'config.toml'), + `[providers.vllm]\ntype = "openai_legacy"\nbase_url = "https://internal.example.com/v1"\napi_key = "EMPTY"\n\n[providers.g]\ntype = "gemini"\nbase_url = "https://g.example.com"\n\n[providers.g2]\ntype = "google_genai"\nbase_url = "https://g2.example.com"\n`, + ); + const r = await migrateConfigStep({ sourceHome: src, targetHome: tgt }); + expect(r.droppedProviders).toEqual([]); + const cfg = await readFile(join(tgt, 'config.toml'), 'utf-8'); + expect(cfg).toContain('type = "openai"'); + expect(cfg.match(/type = "google-genai"/g)).toHaveLength(2); + }); + + it('moves a provider-level reasoning_key onto bound models', async () => { + await writeFile( + join(src, 'config.toml'), + `[providers.vllm]\ntype = "openai_legacy"\nbase_url = "https://internal.example.com/v1"\napi_key = "EMPTY"\nreasoning_key = "reasoning"\n\n[models.m1]\nprovider = "vllm"\nmodel = "m1"\nmax_context_size = 131072\n\n[models.m2]\nprovider = "vllm"\nmodel = "m2"\nmax_context_size = 131072\nreasoning_key = "custom"\n`, + ); + const r = await migrateConfigStep({ sourceHome: src, targetHome: tgt }); + expect(r.droppedProviders).toEqual([]); + const cfg = await readFile(join(tgt, 'config.toml'), 'utf-8'); + expect(cfg).toMatch(/\[models\.m1\][\s\S]*?reasoning_key = "reasoning"/); + expect(cfg).toMatch(/\[models\.m2\][\s\S]*?reasoning_key = "custom"/); + expect(cfg).not.toMatch(/\[providers\.vllm\][^[]*reasoning_key/); }); it('additively merges into a user-modified target config', async () => { @@ -77,7 +104,8 @@ describe('migrateConfigStep', () => { expect(r.configConflicts).toContain('merge_all_available_skills'); const cfg = await readFile(join(tgt, 'config.toml'), 'utf-8'); expect(cfg).toContain('merge_all_available_skills = false'); // target value kept - expect(cfg).toContain('telemetry = true'); // additively brought over + expect(cfg).not.toContain('telemetry'); // v2 has no telemetry section — dropped + expect(r.droppedKeys).toContain('telemetry'); expect(cfg).toContain('kimi-code/kimi-for-coding'); // migrated model added }); @@ -151,12 +179,44 @@ base_url = "https://target.example/v1" await writeFile(join(src, 'config.toml'), 'this is = = not valid toml [[['); const r = await migrateConfigStep({ sourceHome: src, targetHome: tgt }); expect(r.migrated).toBe(false); + expect(r.sourceUnreadable).toBe(true); }); - it('drops a kept-provider model missing required schema fields', async () => { - // `bad-model` references the kept `managed:kimi-code` provider but omits - // `max_context_size`, which kimi-code's ModelAliasSchema requires. Written - // verbatim it would make getConfig() reject the whole config post-migration. + it('falls back to config.json when config.toml is absent', async () => { + await writeFile( + join(src, 'config.json'), + JSON.stringify({ + merge_all_available_skills: true, + providers: { vllm: { type: 'openai_legacy', base_url: 'https://internal.example.com/v1', api_key: 'EMPTY' } }, + }), + ); + const r = await migrateConfigStep({ sourceHome: src, targetHome: tgt }); + expect(r.migrated).toBe(true); + expect(r.sourceUnreadable).toBe(false); + const cfg = await readFile(join(tgt, 'config.toml'), 'utf-8'); + expect(cfg).toContain('merge_all_available_skills = true'); + expect(cfg).toContain('type = "openai"'); + }); + + it('prefers config.toml over config.json when both exist', async () => { + await writeFile(join(src, 'config.toml'), 'merge_all_available_skills = false\n'); + await writeFile(join(src, 'config.json'), '{"merge_all_available_skills": true}'); + const r = await migrateConfigStep({ sourceHome: src, targetHome: tgt }); + expect(r.migrated).toBe(true); + const cfg = await readFile(join(tgt, 'config.toml'), 'utf-8'); + expect(cfg).toContain('merge_all_available_skills = false'); + }); + + it('reports sourceUnreadable for an unparseable config.json', async () => { + await writeFile(join(src, 'config.json'), '{"broken": '); + const r = await migrateConfigStep({ sourceHome: src, targetHome: tgt }); + expect(r.migrated).toBe(false); + expect(r.sourceUnreadable).toBe(true); + }); + + it('keeps a model missing optional schema fields under the v2 model schema', async () => { + // v2's ModelRecordSchema treats `max_context_size` as optional, so a model + // that v1's ModelAliasSchema rejected now validates and migrates. const cfg = `[providers."managed:kimi-code"] type = "kimi" base_url = "https://api.kimi.com/coding/v1" @@ -174,11 +234,11 @@ model = "kimi-for-coding" await writeFile(join(tgt, 'config.toml'), DEFAULT_CONFIG_FILE_TEXT); const r = await migrateConfigStep({ sourceHome: src, targetHome: tgt }); expect(r.migrated).toBe(true); - expect(r.droppedModels).toContain('bad-model'); + expect(r.droppedModels).not.toContain('bad-model'); expect(r.droppedModels).not.toContain('good-model'); const written = await readFile(join(tgt, 'config.toml'), 'utf-8'); expect(written).toContain('good-model'); - expect(written).not.toContain('bad-model'); + expect(written).toContain('bad-model'); }); it('does not write an empty hooks array', async () => { @@ -465,7 +525,7 @@ base_url = "https://target.example/v1" expect(cfg).not.toContain('micro_compaction'); expect(cfg).not.toContain('unknown_flag'); expect(cfg).toContain('[loop_control]'); - expect(cfg).toContain('max_retries_per_step = 2'); + expect(cfg).not.toContain('max_retries_per_step'); expect(cfg).toContain('reserved_context_size = 60000'); expect(cfg).not.toContain('max_steps_per_turn'); expect(cfg).not.toContain('max_steps_per_run'); @@ -493,3 +553,45 @@ base_url = "https://target.example/v1" expect(written).not.toContain('yolo = true'); }); }); + +describe('migrateConfigStep device_id', () => { + it('copies the legacy device_id when the target has none', async () => { + await writeFile(join(src, 'config.toml'), OLD_CONFIG_TOML); + await writeFile(join(src, 'device_id'), 'abc123deviceid\n'); + const r = await migrateConfigStep({ sourceHome: src, targetHome: tgt }); + expect(r.deviceIdCopied).toBe(true); + expect(await readFile(join(tgt, 'device_id'), 'utf-8')).toBe('abc123deviceid\n'); + }); + + it('keeps the target device_id when one already exists', async () => { + await writeFile(join(src, 'config.toml'), OLD_CONFIG_TOML); + await writeFile(join(src, 'device_id'), 'legacy-id\n'); + await writeFile(join(tgt, 'device_id'), 'current-id\n'); + const r = await migrateConfigStep({ sourceHome: src, targetHome: tgt }); + expect(r.deviceIdCopied).toBe(false); + expect(await readFile(join(tgt, 'device_id'), 'utf-8')).toBe('current-id\n'); + }); + + it('reports not-copied when the source has no device_id', async () => { + await writeFile(join(src, 'config.toml'), OLD_CONFIG_TOML); + const r = await migrateConfigStep({ sourceHome: src, targetHome: tgt }); + expect(r.deviceIdCopied).toBe(false); + }); +}); + +describe('migrateConfigStep read-failure classification', () => { + it('reports sourceUnreadable (not "missing") when config.toml exists but cannot be read', async () => { + // A directory named config.toml makes readFile fail with EISDIR — not + // ENOENT — and must surface as an incomplete run instead of a silent skip. + await mkdir(join(src, 'config.toml'), { recursive: true }); + const r = await migrateConfigStep({ sourceHome: src, targetHome: tgt }); + expect(r.migrated).toBe(false); + expect(r.sourceUnreadable).toBe(true); + }); + + it('reports sourceUnreadable when config.toml is unreadable and no config.json exists', async () => { + await mkdir(join(src, 'config.toml'), { recursive: true }); + const r = await migrateConfigStep({ sourceHome: src, targetHome: tgt }); + expect(r.sourceUnreadable).toBe(true); + }); +}); diff --git a/packages/migration-legacy/test/steps/mcp.test.ts b/packages/migration-legacy/test/steps/mcp.test.ts index 5d861324c6c..9ac6de3d386 100644 --- a/packages/migration-legacy/test/steps/mcp.test.ts +++ b/packages/migration-legacy/test/steps/mcp.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, beforeEach, afterEach } from 'vitest'; -import { mkdtemp, writeFile, readFile, rm } from 'node:fs/promises'; +import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { migrateMcpStep } from '../../src/steps/mcp.js'; @@ -52,6 +52,14 @@ describe('migrateMcpStep', () => { it('no source mcp.json: nothing happens', async () => { const r = await migrateMcpStep({ sourceHome: src, targetHome: tgt }); expect(r.mergedServers).toEqual([]); + expect(r.sourceUnreadable).toBe(false); + }); + + it('reports sourceUnreadable for an unparseable source mcp.json', async () => { + await writeFile(join(src, 'mcp.json'), 'not json {{{'); + const r = await migrateMcpStep({ sourceHome: src, targetHome: tgt }); + expect(r.mergedServers).toEqual([]); + expect(r.sourceUnreadable).toBe(true); }); it('drops MCP server entries kimi-code\'s schema rejects', async () => { @@ -89,3 +97,16 @@ describe('migrateMcpStep', () => { expect(sibling.mcpServers.foo.command).toBe('foo'); }); }); + +describe('migrateMcpStep read-failure classification', () => { + it('reports sourceUnreadable when mcp.json exists but cannot be read', async () => { + await mkdir(join(src, 'mcp.json'), { recursive: true }); + const r = await migrateMcpStep({ sourceHome: src, targetHome: tgt }); + expect(r.sourceUnreadable).toBe(true); + }); + + it('reports no unreadable flag when mcp.json is simply absent', async () => { + const r = await migrateMcpStep({ sourceHome: src, targetHome: tgt }); + expect(r.sourceUnreadable).toBe(false); + }); +}); diff --git a/packages/migration-legacy/test/steps/user-history.test.ts b/packages/migration-legacy/test/steps/user-history.test.ts index c2958080073..48041dc6a00 100644 --- a/packages/migration-legacy/test/steps/user-history.test.ts +++ b/packages/migration-legacy/test/steps/user-history.test.ts @@ -3,6 +3,7 @@ import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { migrateUserHistoryStep } from '../../src/steps/user-history.js'; +import { migratePlansStep } from '../../src/steps/plans.js'; let src: string; let tgt: string; @@ -50,3 +51,31 @@ describe('migrateUserHistoryStep', () => { expect(r).toEqual({ copied: 0, skippedExisting: 0 }); }); }); + +describe('migratePlansStep', () => { + it('copies legacy plan files as plain files and skips existing on re-run', async () => { + const plansSrc = await mkdtemp(join(tmpdir(), 'plans-src-')); + try { + await writeFile(join(plansSrc, 'hero-plan.md'), '# plan'); + await writeFile(join(plansSrc, 'other-plan.md'), '# other'); + const r = await migratePlansStep({ targetHome: tgt, plansSourceDir: plansSrc }); + expect(r.copied).toBe(2); + expect(await readFile(join(tgt, 'plans', 'hero-plan.md'), 'utf-8')).toContain('# plan'); + + const second = await migratePlansStep({ targetHome: tgt, plansSourceDir: plansSrc }); + expect(second.copied).toBe(0); + expect(second.skippedExisting).toBe(2); + } finally { + await rm(plansSrc, { recursive: true, force: true }); + } + }); + + it('missing plans source dir: zero counters and no target dir created', async () => { + const r = await migratePlansStep({ + targetHome: tgt, + plansSourceDir: join(tgt, 'does-not-exist'), + }); + expect(r).toEqual({ copied: 0, skippedExisting: 0 }); + expect(await readFile(join(tgt, 'plans', '.keep'), 'utf-8').catch(() => null)).toBeNull(); + }); +}); diff --git a/packages/migration-legacy/test/v2-session-scan.ts b/packages/migration-legacy/test/v2-session-scan.ts new file mode 100644 index 00000000000..0376d233cf4 --- /dev/null +++ b/packages/migration-legacy/test/v2-session-scan.ts @@ -0,0 +1,29 @@ +import { FileStorageService } from '@moonshot-ai/agent-core-v2/persistence/backends/node-fs/fileStorageService'; +import { JsonAtomicDocumentStore } from '@moonshot-ai/agent-core-v2/persistence/backends/node-fs/atomicDocumentStore'; +import { + listWorkspaceIds, + listSessionIds, + readSessionSummary, +} from '@moonshot-ai/agent-core-v2/app/sessionIndex/sessionIndexSource'; +import type { SessionSummary } from '@moonshot-ai/agent-core-v2/app/sessionIndex/sessionIndex'; + +export async function listSessionsV2(homeDir: string): Promise { + const storage = new FileStorageService(homeDir); + const docs = new JsonAtomicDocumentStore(storage); + const out: SessionSummary[] = []; + for (const workspaceId of await listWorkspaceIds(storage, 'sessions')) { + for (const sessionId of await listSessionIds(storage, 'sessions', workspaceId)) { + const summary = await readSessionSummary(docs, 'sessions', workspaceId, sessionId); + if (summary !== undefined) out.push(summary); + } + } + return out; +} + +export async function readSessionSummaryV2( + homeDir: string, + sessionId: string, +): Promise { + const sessions = await listSessionsV2(homeDir); + return sessions.find((s) => s.id === sessionId); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6577cd9a349..da5d1b62fb3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,12 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +catalogs: + default: + zod: + specifier: 4.3.6 + version: 4.3.6 + overrides: kimi-code>@tailwindcss/vite: 4.1.18 ssh2@1.17.0>cpu-features: '-' @@ -312,7 +318,7 @@ importers: version: 3.44.0(react@19.2.5) '@tanstack/react-query': specifier: ^5.74.4 - version: 5.74.4(react@19.2.5) + version: 5.99.2(react@19.2.5) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -394,7 +400,7 @@ importers: version: 16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@testing-library/user-event': specifier: ^14.6.6 - version: 14.6.6(@testing-library/dom@10.4.1) + version: 14.6.7(@testing-library/dom@10.4.1) '@types/diff': specifier: ^8.0.0 version: 8.0.0 @@ -870,9 +876,9 @@ importers: packages/migration-legacy: dependencies: - '@moonshot-ai/agent-core': + '@moonshot-ai/agent-core-v2': specifier: workspace:^ - version: link:../agent-core + version: link:../agent-core-v2 smol-toml: specifier: ^1.6.1 version: 1.6.1 @@ -880,9 +886,9 @@ importers: specifier: ^4.3.6 version: 4.3.6 devDependencies: - '@moonshot-ai/kaos': + '@moonshot-ai/transcript': specifier: workspace:^ - version: link:../kaos + version: link:../transcript packages/minidb: {} @@ -4231,17 +4237,9 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 - '@tanstack/query-core@5.74.4': - resolution: {integrity: sha512-YuG0A0+3i9b2Gfo9fkmNnkUWh5+5cFhWBN0pJAHkHilTx6A0nv8kepkk4T4GRt4e5ahbtFj2eTtkiPcVU1xO4A==} - '@tanstack/query-core@5.99.2': resolution: {integrity: sha512-1HunU0bXVsR1ZJMZbcOPE6VtaBJxsW809RE9xPe4Gz7MlB0GWwQvuTPhMoEmQ/hIzFKJ/DWAuttIe7BOaWx0tA==} - '@tanstack/react-query@5.74.4': - resolution: {integrity: sha512-mAbxw60d4ffQ4qmRYfkO1xzRBPUEf/72Dgo3qqea0J66nIKuDTLEqQt0ku++SDFlMGMnB6uKDnEG1xD/TDse4Q==} - peerDependencies: - react: ^18 || ^19 - '@tanstack/react-query@5.99.2': resolution: {integrity: sha512-vM91UEe45QUS9ED6OklsVL15i8qKcRqNwpWzPTVWvRPRSEgDudDgHpvyTjcdlwHcrKNa80T+xXYcchT2noPnZA==} peerDependencies: @@ -4275,8 +4273,8 @@ packages: '@types/react-dom': optional: true - '@testing-library/user-event@14.6.6': - resolution: {integrity: sha512-Jbs9FpkkIDw8FgSc6kOVsOv8JuuqGAL7J4X1oot77JxAoDlkNn2GRkd0aYRVuQ+pVQAiHWVkE4rX/dkF5fBiCw==} + '@testing-library/user-event@14.6.7': + resolution: {integrity: sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==} engines: {node: '>=12', npm: '>=6'} peerDependencies: '@testing-library/dom': '>=7.21.4' @@ -13007,15 +13005,8 @@ snapshots: tailwindcss: 4.2.2 vite: 6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3) - '@tanstack/query-core@5.74.4': {} - '@tanstack/query-core@5.99.2': {} - '@tanstack/react-query@5.74.4(react@19.2.5)': - dependencies: - '@tanstack/query-core': 5.74.4 - react: 19.2.5 - '@tanstack/react-query@5.99.2(react@19.2.5)': dependencies: '@tanstack/query-core': 5.99.2 @@ -13050,7 +13041,7 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@testing-library/user-event@14.6.6(@testing-library/dom@10.4.1)': + '@testing-library/user-event@14.6.7(@testing-library/dom@10.4.1)': dependencies: '@testing-library/dom': 10.4.1 @@ -16281,8 +16272,8 @@ snapshots: magicast@0.5.2: dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 source-map-js: 1.2.1 make-dir@4.0.0: @@ -17180,14 +17171,14 @@ snapshots: parse-json@5.2.0: dependencies: - '@babel/code-frame': 7.29.0 + '@babel/code-frame': 7.29.7 error-ex: 1.3.4 json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 parse-json@8.3.0: dependencies: - '@babel/code-frame': 7.29.0 + '@babel/code-frame': 7.29.7 index-to-position: 1.2.0 type-fest: 4.41.0