Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);
}
}));
}

/**
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -4143,6 +4143,60 @@ suite('LocalAgentHostSessionsProvider', () => {
});
});

test('session adapters observe capabilities only after receiving a chat catalog', () => {
let listenerCount = 0;
let agentCapabilities = new Map<string, AgentInfo['capabilities']>([['copilotcli', {}]]);
const capabilitiesChanged = disposables.add(new Emitter<void>({
onDidAddListener: () => listenerCount++,
onWillRemoveListener: () => listenerCount--,
}));
const capabilitiesObs = observableFromEvent(disposables, capabilitiesChanged.event, () => agentCapabilities);
const instantiationService = disposables.add(new TestInstantiationService());
instantiationService.stub(IGitHubService, new class extends mock<IGitHubService>() { });
instantiationService.stub(ISessionsService, new class extends mock<ISessionsService>() {
override readonly activeSession = constObservable<IActiveSession | undefined>(undefined);
});
instantiationService.stub(IPullRequestIconCache, new class extends mock<IPullRequestIconCache>() { });
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<void>({ useFakeTimers: true }, async () => {
const provider = createProvider(disposables, agentHost);
const session = setupMultiChatSession(provider, 'multi-fork');
Expand Down
Loading