diff --git a/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts b/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts index db15ec62dadcb..eb34862dc0fca 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts @@ -396,7 +396,7 @@ export class ChatSessionStore extends Disposable { // Write succeeded, update index const newMetadata = await getSessionMetadata(session); - index.entries[session.sessionId] = newMetadata; + index.entries[session.sessionId] = preserveNonEmpty(index.entries[session.sessionId], newMetadata); } catch (e) { this.reportError('sessionWrite', 'Error writing chat session', e); } @@ -413,7 +413,7 @@ export class ChatSessionStore extends Disposable { // TODO get this class on sessionResource const externalSessionId = session.sessionResource.toString(); - index.entries[externalSessionId] = await getSessionMetadata(session); + index.entries[externalSessionId] = preserveNonEmpty(index.entries[externalSessionId], await getSessionMetadata(session)); } catch (e) { this.reportError('sessionMetadataWrite', 'Error writing chat session metadata', e); } @@ -759,11 +759,11 @@ export class ChatSessionStore extends Disposable { updateAndFlushIndexSync(localSessions: ChatModel[], externalSessions: ChatModel[]): void { const index = this.internalGetIndex(); for (const session of localSessions) { - index.entries[session.sessionId] = getSessionMetadataSync(session); + index.entries[session.sessionId] = preserveNonEmpty(index.entries[session.sessionId], getSessionMetadataSync(session)); } for (const session of externalSessions) { const externalSessionId = session.sessionResource.toString(); - index.entries[externalSessionId] = getSessionMetadataSync(session); + index.entries[externalSessionId] = preserveNonEmpty(index.entries[externalSessionId], getSessionMetadataSync(session)); } try { this.storageService.store(ChatIndexStorageKey, index, this.getIndexStorageScope(), StorageTarget.MACHINE); @@ -893,6 +893,18 @@ function getSessionMetadataSync(session: ChatModel): IChatSessionEntryMetadata { }; } +/** + * Once a session's `isEmpty` flag has been cleared, keep it cleared even if a later + * metadata update would otherwise mark it empty again. + */ +function preserveNonEmpty(previous: IChatSessionEntryMetadata | undefined, next: IChatSessionEntryMetadata): IChatSessionEntryMetadata { + if (next.isEmpty && previous && !previous.isEmpty) { + next.isEmpty = false; + } + + return next; +} + async function getSessionMetadata(session: ChatModel | ISerializableChatData): Promise { if (session instanceof ChatModel) { const metadata = getSessionMetadataSync(session); diff --git a/src/vs/workbench/contrib/chat/test/common/model/chatSessionStore.test.ts b/src/vs/workbench/contrib/chat/test/common/model/chatSessionStore.test.ts index 1b6a12deff8b7..80e6625ea0193 100644 --- a/src/vs/workbench/contrib/chat/test/common/model/chatSessionStore.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/model/chatSessionStore.test.ts @@ -29,7 +29,7 @@ import { MockChatModel } from './mockChatModel.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; -function createMockChatModel(sessionResource: URI, options?: { customTitle?: string }): ChatModel { +function createMockChatModel(sessionResource: URI, options?: { customTitle?: string; requestCount?: number }): ChatModel { const sessionId = LocalChatSessionUri.parseLocalSessionId(sessionResource); if (!sessionId) { throw new Error('createMockChatModel requires a local session URI'); @@ -39,10 +39,18 @@ function createMockChatModel(sessionResource: URI, options?: { customTitle?: str if (options?.customTitle) { model.customTitle = options.customTitle; } + if (options?.requestCount) { + setRequestCount(model, options.requestCount); + } // Cast to ChatModel - the mock implements enough of the interface for testing return model as unknown as ChatModel; } +/** Only the request count matters for the index metadata under test. */ +function setRequestCount(model: MockChatModel, count: number): void { + model.requests = Array.from({ length: count }, () => ({} as MockChatModel['requests'][number])); +} + class MockWorkspaceEditingService extends Disposable implements Partial { private readonly _onDidEnterWorkspace = this._register(new Emitter()); readonly onDidEnterWorkspace = this._onDidEnterWorkspace.event; @@ -183,6 +191,46 @@ suite('ChatSessionStore', () => { assert.strictEqual(index['session-1'].title, 'My Custom Title'); }); + test('storeSessions marks a session with no requests as empty', async () => { + const store = createChatSessionStore(); + const model = testDisposables.add(createMockChatModel(LocalChatSessionUri.forSession('session-1'))); + + await store.storeSessions([model]); + + const index = await store.getIndex(); + assert.strictEqual(index['session-1'].isEmpty, true); + }); + + test('storeSessions keeps a session non-empty after all of its requests are removed', async () => { + const store = createChatSessionStore(); + const model = testDisposables.add(createMockChatModel(LocalChatSessionUri.forSession('session-1'), { requestCount: 2 })); + + await store.storeSessions([model]); + assert.strictEqual((await store.getIndex())['session-1'].isEmpty, false); + + // Simulate restoring the checkpoint on the first request, which removes every request. + setRequestCount(model as unknown as MockChatModel, 0); + await store.storeSessions([model]); + + assert.strictEqual((await store.getIndex())['session-1'].isEmpty, false); + }); + + test('updateAndFlushIndexSync persists preserved non-empty state to storage', async () => { + const store = createChatSessionStore(); + const model = testDisposables.add(createMockChatModel(LocalChatSessionUri.forSession('session-1'), { requestCount: 2 })); + + await store.storeSessions([model]); + assert.strictEqual((await store.getIndex())['session-1'].isEmpty, false); + + setRequestCount(model as unknown as MockChatModel, 0); + store.updateAndFlushIndexSync([model], []); + + // Recreate the store so the index is read back from storage rather than the in-memory cache. + const reloadedStore = createChatSessionStore(); + const index = await reloadedStore.getIndex(); + assert.strictEqual(index['session-1'].isEmpty, false); + }); + test('readSession returns stored session data', async () => { const store = createChatSessionStore(); const model = testDisposables.add(createMockChatModel(LocalChatSessionUri.forSession('session-1'))); diff --git a/src/vs/workbench/contrib/chat/test/common/model/mockChatModel.ts b/src/vs/workbench/contrib/chat/test/common/model/mockChatModel.ts index 12bb0f6268e73..8846bf87a3791 100644 --- a/src/vs/workbench/contrib/chat/test/common/model/mockChatModel.ts +++ b/src/vs/workbench/contrib/chat/test/common/model/mockChatModel.ts @@ -19,6 +19,7 @@ export class MockChatModel extends Disposable implements IChatModel { readonly timestamp = 0; readonly timing: IChatSessionTiming = { created: Date.now(), lastRequestStarted: undefined, lastRequestEnded: undefined }; readonly initialLocation = ChatAgentLocation.Chat; + readonly sessionTypeSelectionReason = undefined; readonly title = ''; readonly hasCustomTitle = false; customTitle: string | undefined; @@ -67,7 +68,7 @@ export class MockChatModel extends Disposable implements IChatModel { } startEditingSession(isGlobalEditingSession?: boolean, transferFromSession?: IChatEditingSession): void { } - getRequests(): IChatRequestModel[] { return []; } + getRequests(): IChatRequestModel[] { return this.requests; } setCheckpoint(requestId: string | undefined): void { } setRepoData(data: IExportableRepoData | undefined): void { this.repoData = data; } workingDirectory: URI | undefined = undefined;