microsoft/vscode · #334695
Fix Copilot Sessions Provider to Resolve Changes Summary Correctly
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));