diff --git a/.changeset/migration-import-title-and-corrupt-fixes.md b/.changeset/migration-import-title-and-corrupt-fixes.md new file mode 100644 index 000000000..217d7a929 --- /dev/null +++ b/.changeset/migration-import-title-and-corrupt-fixes.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix two kimi-cli session import edge cases: a blank/whitespace-only custom title is no longer imported as an all-spaces, falsely-custom session title (it falls back to the prompt prefix), and an imported `context.jsonl` whose lines are all valid JSON but not objects is now classified as empty rather than reported as a corrupt migration failure. diff --git a/packages/migration-legacy/src/sessions/state-writer.ts b/packages/migration-legacy/src/sessions/state-writer.ts index 12c849b9c..7f0d0ab4b 100644 --- a/packages/migration-legacy/src/sessions/state-writer.ts +++ b/packages/migration-legacy/src/sessions/state-writer.ts @@ -14,9 +14,14 @@ export interface StateWriteInput { 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 customTitleRaw = input.oldState.custom_title ?? null; + // Trim and treat a blank/whitespace-only custom_title as absent, mirroring + // how `fallbackTitle` is trimmed below. Otherwise an all-spaces title slips + // past the length checks, producing a blank session title falsely flagged as + // user-custom. + const customTitle = + customTitleRaw !== null && customTitleRaw.trim().length > 0 ? customTitleRaw.trim() : null; + const isCustomTitle = customTitle !== null && !input.oldState.title_generated; const fallbackTitle = input.lastUserPrompt.slice(0, 50).trim(); const candidateTitle = customTitle ?? fallbackTitle; const finalTitle = candidateTitle.length > 0 ? candidateTitle : 'Imported session'; diff --git a/packages/migration-legacy/src/sessions/translator.ts b/packages/migration-legacy/src/sessions/translator.ts index 499180b94..c82f9cf31 100644 --- a/packages/migration-legacy/src/sessions/translator.ts +++ b/packages/migration-legacy/src/sessions/translator.ts @@ -49,8 +49,12 @@ export function analyzeContextContent(lines: readonly string[]): ContextContent } catch { continue; } - if (typeof parsed !== 'object' || parsed === null) continue; + // A line that JSON.parse accepts has "parsed" per the corrupt contract + // above, even when it is a scalar/array rather than an object. Mark it + // before the shape check so an all-valid-JSON-but-no-object context is + // classified 'empty' (cleared session), not 'corrupt' (disk damage). hadParseableLine = true; + if (typeof parsed !== 'object' || parsed === null) continue; const role = (parsed as Record)['role']; if (typeof role === 'string' && USABLE_ROLES.has(role)) return 'real'; } diff --git a/packages/migration-legacy/test/sessions/state-writer.test.ts b/packages/migration-legacy/test/sessions/state-writer.test.ts index a393100d8..ce4ac4655 100644 --- a/packages/migration-legacy/test/sessions/state-writer.test.ts +++ b/packages/migration-legacy/test/sessions/state-writer.test.ts @@ -47,6 +47,34 @@ describe('writeSessionState', () => { expect(meta.isCustomTitle).toBe(false); }); + it('treats a blank/whitespace-only custom_title as absent and falls back', async () => { + await writeSessionState(dir, { + oldState: { custom_title: ' ', title_generated: false, wire_mtime: 1 }, + lastUserPrompt: 'a real prompt here', + sourcePath: '/a', + oldSessionUuid: 'u', + wireProtocolFromOld: null, + createdAtMs: 1, + }); + const meta = JSON.parse(await readFile(join(dir, 'state.json'), 'utf-8')); + expect(meta.title).toBe('a real prompt here'); + expect(meta.isCustomTitle).toBe(false); + }); + + it('trims surrounding whitespace from a custom_title', async () => { + await writeSessionState(dir, { + oldState: { custom_title: ' My chat ', title_generated: false, wire_mtime: 1 }, + lastUserPrompt: 'irrelevant', + sourcePath: '/a', + oldSessionUuid: 'u', + wireProtocolFromOld: null, + createdAtMs: 1, + }); + const meta = JSON.parse(await readFile(join(dir, 'state.json'), 'utf-8')); + expect(meta.title).toBe('My chat'); + expect(meta.isCustomTitle).toBe(true); + }); + it('uses Imported session as fallback when no title source', async () => { await writeSessionState(dir, { oldState: { wire_mtime: 1 }, diff --git a/packages/migration-legacy/test/sessions/translator.test.ts b/packages/migration-legacy/test/sessions/translator.test.ts index 785aab192..f3c4bb20f 100644 --- a/packages/migration-legacy/test/sessions/translator.test.ts +++ b/packages/migration-legacy/test/sessions/translator.test.ts @@ -170,4 +170,12 @@ describe('analyzeContextContent', () => { ]), ).toBe('empty'); }); + + it("'empty' (not 'corrupt') when lines are valid JSON scalars/arrays, not objects", () => { + // These lines parse successfully, so they are not "disk damage". Per the + // corrupt contract (every non-blank line *failed to parse*), they must be + // classified 'empty', not 'corrupt'. + expect(analyzeContextContent(['42', '"hi"', 'true'])).toBe('empty'); + expect(analyzeContextContent(['[]'])).toBe('empty'); + }); });