From 1ab5f343a68a4ce936c1823db3f1313dc334d3a9 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Thu, 30 Jul 2026 11:51:57 +0800 Subject: [PATCH 1/2] fix(agent-core-v2): treat cache entries missing required fields as cold misses - normalize `archived` to a boolean when mirroring session metadata to the read model, so entries for pre-`archived` sessions no longer lose the key during JSON serialization - add a runtime shape check on read-model cache hits; entries missing required fields are rebuilt from disk and overwritten, self-healing poisoned entries written before the fix - log a warning when the TUI session picker fails to fetch sessions instead of silently showing "No sessions found." --- apps/kimi-code/src/tui/kimi-tui.ts | 8 +++-- .../app/sessionIndex/sessionIndexService.ts | 29 +++++++++++++--- .../sessionMetadata/sessionMetadataService.ts | 6 +++- .../app/sessionIndex/sessionIndex.test.ts | 33 +++++++++++++++++++ .../sessionMetadata/sessionMetadata.test.ts | 30 +++++++++++++++++ 5 files changed, 99 insertions(+), 7 deletions(-) diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index f0764c82b9..52f34ef164 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -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, @@ -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; } diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts index 29f86e54b5..b58f9ed590 100644 --- a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts @@ -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; + 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; @@ -173,8 +193,8 @@ export class FileSessionIndex implements ISessionIndex { } private async getFromReadModel(id: string): Promise { - const cached = await this.queryStore.get(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); @@ -217,10 +237,11 @@ export class FileSessionIndex implements ISessionIndex { workspaceId: string, sessionId: string, ): Promise { - const cached = await this.queryStore.get(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; diff --git a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts index 9b58cd0f25..d163afa015 100644 --- a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts +++ b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts @@ -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) { diff --git a/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts b/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts index c3f0875c30..a0b35cceef 100644 --- a/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts +++ b/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts @@ -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(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, diff --git a/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts b/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts index 1a3048bfa9..ca8a2cd787 100644 --- a/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts +++ b/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts @@ -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' }); From ac32b1463b88d2a37f004d798dc50430ec170f57 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Thu, 30 Jul 2026 14:19:05 +0800 Subject: [PATCH 2/2] chore: add changeset for session index cold-miss fix --- .changeset/tidy-donuts-heal.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/tidy-donuts-heal.md diff --git a/.changeset/tidy-donuts-heal.md b/.changeset/tidy-donuts-heal.md new file mode 100644 index 0000000000..c2241db828 --- /dev/null +++ b/.changeset/tidy-donuts-heal.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix sessions missing from the session picker when their cached metadata predates the archived flag.