Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts
13401 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { raceTimeout } from '../../../../base/common/async.js';
7
import { CancellationToken } from '../../../../base/common/cancellation.js';
8
import { Emitter, Event } from '../../../../base/common/event.js';
9
import { IMarkdownString, MarkdownString } from '../../../../base/common/htmlContent.js';
10
import { Disposable, DisposableMap, DisposableStore } from '../../../../base/common/lifecycle.js';
11
import { equals } from '../../../../base/common/objects.js';
12
import { constObservable, derived, IObservable, ISettableObservable, observableValue, transaction } from '../../../../base/common/observable.js';
13
import { ThemeIcon } from '../../../../base/common/themables.js';
14
import { URI } from '../../../../base/common/uri.js';
15
import { generateUuid } from '../../../../base/common/uuid.js';
16
import { localize } from '../../../../nls.js';
17
import { AgentSession, IAgentConnection, IAgentSessionMetadata } from '../../../../platform/agentHost/common/agentService.js';
18
import { ResolveSessionConfigResult } from '../../../../platform/agentHost/common/state/protocol/commands.js';
19
import { NotificationType } from '../../../../platform/agentHost/common/state/protocol/notifications.js';
20
import { FileEdit, ModelSelection, RootConfigState, RootState, SessionState, SessionSummary, SessionStatus as ProtocolSessionStatus } from '../../../../platform/agentHost/common/state/protocol/state.js';
21
import { ActionType, isSessionAction } from '../../../../platform/agentHost/common/state/sessionActions.js';
22
import { readSessionGitState, StateComponents, type ISessionGitState } from '../../../../platform/agentHost/common/state/sessionState.js';
23
import { ChatViewPaneTarget, IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js';
24
import { IChatSendRequestOptions, IChatService } from '../../../../workbench/contrib/chat/common/chatService/chatService.js';
25
import { IChatSessionFileChange, IChatSessionFileChange2, IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js';
26
import { ChatAgentLocation, ChatModeKind } from '../../../../workbench/contrib/chat/common/constants.js';
27
import { ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js';
28
import { diffsEqual, diffsToChanges, mapProtocolStatus } from './agentHostDiffs.js';
29
import { buildMutableConfigSchema, IAgentHostSessionsProvider, resolvedConfigsEqual } from '../../../common/agentHostSessionsProvider.js';
30
import { agentHostSessionWorkspaceKey } from '../../../common/agentHostSessionWorkspace.js';
31
import { isSessionConfigComplete } from '../../../common/sessionConfig.js';
32
import { IChat, IGitHubInfo, ISession, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction, SessionStatus, toSessionId } from '../../../services/sessions/common/session.js';
33
import { ISendRequestOptions, ISessionChangeEvent } from '../../../services/sessions/common/sessionsProvider.js';
34
35
// ============================================================================
36
// AgentHostSessionAdapter — shared adapter for local and remote sessions
37
// ============================================================================
38
39
/**
40
* Variation points the host provider supplies when building an adapter.
41
* Differences between local and remote sessions (icon, description text,
42
* workspace builder, optional URI mapping) flow through this options bag so
43
* the adapter itself stays a single concrete class.
44
*/
45
export interface IAgentHostAdapterOptions {
46
readonly icon: ThemeIcon;
47
readonly description: IMarkdownString | undefined;
48
/** Loading observable wired to the provider's authentication-pending state. */
49
readonly loading: IObservable<boolean>;
50
/** Builds the session workspace from session metadata; provider-specific (icon, providerLabel, requiresWorkspaceTrust). */
51
readonly buildWorkspace: (project: IAgentSessionMetadata['project'], workingDirectory: URI | undefined, gitState: ISessionGitState | undefined) => ISessionWorkspace | undefined;
52
/** Optional URI mapping for diff entries (remote uses `toAgentHostUri`; local uses identity). */
53
readonly mapDiffUri?: (uri: URI) => URI;
54
}
55
56
/**
57
* Adapts an {@link IAgentSessionMetadata} into an {@link ISession} for the
58
* sessions UI. A single concrete class for both local and remote agent
59
* hosts — variation flows through {@link IAgentHostAdapterOptions}.
60
*/
61
export class AgentHostSessionAdapter implements ISession {
62
63
readonly sessionId: string;
64
readonly resource: URI;
65
readonly providerId: string;
66
readonly sessionType: string;
67
readonly icon: ThemeIcon;
68
readonly createdAt: Date;
69
readonly workspace: ISettableObservable<ISessionWorkspace | undefined>;
70
readonly title: ISettableObservable<string>;
71
readonly updatedAt: ISettableObservable<Date>;
72
readonly status: ISettableObservable<SessionStatus>;
73
readonly changes = observableValue<readonly (IChatSessionFileChange | IChatSessionFileChange2)[]>('changes', []);
74
readonly modelId: ISettableObservable<string | undefined>;
75
modelSelection: ModelSelection | undefined;
76
readonly mode = observableValue<{ readonly id: string; readonly kind: string } | undefined>('mode', undefined);
77
readonly loading: IObservable<boolean>;
78
readonly isArchived = observableValue('isArchived', false);
79
readonly isRead = observableValue('isRead', true);
80
readonly description: IObservable<IMarkdownString | undefined>;
81
readonly lastTurnEnd: ISettableObservable<Date | undefined>;
82
readonly gitHubInfo = observableValue<IGitHubInfo | undefined>('gitHubInfo', undefined);
83
84
readonly mainChat: IChat;
85
readonly chats: IObservable<readonly IChat[]>;
86
readonly capabilities = { supportsMultipleChats: false };
87
readonly deduplicationKey: string;
88
89
readonly agentProvider: string;
90
91
// Retained so we can rebuild `workspace` when only `_meta` changes via
92
// a `SessionMetaChanged` action dispatched on session open (without a full
93
// list refresh). See `_applySessionMetaFromState` / `setMeta`.
94
private _project: IAgentSessionMetadata['project'];
95
private _workingDirectory: URI | undefined;
96
private _meta: IAgentSessionMetadata['_meta'];
97
private _activity: ISettableObservable<string | undefined>;
98
99
constructor(
100
metadata: IAgentSessionMetadata,
101
providerId: string,
102
resourceScheme: string,
103
logicalSessionType: string,
104
private readonly _options: IAgentHostAdapterOptions,
105
) {
106
const rawId = AgentSession.id(metadata.session);
107
const agentProvider = AgentSession.provider(metadata.session);
108
if (!agentProvider) {
109
throw new Error(`Agent session URI has no provider scheme: ${metadata.session.toString()}`);
110
}
111
this.agentProvider = agentProvider;
112
this.deduplicationKey = metadata.session.toString();
113
this.resource = URI.from({ scheme: resourceScheme, path: `/${rawId}` });
114
this.sessionId = toSessionId(providerId, this.resource);
115
this.providerId = providerId;
116
this.sessionType = logicalSessionType;
117
this.icon = _options.icon;
118
this.createdAt = new Date(metadata.startTime);
119
this.title = observableValue('title', metadata.summary || `Session ${rawId.substring(0, 8)}`);
120
this.updatedAt = observableValue('updatedAt', new Date(metadata.modifiedTime));
121
this.modelSelection = metadata.model;
122
this.status = observableValue<SessionStatus>('status', metadata.status !== undefined ? mapProtocolStatus(metadata.status) : SessionStatus.Completed);
123
this.modelId = observableValue<string | undefined>('modelId', metadata.model ? `${resourceScheme}:${metadata.model.id}` : undefined);
124
this.lastTurnEnd = observableValue('lastTurnEnd', metadata.modifiedTime ? new Date(metadata.modifiedTime) : undefined);
125
this._activity = observableValue('activity', metadata.activity);
126
this._project = metadata.project;
127
this._workingDirectory = metadata.workingDirectory;
128
this._meta = metadata._meta;
129
const initialGitState = readSessionGitState(this._meta);
130
const initialWorkspace = _options.buildWorkspace(this._project, this._workingDirectory, initialGitState);
131
this.workspace = observableValue('workspace', initialWorkspace);
132
this.loading = _options.loading;
133
this.description = derived(reader => {
134
const status = this.status.read(reader);
135
if (status === SessionStatus.InProgress || status === SessionStatus.NeedsInput) {
136
const activity = this._activity.read(reader);
137
if (activity) {
138
return new MarkdownString().appendText(activity);
139
}
140
}
141
142
return this._options.description;
143
});
144
145
if (metadata.isRead === false) {
146
this.isRead.set(false, undefined);
147
}
148
if (metadata.isArchived) {
149
this.isArchived.set(true, undefined);
150
}
151
if (metadata.diffs && metadata.diffs.length > 0) {
152
this.changes.set(diffsToChanges(metadata.diffs, _options.mapDiffUri), undefined);
153
}
154
155
this.mainChat = {
156
resource: this.resource,
157
createdAt: this.createdAt,
158
title: this.title,
159
updatedAt: this.updatedAt,
160
status: this.status,
161
changes: this.changes,
162
modelId: this.modelId,
163
mode: this.mode,
164
isArchived: this.isArchived,
165
isRead: this.isRead,
166
description: this.description,
167
lastTurnEnd: this.lastTurnEnd,
168
};
169
this.chats = constObservable([this.mainChat]);
170
}
171
172
/**
173
* Update fields from a refreshed metadata snapshot. Returns `true` iff
174
* any user-visible field changed.
175
*/
176
update(metadata: IAgentSessionMetadata): boolean {
177
let didChange = false;
178
179
transaction(tx => {
180
const summary = metadata.summary;
181
if (summary !== undefined && summary !== this.title.get()) {
182
this.title.set(summary, tx);
183
didChange = true;
184
}
185
186
if (metadata.status !== undefined) {
187
const uiStatus = mapProtocolStatus(metadata.status);
188
if (uiStatus !== this.status.get()) {
189
this.status.set(uiStatus, tx);
190
didChange = true;
191
}
192
}
193
194
const modifiedTime = metadata.modifiedTime;
195
if (this.updatedAt.get().getTime() !== modifiedTime) {
196
this.updatedAt.set(new Date(modifiedTime), tx);
197
didChange = true;
198
}
199
200
const currentLastTurnEndTime = this.lastTurnEnd.get()?.getTime();
201
const nextLastTurnEndTime = modifiedTime ? modifiedTime : undefined;
202
if (currentLastTurnEndTime !== nextLastTurnEndTime) {
203
this.lastTurnEnd.set(nextLastTurnEndTime !== undefined ? new Date(nextLastTurnEndTime) : undefined, tx);
204
didChange = true;
205
}
206
207
this._project = metadata.project;
208
this._workingDirectory = metadata.workingDirectory;
209
// Only update `_meta` when the source actually provides one. `update()`
210
// is fed from SessionSummary (via `listSessions`/`sessionAdded` paths)
211
// which has no `_meta` field, so an undefined value here means "not
212
// included" rather than "cleared". `_meta` (e.g. git state) flows in
213
// exclusively via `setMeta` from `SessionState` subscription updates.
214
if (metadata._meta !== undefined) {
215
this._meta = metadata._meta;
216
}
217
const workspace = this._options.buildWorkspace(this._project, this._workingDirectory, readSessionGitState(this._meta));
218
if (agentHostSessionWorkspaceKey(workspace) !== agentHostSessionWorkspaceKey(this.workspace.get())) {
219
this.workspace.set(workspace, tx);
220
didChange = true;
221
}
222
223
if (metadata.isRead !== undefined && metadata.isRead !== this.isRead.get()) {
224
this.isRead.set(metadata.isRead, tx);
225
didChange = true;
226
}
227
228
if (metadata.isArchived !== undefined && metadata.isArchived !== this.isArchived.get()) {
229
this.isArchived.set(metadata.isArchived, tx);
230
didChange = true;
231
}
232
233
this.modelSelection = metadata.model;
234
const modelId = metadata.model ? `${this.resource.scheme}:${metadata.model.id}` : undefined;
235
if (modelId !== this.modelId.get()) {
236
this.modelId.set(modelId, tx);
237
didChange = true;
238
}
239
240
if (metadata.diffs && !diffsEqual(this.changes.get(), metadata.diffs, this._options.mapDiffUri)) {
241
this.changes.set(diffsToChanges(metadata.diffs, this._options.mapDiffUri), tx);
242
didChange = true;
243
}
244
245
if (this._activity.get() !== metadata.activity) {
246
this._activity.set(metadata.activity, tx);
247
didChange = true;
248
}
249
});
250
251
return didChange;
252
}
253
254
/**
255
* Sets the activity text from a `SessionSummaryChanged` notification.
256
* Returns `true` iff the activity observable changed.
257
*/
258
setActivity(activity: string | undefined): boolean {
259
if (this._activity.get() !== activity) {
260
this._activity.set(activity, undefined);
261
return true;
262
}
263
264
return false;
265
}
266
267
/**
268
* Apply a `SessionState._meta` delta (fed from `_applySessionMetaFromState`)
269
* and rebuild the workspace if the git state changed. Returns `true` iff
270
* the workspace actually changed.
271
*/
272
setMeta(meta: IAgentSessionMetadata['_meta']): boolean {
273
this._meta = meta;
274
const gitState = readSessionGitState(this._meta);
275
const workspace = this._options.buildWorkspace(this._project, this._workingDirectory, gitState);
276
if (agentHostSessionWorkspaceKey(workspace) === agentHostSessionWorkspaceKey(this.workspace.get())) {
277
return false;
278
}
279
this.workspace.set(workspace, undefined);
280
return true;
281
}
282
}
283
284
// ============================================================================
285
// BaseAgentHostSessionsProvider — shared base for local and remote providers
286
// ============================================================================
287
288
/**
289
* Shared base class for the local and remote agent host sessions providers.
290
*
291
* Owns the structures and flows that are identical between the two:
292
* the session cache, the new-session/running-session config picker state,
293
* the lazy session-state subscriptions, the AHP notification/action
294
* handlers, and every connection-routed method (set/get/archive/delete/
295
* rename/setModel/sendAndCreateChat).
296
*
297
* Subclasses supply the genuine variation points: the connection
298
* accessor, the authentication-pending observable, an adapter factory,
299
* URI-scheme mapping for session metadata, the agent-provider lookup, and
300
* the browse UI.
301
*/
302
export abstract class BaseAgentHostSessionsProvider extends Disposable implements IAgentHostSessionsProvider {
303
304
abstract readonly id: string;
305
abstract readonly label: string;
306
abstract readonly icon: ThemeIcon;
307
abstract readonly browseActions: readonly ISessionWorkspaceBrowseAction[];
308
309
get sessionTypes(): readonly ISessionType[] { return this._sessionTypes; }
310
protected _sessionTypes: ISessionType[] = [];
311
312
protected readonly _onDidChangeSessionTypes = this._register(new Emitter<void>());
313
readonly onDidChangeSessionTypes: Event<void> = this._onDidChangeSessionTypes.event;
314
315
protected readonly _onDidChangeSessions = this._register(new Emitter<ISessionChangeEvent>());
316
readonly onDidChangeSessions: Event<ISessionChangeEvent> = this._onDidChangeSessions.event;
317
318
protected readonly _onDidReplaceSession = this._register(new Emitter<{ readonly from: ISession; readonly to: ISession }>());
319
readonly onDidReplaceSession: Event<{ readonly from: ISession; readonly to: ISession }> = this._onDidReplaceSession.event;
320
321
protected readonly _onDidChangeSessionConfig = this._register(new Emitter<string>());
322
readonly onDidChangeSessionConfig = this._onDidChangeSessionConfig.event;
323
324
protected readonly _onDidChangeRootConfig = this._register(new Emitter<void>());
325
readonly onDidChangeRootConfig = this._onDidChangeRootConfig.event;
326
327
/** Last-known root config state (schema + values), seeded from `RootState.config`. */
328
protected _rootConfig: RootConfigState | undefined;
329
330
/** Cache of adapted sessions, keyed by raw session ID. */
331
protected readonly _sessionCache = new Map<string, AgentHostSessionAdapter>();
332
333
/**
334
* Temporary session that has been sent (first turn dispatched) but not yet
335
* committed to a real backend session. Shown in the session list until the
336
* server creates the backend session, at which point it is replaced via
337
* {@link _onDidReplaceSession}.
338
*/
339
protected _pendingSession: ISession | undefined;
340
341
protected _currentNewSession: ISession | undefined;
342
protected _currentNewSessionStatus: ISettableObservable<SessionStatus> | undefined;
343
protected _currentNewSessionModelId: ISettableObservable<string | undefined> | undefined;
344
protected _currentNewSessionLoading: ISettableObservable<boolean> | undefined;
345
protected _selectedModelId: string | undefined;
346
347
protected readonly _newSessionWorkspaces = new Map<string, URI>();
348
protected readonly _newSessionConfigs = new Map<string, ResolveSessionConfigResult>();
349
protected readonly _newSessionAgentProviders = new Map<string, string>();
350
protected readonly _newSessionConfigRequests = new Map<string, number>();
351
352
/** Full resolved config (schema + values) for running sessions, keyed by session ID. */
353
protected readonly _runningSessionConfigs = new Map<string, ResolveSessionConfigResult>();
354
355
/**
356
* Lazy session-state subscriptions used to seed {@link _runningSessionConfigs}
357
* for sessions that already exist on the agent host (e.g. created in a prior
358
* window). The underlying wire subscription is reference-counted by
359
* {@link IAgentConnection.getSubscription}, so when the session handler is
360
* also subscribed (i.e. chat content is loaded) no extra wire subscribe is
361
* issued. Keyed by session ID.
362
*/
363
protected readonly _sessionStateSubscriptions = this._register(new DisposableMap<string, DisposableStore>());
364
365
protected _cacheInitialized = false;
366
367
constructor(
368
@IChatSessionsService protected readonly _chatSessionsService: IChatSessionsService,
369
@IChatService protected readonly _chatService: IChatService,
370
@IChatWidgetService protected readonly _chatWidgetService: IChatWidgetService,
371
@ILanguageModelsService protected readonly _languageModelsService: ILanguageModelsService,
372
) {
373
super();
374
}
375
376
// -- Subclass hooks -------------------------------------------------------
377
378
/** Current connection (always present for local; may be undefined while disconnected for remote). */
379
protected abstract get connection(): IAgentConnection | undefined;
380
381
/** Provider-level authentication-pending observable used to derive `loading` for sessions. */
382
protected abstract get authenticationPending(): IObservable<boolean>;
383
384
/**
385
* Subclass-specific portion of the adapter options. Base fills in
386
* the bits that are uniform across hosts (`icon`, `loading`,
387
* `mapDiffUri`) from the corresponding hooks.
388
*/
389
protected abstract _adapterOptions(): Pick<IAgentHostAdapterOptions, 'description' | 'buildWorkspace'>;
390
391
/** Build an adapter for the given metadata. */
392
protected createAdapter(meta: IAgentSessionMetadata): AgentHostSessionAdapter {
393
const provider = AgentSession.provider(meta.session);
394
if (!provider) {
395
throw new Error(`Agent session URI has no provider scheme: ${meta.session.toString()}`);
396
}
397
return new AgentHostSessionAdapter(meta, this.id, this.resourceSchemeForProvider(provider), provider, {
398
icon: this.icon,
399
loading: this.authenticationPending,
400
mapDiffUri: this._diffUriMapper(),
401
...this._adapterOptions(),
402
});
403
}
404
405
/**
406
* Computes the URI resource scheme used to route session URIs to this
407
* provider's content provider for a given agent provider name. Local
408
* uses `agent-host-${provider}`; remote uses a per-connection scheme.
409
*
410
* The resource scheme is host-specific and exists purely for content
411
* provider routing. The logical {@link ISession.sessionType} is the
412
* agent provider name itself, so the same agent (e.g. `copilotcli`)
413
* appears under one shared session type across hosts.
414
*/
415
protected abstract resourceSchemeForProvider(provider: string): string;
416
417
/** Format the human-readable label for a session type entry (e.g. `Copilot [Local]`). */
418
protected abstract _formatSessionTypeLabel(agentLabel: string): string;
419
420
/**
421
* Reconcile {@link _sessionTypes} against the agents advertised by the
422
* host's root state, firing {@link onDidChangeSessionTypes} only if the
423
* id/label set actually changed.
424
*/
425
protected _syncSessionTypesFromRootState(rootState: RootState): void {
426
const next = rootState.agents.map((agent): ISessionType => ({
427
id: agent.provider,
428
label: this._formatSessionTypeLabel(agent.displayName?.trim() || agent.provider),
429
icon: this.icon,
430
}));
431
432
const prev = this._sessionTypes;
433
if (prev.length === next.length && prev.every((t, i) => t.id === next[i].id && t.label === next[i].label)) {
434
return;
435
}
436
this._sessionTypes = next;
437
this._onDidChangeSessionTypes.fire();
438
}
439
440
/**
441
* Reconcile {@link _rootConfig} against {@link RootState.config}, firing
442
* {@link onDidChangeRootConfig} only when schema or values actually change.
443
*/
444
protected _syncRootConfigFromRootState(rootState: RootState): void {
445
const next = rootState.config;
446
const prev = this._rootConfig;
447
if (prev === next) {
448
return;
449
}
450
if (!next) {
451
this._rootConfig = undefined;
452
this._onDidChangeRootConfig.fire();
453
return;
454
}
455
if (prev && prev.schema === next.schema && equals(prev.values, next.values)) {
456
return;
457
}
458
this._rootConfig = next;
459
this._onDidChangeRootConfig.fire();
460
}
461
462
abstract resolveWorkspace(repositoryUri: URI): ISessionWorkspace | undefined;
463
464
/** Optional event fired when the underlying connection is lost; used to short-circuit `_waitForNewSession`. */
465
protected get onConnectionLost(): Event<void> { return Event.None; }
466
467
/** Maps a working-directory URI from the session summary to a local URI. Default identity; remote overrides to `toAgentHostUri`. */
468
protected mapWorkingDirectoryUri(uri: URI): URI { return uri; }
469
470
/** Maps a project URI from the session summary to a local URI. Default identity; remote overrides for `file:` paths. */
471
protected mapProjectUri(uri: URI): URI { return uri; }
472
473
// -- Session listing ------------------------------------------------------
474
475
getSessionTypes(_repositoryUri: URI): ISessionType[] {
476
return [...this.sessionTypes];
477
}
478
479
getSessions(): ISession[] {
480
this._ensureSessionCache();
481
const sessions: ISession[] = [...this._sessionCache.values()];
482
if (this._pendingSession) {
483
sessions.push(this._pendingSession);
484
}
485
return sessions;
486
}
487
488
getSessionByResource(resource: URI): ISession | undefined {
489
if (this._currentNewSession?.resource.toString() === resource.toString()) {
490
return this._currentNewSession;
491
}
492
493
if (this._pendingSession?.resource.toString() === resource.toString()) {
494
return this._pendingSession;
495
}
496
497
this._ensureSessionCache();
498
for (const cached of this._sessionCache.values()) {
499
if (cached.resource.toString() === resource.toString()) {
500
// Opening a session: subscribe to its AHP state so that
501
// `_meta` (e.g. lazy git state computed by the agent host)
502
// flows into the cached adapter.
503
this._ensureSessionStateSubscription(cached.sessionId);
504
return cached;
505
}
506
}
507
508
return undefined;
509
}
510
511
// -- Session lifecycle ----------------------------------------------------
512
513
createNewSession(workspaceUri: URI, sessionTypeId: string): ISession {
514
if (!workspaceUri) {
515
throw new Error('Workspace has no repository URI');
516
}
517
518
if (this._currentNewSession) {
519
this._clearNewSessionConfig(this._currentNewSession.sessionId);
520
}
521
this._currentNewSession = undefined;
522
this._selectedModelId = undefined;
523
this._currentNewSessionModelId = undefined;
524
this._currentNewSessionLoading = undefined;
525
this._currentNewSessionStatus = undefined;
526
527
const sessionType = this.sessionTypes.find(t => t.id === sessionTypeId);
528
if (!sessionType) {
529
throw new Error(this._noAgentsErrorMessage());
530
}
531
532
this._validateBeforeCreate(sessionType);
533
534
const workspace = this.resolveWorkspace(workspaceUri);
535
if (!workspace) {
536
throw new Error(`Cannot resolve workspace for URI: ${workspaceUri.toString()}`);
537
}
538
return this._createNewSessionForType(workspace, sessionType);
539
}
540
541
/** Subclass hook for additional pre-create checks (e.g. remote requires connection). */
542
protected _validateBeforeCreate(_sessionType: ISessionType): void { /* default: no-op */ }
543
544
/** Localized "no agents" error message. Subclasses can override. */
545
protected _noAgentsErrorMessage(): string {
546
return localize('noAgents', "Agent host has not advertised any agents yet.");
547
}
548
549
private _createNewSessionForType(workspace: ISessionWorkspace, sessionType: ISessionType): ISession {
550
const workspaceUri = workspace.repositories[0]?.uri;
551
if (!workspaceUri) {
552
throw new Error('Workspace has no repository URI');
553
}
554
555
const resourceScheme = this.resourceSchemeForProvider(sessionType.id);
556
const resource = URI.from({ scheme: resourceScheme, path: `/untitled-${generateUuid()}` });
557
const status = observableValue<SessionStatus>(this, SessionStatus.Untitled);
558
const title = observableValue(this, '');
559
const updatedAt = observableValue(this, new Date());
560
const changes = observableValue<readonly (IChatSessionFileChange | IChatSessionFileChange2)[]>(this, []);
561
const modelId = observableValue<string | undefined>(this, undefined);
562
const mode = observableValue<{ readonly id: string; readonly kind: string } | undefined>(this, undefined);
563
const isArchived = observableValue(this, false);
564
const isRead = observableValue(this, true);
565
const description = observableValue<IMarkdownString | undefined>(this, undefined);
566
const lastTurnEnd = observableValue<Date | undefined>(this, undefined);
567
const loading = observableValue(this, true);
568
const createdAt = new Date();
569
570
const mainChat: IChat = {
571
resource, createdAt, title, updatedAt, status,
572
changes, modelId, mode, isArchived, isRead, description, lastTurnEnd,
573
};
574
575
const authPending = this.authenticationPending;
576
const session: ISession = {
577
sessionId: `${this.id}:${resource.toString()}`,
578
resource,
579
providerId: this.id,
580
sessionType: sessionType.id,
581
icon: this.icon,
582
createdAt,
583
workspace: observableValue(this, workspace),
584
title,
585
updatedAt,
586
status,
587
changes,
588
modelId,
589
mode,
590
loading: derived(reader => loading.read(reader) || authPending.read(reader)),
591
isArchived,
592
isRead,
593
description,
594
lastTurnEnd,
595
gitHubInfo: observableValue(this, undefined),
596
mainChat,
597
chats: constObservable([mainChat]),
598
capabilities: { supportsMultipleChats: false },
599
};
600
this._currentNewSession = session;
601
this._currentNewSessionStatus = status;
602
this._currentNewSessionModelId = modelId;
603
this._currentNewSessionLoading = loading;
604
const agentProvider = sessionType.id;
605
this._newSessionWorkspaces.set(session.sessionId, workspaceUri);
606
this._newSessionAgentProviders.set(session.sessionId, agentProvider);
607
this._newSessionConfigs.set(session.sessionId, { schema: { type: 'object', properties: {} }, values: {} });
608
this._onDidChangeSessionConfig.fire(session.sessionId);
609
this._resolveSessionConfig(session.sessionId, agentProvider, workspaceUri, undefined);
610
return session;
611
}
612
613
// -- Dynamic session config ----------------------------------------------
614
615
getSessionConfig(sessionId: string): ResolveSessionConfigResult | undefined {
616
// New-session config wins (during pre-creation flow). Otherwise lazily
617
// subscribe to the session's state so the running picker can seed its
618
// schema/values from the AHP `SessionState.config` snapshot for sessions
619
// that weren't created in this window.
620
const newSessionConfig = this._newSessionConfigs.get(sessionId);
621
if (newSessionConfig) {
622
return newSessionConfig;
623
}
624
this._ensureSessionStateSubscription(sessionId);
625
return this._runningSessionConfigs.get(sessionId);
626
}
627
628
async setSessionConfigValue(sessionId: string, property: string, value: unknown): Promise<void> {
629
// New session (pre-creation): re-resolve the full config schema
630
const workingDirectory = this._newSessionWorkspaces.get(sessionId);
631
if (workingDirectory) {
632
const current = this._newSessionConfigs.get(sessionId)?.values ?? {};
633
this._newSessionConfigs.set(sessionId, { schema: { type: 'object', properties: {} }, values: { ...current, [property]: value } });
634
this._setNewSessionLoading(sessionId, true);
635
this._onDidChangeSessionConfig.fire(sessionId);
636
await this._resolveSessionConfig(sessionId, this._getAgentProviderForSession(sessionId), workingDirectory, { ...current, [property]: value });
637
return;
638
}
639
640
// Running session: dispatch SessionConfigChanged for sessionMutable properties
641
const runningConfig = this._runningSessionConfigs.get(sessionId);
642
const connection = this.connection;
643
if (!runningConfig || !connection) {
644
return;
645
}
646
const schema = runningConfig.schema.properties[property];
647
if (!schema?.sessionMutable) {
648
return;
649
}
650
651
// Update local cache optimistically
652
this._runningSessionConfigs.set(sessionId, {
653
...runningConfig,
654
values: { ...runningConfig.values, [property]: value },
655
});
656
this._onDidChangeSessionConfig.fire(sessionId);
657
658
// Dispatch to the agent host
659
const rawId = this._rawIdFromChatId(sessionId);
660
const cached = rawId ? this._sessionCache.get(rawId) : undefined;
661
if (cached && rawId) {
662
const action = { type: ActionType.SessionConfigChanged as const, session: AgentSession.uri(cached.agentProvider, rawId).toString(), config: { [property]: value } };
663
connection.dispatch(action);
664
}
665
}
666
667
async replaceSessionConfig(sessionId: string, values: Record<string, unknown>): Promise<void> {
668
const runningConfig = this._runningSessionConfigs.get(sessionId);
669
const connection = this.connection;
670
if (!runningConfig || !connection) {
671
return;
672
}
673
674
// Build the outgoing payload: for every known property, prefer the
675
// caller-supplied value if the property is user-editable
676
// (`sessionMutable: true` and not `readOnly`), otherwise force the
677
// current value through. This guarantees replace semantics never
678
// alter a non-editable property even if the caller included it.
679
const nextValues: Record<string, unknown> = {};
680
for (const [key, schema] of Object.entries(runningConfig.schema.properties)) {
681
const editable = schema.sessionMutable === true && schema.readOnly !== true;
682
if (editable) {
683
nextValues[key] = values[key];
684
} else if (Object.hasOwn(runningConfig.values, key)) {
685
nextValues[key] = runningConfig.values[key];
686
}
687
}
688
// Unknown keys from the caller are ignored (no schema entry).
689
690
// Skip the dispatch entirely when nothing meaningful changes.
691
if (equals(nextValues, runningConfig.values)) {
692
return;
693
}
694
695
// Update local cache optimistically (full replace).
696
this._runningSessionConfigs.set(sessionId, {
697
...runningConfig,
698
values: nextValues,
699
});
700
this._onDidChangeSessionConfig.fire(sessionId);
701
702
// Dispatch to the agent host with replace semantics.
703
const rawId = this._rawIdFromChatId(sessionId);
704
const cached = rawId ? this._sessionCache.get(rawId) : undefined;
705
if (cached && rawId) {
706
const action = {
707
type: ActionType.SessionConfigChanged as const,
708
session: AgentSession.uri(cached.agentProvider, rawId).toString(),
709
config: nextValues,
710
replace: true,
711
};
712
connection.dispatch(action);
713
}
714
}
715
716
async getSessionConfigCompletions(sessionId: string, property: string, query?: string) {
717
const workingDirectory = this._newSessionWorkspaces.get(sessionId);
718
const connection = this.connection;
719
if (!workingDirectory || !connection) {
720
return [];
721
}
722
const result = await connection.sessionConfigCompletions({
723
provider: this._getAgentProviderForSession(sessionId),
724
workingDirectory,
725
config: this._newSessionConfigs.get(sessionId)?.values,
726
property,
727
query,
728
});
729
return result.items;
730
}
731
732
getCreateSessionConfig(sessionId: string): Record<string, unknown> | undefined {
733
return this._newSessionConfigs.get(sessionId)?.values;
734
}
735
736
clearSessionConfig(sessionId: string): void {
737
this._clearNewSessionConfig(sessionId);
738
}
739
740
// -- Root (agent host) Config --------------------------------------------
741
742
getRootConfig(): RootConfigState | undefined {
743
return this._rootConfig;
744
}
745
746
async setRootConfigValue(property: string, value: unknown): Promise<void> {
747
const current = this._rootConfig;
748
const connection = this.connection;
749
if (!current || !connection) {
750
return;
751
}
752
if (!current.schema.properties[property]) {
753
return;
754
}
755
756
// Optimistically update local cache.
757
this._rootConfig = {
758
...current,
759
values: { ...current.values, [property]: value },
760
};
761
this._onDidChangeRootConfig.fire();
762
763
const action = {
764
type: ActionType.RootConfigChanged as const,
765
config: { [property]: value },
766
};
767
connection.dispatch(action);
768
}
769
770
async replaceRootConfig(values: Record<string, unknown>): Promise<void> {
771
const current = this._rootConfig;
772
const connection = this.connection;
773
if (!current || !connection) {
774
return;
775
}
776
777
// Filter to known properties so we don't dispatch values for keys the
778
// host didn't publish a schema for.
779
const nextValues: Record<string, unknown> = {};
780
for (const [key, value] of Object.entries(values)) {
781
if (current.schema.properties[key]) {
782
nextValues[key] = value;
783
}
784
}
785
786
if (equals(nextValues, current.values)) {
787
return;
788
}
789
790
this._rootConfig = { ...current, values: nextValues };
791
this._onDidChangeRootConfig.fire();
792
793
const action = {
794
type: ActionType.RootConfigChanged as const,
795
config: nextValues,
796
replace: true,
797
};
798
connection.dispatch(action);
799
}
800
801
// -- Model selection ------------------------------------------------------
802
803
setModel(sessionId: string, modelId: string): void {
804
if (this._currentNewSession?.sessionId === sessionId) {
805
this._selectedModelId = modelId;
806
this._currentNewSessionModelId?.set(modelId, undefined);
807
return;
808
}
809
810
const rawId = this._rawIdFromChatId(sessionId);
811
const cached = rawId ? this._sessionCache.get(rawId) : undefined;
812
const connection = this.connection;
813
if (cached && rawId && connection) {
814
cached.modelId.set(modelId, undefined);
815
this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] });
816
const resourceScheme = cached.resource.scheme;
817
const rawModelId = modelId.startsWith(`${resourceScheme}:`) ? modelId.substring(resourceScheme.length + 1) : modelId;
818
const model = cached.modelSelection?.id === rawModelId ? cached.modelSelection : { id: rawModelId };
819
const action = { type: ActionType.SessionModelChanged as const, session: AgentSession.uri(cached.agentProvider, rawId).toString(), model };
820
connection.dispatch(action);
821
}
822
}
823
824
// -- Session actions ------------------------------------------------------
825
826
async archiveSession(sessionId: string): Promise<void> {
827
const rawId = this._rawIdFromChatId(sessionId);
828
const cached = rawId ? this._sessionCache.get(rawId) : undefined;
829
if (cached && rawId) {
830
cached.isArchived.set(true, undefined);
831
this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] });
832
const connection = this.connection;
833
if (connection) {
834
const action = { type: ActionType.SessionIsArchivedChanged as const, session: AgentSession.uri(cached.agentProvider, rawId).toString(), isArchived: true };
835
connection.dispatch(action);
836
}
837
}
838
}
839
840
async unarchiveSession(sessionId: string): Promise<void> {
841
const rawId = this._rawIdFromChatId(sessionId);
842
const cached = rawId ? this._sessionCache.get(rawId) : undefined;
843
if (cached && rawId) {
844
cached.isArchived.set(false, undefined);
845
this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] });
846
const connection = this.connection;
847
if (connection) {
848
const action = { type: ActionType.SessionIsArchivedChanged as const, session: AgentSession.uri(cached.agentProvider, rawId).toString(), isArchived: false };
849
connection.dispatch(action);
850
}
851
}
852
}
853
854
async deleteSession(sessionId: string): Promise<void> {
855
const rawId = this._rawIdFromChatId(sessionId);
856
const cached = rawId ? this._sessionCache.get(rawId) : undefined;
857
const connection = this.connection;
858
if (cached && rawId && connection) {
859
await connection.disposeSession(AgentSession.uri(cached.agentProvider, rawId));
860
this._sessionCache.delete(rawId);
861
this._runningSessionConfigs.delete(sessionId);
862
this._onDidChangeSessions.fire({ added: [], removed: [cached], changed: [] });
863
}
864
}
865
866
async renameChat(sessionId: string, _chatUri: URI, title: string): Promise<void> {
867
const rawId = this._rawIdFromChatId(sessionId);
868
const cached = rawId ? this._sessionCache.get(rawId) : undefined;
869
const connection = this.connection;
870
if (cached && rawId && connection) {
871
cached.title.set(title, undefined);
872
this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] });
873
const action = { type: ActionType.SessionTitleChanged as const, session: AgentSession.uri(cached.agentProvider, rawId).toString(), title };
874
connection.dispatch(action);
875
}
876
}
877
878
async deleteChat(_sessionId: string, _chatUri: URI): Promise<void> {
879
// Agent host sessions don't support deleting individual chats
880
}
881
882
addChat(_sessionId: string): IChat {
883
throw new Error('Multiple chats per session is not supported for agent host sessions');
884
}
885
886
async sendRequest(_sessionId: string, _chatResource: URI, _options: ISendRequestOptions): Promise<ISession> {
887
throw new Error('Multiple chats per session is not supported for agent host sessions');
888
}
889
890
async sendAndCreateChat(chatId: string, options: ISendRequestOptions): Promise<ISession> {
891
const connection = this.connection;
892
if (!connection) {
893
throw new Error(this._notConnectedSendErrorMessage());
894
}
895
896
const session = this._currentNewSession;
897
if (!session || session.sessionId !== chatId) {
898
throw new Error(`Session '${chatId}' not found or not a new session`);
899
}
900
901
const { query, attachedContext } = options;
902
903
const sessionType = session.resource.scheme;
904
const contribution = this._chatSessionsService.getChatSessionContribution(sessionType);
905
906
const sendOptions: IChatSendRequestOptions = {
907
location: ChatAgentLocation.Chat,
908
userSelectedModelId: this._selectedModelId,
909
modeInfo: {
910
kind: ChatModeKind.Agent,
911
isBuiltin: true,
912
modeInstructions: undefined,
913
modeId: 'agent',
914
applyCodeBlockSuggestionId: undefined,
915
permissionLevel: undefined,
916
},
917
agentIdSilent: contribution?.type,
918
attachedContext,
919
agentHostSessionConfig: this.getCreateSessionConfig(chatId),
920
};
921
922
// Open chat widget — getOrCreateChatSession will wait for the session
923
// handler to become available via canResolveChatSession internally.
924
await this._chatSessionsService.getOrCreateChatSession(session.resource, CancellationToken.None);
925
const chatWidget = await this._chatWidgetService.openSession(session.resource, ChatViewPaneTarget);
926
if (!chatWidget) {
927
throw new Error(`[${this.id}] Failed to open chat widget`);
928
}
929
930
// Load session model and apply selected model
931
const modelRef = await this._chatService.acquireOrLoadSession(session.resource, ChatAgentLocation.Chat, CancellationToken.None);
932
if (modelRef) {
933
if (this._selectedModelId) {
934
const languageModel = this._languageModelsService.lookupLanguageModel(this._selectedModelId);
935
if (languageModel) {
936
modelRef.object.inputModel.setState({ selectedModel: { identifier: this._selectedModelId, metadata: languageModel } });
937
}
938
}
939
modelRef.dispose();
940
}
941
942
// Capture existing session keys before sending so we can detect the new
943
// backend session. Must be captured before sendRequest because the
944
// backend session may be created during the send and arrive via
945
// notification before sendRequest resolves.
946
this._ensureSessionCache();
947
const existingKeys = new Set(this._sessionCache.keys());
948
949
const result = await this._chatService.sendRequest(session.resource, query, sendOptions);
950
if (result.kind === 'rejected') {
951
throw new Error(`[${this.id}] sendRequest rejected: ${result.reason}`);
952
}
953
954
this._currentNewSessionStatus?.set(SessionStatus.InProgress, undefined);
955
const newSession = session;
956
this._pendingSession = newSession;
957
this._onDidChangeSessions.fire({ added: [newSession], removed: [], changed: [] });
958
959
this._selectedModelId = undefined;
960
this._currentNewSessionStatus = undefined;
961
this._currentNewSessionModelId = undefined;
962
this._currentNewSessionLoading = undefined;
963
964
try {
965
const committedSession = await this._waitForNewSession(existingKeys);
966
if (committedSession) {
967
this._preserveNewSessionConfig(chatId, committedSession.sessionId);
968
this._currentNewSession = undefined;
969
this._currentNewSessionModelId = undefined;
970
this._currentNewSessionLoading = undefined;
971
this._clearNewSessionConfig(chatId);
972
this._onDidReplaceSession.fire({ from: newSession, to: committedSession });
973
return committedSession;
974
}
975
} catch {
976
// Connection lost or timeout — clean up
977
} finally {
978
this._pendingSession = undefined;
979
}
980
981
this._currentNewSession = undefined;
982
this._currentNewSessionModelId = undefined;
983
this._currentNewSessionLoading = undefined;
984
this._clearNewSessionConfig(chatId);
985
return newSession;
986
}
987
988
/** Localized error message when sendAndCreateChat is invoked without a connection. Subclasses can override. */
989
protected _notConnectedSendErrorMessage(): string {
990
return localize('notConnectedSend', "Cannot send request: not connected to agent host.");
991
}
992
993
// -- Session config plumbing ---------------------------------------------
994
995
private async _resolveSessionConfig(sessionId: string, agentProvider: string, workingDirectory: URI, config: Record<string, unknown> | undefined): Promise<void> {
996
const connection = this.connection;
997
if (!connection) {
998
this._setNewSessionLoading(sessionId, false);
999
return;
1000
}
1001
const request = (this._newSessionConfigRequests.get(sessionId) ?? 0) + 1;
1002
this._newSessionConfigRequests.set(sessionId, request);
1003
try {
1004
const result = await connection.resolveSessionConfig({
1005
provider: agentProvider,
1006
workingDirectory,
1007
config,
1008
});
1009
if (this._newSessionConfigRequests.get(sessionId) !== request) {
1010
return;
1011
}
1012
this._newSessionConfigs.set(sessionId, result);
1013
this._setNewSessionLoading(sessionId, !isSessionConfigComplete(result));
1014
} catch {
1015
if (this._newSessionConfigRequests.get(sessionId) !== request) {
1016
return;
1017
}
1018
this._newSessionConfigs.delete(sessionId);
1019
this._setNewSessionLoading(sessionId, false);
1020
}
1021
this._onDidChangeSessionConfig.fire(sessionId);
1022
}
1023
1024
protected _clearNewSessionConfig(sessionId: string): void {
1025
this._newSessionWorkspaces.delete(sessionId);
1026
this._newSessionConfigs.delete(sessionId);
1027
this._newSessionAgentProviders.delete(sessionId);
1028
this._newSessionConfigRequests.delete(sessionId);
1029
}
1030
1031
/**
1032
* When a session transitions from untitled (new) to committed (running),
1033
* carry over the full resolved config (schema + values) so consumers like
1034
* the session-settings JSONC editor can round-trip non-mutable values
1035
* (`isolation`, `branch`, …) through a replace dispatch. Mutable-vs-readonly
1036
* behavior is still driven off the per-property `sessionMutable` flag.
1037
*/
1038
private _preserveNewSessionConfig(oldSessionId: string, newSessionId: string): void {
1039
const config = this._newSessionConfigs.get(oldSessionId);
1040
if (!config) {
1041
return;
1042
}
1043
if (Object.keys(config.schema.properties).length > 0) {
1044
this._runningSessionConfigs.set(newSessionId, {
1045
schema: { type: 'object', properties: { ...config.schema.properties } },
1046
values: { ...config.values },
1047
});
1048
}
1049
}
1050
1051
private _setNewSessionLoading(sessionId: string, loading: boolean): void {
1052
if (this._currentNewSession?.sessionId === sessionId) {
1053
this._currentNewSessionLoading?.set(loading, undefined);
1054
}
1055
}
1056
1057
protected _rawIdFromChatId(chatId: string): string | undefined {
1058
const prefix = `${this.id}:`;
1059
const resourceStr = chatId.startsWith(prefix) ? chatId.substring(prefix.length) : chatId;
1060
try {
1061
return URI.parse(resourceStr).path.substring(1) || undefined;
1062
} catch {
1063
return undefined;
1064
}
1065
}
1066
1067
private _getAgentProviderForSession(sessionId: string): string {
1068
const provider = this._newSessionAgentProviders.get(sessionId);
1069
if (!provider) {
1070
throw new Error(`No agent provider tracked for new session: ${sessionId}`);
1071
}
1072
return provider;
1073
}
1074
1075
// -- Lazy session-state subscription seeding -----------------------------
1076
1077
/**
1078
* Lazily acquire a session-state subscription for `sessionId` so that
1079
* `_runningSessionConfigs` is seeded from the AHP `SessionState.config`
1080
* snapshot. Safe to call repeatedly — no-op once a subscription exists.
1081
*
1082
* The subscription is reference-counted by {@link IAgentConnection.getSubscription},
1083
* so when the session handler is also subscribed (chat content open) this
1084
* shares the existing wire subscription rather than opening a new one.
1085
*/
1086
private _ensureSessionStateSubscription(sessionId: string): void {
1087
if (this._sessionStateSubscriptions.has(sessionId)) {
1088
return;
1089
}
1090
const connection = this.connection;
1091
if (!connection) {
1092
return;
1093
}
1094
const rawId = this._rawIdFromChatId(sessionId);
1095
if (!rawId) {
1096
return;
1097
}
1098
const cached = this._sessionCache.get(rawId);
1099
if (!cached) {
1100
return;
1101
}
1102
const sessionUri = AgentSession.uri(cached.agentProvider, rawId);
1103
const ref = connection.getSubscription(StateComponents.Session, sessionUri);
1104
const store = new DisposableStore();
1105
store.add(ref);
1106
store.add(ref.object.onDidChange(state => {
1107
this._applySessionStateUpdate(sessionId, state);
1108
}));
1109
this._sessionStateSubscriptions.set(sessionId, store);
1110
1111
const value = ref.object.value;
1112
if (value && !(value instanceof Error)) {
1113
this._applySessionStateUpdate(sessionId, value);
1114
}
1115
}
1116
1117
/**
1118
* Fan-out for AHP `SessionState` snapshots: keeps both the running
1119
* session config and the cached adapter's `_meta` (e.g. git state) in
1120
* sync.
1121
*/
1122
private _applySessionStateUpdate(sessionId: string, state: SessionState): void {
1123
this._seedRunningConfigFromState(sessionId, state);
1124
this._applySessionMetaFromState(sessionId, state);
1125
}
1126
1127
private _applySessionMetaFromState(sessionId: string, state: SessionState): void {
1128
const rawId = this._rawIdFromChatId(sessionId);
1129
if (!rawId) {
1130
return;
1131
}
1132
const cached = this._sessionCache.get(rawId);
1133
if (!cached) {
1134
return;
1135
}
1136
if (cached.setMeta(state._meta)) {
1137
this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] });
1138
}
1139
}
1140
1141
/**
1142
* Seed {@link _runningSessionConfigs} from the AHP `SessionState.config`
1143
* snapshot. Keeps the full schema + values (including non-mutable ones)
1144
* so consumers like the JSONC settings editor can round-trip all values
1145
* through a replace dispatch. No-op if structurally equal to avoid spurious
1146
* `onDidChangeSessionConfig` fires.
1147
*/
1148
private _seedRunningConfigFromState(sessionId: string, state: SessionState): void {
1149
const stateConfig = state.config;
1150
if (!stateConfig) {
1151
return;
1152
}
1153
if (Object.keys(stateConfig.schema.properties).length === 0) {
1154
return;
1155
}
1156
const seeded: ResolveSessionConfigResult = {
1157
schema: { type: 'object', properties: { ...stateConfig.schema.properties } },
1158
values: { ...stateConfig.values },
1159
};
1160
const existing = this._runningSessionConfigs.get(sessionId);
1161
if (existing && resolvedConfigsEqual(existing, seeded)) {
1162
return;
1163
}
1164
this._runningSessionConfigs.set(sessionId, seeded);
1165
this._onDidChangeSessionConfig.fire(sessionId);
1166
}
1167
1168
// -- Session cache management --------------------------------------------
1169
1170
protected _ensureSessionCache(): void {
1171
if (this._cacheInitialized) {
1172
return;
1173
}
1174
this._cacheInitialized = true;
1175
this._refreshSessions();
1176
}
1177
1178
protected async _refreshSessions(): Promise<void> {
1179
const connection = this.connection;
1180
if (!connection) {
1181
return;
1182
}
1183
try {
1184
const sessions = await connection.listSessions();
1185
const currentKeys = new Set<string>();
1186
const added: ISession[] = [];
1187
const changed: ISession[] = [];
1188
1189
for (const meta of sessions) {
1190
const rawId = AgentSession.id(meta.session);
1191
currentKeys.add(rawId);
1192
1193
const existing = this._sessionCache.get(rawId);
1194
if (existing) {
1195
if (existing.update(meta)) {
1196
changed.push(existing);
1197
}
1198
} else {
1199
const cached = this.createAdapter(meta);
1200
this._sessionCache.set(rawId, cached);
1201
added.push(cached);
1202
}
1203
}
1204
1205
const removed: ISession[] = [];
1206
for (const [key, cached] of this._sessionCache) {
1207
if (!currentKeys.has(key)) {
1208
this._sessionCache.delete(key);
1209
this._runningSessionConfigs.delete(cached.sessionId);
1210
removed.push(cached);
1211
}
1212
}
1213
1214
if (added.length > 0 || removed.length > 0 || changed.length > 0) {
1215
this._onDidChangeSessions.fire({ added, removed, changed });
1216
}
1217
} catch {
1218
// Connection may not be ready yet
1219
}
1220
}
1221
1222
private async _waitForNewSession(existingKeys: Set<string>): Promise<ISession | undefined> {
1223
await this._refreshSessions();
1224
for (const [key, cached] of this._sessionCache) {
1225
if (!existingKeys.has(key)) {
1226
return cached;
1227
}
1228
}
1229
1230
const waitDisposables = new DisposableStore();
1231
try {
1232
const sessionPromise = new Promise<ISession | undefined>((resolve) => {
1233
waitDisposables.add(this._onDidChangeSessions.event(e => {
1234
const newSession = e.added.find(s => {
1235
const rawId = s.resource.path.substring(1);
1236
return !existingKeys.has(rawId);
1237
});
1238
if (newSession) {
1239
resolve(newSession);
1240
}
1241
}));
1242
waitDisposables.add(this.onConnectionLost(() => resolve(undefined)));
1243
});
1244
return await raceTimeout(sessionPromise, 30_000);
1245
} finally {
1246
waitDisposables.dispose();
1247
}
1248
}
1249
1250
// -- AHP notification / action handlers ----------------------------------
1251
1252
/**
1253
* Wire AHP notification and action listeners on the given connection.
1254
* Subclasses call this from their constructor (local) or `setConnection`
1255
* (remote), passing a store that bounds the listeners' lifetime.
1256
*/
1257
protected _attachConnectionListeners(connection: IAgentConnection, store: DisposableStore): void {
1258
store.add(connection.onDidNotification(n => {
1259
if (n.type === NotificationType.SessionAdded) {
1260
this._handleSessionAdded(n.summary);
1261
} else if (n.type === NotificationType.SessionRemoved) {
1262
this._handleSessionRemoved(n.session);
1263
} else if (n.type === NotificationType.SessionSummaryChanged) {
1264
this._handleSessionSummaryChanged(n.session, n.changes);
1265
}
1266
}));
1267
1268
store.add(connection.onDidAction(e => {
1269
if (e.action.type === ActionType.SessionTurnComplete && isSessionAction(e.action)) {
1270
this._refreshSessions();
1271
} else if (e.action.type === ActionType.SessionTitleChanged && isSessionAction(e.action)) {
1272
this._handleTitleChanged(e.action.session, e.action.title);
1273
} else if (e.action.type === ActionType.SessionModelChanged && isSessionAction(e.action)) {
1274
this._handleModelChanged(e.action.session, e.action.model);
1275
} else if (e.action.type === ActionType.SessionIsReadChanged && isSessionAction(e.action)) {
1276
this._handleIsReadChanged(e.action.session, e.action.isRead);
1277
} else if (e.action.type === ActionType.SessionIsArchivedChanged && isSessionAction(e.action)) {
1278
this._handleIsArchivedChanged(e.action.session, e.action.isArchived);
1279
} else if (e.action.type === ActionType.SessionConfigChanged && isSessionAction(e.action)) {
1280
this._handleConfigChanged(e.action.session, e.action.config, e.action.replace === true);
1281
} else if (e.action.type === ActionType.SessionDiffsChanged && isSessionAction(e.action)) {
1282
this._handleDiffsChanged(e.action.session, e.action.diffs);
1283
}
1284
}));
1285
}
1286
1287
private _handleSessionAdded(summary: SessionSummary): void {
1288
const sessionUri = URI.parse(summary.resource);
1289
const rawId = AgentSession.id(sessionUri);
1290
if (this._sessionCache.has(rawId)) {
1291
return;
1292
}
1293
1294
const workingDir = typeof summary.workingDirectory === 'string'
1295
? this.mapWorkingDirectoryUri(URI.parse(summary.workingDirectory))
1296
: undefined;
1297
const meta: IAgentSessionMetadata = {
1298
session: sessionUri,
1299
startTime: summary.createdAt,
1300
modifiedTime: summary.modifiedAt,
1301
summary: summary.title,
1302
activity: summary.activity,
1303
status: summary.status,
1304
...(summary.project ? { project: { uri: this.mapProjectUri(URI.parse(summary.project.uri)), displayName: summary.project.displayName } } : {}),
1305
model: summary.model,
1306
workingDirectory: workingDir,
1307
isRead: !!(summary.status & ProtocolSessionStatus.IsRead),
1308
isArchived: !!(summary.status & ProtocolSessionStatus.IsArchived),
1309
};
1310
const cached = this.createAdapter(meta);
1311
this._sessionCache.set(rawId, cached);
1312
this._onDidChangeSessions.fire({ added: [cached], removed: [], changed: [] });
1313
}
1314
1315
private _handleSessionRemoved(session: URI | string): void {
1316
const rawId = AgentSession.id(session);
1317
const cached = this._sessionCache.get(rawId);
1318
if (cached) {
1319
this._sessionCache.delete(rawId);
1320
this._runningSessionConfigs.delete(cached.sessionId);
1321
this._sessionStateSubscriptions.deleteAndDispose(cached.sessionId);
1322
this._onDidChangeSessions.fire({ added: [], removed: [cached], changed: [] });
1323
}
1324
}
1325
1326
private _handleTitleChanged(session: string, title: string): void {
1327
const rawId = AgentSession.id(session);
1328
const cached = this._sessionCache.get(rawId);
1329
if (cached) {
1330
cached.title.set(title, undefined);
1331
this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] });
1332
}
1333
}
1334
1335
private _handleModelChanged(session: string, model: ModelSelection): void {
1336
const rawId = AgentSession.id(session);
1337
const cached = this._sessionCache.get(rawId);
1338
if (cached) {
1339
cached.modelSelection = model;
1340
}
1341
const modelId = cached ? `${cached.resource.scheme}:${model.id}` : undefined;
1342
if (cached && cached.modelId.get() !== modelId) {
1343
cached.modelId.set(modelId, undefined);
1344
this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] });
1345
}
1346
}
1347
1348
private _handleIsReadChanged(session: string, isRead: boolean): void {
1349
const rawId = AgentSession.id(session);
1350
const cached = this._sessionCache.get(rawId);
1351
if (cached) {
1352
cached.isRead.set(isRead, undefined);
1353
this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] });
1354
}
1355
}
1356
1357
private _handleIsArchivedChanged(session: string, isArchived: boolean): void {
1358
const rawId = AgentSession.id(session);
1359
const cached = this._sessionCache.get(rawId);
1360
if (cached) {
1361
cached.isArchived.set(isArchived, undefined);
1362
this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] });
1363
}
1364
}
1365
1366
private _handleDiffsChanged(session: string, diffs: FileEdit[]): void {
1367
const rawId = AgentSession.id(session);
1368
const cached = this._sessionCache.get(rawId);
1369
if (cached) {
1370
cached.changes.set(diffsToChanges(diffs, this._diffUriMapper()), undefined);
1371
this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] });
1372
}
1373
}
1374
1375
private _handleSessionSummaryChanged(session: string, changes: Partial<SessionSummary>): void {
1376
const rawId = AgentSession.id(session);
1377
const cached = this._sessionCache.get(rawId);
1378
if (!cached) {
1379
return;
1380
}
1381
1382
let didChange = false;
1383
1384
if (changes.status !== undefined) {
1385
const uiStatus = mapProtocolStatus(changes.status);
1386
if (uiStatus !== cached.status.get()) {
1387
cached.status.set(uiStatus, undefined);
1388
didChange = true;
1389
}
1390
1391
const isRead = !!(changes.status & ProtocolSessionStatus.IsRead);
1392
if (isRead !== cached.isRead.get()) {
1393
cached.isRead.set(isRead, undefined);
1394
didChange = true;
1395
}
1396
1397
const isArchived = !!(changes.status & ProtocolSessionStatus.IsArchived);
1398
if (isArchived !== cached.isArchived.get()) {
1399
cached.isArchived.set(isArchived, undefined);
1400
didChange = true;
1401
}
1402
}
1403
1404
if (changes.title !== undefined && changes.title !== cached.title.get()) {
1405
cached.title.set(changes.title, undefined);
1406
didChange = true;
1407
}
1408
1409
if (changes.diffs !== undefined) {
1410
const mapUri = this._diffUriMapper();
1411
if (!diffsEqual(cached.changes.get(), changes.diffs, mapUri)) {
1412
cached.changes.set(diffsToChanges(changes.diffs, mapUri), undefined);
1413
didChange = true;
1414
}
1415
}
1416
1417
if (Object.prototype.hasOwnProperty.call(changes, 'activity') && cached.setActivity(changes.activity)) {
1418
didChange = true;
1419
}
1420
1421
if (didChange) {
1422
this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] });
1423
}
1424
}
1425
1426
private _handleConfigChanged(session: string, config: Record<string, unknown>, replace: boolean): void {
1427
const rawId = AgentSession.id(session);
1428
const cached = this._sessionCache.get(rawId);
1429
if (!cached) {
1430
return;
1431
}
1432
const sessionId = cached.sessionId;
1433
const existing = this._runningSessionConfigs.get(sessionId);
1434
if (existing) {
1435
this._runningSessionConfigs.set(sessionId, {
1436
...existing,
1437
values: replace ? { ...config } : { ...existing.values, ...config },
1438
});
1439
} else {
1440
// Session was restored (e.g. after reload) — create a minimal
1441
// config entry from the changed values so the picker can render.
1442
// `replace` vs merge is moot here (no existing values to merge with).
1443
this._runningSessionConfigs.set(sessionId, {
1444
schema: { type: 'object', properties: buildMutableConfigSchema(config) },
1445
values: config,
1446
});
1447
}
1448
this._onDidChangeSessionConfig.fire(sessionId);
1449
}
1450
1451
/**
1452
* Optional URI mapper used when applying diff changes. Subclasses
1453
* override to translate remote diff URIs into agent-host URIs.
1454
*/
1455
protected _diffUriMapper(): ((uri: URI) => URI) | undefined { return undefined; }
1456
}
1457
1458