Merge branch 'main' into dev/dmitriv/agent-host-implicit-context

This commit is contained in:
Dmitriy Vasyura
2026-06-26 18:29:50 -07:00
committed by GitHub
13 changed files with 534 additions and 57 deletions
@@ -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,
};
}
@@ -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);
});
});
@@ -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,
@@ -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<string, IObservable<readonly IEditSessionEntryDiff[]>>();
constructor(
private readonly _connection: IAgentConnection,
private readonly _connectionAuthority: string,
private readonly _resolveBackendSession: (sessionResource: URI) => URI | undefined,
) {
super();
}
getChangesForRequest(sessionResource: URI, requestId: string): IObservable<readonly IEditSessionEntryDiff[]> | 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<readonly IEditSessionEntryDiff[]> {
// 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<SessionState>(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<ChangesetState>(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<T>(component: StateComponents.Session | StateComponents.Changeset, resourceObs: IObservable<URI | undefined>): IObservable<IObservable<T | Error | undefined>> {
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,
};
}
}
@@ -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 => {
@@ -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,
});
@@ -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);
@@ -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<IChatResponseFileChangesService>('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<readonly IEditSessionEntryDiff[]> | 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<readonly IEditSessionEntryDiff[]> | undefined;
}
export class ChatResponseFileChangesService extends Disposable implements IChatResponseFileChangesService {
declare readonly _serviceBrand: undefined;
private readonly _providers = new Map<string, IChatResponseFileChangesProvider>();
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<readonly IEditSessionEntryDiff[]> | undefined {
const provider = this._providers.get(getChatSessionType(sessionResource));
return provider?.getChangesForRequest(sessionResource, requestId);
}
}
@@ -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))
@@ -1491,11 +1491,16 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
if (!this.shouldShowFileChangesSummary(element)) {
return undefined;
}
// Only chat editing sessions surface diff data via the editing
// session; agent host responses emit `externalEdit` parts that
// already render the per-file change counts inline, so the
// aggregate summary is skipped intentionally here.
if (!element.model.entireResponse.value.some(part => 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;
}
@@ -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<IAgentConnection>() {
override readonly clientId = 'test-client';
private readonly _emitters = new Map<string, Emitter<unknown>>();
private readonly _values = new Map<string, unknown>();
setState(resource: string, value: unknown): void {
this._values.set(resource, value);
this._emitters.get(resource)?.fire(value);
}
override getSubscription<T extends StateComponents>(_kind: T, resource: URI, _owner: string): IReference<IAgentSubscription<never>> {
const key = resource.toString();
let emitter = this._emitters.get(key);
if (!emitter) {
emitter = new Emitter<unknown>();
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<never>;
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);
});
});
@@ -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<string, IChatModel>();
const onDidCreateModel = disposables.add(new Emitter<IChatModel>());
const chatService = {
@@ -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,