Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts
4780 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 { ThrottledDelayer } from '../../../../../base/common/async.js';
7
import { CancellationToken } from '../../../../../base/common/cancellation.js';
8
import { Codicon } from '../../../../../base/common/codicons.js';
9
import { Emitter, Event } from '../../../../../base/common/event.js';
10
import { IMarkdownString } from '../../../../../base/common/htmlContent.js';
11
import { Disposable } from '../../../../../base/common/lifecycle.js';
12
import { ResourceMap } from '../../../../../base/common/map.js';
13
import { MarshalledId } from '../../../../../base/common/marshallingIds.js';
14
import { ThemeIcon } from '../../../../../base/common/themables.js';
15
import { URI, UriComponents } from '../../../../../base/common/uri.js';
16
import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';
17
import { ILogService } from '../../../../../platform/log/common/log.js';
18
import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js';
19
import { ILifecycleService } from '../../../../services/lifecycle/common/lifecycle.js';
20
import { ChatSessionStatus as AgentSessionStatus, IChatSessionFileChange, IChatSessionItem, IChatSessionsExtensionPoint, IChatSessionsService, isSessionInProgressStatus } from '../../common/chatSessionsService.js';
21
import { AgentSessionProviders, getAgentSessionProviderIcon, getAgentSessionProviderName } from './agentSessions.js';
22
23
//#region Interfaces, Types
24
25
export { ChatSessionStatus as AgentSessionStatus } from '../../common/chatSessionsService.js';
26
export { isSessionInProgressStatus } from '../../common/chatSessionsService.js';
27
28
export interface IAgentSessionsModel {
29
30
readonly onWillResolve: Event<void>;
31
readonly onDidResolve: Event<void>;
32
33
readonly onDidChangeSessions: Event<void>;
34
35
readonly sessions: IAgentSession[];
36
getSession(resource: URI): IAgentSession | undefined;
37
38
resolve(provider: string | string[] | undefined): Promise<void>;
39
}
40
41
interface IAgentSessionData extends Omit<IChatSessionItem, 'archived' | 'iconPath'> {
42
43
readonly providerType: string;
44
readonly providerLabel: string;
45
46
readonly resource: URI;
47
48
readonly status: AgentSessionStatus;
49
50
readonly tooltip?: string | IMarkdownString;
51
52
readonly label: string;
53
readonly description?: string | IMarkdownString;
54
readonly badge?: string | IMarkdownString;
55
readonly icon: ThemeIcon;
56
57
readonly timing: IChatSessionItem['timing'] & {
58
readonly inProgressTime?: number;
59
readonly finishedOrFailedTime?: number;
60
};
61
62
readonly changes?: IChatSessionItem['changes'];
63
}
64
65
/**
66
* Checks if the provided changes object represents valid diff information.
67
*/
68
export function hasValidDiff(changes: IAgentSession['changes']): boolean {
69
if (!changes) {
70
return false;
71
}
72
73
if (changes instanceof Array) {
74
return changes.length > 0;
75
}
76
77
return changes.files > 0 || changes.insertions > 0 || changes.deletions > 0;
78
}
79
80
/**
81
* Gets a summary of agent session changes, converting from array format to object format if needed.
82
*/
83
export function getAgentChangesSummary(changes: IAgentSession['changes']) {
84
if (!changes) {
85
return;
86
}
87
88
if (!(changes instanceof Array)) {
89
return changes;
90
}
91
92
let insertions = 0;
93
let deletions = 0;
94
for (const change of changes) {
95
insertions += change.insertions;
96
deletions += change.deletions;
97
}
98
99
return { files: changes.length, insertions, deletions };
100
}
101
102
export interface IAgentSession extends IAgentSessionData {
103
isArchived(): boolean;
104
setArchived(archived: boolean): void;
105
106
isRead(): boolean;
107
setRead(read: boolean): void;
108
}
109
110
interface IInternalAgentSessionData extends IAgentSessionData {
111
112
/**
113
* The `archived` property is provided by the session provider
114
* and will be used as the initial value if the user has not
115
* changed the archived state for the session previously. It
116
* is kept internal to not expose it publicly. Use `isArchived()`
117
* and `setArchived()` methods instead.
118
*/
119
readonly archived: boolean | undefined;
120
}
121
122
interface IInternalAgentSession extends IAgentSession, IInternalAgentSessionData { }
123
124
export function isLocalAgentSessionItem(session: IAgentSession): boolean {
125
return session.providerType === AgentSessionProviders.Local;
126
}
127
128
export function isAgentSession(obj: unknown): obj is IAgentSession {
129
const session = obj as IAgentSession | undefined;
130
131
return URI.isUri(session?.resource) && typeof session.setArchived === 'function' && typeof session.setRead === 'function';
132
}
133
134
export function isAgentSessionsModel(obj: unknown): obj is IAgentSessionsModel {
135
const sessionsModel = obj as IAgentSessionsModel | undefined;
136
137
return Array.isArray(sessionsModel?.sessions) && typeof sessionsModel?.getSession === 'function';
138
}
139
140
interface IAgentSessionState {
141
readonly archived: boolean;
142
readonly read: number /* last date turned read */;
143
}
144
145
export const enum AgentSessionSection {
146
InProgress = 'inProgress',
147
Today = 'today',
148
Yesterday = 'yesterday',
149
Week = 'week',
150
Older = 'older',
151
Archived = 'archived',
152
}
153
154
export interface IAgentSessionSection {
155
readonly section: AgentSessionSection;
156
readonly label: string;
157
readonly sessions: IAgentSession[];
158
}
159
160
export function isAgentSessionSection(obj: unknown): obj is IAgentSessionSection {
161
const candidate = obj as IAgentSessionSection;
162
163
return typeof candidate.section === 'string' && Array.isArray(candidate.sessions);
164
}
165
166
export interface IMarshalledAgentSessionContext {
167
readonly $mid: MarshalledId.AgentSessionContext;
168
readonly session: IAgentSession;
169
}
170
171
export function isMarshalledAgentSessionContext(thing: unknown): thing is IMarshalledAgentSessionContext {
172
if (typeof thing === 'object' && thing !== null) {
173
const candidate = thing as IMarshalledAgentSessionContext;
174
return candidate.$mid === MarshalledId.AgentSessionContext && typeof candidate.session === 'object' && candidate.session !== null;
175
}
176
177
return false;
178
}
179
180
//#endregion
181
182
export class AgentSessionsModel extends Disposable implements IAgentSessionsModel {
183
184
private readonly _onWillResolve = this._register(new Emitter<void>());
185
readonly onWillResolve = this._onWillResolve.event;
186
187
private readonly _onDidResolve = this._register(new Emitter<void>());
188
readonly onDidResolve = this._onDidResolve.event;
189
190
private readonly _onDidChangeSessions = this._register(new Emitter<void>());
191
readonly onDidChangeSessions = this._onDidChangeSessions.event;
192
193
private _sessions: ResourceMap<IInternalAgentSession>;
194
get sessions(): IAgentSession[] { return Array.from(this._sessions.values()); }
195
196
private readonly resolver = this._register(new ThrottledDelayer<void>(300));
197
private readonly providersToResolve = new Set<string | undefined>();
198
199
private readonly mapSessionToState = new ResourceMap<{
200
status: AgentSessionStatus;
201
202
inProgressTime?: number;
203
finishedOrFailedTime?: number;
204
}>();
205
206
private readonly cache: AgentSessionsCache;
207
208
constructor(
209
@IChatSessionsService private readonly chatSessionsService: IChatSessionsService,
210
@ILifecycleService private readonly lifecycleService: ILifecycleService,
211
@IInstantiationService private readonly instantiationService: IInstantiationService,
212
@IStorageService private readonly storageService: IStorageService,
213
@ILogService private readonly logService: ILogService,
214
) {
215
super();
216
217
this._sessions = new ResourceMap<IInternalAgentSession>();
218
219
this.cache = this.instantiationService.createInstance(AgentSessionsCache);
220
for (const data of this.cache.loadCachedSessions()) {
221
const session = this.toAgentSession(data);
222
this._sessions.set(session.resource, session);
223
}
224
this.sessionStates = this.cache.loadSessionStates();
225
226
this.registerListeners();
227
}
228
229
private registerListeners(): void {
230
231
// Sessions changes
232
this._register(this.chatSessionsService.onDidChangeItemsProviders(({ chatSessionType: provider }) => this.resolve(provider)));
233
this._register(this.chatSessionsService.onDidChangeAvailability(() => this.resolve(undefined)));
234
this._register(this.chatSessionsService.onDidChangeSessionItems(provider => this.resolve(provider)));
235
236
// State
237
this._register(this.storageService.onWillSaveState(() => {
238
this.cache.saveCachedSessions(Array.from(this._sessions.values()));
239
this.cache.saveSessionStates(this.sessionStates);
240
}));
241
}
242
243
getSession(resource: URI): IAgentSession | undefined {
244
return this._sessions.get(resource);
245
}
246
247
async resolve(provider: string | string[] | undefined): Promise<void> {
248
if (Array.isArray(provider)) {
249
for (const p of provider) {
250
this.providersToResolve.add(p);
251
}
252
} else {
253
this.providersToResolve.add(provider);
254
}
255
256
return this.resolver.trigger(async token => {
257
if (token.isCancellationRequested || this.lifecycleService.willShutdown) {
258
return;
259
}
260
261
try {
262
this._onWillResolve.fire();
263
return await this.doResolve(token);
264
} finally {
265
this._onDidResolve.fire();
266
}
267
});
268
}
269
270
private async doResolve(token: CancellationToken): Promise<void> {
271
const providersToResolve = Array.from(this.providersToResolve);
272
this.providersToResolve.clear();
273
274
this.logService.trace(`[agent sessions] Resolving agent sessions for providers: ${providersToResolve.map(p => p ?? 'all').join(', ')}`);
275
276
const mapSessionContributionToType = new Map<string, IChatSessionsExtensionPoint>();
277
for (const contribution of this.chatSessionsService.getAllChatSessionContributions()) {
278
mapSessionContributionToType.set(contribution.type, contribution);
279
}
280
281
const resolvedProviders = new Set<string>();
282
const sessions = new ResourceMap<IInternalAgentSession>();
283
for (const provider of this.chatSessionsService.getAllChatSessionItemProviders()) {
284
if (!providersToResolve.includes(undefined) && !providersToResolve.includes(provider.chatSessionType)) {
285
continue; // skip: not considered for resolving
286
}
287
288
let providerSessions: IChatSessionItem[];
289
try {
290
providerSessions = await provider.provideChatSessionItems(token);
291
this.logService.trace(`[agent sessions] Resolved ${providerSessions.length} agent sessions for provider ${provider.chatSessionType}`);
292
} catch (error) {
293
this.logService.error(`Failed to resolve sessions for provider ${provider.chatSessionType}`, error);
294
continue; // skip: failed to resolve sessions for provider
295
}
296
297
resolvedProviders.add(provider.chatSessionType);
298
299
if (token.isCancellationRequested) {
300
return;
301
}
302
303
for (const session of providerSessions) {
304
305
// Icon + Label
306
let icon: ThemeIcon;
307
let providerLabel: string;
308
switch ((provider.chatSessionType)) {
309
case AgentSessionProviders.Local:
310
providerLabel = getAgentSessionProviderName(AgentSessionProviders.Local);
311
icon = getAgentSessionProviderIcon(AgentSessionProviders.Local);
312
break;
313
case AgentSessionProviders.Background:
314
providerLabel = getAgentSessionProviderName(AgentSessionProviders.Background);
315
icon = getAgentSessionProviderIcon(AgentSessionProviders.Background);
316
break;
317
case AgentSessionProviders.Cloud:
318
providerLabel = getAgentSessionProviderName(AgentSessionProviders.Cloud);
319
icon = getAgentSessionProviderIcon(AgentSessionProviders.Cloud);
320
break;
321
default: {
322
providerLabel = mapSessionContributionToType.get(provider.chatSessionType)?.name ?? provider.chatSessionType;
323
icon = session.iconPath ?? Codicon.terminal;
324
}
325
}
326
327
// State + Timings
328
// TODO@bpasero this is a workaround for not having precise timing info in sessions
329
// yet: we only track the time when a transition changes because then we can say with
330
// confidence that the time is correct by assuming `Date.now()`. A better approach would
331
// be to get all this information directly from the session.
332
const status = session.status ?? AgentSessionStatus.Completed;
333
const state = this.mapSessionToState.get(session.resource);
334
let inProgressTime = state?.inProgressTime;
335
let finishedOrFailedTime = state?.finishedOrFailedTime;
336
337
// No previous state, just add it
338
if (!state) {
339
this.mapSessionToState.set(session.resource, {
340
status,
341
inProgressTime: isSessionInProgressStatus(status) ? Date.now() : undefined, // this is not accurate but best effort
342
});
343
}
344
345
// State changed, update it
346
else if (status !== state.status) {
347
inProgressTime = isSessionInProgressStatus(status) ? Date.now() : state.inProgressTime;
348
finishedOrFailedTime = !isSessionInProgressStatus(status) ? Date.now() : state.finishedOrFailedTime;
349
350
this.mapSessionToState.set(session.resource, {
351
status,
352
inProgressTime,
353
finishedOrFailedTime
354
});
355
}
356
357
const changes = session.changes;
358
const normalizedChanges = changes && !(changes instanceof Array)
359
? { files: changes.files, insertions: changes.insertions, deletions: changes.deletions }
360
: changes;
361
362
// Times: it is important to always provide a start and end time to track
363
// unread/read state for example.
364
// If somehow the provider does not provide any, fallback to last known
365
let startTime = session.timing.startTime;
366
let endTime = session.timing.endTime;
367
if (!startTime || !endTime) {
368
const existing = this._sessions.get(session.resource);
369
if (!startTime && existing?.timing.startTime) {
370
startTime = existing.timing.startTime;
371
}
372
373
if (!endTime && existing?.timing.endTime) {
374
endTime = existing.timing.endTime;
375
}
376
}
377
378
sessions.set(session.resource, this.toAgentSession({
379
providerType: provider.chatSessionType,
380
providerLabel,
381
resource: session.resource,
382
label: session.label,
383
description: session.description,
384
icon,
385
badge: session.badge,
386
tooltip: session.tooltip,
387
status,
388
archived: session.archived,
389
timing: { startTime, endTime, inProgressTime, finishedOrFailedTime },
390
changes: normalizedChanges,
391
}));
392
}
393
}
394
395
for (const [, session] of this._sessions) {
396
if (!resolvedProviders.has(session.providerType)) {
397
sessions.set(session.resource, session); // fill in existing sessions for providers that did not resolve
398
}
399
}
400
401
this._sessions = sessions;
402
this.logService.trace(`[agent sessions] Total resolved agent sessions:`, Array.from(this._sessions.values()));
403
404
for (const [resource] of this.mapSessionToState) {
405
if (!sessions.has(resource)) {
406
this.mapSessionToState.delete(resource); // clean up tracking for removed sessions
407
}
408
}
409
410
for (const [resource] of this.sessionStates) {
411
if (!sessions.has(resource)) {
412
this.sessionStates.delete(resource); // clean up states for removed sessions
413
}
414
}
415
416
this._onDidChangeSessions.fire();
417
}
418
419
private toAgentSession(data: IInternalAgentSessionData): IInternalAgentSession {
420
return {
421
...data,
422
isArchived: () => this.isArchived(data),
423
setArchived: (archived: boolean) => this.setArchived(data, archived),
424
isRead: () => this.isRead(data),
425
setRead: (read: boolean) => this.setRead(data, read),
426
};
427
}
428
429
//#region States
430
431
// In order to reduce the amount of unread sessions a user will
432
// see after updating to 1.107, we specify a fixed date that a
433
// session needs to be created after to be considered unread unless
434
// the user has explicitly marked it as read.
435
private static readonly READ_STATE_INITIAL_DATE = Date.UTC(2025, 11 /* December */, 8);
436
437
private readonly sessionStates: ResourceMap<IAgentSessionState>;
438
439
private isArchived(session: IInternalAgentSessionData): boolean {
440
return this.sessionStates.get(session.resource)?.archived ?? Boolean(session.archived);
441
}
442
443
private setArchived(session: IInternalAgentSessionData, archived: boolean): void {
444
if (archived === this.isArchived(session)) {
445
return; // no change
446
}
447
448
const state = this.sessionStates.get(session.resource) ?? { archived: false, read: 0 };
449
this.sessionStates.set(session.resource, { ...state, archived });
450
451
this._onDidChangeSessions.fire();
452
}
453
454
private isRead(session: IInternalAgentSessionData): boolean {
455
const readDate = this.sessionStates.get(session.resource)?.read;
456
457
return (readDate ?? AgentSessionsModel.READ_STATE_INITIAL_DATE) >= (session.timing.endTime ?? session.timing.startTime);
458
}
459
460
private setRead(session: IInternalAgentSessionData, read: boolean): void {
461
if (read === this.isRead(session)) {
462
return; // no change
463
}
464
465
const state = this.sessionStates.get(session.resource) ?? { archived: false, read: 0 };
466
this.sessionStates.set(session.resource, { ...state, read: read ? Date.now() : 0 });
467
468
this._onDidChangeSessions.fire();
469
}
470
471
//#endregion
472
}
473
474
//#region Sessions Cache
475
476
interface ISerializedAgentSession extends Omit<IAgentSessionData, 'iconPath' | 'resource' | 'icon'> {
477
478
readonly providerType: string;
479
readonly providerLabel: string;
480
481
readonly resource: UriComponents /* old shape */ | string /* new shape that is more compact */;
482
483
readonly status: AgentSessionStatus;
484
485
readonly tooltip?: string | IMarkdownString;
486
487
readonly label: string;
488
readonly description?: string | IMarkdownString;
489
readonly badge?: string | IMarkdownString;
490
readonly icon: string;
491
492
readonly archived: boolean | undefined;
493
494
readonly timing: {
495
readonly startTime: number;
496
readonly endTime?: number;
497
};
498
499
readonly changes?: readonly IChatSessionFileChange[] | {
500
readonly files: number;
501
readonly insertions: number;
502
readonly deletions: number;
503
};
504
}
505
506
interface ISerializedAgentSessionState extends IAgentSessionState {
507
readonly resource: UriComponents /* old shape */ | string /* new shape that is more compact */;
508
}
509
510
class AgentSessionsCache {
511
512
private static readonly SESSIONS_STORAGE_KEY = 'agentSessions.model.cache';
513
private static readonly STATE_STORAGE_KEY = 'agentSessions.state.cache';
514
515
constructor(
516
@IStorageService private readonly storageService: IStorageService
517
) { }
518
519
//#region Sessions
520
521
saveCachedSessions(sessions: IInternalAgentSessionData[]): void {
522
const serialized: ISerializedAgentSession[] = sessions.map(session => ({
523
providerType: session.providerType,
524
providerLabel: session.providerLabel,
525
526
resource: session.resource.toString(),
527
528
icon: session.icon.id,
529
label: session.label,
530
description: session.description,
531
badge: session.badge,
532
tooltip: session.tooltip,
533
534
status: session.status,
535
archived: session.archived,
536
537
timing: {
538
startTime: session.timing.startTime,
539
endTime: session.timing.endTime,
540
},
541
542
changes: session.changes,
543
} satisfies ISerializedAgentSession));
544
545
this.storageService.store(AgentSessionsCache.SESSIONS_STORAGE_KEY, JSON.stringify(serialized), StorageScope.WORKSPACE, StorageTarget.MACHINE);
546
}
547
548
loadCachedSessions(): IInternalAgentSessionData[] {
549
const sessionsCache = this.storageService.get(AgentSessionsCache.SESSIONS_STORAGE_KEY, StorageScope.WORKSPACE);
550
if (!sessionsCache) {
551
return [];
552
}
553
554
try {
555
const cached = JSON.parse(sessionsCache) as ISerializedAgentSession[];
556
return cached.map(session => ({
557
providerType: session.providerType,
558
providerLabel: session.providerLabel,
559
560
resource: typeof session.resource === 'string' ? URI.parse(session.resource) : URI.revive(session.resource),
561
562
icon: ThemeIcon.fromId(session.icon),
563
label: session.label,
564
description: session.description,
565
badge: session.badge,
566
tooltip: session.tooltip,
567
568
status: session.status,
569
archived: session.archived,
570
571
timing: {
572
startTime: session.timing.startTime,
573
endTime: session.timing.endTime,
574
},
575
576
changes: Array.isArray(session.changes) ? session.changes.map((change: IChatSessionFileChange) => ({
577
modifiedUri: URI.revive(change.modifiedUri),
578
originalUri: change.originalUri ? URI.revive(change.originalUri) : undefined,
579
insertions: change.insertions,
580
deletions: change.deletions,
581
})) : session.changes,
582
}));
583
} catch {
584
return []; // invalid data in storage, fallback to empty sessions list
585
}
586
}
587
588
//#endregion
589
590
//#region States
591
592
saveSessionStates(states: ResourceMap<IAgentSessionState>): void {
593
const serialized: ISerializedAgentSessionState[] = Array.from(states.entries()).map(([resource, state]) => ({
594
resource: resource.toString(),
595
archived: state.archived,
596
read: state.read
597
}));
598
599
this.storageService.store(AgentSessionsCache.STATE_STORAGE_KEY, JSON.stringify(serialized), StorageScope.WORKSPACE, StorageTarget.MACHINE);
600
}
601
602
loadSessionStates(): ResourceMap<IAgentSessionState> {
603
const states = new ResourceMap<IAgentSessionState>();
604
605
const statesCache = this.storageService.get(AgentSessionsCache.STATE_STORAGE_KEY, StorageScope.WORKSPACE);
606
if (!statesCache) {
607
return states;
608
}
609
610
try {
611
const cached = JSON.parse(statesCache) as ISerializedAgentSessionState[];
612
613
for (const entry of cached) {
614
states.set(typeof entry.resource === 'string' ? URI.parse(entry.resource) : URI.revive(entry.resource), {
615
archived: entry.archived,
616
read: entry.read
617
});
618
}
619
} catch {
620
// invalid data in storage, fallback to empty states
621
}
622
623
return states;
624
}
625
626
//#endregion
627
}
628
629
//#endregion
630
631