Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/migration-import-title-and-corrupt-fixes.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 8 additions & 3 deletions packages/migration-legacy/src/sessions/state-writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,14 @@ export interface StateWriteInput {
export async function writeSessionState(sessionDir: string, input: StateWriteInput): Promise<void> {
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';
Expand Down
6 changes: 5 additions & 1 deletion packages/migration-legacy/src/sessions/translator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>)['role'];
if (typeof role === 'string' && USABLE_ROLES.has(role)) return 'real';
}
Expand Down
28 changes: 28 additions & 0 deletions packages/migration-legacy/test/sessions/state-writer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
8 changes: 8 additions & 0 deletions packages/migration-legacy/test/sessions/translator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});