From 0e89f70bf68fe67f2dc047ec18b1b5f33121dfde Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Fri, 31 Jul 2026 15:16:48 +0800 Subject: [PATCH] fix: serve the canonical title state from the session index and version the read-model cache readSummary now derives the title state with the same priority chain as the metadata document's canonical normalization (explicit custom marker, valid titleKind, legacy false marker, customTitle, plain title), so list and resume agree on legacy documents too. Read-model cache entries carry a summary version stamp and older-stamped entries are treated as cold misses, so an upgraded reader never serves a stale-shaped summary. --- .../agent-core-v2/docs/state-manifest.d.ts | 2 +- .../src/app/sessionIndex/sessionIndex.ts | 7 +++- .../app/sessionIndex/sessionIndexService.ts | 38 +++++++++++++------ .../sessionMetadata/sessionMetadataService.ts | 2 + .../app/sessionIndex/sessionIndex.test.ts | 36 ++++++++++++++++-- 5 files changed, 69 insertions(+), 16 deletions(-) diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index 109782ff9..3ddcbc858 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -740,7 +740,7 @@ export interface AgentStateSnapshot { 'llmRequester.lastConfigLogSignature': string | undefined; 'llmRequester.mediaDegradedTurns': Set; 'llmRequester.mediaStrippedTurns': Map; 'llmRequester.turnConfigs': Map): SessionSummary['titleKind'] { + const title = typeof meta['title'] === 'string' ? meta['title'] : undefined; + const titleKind = meta['titleKind']; + if (title !== undefined && meta['isCustomTitle'] === true) return 'custom'; + if ( + title !== undefined && + (titleKind === 'replaceable' || titleKind === 'generated' || titleKind === 'custom') + ) { + return titleKind; + } + if (title !== undefined && meta['isCustomTitle'] === false) return 'replaceable'; + if (typeof meta['customTitle'] === 'string') return 'custom'; + return title === undefined ? undefined : 'replaceable'; } function recoverCwd(meta: Record): string | undefined { @@ -110,10 +119,11 @@ function matchesChildOf(summary: SessionSummary, parentId: string | undefined): * 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 { +function isSessionSummaryShape(value: unknown): value is CachedSessionSummary { if (value === null || typeof value !== 'object') return false; const summary = value as Record; return ( + summary['v'] === READ_MODEL_SUMMARY_VERSION && typeof summary['id'] === 'string' && typeof summary['workspaceId'] === 'string' && typeof summary['createdAt'] === 'number' && @@ -247,11 +257,17 @@ export class FileSessionIndex implements ISessionIndex { sessionId: string, ): Promise { const cached: unknown = await this.queryStore.get(SESSION_COLLECTION, sessionId); - if (isSessionSummaryShape(cached)) return cached; + if (isSessionSummaryShape(cached)) { + const { v: _version, ...summary } = cached; + return summary; + } 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); + await this.queryStore.put(SESSION_COLLECTION, sessionId, { + ...summary, + v: READ_MODEL_SUMMARY_VERSION, + }); } return summary; } @@ -343,7 +359,7 @@ export class FileSessionIndex implements ISessionIndex { workspaceId, cwd: recoverCwd(meta), title: typeof meta['title'] === 'string' ? meta['title'] : undefined, - titleKind: toTitleKind(meta['titleKind'], meta['isCustomTitle']), + titleKind: toTitleKind(meta), lastPrompt: typeof meta['lastPrompt'] === 'string' ? meta['lastPrompt'] : undefined, createdAt: parseTime(meta['createdAt']), updatedAt: parseTime(meta['updatedAt']), diff --git a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts index 5a44f797d..cf0c62fd1 100644 --- a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts +++ b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts @@ -42,6 +42,7 @@ import { Emitter, type Event } from '#/_base/event'; import { ILogService } from '#/_base/log/log'; import { defineState } from '#/_base/state/stateRegistry'; import { IFlagService } from '#/app/flag/flag'; +import { READ_MODEL_SUMMARY_VERSION } from '#/app/sessionIndex/sessionIndex'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; import { IQueryStore } from '#/persistence/interface/queryStore'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; @@ -174,6 +175,7 @@ export class SessionMetadata extends Disposable implements ISessionMetadata { // poison the cache entry and fail contract validation on reads. archived: this.data.archived === true, custom: this.data.custom, + v: READ_MODEL_SUMMARY_VERSION, }); } catch (error) { this.log.warn('failed to mirror session metadata to read model', { 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 ba023f11d..56fc7fd61 100644 --- a/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts +++ b/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts @@ -15,7 +15,11 @@ import { ILogService } from '#/_base/log/log'; import { encodeWorkDirKey } from '#/_base/utils/workdir-slug'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IFlagService } from '#/app/flag/flag'; -import { ISessionIndex, type SessionSummary } from '#/app/sessionIndex/sessionIndex'; +import { + ISessionIndex, + READ_MODEL_SUMMARY_VERSION, + type SessionSummary, +} from '#/app/sessionIndex/sessionIndex'; import { FileSessionIndex } from '#/app/sessionIndex/sessionIndexService'; import { MiniDbQueryStore } from '#/persistence/backends/minidb/miniDbQueryStore'; import { JsonAtomicDocumentStore } from '#/persistence/backends/node-fs/atomicDocumentStore'; @@ -144,11 +148,13 @@ describe('FileSessionIndex (legacy)', () => { isCustomTitle: true, }); await seedSession('plain', { title: 'plain' }); + await seedSession('legacy-custom', { customTitle: 'legacy title' }); const store = build(); expect((await store.get('generated'))?.titleKind).toBe('generated'); expect((await store.get('stale-mixed'))?.titleKind).toBe('custom'); - expect((await store.get('plain'))?.titleKind).toBeUndefined(); + expect((await store.get('plain'))?.titleKind).toBe('replaceable'); + expect((await store.get('legacy-custom'))?.titleKind).toBe('custom'); }); it('list filters by sessionId without enumerating all sessions', async () => { @@ -314,13 +320,14 @@ describe('FileSessionIndex (read model)', () => { await fsp.writeFile(join(dir, 'state.json'), JSON.stringify(meta)); } - function summary(id: string, overrides: Partial = {}): SessionSummary { + function summary(id: string, overrides: Partial = {}) { return { id, workspaceId, createdAt: 1, updatedAt: 2, archived: false, + v: READ_MODEL_SUMMARY_VERSION, ...overrides, }; } @@ -373,6 +380,29 @@ describe('FileSessionIndex (read model)', () => { expect(cached?.archived).toBe(false); }); + it('treats a cache entry stamped with an older summary version as a cold miss', async () => { + await seedSession('s1', { + title: '用户标题', + titleKind: 'replaceable', + isCustomTitle: true, + createdAt: 1, + updatedAt: 2, + }); + const store = build(); + await queryStore.put(SESSION_COLLECTION, 's1', { + ...summary('s1', { title: '用户标题', titleKind: 'replaceable' }), + v: 1, + }); + + const got = await store.get('s1'); + // The stale-stamped entry is ignored and backfilled from disk, which + // honors the legacy custom marker over the stale titleKind. + expect(got?.titleKind).toBe('custom'); + const cached = await queryStore.get>(SESSION_COLLECTION, 's1'); + expect(cached?.['v']).toBe(READ_MODEL_SUMMARY_VERSION); + expect(cached?.['titleKind']).toBe('custom'); + }); + 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();