microsoft/vscode · #334695

Fix Copilot Sessions Provider to Resolve Changes Summary Correctly

benibenj · merged Sep 5, 20262 files · 62 + / 17
src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts27 + / 16
@@ -24,7 +24,7 @@ import { AgentSessionProviders, AgentSessionTarget } from '../../../../../workbe import { IChatService, IChatSendRequestOptions } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { IChatResponseModel } from '../../../../../workbench/contrib/chat/common/model/chatModel.js'; import { ChatSessionStatus, IChatSessionsService, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, SessionType } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js';-import { ChatModelSource, ISession, IChat, ISessionGitRepository, ISessionFolder, ISessionWorkspace, ISideChatSelection, SessionStatus, GITHUB_REMOTE_FILE_SCHEME, IGitHubInfo, ISessionType, ISessionWorkspaceBrowseAction, ISessionFileChange, sessionFileChangesEqual, gitHubInfoEqual, sessionWorkspaceEqual, toSessionId, SESSION_WORKSPACE_GROUP_LOCAL, SESSION_WORKSPACE_GROUP_GITHUB, ISessionChangeset, IChatCheckpoints, ChatInteractivity, SessionTypeAuthRequirement } from '../../../../services/sessions/common/session.js';+import { ChatModelSource, ISession, IChat, ISessionGitRepository, ISessionFolder, ISessionWorkspace, ISideChatSelection, SessionStatus, GITHUB_REMOTE_FILE_SCHEME, IGitHubInfo, ISessionType, ISessionWorkspaceBrowseAction, ISessionFileChange, sessionFileChangesEqual, gitHubInfoEqual, sessionWorkspaceEqual, toSessionId, SESSION_WORKSPACE_GROUP_LOCAL, SESSION_WORKSPACE_GROUP_GITHUB, ISessionChangeset, IChatCheckpoints, ChatInteractivity, SessionTypeAuthRequirement, ISessionChangesSummary } from '../../../../services/sessions/common/session.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind, ChatPermissionLevel, isChatPermissionLevel } from '../../../../../workbench/contrib/chat/common/constants.js'; import { basename, dirname, isEqual, isEqualOrParent } from '../../../../../base/common/resources.js'; import { IDeleteChatOptions, ISendRequestOptions, ISessionChangeEvent, ISessionModelPickerOptions, ISessionModelsSnapshot, ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js';@@ -99,6 +99,8 @@ export interface ICopilotChatSession { 	readonly updatedAt: IObservable<Date>; 	/** Current session status. */ 	readonly status: IObservable<SessionStatus>;+	/** Summary of file changes produced by the session. */+	readonly changesSummary?: IObservable<ISessionChangesSummary | undefined>; 	/** File changes produced by the session. */ 	readonly changes: IObservable<readonly ISessionFileChange[]>; 	/** Currently selected model identifier. */@@ -182,6 +184,10 @@ function isNewSession(session: ICopilotChatSession): session is NewSession { 	return session instanceof CopilotCLISession || session instanceof RemoteNewSession; } +function isChangesSummary(changes: IAgentSession['changes']): changes is { readonly files: number; readonly insertions: number; readonly deletions: number } {+	return !!changes && !Array.isArray(changes);+}+ /**  * Builds an {@link IChat} snapshot from an {@link ICopilotChatSession}. Used to  * seed the chat's own `mainChat` observable.@@ -946,6 +952,9 @@ class AgentSessionAdapter implements ICopilotChatSession { 	private readonly _changes: ReturnType<typeof observableValue<readonly ISessionFileChange[]>>; 	readonly changes: IObservable<readonly ISessionFileChange[]>; +	private readonly _changesSummary: ReturnType<typeof observableValueOpts<ISessionChangesSummary | undefined>>;+	readonly changesSummary: IObservable<ISessionChangesSummary | undefined>;+ 	private readonly _checkpoints: ReturnType<typeof observableValueOpts<IChatCheckpoints | undefined>>; 	readonly checkpoints: IObservable<IChatCheckpoints | undefined>; @@ -1067,6 +1076,9 @@ class AgentSessionAdapter implements ICopilotChatSession { 		this._changes = observableValueOpts<readonly ISessionFileChange[]>({ owner: this, equalsFn: sessionFileChangesEqual }, this._extractChanges(session)); 		this.changes = this._changes; +		this._changesSummary = observableValueOpts<ISessionChangesSummary | undefined>({ owner: this, equalsFn: structuralEquals }, this._extractChangesSummary(session));+		this.changesSummary = this._changesSummary;+ 		this._checkpoints = observableValueOpts<IChatCheckpoints | undefined>({ owner: this, equalsFn: structuralEquals }, this._extractCheckpoints(session)); 		this.checkpoints = this._checkpoints; @@ -1123,6 +1135,7 @@ class AgentSessionAdapter implements ICopilotChatSession { 			changed = setIfChanged(this._updatedAt, new Date(updatedTime), tx, dateEquals) || changed; 			changed = setIfChanged(this._status, toSessionStatus(session.status), tx) || changed; 			changed = setIfChanged(this._changes, this._extractChanges(session), tx, sessionFileChangesEqual) || changed;+			changed = setIfChanged(this._changesSummary, this._extractChangesSummary(session), tx, structuralEquals) || changed; 			changed = setIfChanged(this._checkpoints, this._extractCheckpoints(session), tx, structuralEquals) || changed; 			changed = setIfChanged(this._isArchived, session.isArchived(), tx) || changed; 			changed = setIfChanged(this._isRead, session.isRead(), tx) || changed;@@ -1272,22 +1285,18 @@ class AgentSessionAdapter implements ICopilotChatSession { 	}  	private _extractChanges(session: IAgentSession): readonly ISessionFileChange[] {-		if (!session.changes) {-			return [];-		}-		if (Array.isArray(session.changes)) {-			return session.changes as ISessionFileChange[];-		}-		// Summary object — create a synthetic entry for total insertions/deletions-		const summary = session.changes as { readonly files: number; readonly insertions: number; readonly deletions: number };-		if (summary.insertions > 0 || summary.deletions > 0) {-			return [{-				modifiedUri: URI.parse('summary://changes'),-				insertions: summary.insertions,-				deletions: summary.deletions,-			}];+		return session.changes && !isChangesSummary(session.changes) ? session.changes : [];+	}++	private _extractChangesSummary(session: IAgentSession): ISessionChangesSummary | undefined {+		if (!isChangesSummary(session.changes)) {+			return undefined; 		}-		return [];+		return {+			files: session.changes.files,+			additions: session.changes.insertions,+			deletions: session.changes.deletions,+		}; 	}  	private _extractCheckpoints(session: IAgentSession): IChatCheckpoints | undefined {@@ -3357,6 +3366,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions 			updatedAt: chatsObs.map((chats, reader) => this._latestDate(chats, c => c.updatedAt.read(reader))!), 			status: chatsObs.map((chats, reader) => this._aggregateStatus(chats, reader)), 			changesets: this._createChangesets(primaryChat.sessionType, primaryChat.workspace, chatsObs),+			changesSummary: primaryChat.changesSummary, 			changes: primaryChat.changes, 			modelId: primaryChat.modelId, 			mode: primaryChat.mode,@@ -3399,6 +3409,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions 			updatedAt: chat.updatedAt, 			status: chat.status, 			changesets,+			changesSummary: chat.changesSummary, 			changes: chat.changes, 			modelId: chat.modelId, 			mode: chat.mode,
src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts35 + / 1
@@ -36,7 +36,7 @@ import { IChatResponseModel } from '../../../../../../workbench/contrib/chat/com import { IChatAgentData } from '../../../../../../workbench/contrib/chat/common/participants/chatAgents.js'; import { IGitRepository, IGitService } from '../../../../../../workbench/contrib/git/common/gitService.js'; import { ISessionChangeEvent } from '../../../../../services/sessions/common/sessionsProvider.js';-import { ChatModelSource, GITHUB_REMOTE_FILE_SCHEME, IChat, ISession, ISessionWorkspace, SESSION_WORKSPACE_GROUP_GITHUB, SESSION_WORKSPACE_GROUP_LOCAL, SessionStatus } from '../../../../../services/sessions/common/session.js';+import { ChatModelSource, GITHUB_REMOTE_FILE_SCHEME, IChat, ISession, ISessionChangesSummary, ISessionFileChange, ISessionWorkspace, SESSION_WORKSPACE_GROUP_GITHUB, SESSION_WORKSPACE_GROUP_LOCAL, SessionStatus } from '../../../../../services/sessions/common/session.js'; import { CloudSandboxEnabledSettingId, type ICloudSandboxCreateSessionRequest } from '../../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; import { RemoteAgentHostsEnabledSettingId } from '../../../../../../platform/agentHost/common/remoteAgentHostService.js'; import { CloudSandboxAgentHostContribution, type ICloudSandboxProvisionedSession } from '../../../remoteAgentHost/browser/cloudSandboxAgentHostContribution.js';@@ -80,6 +80,7 @@ function createMockAgentSession(resource: URI, opts?: { 	read?: boolean; 	createdAt?: number; 	status?: ChatSessionStatus;+	changes?: IAgentSession['changes']; 	metadata?: Record<string, unknown>; 	onSetRead?: () => void; }): IAgentSession {@@ -94,6 +95,7 @@ function createMockAgentSession(resource: URI, opts?: { 		override readonly status = opts?.status ?? ChatSessionStatus.Completed; 		override readonly icon = Codicon.copilot; 		override readonly timing = { created: opts?.createdAt ?? Date.now(), lastRequestStarted: undefined, lastRequestEnded: undefined };+		override readonly changes = opts?.changes; 		override readonly metadata = opts?.metadata ?? { repositoryPath: '/test/repo' }; 		override isArchived(): boolean { return archived; } 		override setArchived(value: boolean): void { archived = value; }@@ -740,6 +742,38 @@ suite('CopilotChatSessionsProvider', () => { 		assert.strictEqual(sessions.length, 2); 	}); +	test('adapts and atomically refreshes aggregate change metadata without synthetic file changes', () => {+		const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session' });+		model.addSession(createMockAgentSession(resource, {+			changes: { files: 2, insertions: 12, deletions: 4 },+		}));++		const provider = createProvider(disposables, model);+		const session = provider.getSessions()[0];+		const observed: { readonly changes: readonly ISessionFileChange[]; readonly changesSummary: ISessionChangesSummary | undefined }[] = [];+		disposables.add(autorun(reader => {+			observed.push({+				changes: session.changes.read(reader),+				changesSummary: session.changesSummary?.read(reader),+			});+		}));++		model.replaceSession(createMockAgentSession(resource, {+			changes: { files: 3, insertions: 20, deletions: 6 },+		}));++		assert.deepStrictEqual(observed, [+			{+				changes: [],+				changesSummary: { files: 2, additions: 12, deletions: 4 },+			},+			{+				changes: [],+				changesSummary: { files: 3, additions: 20, deletions: 6 },+			},+		]);+	});+ 	test('getSessions does not emit session changes while reading the initial cache', () => { 		const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session' }); 		model.addSession(createMockAgentSession(resource));