diff --git a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md index 425fa2ff313b3e..70be9143281f44 100644 --- a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md +++ b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md @@ -96,7 +96,7 @@ A single agent host session uses several distinct identifiers: ## Architecture -- **`AgentHostSessionAdapter`** (`baseAgentHostSessionsProvider.ts`) is the `ISession` implementation. It wraps an `IAgentSessionMetadata` from the backend and exposes the observable session surface (`status`, `title`, `workspace`, `mainChat`, `mode`, …). The base provider keeps a `_sessionCache` of adapters keyed by `rawId`. Adapter capabilities derive from a shared provider-to-capabilities lookup, so one root-state event listener and one catalog scan serve the entire cache; root-state errors and disconnects clear the lookup so stale capabilities are not retained. +- **`AgentHostSessionAdapter`** (`baseAgentHostSessionsProvider.ts`) is the `ISession` implementation. It wraps an `IAgentSessionMetadata` from the backend and exposes the observable session surface (`status`, `title`, `workspace`, `mainChat`, `mode`, …). The base provider keeps a `_sessionCache` of adapters keyed by `rawId`. Adapter capabilities derive from a shared provider-to-capabilities lookup, so one root-state event listener and one catalog scan serve the entire cache; an adapter observes that lookup only after receiving a chat catalog that may need reapplication. Root-state errors and disconnects clear the lookup so stale capabilities are not retained. - **`NewSession`** is a disposable draft (pre-creation) session. Several can be in flight simultaneously; the management layer tears down superseded drafts via `deleteNewSession`. A draft eagerly creates its backend session once authentication settles, then **graduates** into a committed `AgentHostSessionAdapter` on first send. - The base provider is abstract; concrete providers supply: `connection`, `authenticationPending`, `resourceSchemeForProvider`, `_formatSessionTypeLabel`, `_adapterOptions` (workspace builder), `resolveWorkspace`, and optionally `_diffUriMapper`. diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index 480b0381952745..f1156ac5de4ea3 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -648,9 +648,10 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { /** * The last {@link SessionState} applied to the chat catalog, retained so the * catalog can be re-reconciled when {@link capabilities} change after the - * fact (see the capability autorun in the constructor). + * fact. */ private _lastCatalogState: SessionState | undefined; + private readonly _chatCatalogCapabilitiesObserver = this._register(new MutableDisposable()); private readonly _rawId: string; private readonly _resourceScheme: string; @@ -905,19 +906,6 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { supportsDelete: true, }; }); - - // Re-apply the chat catalog when advertised capabilities change (e.g. the - // agent host's root state arrives after the session's first state update). - // Without this, a multi-chat session whose state was processed while - // `supportsMultipleChats` was still `false` would stay collapsed to - // `[defaultChat]` until the next session-state update. - this._register(autorun(reader => { - this.capabilities.read(reader); - const state = this._lastCatalogState; - if (state) { - this._applyChatCatalog(state); - } - })); } /** @@ -938,7 +926,17 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { */ applyChatCatalog(state: SessionState): void { this._lastCatalogState = state; - this._applyChatCatalog(state); + if (this._chatCatalogCapabilitiesObserver.value) { + this._applyChatCatalog(state); + } else { + this._chatCatalogCapabilitiesObserver.value = autorun(reader => { + this.capabilities.read(reader); + const currentState = this._lastCatalogState; + if (currentState) { + this._applyChatCatalog(currentState); + } + }); + } } private _applyChatCatalog(state: SessionState): void { diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index ac95e75117f5f3..575d8fdf8ccf2d 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -9,7 +9,7 @@ import { DeferredPromise, raceTimeout, timeout } from '../../../../../../base/co import { Codicon } from '../../../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { DisposableMap, DisposableStore, ImmortalReference, toDisposable, type IReference } from '../../../../../../base/common/lifecycle.js'; -import { autorun, constObservable, ISettableObservable, observableValue, type IObservable } from '../../../../../../base/common/observable.js'; +import { autorun, constObservable, ISettableObservable, observableFromEvent, observableValue, type IObservable } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; import { isEqual } from '../../../../../../base/common/resources.js'; import { mock } from '../../../../../../base/test/common/mock.js'; @@ -45,7 +45,7 @@ import { IActiveSession } from '../../../../../services/sessions/common/sessions import { ISessionsService } from '../../../../../services/sessions/browser/sessionsService.js'; import { IAgentCustomizationScope, IAgentHostActiveClientService } from '../../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.js'; import { LocalAgentHostSessionsProvider } from '../../browser/localAgentHostSessionsProvider.js'; -import { AgentHostSessionAdapter } from '../../browser/baseAgentHostSessionsProvider.js'; +import { AgentHostSessionAdapter, type IAgentHostAdapterOptions } from '../../browser/baseAgentHostSessionsProvider.js'; import { IAutomationStorageService } from '../../../../automations/common/automationStorageService.js'; import { TestAutomationStorageService } from '../../../../automations/test/browser/automationTestUtils.js'; import { ILabelService } from '../../../../../../platform/label/common/label.js'; @@ -4143,6 +4143,60 @@ suite('LocalAgentHostSessionsProvider', () => { }); }); + test('session adapters observe capabilities only after receiving a chat catalog', () => { + let listenerCount = 0; + let agentCapabilities = new Map([['copilotcli', {}]]); + const capabilitiesChanged = disposables.add(new Emitter({ + onDidAddListener: () => listenerCount++, + onWillRemoveListener: () => listenerCount--, + })); + const capabilitiesObs = observableFromEvent(disposables, capabilitiesChanged.event, () => agentCapabilities); + const instantiationService = disposables.add(new TestInstantiationService()); + instantiationService.stub(IGitHubService, new class extends mock() { }); + instantiationService.stub(ISessionsService, new class extends mock() { + override readonly activeSession = constObservable(undefined); + }); + instantiationService.stub(IPullRequestIconCache, new class extends mock() { }); + const options: IAgentHostAdapterOptions = { + icon: Codicon.copilot, + loading: constObservable(false), + buildWorkspace: () => undefined, + instantiationService, + getConnection: () => undefined, + agentCapabilities: capabilitiesObs, + }; + const adapters = Array.from({ length: 200 }, (_, index) => disposables.add(instantiationService.createInstance( + AgentHostSessionAdapter, + createSession(`lazy-capabilities-${index}`), + 'local-agent-host', + 'agent-host-copilotcli', + 'copilotcli', + options, + ))); + const sessionUri = AgentSession.uri('copilotcli', 'lazy-capabilities-0').toString(); + const defaultChat = buildDefaultChatUri(sessionUri); + const peerChat = buildChatUri(sessionUri, 'peer-1'); + + const listenerCountBeforeCatalog = listenerCount; + adapters[0].applyChatCatalog(makeState([ + makeChatSummary(defaultChat, ''), + makeChatSummary(peerChat, 'Peer'), + ], { defaultChat })); + const listenerCountAfterCatalog = listenerCount; + agentCapabilities = new Map([['copilotcli', { multipleChats: { fork: true } }]]); + capabilitiesChanged.fire(); + + assert.deepStrictEqual({ + listenerCountBeforeCatalog, + listenerCountAfterCatalog, + chatFragmentsAfterHydration: adapters[0].chats.get().map(chat => chat.resource.fragment), + }, { + listenerCountBeforeCatalog: 0, + listenerCountAfterCatalog: 1, + chatFragmentsAfterHydration: ['', 'peer-1'], + }); + }); + test('forkChat forwards the source chat and turn to the host and surfaces a new peer chat', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const provider = createProvider(disposables, agentHost); const session = setupMultiChatSession(provider, 'multi-fork');