diff --git a/apps/kimi-code/src/migration/detect-pending.ts b/apps/kimi-code/src/migration/detect-pending.ts index a56a331858d..5ad58fc1747 100644 --- a/apps/kimi-code/src/migration/detect-pending.ts +++ b/apps/kimi-code/src/migration/detect-pending.ts @@ -7,7 +7,6 @@ import { existsSync } from 'node:fs'; import { detectMigration, - countImportedSessionsNeedingRepair, shouldSuppressMigration, type MigrationPlan, } from '@moonshot-ai/migration-legacy'; @@ -35,16 +34,8 @@ 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; @@ -73,9 +64,8 @@ export async function detectPendingMigration( !plan.hasUserHistory && !plan.hasSkills && !plan.hasPlans && - sessionsNeedingRepair === 0 && (plan.sessionScanFailures?.length ?? 0) === 0; if (nothingToMigrate) return null; - return { ...plan, sessionsNeedingRepair }; + return plan; } diff --git a/apps/kimi-code/src/migration/migration-screen.ts b/apps/kimi-code/src/migration/migration-screen.ts index 6477185f375..1a26d85ab74 100644 --- a/apps/kimi-code/src/migration/migration-screen.ts +++ b/apps/kimi-code/src/migration/migration-screen.ts @@ -304,11 +304,6 @@ 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`)); } @@ -325,7 +320,6 @@ export class MigrationScreenComponent extends Container implements Focusable { } if ( sum.sessions.sessionsMigrated === 0 && - sum.sessions.sessionsRepaired === 0 && sum.plans.copied === 0 && migratedKinds.length === 0 ) { diff --git a/apps/kimi-code/src/migration/run-headless.ts b/apps/kimi-code/src/migration/run-headless.ts index 396045d5a3b..491d560fd21 100644 --- a/apps/kimi-code/src/migration/run-headless.ts +++ b/apps/kimi-code/src/migration/run-headless.ts @@ -174,7 +174,7 @@ function logReport( 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}` + + ` already-migrated=${s.sessionsAlreadyMigrated} 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}` : '') + diff --git a/apps/kimi-code/test/migration/detect-pending.test.ts b/apps/kimi-code/test/migration/detect-pending.test.ts index bafa91a27cc..ff02e4ebffa 100644 --- a/apps/kimi-code/test/migration/detect-pending.test.ts +++ b/apps/kimi-code/test/migration/detect-pending.test.ts @@ -139,49 +139,4 @@ describe('detectPendingMigration', () => { 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/migration-screen.test.ts b/apps/kimi-code/test/migration/migration-screen.test.ts index 3be35d2fef2..6b508e889d4 100644 --- a/apps/kimi-code/test/migration/migration-screen.test.ts +++ b/apps/kimi-code/test/migration/migration-screen.test.ts @@ -279,7 +279,6 @@ function makeReport( sessionsAttempted: 50, sessionsMigrated: 50, sessionsAlreadyMigrated: 0, - sessionsRepaired: 0, sessionsSkippedPlaceholder: 0, sessionsSkippedEmpty: 0, sessionsSkippedMalformed: 0, @@ -316,17 +315,38 @@ 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', () => { + it('renders nothing-needed-migrating when every counter is zero', () => { const c = new MigrationScreenComponent({ plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', onComplete: () => {}, }); - c._testShowResult(makeReport({ sessionsMigrated: 0, sessionsRepaired: 7 })); + c._testShowResult( + makeReport( + { sessionsAttempted: 0, sessionsMigrated: 0 }, + { + config: { + migrated: false, + tuiExtracted: false, + droppedProviders: [], + droppedModels: [], + droppedKeys: [], + configConflicts: [], + wroteSiblingDueToConflict: false, + wroteTuiSibling: false, + migratedHooks: 0, + droppedHooks: 0, + sourceUnreadable: false, + deviceIdCopied: false, + siblingContents: { providers: [], models: [], hooks: 0 }, + }, + userHistory: { copied: 0, skippedExisting: 0 }, + }, + ), + ); const out = c.render(80).join('\n'); - expect(out).toContain('7 sessions repaired'); - expect(out).not.toContain('Nothing needed migrating'); + expect(out).toContain('Nothing needed migrating'); }); it('renders migrated hooks in the ✓ line and dropped hooks as a warning', () => { diff --git a/apps/vscode/src/migration/legacy-migration.manager.ts b/apps/vscode/src/migration/legacy-migration.manager.ts index 496c7faf6d7..af741a68c77 100644 --- a/apps/vscode/src/migration/legacy-migration.manager.ts +++ b/apps/vscode/src/migration/legacy-migration.manager.ts @@ -5,7 +5,6 @@ import { isAbsolute, join, resolve, win32 } from "node:path"; import { detectMigration, runMigration, - countImportedSessionsNeedingRepair, defaultPlansSourceDir, shouldSuppressMigration, type MigrationPlan, @@ -278,12 +277,6 @@ 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; @@ -353,7 +346,6 @@ export class LegacyMigrationManager { if ( !ignoreMarker && - sessionsNeedingRepair === 0 && shouldSuppressMigration({ sourceHome: candidate.sourceHome, targetHome: this.targetHome, @@ -365,7 +357,7 @@ export class LegacyMigrationManager { pending.push({ preview, - plan: { ...plan, sessionsNeedingRepair }, + plan, }); } @@ -565,8 +557,7 @@ function aggregateTotals(sources: readonly LegacyMigrationSourceResult[]): Legac skills += summary.skills.copied; planFiles += summary.plans.copied; sessions += summary.sessions.sessionsMigrated; - alreadyMigratedSessions += - summary.sessions.sessionsAlreadyMigrated + summary.sessions.sessionsRepaired; + alreadyMigratedSessions += summary.sessions.sessionsAlreadyMigrated; skippedItems += summary.userHistory.skippedExisting + summary.skills.skippedExisting + diff --git a/packages/migration-legacy/src/index.ts b/packages/migration-legacy/src/index.ts index 6689064a230..999f305b46b 100644 --- a/packages/migration-legacy/src/index.ts +++ b/packages/migration-legacy/src/index.ts @@ -6,7 +6,6 @@ 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 { diff --git a/packages/migration-legacy/src/run-migration.ts b/packages/migration-legacy/src/run-migration.ts index fdbeddbd993..50a97090a6e 100644 --- a/packages/migration-legacy/src/run-migration.ts +++ b/packages/migration-legacy/src/run-migration.ts @@ -189,7 +189,6 @@ function emptyConfigOnlySessions(): SessionsSummary { sessionsAttempted: 0, sessionsMigrated: 0, sessionsAlreadyMigrated: 0, - sessionsRepaired: 0, sessionsSkippedPlaceholder: 0, sessionsSkippedEmpty: 0, sessionsSkippedMalformed: 0, diff --git a/packages/migration-legacy/src/sessions/index.ts b/packages/migration-legacy/src/sessions/index.ts index e1c6d0a98b1..130cfdc1870 100644 --- a/packages/migration-legacy/src/sessions/index.ts +++ b/packages/migration-legacy/src/sessions/index.ts @@ -126,7 +126,6 @@ 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({ @@ -156,7 +155,7 @@ export async function migrateSessionsStep( reason: `session migrated but index append failed: ${String(error)}`, }); } - } else if (result.outcome === 'already-migrated' || result.outcome === 'repaired') { + } else if (result.outcome === 'already-migrated') { // 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 @@ -167,8 +166,7 @@ export async function migrateSessionsStep( sessionDir: result.targetDir, workDir: c.workdirPath, }); - if (result.outcome === 'repaired') repaired++; - else alreadyMigrated++; + 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. @@ -204,7 +202,6 @@ export async function migrateSessionsStep( sessionsAttempted: candidates.length, sessionsMigrated: migrated, sessionsAlreadyMigrated: alreadyMigrated, - sessionsRepaired: repaired, sessionsSkippedPlaceholder, sessionsSkippedEmpty, sessionsSkippedMalformed, @@ -287,7 +284,6 @@ function emptySummary(): SessionsSummary { sessionsAttempted: 0, sessionsMigrated: 0, sessionsAlreadyMigrated: 0, - sessionsRepaired: 0, sessionsSkippedPlaceholder: 0, sessionsSkippedEmpty: 0, sessionsSkippedMalformed: 0, diff --git a/packages/migration-legacy/src/sessions/migrate-one.ts b/packages/migration-legacy/src/sessions/migrate-one.ts index 0cd621c6d4a..65d02829608 100644 --- a/packages/migration-legacy/src/sessions/migrate-one.ts +++ b/packages/migration-legacy/src/sessions/migrate-one.ts @@ -17,14 +17,11 @@ 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 }; @@ -41,23 +38,9 @@ export async function migrateOneSession(input: MigrateOneInput): Promise= 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)', - }; + return { outcome: 'already-migrated', targetDir }; } // A real, unrelated kimi-code session occupies the path — a true conflict. if (cls === 'foreign') { @@ -231,19 +214,6 @@ 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 deleted file mode 100644 index 5340c7bf22a..00000000000 --- a/packages/migration-legacy/src/sessions/repair-imported.ts +++ /dev/null @@ -1,297 +0,0 @@ -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/state-writer.ts b/packages/migration-legacy/src/sessions/state-writer.ts index d25dfa3d2f6..a7626b18eb1 100644 --- a/packages/migration-legacy/src/sessions/state-writer.ts +++ b/packages/migration-legacy/src/sessions/state-writer.ts @@ -7,7 +7,6 @@ import { } 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; @@ -88,7 +87,6 @@ export async function writeSessionState(sessionDir: string, input: StateWriteInp }, 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, diff --git a/packages/migration-legacy/src/sessions/turn-structure.ts b/packages/migration-legacy/src/sessions/turn-structure.ts index 2b5e398e573..b45c3973222 100644 --- a/packages/migration-legacy/src/sessions/turn-structure.ts +++ b/packages/migration-legacy/src/sessions/turn-structure.ts @@ -1,15 +1,5 @@ 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[]; diff --git a/packages/migration-legacy/src/types.ts b/packages/migration-legacy/src/types.ts index 88f68d62d36..6b7c0417659 100644 --- a/packages/migration-legacy/src/types.ts +++ b/packages/migration-legacy/src/types.ts @@ -21,13 +21,6 @@ export interface MigrationPlan { 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. @@ -143,11 +136,6 @@ 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; diff --git a/packages/migration-legacy/test/report.test.ts b/packages/migration-legacy/test/report.test.ts index 57263603dd7..c4819318d93 100644 --- a/packages/migration-legacy/test/report.test.ts +++ b/packages/migration-legacy/test/report.test.ts @@ -49,7 +49,6 @@ describe('writeReport', () => { sessionsAttempted: 0, sessionsMigrated: 0, sessionsAlreadyMigrated: 0, - sessionsRepaired: 0, sessionsSkippedPlaceholder: 0, sessionsSkippedEmpty: 0, sessionsSkippedMalformed: 0, 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 baee1513579..9f752288a24 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 @@ -24,7 +24,6 @@ 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", @@ -72,7 +71,6 @@ 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", @@ -179,7 +177,6 @@ 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", @@ -227,7 +224,6 @@ 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", @@ -278,7 +274,6 @@ 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", @@ -323,7 +318,6 @@ 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", @@ -368,7 +362,6 @@ 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", @@ -416,7 +409,6 @@ 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", @@ -465,7 +457,6 @@ 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", diff --git a/packages/migration-legacy/test/sessions/migrate-one.test.ts b/packages/migration-legacy/test/sessions/migrate-one.test.ts index 189438d684d..e4cee02ead9 100644 --- a/packages/migration-legacy/test/sessions/migrate-one.test.ts +++ b/packages/migration-legacy/test/sessions/migrate-one.test.ts @@ -4,7 +4,6 @@ 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'; @@ -318,241 +317,38 @@ describe('migrateOneSession (tiny-hello-world fixture)', () => { }); }); -describe('migrateOneSession repair of message-only imports', () => { - const workdirPath = '/Users/me/proj'; - - async function seedImportedTarget( - wireLines: string[], - stateExtra: Record = {}, - ): Promise { +describe('migrateOneSession with a pre-existing old-format import', () => { + it('leaves an old message-only import untouched as already-migrated', async () => { + const workdirPath = '/Users/me/proj'; const targetDir = join( targetSessionsDir(targetHome), computeWorkdirBucket(workdirPath), - 'ses_repair-uuid', + 'ses_old-import-uuid', ); await mkdir(join(targetDir, 'agents', 'main'), { recursive: true }); + const wireLines = [ + '{"type":"metadata","protocol_version":"1.0","created_at":1700000000000}', + '{"type":"context.append_message","message":{"role":"user","content":[{"type":"text","text":"old question"}],"toolCalls":[]}}', + ]; await writeFile(join(targetDir, 'agents', 'main', 'wire.jsonl'), wireLines.join('\n') + '\n'); await writeFile( join(targetDir, 'state.json'), JSON.stringify({ - id: 'ses_repair-uuid', + id: 'ses_old-import-uuid', title: 'old import', - custom: { imported_from_kimi_cli: true, kimi_cli_session_id: 'repair-uuid' }, - ...stateExtra, + custom: { imported_from_kimi_cli: true, kimi_cli_session_id: 'old-import-uuid' }, }), ); - 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') }, + const result = await migrateOneSession({ + source: { uuid: 'old-import-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); + const wire = await readFile(join(targetDir, 'agents', 'main', 'wire.jsonl'), 'utf-8'); + expect(wire).toBe(wireLines.join('\n') + '\n'); }); }); @@ -595,7 +391,7 @@ describe('migrateOneSession todo list migration', () => { ], }); const state = JSON.parse(await readFile(join(targetDir, 'state.json'), 'utf-8')); - expect(state.custom.import_format_version).toBe(2); + expect(state.custom.imported_from_kimi_cli).toBe(true); }); }); @@ -722,71 +518,4 @@ describe('migrateOneSession subagent migration', () => { 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'); - }); });