Path: blob/main/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts
13401 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { raceTimeout } from '../../../../base/common/async.js';6import { CancellationToken } from '../../../../base/common/cancellation.js';7import { Emitter, Event } from '../../../../base/common/event.js';8import { IMarkdownString, MarkdownString } from '../../../../base/common/htmlContent.js';9import { Disposable, DisposableMap, DisposableStore } from '../../../../base/common/lifecycle.js';10import { equals } from '../../../../base/common/objects.js';11import { constObservable, derived, IObservable, ISettableObservable, observableValue, transaction } from '../../../../base/common/observable.js';12import { ThemeIcon } from '../../../../base/common/themables.js';13import { URI } from '../../../../base/common/uri.js';14import { generateUuid } from '../../../../base/common/uuid.js';15import { localize } from '../../../../nls.js';16import { AgentSession, IAgentConnection, IAgentSessionMetadata } from '../../../../platform/agentHost/common/agentService.js';17import { ResolveSessionConfigResult } from '../../../../platform/agentHost/common/state/protocol/commands.js';18import { NotificationType } from '../../../../platform/agentHost/common/state/protocol/notifications.js';19import { FileEdit, ModelSelection, RootConfigState, RootState, SessionState, SessionSummary, SessionStatus as ProtocolSessionStatus } from '../../../../platform/agentHost/common/state/protocol/state.js';20import { ActionType, isSessionAction } from '../../../../platform/agentHost/common/state/sessionActions.js';21import { readSessionGitState, StateComponents, type ISessionGitState } from '../../../../platform/agentHost/common/state/sessionState.js';22import { ChatViewPaneTarget, IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js';23import { IChatSendRequestOptions, IChatService } from '../../../../workbench/contrib/chat/common/chatService/chatService.js';24import { IChatSessionFileChange, IChatSessionFileChange2, IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js';25import { ChatAgentLocation, ChatModeKind } from '../../../../workbench/contrib/chat/common/constants.js';26import { ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js';27import { diffsEqual, diffsToChanges, mapProtocolStatus } from './agentHostDiffs.js';28import { buildMutableConfigSchema, IAgentHostSessionsProvider, resolvedConfigsEqual } from '../../../common/agentHostSessionsProvider.js';29import { agentHostSessionWorkspaceKey } from '../../../common/agentHostSessionWorkspace.js';30import { isSessionConfigComplete } from '../../../common/sessionConfig.js';31import { IChat, IGitHubInfo, ISession, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction, SessionStatus, toSessionId } from '../../../services/sessions/common/session.js';32import { ISendRequestOptions, ISessionChangeEvent } from '../../../services/sessions/common/sessionsProvider.js';3334// ============================================================================35// AgentHostSessionAdapter — shared adapter for local and remote sessions36// ============================================================================3738/**39* Variation points the host provider supplies when building an adapter.40* Differences between local and remote sessions (icon, description text,41* workspace builder, optional URI mapping) flow through this options bag so42* the adapter itself stays a single concrete class.43*/44export interface IAgentHostAdapterOptions {45readonly icon: ThemeIcon;46readonly description: IMarkdownString | undefined;47/** Loading observable wired to the provider's authentication-pending state. */48readonly loading: IObservable<boolean>;49/** Builds the session workspace from session metadata; provider-specific (icon, providerLabel, requiresWorkspaceTrust). */50readonly buildWorkspace: (project: IAgentSessionMetadata['project'], workingDirectory: URI | undefined, gitState: ISessionGitState | undefined) => ISessionWorkspace | undefined;51/** Optional URI mapping for diff entries (remote uses `toAgentHostUri`; local uses identity). */52readonly mapDiffUri?: (uri: URI) => URI;53}5455/**56* Adapts an {@link IAgentSessionMetadata} into an {@link ISession} for the57* sessions UI. A single concrete class for both local and remote agent58* hosts — variation flows through {@link IAgentHostAdapterOptions}.59*/60export class AgentHostSessionAdapter implements ISession {6162readonly sessionId: string;63readonly resource: URI;64readonly providerId: string;65readonly sessionType: string;66readonly icon: ThemeIcon;67readonly createdAt: Date;68readonly workspace: ISettableObservable<ISessionWorkspace | undefined>;69readonly title: ISettableObservable<string>;70readonly updatedAt: ISettableObservable<Date>;71readonly status: ISettableObservable<SessionStatus>;72readonly changes = observableValue<readonly (IChatSessionFileChange | IChatSessionFileChange2)[]>('changes', []);73readonly modelId: ISettableObservable<string | undefined>;74modelSelection: ModelSelection | undefined;75readonly mode = observableValue<{ readonly id: string; readonly kind: string } | undefined>('mode', undefined);76readonly loading: IObservable<boolean>;77readonly isArchived = observableValue('isArchived', false);78readonly isRead = observableValue('isRead', true);79readonly description: IObservable<IMarkdownString | undefined>;80readonly lastTurnEnd: ISettableObservable<Date | undefined>;81readonly gitHubInfo = observableValue<IGitHubInfo | undefined>('gitHubInfo', undefined);8283readonly mainChat: IChat;84readonly chats: IObservable<readonly IChat[]>;85readonly capabilities = { supportsMultipleChats: false };86readonly deduplicationKey: string;8788readonly agentProvider: string;8990// Retained so we can rebuild `workspace` when only `_meta` changes via91// a `SessionMetaChanged` action dispatched on session open (without a full92// list refresh). See `_applySessionMetaFromState` / `setMeta`.93private _project: IAgentSessionMetadata['project'];94private _workingDirectory: URI | undefined;95private _meta: IAgentSessionMetadata['_meta'];96private _activity: ISettableObservable<string | undefined>;9798constructor(99metadata: IAgentSessionMetadata,100providerId: string,101resourceScheme: string,102logicalSessionType: string,103private readonly _options: IAgentHostAdapterOptions,104) {105const rawId = AgentSession.id(metadata.session);106const agentProvider = AgentSession.provider(metadata.session);107if (!agentProvider) {108throw new Error(`Agent session URI has no provider scheme: ${metadata.session.toString()}`);109}110this.agentProvider = agentProvider;111this.deduplicationKey = metadata.session.toString();112this.resource = URI.from({ scheme: resourceScheme, path: `/${rawId}` });113this.sessionId = toSessionId(providerId, this.resource);114this.providerId = providerId;115this.sessionType = logicalSessionType;116this.icon = _options.icon;117this.createdAt = new Date(metadata.startTime);118this.title = observableValue('title', metadata.summary || `Session ${rawId.substring(0, 8)}`);119this.updatedAt = observableValue('updatedAt', new Date(metadata.modifiedTime));120this.modelSelection = metadata.model;121this.status = observableValue<SessionStatus>('status', metadata.status !== undefined ? mapProtocolStatus(metadata.status) : SessionStatus.Completed);122this.modelId = observableValue<string | undefined>('modelId', metadata.model ? `${resourceScheme}:${metadata.model.id}` : undefined);123this.lastTurnEnd = observableValue('lastTurnEnd', metadata.modifiedTime ? new Date(metadata.modifiedTime) : undefined);124this._activity = observableValue('activity', metadata.activity);125this._project = metadata.project;126this._workingDirectory = metadata.workingDirectory;127this._meta = metadata._meta;128const initialGitState = readSessionGitState(this._meta);129const initialWorkspace = _options.buildWorkspace(this._project, this._workingDirectory, initialGitState);130this.workspace = observableValue('workspace', initialWorkspace);131this.loading = _options.loading;132this.description = derived(reader => {133const status = this.status.read(reader);134if (status === SessionStatus.InProgress || status === SessionStatus.NeedsInput) {135const activity = this._activity.read(reader);136if (activity) {137return new MarkdownString().appendText(activity);138}139}140141return this._options.description;142});143144if (metadata.isRead === false) {145this.isRead.set(false, undefined);146}147if (metadata.isArchived) {148this.isArchived.set(true, undefined);149}150if (metadata.diffs && metadata.diffs.length > 0) {151this.changes.set(diffsToChanges(metadata.diffs, _options.mapDiffUri), undefined);152}153154this.mainChat = {155resource: this.resource,156createdAt: this.createdAt,157title: this.title,158updatedAt: this.updatedAt,159status: this.status,160changes: this.changes,161modelId: this.modelId,162mode: this.mode,163isArchived: this.isArchived,164isRead: this.isRead,165description: this.description,166lastTurnEnd: this.lastTurnEnd,167};168this.chats = constObservable([this.mainChat]);169}170171/**172* Update fields from a refreshed metadata snapshot. Returns `true` iff173* any user-visible field changed.174*/175update(metadata: IAgentSessionMetadata): boolean {176let didChange = false;177178transaction(tx => {179const summary = metadata.summary;180if (summary !== undefined && summary !== this.title.get()) {181this.title.set(summary, tx);182didChange = true;183}184185if (metadata.status !== undefined) {186const uiStatus = mapProtocolStatus(metadata.status);187if (uiStatus !== this.status.get()) {188this.status.set(uiStatus, tx);189didChange = true;190}191}192193const modifiedTime = metadata.modifiedTime;194if (this.updatedAt.get().getTime() !== modifiedTime) {195this.updatedAt.set(new Date(modifiedTime), tx);196didChange = true;197}198199const currentLastTurnEndTime = this.lastTurnEnd.get()?.getTime();200const nextLastTurnEndTime = modifiedTime ? modifiedTime : undefined;201if (currentLastTurnEndTime !== nextLastTurnEndTime) {202this.lastTurnEnd.set(nextLastTurnEndTime !== undefined ? new Date(nextLastTurnEndTime) : undefined, tx);203didChange = true;204}205206this._project = metadata.project;207this._workingDirectory = metadata.workingDirectory;208// Only update `_meta` when the source actually provides one. `update()`209// is fed from SessionSummary (via `listSessions`/`sessionAdded` paths)210// which has no `_meta` field, so an undefined value here means "not211// included" rather than "cleared". `_meta` (e.g. git state) flows in212// exclusively via `setMeta` from `SessionState` subscription updates.213if (metadata._meta !== undefined) {214this._meta = metadata._meta;215}216const workspace = this._options.buildWorkspace(this._project, this._workingDirectory, readSessionGitState(this._meta));217if (agentHostSessionWorkspaceKey(workspace) !== agentHostSessionWorkspaceKey(this.workspace.get())) {218this.workspace.set(workspace, tx);219didChange = true;220}221222if (metadata.isRead !== undefined && metadata.isRead !== this.isRead.get()) {223this.isRead.set(metadata.isRead, tx);224didChange = true;225}226227if (metadata.isArchived !== undefined && metadata.isArchived !== this.isArchived.get()) {228this.isArchived.set(metadata.isArchived, tx);229didChange = true;230}231232this.modelSelection = metadata.model;233const modelId = metadata.model ? `${this.resource.scheme}:${metadata.model.id}` : undefined;234if (modelId !== this.modelId.get()) {235this.modelId.set(modelId, tx);236didChange = true;237}238239if (metadata.diffs && !diffsEqual(this.changes.get(), metadata.diffs, this._options.mapDiffUri)) {240this.changes.set(diffsToChanges(metadata.diffs, this._options.mapDiffUri), tx);241didChange = true;242}243244if (this._activity.get() !== metadata.activity) {245this._activity.set(metadata.activity, tx);246didChange = true;247}248});249250return didChange;251}252253/**254* Sets the activity text from a `SessionSummaryChanged` notification.255* Returns `true` iff the activity observable changed.256*/257setActivity(activity: string | undefined): boolean {258if (this._activity.get() !== activity) {259this._activity.set(activity, undefined);260return true;261}262263return false;264}265266/**267* Apply a `SessionState._meta` delta (fed from `_applySessionMetaFromState`)268* and rebuild the workspace if the git state changed. Returns `true` iff269* the workspace actually changed.270*/271setMeta(meta: IAgentSessionMetadata['_meta']): boolean {272this._meta = meta;273const gitState = readSessionGitState(this._meta);274const workspace = this._options.buildWorkspace(this._project, this._workingDirectory, gitState);275if (agentHostSessionWorkspaceKey(workspace) === agentHostSessionWorkspaceKey(this.workspace.get())) {276return false;277}278this.workspace.set(workspace, undefined);279return true;280}281}282283// ============================================================================284// BaseAgentHostSessionsProvider — shared base for local and remote providers285// ============================================================================286287/**288* Shared base class for the local and remote agent host sessions providers.289*290* Owns the structures and flows that are identical between the two:291* the session cache, the new-session/running-session config picker state,292* the lazy session-state subscriptions, the AHP notification/action293* handlers, and every connection-routed method (set/get/archive/delete/294* rename/setModel/sendAndCreateChat).295*296* Subclasses supply the genuine variation points: the connection297* accessor, the authentication-pending observable, an adapter factory,298* URI-scheme mapping for session metadata, the agent-provider lookup, and299* the browse UI.300*/301export abstract class BaseAgentHostSessionsProvider extends Disposable implements IAgentHostSessionsProvider {302303abstract readonly id: string;304abstract readonly label: string;305abstract readonly icon: ThemeIcon;306abstract readonly browseActions: readonly ISessionWorkspaceBrowseAction[];307308get sessionTypes(): readonly ISessionType[] { return this._sessionTypes; }309protected _sessionTypes: ISessionType[] = [];310311protected readonly _onDidChangeSessionTypes = this._register(new Emitter<void>());312readonly onDidChangeSessionTypes: Event<void> = this._onDidChangeSessionTypes.event;313314protected readonly _onDidChangeSessions = this._register(new Emitter<ISessionChangeEvent>());315readonly onDidChangeSessions: Event<ISessionChangeEvent> = this._onDidChangeSessions.event;316317protected readonly _onDidReplaceSession = this._register(new Emitter<{ readonly from: ISession; readonly to: ISession }>());318readonly onDidReplaceSession: Event<{ readonly from: ISession; readonly to: ISession }> = this._onDidReplaceSession.event;319320protected readonly _onDidChangeSessionConfig = this._register(new Emitter<string>());321readonly onDidChangeSessionConfig = this._onDidChangeSessionConfig.event;322323protected readonly _onDidChangeRootConfig = this._register(new Emitter<void>());324readonly onDidChangeRootConfig = this._onDidChangeRootConfig.event;325326/** Last-known root config state (schema + values), seeded from `RootState.config`. */327protected _rootConfig: RootConfigState | undefined;328329/** Cache of adapted sessions, keyed by raw session ID. */330protected readonly _sessionCache = new Map<string, AgentHostSessionAdapter>();331332/**333* Temporary session that has been sent (first turn dispatched) but not yet334* committed to a real backend session. Shown in the session list until the335* server creates the backend session, at which point it is replaced via336* {@link _onDidReplaceSession}.337*/338protected _pendingSession: ISession | undefined;339340protected _currentNewSession: ISession | undefined;341protected _currentNewSessionStatus: ISettableObservable<SessionStatus> | undefined;342protected _currentNewSessionModelId: ISettableObservable<string | undefined> | undefined;343protected _currentNewSessionLoading: ISettableObservable<boolean> | undefined;344protected _selectedModelId: string | undefined;345346protected readonly _newSessionWorkspaces = new Map<string, URI>();347protected readonly _newSessionConfigs = new Map<string, ResolveSessionConfigResult>();348protected readonly _newSessionAgentProviders = new Map<string, string>();349protected readonly _newSessionConfigRequests = new Map<string, number>();350351/** Full resolved config (schema + values) for running sessions, keyed by session ID. */352protected readonly _runningSessionConfigs = new Map<string, ResolveSessionConfigResult>();353354/**355* Lazy session-state subscriptions used to seed {@link _runningSessionConfigs}356* for sessions that already exist on the agent host (e.g. created in a prior357* window). The underlying wire subscription is reference-counted by358* {@link IAgentConnection.getSubscription}, so when the session handler is359* also subscribed (i.e. chat content is loaded) no extra wire subscribe is360* issued. Keyed by session ID.361*/362protected readonly _sessionStateSubscriptions = this._register(new DisposableMap<string, DisposableStore>());363364protected _cacheInitialized = false;365366constructor(367@IChatSessionsService protected readonly _chatSessionsService: IChatSessionsService,368@IChatService protected readonly _chatService: IChatService,369@IChatWidgetService protected readonly _chatWidgetService: IChatWidgetService,370@ILanguageModelsService protected readonly _languageModelsService: ILanguageModelsService,371) {372super();373}374375// -- Subclass hooks -------------------------------------------------------376377/** Current connection (always present for local; may be undefined while disconnected for remote). */378protected abstract get connection(): IAgentConnection | undefined;379380/** Provider-level authentication-pending observable used to derive `loading` for sessions. */381protected abstract get authenticationPending(): IObservable<boolean>;382383/**384* Subclass-specific portion of the adapter options. Base fills in385* the bits that are uniform across hosts (`icon`, `loading`,386* `mapDiffUri`) from the corresponding hooks.387*/388protected abstract _adapterOptions(): Pick<IAgentHostAdapterOptions, 'description' | 'buildWorkspace'>;389390/** Build an adapter for the given metadata. */391protected createAdapter(meta: IAgentSessionMetadata): AgentHostSessionAdapter {392const provider = AgentSession.provider(meta.session);393if (!provider) {394throw new Error(`Agent session URI has no provider scheme: ${meta.session.toString()}`);395}396return new AgentHostSessionAdapter(meta, this.id, this.resourceSchemeForProvider(provider), provider, {397icon: this.icon,398loading: this.authenticationPending,399mapDiffUri: this._diffUriMapper(),400...this._adapterOptions(),401});402}403404/**405* Computes the URI resource scheme used to route session URIs to this406* provider's content provider for a given agent provider name. Local407* uses `agent-host-${provider}`; remote uses a per-connection scheme.408*409* The resource scheme is host-specific and exists purely for content410* provider routing. The logical {@link ISession.sessionType} is the411* agent provider name itself, so the same agent (e.g. `copilotcli`)412* appears under one shared session type across hosts.413*/414protected abstract resourceSchemeForProvider(provider: string): string;415416/** Format the human-readable label for a session type entry (e.g. `Copilot [Local]`). */417protected abstract _formatSessionTypeLabel(agentLabel: string): string;418419/**420* Reconcile {@link _sessionTypes} against the agents advertised by the421* host's root state, firing {@link onDidChangeSessionTypes} only if the422* id/label set actually changed.423*/424protected _syncSessionTypesFromRootState(rootState: RootState): void {425const next = rootState.agents.map((agent): ISessionType => ({426id: agent.provider,427label: this._formatSessionTypeLabel(agent.displayName?.trim() || agent.provider),428icon: this.icon,429}));430431const prev = this._sessionTypes;432if (prev.length === next.length && prev.every((t, i) => t.id === next[i].id && t.label === next[i].label)) {433return;434}435this._sessionTypes = next;436this._onDidChangeSessionTypes.fire();437}438439/**440* Reconcile {@link _rootConfig} against {@link RootState.config}, firing441* {@link onDidChangeRootConfig} only when schema or values actually change.442*/443protected _syncRootConfigFromRootState(rootState: RootState): void {444const next = rootState.config;445const prev = this._rootConfig;446if (prev === next) {447return;448}449if (!next) {450this._rootConfig = undefined;451this._onDidChangeRootConfig.fire();452return;453}454if (prev && prev.schema === next.schema && equals(prev.values, next.values)) {455return;456}457this._rootConfig = next;458this._onDidChangeRootConfig.fire();459}460461abstract resolveWorkspace(repositoryUri: URI): ISessionWorkspace | undefined;462463/** Optional event fired when the underlying connection is lost; used to short-circuit `_waitForNewSession`. */464protected get onConnectionLost(): Event<void> { return Event.None; }465466/** Maps a working-directory URI from the session summary to a local URI. Default identity; remote overrides to `toAgentHostUri`. */467protected mapWorkingDirectoryUri(uri: URI): URI { return uri; }468469/** Maps a project URI from the session summary to a local URI. Default identity; remote overrides for `file:` paths. */470protected mapProjectUri(uri: URI): URI { return uri; }471472// -- Session listing ------------------------------------------------------473474getSessionTypes(_repositoryUri: URI): ISessionType[] {475return [...this.sessionTypes];476}477478getSessions(): ISession[] {479this._ensureSessionCache();480const sessions: ISession[] = [...this._sessionCache.values()];481if (this._pendingSession) {482sessions.push(this._pendingSession);483}484return sessions;485}486487getSessionByResource(resource: URI): ISession | undefined {488if (this._currentNewSession?.resource.toString() === resource.toString()) {489return this._currentNewSession;490}491492if (this._pendingSession?.resource.toString() === resource.toString()) {493return this._pendingSession;494}495496this._ensureSessionCache();497for (const cached of this._sessionCache.values()) {498if (cached.resource.toString() === resource.toString()) {499// Opening a session: subscribe to its AHP state so that500// `_meta` (e.g. lazy git state computed by the agent host)501// flows into the cached adapter.502this._ensureSessionStateSubscription(cached.sessionId);503return cached;504}505}506507return undefined;508}509510// -- Session lifecycle ----------------------------------------------------511512createNewSession(workspaceUri: URI, sessionTypeId: string): ISession {513if (!workspaceUri) {514throw new Error('Workspace has no repository URI');515}516517if (this._currentNewSession) {518this._clearNewSessionConfig(this._currentNewSession.sessionId);519}520this._currentNewSession = undefined;521this._selectedModelId = undefined;522this._currentNewSessionModelId = undefined;523this._currentNewSessionLoading = undefined;524this._currentNewSessionStatus = undefined;525526const sessionType = this.sessionTypes.find(t => t.id === sessionTypeId);527if (!sessionType) {528throw new Error(this._noAgentsErrorMessage());529}530531this._validateBeforeCreate(sessionType);532533const workspace = this.resolveWorkspace(workspaceUri);534if (!workspace) {535throw new Error(`Cannot resolve workspace for URI: ${workspaceUri.toString()}`);536}537return this._createNewSessionForType(workspace, sessionType);538}539540/** Subclass hook for additional pre-create checks (e.g. remote requires connection). */541protected _validateBeforeCreate(_sessionType: ISessionType): void { /* default: no-op */ }542543/** Localized "no agents" error message. Subclasses can override. */544protected _noAgentsErrorMessage(): string {545return localize('noAgents', "Agent host has not advertised any agents yet.");546}547548private _createNewSessionForType(workspace: ISessionWorkspace, sessionType: ISessionType): ISession {549const workspaceUri = workspace.repositories[0]?.uri;550if (!workspaceUri) {551throw new Error('Workspace has no repository URI');552}553554const resourceScheme = this.resourceSchemeForProvider(sessionType.id);555const resource = URI.from({ scheme: resourceScheme, path: `/untitled-${generateUuid()}` });556const status = observableValue<SessionStatus>(this, SessionStatus.Untitled);557const title = observableValue(this, '');558const updatedAt = observableValue(this, new Date());559const changes = observableValue<readonly (IChatSessionFileChange | IChatSessionFileChange2)[]>(this, []);560const modelId = observableValue<string | undefined>(this, undefined);561const mode = observableValue<{ readonly id: string; readonly kind: string } | undefined>(this, undefined);562const isArchived = observableValue(this, false);563const isRead = observableValue(this, true);564const description = observableValue<IMarkdownString | undefined>(this, undefined);565const lastTurnEnd = observableValue<Date | undefined>(this, undefined);566const loading = observableValue(this, true);567const createdAt = new Date();568569const mainChat: IChat = {570resource, createdAt, title, updatedAt, status,571changes, modelId, mode, isArchived, isRead, description, lastTurnEnd,572};573574const authPending = this.authenticationPending;575const session: ISession = {576sessionId: `${this.id}:${resource.toString()}`,577resource,578providerId: this.id,579sessionType: sessionType.id,580icon: this.icon,581createdAt,582workspace: observableValue(this, workspace),583title,584updatedAt,585status,586changes,587modelId,588mode,589loading: derived(reader => loading.read(reader) || authPending.read(reader)),590isArchived,591isRead,592description,593lastTurnEnd,594gitHubInfo: observableValue(this, undefined),595mainChat,596chats: constObservable([mainChat]),597capabilities: { supportsMultipleChats: false },598};599this._currentNewSession = session;600this._currentNewSessionStatus = status;601this._currentNewSessionModelId = modelId;602this._currentNewSessionLoading = loading;603const agentProvider = sessionType.id;604this._newSessionWorkspaces.set(session.sessionId, workspaceUri);605this._newSessionAgentProviders.set(session.sessionId, agentProvider);606this._newSessionConfigs.set(session.sessionId, { schema: { type: 'object', properties: {} }, values: {} });607this._onDidChangeSessionConfig.fire(session.sessionId);608this._resolveSessionConfig(session.sessionId, agentProvider, workspaceUri, undefined);609return session;610}611612// -- Dynamic session config ----------------------------------------------613614getSessionConfig(sessionId: string): ResolveSessionConfigResult | undefined {615// New-session config wins (during pre-creation flow). Otherwise lazily616// subscribe to the session's state so the running picker can seed its617// schema/values from the AHP `SessionState.config` snapshot for sessions618// that weren't created in this window.619const newSessionConfig = this._newSessionConfigs.get(sessionId);620if (newSessionConfig) {621return newSessionConfig;622}623this._ensureSessionStateSubscription(sessionId);624return this._runningSessionConfigs.get(sessionId);625}626627async setSessionConfigValue(sessionId: string, property: string, value: unknown): Promise<void> {628// New session (pre-creation): re-resolve the full config schema629const workingDirectory = this._newSessionWorkspaces.get(sessionId);630if (workingDirectory) {631const current = this._newSessionConfigs.get(sessionId)?.values ?? {};632this._newSessionConfigs.set(sessionId, { schema: { type: 'object', properties: {} }, values: { ...current, [property]: value } });633this._setNewSessionLoading(sessionId, true);634this._onDidChangeSessionConfig.fire(sessionId);635await this._resolveSessionConfig(sessionId, this._getAgentProviderForSession(sessionId), workingDirectory, { ...current, [property]: value });636return;637}638639// Running session: dispatch SessionConfigChanged for sessionMutable properties640const runningConfig = this._runningSessionConfigs.get(sessionId);641const connection = this.connection;642if (!runningConfig || !connection) {643return;644}645const schema = runningConfig.schema.properties[property];646if (!schema?.sessionMutable) {647return;648}649650// Update local cache optimistically651this._runningSessionConfigs.set(sessionId, {652...runningConfig,653values: { ...runningConfig.values, [property]: value },654});655this._onDidChangeSessionConfig.fire(sessionId);656657// Dispatch to the agent host658const rawId = this._rawIdFromChatId(sessionId);659const cached = rawId ? this._sessionCache.get(rawId) : undefined;660if (cached && rawId) {661const action = { type: ActionType.SessionConfigChanged as const, session: AgentSession.uri(cached.agentProvider, rawId).toString(), config: { [property]: value } };662connection.dispatch(action);663}664}665666async replaceSessionConfig(sessionId: string, values: Record<string, unknown>): Promise<void> {667const runningConfig = this._runningSessionConfigs.get(sessionId);668const connection = this.connection;669if (!runningConfig || !connection) {670return;671}672673// Build the outgoing payload: for every known property, prefer the674// caller-supplied value if the property is user-editable675// (`sessionMutable: true` and not `readOnly`), otherwise force the676// current value through. This guarantees replace semantics never677// alter a non-editable property even if the caller included it.678const nextValues: Record<string, unknown> = {};679for (const [key, schema] of Object.entries(runningConfig.schema.properties)) {680const editable = schema.sessionMutable === true && schema.readOnly !== true;681if (editable) {682nextValues[key] = values[key];683} else if (Object.hasOwn(runningConfig.values, key)) {684nextValues[key] = runningConfig.values[key];685}686}687// Unknown keys from the caller are ignored (no schema entry).688689// Skip the dispatch entirely when nothing meaningful changes.690if (equals(nextValues, runningConfig.values)) {691return;692}693694// Update local cache optimistically (full replace).695this._runningSessionConfigs.set(sessionId, {696...runningConfig,697values: nextValues,698});699this._onDidChangeSessionConfig.fire(sessionId);700701// Dispatch to the agent host with replace semantics.702const rawId = this._rawIdFromChatId(sessionId);703const cached = rawId ? this._sessionCache.get(rawId) : undefined;704if (cached && rawId) {705const action = {706type: ActionType.SessionConfigChanged as const,707session: AgentSession.uri(cached.agentProvider, rawId).toString(),708config: nextValues,709replace: true,710};711connection.dispatch(action);712}713}714715async getSessionConfigCompletions(sessionId: string, property: string, query?: string) {716const workingDirectory = this._newSessionWorkspaces.get(sessionId);717const connection = this.connection;718if (!workingDirectory || !connection) {719return [];720}721const result = await connection.sessionConfigCompletions({722provider: this._getAgentProviderForSession(sessionId),723workingDirectory,724config: this._newSessionConfigs.get(sessionId)?.values,725property,726query,727});728return result.items;729}730731getCreateSessionConfig(sessionId: string): Record<string, unknown> | undefined {732return this._newSessionConfigs.get(sessionId)?.values;733}734735clearSessionConfig(sessionId: string): void {736this._clearNewSessionConfig(sessionId);737}738739// -- Root (agent host) Config --------------------------------------------740741getRootConfig(): RootConfigState | undefined {742return this._rootConfig;743}744745async setRootConfigValue(property: string, value: unknown): Promise<void> {746const current = this._rootConfig;747const connection = this.connection;748if (!current || !connection) {749return;750}751if (!current.schema.properties[property]) {752return;753}754755// Optimistically update local cache.756this._rootConfig = {757...current,758values: { ...current.values, [property]: value },759};760this._onDidChangeRootConfig.fire();761762const action = {763type: ActionType.RootConfigChanged as const,764config: { [property]: value },765};766connection.dispatch(action);767}768769async replaceRootConfig(values: Record<string, unknown>): Promise<void> {770const current = this._rootConfig;771const connection = this.connection;772if (!current || !connection) {773return;774}775776// Filter to known properties so we don't dispatch values for keys the777// host didn't publish a schema for.778const nextValues: Record<string, unknown> = {};779for (const [key, value] of Object.entries(values)) {780if (current.schema.properties[key]) {781nextValues[key] = value;782}783}784785if (equals(nextValues, current.values)) {786return;787}788789this._rootConfig = { ...current, values: nextValues };790this._onDidChangeRootConfig.fire();791792const action = {793type: ActionType.RootConfigChanged as const,794config: nextValues,795replace: true,796};797connection.dispatch(action);798}799800// -- Model selection ------------------------------------------------------801802setModel(sessionId: string, modelId: string): void {803if (this._currentNewSession?.sessionId === sessionId) {804this._selectedModelId = modelId;805this._currentNewSessionModelId?.set(modelId, undefined);806return;807}808809const rawId = this._rawIdFromChatId(sessionId);810const cached = rawId ? this._sessionCache.get(rawId) : undefined;811const connection = this.connection;812if (cached && rawId && connection) {813cached.modelId.set(modelId, undefined);814this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] });815const resourceScheme = cached.resource.scheme;816const rawModelId = modelId.startsWith(`${resourceScheme}:`) ? modelId.substring(resourceScheme.length + 1) : modelId;817const model = cached.modelSelection?.id === rawModelId ? cached.modelSelection : { id: rawModelId };818const action = { type: ActionType.SessionModelChanged as const, session: AgentSession.uri(cached.agentProvider, rawId).toString(), model };819connection.dispatch(action);820}821}822823// -- Session actions ------------------------------------------------------824825async archiveSession(sessionId: string): Promise<void> {826const rawId = this._rawIdFromChatId(sessionId);827const cached = rawId ? this._sessionCache.get(rawId) : undefined;828if (cached && rawId) {829cached.isArchived.set(true, undefined);830this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] });831const connection = this.connection;832if (connection) {833const action = { type: ActionType.SessionIsArchivedChanged as const, session: AgentSession.uri(cached.agentProvider, rawId).toString(), isArchived: true };834connection.dispatch(action);835}836}837}838839async unarchiveSession(sessionId: string): Promise<void> {840const rawId = this._rawIdFromChatId(sessionId);841const cached = rawId ? this._sessionCache.get(rawId) : undefined;842if (cached && rawId) {843cached.isArchived.set(false, undefined);844this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] });845const connection = this.connection;846if (connection) {847const action = { type: ActionType.SessionIsArchivedChanged as const, session: AgentSession.uri(cached.agentProvider, rawId).toString(), isArchived: false };848connection.dispatch(action);849}850}851}852853async deleteSession(sessionId: string): Promise<void> {854const rawId = this._rawIdFromChatId(sessionId);855const cached = rawId ? this._sessionCache.get(rawId) : undefined;856const connection = this.connection;857if (cached && rawId && connection) {858await connection.disposeSession(AgentSession.uri(cached.agentProvider, rawId));859this._sessionCache.delete(rawId);860this._runningSessionConfigs.delete(sessionId);861this._onDidChangeSessions.fire({ added: [], removed: [cached], changed: [] });862}863}864865async renameChat(sessionId: string, _chatUri: URI, title: string): Promise<void> {866const rawId = this._rawIdFromChatId(sessionId);867const cached = rawId ? this._sessionCache.get(rawId) : undefined;868const connection = this.connection;869if (cached && rawId && connection) {870cached.title.set(title, undefined);871this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] });872const action = { type: ActionType.SessionTitleChanged as const, session: AgentSession.uri(cached.agentProvider, rawId).toString(), title };873connection.dispatch(action);874}875}876877async deleteChat(_sessionId: string, _chatUri: URI): Promise<void> {878// Agent host sessions don't support deleting individual chats879}880881addChat(_sessionId: string): IChat {882throw new Error('Multiple chats per session is not supported for agent host sessions');883}884885async sendRequest(_sessionId: string, _chatResource: URI, _options: ISendRequestOptions): Promise<ISession> {886throw new Error('Multiple chats per session is not supported for agent host sessions');887}888889async sendAndCreateChat(chatId: string, options: ISendRequestOptions): Promise<ISession> {890const connection = this.connection;891if (!connection) {892throw new Error(this._notConnectedSendErrorMessage());893}894895const session = this._currentNewSession;896if (!session || session.sessionId !== chatId) {897throw new Error(`Session '${chatId}' not found or not a new session`);898}899900const { query, attachedContext } = options;901902const sessionType = session.resource.scheme;903const contribution = this._chatSessionsService.getChatSessionContribution(sessionType);904905const sendOptions: IChatSendRequestOptions = {906location: ChatAgentLocation.Chat,907userSelectedModelId: this._selectedModelId,908modeInfo: {909kind: ChatModeKind.Agent,910isBuiltin: true,911modeInstructions: undefined,912modeId: 'agent',913applyCodeBlockSuggestionId: undefined,914permissionLevel: undefined,915},916agentIdSilent: contribution?.type,917attachedContext,918agentHostSessionConfig: this.getCreateSessionConfig(chatId),919};920921// Open chat widget — getOrCreateChatSession will wait for the session922// handler to become available via canResolveChatSession internally.923await this._chatSessionsService.getOrCreateChatSession(session.resource, CancellationToken.None);924const chatWidget = await this._chatWidgetService.openSession(session.resource, ChatViewPaneTarget);925if (!chatWidget) {926throw new Error(`[${this.id}] Failed to open chat widget`);927}928929// Load session model and apply selected model930const modelRef = await this._chatService.acquireOrLoadSession(session.resource, ChatAgentLocation.Chat, CancellationToken.None);931if (modelRef) {932if (this._selectedModelId) {933const languageModel = this._languageModelsService.lookupLanguageModel(this._selectedModelId);934if (languageModel) {935modelRef.object.inputModel.setState({ selectedModel: { identifier: this._selectedModelId, metadata: languageModel } });936}937}938modelRef.dispose();939}940941// Capture existing session keys before sending so we can detect the new942// backend session. Must be captured before sendRequest because the943// backend session may be created during the send and arrive via944// notification before sendRequest resolves.945this._ensureSessionCache();946const existingKeys = new Set(this._sessionCache.keys());947948const result = await this._chatService.sendRequest(session.resource, query, sendOptions);949if (result.kind === 'rejected') {950throw new Error(`[${this.id}] sendRequest rejected: ${result.reason}`);951}952953this._currentNewSessionStatus?.set(SessionStatus.InProgress, undefined);954const newSession = session;955this._pendingSession = newSession;956this._onDidChangeSessions.fire({ added: [newSession], removed: [], changed: [] });957958this._selectedModelId = undefined;959this._currentNewSessionStatus = undefined;960this._currentNewSessionModelId = undefined;961this._currentNewSessionLoading = undefined;962963try {964const committedSession = await this._waitForNewSession(existingKeys);965if (committedSession) {966this._preserveNewSessionConfig(chatId, committedSession.sessionId);967this._currentNewSession = undefined;968this._currentNewSessionModelId = undefined;969this._currentNewSessionLoading = undefined;970this._clearNewSessionConfig(chatId);971this._onDidReplaceSession.fire({ from: newSession, to: committedSession });972return committedSession;973}974} catch {975// Connection lost or timeout — clean up976} finally {977this._pendingSession = undefined;978}979980this._currentNewSession = undefined;981this._currentNewSessionModelId = undefined;982this._currentNewSessionLoading = undefined;983this._clearNewSessionConfig(chatId);984return newSession;985}986987/** Localized error message when sendAndCreateChat is invoked without a connection. Subclasses can override. */988protected _notConnectedSendErrorMessage(): string {989return localize('notConnectedSend', "Cannot send request: not connected to agent host.");990}991992// -- Session config plumbing ---------------------------------------------993994private async _resolveSessionConfig(sessionId: string, agentProvider: string, workingDirectory: URI, config: Record<string, unknown> | undefined): Promise<void> {995const connection = this.connection;996if (!connection) {997this._setNewSessionLoading(sessionId, false);998return;999}1000const request = (this._newSessionConfigRequests.get(sessionId) ?? 0) + 1;1001this._newSessionConfigRequests.set(sessionId, request);1002try {1003const result = await connection.resolveSessionConfig({1004provider: agentProvider,1005workingDirectory,1006config,1007});1008if (this._newSessionConfigRequests.get(sessionId) !== request) {1009return;1010}1011this._newSessionConfigs.set(sessionId, result);1012this._setNewSessionLoading(sessionId, !isSessionConfigComplete(result));1013} catch {1014if (this._newSessionConfigRequests.get(sessionId) !== request) {1015return;1016}1017this._newSessionConfigs.delete(sessionId);1018this._setNewSessionLoading(sessionId, false);1019}1020this._onDidChangeSessionConfig.fire(sessionId);1021}10221023protected _clearNewSessionConfig(sessionId: string): void {1024this._newSessionWorkspaces.delete(sessionId);1025this._newSessionConfigs.delete(sessionId);1026this._newSessionAgentProviders.delete(sessionId);1027this._newSessionConfigRequests.delete(sessionId);1028}10291030/**1031* When a session transitions from untitled (new) to committed (running),1032* carry over the full resolved config (schema + values) so consumers like1033* the session-settings JSONC editor can round-trip non-mutable values1034* (`isolation`, `branch`, …) through a replace dispatch. Mutable-vs-readonly1035* behavior is still driven off the per-property `sessionMutable` flag.1036*/1037private _preserveNewSessionConfig(oldSessionId: string, newSessionId: string): void {1038const config = this._newSessionConfigs.get(oldSessionId);1039if (!config) {1040return;1041}1042if (Object.keys(config.schema.properties).length > 0) {1043this._runningSessionConfigs.set(newSessionId, {1044schema: { type: 'object', properties: { ...config.schema.properties } },1045values: { ...config.values },1046});1047}1048}10491050private _setNewSessionLoading(sessionId: string, loading: boolean): void {1051if (this._currentNewSession?.sessionId === sessionId) {1052this._currentNewSessionLoading?.set(loading, undefined);1053}1054}10551056protected _rawIdFromChatId(chatId: string): string | undefined {1057const prefix = `${this.id}:`;1058const resourceStr = chatId.startsWith(prefix) ? chatId.substring(prefix.length) : chatId;1059try {1060return URI.parse(resourceStr).path.substring(1) || undefined;1061} catch {1062return undefined;1063}1064}10651066private _getAgentProviderForSession(sessionId: string): string {1067const provider = this._newSessionAgentProviders.get(sessionId);1068if (!provider) {1069throw new Error(`No agent provider tracked for new session: ${sessionId}`);1070}1071return provider;1072}10731074// -- Lazy session-state subscription seeding -----------------------------10751076/**1077* Lazily acquire a session-state subscription for `sessionId` so that1078* `_runningSessionConfigs` is seeded from the AHP `SessionState.config`1079* snapshot. Safe to call repeatedly — no-op once a subscription exists.1080*1081* The subscription is reference-counted by {@link IAgentConnection.getSubscription},1082* so when the session handler is also subscribed (chat content open) this1083* shares the existing wire subscription rather than opening a new one.1084*/1085private _ensureSessionStateSubscription(sessionId: string): void {1086if (this._sessionStateSubscriptions.has(sessionId)) {1087return;1088}1089const connection = this.connection;1090if (!connection) {1091return;1092}1093const rawId = this._rawIdFromChatId(sessionId);1094if (!rawId) {1095return;1096}1097const cached = this._sessionCache.get(rawId);1098if (!cached) {1099return;1100}1101const sessionUri = AgentSession.uri(cached.agentProvider, rawId);1102const ref = connection.getSubscription(StateComponents.Session, sessionUri);1103const store = new DisposableStore();1104store.add(ref);1105store.add(ref.object.onDidChange(state => {1106this._applySessionStateUpdate(sessionId, state);1107}));1108this._sessionStateSubscriptions.set(sessionId, store);11091110const value = ref.object.value;1111if (value && !(value instanceof Error)) {1112this._applySessionStateUpdate(sessionId, value);1113}1114}11151116/**1117* Fan-out for AHP `SessionState` snapshots: keeps both the running1118* session config and the cached adapter's `_meta` (e.g. git state) in1119* sync.1120*/1121private _applySessionStateUpdate(sessionId: string, state: SessionState): void {1122this._seedRunningConfigFromState(sessionId, state);1123this._applySessionMetaFromState(sessionId, state);1124}11251126private _applySessionMetaFromState(sessionId: string, state: SessionState): void {1127const rawId = this._rawIdFromChatId(sessionId);1128if (!rawId) {1129return;1130}1131const cached = this._sessionCache.get(rawId);1132if (!cached) {1133return;1134}1135if (cached.setMeta(state._meta)) {1136this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] });1137}1138}11391140/**1141* Seed {@link _runningSessionConfigs} from the AHP `SessionState.config`1142* snapshot. Keeps the full schema + values (including non-mutable ones)1143* so consumers like the JSONC settings editor can round-trip all values1144* through a replace dispatch. No-op if structurally equal to avoid spurious1145* `onDidChangeSessionConfig` fires.1146*/1147private _seedRunningConfigFromState(sessionId: string, state: SessionState): void {1148const stateConfig = state.config;1149if (!stateConfig) {1150return;1151}1152if (Object.keys(stateConfig.schema.properties).length === 0) {1153return;1154}1155const seeded: ResolveSessionConfigResult = {1156schema: { type: 'object', properties: { ...stateConfig.schema.properties } },1157values: { ...stateConfig.values },1158};1159const existing = this._runningSessionConfigs.get(sessionId);1160if (existing && resolvedConfigsEqual(existing, seeded)) {1161return;1162}1163this._runningSessionConfigs.set(sessionId, seeded);1164this._onDidChangeSessionConfig.fire(sessionId);1165}11661167// -- Session cache management --------------------------------------------11681169protected _ensureSessionCache(): void {1170if (this._cacheInitialized) {1171return;1172}1173this._cacheInitialized = true;1174this._refreshSessions();1175}11761177protected async _refreshSessions(): Promise<void> {1178const connection = this.connection;1179if (!connection) {1180return;1181}1182try {1183const sessions = await connection.listSessions();1184const currentKeys = new Set<string>();1185const added: ISession[] = [];1186const changed: ISession[] = [];11871188for (const meta of sessions) {1189const rawId = AgentSession.id(meta.session);1190currentKeys.add(rawId);11911192const existing = this._sessionCache.get(rawId);1193if (existing) {1194if (existing.update(meta)) {1195changed.push(existing);1196}1197} else {1198const cached = this.createAdapter(meta);1199this._sessionCache.set(rawId, cached);1200added.push(cached);1201}1202}12031204const removed: ISession[] = [];1205for (const [key, cached] of this._sessionCache) {1206if (!currentKeys.has(key)) {1207this._sessionCache.delete(key);1208this._runningSessionConfigs.delete(cached.sessionId);1209removed.push(cached);1210}1211}12121213if (added.length > 0 || removed.length > 0 || changed.length > 0) {1214this._onDidChangeSessions.fire({ added, removed, changed });1215}1216} catch {1217// Connection may not be ready yet1218}1219}12201221private async _waitForNewSession(existingKeys: Set<string>): Promise<ISession | undefined> {1222await this._refreshSessions();1223for (const [key, cached] of this._sessionCache) {1224if (!existingKeys.has(key)) {1225return cached;1226}1227}12281229const waitDisposables = new DisposableStore();1230try {1231const sessionPromise = new Promise<ISession | undefined>((resolve) => {1232waitDisposables.add(this._onDidChangeSessions.event(e => {1233const newSession = e.added.find(s => {1234const rawId = s.resource.path.substring(1);1235return !existingKeys.has(rawId);1236});1237if (newSession) {1238resolve(newSession);1239}1240}));1241waitDisposables.add(this.onConnectionLost(() => resolve(undefined)));1242});1243return await raceTimeout(sessionPromise, 30_000);1244} finally {1245waitDisposables.dispose();1246}1247}12481249// -- AHP notification / action handlers ----------------------------------12501251/**1252* Wire AHP notification and action listeners on the given connection.1253* Subclasses call this from their constructor (local) or `setConnection`1254* (remote), passing a store that bounds the listeners' lifetime.1255*/1256protected _attachConnectionListeners(connection: IAgentConnection, store: DisposableStore): void {1257store.add(connection.onDidNotification(n => {1258if (n.type === NotificationType.SessionAdded) {1259this._handleSessionAdded(n.summary);1260} else if (n.type === NotificationType.SessionRemoved) {1261this._handleSessionRemoved(n.session);1262} else if (n.type === NotificationType.SessionSummaryChanged) {1263this._handleSessionSummaryChanged(n.session, n.changes);1264}1265}));12661267store.add(connection.onDidAction(e => {1268if (e.action.type === ActionType.SessionTurnComplete && isSessionAction(e.action)) {1269this._refreshSessions();1270} else if (e.action.type === ActionType.SessionTitleChanged && isSessionAction(e.action)) {1271this._handleTitleChanged(e.action.session, e.action.title);1272} else if (e.action.type === ActionType.SessionModelChanged && isSessionAction(e.action)) {1273this._handleModelChanged(e.action.session, e.action.model);1274} else if (e.action.type === ActionType.SessionIsReadChanged && isSessionAction(e.action)) {1275this._handleIsReadChanged(e.action.session, e.action.isRead);1276} else if (e.action.type === ActionType.SessionIsArchivedChanged && isSessionAction(e.action)) {1277this._handleIsArchivedChanged(e.action.session, e.action.isArchived);1278} else if (e.action.type === ActionType.SessionConfigChanged && isSessionAction(e.action)) {1279this._handleConfigChanged(e.action.session, e.action.config, e.action.replace === true);1280} else if (e.action.type === ActionType.SessionDiffsChanged && isSessionAction(e.action)) {1281this._handleDiffsChanged(e.action.session, e.action.diffs);1282}1283}));1284}12851286private _handleSessionAdded(summary: SessionSummary): void {1287const sessionUri = URI.parse(summary.resource);1288const rawId = AgentSession.id(sessionUri);1289if (this._sessionCache.has(rawId)) {1290return;1291}12921293const workingDir = typeof summary.workingDirectory === 'string'1294? this.mapWorkingDirectoryUri(URI.parse(summary.workingDirectory))1295: undefined;1296const meta: IAgentSessionMetadata = {1297session: sessionUri,1298startTime: summary.createdAt,1299modifiedTime: summary.modifiedAt,1300summary: summary.title,1301activity: summary.activity,1302status: summary.status,1303...(summary.project ? { project: { uri: this.mapProjectUri(URI.parse(summary.project.uri)), displayName: summary.project.displayName } } : {}),1304model: summary.model,1305workingDirectory: workingDir,1306isRead: !!(summary.status & ProtocolSessionStatus.IsRead),1307isArchived: !!(summary.status & ProtocolSessionStatus.IsArchived),1308};1309const cached = this.createAdapter(meta);1310this._sessionCache.set(rawId, cached);1311this._onDidChangeSessions.fire({ added: [cached], removed: [], changed: [] });1312}13131314private _handleSessionRemoved(session: URI | string): void {1315const rawId = AgentSession.id(session);1316const cached = this._sessionCache.get(rawId);1317if (cached) {1318this._sessionCache.delete(rawId);1319this._runningSessionConfigs.delete(cached.sessionId);1320this._sessionStateSubscriptions.deleteAndDispose(cached.sessionId);1321this._onDidChangeSessions.fire({ added: [], removed: [cached], changed: [] });1322}1323}13241325private _handleTitleChanged(session: string, title: string): void {1326const rawId = AgentSession.id(session);1327const cached = this._sessionCache.get(rawId);1328if (cached) {1329cached.title.set(title, undefined);1330this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] });1331}1332}13331334private _handleModelChanged(session: string, model: ModelSelection): void {1335const rawId = AgentSession.id(session);1336const cached = this._sessionCache.get(rawId);1337if (cached) {1338cached.modelSelection = model;1339}1340const modelId = cached ? `${cached.resource.scheme}:${model.id}` : undefined;1341if (cached && cached.modelId.get() !== modelId) {1342cached.modelId.set(modelId, undefined);1343this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] });1344}1345}13461347private _handleIsReadChanged(session: string, isRead: boolean): void {1348const rawId = AgentSession.id(session);1349const cached = this._sessionCache.get(rawId);1350if (cached) {1351cached.isRead.set(isRead, undefined);1352this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] });1353}1354}13551356private _handleIsArchivedChanged(session: string, isArchived: boolean): void {1357const rawId = AgentSession.id(session);1358const cached = this._sessionCache.get(rawId);1359if (cached) {1360cached.isArchived.set(isArchived, undefined);1361this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] });1362}1363}13641365private _handleDiffsChanged(session: string, diffs: FileEdit[]): void {1366const rawId = AgentSession.id(session);1367const cached = this._sessionCache.get(rawId);1368if (cached) {1369cached.changes.set(diffsToChanges(diffs, this._diffUriMapper()), undefined);1370this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] });1371}1372}13731374private _handleSessionSummaryChanged(session: string, changes: Partial<SessionSummary>): void {1375const rawId = AgentSession.id(session);1376const cached = this._sessionCache.get(rawId);1377if (!cached) {1378return;1379}13801381let didChange = false;13821383if (changes.status !== undefined) {1384const uiStatus = mapProtocolStatus(changes.status);1385if (uiStatus !== cached.status.get()) {1386cached.status.set(uiStatus, undefined);1387didChange = true;1388}13891390const isRead = !!(changes.status & ProtocolSessionStatus.IsRead);1391if (isRead !== cached.isRead.get()) {1392cached.isRead.set(isRead, undefined);1393didChange = true;1394}13951396const isArchived = !!(changes.status & ProtocolSessionStatus.IsArchived);1397if (isArchived !== cached.isArchived.get()) {1398cached.isArchived.set(isArchived, undefined);1399didChange = true;1400}1401}14021403if (changes.title !== undefined && changes.title !== cached.title.get()) {1404cached.title.set(changes.title, undefined);1405didChange = true;1406}14071408if (changes.diffs !== undefined) {1409const mapUri = this._diffUriMapper();1410if (!diffsEqual(cached.changes.get(), changes.diffs, mapUri)) {1411cached.changes.set(diffsToChanges(changes.diffs, mapUri), undefined);1412didChange = true;1413}1414}14151416if (Object.prototype.hasOwnProperty.call(changes, 'activity') && cached.setActivity(changes.activity)) {1417didChange = true;1418}14191420if (didChange) {1421this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] });1422}1423}14241425private _handleConfigChanged(session: string, config: Record<string, unknown>, replace: boolean): void {1426const rawId = AgentSession.id(session);1427const cached = this._sessionCache.get(rawId);1428if (!cached) {1429return;1430}1431const sessionId = cached.sessionId;1432const existing = this._runningSessionConfigs.get(sessionId);1433if (existing) {1434this._runningSessionConfigs.set(sessionId, {1435...existing,1436values: replace ? { ...config } : { ...existing.values, ...config },1437});1438} else {1439// Session was restored (e.g. after reload) — create a minimal1440// config entry from the changed values so the picker can render.1441// `replace` vs merge is moot here (no existing values to merge with).1442this._runningSessionConfigs.set(sessionId, {1443schema: { type: 'object', properties: buildMutableConfigSchema(config) },1444values: config,1445});1446}1447this._onDidChangeSessionConfig.fire(sessionId);1448}14491450/**1451* Optional URI mapper used when applying diff changes. Subclasses1452* override to translate remote diff URIs into agent-host URIs.1453*/1454protected _diffUriMapper(): ((uri: URI) => URI) | undefined { return undefined; }1455}145614571458