mirror of
https://github.com/microsoft/vscode.git
synced 2026-07-09 08:15:05 +01:00
Merge branch 'main' into dev/dmitriv/agent-host-implicit-context
This commit is contained in:
@@ -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,
|
||||
|
||||
+137
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
+15
@@ -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
-43
@@ -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);
|
||||
}
|
||||
}
|
||||
+18
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+125
@@ -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);
|
||||
});
|
||||
});
|
||||
+4
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user