Path: blob/main/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts
4780 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 { ThrottledDelayer } from '../../../../../base/common/async.js';6import { CancellationToken } from '../../../../../base/common/cancellation.js';7import { Codicon } from '../../../../../base/common/codicons.js';8import { Emitter, Event } from '../../../../../base/common/event.js';9import { IMarkdownString } from '../../../../../base/common/htmlContent.js';10import { Disposable } from '../../../../../base/common/lifecycle.js';11import { ResourceMap } from '../../../../../base/common/map.js';12import { MarshalledId } from '../../../../../base/common/marshallingIds.js';13import { ThemeIcon } from '../../../../../base/common/themables.js';14import { URI, UriComponents } from '../../../../../base/common/uri.js';15import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';16import { ILogService } from '../../../../../platform/log/common/log.js';17import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js';18import { ILifecycleService } from '../../../../services/lifecycle/common/lifecycle.js';19import { ChatSessionStatus as AgentSessionStatus, IChatSessionFileChange, IChatSessionItem, IChatSessionsExtensionPoint, IChatSessionsService, isSessionInProgressStatus } from '../../common/chatSessionsService.js';20import { AgentSessionProviders, getAgentSessionProviderIcon, getAgentSessionProviderName } from './agentSessions.js';2122//#region Interfaces, Types2324export { ChatSessionStatus as AgentSessionStatus } from '../../common/chatSessionsService.js';25export { isSessionInProgressStatus } from '../../common/chatSessionsService.js';2627export interface IAgentSessionsModel {2829readonly onWillResolve: Event<void>;30readonly onDidResolve: Event<void>;3132readonly onDidChangeSessions: Event<void>;3334readonly sessions: IAgentSession[];35getSession(resource: URI): IAgentSession | undefined;3637resolve(provider: string | string[] | undefined): Promise<void>;38}3940interface IAgentSessionData extends Omit<IChatSessionItem, 'archived' | 'iconPath'> {4142readonly providerType: string;43readonly providerLabel: string;4445readonly resource: URI;4647readonly status: AgentSessionStatus;4849readonly tooltip?: string | IMarkdownString;5051readonly label: string;52readonly description?: string | IMarkdownString;53readonly badge?: string | IMarkdownString;54readonly icon: ThemeIcon;5556readonly timing: IChatSessionItem['timing'] & {57readonly inProgressTime?: number;58readonly finishedOrFailedTime?: number;59};6061readonly changes?: IChatSessionItem['changes'];62}6364/**65* Checks if the provided changes object represents valid diff information.66*/67export function hasValidDiff(changes: IAgentSession['changes']): boolean {68if (!changes) {69return false;70}7172if (changes instanceof Array) {73return changes.length > 0;74}7576return changes.files > 0 || changes.insertions > 0 || changes.deletions > 0;77}7879/**80* Gets a summary of agent session changes, converting from array format to object format if needed.81*/82export function getAgentChangesSummary(changes: IAgentSession['changes']) {83if (!changes) {84return;85}8687if (!(changes instanceof Array)) {88return changes;89}9091let insertions = 0;92let deletions = 0;93for (const change of changes) {94insertions += change.insertions;95deletions += change.deletions;96}9798return { files: changes.length, insertions, deletions };99}100101export interface IAgentSession extends IAgentSessionData {102isArchived(): boolean;103setArchived(archived: boolean): void;104105isRead(): boolean;106setRead(read: boolean): void;107}108109interface IInternalAgentSessionData extends IAgentSessionData {110111/**112* The `archived` property is provided by the session provider113* and will be used as the initial value if the user has not114* changed the archived state for the session previously. It115* is kept internal to not expose it publicly. Use `isArchived()`116* and `setArchived()` methods instead.117*/118readonly archived: boolean | undefined;119}120121interface IInternalAgentSession extends IAgentSession, IInternalAgentSessionData { }122123export function isLocalAgentSessionItem(session: IAgentSession): boolean {124return session.providerType === AgentSessionProviders.Local;125}126127export function isAgentSession(obj: unknown): obj is IAgentSession {128const session = obj as IAgentSession | undefined;129130return URI.isUri(session?.resource) && typeof session.setArchived === 'function' && typeof session.setRead === 'function';131}132133export function isAgentSessionsModel(obj: unknown): obj is IAgentSessionsModel {134const sessionsModel = obj as IAgentSessionsModel | undefined;135136return Array.isArray(sessionsModel?.sessions) && typeof sessionsModel?.getSession === 'function';137}138139interface IAgentSessionState {140readonly archived: boolean;141readonly read: number /* last date turned read */;142}143144export const enum AgentSessionSection {145InProgress = 'inProgress',146Today = 'today',147Yesterday = 'yesterday',148Week = 'week',149Older = 'older',150Archived = 'archived',151}152153export interface IAgentSessionSection {154readonly section: AgentSessionSection;155readonly label: string;156readonly sessions: IAgentSession[];157}158159export function isAgentSessionSection(obj: unknown): obj is IAgentSessionSection {160const candidate = obj as IAgentSessionSection;161162return typeof candidate.section === 'string' && Array.isArray(candidate.sessions);163}164165export interface IMarshalledAgentSessionContext {166readonly $mid: MarshalledId.AgentSessionContext;167readonly session: IAgentSession;168}169170export function isMarshalledAgentSessionContext(thing: unknown): thing is IMarshalledAgentSessionContext {171if (typeof thing === 'object' && thing !== null) {172const candidate = thing as IMarshalledAgentSessionContext;173return candidate.$mid === MarshalledId.AgentSessionContext && typeof candidate.session === 'object' && candidate.session !== null;174}175176return false;177}178179//#endregion180181export class AgentSessionsModel extends Disposable implements IAgentSessionsModel {182183private readonly _onWillResolve = this._register(new Emitter<void>());184readonly onWillResolve = this._onWillResolve.event;185186private readonly _onDidResolve = this._register(new Emitter<void>());187readonly onDidResolve = this._onDidResolve.event;188189private readonly _onDidChangeSessions = this._register(new Emitter<void>());190readonly onDidChangeSessions = this._onDidChangeSessions.event;191192private _sessions: ResourceMap<IInternalAgentSession>;193get sessions(): IAgentSession[] { return Array.from(this._sessions.values()); }194195private readonly resolver = this._register(new ThrottledDelayer<void>(300));196private readonly providersToResolve = new Set<string | undefined>();197198private readonly mapSessionToState = new ResourceMap<{199status: AgentSessionStatus;200201inProgressTime?: number;202finishedOrFailedTime?: number;203}>();204205private readonly cache: AgentSessionsCache;206207constructor(208@IChatSessionsService private readonly chatSessionsService: IChatSessionsService,209@ILifecycleService private readonly lifecycleService: ILifecycleService,210@IInstantiationService private readonly instantiationService: IInstantiationService,211@IStorageService private readonly storageService: IStorageService,212@ILogService private readonly logService: ILogService,213) {214super();215216this._sessions = new ResourceMap<IInternalAgentSession>();217218this.cache = this.instantiationService.createInstance(AgentSessionsCache);219for (const data of this.cache.loadCachedSessions()) {220const session = this.toAgentSession(data);221this._sessions.set(session.resource, session);222}223this.sessionStates = this.cache.loadSessionStates();224225this.registerListeners();226}227228private registerListeners(): void {229230// Sessions changes231this._register(this.chatSessionsService.onDidChangeItemsProviders(({ chatSessionType: provider }) => this.resolve(provider)));232this._register(this.chatSessionsService.onDidChangeAvailability(() => this.resolve(undefined)));233this._register(this.chatSessionsService.onDidChangeSessionItems(provider => this.resolve(provider)));234235// State236this._register(this.storageService.onWillSaveState(() => {237this.cache.saveCachedSessions(Array.from(this._sessions.values()));238this.cache.saveSessionStates(this.sessionStates);239}));240}241242getSession(resource: URI): IAgentSession | undefined {243return this._sessions.get(resource);244}245246async resolve(provider: string | string[] | undefined): Promise<void> {247if (Array.isArray(provider)) {248for (const p of provider) {249this.providersToResolve.add(p);250}251} else {252this.providersToResolve.add(provider);253}254255return this.resolver.trigger(async token => {256if (token.isCancellationRequested || this.lifecycleService.willShutdown) {257return;258}259260try {261this._onWillResolve.fire();262return await this.doResolve(token);263} finally {264this._onDidResolve.fire();265}266});267}268269private async doResolve(token: CancellationToken): Promise<void> {270const providersToResolve = Array.from(this.providersToResolve);271this.providersToResolve.clear();272273this.logService.trace(`[agent sessions] Resolving agent sessions for providers: ${providersToResolve.map(p => p ?? 'all').join(', ')}`);274275const mapSessionContributionToType = new Map<string, IChatSessionsExtensionPoint>();276for (const contribution of this.chatSessionsService.getAllChatSessionContributions()) {277mapSessionContributionToType.set(contribution.type, contribution);278}279280const resolvedProviders = new Set<string>();281const sessions = new ResourceMap<IInternalAgentSession>();282for (const provider of this.chatSessionsService.getAllChatSessionItemProviders()) {283if (!providersToResolve.includes(undefined) && !providersToResolve.includes(provider.chatSessionType)) {284continue; // skip: not considered for resolving285}286287let providerSessions: IChatSessionItem[];288try {289providerSessions = await provider.provideChatSessionItems(token);290this.logService.trace(`[agent sessions] Resolved ${providerSessions.length} agent sessions for provider ${provider.chatSessionType}`);291} catch (error) {292this.logService.error(`Failed to resolve sessions for provider ${provider.chatSessionType}`, error);293continue; // skip: failed to resolve sessions for provider294}295296resolvedProviders.add(provider.chatSessionType);297298if (token.isCancellationRequested) {299return;300}301302for (const session of providerSessions) {303304// Icon + Label305let icon: ThemeIcon;306let providerLabel: string;307switch ((provider.chatSessionType)) {308case AgentSessionProviders.Local:309providerLabel = getAgentSessionProviderName(AgentSessionProviders.Local);310icon = getAgentSessionProviderIcon(AgentSessionProviders.Local);311break;312case AgentSessionProviders.Background:313providerLabel = getAgentSessionProviderName(AgentSessionProviders.Background);314icon = getAgentSessionProviderIcon(AgentSessionProviders.Background);315break;316case AgentSessionProviders.Cloud:317providerLabel = getAgentSessionProviderName(AgentSessionProviders.Cloud);318icon = getAgentSessionProviderIcon(AgentSessionProviders.Cloud);319break;320default: {321providerLabel = mapSessionContributionToType.get(provider.chatSessionType)?.name ?? provider.chatSessionType;322icon = session.iconPath ?? Codicon.terminal;323}324}325326// State + Timings327// TODO@bpasero this is a workaround for not having precise timing info in sessions328// yet: we only track the time when a transition changes because then we can say with329// confidence that the time is correct by assuming `Date.now()`. A better approach would330// be to get all this information directly from the session.331const status = session.status ?? AgentSessionStatus.Completed;332const state = this.mapSessionToState.get(session.resource);333let inProgressTime = state?.inProgressTime;334let finishedOrFailedTime = state?.finishedOrFailedTime;335336// No previous state, just add it337if (!state) {338this.mapSessionToState.set(session.resource, {339status,340inProgressTime: isSessionInProgressStatus(status) ? Date.now() : undefined, // this is not accurate but best effort341});342}343344// State changed, update it345else if (status !== state.status) {346inProgressTime = isSessionInProgressStatus(status) ? Date.now() : state.inProgressTime;347finishedOrFailedTime = !isSessionInProgressStatus(status) ? Date.now() : state.finishedOrFailedTime;348349this.mapSessionToState.set(session.resource, {350status,351inProgressTime,352finishedOrFailedTime353});354}355356const changes = session.changes;357const normalizedChanges = changes && !(changes instanceof Array)358? { files: changes.files, insertions: changes.insertions, deletions: changes.deletions }359: changes;360361// Times: it is important to always provide a start and end time to track362// unread/read state for example.363// If somehow the provider does not provide any, fallback to last known364let startTime = session.timing.startTime;365let endTime = session.timing.endTime;366if (!startTime || !endTime) {367const existing = this._sessions.get(session.resource);368if (!startTime && existing?.timing.startTime) {369startTime = existing.timing.startTime;370}371372if (!endTime && existing?.timing.endTime) {373endTime = existing.timing.endTime;374}375}376377sessions.set(session.resource, this.toAgentSession({378providerType: provider.chatSessionType,379providerLabel,380resource: session.resource,381label: session.label,382description: session.description,383icon,384badge: session.badge,385tooltip: session.tooltip,386status,387archived: session.archived,388timing: { startTime, endTime, inProgressTime, finishedOrFailedTime },389changes: normalizedChanges,390}));391}392}393394for (const [, session] of this._sessions) {395if (!resolvedProviders.has(session.providerType)) {396sessions.set(session.resource, session); // fill in existing sessions for providers that did not resolve397}398}399400this._sessions = sessions;401this.logService.trace(`[agent sessions] Total resolved agent sessions:`, Array.from(this._sessions.values()));402403for (const [resource] of this.mapSessionToState) {404if (!sessions.has(resource)) {405this.mapSessionToState.delete(resource); // clean up tracking for removed sessions406}407}408409for (const [resource] of this.sessionStates) {410if (!sessions.has(resource)) {411this.sessionStates.delete(resource); // clean up states for removed sessions412}413}414415this._onDidChangeSessions.fire();416}417418private toAgentSession(data: IInternalAgentSessionData): IInternalAgentSession {419return {420...data,421isArchived: () => this.isArchived(data),422setArchived: (archived: boolean) => this.setArchived(data, archived),423isRead: () => this.isRead(data),424setRead: (read: boolean) => this.setRead(data, read),425};426}427428//#region States429430// In order to reduce the amount of unread sessions a user will431// see after updating to 1.107, we specify a fixed date that a432// session needs to be created after to be considered unread unless433// the user has explicitly marked it as read.434private static readonly READ_STATE_INITIAL_DATE = Date.UTC(2025, 11 /* December */, 8);435436private readonly sessionStates: ResourceMap<IAgentSessionState>;437438private isArchived(session: IInternalAgentSessionData): boolean {439return this.sessionStates.get(session.resource)?.archived ?? Boolean(session.archived);440}441442private setArchived(session: IInternalAgentSessionData, archived: boolean): void {443if (archived === this.isArchived(session)) {444return; // no change445}446447const state = this.sessionStates.get(session.resource) ?? { archived: false, read: 0 };448this.sessionStates.set(session.resource, { ...state, archived });449450this._onDidChangeSessions.fire();451}452453private isRead(session: IInternalAgentSessionData): boolean {454const readDate = this.sessionStates.get(session.resource)?.read;455456return (readDate ?? AgentSessionsModel.READ_STATE_INITIAL_DATE) >= (session.timing.endTime ?? session.timing.startTime);457}458459private setRead(session: IInternalAgentSessionData, read: boolean): void {460if (read === this.isRead(session)) {461return; // no change462}463464const state = this.sessionStates.get(session.resource) ?? { archived: false, read: 0 };465this.sessionStates.set(session.resource, { ...state, read: read ? Date.now() : 0 });466467this._onDidChangeSessions.fire();468}469470//#endregion471}472473//#region Sessions Cache474475interface ISerializedAgentSession extends Omit<IAgentSessionData, 'iconPath' | 'resource' | 'icon'> {476477readonly providerType: string;478readonly providerLabel: string;479480readonly resource: UriComponents /* old shape */ | string /* new shape that is more compact */;481482readonly status: AgentSessionStatus;483484readonly tooltip?: string | IMarkdownString;485486readonly label: string;487readonly description?: string | IMarkdownString;488readonly badge?: string | IMarkdownString;489readonly icon: string;490491readonly archived: boolean | undefined;492493readonly timing: {494readonly startTime: number;495readonly endTime?: number;496};497498readonly changes?: readonly IChatSessionFileChange[] | {499readonly files: number;500readonly insertions: number;501readonly deletions: number;502};503}504505interface ISerializedAgentSessionState extends IAgentSessionState {506readonly resource: UriComponents /* old shape */ | string /* new shape that is more compact */;507}508509class AgentSessionsCache {510511private static readonly SESSIONS_STORAGE_KEY = 'agentSessions.model.cache';512private static readonly STATE_STORAGE_KEY = 'agentSessions.state.cache';513514constructor(515@IStorageService private readonly storageService: IStorageService516) { }517518//#region Sessions519520saveCachedSessions(sessions: IInternalAgentSessionData[]): void {521const serialized: ISerializedAgentSession[] = sessions.map(session => ({522providerType: session.providerType,523providerLabel: session.providerLabel,524525resource: session.resource.toString(),526527icon: session.icon.id,528label: session.label,529description: session.description,530badge: session.badge,531tooltip: session.tooltip,532533status: session.status,534archived: session.archived,535536timing: {537startTime: session.timing.startTime,538endTime: session.timing.endTime,539},540541changes: session.changes,542} satisfies ISerializedAgentSession));543544this.storageService.store(AgentSessionsCache.SESSIONS_STORAGE_KEY, JSON.stringify(serialized), StorageScope.WORKSPACE, StorageTarget.MACHINE);545}546547loadCachedSessions(): IInternalAgentSessionData[] {548const sessionsCache = this.storageService.get(AgentSessionsCache.SESSIONS_STORAGE_KEY, StorageScope.WORKSPACE);549if (!sessionsCache) {550return [];551}552553try {554const cached = JSON.parse(sessionsCache) as ISerializedAgentSession[];555return cached.map(session => ({556providerType: session.providerType,557providerLabel: session.providerLabel,558559resource: typeof session.resource === 'string' ? URI.parse(session.resource) : URI.revive(session.resource),560561icon: ThemeIcon.fromId(session.icon),562label: session.label,563description: session.description,564badge: session.badge,565tooltip: session.tooltip,566567status: session.status,568archived: session.archived,569570timing: {571startTime: session.timing.startTime,572endTime: session.timing.endTime,573},574575changes: Array.isArray(session.changes) ? session.changes.map((change: IChatSessionFileChange) => ({576modifiedUri: URI.revive(change.modifiedUri),577originalUri: change.originalUri ? URI.revive(change.originalUri) : undefined,578insertions: change.insertions,579deletions: change.deletions,580})) : session.changes,581}));582} catch {583return []; // invalid data in storage, fallback to empty sessions list584}585}586587//#endregion588589//#region States590591saveSessionStates(states: ResourceMap<IAgentSessionState>): void {592const serialized: ISerializedAgentSessionState[] = Array.from(states.entries()).map(([resource, state]) => ({593resource: resource.toString(),594archived: state.archived,595read: state.read596}));597598this.storageService.store(AgentSessionsCache.STATE_STORAGE_KEY, JSON.stringify(serialized), StorageScope.WORKSPACE, StorageTarget.MACHINE);599}600601loadSessionStates(): ResourceMap<IAgentSessionState> {602const states = new ResourceMap<IAgentSessionState>();603604const statesCache = this.storageService.get(AgentSessionsCache.STATE_STORAGE_KEY, StorageScope.WORKSPACE);605if (!statesCache) {606return states;607}608609try {610const cached = JSON.parse(statesCache) as ISerializedAgentSessionState[];611612for (const entry of cached) {613states.set(typeof entry.resource === 'string' ? URI.parse(entry.resource) : URI.revive(entry.resource), {614archived: entry.archived,615read: entry.read616});617}618} catch {619// invalid data in storage, fallback to empty states620}621622return states;623}624625//#endregion626}627628//#endregion629630631