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();