Try to reduce how often LocalAgentsSessionsController fires updates

`LocalAgentsSessionsController` is firing updates on every single response change. This PR tries to reduce this by doing a object equality check before firing the update. In a follow up I'll also see if we can debounce listening to so many request updates
This commit is contained in:
Matt Bierner
2026-03-26 23:06:38 -07:00
parent d7c19c5af6
commit 82136c0853
11 changed files with 173 additions and 117 deletions

View File

@@ -146,7 +146,7 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA
}));
this._register(this._chatService.onDidDisposeSession(e => {
for (const resource of e.sessionResource) {
for (const resource of e.sessionResources) {
this._proxy.$releaseSession(resource);
}
}));

View File

@@ -7,16 +7,17 @@ import { coalesce } from '../../../../../base/common/arrays.js';
import { CancellationToken } from '../../../../../base/common/cancellation.js';
import { Codicon } from '../../../../../base/common/codicons.js';
import { Emitter } from '../../../../../base/common/event.js';
import { Disposable } from '../../../../../base/common/lifecycle.js';
import { ResourceSet } from '../../../../../base/common/map.js';
import { Schemas } from '../../../../../base/common/network.js';
import { Disposable, DisposableResourceMap } from '../../../../../base/common/lifecycle.js';
import { ResourceMap, ResourceSet } from '../../../../../base/common/map.js';
import { equals } from '../../../../../base/common/objects.js';
import { autorun, observableSignalFromEvent } from '../../../../../base/common/observable.js';
import { isEqual } from '../../../../../base/common/resources.js';
import { URI } from '../../../../../base/common/uri.js';
import { ILogService } from '../../../../../platform/log/common/log.js';
import { IWorkbenchContribution } from '../../../../common/contributions.js';
import { convertLegacyChatSessionTiming, IChatDetail, IChatService, ResponseModelState } from '../../common/chatService/chatService.js';
import { convertLegacyChatSessionTiming, IChatDetail, IChatService, IChatSessionTiming, ResponseModelState } from '../../common/chatService/chatService.js';
import { chatModelToChatDetail } from '../../common/chatService/chatServiceImpl.js';
import { ChatSessionStatus, IChatSessionItem, IChatSessionItemController, IChatSessionItemsDelta, IChatSessionsService, localChatSessionType } from '../../common/chatSessionsService.js';
import { IChatModel } from '../../common/model/chatModel.js';
import { ChatModel, IChatModel } from '../../common/model/chatModel.js';
import { getChatSessionType } from '../../common/model/chatUri.js';
import { getInProgressSessionDescription } from '../chatSessions/chatSessionDescription.js';
@@ -26,16 +27,14 @@ export class LocalAgentsSessionsController extends Disposable implements IChatSe
readonly chatSessionType = localChatSessionType;
private readonly _onDidChange = this._register(new Emitter<void>());
readonly onDidChange = this._onDidChange.event;
readonly _onDidChangeChatSessionItems = this._register(new Emitter<IChatSessionItemsDelta>());
readonly onDidChangeChatSessionItems = this._onDidChangeChatSessionItems.event;
private readonly _modelListeners = this._register(new DisposableResourceMap());
constructor(
@IChatService private readonly chatService: IChatService,
@IChatSessionsService private readonly chatSessionsService: IChatSessionsService,
@ILogService private readonly logService: ILogService,
) {
super();
@@ -44,44 +43,81 @@ export class LocalAgentsSessionsController extends Disposable implements IChatSe
this.registerListeners();
}
private _items: IChatSessionItem[] = [];
private _items = new ResourceMap<LocalChatSessionItem>();
get items(): readonly IChatSessionItem[] {
return this._items;
return Array.from(this._items.values());
}
async refresh(token: CancellationToken): Promise<void> {
this._items = await this.provideChatSessionItems(token);
const newItems = await this.provideChatSessionItems(token);
this._items.clear();
for (const item of newItems) {
this._items.set(item.resource, item);
}
}
private registerListeners(): void {
this._register(this.chatService.registerChatModelChangeListeners(Schemas.vscodeLocalChatSession, async sessionResource => {
if (getChatSessionType(sessionResource) !== this.chatSessionType) {
const tryAddModelListeners = async (model: IChatModel) => {
if (getChatSessionType(model.sessionResource) !== this.chatSessionType) {
return;
}
// TODO: This gets fired too often
await this.refresh(CancellationToken.None);
const item = this.getItem(sessionResource);
const onChange = () => {
this.tryUpdateLiveSessionItem(model);
};
if (item) {
this._onDidChangeChatSessionItems.fire({ addedOrUpdated: [item] });
}
}));
await this.refresh(CancellationToken.None);
onChange();
const requestChangeListener = model.lastRequestObs.map(last => last?.response && observableSignalFromEvent('chatSessions.modelRequestChangeListener', last.response.onDidChange));
const modelChangeListener = observableSignalFromEvent('chatSessions.modelChangeListener', model.onDidChange);
this._modelListeners.set(model.sessionResource, autorun(reader => {
requestChangeListener.read(reader)?.read(reader);
modelChangeListener.read(reader);
onChange();
}));
};
this._register(this.chatService.onDidCreateModel(model => tryAddModelListeners(model)));
for (const model of this.chatService.chatModels.get()) {
tryAddModelListeners(model);
}
this._register(this.chatService.onDidDisposeSession(e => {
const removedSessionResources = e.sessionResource.filter(resource => getChatSessionType(resource) === this.chatSessionType);
for (const sessionResource of e.sessionResources) {
this._modelListeners.deleteAndDispose(sessionResource);
}
const removedSessionResources = e.sessionResources.filter(resource => getChatSessionType(resource) === this.chatSessionType);
if (removedSessionResources.length) {
this._onDidChangeChatSessionItems.fire({ removed: removedSessionResources });
}
}));
}
private getItem(sessionResource: URI): IChatSessionItem | undefined {
return this._items.find(item => isEqual(item.resource, sessionResource));
private async tryUpdateLiveSessionItem(model: IChatModel): Promise<void> {
if (!(model instanceof ChatModel)) {
return;
}
const existing = this._items.get(model.sessionResource);
if (!existing) {
return;
}
const updated = new LocalChatSessionItem(await chatModelToChatDetail(model), model);
if (existing.isEqual(updated)) {
return;
}
this._items.set(existing.resource, updated);
this._onDidChangeChatSessionItems.fire({ addedOrUpdated: [updated] });
}
private async provideChatSessionItems(token: CancellationToken): Promise<IChatSessionItem[]> {
const sessions: IChatSessionItem[] = [];
private async provideChatSessionItems(token: CancellationToken): Promise<LocalChatSessionItem[]> {
const sessions: LocalChatSessionItem[] = [];
const sessionsByResource = new ResourceSet();
for (const sessionDetail of await this.chatService.getLiveSessionItems()) {
@@ -102,7 +138,7 @@ export class LocalAgentsSessionsController extends Disposable implements IChatSe
return sessions;
}
private async getHistoryItems(): Promise<IChatSessionItem[]> {
private async getHistoryItems(): Promise<LocalChatSessionItem[]> {
try {
const historyItems = await this.chatService.getHistorySessionItems();
@@ -112,72 +148,90 @@ export class LocalAgentsSessionsController extends Disposable implements IChatSe
}
}
private toChatSessionItem(chat: IChatDetail): IChatSessionItem | undefined {
private toChatSessionItem(chat: IChatDetail): LocalChatSessionItem | undefined {
const model = this.chatService.getSession(chat.sessionResource);
let description: string | undefined;
if (model) {
if (!model.hasRequests) {
return undefined; // ignore sessions without requests
}
description = getInProgressSessionDescription(model);
} else if (chat.isActive) {
// Sessions that are active but don't have a chat model are ultimately untitled with no requests
return undefined;
}
return {
resource: chat.sessionResource,
label: chat.title,
description,
status: model ? this.modelToStatus(model) : this.chatResponseStateToStatus(chat.lastResponseState),
iconPath: Codicon.chatSparkle,
timing: convertLegacyChatSessionTiming(chat.timing),
changes: chat.stats ? {
insertions: chat.stats.added,
deletions: chat.stats.removed,
files: chat.stats.fileCount,
} : undefined
};
}
private modelToStatus(model: IChatModel): ChatSessionStatus | undefined {
if (model.requestInProgress.get()) {
this.logService.trace(`[agent sessions] Session ${model.sessionResource.toString()} request is in progress.`);
return ChatSessionStatus.InProgress;
}
const lastRequest = model.getRequests().at(-1);
this.logService.trace(`[agent sessions] Session ${model.sessionResource.toString()} last request response: state ${lastRequest?.response?.state}, isComplete ${lastRequest?.response?.isComplete}, isCanceled ${lastRequest?.response?.isCanceled}, error: ${lastRequest?.response?.result?.errorDetails?.message}.`);
if (lastRequest?.response) {
if (lastRequest.response.state === ResponseModelState.NeedsInput) {
return ChatSessionStatus.NeedsInput;
} else if (lastRequest.response.isCanceled || lastRequest.response.result?.errorDetails?.code === 'canceled') {
return ChatSessionStatus.Completed;
} else if (lastRequest.response.result?.errorDetails) {
return ChatSessionStatus.Failed;
} else if (lastRequest.response.isComplete) {
return ChatSessionStatus.Completed;
} else {
return ChatSessionStatus.InProgress;
}
}
return undefined;
}
private chatResponseStateToStatus(state: ResponseModelState): ChatSessionStatus {
switch (state) {
case ResponseModelState.Cancelled:
case ResponseModelState.Complete:
return ChatSessionStatus.Completed;
case ResponseModelState.Failed:
return ChatSessionStatus.Failed;
case ResponseModelState.Pending:
return ChatSessionStatus.InProgress;
case ResponseModelState.NeedsInput:
return ChatSessionStatus.NeedsInput;
}
return new LocalChatSessionItem(chat, model);
}
}
class LocalChatSessionItem implements IChatSessionItem {
readonly resource: URI;
readonly iconPath = Codicon.chatSparkle;
readonly label: string;
readonly description: string | undefined;
readonly status: ChatSessionStatus | undefined;
readonly timing: IChatSessionTiming;
readonly changes: IChatSessionItem['changes'];
constructor(chatDetail: IChatDetail, model: IChatModel | undefined) {
this.resource = chatDetail.sessionResource;
this.label = chatDetail.title;
this.description = model ? getInProgressSessionDescription(model) : undefined;
this.status = (model && getSessionStatusForModel(model)) ?? chatResponseStateToSessionStatus(chatDetail.lastResponseState);
this.timing = convertLegacyChatSessionTiming(chatDetail.timing);
this.changes = chatDetail.stats ? {
insertions: chatDetail.stats.added,
deletions: chatDetail.stats.removed,
files: chatDetail.stats.fileCount,
} : undefined;
}
isEqual(other: LocalChatSessionItem): boolean {
return isEqual(this.resource, other.resource)
&& this.label === other.label
&& this.description === other.description
&& this.status === other.status
&& this.timing.created === other.timing.created
&& this.timing.lastRequestStarted === other.timing.lastRequestStarted
&& this.timing.lastRequestEnded === other.timing.lastRequestEnded
&& equals(this.changes, other.changes);
}
}
function getSessionStatusForModel(model: IChatModel): ChatSessionStatus | undefined {
if (model.requestInProgress.get()) {
return ChatSessionStatus.InProgress;
}
const lastRequest = model.getRequests().at(-1);
if (lastRequest?.response) {
if (lastRequest.response.state === ResponseModelState.NeedsInput) {
return ChatSessionStatus.NeedsInput;
} else if (lastRequest.response.isCanceled || lastRequest.response.result?.errorDetails?.code === 'canceled') {
return ChatSessionStatus.Completed;
} else if (lastRequest.response.result?.errorDetails) {
return ChatSessionStatus.Failed;
} else if (lastRequest.response.isComplete) {
return ChatSessionStatus.Completed;
} else {
return ChatSessionStatus.InProgress;
}
}
return undefined;
}
function chatResponseStateToSessionStatus(state: ResponseModelState): ChatSessionStatus {
switch (state) {
case ResponseModelState.Cancelled:
case ResponseModelState.Complete:
return ChatSessionStatus.Completed;
case ResponseModelState.Failed:
return ChatSessionStatus.Failed;
case ResponseModelState.Pending:
return ChatSessionStatus.InProgress;
case ResponseModelState.NeedsInput:
return ChatSessionStatus.NeedsInput;
}
}

View File

@@ -87,7 +87,7 @@ export class ChatEditingService extends Disposable implements IChatEditingServic
this._register(this._chatService.onDidDisposeSession((e) => {
if (e.reason === 'cleared') {
for (const resource of e.sessionResource) {
for (const resource of e.sessionResources) {
this.getEditingSession(resource)?.stop();
}
}

View File

@@ -1525,7 +1525,7 @@ export interface IChatService {
readonly onDidReceiveQuestionCarouselAnswer: Event<{ requestId: string; resolveId: string; answers: IChatQuestionAnswers | undefined }>;
notifyQuestionCarouselAnswer(requestId: string, resolveId: string, answers: IChatQuestionAnswers | undefined): void;
readonly onDidDisposeSession: Event<{ readonly sessionResource: readonly URI[]; readonly reason: 'cleared' }>;
readonly onDidDisposeSession: Event<{ readonly sessionResources: readonly URI[]; readonly reason: 'cleared' }>;
transferChatSession(transferredSessionResource: URI, toWorkspace: URI): Promise<void>;

View File

@@ -126,7 +126,7 @@ export class ChatService extends Disposable implements IChatService {
private readonly _onDidReceiveQuestionCarouselAnswer = this._register(new Emitter<{ requestId: string; resolveId: string; answers: IChatQuestionAnswers | undefined }>());
public readonly onDidReceiveQuestionCarouselAnswer = this._onDidReceiveQuestionCarouselAnswer.event;
private readonly _onDidDisposeSession = this._register(new Emitter<{ readonly sessionResource: URI[]; reason: 'cleared' }>());
private readonly _onDidDisposeSession = this._register(new Emitter<{ readonly sessionResources: URI[]; reason: 'cleared' }>());
public readonly onDidDisposeSession = this._onDidDisposeSession.event;
private readonly _sessionFollowupCancelTokens = this._register(new DisposableResourceMap<CancellationTokenSource>());
@@ -195,7 +195,7 @@ export class ChatService extends Disposable implements IChatService {
this._register(this._sessionModels.onDidDisposeModel(model => {
clearChatMarks(model.sessionResource);
this.chatDebugService.endSession(model.sessionResource);
this._onDidDisposeSession.fire({ sessionResource: [model.sessionResource], reason: 'cleared' });
this._onDidDisposeSession.fire({ sessionResources: [model.sessionResource], reason: 'cleared' });
}));
this._chatServiceTelemetry = this.instantiationService.createInstance(ChatServiceTelemetry);
@@ -400,18 +400,7 @@ export class ChatService extends Disposable implements IChatService {
async getLiveSessionItems(): Promise<IChatDetail[]> {
return await Promise.all(Array.from(this._sessionModels.values())
.filter(session => this.shouldBeInHistory(session))
.map(async (session): Promise<IChatDetail> => {
const title = session.title || localize('newChat', "New Chat");
return {
sessionResource: session.sessionResource,
title,
lastMessageDate: session.lastMessageDate,
timing: session.timing,
isActive: true,
stats: await awaitStatsForSession(session),
lastResponseState: session.lastRequest?.response?.state ?? ResponseModelState.Pending,
};
}));
.map(chatModelToChatDetail));
}
/**
@@ -452,7 +441,7 @@ export class ChatService extends Disposable implements IChatService {
async removeHistoryEntry(sessionResource: URI): Promise<void> {
await this._chatSessionStore.deleteSession(this.toLocalSessionId(sessionResource));
this._onDidDisposeSession.fire({ sessionResource: [sessionResource], reason: 'cleared' });
this._onDidDisposeSession.fire({ sessionResources: [sessionResource], reason: 'cleared' });
}
async clearAllHistoryEntries(): Promise<void> {
@@ -1881,3 +1870,16 @@ export class ChatService extends Disposable implements IChatService {
return disposableStore;
}
}
export async function chatModelToChatDetail(model: ChatModel): Promise<IChatDetail> {
const title = model.title || localize('newChat', "New Chat");
return {
sessionResource: model.sessionResource,
title,
lastMessageDate: model.lastMessageDate,
timing: model.timing,
isActive: true,
stats: await awaitStatsForSession(model),
lastResponseState: model.lastRequest?.response?.state ?? ResponseModelState.Pending,
};
}

View File

@@ -61,7 +61,7 @@ export class ChatResponseResourceFileSystemProvider extends Disposable implement
) {
super();
this._register(this.chatService.onDidDisposeSession(e => {
for (const sessionResource of e.sessionResource) {
for (const sessionResource of e.sessionResources) {
const uris = this._sessionAssociations.get(sessionResource);
if (uris) {
for (const uri of uris) {

View File

@@ -426,7 +426,7 @@ suite('ChatService', () => {
let disposed = false;
testDisposables.add(testService.onDidDisposeSession(e => {
for (const resource of e.sessionResource) {
for (const resource of e.sessionResources) {
if (resource.toString() === model.sessionResource.toString()) {
disposed = true;
}

View File

@@ -27,11 +27,11 @@ export class MockChatService implements IChatService {
private liveSessionItems: IChatDetail[] = [];
private historySessionItems: IChatDetail[] = [];
private readonly _onDidDisposeSession = new Emitter<{ sessionResource: URI[]; reason: 'cleared' }>();
private readonly _onDidDisposeSession = new Emitter<{ sessionResources: URI[]; reason: 'cleared' }>();
readonly onDidDisposeSession = this._onDidDisposeSession.event;
fireDidDisposeSession(sessionResource: URI[]): void {
this._onDidDisposeSession.fire({ sessionResource, reason: 'cleared' });
fireDidDisposeSession(sessionResources: URI[]): void {
this._onDidDisposeSession.fire({ sessionResources, reason: 'cleared' });
}
setSaveModelsEnabled(enabled: boolean): void {

View File

@@ -81,7 +81,7 @@ export class TerminalChatService extends Disposable implements ITerminalChatServ
// Clear session auto-approve rules when chat sessions end
this._register(this._chatService.onDidDisposeSession(e => {
for (const resource of e.sessionResource) {
for (const resource of e.sessionResources) {
this._sessionAutoApproveRules.delete(resource);
this._sessionAutoApprovalEnabled.delete(resource);
}
@@ -105,7 +105,7 @@ export class TerminalChatService extends Disposable implements ITerminalChatServ
}));
this._register(this._chatService.onDidDisposeSession(e => {
for (const resource of e.sessionResource) {
for (const resource of e.sessionResources) {
if (LocalChatSessionUri.parseLocalSessionId(resource) === terminalToolSessionId) {
this._terminalInstancesByToolSessionId.delete(terminalToolSessionId);
this._toolSessionIdByTerminalInstance.delete(instance);

View File

@@ -512,7 +512,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {
// Listen for chat session disposal to clean up associated terminals
this._register(this._chatService.onDidDisposeSession(e => {
for (const resource of e.sessionResource) {
for (const resource of e.sessionResources) {
this._cleanupSessionTerminals(resource);
}
}));

View File

@@ -75,7 +75,7 @@ suite('RunInTerminalTool', () => {
let storageService: IStorageService;
let workspaceContextService: TestContextService;
let terminalServiceDisposeEmitter: Emitter<ITerminalInstance>;
let chatServiceDisposeEmitter: Emitter<{ sessionResource: URI[]; reason: 'cleared' }>;
let chatServiceDisposeEmitter: Emitter<{ sessionResources: URI[]; reason: 'cleared' }>;
let chatSessionArchivedEmitter: Emitter<IAgentSession>;
let sandboxEnabled: boolean;
let terminalSandboxService: ITerminalSandboxService;
@@ -95,7 +95,7 @@ suite('RunInTerminalTool', () => {
setConfig(TerminalChatAgentToolsSettingId.BlockDetectedFileWrites, 'outsideWorkspace');
sandboxEnabled = false;
terminalServiceDisposeEmitter = new Emitter<ITerminalInstance>();
chatServiceDisposeEmitter = new Emitter<{ sessionResource: URI[]; reason: 'cleared' }>();
chatServiceDisposeEmitter = new Emitter<{ sessionResources: URI[]; reason: 'cleared' }>();
chatSessionArchivedEmitter = new Emitter<IAgentSession>();
instantiationService = workbenchInstantiationService({
@@ -1521,7 +1521,7 @@ suite('RunInTerminalTool', () => {
});
runInTerminalTool.sessionTerminalInstances.set(sessionResource, new Set([mockTerminal1, mockTerminal2]));
chatServiceDisposeEmitter.fire({ sessionResource: [sessionResource], reason: 'cleared' });
chatServiceDisposeEmitter.fire({ sessionResources: [sessionResource], reason: 'cleared' });
strictEqual(terminal1Disposed, true, 'Terminal 1 should have been disposed');
strictEqual(terminal2Disposed, true, 'Terminal 2 should have been disposed');
@@ -1543,7 +1543,7 @@ suite('RunInTerminalTool', () => {
ok(runInTerminalTool.sessionTerminalAssociations.has(sessionResource), 'Terminal association should exist before disposal');
chatServiceDisposeEmitter.fire({ sessionResource: [sessionResource], reason: 'cleared' });
chatServiceDisposeEmitter.fire({ sessionResources: [sessionResource], reason: 'cleared' });
strictEqual(terminalDisposed, true, 'Terminal should have been disposed');
ok(!runInTerminalTool.sessionTerminalAssociations.has(sessionResource), 'Terminal association should be removed after disposal');
@@ -1574,7 +1574,7 @@ suite('RunInTerminalTool', () => {
ok(runInTerminalTool.sessionTerminalAssociations.has(sessionResource1), 'Session 1 terminal association should exist');
ok(runInTerminalTool.sessionTerminalAssociations.has(sessionResource2), 'Session 2 terminal association should exist');
chatServiceDisposeEmitter.fire({ sessionResource: [sessionResource1], reason: 'cleared' });
chatServiceDisposeEmitter.fire({ sessionResources: [sessionResource1], reason: 'cleared' });
strictEqual(terminal1Disposed, true, 'Terminal 1 should have been disposed');
strictEqual(terminal2Disposed, false, 'Terminal 2 should NOT have been disposed');
@@ -1584,7 +1584,7 @@ suite('RunInTerminalTool', () => {
test('should handle disposal of non-existent session gracefully', () => {
strictEqual(runInTerminalTool.sessionTerminalAssociations.size, 0, 'No associations should exist initially');
chatServiceDisposeEmitter.fire({ sessionResource: [LocalChatSessionUri.forSession('non-existent-session')], reason: 'cleared' });
chatServiceDisposeEmitter.fire({ sessionResources: [LocalChatSessionUri.forSession('non-existent-session')], reason: 'cleared' });
strictEqual(runInTerminalTool.sessionTerminalAssociations.size, 0, 'No associations should exist after handling non-existent session');
});
});
@@ -1921,7 +1921,7 @@ suite('ChatAgentToolsContribution - tool registration refresh', () => {
store.add(fileService.registerProvider(Schemas.file, fileSystemProvider));
const terminalServiceDisposeEmitter = store.add(new Emitter<ITerminalInstance>());
const chatServiceDisposeEmitter = store.add(new Emitter<{ sessionResource: URI[]; reason: 'cleared' }>());
const chatServiceDisposeEmitter = store.add(new Emitter<{ sessionResources: URI[]; reason: 'cleared' }>());
const chatSessionArchivedEmitter = store.add(new Emitter<IAgentSession>());
instantiationService = workbenchInstantiationService({