fix: delete local sessions immediately and prevent re-persistence

Local sessions were not removed from the UI when deleted, and reappeared
after reload. Three issues were at play:

1. removeHistoryEntry deleted the persisted file but left the live
   ChatModel alive and unmarked, so getLiveSessionItems kept returning it
   and willDisposeModel re-saved it on shutdown. Fixed by adding an
   isDeleted flag on ChatModel, checked by shouldBeInHistory and
   shouldStoreSession.

2. LocalAgentsSessionsController fired a removed event on
   onDidDisposeSession but never removed the session from _items, so
   subsequent refresh cycles via resolveProvider re-added it. Fixed by
   deleting from _items in the dispose handler.

3. _refreshSessionCache only detected removal of AgentSessionAdapter
   instances, but committed local sessions could remain as LocalNewSession
   in the cache. Changed the filter to skip only _currentNewSession.
This commit is contained in:
Alex Dima
2026-05-18 01:06:58 +02:00
parent 33535212a9
commit d5747b31c4
6 changed files with 151 additions and 2 deletions
@@ -2867,7 +2867,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions
const removedData: ICopilotChatSession[] = [];
for (const [key, adapter] of this._sessionCache) {
if (!currentKeys.has(key) && adapter instanceof AgentSessionAdapter) {
if (!currentKeys.has(key) && adapter !== this._currentNewSession) {
removedData.push(adapter);
cacheChanged = true;
}
@@ -112,6 +112,9 @@ export class LocalAgentsSessionsController extends Disposable implements IChatSe
const removedSessionResources = e.sessionResources.filter(resource => getChatSessionType(resource) === this.chatSessionType);
if (removedSessionResources.length) {
for (const resource of removedSessionResources) {
this._items.delete(resource);
}
this._onDidChangeChatSessionItems.fire({ removed: removedSessionResources });
}
}));
@@ -289,6 +289,9 @@ export class ChatService extends Disposable implements IChatService {
* Only persist local sessions from chat that are not imported.
*/
private shouldStoreSession(session: ChatModel): boolean {
if (session.isDeleted) {
return false;
}
if (!LocalChatSessionUri.parseLocalSessionId(session.sessionResource)) {
return false;
}
@@ -437,11 +440,15 @@ export class ChatService extends Disposable implements IChatService {
}
private shouldBeInHistory(entry: ChatModel): boolean {
return !entry.isImported && !!LocalChatSessionUri.parseLocalSessionId(entry.sessionResource) && entry.initialLocation === ChatAgentLocation.Chat;
return !entry.isImported && !entry.isDeleted && !!LocalChatSessionUri.parseLocalSessionId(entry.sessionResource) && entry.initialLocation === ChatAgentLocation.Chat;
}
async removeHistoryEntry(sessionResource: URI): Promise<void> {
await this._chatSessionStore.deleteSession(this.toLocalSessionId(sessionResource));
const model = this._sessionModels.get(sessionResource);
if (model) {
model.markDeleted();
}
this._onDidDisposeSession.fire({ sessionResources: [sessionResource], reason: 'cleared' });
}
@@ -2310,6 +2310,14 @@ export class ChatModel extends Disposable implements IChatModel {
return this._isImported;
}
private _isDeleted = false;
get isDeleted(): boolean {
return this._isDeleted;
}
markDeleted(): void {
this._isDeleted = true;
}
private _customTitle: string | undefined;
get customTitle(): string | undefined {
return this._customTitle;
@@ -702,5 +702,73 @@ suite('LocalAgentsSessionsController', () => {
assert.strictEqual(changeEventCount, 0, 'onDidChangeChatSessionItems should NOT fire after model is removed');
});
});
test('should remove session from items and fire removed event on onDidDisposeSession', async () => {
return runWithFakedTimers({}, async () => {
const controller = createController();
const sessionResource = LocalChatSessionUri.forSession('dispose-session');
const mockModel = createMockChatModel({
sessionResource,
hasRequests: true
});
// Add the session and populate items
mockChatService.addSession(mockModel);
mockChatService.setLiveSessionItems([await chatModelToChatDetail(mockModel)]);
await controller.refresh(CancellationToken.None);
assert.strictEqual(controller.items.length, 1);
// Listen for the removed event
const removedResources: URI[] = [];
disposables.add(controller.onDidChangeChatSessionItems(delta => {
if (delta.removed) {
removedResources.push(...delta.removed);
}
}));
// Fire onDidDisposeSession (simulates removeHistoryEntry)
mockChatService.fireDidDisposeSession([sessionResource]);
// Session should be removed from items immediately
assert.strictEqual(controller.items.length, 0, 'items should be empty after dispose');
assert.strictEqual(removedResources.length, 1, 'removed event should fire');
assert.strictEqual(removedResources[0].toString(), sessionResource.toString());
// Even if refresh is called again, the session should not reappear
// (because getLiveSessionItems would still return it, but shouldBeInHistory
// would filter it in the real ChatService — here we simulate by keeping
// liveSessionItems unchanged, but _items was already cleared)
});
});
test('should not re-add disposed session to items on refresh', async () => {
return runWithFakedTimers({}, async () => {
const controller = createController();
const sessionResource = LocalChatSessionUri.forSession('disposed-refresh-session');
const mockModel = createMockChatModel({
sessionResource,
hasRequests: true
});
// Add the session and populate items
mockChatService.addSession(mockModel);
mockChatService.setLiveSessionItems([await chatModelToChatDetail(mockModel)]);
await controller.refresh(CancellationToken.None);
assert.strictEqual(controller.items.length, 1);
// Dispose the session
mockChatService.fireDidDisposeSession([sessionResource]);
assert.strictEqual(controller.items.length, 0);
// Clear live items (simulates isDeleted filtering in real ChatService)
mockChatService.setLiveSessionItems([]);
// Refresh should not bring it back
await controller.refresh(CancellationToken.None);
assert.strictEqual(controller.items.length, 0, 'disposed session should not reappear after refresh');
});
});
});
});
@@ -1817,6 +1817,69 @@ suite('ChatService', () => {
// Clean up
ref.dispose();
});
test('removeHistoryEntry marks model as deleted and excludes from getLiveSessionItems', async () => {
testDisposables.add(chatAgentService.registerAgentImplementation(chatAgentWithMarkdownId, chatAgentWithMarkdown));
const testService = createChatService();
// Create a session and send a message so it has requests
const ref = testDisposables.add(startSessionModel(testService));
const model = ref.object;
const response = await testService.sendRequest(model.sessionResource, `@${chatAgentWithMarkdownId} test request`);
ChatSendResult.assertSent(response);
await response.data.responseCompletePromise;
assert.strictEqual(model.getRequests().length, 1);
// Verify the session appears in live session items
const liveItemsBefore = await testService.getLiveSessionItems();
assert.ok(
liveItemsBefore.some(item => item.sessionResource.toString() === model.sessionResource.toString()),
'Session should appear in getLiveSessionItems before deletion'
);
// Delete the session
await testService.removeHistoryEntry(model.sessionResource);
// Verify the session no longer appears in live session items
const liveItemsAfter = await testService.getLiveSessionItems();
assert.ok(
!liveItemsAfter.some(item => item.sessionResource.toString() === model.sessionResource.toString()),
'Session should NOT appear in getLiveSessionItems after deletion'
);
// Verify onDidDisposeSession was fired
// (model is still alive because ref holds it, but it's marked deleted)
assert.strictEqual((model as ChatModel).isDeleted, true);
});
test('removeHistoryEntry prevents re-saving on model disposal', async () => {
testDisposables.add(chatAgentService.registerAgentImplementation(chatAgentWithMarkdownId, chatAgentWithMarkdown));
const testService = createChatService();
// Create a session with a request
const ref = testDisposables.add(startSessionModel(testService));
const model = ref.object;
const response = await testService.sendRequest(model.sessionResource, `@${chatAgentWithMarkdownId} test request`);
ChatSendResult.assertSent(response);
await response.data.responseCompletePromise;
// Delete the history entry
await testService.removeHistoryEntry(model.sessionResource);
// Release the model reference — this triggers willDisposeModel
ref.dispose();
await testService.waitForModelDisposals();
// Verify the session does NOT reappear in history after disposal
const testService2 = createChatService();
const historyItems = await testService2.getHistorySessionItems();
assert.ok(
!historyItems.some(item => item.sessionResource.toString() === model.sessionResource.toString()),
'Deleted session should NOT reappear in history after model disposal'
);
});
});