diff --git a/src/vs/platform/agentHost/common/fileEditDiff.ts b/src/vs/platform/agentHost/common/fileEditDiff.ts new file mode 100644 index 00000000000..a0fa733e73b --- /dev/null +++ b/src/vs/platform/agentHost/common/fileEditDiff.ts @@ -0,0 +1,68 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../base/common/uri.js'; +import { isEqual } from '../../../base/common/resources.js'; +import type { FileEdit } from './state/protocol/state.js'; +import { FileEditKind } from './state/sessionState.js'; + +/** + * A {@link FileEdit} decoded into parsed URIs and a resolved {@link FileEditKind}. + * + * The create/delete/rename/edit detection and the "primary resource" rule + * (after-URI for create/edit/rename, before-URI for delete) are subtle and + * were historically duplicated across several adapters. {@link normalizeFileEdit} + * centralizes them so every consumer derives the same shape from a protocol + * {@link FileEdit}. + */ +export interface INormalizedFileEdit { + /** The kind of file operation. */ + readonly kind: FileEditKind; + /** Primary file URI: after-URI for create/edit/rename, before-URI for delete. */ + readonly resource: URI; + /** The before-state file URI, when present (absent for creates). */ + readonly beforeUri?: URI; + /** The after-state file URI, when present (absent for deletes). */ + readonly afterUri?: URI; + /** URI from which the before-content can be read (absent for creates). */ + readonly beforeContentUri?: URI; + /** URI from which the after-content can be read (absent for deletes). */ + readonly afterContentUri?: URI; +} + +/** + * Decodes a protocol {@link FileEdit} into parsed URIs and a resolved + * {@link FileEditKind}. Returns `undefined` when the edit carries no usable + * file URI (neither `before` nor `after`). + */ +export function normalizeFileEdit(edit: FileEdit): INormalizedFileEdit | undefined { + const beforeUri = edit.before ? URI.parse(edit.before.uri) : undefined; + const afterUri = edit.after ? URI.parse(edit.after.uri) : undefined; + + const resource = afterUri ?? beforeUri; + if (!resource) { + return undefined; + } + + let kind: FileEditKind; + if (!beforeUri && afterUri) { + kind = FileEditKind.Create; + } else if (beforeUri && !afterUri) { + kind = FileEditKind.Delete; + } else if (beforeUri && afterUri && !isEqual(beforeUri, afterUri)) { + kind = FileEditKind.Rename; + } else { + kind = FileEditKind.Edit; + } + + return { + kind, + resource, + beforeUri, + afterUri, + beforeContentUri: edit.before?.content.uri ? URI.parse(edit.before.content.uri) : undefined, + afterContentUri: edit.after?.content.uri ? URI.parse(edit.after.content.uri) : undefined, + }; +} diff --git a/src/vs/platform/agentHost/test/common/fileEditDiff.test.ts b/src/vs/platform/agentHost/test/common/fileEditDiff.test.ts new file mode 100644 index 00000000000..16d7776fc50 --- /dev/null +++ b/src/vs/platform/agentHost/test/common/fileEditDiff.test.ts @@ -0,0 +1,54 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { URI } from '../../../../base/common/uri.js'; +import type { FileEdit } from '../../common/state/protocol/state.js'; +import { FileEditKind } from '../../common/state/sessionState.js'; +import { normalizeFileEdit } from '../../common/fileEditDiff.js'; + +suite('fileEditDiff', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + const fileA = URI.file('/repo/a.ts').toString(); + const fileB = URI.file('/repo/b.ts').toString(); + const beforeContent = 'git-blob://before'; + const afterContent = 'git-blob://after'; + + test('normalizes added, modified, deleted, and renamed edits', () => { + const created: FileEdit = { after: { uri: fileA, content: { uri: afterContent } } }; + const modified: FileEdit = { before: { uri: fileA, content: { uri: beforeContent } }, after: { uri: fileA, content: { uri: afterContent } } }; + const deleted: FileEdit = { before: { uri: fileA, content: { uri: beforeContent } } }; + const renamed: FileEdit = { before: { uri: fileA, content: { uri: beforeContent } }, after: { uri: fileB, content: { uri: afterContent } } }; + + const summarize = (edit: FileEdit) => { + const n = normalizeFileEdit(edit); + return n && { + kind: n.kind, + resource: n.resource.toString(), + beforeUri: n.beforeUri?.toString(), + afterUri: n.afterUri?.toString(), + beforeContentUri: n.beforeContentUri?.toString(), + afterContentUri: n.afterContentUri?.toString(), + }; + }; + + assert.deepStrictEqual( + [created, modified, deleted, renamed].map(summarize), + [ + { kind: FileEditKind.Create, resource: fileA, beforeUri: undefined, afterUri: fileA, beforeContentUri: undefined, afterContentUri: afterContent }, + { kind: FileEditKind.Edit, resource: fileA, beforeUri: fileA, afterUri: fileA, beforeContentUri: beforeContent, afterContentUri: afterContent }, + { kind: FileEditKind.Delete, resource: fileA, beforeUri: fileA, afterUri: undefined, beforeContentUri: beforeContent, afterContentUri: undefined }, + { kind: FileEditKind.Rename, resource: fileB, beforeUri: fileA, afterUri: fileB, beforeContentUri: beforeContent, afterContentUri: afterContent }, + ] + ); + }); + + test('returns undefined when no usable URI is present', () => { + assert.strictEqual(normalizeFileEdit({}), undefined); + }); +}); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostDiffs.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostDiffs.ts index ebf9759a062..55e4792e368 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostDiffs.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostDiffs.ts @@ -7,6 +7,7 @@ import { isDefined } from '../../../../../base/common/types.js'; import { URI } from '../../../../../base/common/uri.js'; import { SessionStatus as ProtocolSessionStatus, type ChangesetFile } from '../../../../../platform/agentHost/common/state/protocol/state.js'; import { ISessionFileDiff } from '../../../../../platform/agentHost/common/state/sessionState.js'; +import { normalizeFileEdit } from '../../../../../platform/agentHost/common/fileEditDiff.js'; import { IChatSessionFileChange2, isIChatSessionFileChange2 } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { ISessionFileChange, SessionStatus } from '../../../../services/sessions/common/session.js'; @@ -36,26 +37,23 @@ export function mapProtocolStatus(protocol: ProtocolSessionStatus): SessionStatu * host provider uses this to rewrite `file:` URIs into agent-host URIs. */ export function diffToChange(d: ISessionFileDiff, mapUri?: (uri: URI) => URI): IChatSessionFileChange2 | undefined { - const rawUri = d.after?.uri ?? d.before?.uri; - if (!rawUri) { + const normalized = normalizeFileEdit(d); + if (!normalized) { return undefined; } - const parseAndMap = (raw: string): URI => { - const parsed = URI.parse(raw); - return mapUri ? mapUri(parsed) : parsed; - }; + const map = (uri: URI): URI => mapUri ? mapUri(uri) : uri; - const uri = parseAndMap(rawUri); + const uri = map(normalized.resource); // For deletions (no `after`), `modifiedUri` is `undefined` so the // renderer treats the entry as a deletion and doesn't try to open the // (now-missing) file as the "modified" side of the diff editor. - const modifiedUri = d.after ? parseAndMap(d.after.uri) : undefined; + const modifiedUri = normalized.afterUri ? map(normalized.afterUri) : undefined; // Use the before-content reference URI so the diff editor can // fetch the snapshot of the file *before* the session's edits. - const originalUri = d.before?.content?.uri ? parseAndMap(d.before.content.uri) : undefined; + const originalUri = normalized.beforeContentUri ? map(normalized.beforeContentUri) : undefined; return { uri, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostResponseFileChanges.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostResponseFileChanges.ts new file mode 100644 index 00000000000..1da734f7c3f --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostResponseFileChanges.ts @@ -0,0 +1,137 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../../../base/common/lifecycle.js'; +import { constObservable, derived, IObservable, observableFromEvent } from '../../../../../../base/common/observable.js'; +import { isDefined } from '../../../../../../base/common/types.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; +import { buildTurnChangesetUri, ChangesetKind } from '../../../../../../platform/agentHost/common/changesetUri.js'; +import { normalizeFileEdit } from '../../../../../../platform/agentHost/common/fileEditDiff.js'; +import { toAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; +import { StateComponents, type ChangesetFile, type ChangesetState, type SessionState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { IEditSessionEntryDiff } from '../../../common/editing/chatEditingService.js'; +import { IChatResponseFileChangesProvider } from '../../chatResponseFileChangesService.js'; + +const SUBSCRIPTION_OWNER = 'AgentHostResponseFileChangesProvider'; + +/** + * Supplies the chat "Changed N files" summary for agent host responses from the + * authoritative per-turn changeset the host computes server-side (the same + * source backing the Agents-app Changes view), rather than from the chat + * editing session. + * + * For each `(sessionResource, requestId)` it subscribes to the session's + * per-turn changeset — `requestId` is the agent host turn id — and maps its + * files into {@link IEditSessionEntryDiff} entries. Subscriptions are acquired + * lazily inside the returned observable (so they exist only while a summary is + * actually observing the diffs) and the per-request observables are memoized so + * repeated lookups share one subscription. + */ +export class AgentHostResponseFileChangesProvider extends Disposable implements IChatResponseFileChangesProvider { + + private readonly _perRequest = new Map>(); + + constructor( + private readonly _connection: IAgentConnection, + private readonly _connectionAuthority: string, + private readonly _resolveBackendSession: (sessionResource: URI) => URI | undefined, + ) { + super(); + } + + getChangesForRequest(sessionResource: URI, requestId: string): IObservable | undefined { + const backendSession = this._resolveBackendSession(sessionResource); + if (!backendSession || !requestId) { + return undefined; + } + + const key = `${backendSession.toString()}\0${requestId}`; + let obs = this._perRequest.get(key); + if (!obs) { + obs = this._createDiffsObservable(backendSession, requestId); + this._perRequest.set(key, obs); + } + return obs; + } + + private _createDiffsObservable(backendSession: URI, requestId: string): IObservable { + // Resolve the per-turn changeset URI, but only when the agent actually + // advertises a `turn` changeset in its catalogue. Agents that don't + // support per-turn changesets never produce a turn-changeset URI, so + // the summary stays empty (and self-hidden) for them. + const sessionStateObs = this._subscribe(StateComponents.Session, constObservable(backendSession)); + + const turnChangesetUriObs = derived(reader => { + const sessionState = sessionStateObs.read(reader).read(reader); + if (!sessionState || sessionState instanceof Error) { + return undefined; + } + const supportsTurnChangeset = sessionState.changesets?.some(c => c.changeKind === ChangesetKind.Turn); + if (!supportsTurnChangeset) { + return undefined; + } + return URI.parse(buildTurnChangesetUri(backendSession.toString(), requestId)); + }); + + const changesetStateObs = this._subscribe(StateComponents.Changeset, turnChangesetUriObs); + + return derived(reader => { + const changesetState = changesetStateObs.read(reader).read(reader); + if (!changesetState || changesetState instanceof Error) { + return []; + } + return changesetState.files + .map(file => this._changesetFileToEntryDiff(file)) + .filter(isDefined); + }); + } + + /** + * Builds a two-level observable that owns a refcounted subscription to + * `component` at the (observable) resource. The outer observable acquires + * the subscription against the current resource and releases it when the + * resource changes or no one observes; the inner observable tracks the + * subscription's value. + */ + private _subscribe(component: StateComponents.Session | StateComponents.Changeset, resourceObs: IObservable): IObservable> { + return derived(reader => { + const resource = resourceObs.read(reader); + if (!resource) { + return constObservable(undefined); + } + const subscriptionRef = reader.store.add(this._connection.getSubscription(component, resource, SUBSCRIPTION_OWNER)); + return observableFromEvent(this, subscriptionRef.object.onDidChange, () => subscriptionRef.object.value as T | Error | undefined); + }); + } + + private _changesetFileToEntryDiff(file: ChangesetFile): IEditSessionEntryDiff | undefined { + const normalized = normalizeFileEdit(file.edit); + if (!normalized) { + return undefined; + } + + const modifiedURI = toAgentHostUri(normalized.resource, this._connectionAuthority); + // For creates there is no before-content; fall back to the modified URI + // so the entry still resolves. The collapsed summary uses the + // server-provided counts below, so its +/- numbers stay correct + // regardless; only an explicitly-opened diff of a created file shows no + // delta. + const originalURI = normalized.beforeContentUri + ? toAgentHostUri(normalized.beforeContentUri, this._connectionAuthority) + : modifiedURI; + + return { + originalURI, + modifiedURI, + added: file.edit.diff?.added ?? 0, + removed: file.edit.diff?.removed ?? 0, + quitEarly: false, + identical: false, + isFinal: true, + isBusy: false, + }; + } +} diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index e5c949d1b02..5f492ae14da 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -75,6 +75,8 @@ import { IAgentHostActiveClientService } from './agentHostActiveClientService.js import { IAgentHostSessionWorkingDirectoryResolver } from './agentHostSessionWorkingDirectoryResolver.js'; import { IAgentHostNewSessionFolderService } from './agentHostNewSessionFolderService.js'; import { AgentHostSnapshotController } from './agentHostSnapshotController.js'; +import { AgentHostResponseFileChangesProvider } from './agentHostResponseFileChanges.js'; +import { IChatResponseFileChangesService } from '../../chatResponseFileChangesService.js'; import { toolDataToDefinition } from './agentHostToolUtils.js'; import { IAgentHostUntitledProvisionalSessionService } from './agentHostUntitledProvisionalSessionService.js'; import { activeTurnToProgress, completedToolCallToEditParts, completedToolCallToSerialized, finalizeToolInvocation, getTerminalContentUri, isSubagentTool, makeAhpTerminalToolSessionId, messageAttachmentsToVariableData, messageToVariableData, parseAhpTerminalToolSessionId, rawMarkdownToString, stringOrMarkdownToString, toolCallStateToInvocation, turnsToHistory, updateRunningToolSpecificData, usageInfoToChatUsage, usageInfoToQuotas, type IToolCallFileEdit, type TurnModelLookup } from './stateToProgressAdapter.js'; @@ -645,6 +647,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC @IWorkspaceTrustRequestService private readonly _workspaceTrustRequestService: IWorkspaceTrustRequestService, @IModelService private readonly _modelService: IModelService, @IWorkingCopyService private readonly _workingCopyService: IWorkingCopyService, + @IChatResponseFileChangesService private readonly _chatResponseFileChangesService: IChatResponseFileChangesService, ) { super(); this._config = config; @@ -697,6 +700,18 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC }, )); + // Supply the per-response "Changed N files" chat summary from the + // authoritative server-computed per-turn changeset (the same source as + // the Agents-app Changes view) instead of the editing session. + this._register(this._chatResponseFileChangesService.registerProvider( + config.sessionType, + this._register(new AgentHostResponseFileChangesProvider( + config.connection, + config.connectionAuthority, + sessionResource => this._resolveSessionUri(sessionResource), + )), + )); + // Push customization changes to sessions where this client is already active without reclaiming. const customizationsObs = this._activeClientService.getCustomizations(config.sessionType); this._register(autorun(reader => { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts index 31a68a20370..a799b7d489b 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts @@ -16,6 +16,7 @@ import { AGENT_HOST_SCHEME, toAgentHostUri } from '../../../../../../platform/ag import { getAgentFeedbackAttachmentMetadata, isAgentFeedbackAnnotationsAttachment, isAgentFeedbackAttachment } from '../../../../../../platform/agentHost/common/meta/agentFeedbackAttachments.js'; import { isViewUnreviewedCommentsTool } from '../../../../../../platform/agentHost/common/meta/agentFeedbackAnnotations.js'; import { MessageAttachmentKind, type FileEdit, type MessageAttachment, type StringOrMarkdown, type TextRange } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { normalizeFileEdit } from '../../../../../../platform/agentHost/common/fileEditDiff.js'; import { type ChatExternalEditKind, type ChatMcpAppData, type IChatAgentFeedbackReviewConfirmationData, type IChatExternalEdit, type IChatModifiedFilesConfirmationData, type IChatProgress, type IChatResponseErrorDetails, type IChatSearchToolInvocationData, type IChatTerminalToolInvocationData, type IChatToolInputInvocationData, type IChatToolInvocationSerialized, type IChatUsage, ToolConfirmKind } from '../../../common/chatService/chatService.js'; import { type IChatSessionHistoryItem } from '../../../common/chatSessionsService.js'; import { type IQuotaSnapshot } from '../../../../../services/chat/common/chatEntitlementService.js'; @@ -24,7 +25,7 @@ import { type IChatRequestVariableData } from '../../../common/model/chatModel.j import { AgentHostCompletionReferenceKind, restorePasteVariableEntryFromAttachment, toAgentHostCompletionVariableEntryFromMetadata, type IAgentFeedbackVariableEntry, type IChatRequestVariableEntry } from '../../../common/attachments/chatVariableEntries.js'; import { type IToolConfirmationMessages, type IToolData, type IToolResult, type IToolResultInputOutputDetails, ToolDataSource, ToolInvocationPresentation } from '../../../common/tools/languageModelToolsService.js'; import { MCP } from '../../../../mcp/common/modelContextProtocol.js'; -import { basename, isEqual } from '../../../../../../base/common/resources.js'; +import { basename } from '../../../../../../base/common/resources.js'; import { hasKey, type Mutable } from '../../../../../../base/common/types.js'; import { localize } from '../../../../../../nls.js'; import type { IRange } from '../../../../../../editor/common/core/range.js'; @@ -971,33 +972,20 @@ export function completedToolCallToEditParts(tc: ICompletedToolCall, connectionA * lookups resolve through the agent host file system provider. */ function fileEditToExternalEdit(edit: FileEdit, undoStopId: string, connectionAuthority: string): IChatExternalEdit | undefined { - const rawFileUri = edit.after?.uri ? URI.parse(edit.after.uri) : edit.before?.uri ? URI.parse(edit.before.uri) : undefined; - if (!rawFileUri) { + const normalized = normalizeFileEdit(edit); + if (!normalized) { return undefined; } - const isCreate = !edit.before && !!edit.after; - const isDelete = !!edit.before && !edit.after; - const isRename = !!edit.before && !!edit.after && !isEqual(URI.parse(edit.before.uri), URI.parse(edit.after.uri)); - let editKind: ChatExternalEditKind; - if (isCreate) { - editKind = 'create'; - } else if (isDelete) { - editKind = 'delete'; - } else if (isRename) { - editKind = 'rename'; - } else { - editKind = 'edit'; - } const diff = edit.diff && (edit.diff.added !== undefined || edit.diff.removed !== undefined) ? { added: edit.diff.added ?? 0, removed: edit.diff.removed ?? 0 } : undefined; return { kind: 'externalEdit', - uri: toAgentHostUri(rawFileUri, connectionAuthority), - editKind, - originalUri: isRename && edit.before ? toAgentHostUri(URI.parse(edit.before.uri), connectionAuthority) : undefined, - beforeContentUri: edit.before?.content.uri ? toAgentHostUri(URI.parse(edit.before.content.uri), connectionAuthority) : undefined, - afterContentUri: edit.after?.content.uri ? toAgentHostUri(URI.parse(edit.after.content.uri), connectionAuthority) : undefined, + uri: toAgentHostUri(normalized.resource, connectionAuthority), + editKind: normalized.kind as ChatExternalEditKind, + originalUri: normalized.kind === FileEditKind.Rename && normalized.beforeUri ? toAgentHostUri(normalized.beforeUri, connectionAuthority) : undefined, + beforeContentUri: normalized.beforeContentUri ? toAgentHostUri(normalized.beforeContentUri, connectionAuthority) : undefined, + afterContentUri: normalized.afterContentUri ? toAgentHostUri(normalized.afterContentUri, connectionAuthority) : undefined, diff, undoStopId, }; @@ -1498,32 +1486,17 @@ export function fileEditsToExternalEdits(tc: ToolCallState): IToolCallFileEdit[] function mapFileEdits(items: readonly FileEdit[], undoStopId: string): IToolCallFileEdit[] { const result: IToolCallFileEdit[] = []; for (const edit of items) { - const isCreate = !edit.before && !!edit.after; - const isDelete = !!edit.before && !edit.after; - const isRename = !!edit.before && !!edit.after && !isEqual(URI.parse(edit.before.uri), URI.parse(edit.after.uri)); - - let kind: FileEditKind; - if (isCreate) { - kind = FileEditKind.Create; - } else if (isDelete) { - kind = FileEditKind.Delete; - } else if (isRename) { - kind = FileEditKind.Rename; - } else { - kind = FileEditKind.Edit; - } - - const resource = edit.after?.uri ? URI.parse(edit.after.uri) : edit.before?.uri ? URI.parse(edit.before.uri) : undefined; - if (!resource) { + const normalized = normalizeFileEdit(edit); + if (!normalized) { continue; } result.push({ - kind, - resource, - originalResource: isRename ? URI.parse(edit.before!.uri) : undefined, - beforeContentUri: edit.before?.content.uri ? URI.parse(edit.before.content.uri) : undefined, - afterContentUri: edit.after?.content.uri ? URI.parse(edit.after.content.uri) : undefined, + kind: normalized.kind, + resource: normalized.resource, + originalResource: normalized.kind === FileEditKind.Rename ? normalized.beforeUri : undefined, + beforeContentUri: normalized.beforeContentUri, + afterContentUri: normalized.afterContentUri, undoStopId, diff: edit.diff, }); diff --git a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts index 93275f6886b..3be853bea3f 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -68,6 +68,7 @@ import { ILanguageModelToolsConfirmationService } from '../common/tools/language import { ILanguageModelToolsService } from '../common/tools/languageModelToolsService.js'; import { ChatToolRiskAssessmentService, IChatToolRiskAssessmentService } from './tools/chatToolRiskAssessmentService.js'; import { ChatGoalSummaryService, IChatGoalSummaryService } from './chatGoalSummaryService.js'; +import { ChatResponseFileChangesService, IChatResponseFileChangesService } from './chatResponseFileChangesService.js'; import { AgentPluginDiscoveryPriority, agentPluginDiscoveryRegistry, IAgentPluginService } from '../common/plugins/agentPluginService.js'; import { ChatPromptFilesExtensionPointHandler } from '../common/promptSyntax/chatPromptFilesContribution.js'; import { isTildePath, PromptsConfig } from '../common/promptSyntax/config/config.js'; @@ -2551,6 +2552,7 @@ registerSingleton(IToolResultCompressor, ToolResultCompressorService, Instantiat registerSingleton(ILanguageModelToolsConfirmationService, LanguageModelToolsConfirmationService, InstantiationType.Delayed); registerSingleton(IChatToolRiskAssessmentService, ChatToolRiskAssessmentService, InstantiationType.Delayed); registerSingleton(IChatGoalSummaryService, ChatGoalSummaryService, InstantiationType.Delayed); +registerSingleton(IChatResponseFileChangesService, ChatResponseFileChangesService, InstantiationType.Delayed); registerSingleton(IVoiceChatService, VoiceChatService, InstantiationType.Delayed); registerSingleton(IChatCodeBlockContextProviderService, ChatCodeBlockContextProviderService, InstantiationType.Delayed); registerSingleton(ICodeMapperService, CodeMapperService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/chat/browser/chatResponseFileChangesService.ts b/src/vs/workbench/contrib/chat/browser/chatResponseFileChangesService.ts new file mode 100644 index 00000000000..09dc6488202 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatResponseFileChangesService.ts @@ -0,0 +1,74 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { IObservable } from '../../../../base/common/observable.js'; +import { URI } from '../../../../base/common/uri.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { getChatSessionType } from '../common/model/chatUri.js'; +import { IEditSessionEntryDiff } from '../common/editing/chatEditingService.js'; + +export const IChatResponseFileChangesService = createDecorator('chatResponseFileChangesService'); + +/** + * Supplies the per-response (per-request) file-change diffs rendered by the + * "Changed N files" summary under a completed chat response. + * + * Most chat sessions derive these diffs from their {@link IChatEditingSession}. + * Some session types (notably agent host sessions) own an authoritative, + * server-computed view of a turn's changes and provide it here instead, so the + * summary reflects the same source of truth as the rest of that session's + * change UI. + */ +export interface IChatResponseFileChangesProvider { + /** + * Returns an observable of the file-change diffs produced by `requestId` + * within `sessionResource`, or `undefined` when this provider cannot + * supply changes for that request (in which case the caller falls back to + * the chat editing session). + */ + getChangesForRequest(sessionResource: URI, requestId: string): IObservable | undefined; +} + +export interface IChatResponseFileChangesService { + readonly _serviceBrand: undefined; + + /** + * Registers a provider for a chat session type (as returned by + * {@link getChatSessionType}). At most one provider may be registered per + * session type. + */ + registerProvider(chatSessionType: string, provider: IChatResponseFileChangesProvider): IDisposable; + + /** + * Returns the per-request change diffs for `sessionResource` from the + * provider registered for its session type, or `undefined` when there is + * no provider or the provider cannot supply changes for this request. + */ + getChangesForRequest(sessionResource: URI, requestId: string): IObservable | undefined; +} + +export class ChatResponseFileChangesService extends Disposable implements IChatResponseFileChangesService { + declare readonly _serviceBrand: undefined; + + private readonly _providers = new Map(); + + registerProvider(chatSessionType: string, provider: IChatResponseFileChangesProvider): IDisposable { + if (this._providers.has(chatSessionType)) { + throw new Error(`A chat response file changes provider is already registered for session type '${chatSessionType}'`); + } + this._providers.set(chatSessionType, provider); + return toDisposable(() => { + if (this._providers.get(chatSessionType) === provider) { + this._providers.delete(chatSessionType); + } + }); + } + + getChangesForRequest(sessionResource: URI, requestId: string): IObservable | undefined { + const provider = this._providers.get(getChatSessionType(sessionResource)); + return provider?.getChangesForRequest(sessionResource, requestId); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatChangesSummaryPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatChangesSummaryPart.ts index f93bfe8f838..597e53567d0 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatChangesSummaryPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatChangesSummaryPart.ts @@ -31,6 +31,7 @@ import { ChatEditingSnapshotTextModelContentProvider } from '../../chatEditing/c import { ChatConfiguration } from '../../../common/constants.js'; import { IChatService } from '../../../common/chatService/chatService.js'; import { IChatChangesSummaryPart as IChatFileChangesSummaryPart, IChatRendererContent } from '../../../common/model/chatViewModel.js'; +import { IChatResponseFileChangesService } from '../../chatResponseFileChangesService.js'; import { ChatTreeItem } from '../../chat.js'; import { ResourcePool } from './chatCollections.js'; import { IChatContentPart, IChatContentPartRenderContext } from './chatContentParts.js'; @@ -57,6 +58,7 @@ export class ChatCheckpointFileChangesSummaryContentPart extends Disposable impl @IEditorService private readonly editorService: IEditorService, @IConfigurationService private readonly configurationService: IConfigurationService, @IInstantiationService private readonly instantiationService: IInstantiationService, + @IChatResponseFileChangesService private readonly chatResponseFileChangesService: IChatResponseFileChangesService, ) { super(); @@ -66,11 +68,27 @@ export class ChatCheckpointFileChangesSummaryContentPart extends Disposable impl this.domNode = $('.checkpoint-file-changes-summary', undefined, headerDomNode); this.domNode.tabIndex = 0; + // Hide the whole summary when there are no changes to show. The part is + // created eagerly for completed responses, but session types whose + // changes are computed asynchronously (e.g. agent host turn changesets) + // only know whether a turn produced edits once the diffs resolve. + this._register(autorun(r => { + const hasChanges = this.fileChangesDiffsObservable.read(r).length > 0; + this.domNode.style.display = hasChanges ? '' : 'none'; + })); + this._register(this.renderHeader(headerDomNode)); this._register(this.renderFilesList(this.domNode)); } private computeFileChangesDiffs({ requestId, sessionResource }: IChatFileChangesSummaryPart) { + // Prefer a session-type-specific provider (the authoritative source for + // session types that own their own change computation); otherwise fall + // back to the chat editing session's per-request diffs. + const fromProvider = this.chatResponseFileChangesService.getChangesForRequest(sessionResource, requestId); + if (fromProvider) { + return fromProvider; + } return this.chatService.chatModels .map(models => Iterable.find(models, m => isEqual(m.sessionResource, sessionResource))) .map(model => model?.editingSession?.getDiffsForFilesInRequest(requestId)) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 39d2da8b1ac..66e50b5272a 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -1491,11 +1491,16 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer part.kind === 'textEditGroup' || part.kind === 'notebookEditGroup')) { + // Agent host sessions compute their per-turn changes server-side and + // supply them via IChatResponseFileChangesService; the summary part + // resolves them asynchronously and self-hides when the turn produced no + // edits. Other sessions surface diff data through the chat editing + // session, which only has data when the response carries text/notebook + // edit groups — so skip the summary for those unless such a group is + // present. + const sessionType = getChatSessionType(element.sessionResource); + if (!isAgentHostTarget(sessionType) && + !element.model.entireResponse.value.some(part => part.kind === 'textEditGroup' || part.kind === 'notebookEditGroup')) { return undefined; } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostResponseFileChanges.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostResponseFileChanges.test.ts new file mode 100644 index 00000000000..0777f22c9fb --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostResponseFileChanges.test.ts @@ -0,0 +1,125 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { DisposableStore, IReference } from '../../../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../../../base/common/observable.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { mock } from '../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; +import { buildTurnChangesetUri } from '../../../../../../platform/agentHost/common/changesetUri.js'; +import { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; +import { ChangesetStatus, StateComponents, type ChangesetState, type SessionState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { IEditSessionEntryDiff } from '../../../common/editing/chatEditingService.js'; +import { AgentHostResponseFileChangesProvider } from '../../../browser/agentSessions/agentHost/agentHostResponseFileChanges.js'; + +class FakeAgentConnection extends mock() { + override readonly clientId = 'test-client'; + + private readonly _emitters = new Map>(); + private readonly _values = new Map(); + + setState(resource: string, value: unknown): void { + this._values.set(resource, value); + this._emitters.get(resource)?.fire(value); + } + + override getSubscription(_kind: T, resource: URI, _owner: string): IReference> { + const key = resource.toString(); + let emitter = this._emitters.get(key); + if (!emitter) { + emitter = new Emitter(); + this._emitters.set(key, emitter); + } + const self = this; + const sub = { + get value() { return self._values.get(key); }, + get verifiedValue() { return self._values.get(key); }, + onDidChange: emitter.event, + onWillApplyAction: Event.None, + onDidApplyAction: Event.None, + } as unknown as IAgentSubscription; + return { object: sub, dispose: () => { } }; + } +} + +suite('AgentHostResponseFileChangesProvider', () => { + + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + const backendSession = URI.parse('copilot:/sess-1'); + const authority = 'authority-1'; + const chatResource = URI.parse('agent-host-copilot:/sess-1'); + + function turnChangesetUri(turnId: string): string { + return URI.parse(buildTurnChangesetUri(backendSession.toString(), turnId)).toString(); + } + + function sessionStateWithTurnSupport(): SessionState { + return { + changesets: [{ label: 'This Turn', uriTemplate: buildTurnChangesetUri(backendSession.toString(), '{turnId}'), changeKind: 'turn' }], + } as unknown as SessionState; + } + + function observe(provider: AgentHostResponseFileChangesProvider, ds: DisposableStore): { latest: () => readonly IEditSessionEntryDiff[] } { + const obs = provider.getChangesForRequest(chatResource, 't1')!; + let latest: readonly IEditSessionEntryDiff[] = []; + ds.add(autorun(r => { latest = obs.read(r); })); + return { latest: () => latest }; + } + + test('maps per-turn changeset files into entry diffs', () => { + const ds = store.add(new DisposableStore()); + const conn = new FakeAgentConnection(); + const provider = ds.add(new AgentHostResponseFileChangesProvider(conn, authority, () => backendSession)); + + conn.setState(backendSession.toString(), sessionStateWithTurnSupport()); + conn.setState(turnChangesetUri('t1'), { + status: ChangesetStatus.Ready, + files: [ + { id: '1', edit: { before: { uri: URI.file('/repo/a.ts').toString(), content: { uri: 'git-blob://a-before' } }, after: { uri: URI.file('/repo/a.ts').toString(), content: { uri: 'git-blob://a-after' } }, diff: { added: 3, removed: 1 } } }, + { id: '2', edit: { after: { uri: URI.file('/repo/b.ts').toString(), content: { uri: 'git-blob://b-after' } }, diff: { added: 5, removed: 0 } } }, + ], + } satisfies ChangesetState); + + const { latest } = observe(provider, ds); + assert.deepStrictEqual(latest().map(d => ({ added: d.added, removed: d.removed, modified: d.modifiedURI.path })), [ + { added: 3, removed: 1, modified: '/repo/a.ts' }, + { added: 5, removed: 0, modified: '/repo/b.ts' }, + ]); + }); + + test('returns empty when the agent does not advertise a turn changeset', () => { + const ds = store.add(new DisposableStore()); + const conn = new FakeAgentConnection(); + const provider = ds.add(new AgentHostResponseFileChangesProvider(conn, authority, () => backendSession)); + + conn.setState(backendSession.toString(), { changesets: [{ label: 'All', uriTemplate: `${backendSession}/changeset/session`, changeKind: 'session' }] } as unknown as SessionState); + + const { latest } = observe(provider, ds); + assert.deepStrictEqual(latest(), []); + }); + + test('memoizes the observable per request', () => { + const ds = store.add(new DisposableStore()); + const conn = new FakeAgentConnection(); + const provider = ds.add(new AgentHostResponseFileChangesProvider(conn, authority, () => backendSession)); + + assert.strictEqual( + provider.getChangesForRequest(chatResource, 't1'), + provider.getChangesForRequest(chatResource, 't1') + ); + }); + + test('returns undefined when the backend session cannot be resolved', () => { + const ds = store.add(new DisposableStore()); + const conn = new FakeAgentConnection(); + const provider = ds.add(new AgentHostResponseFileChangesProvider(conn, authority, () => undefined)); + + assert.strictEqual(provider.getChangesForRequest(chatResource, 't1'), undefined); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 9e63c52ae41..cca37f80892 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -35,6 +35,7 @@ import { IChatAgentData, IChatAgentImplementation, IChatAgentRequest, IChatAgent import { ChatAgentLocation, ChatModeKind } from '../../../common/constants.js'; import { ChatRequestQueueKind, ElicitationState, IChatService, IChatMarkdownContent, IChatProgress, IChatSubagentToolInvocationData, IChatTerminalToolInvocationData, IChatToolInputInvocationData, IChatToolInvocation, IChatToolInvocationSerialized, IChatUsage, ToolConfirmKind } from '../../../common/chatService/chatService.js'; import { IChatEditingService } from '../../../common/editing/chatEditingService.js'; +import { IChatResponseFileChangesService } from '../../../browser/chatResponseFileChangesService.js'; import { IMarkdownString } from '../../../../../../base/common/htmlContent.js'; import { IChatSessionsService, type IChatSessionItemController, type IChatSessionRequestHistoryItem, type IChatSessionsExtensionPoint } from '../../../common/chatSessionsService.js'; import { ILanguageModelsService, type ILanguageModelChatMetadata } from '../../../common/languageModels.js'; @@ -588,6 +589,9 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv instantiationService.stub(IChatEditingService, { registerEditingSessionProvider: () => toDisposable(() => { }), }); + instantiationService.stub(IChatResponseFileChangesService, { + registerProvider: () => toDisposable(() => { }), + }); const chatModels = new Map(); const onDidCreateModel = disposables.add(new Emitter()); const chatService = { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts index 69d736feb84..47bc5e07736 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts @@ -24,6 +24,7 @@ import { ToolCallConfirmationReason, ToolCallContributorKind, ToolResultContentT import { IChatAgentService } from '../../../common/participants/chatAgents.js'; import { IChatProgress, IChatService, IChatToolInvocation, ToolConfirmKind } from '../../../common/chatService/chatService.js'; import { IChatEditingService } from '../../../common/editing/chatEditingService.js'; +import { IChatResponseFileChangesService } from '../../../browser/chatResponseFileChangesService.js'; import { ILanguageModelsService } from '../../../common/languageModels.js'; import { ChatToolInvocation } from '../../../common/model/chatProgressTypes/chatToolInvocation.js'; import { IProductService } from '../../../../../../platform/product/common/productService.js'; @@ -446,6 +447,9 @@ suite('AgentHostClientTools', () => { instantiationService.stub(IChatEditingService, { registerEditingSessionProvider: () => toDisposable(() => { }), }); + instantiationService.stub(IChatResponseFileChangesService, { + registerProvider: () => toDisposable(() => { }), + }); instantiationService.stub(IChatService, { getSession: () => undefined, onDidCreateModel: Event.None,