Skip to content
Merged
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/tidy-donuts-heal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix sessions missing from the session picker when their cached metadata predates the archived flag.
8 changes: 6 additions & 2 deletions apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { writeFileSync } from 'node:fs';
import { join } from 'node:path';

import type { DeviceAuthorization } from '@moonshot-ai/kimi-code-oauth';
import { log } from '@moonshot-ai/kimi-code-sdk';
import type {
ApprovalRequest,
ApprovalResponse,
Expand Down Expand Up @@ -1675,8 +1676,11 @@ export class KimiTUI {
this.state.appState.sessionId,
this.hasSessionContent(),
);
} catch {
/* silently ignore */
} catch (error) {
// The picker must keep working (it renders the empty state), but a
// swallowed failure surfaces as a misleading "No sessions found." —
// keep a log trail so the real error stays discoverable.
log.warn('failed to fetch sessions for picker', { error: String(error) });
} finally {
this.state.loadingSessions = false;
}
Expand Down
29 changes: 25 additions & 4 deletions packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,26 @@ function matchesChildOf(summary: SessionSummary, parentId: string | undefined):
);
}

/**
* Runtime shape check for read-model cache hits. The query store persists
* caller-provided JSON with no schema enforcement (and entries mirrored before
* a fix may predate required fields — e.g. `archived` written as `undefined`
* and dropped by JSON), so a cached value is only trusted when it carries the
* fields the session-summary contract requires; anything else is treated as a
* cold miss and rebuilt from disk.
*/
function isSessionSummaryShape(value: unknown): value is SessionSummary {
if (value === null || typeof value !== 'object') return false;
const summary = value as Record<string, unknown>;
return (
typeof summary['id'] === 'string' &&
typeof summary['workspaceId'] === 'string' &&
typeof summary['createdAt'] === 'number' &&
typeof summary['updatedAt'] === 'number' &&
typeof summary['archived'] === 'boolean'
);
}

export class FileSessionIndex implements ISessionIndex {
declare readonly _serviceBrand: undefined;

Expand Down Expand Up @@ -173,8 +193,8 @@ export class FileSessionIndex implements ISessionIndex {
}

private async getFromReadModel(id: string): Promise<SessionSummary | undefined> {
const cached = await this.queryStore.get<SessionSummary>(SESSION_COLLECTION, id);
if (cached !== undefined) return cached;
const cached: unknown = await this.queryStore.get(SESSION_COLLECTION, id);
if (isSessionSummaryShape(cached)) return cached;
for (const workspaceId of await this.listWorkspaceIds()) {
if (!(await this.hasSession(workspaceId, id))) continue;
return this.getCachedSummary(workspaceId, id);
Expand Down Expand Up @@ -217,10 +237,11 @@ export class FileSessionIndex implements ISessionIndex {
workspaceId: string,
sessionId: string,
): Promise<SessionSummary | undefined> {
const cached = await this.queryStore.get<SessionSummary>(SESSION_COLLECTION, sessionId);
if (cached !== undefined) return cached;
const cached: unknown = await this.queryStore.get(SESSION_COLLECTION, sessionId);
if (isSessionSummaryShape(cached)) return cached;
const summary = await this.readSummary(workspaceId, sessionId);
if (summary !== undefined) {
// Also overwrites a cache entry that failed the shape check above.
await this.queryStore.put(SESSION_COLLECTION, sessionId, summary);
}
return summary;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,11 @@ export class SessionMetadata extends Disposable implements ISessionMetadata {
lastPrompt: this.data.lastPrompt,
createdAt: this.data.createdAt,
updatedAt: this.data.updatedAt,
archived: this.data.archived,
// `data.archived` stays undefined for sessions whose state.json
// predates the field; the read-model contract requires a boolean
// (`readSummary` normalizes the same way), so an undefined here would
// poison the cache entry and fail contract validation on reads.
archived: this.data.archived === true,
custom: this.data.custom,
});
} catch (error) {
Expand Down
33 changes: 33 additions & 0 deletions packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,39 @@ describe('FileSessionIndex (read model)', () => {
expect(got?.title).toBe('cached');
});

it('list treats a cache entry missing required fields as a cold miss', async () => {
await seedSession('s1', { title: 'on-disk', createdAt: 1, updatedAt: 2 });
const store = build();
// Mirrors a poisoned entry written before `archived` was normalized to a
// boolean (JSON dropped the undefined field entirely).
await queryStore.put(SESSION_COLLECTION, 's1', {
id: 's1',
workspaceId,
title: 'stale',
createdAt: 1,
updatedAt: 2,
});

const page = await store.list({ workspaceIds: [workspaceId] });
expect(page.items).toHaveLength(1);
expect(page.items[0]?.title).toBe('on-disk');
expect(page.items[0]?.archived).toBe(false);

// The bad entry is overwritten by the disk backfill.
const cached = await queryStore.get<SessionSummary>(SESSION_COLLECTION, 's1');
expect(cached?.archived).toBe(false);
});

it('get falls back to disk when the cached entry fails the shape check', async () => {
await seedSession('s1', { title: 'on-disk', createdAt: 1, updatedAt: 2 });
const store = build();
await queryStore.put(SESSION_COLLECTION, 's1', { id: 's1' });

const got = await store.get('s1');
expect(got?.title).toBe('on-disk');
expect(got?.archived).toBe(false);
});

it('list filters by childOf from the read model', async () => {
await seedSession('child-a', {
createdAt: 2,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,36 @@ describe('SessionMetadata', () => {
expect(await meta.read()).toMatchObject({ title: 't', archived: true });
});

it('mirrors a boolean archived to the read model even when the loaded document lacks the field', async () => {
// A state.json written before `archived` existed: normalizeSessionMeta
// keeps the field undefined, and a naive mirror would drop the key from
// the cached JSON entirely (failing the read-model contract on reads).
const store = ix.get(IAtomicDocumentStore);
await store.set(META_SCOPE, 'state.json', {
id: 's1',
version: 2,
createdAt: 1700000000000,
updatedAt: 1700000000000,
agents: {},
custom: {},
});

const writes: unknown[] = [];
ix.stub(IQueryStore, {
...stubQueryStore(),
put: async (_c: string, _k: string, value: unknown) => {
writes.push(value);
},
});
ix.stub(IFlagService, stubFlag(true));

const meta = ix.get(ISessionMetadata);
await meta.update({ title: 'x' });

expect(writes).toHaveLength(1);
expect(writes[0]).toMatchObject({ id: 's1', archived: false });
});

it('persists across instances', async () => {
const meta = ix.get(ISessionMetadata);
await meta.update({ title: 'persisted' });
Expand Down
Loading