diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/agentHostFileSystemProvider.ts b/src/vs/platform/agentHost/common/agentHostFileSystemProvider.ts similarity index 58% rename from src/vs/sessions/contrib/remoteAgentHost/browser/agentHostFileSystemProvider.ts rename to src/vs/platform/agentHost/common/agentHostFileSystemProvider.ts index 4b6b21adaea..f194e543d27 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/agentHostFileSystemProvider.ts +++ b/src/vs/platform/agentHost/common/agentHostFileSystemProvider.ts @@ -3,55 +3,46 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Emitter } from '../../../../base/common/event.js'; -import { Disposable, toDisposable, type IDisposable } from '../../../../base/common/lifecycle.js'; -import { dirname, basename } from '../../../../base/common/resources.js'; -import { URI } from '../../../../base/common/uri.js'; -import { IRemoteAgentHostService } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; -import type { IDirectoryEntry } from '../../../../platform/agentHost/common/state/sessionProtocol.js'; -import { - createFileSystemProviderError, - FilePermission, - FileSystemProviderCapabilities, - FileSystemProviderErrorCode, - FileType, - type IFileChange, - type IFileDeleteOptions, - type IFileOverwriteOptions, - type IFileSystemProvider, - type IFileWriteOptions, - type IStat, -} from '../../../../platform/files/common/files.js'; +import { VSBuffer } from '../../../base/common/buffer.js'; +import { Emitter } from '../../../base/common/event.js'; +import { Disposable, IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; +import { basename, dirname } from '../../../base/common/resources.js'; +import { URI } from '../../../base/common/uri.js'; +import { createFileSystemProviderError, FilePermission, FileSystemProviderCapabilities, FileSystemProviderErrorCode, FileType, IFileChange, IFileDeleteOptions, IFileOverwriteOptions, IFileSystemProvider, IFileWriteOptions, IStat } from '../../files/common/files.js'; +import { type IAgentConnection } from './agentService.js'; +import { fromAgentHostUri, toAgentHostUri } from './agentHostUri.js'; +import { IDirectoryEntry } from './state/protocol/commands.js'; + /** - * The URI scheme used for browsing remote agent host filesystems. - * URIs are structured as `agenthost://{sanitizedAddress}/path/on/remote`. - */ -export const AGENT_HOST_FS_SCHEME = 'agenthost'; - -/** - * Build an agenthost URI for a given address and path. + * Build a {@link AGENT_HOST_SCHEME} URI for a given connection authority + * and remote path. Assumes the remote path is a `file://` resource. */ export function agentHostUri(authority: string, path: string): URI { - return URI.from({ scheme: AGENT_HOST_FS_SCHEME, authority, path: path || '/' }); + return toAgentHostUri(URI.file(path), authority); } /** - * Extract the remote filesystem path from an agenthost URI. - * This is the inverse of {@link agentHostUri} -- the path component - * of the URI is the path on the remote machine. + * Extract the remote filesystem path from a {@link AGENT_HOST_SCHEME} URI. */ export function agentHostRemotePath(uri: URI): string { - return uri.path; + return fromAgentHostUri(uri).path; } /** - * Read-only {@link IFileSystemProvider} that proxies `stat` and `readdir` - * calls through the agent host protocol's `browseDirectory` RPC. + * Read-only {@link IFileSystemProvider} that proxies filesystem operations + * through the agent host protocol. * - * Registered once under the {@link AGENT_HOST_FS_SCHEME} scheme. Individual - * connections are identified by the URI's authority component, which is - * the sanitized remote address. + * Registered under the {@link AGENT_HOST_SCHEME} scheme. URIs encode the + * original scheme and authority in the path so any remote resource can be + * represented (not just `file://`): + * + * ``` + * vscode-agent-host://[connectionAuthority]/[originalScheme]/[originalAuthority]/[originalPath] + * ``` + * + * Individual connections are identified by the URI's authority component, + * which is the sanitized remote address. */ export class AgentHostFileSystemProvider extends Disposable implements IFileSystemProvider { @@ -66,21 +57,15 @@ export class AgentHostFileSystemProvider extends Disposable implements IFileSyst private readonly _onDidChangeFile = this._register(new Emitter()); readonly onDidChangeFile = this._onDidChangeFile.event; - private readonly _authorityToAddress = new Map(); - - constructor( - @IRemoteAgentHostService private readonly _remoteAgentHostService: IRemoteAgentHostService, - ) { - super(); - } + private readonly _authorityToConnection = new Map(); /** - * Register a mapping from a URI authority to a remote address. + * Register a mapping from a URI authority to an agent connection. * Returns a disposable that unregisters the mapping. */ - registerAuthority(authority: string, address: string): IDisposable { - this._authorityToAddress.set(authority, address); - return toDisposable(() => this._authorityToAddress.delete(authority)); + registerAuthority(authority: string, connection: IAgentConnection): IDisposable { + this._authorityToConnection.set(authority, connection); + return toDisposable(() => this._authorityToConnection.delete(authority)); } watch(): IDisposable { @@ -121,8 +106,18 @@ export class AgentHostFileSystemProvider extends Disposable implements IFileSyst // ---- Read-only stubs (required by interface) ---------------------------- - async readFile(): Promise { - throw createFileSystemProviderError('readFile not supported on remote agent host filesystem', FileSystemProviderErrorCode.NoPermissions); + async readFile(resource: URI): Promise { + const connection = this._getConnection(resource.authority); + try { + const originalUri = fromAgentHostUri(resource); + const result = await connection.fetchContent(originalUri.toString()); + return VSBuffer.fromString(result.data).buffer; + } catch (err) { + throw createFileSystemProviderError( + err instanceof Error ? err.message : String(err), + FileSystemProviderErrorCode.FileNotFound, + ); + } } async writeFile(_resource: URI, _content: Uint8Array, _opts: IFileWriteOptions): Promise { @@ -144,13 +139,9 @@ export class AgentHostFileSystemProvider extends Disposable implements IFileSyst // ---- Internals ---------------------------------------------------------- private _getConnection(authority: string) { - const address = this._authorityToAddress.get(authority); - if (!address) { - throw createFileSystemProviderError(`No connection for authority: ${authority}`, FileSystemProviderErrorCode.Unavailable); - } - const connection = this._remoteAgentHostService.getConnection(address); + const connection = this._authorityToConnection.get(authority); if (!connection) { - throw createFileSystemProviderError(`Connection unavailable: ${address}`, FileSystemProviderErrorCode.Unavailable); + throw createFileSystemProviderError(`No connection for authority: ${authority}`, FileSystemProviderErrorCode.Unavailable); } return connection; } @@ -158,9 +149,8 @@ export class AgentHostFileSystemProvider extends Disposable implements IFileSyst private async _listDirectory(authority: string, resource: URI): Promise { const connection = this._getConnection(authority); try { - // Convert the agenthost URI to a file URI for the remote server - const remoteUri = URI.from({ scheme: 'file', path: resource.path || '/' }); - const result = await connection.browseDirectory(remoteUri); + const originalUri = fromAgentHostUri(resource); + const result = await connection.browseDirectory(originalUri); return result.entries; } catch (err) { throw createFileSystemProviderError( diff --git a/src/vs/platform/agentHost/common/agentHostUri.ts b/src/vs/platform/agentHost/common/agentHostUri.ts new file mode 100644 index 00000000000..49a172fac0f --- /dev/null +++ b/src/vs/platform/agentHost/common/agentHostUri.ts @@ -0,0 +1,118 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { encodeBase64, VSBuffer } from '../../../base/common/buffer.js'; +import { URI } from '../../../base/common/uri.js'; +import type { ResourceLabelFormatter } from '../../label/common/label.js'; + +/** + * The URI scheme for accessing files on a remote agent host. + * + * URIs encode the original scheme, authority, and path so that any + * remote resource can be represented without assuming `file://`: + * + * ``` + * vscode-agent-host://[connectionAuthority]/[originalScheme]/[originalAuthority]/[originalPath] + * ``` + * + * For example, `file:///home/user/foo.ts` on remote `my-server` becomes: + * ``` + * vscode-agent-host://my-server/file//home/user/foo.ts + * ``` + */ +export const AGENT_HOST_SCHEME = 'vscode-agent-host'; + +/** + * Wraps a remote URI into a {@link AGENT_HOST_SCHEME} URI that can be + * resolved through the agent host filesystem provider. + * + * @param originalUri The URI on the remote (e.g. `file:///path` or + * `agenthost-content:///sessionId/...`) + * @param connectionAuthority The sanitized connection identifier used as + * the URI authority (from {@link agentHostAuthority}). + */ +export function toAgentHostUri(originalUri: URI, connectionAuthority: string): URI { + if (connectionAuthority === 'local') { + return originalUri; + } + + // Path format: /[originalScheme]/[originalAuthority]/[originalPath] + const originalAuthority = originalUri.authority || ''; + return URI.from({ + scheme: AGENT_HOST_SCHEME, + authority: connectionAuthority, + path: `/${originalUri.scheme}${originalAuthority ? `/${originalAuthority}` : ''}${originalUri.path}`, + }); +} + +/** + * Extracts the original URI from a {@link AGENT_HOST_SCHEME} URI. + * + * The inverse of {@link toAgentHostUri}. + */ +export function fromAgentHostUri(agentHostUri: URI): URI { + // Path: /[originalScheme]/[originalAuthority]/[rest of original path] + const path = agentHostUri.path; + + // Find first segment boundary after leading / + const schemeEnd = path.indexOf('/', 1); + if (schemeEnd === -1) { + // Malformed — treat whole path as file scheme + return URI.from({ scheme: 'file', path }); + } + + const originalScheme = path.substring(1, schemeEnd); + + // Find second segment boundary (authority/path split) + const authorityEnd = path.indexOf('/', schemeEnd + 1); + if (authorityEnd === -1) { + // No path after authority + const originalAuthority = path.substring(schemeEnd + 1); + return URI.from({ scheme: originalScheme, authority: originalAuthority, path: '/' }); + } + + let originalAuthority = path.substring(schemeEnd + 1, authorityEnd); + let originalPath = path.substring(authorityEnd); + if (originalScheme === 'file') { + // file scheme URIs must have an authority of '' (not undefined) to be treated as absolute paths + originalPath = originalAuthority + originalPath; + originalAuthority = ''; + } + + return URI.from({ + scheme: originalScheme, + authority: originalAuthority || undefined, + path: originalPath, + }); +} + +/** + * Encode a remote address into an identifier that is safe for use in + * both URI schemes and URI authorities, and is collision-free. + * + * If the address contains only alphanumeric characters it is returned as-is. + * Otherwise it is url-safe base64-encoded (no padding) to guarantee the + * result contains only `[A-Za-z0-9_-]`. + */ +export function agentHostAuthority(address: string): string { + if (/^[a-zA-Z0-9]+$/.test(address)) { + return address; + } + return 'b64-' + encodeBase64(VSBuffer.fromString(address), false, true); +} + +/** + * Label formatter for {@link AGENT_HOST_SCHEME} URIs. Strips the two + * leading path segments (`/scheme/authority`) to display the original + * file path. + */ +export const AGENT_HOST_LABEL_FORMATTER: ResourceLabelFormatter = { + scheme: AGENT_HOST_SCHEME, + formatting: { + label: '${path}', + separator: '/', + stripPathSegments: 2, + }, +}; diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index e21f2c9cfd0..6309134eb57 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -8,7 +8,7 @@ import { IAuthorizationProtectedResourceMetadata } from '../../../base/common/oa import { URI } from '../../../base/common/uri.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import type { IActionEnvelope, INotification, ISessionAction } from './state/sessionActions.js'; -import type { IBrowseDirectoryResult, IStateSnapshot } from './state/sessionProtocol.js'; +import type { IBrowseDirectoryResult, IFetchContentResult, IStateSnapshot } from './state/sessionProtocol.js'; import { AttachmentType, PermissionKind, type IToolCallResult, type PolicyState } from './state/sessionState.js'; // IPC contract between the renderer and the agent host utility process. @@ -438,6 +438,12 @@ export interface IAgentService { * Used by the client to drive a remote folder picker before session creation. */ browseDirectory(uri: URI): Promise; + + /** + * Fetch stored content by URI from the agent host (e.g. file edit snapshots, + * or reading files from the remote filesystem). + */ + fetchContent(uri: string): Promise; } /** diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index b2b53dda873..4725e250dba 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -14,6 +14,7 @@ import { hasKey } from '../../../../base/common/types.js'; import { SessionLifecycle, ToolResultContentType, + IToolResultFileEditContent, type IActiveTurn, type IRootState, type ISessionState, @@ -115,6 +116,23 @@ export function getToolOutputText(result: IToolCallResult): string | undefined { return textParts.map(p => p.text).join('\n'); } +/** + * Extracts file edit content entries from a tool call result's `content` array. + * Returns an empty array if there are no file edit content parts. + */ +export function getToolFileEdits(result: IToolCallResult): IToolResultFileEditContent[] { + if (!result.content || result.content.length === 0) { + return []; + } + const edits: IToolResultFileEditContent[] = []; + for (const c of result.content) { + if (hasKey(c, { type: true }) && c.type === ToolResultContentType.FileEdit) { + edits.push(c); + } + } + return edits; +} + // ---- Factory helpers -------------------------------------------------------- export function createRootState(): IRootState { diff --git a/src/vs/platform/agentHost/electron-browser/agentHostService.ts b/src/vs/platform/agentHost/electron-browser/agentHostService.ts index da706baae2f..0e93d66c676 100644 --- a/src/vs/platform/agentHost/electron-browser/agentHostService.ts +++ b/src/vs/platform/agentHost/electron-browser/agentHostService.ts @@ -15,7 +15,7 @@ import { IConfigurationService } from '../../configuration/common/configuration. import { ILogService } from '../../log/common/log.js'; import { AgentHostEnabledSettingId, AgentHostIpcChannels, IAgentCreateSessionConfig, IAgentDescriptor, IAgentHostService, IAgentService, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult, IResourceMetadata } from '../common/agentService.js'; import type { IActionEnvelope, INotification, ISessionAction } from '../common/state/sessionActions.js'; -import type { IBrowseDirectoryResult, IStateSnapshot } from '../common/state/sessionProtocol.js'; +import type { IBrowseDirectoryResult, IFetchContentResult, IStateSnapshot } from '../common/state/sessionProtocol.js'; import { revive } from '../../../base/common/marshalling.js'; import { URI } from '../../../base/common/uri.js'; @@ -119,6 +119,9 @@ class AgentHostServiceClient extends Disposable implements IAgentHostService { browseDirectory(uri: URI): Promise { return this._proxy.browseDirectory(uri); } + fetchContent(uri: string): Promise { + return this._proxy.fetchContent(uri); + } async restartAgentHost(): Promise { // Restart is handled by the main process side } diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 9a23ecdb1ee..a5df7e1223f 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -19,7 +19,6 @@ import { type ISessionSummary, type URI as ProtocolURI, } from '../common/state/sessionState.js'; import { mapProgressEventToActions } from './agentEventMapper.js'; -import { FileEditTracker } from './copilot/fileEditTracker.js'; import type { IProtocolSideEffectHandler } from './protocolServerHandler.js'; import { SessionStateManager } from './sessionStateManager.js'; @@ -276,19 +275,15 @@ export class AgentSideEffects extends Disposable implements IProtocolSideEffectH } async handleFetchContent(uri: string): Promise { - const fileUri = FileEditTracker.resolveContentUri(uri, this._options.sessionDataService); - if (!fileUri) { - throw new ProtocolError(AhpErrorCodes.ContentNotFound, `Unknown content URI: ${uri}`); - } try { - const content = await this._fileService.readFile(fileUri); + const content = await this._fileService.readFile(URI.parse(uri)); return { data: content.value.toString(), encoding: ContentEncoding.Utf8, contentType: 'text/plain', }; - } catch { - throw new ProtocolError(AhpErrorCodes.ContentNotFound, `Content not found: ${uri}`); + } catch (_e) { + throw new ProtocolError(AhpErrorCodes.NotFound, `Content not found: ${uri}`); } } diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 6bdb4aa458a..b482b88afbb 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -379,8 +379,7 @@ export class CopilotAgent extends Disposable implements IAgent { return { onPreToolUse: async (input: { toolName: string; toolArgs: unknown }, invocation: { sessionId: string }) => { if (isEditTool(input.toolName)) { - const params = input.toolArgs as Record | undefined; - const filePath = getEditFilePath(params); + const filePath = getEditFilePath(input.toolArgs); if (filePath) { const tracker = this._getOrCreateEditTracker(invocation.sessionId); await tracker.trackEditStart(filePath); @@ -389,8 +388,7 @@ export class CopilotAgent extends Disposable implements IAgent { }, onPostToolUse: async (input: { toolName: string; toolArgs: unknown }, invocation: { sessionId: string }) => { if (isEditTool(input.toolName)) { - const params = input.toolArgs as Record | undefined; - const filePath = getEditFilePath(params); + const filePath = getEditFilePath(input.toolArgs); if (filePath) { const tracker = this._editTrackers.get(invocation.sessionId); await tracker?.completeEdit(filePath); diff --git a/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts b/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts index 7702844d418..397c4b0fb71 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts @@ -55,7 +55,7 @@ interface ICopilotShellToolArgs { /** Parameters for file tools (`view`, `edit`, `write`). */ interface ICopilotFileToolArgs { - file_path: string; + path: string; } /** Parameters for the `grep` tool. */ @@ -88,9 +88,17 @@ export function isEditTool(toolName: string): boolean { /** * Extracts the target file path from an edit tool's parameters, if available. */ -export function getEditFilePath(parameters: Record | undefined): string | undefined { +export function getEditFilePath(parameters: unknown): string | undefined { + if (typeof parameters === 'string') { + try { + parameters = JSON.parse(parameters); + } catch { + return undefined; + } + } + const args = parameters as ICopilotFileToolArgs | undefined; - return args?.file_path; + return args?.path; } /** Set of tool names that execute shell commands (bash or powershell). */ @@ -163,22 +171,22 @@ export function getInvocationMessage(toolName: string, displayName: string, para switch (toolName) { case CopilotToolName.View: { const args = parameters as ICopilotFileToolArgs | undefined; - if (args?.file_path) { - return localize('toolInvoke.viewFile', "Reading {0}", args.file_path); + if (args?.path) { + return localize('toolInvoke.viewFile', "Reading {0}", args.path); } return localize('toolInvoke.view', "Reading file"); } case CopilotToolName.Edit: { const args = parameters as ICopilotFileToolArgs | undefined; - if (args?.file_path) { - return localize('toolInvoke.editFile', "Editing {0}", args.file_path); + if (args?.path) { + return localize('toolInvoke.editFile', "Editing {0}", args.path); } return localize('toolInvoke.edit', "Editing file"); } case CopilotToolName.Write: { const args = parameters as ICopilotFileToolArgs | undefined; - if (args?.file_path) { - return localize('toolInvoke.writeFile', "Writing to {0}", args.file_path); + if (args?.path) { + return localize('toolInvoke.writeFile', "Writing to {0}", args.path); } return localize('toolInvoke.write', "Writing file"); } @@ -218,22 +226,22 @@ export function getPastTenseMessage(toolName: string, displayName: string, param switch (toolName) { case CopilotToolName.View: { const args = parameters as ICopilotFileToolArgs | undefined; - if (args?.file_path) { - return localize('toolComplete.viewFile', "Read {0}", args.file_path); + if (args?.path) { + return localize('toolComplete.viewFile', "Read {0}", args.path); } return localize('toolComplete.view', "Read file"); } case CopilotToolName.Edit: { const args = parameters as ICopilotFileToolArgs | undefined; - if (args?.file_path) { - return localize('toolComplete.editFile', "Edited {0}", args.file_path); + if (args?.path) { + return localize('toolComplete.editFile', "Edited {0}", args.path); } return localize('toolComplete.edit', "Edited file"); } case CopilotToolName.Write: { const args = parameters as ICopilotFileToolArgs | undefined; - if (args?.file_path) { - return localize('toolComplete.writeFile', "Wrote to {0}", args.file_path); + if (args?.path) { + return localize('toolComplete.writeFile', "Wrote to {0}", args.path); } return localize('toolComplete.write', "Wrote file"); } diff --git a/src/vs/platform/agentHost/node/copilot/fileEditTracker.ts b/src/vs/platform/agentHost/node/copilot/fileEditTracker.ts index 5d65e6e5ca8..b7e959c5a80 100644 --- a/src/vs/platform/agentHost/node/copilot/fileEditTracker.ts +++ b/src/vs/platform/agentHost/node/copilot/fileEditTracker.ts @@ -10,18 +10,16 @@ import { ILogService } from '../../../log/common/log.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; import { ToolResultContentType, type IToolResultFileEditContent } from '../../common/state/sessionState.js'; -/** Scheme used for content URIs served via fetchContent. */ -export const AGENT_CONTENT_SCHEME = 'agenthost-content'; - /** * Tracks file edits made by tools in a session by snapshotting file content - * before and after each edit tool invocation. + * before each edit tool invocation. * - * Before/after content is stored in the session data directory under - * `file-edits/{editKey}/before` and `file-edits/{editKey}/after`. + * Before-snapshots are stored in the session data directory under + * `file-edits/{editKey}/before` and addressable via URIs of the form: + * `agenthost-content://[authority]/[sessionId]/file-edits/{editKey}/before` * - * Content is addressable via URIs of the form: - * `agenthost-content:///{sessionId}/file-edits/{editKey}/before` + * The after-content is the real file on the agent host, accessible via + * the `agenthost://` filesystem provider. */ export class FileEditTracker { @@ -29,7 +27,7 @@ export class FileEditTracker { * Pending edits keyed by file path. The `onPreToolUse` hook stores * entries here; `completeEdit` pops them when the tool finishes. */ - private readonly _pendingEdits = new Map }>(); + private readonly _pendingEdits = new Map }>(); /** * Completed edits keyed by file path. The `onPostToolUse` hook stores @@ -59,14 +57,15 @@ export class FileEditTracker { const beforeUri = URI.joinPath(sessionDataDir, 'file-edits', editKey, 'before'); const snapshotDone = this._snapshotFile(filePath, beforeUri); - this._pendingEdits.set(filePath, { editKey, snapshotDone }); + this._pendingEdits.set(filePath, { editKey, beforeUri, snapshotDone }); await snapshotDone; } /** * Call from the `onPostToolUse` hook after an edit tool finishes. - * Snapshots the file's current content as the "after" state and stores - * the result for later synchronous retrieval via {@link takeCompletedEdit}. + * Stores the result for later synchronous retrieval via {@link takeCompletedEdit}. + * The `beforeURI` points to the stored snapshot; the `afterURI` is + * the real file path (the tool already modified it on disk). * * @param filePath - Absolute path of the file that was edited. */ @@ -78,42 +77,16 @@ export class FileEditTracker { this._pendingEdits.delete(filePath); await pending.snapshotDone; + // Snapshot the after-content into session data so it remains + // stable even if the file is modified again later. const sessionDataDir = this._sessionDataService.getSessionDataDirById(this._sessionId); - const editDir = URI.joinPath(sessionDataDir, 'file-edits', pending.editKey); - - // Snapshot the file after the edit - const afterUri = URI.joinPath(editDir, 'after'); - let afterContent: string; - try { - const fileUri = URI.file(filePath); - const afterData = await this._fileService.readFile(fileUri); - afterContent = afterData.value.toString(); - await this._fileService.writeFile(afterUri, afterData.value); - } catch { - afterContent = ''; - await this._fileService.writeFile(afterUri, VSBuffer.fromString('')).catch(() => { }); - } - - // Read the before content for diff stats - let beforeContent: string; - try { - const beforeData = await this._fileService.readFile(URI.joinPath(editDir, 'before')); - beforeContent = beforeData.value.toString(); - } catch { - beforeContent = ''; - } - - const beforeLines = beforeContent ? beforeContent.split('\n').length : 0; - const afterLines = afterContent ? afterContent.split('\n').length : 0; + const afterUri = URI.joinPath(sessionDataDir, 'file-edits', pending.editKey, 'after'); + await this._snapshotFile(filePath, afterUri); this._completedEdits.set(filePath, { type: ToolResultContentType.FileEdit, - beforeURI: `${AGENT_CONTENT_SCHEME}:///${this._sessionId}/file-edits/${pending.editKey}/before`, - afterURI: `${AGENT_CONTENT_SCHEME}:///${this._sessionId}/file-edits/${pending.editKey}/after`, - diff: { - added: Math.max(0, afterLines - beforeLines), - removed: Math.max(0, beforeLines - afterLines), - }, + beforeURI: pending.beforeUri.toString(), + afterURI: afterUri.toString(), }); } @@ -139,32 +112,6 @@ export class FileEditTracker { await this._fileService.writeFile(targetUri, VSBuffer.fromString('')).catch(() => { }); } } - - /** - * Resolves an `agenthost-content:` URI to the stored file on disk. - * Returns `undefined` if the URI doesn't match the expected format. - */ - static resolveContentUri(uri: string, sessionDataService: ISessionDataService): URI | undefined { - // agenthost-content:///sessionId/file-edits/editKey/before|after - try { - const parsed = URI.parse(uri); - if (parsed.scheme !== AGENT_CONTENT_SCHEME) { - return undefined; - } - const parts = parsed.path.split('/').filter(Boolean); - if (parts.length !== 4 || parts[1] !== 'file-edits') { - return undefined; - } - const [sessionId, , editKey, snapshot] = parts; - if (snapshot !== 'before' && snapshot !== 'after') { - return undefined; - } - const sessionDataDir = sessionDataService.getSessionDataDirById(sessionId); - return URI.joinPath(sessionDataDir, 'file-edits', editKey, snapshot); - } catch { - return undefined; - } - } } let _editKeyCounter = 0; diff --git a/src/vs/sessions/contrib/remoteAgentHost/test/browser/agentHostFileSystemProvider.test.ts b/src/vs/platform/agentHost/test/common/agentHostFileSystemProvider.test.ts similarity index 72% rename from src/vs/sessions/contrib/remoteAgentHost/test/browser/agentHostFileSystemProvider.test.ts rename to src/vs/platform/agentHost/test/common/agentHostFileSystemProvider.test.ts index b4396b54d59..e2efbd33cdb 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/test/browser/agentHostFileSystemProvider.test.ts +++ b/src/vs/platform/agentHost/test/common/agentHostFileSystemProvider.test.ts @@ -4,29 +4,25 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { URI } from '../../../../../base/common/uri.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { AGENT_HOST_FS_SCHEME, agentHostRemotePath, agentHostUri } from '../../browser/agentHostFileSystemProvider.js'; -import { agentHostAuthority } from '../../browser/remoteAgentHost.contribution.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { agentHostRemotePath, agentHostUri } from '../../common/agentHostFileSystemProvider.js'; +import { AGENT_HOST_SCHEME, agentHostAuthority } from '../../common/agentHostUri.js'; suite('AgentHostFileSystemProvider - URI helpers', () => { ensureNoDisposablesAreLeakedInTestSuite(); test('agentHostUri builds correct URI', () => { - const uri = agentHostUri('localhost:8081', '/home/user/project'); - assert.strictEqual(uri.scheme, AGENT_HOST_FS_SCHEME); - assert.strictEqual(uri.authority, 'localhost:8081'); - assert.strictEqual(uri.path, '/home/user/project'); + const uri = agentHostUri('localhost', '/home/user/project'); + assert.strictEqual(uri.scheme, AGENT_HOST_SCHEME); + assert.strictEqual(uri.authority, 'localhost'); + // path encodes file scheme: /file//home/user/project + assert.ok(uri.path.includes('/home/user/project')); }); - test('agentHostUri defaults to root path', () => { - const uri = agentHostUri('localhost:8081', ''); - assert.strictEqual(uri.path, '/'); - }); - - test('agentHostRemotePath extracts the path component', () => { - const uri = URI.from({ scheme: AGENT_HOST_FS_SCHEME, authority: 'host', path: '/some/path' }); + test('agentHostRemotePath extracts the original path', () => { + const uri = agentHostUri('host', '/some/path'); assert.strictEqual(agentHostRemotePath(uri), '/some/path'); }); @@ -61,7 +57,7 @@ suite('AgentHostAuthority - encoding', () => { const addresses = ['localhost', 'localhost:8081', 'user@host:8080', 'host with spaces', '192.168.1.1:9090']; for (const address of addresses) { const authority = agentHostAuthority(address); - const uri = URI.from({ scheme: AGENT_HOST_FS_SCHEME, authority, path: '/test' }); + const uri = URI.from({ scheme: AGENT_HOST_SCHEME, authority, path: '/test' }); assert.strictEqual(uri.authority, authority, `authority for '${address}' must round-trip through URI`); } }); diff --git a/src/vs/platform/agentHost/test/node/fileEditTracker.test.ts b/src/vs/platform/agentHost/test/node/fileEditTracker.test.ts index 3fad7ee4a70..8679dab91e4 100644 --- a/src/vs/platform/agentHost/test/node/fileEditTracker.test.ts +++ b/src/vs/platform/agentHost/test/node/fileEditTracker.test.ts @@ -15,7 +15,7 @@ import { NullLogService } from '../../../log/common/log.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; import { ToolResultContentType } from '../../common/state/sessionState.js'; import { SessionDataService } from '../../node/sessionDataService.js'; -import { AGENT_CONTENT_SCHEME, FileEditTracker } from '../../node/copilot/fileEditTracker.js'; +import { FileEditTracker } from '../../node/copilot/fileEditTracker.js'; suite('FileEditTracker', () => { @@ -48,8 +48,10 @@ suite('FileEditTracker', () => { const fileEdit = tracker.takeCompletedEdit('/workspace/test.txt'); assert.ok(fileEdit); assert.strictEqual(fileEdit.type, ToolResultContentType.FileEdit); - assert.strictEqual(fileEdit.diff?.added, 1); - assert.strictEqual(fileEdit.diff?.removed, 0); + // Both URIs point to snapshots in the session data directory + const sessionDir = sessionDataService.getSessionDataDirById('test-session'); + assert.ok(fileEdit.beforeURI.startsWith(sessionDir.toString())); + assert.ok(fileEdit.afterURI.startsWith(sessionDir.toString())); }); test('tracks edit for newly created file (no before content)', async () => { @@ -62,8 +64,8 @@ suite('FileEditTracker', () => { const fileEdit = tracker.takeCompletedEdit('/workspace/new-file.txt'); assert.ok(fileEdit); - assert.strictEqual(fileEdit.diff?.added, 2); - assert.strictEqual(fileEdit.diff?.removed, 0); + const sessionDir = sessionDataService.getSessionDataDirById('test-session'); + assert.ok(fileEdit.afterURI.startsWith(sessionDir.toString())); }); test('takeCompletedEdit returns undefined for unknown file path', () => { @@ -71,53 +73,20 @@ suite('FileEditTracker', () => { assert.strictEqual(result, undefined); }); - test('content URIs use the correct scheme and format', async () => { + test('before and after snapshot content can be read back', async () => { const sourceFs = disposables.add(new InMemoryFileSystemProvider()); disposables.add(fileService.registerProvider(Schemas.file, sourceFs)); - await fileService.writeFile(URI.file('/workspace/file.ts'), VSBuffer.fromString('code')); + await fileService.writeFile(URI.file('/workspace/file.ts'), VSBuffer.fromString('original')); await tracker.trackEditStart('/workspace/file.ts'); - await fileService.writeFile(URI.file('/workspace/file.ts'), VSBuffer.fromString('new code')); + await fileService.writeFile(URI.file('/workspace/file.ts'), VSBuffer.fromString('modified')); await tracker.completeEdit('/workspace/file.ts'); const fileEdit = tracker.takeCompletedEdit('/workspace/file.ts'); assert.ok(fileEdit); - assert.ok(fileEdit.beforeURI.startsWith(`${AGENT_CONTENT_SCHEME}:///test-session/file-edits/`)); - assert.ok(fileEdit.afterURI.startsWith(`${AGENT_CONTENT_SCHEME}:///test-session/file-edits/`)); - assert.ok(fileEdit.beforeURI.endsWith('/before')); - assert.ok(fileEdit.afterURI.endsWith('/after')); - }); - - test('resolveContentUri maps to session data directory', async () => { - const sourceFs = disposables.add(new InMemoryFileSystemProvider()); - disposables.add(fileService.registerProvider(Schemas.file, sourceFs)); - await fileService.writeFile(URI.file('/workspace/resolve.txt'), VSBuffer.fromString('x')); - - await tracker.trackEditStart('/workspace/resolve.txt'); - await tracker.completeEdit('/workspace/resolve.txt'); - - const fileEdit = tracker.takeCompletedEdit('/workspace/resolve.txt'); - assert.ok(fileEdit); - - const resolved = FileEditTracker.resolveContentUri(fileEdit.beforeURI, sessionDataService); - assert.ok(resolved); - const sessionDir = sessionDataService.getSessionDataDirById('test-session'); - assert.ok(resolved.toString().startsWith(sessionDir.toString())); - }); - - test('resolveContentUri returns undefined for unknown scheme', () => { - const resolved = FileEditTracker.resolveContentUri( - 'https:///test-session/file-edits/tc-1/before', - sessionDataService, - ); - assert.strictEqual(resolved, undefined); - }); - - test('resolveContentUri returns undefined for invalid path', () => { - const resolved = FileEditTracker.resolveContentUri( - `${AGENT_CONTENT_SCHEME}:///test-session/invalid/path`, - sessionDataService, - ); - assert.strictEqual(resolved, undefined); + const beforeContent = await fileService.readFile(URI.parse(fileEdit.beforeURI)); + assert.strictEqual(beforeContent.value.toString(), 'original'); + const afterContent = await fileService.readFile(URI.parse(fileEdit.afterURI)); + assert.strictEqual(afterContent.value.toString(), 'modified'); }); }); diff --git a/src/vs/platform/label/common/label.ts b/src/vs/platform/label/common/label.ts index 1da00285dc8..11d3f7c7d92 100644 --- a/src/vs/platform/label/common/label.ts +++ b/src/vs/platform/label/common/label.ts @@ -66,4 +66,10 @@ export interface ResourceLabelFormatting { workspaceTooltip?: string; authorityPrefix?: string; stripPathStartingSeparator?: boolean; + /** + * Number of leading path segments to strip from `${path}` before + * substitution. For example, a value of `2` turns + * `/scheme/authority/rest/of/path` into `/rest/of/path`. + */ + stripPathSegments?: number; } diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts index 92941d06af8..66ff48bdbbf 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts @@ -4,42 +4,30 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable, DisposableMap, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; -import { encodeBase64, VSBuffer } from '../../../../base/common/buffer.js'; -import { ILogService, LogLevel } from '../../../../platform/log/common/log.js'; import { URI } from '../../../../base/common/uri.js'; -import { IOutputService } from '../../../../workbench/services/output/common/output.js'; -import { IAuthenticationService } from '../../../../workbench/services/authentication/common/authentication.js'; -import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js'; +import { AgentHostFileSystemProvider } from '../../../../platform/agentHost/common/agentHostFileSystemProvider.js'; +import { AGENT_HOST_LABEL_FORMATTER, AGENT_HOST_SCHEME, agentHostAuthority } from '../../../../platform/agentHost/common/agentHostUri.js'; import { type AgentProvider, type IAgentConnection } from '../../../../platform/agentHost/common/agentService.js'; +import { IRemoteAgentHostService } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; import { isSessionAction } from '../../../../platform/agentHost/common/state/sessionActions.js'; import { SessionClientState } from '../../../../platform/agentHost/common/state/sessionClientState.js'; import { ROOT_STATE_URI, type IAgentInfo, type IRootState } from '../../../../platform/agentHost/common/state/sessionState.js'; -import { IRemoteAgentHostService } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { ILabelService } from '../../../../platform/label/common/label.js'; +import { ILogService, LogLevel } from '../../../../platform/log/common/log.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; -import { IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; -import { ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js'; +import { resolveTokenForResource } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostAuth.js'; import { AgentHostLanguageModelProvider } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.js'; import { AgentHostSessionHandler } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.js'; import { AgentHostSessionListController } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.js'; +import { IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js'; +import { IAuthenticationService } from '../../../../workbench/services/authentication/common/authentication.js'; +import { IOutputService } from '../../../../workbench/services/output/common/output.js'; import { ISessionsManagementService } from '../../../contrib/sessions/browser/sessionsManagementService.js'; -import { IFileService } from '../../../../platform/files/common/files.js'; -import { AGENT_HOST_FS_SCHEME, AgentHostFileSystemProvider } from './agentHostFileSystemProvider.js'; -/** - * Encode a remote address into an identifier that is safe for use in - * both URI schemes and URI authorities, and is collision-free. - * - * If the address contains only alphanumeric characters it is returned as-is. - * Otherwise it is url-safe base64-encoded (no padding) to guarantee the - * result contains only `[A-Za-z0-9_-]`. - */ -export function agentHostAuthority(address: string): string { - if (/^[a-zA-Z0-9]+$/.test(address)) { - return address; - } - return 'b64-' + encodeBase64(VSBuffer.fromString(address), false, true); -} /** Per-connection state bundle, disposed when a connection is removed. */ class ConnectionState extends Disposable { @@ -89,13 +77,17 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc @IDefaultAccountService private readonly _defaultAccountService: IDefaultAccountService, @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, @IFileService private readonly _fileService: IFileService, + @ILabelService private readonly _labelService: ILabelService, ) { super(); // Register a single read-only filesystem provider for all remote agent // hosts. Individual connections are identified by the URI authority. - this._fsProvider = this._register(this._instantiationService.createInstance(AgentHostFileSystemProvider)); - this._register(this._fileService.registerProvider(AGENT_HOST_FS_SCHEME, this._fsProvider)); + this._fsProvider = this._register(new AgentHostFileSystemProvider()); + this._register(this._fileService.registerProvider(AGENT_HOST_SCHEME, this._fsProvider)); + + // Display agent-host URIs with the original file path + this._register(this._labelService.registerFormatter(AGENT_HOST_LABEL_FORMATTER)); // Reconcile when connections change (added/removed/reconnected) this._register(this._remoteAgentHostService.onDidChangeConnections(() => { @@ -147,9 +139,9 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc this._connections.set(address, connState); const store = connState.store; - // Track authority -> address mapping for FS provider routing + // Track authority -> connection mapping for FS provider routing const authority = agentHostAuthority(address); - store.add(this._fsProvider.registerAuthority(authority, address)); + store.add(this._fsProvider.registerAuthority(authority, connection)); // Forward non-session actions to client state store.add(connection.onDidAction(envelope => { @@ -280,6 +272,7 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc fullName: displayName, description: agent.description, connection, + connectionAuthority: sanitized, extensionId: 'vscode.remote-agent-host', extensionDisplayName: 'Remote Agent Host', resolveWorkingDirectory, @@ -336,21 +329,8 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc * Resolve a bearer token for a set of authorization servers using the * standard VS Code authentication service provider resolution. */ - private async _resolveTokenForResource(resourceServer: URI, authorizationServers: readonly string[], scopes: readonly string[]): Promise { - for (const server of authorizationServers) { - const serverUri = URI.parse(server); - const providerId = await this._authenticationService.getOrActivateProviderIdForServer(serverUri, resourceServer); - if (!providerId) { - this._logService.trace(`[RemoteAgentHost] No auth provider found for server: ${server}`); - continue; - } - - const sessions = await this._authenticationService.getSessions(providerId, [...scopes], { authorizationServer: serverUri }, true); - if (sessions.length > 0) { - return sessions[0].accessToken; - } - } - return undefined; + private _resolveTokenForResource(resourceServer: URI, authorizationServers: readonly string[], scopes: readonly string[]): Promise { + return resolveTokenForResource(resourceServer, authorizationServers, scopes, this._authenticationService, this._logService, '[RemoteAgentHost]'); } /** diff --git a/src/vs/sessions/contrib/workspace/browser/workspaceFolderManagement.ts b/src/vs/sessions/contrib/workspace/browser/workspaceFolderManagement.ts index 5c998b71832..fe3a528e0ba 100644 --- a/src/vs/sessions/contrib/workspace/browser/workspaceFolderManagement.ts +++ b/src/vs/sessions/contrib/workspace/browser/workspaceFolderManagement.ts @@ -16,7 +16,7 @@ import { autorun } from '../../../../base/common/observable.js'; import { IWorkspaceFolderCreationData } from '../../../../platform/workspaces/common/workspaces.js'; import { getGitHubRemoteFileDisplayName } from '../../fileTreeView/browser/githubFileSystemProvider.js'; import { Queue } from '../../../../base/common/async.js'; -import { AGENT_HOST_FS_SCHEME } from '../../remoteAgentHost/browser/agentHostFileSystemProvider.js'; +import { AGENT_HOST_SCHEME } from '../../../../platform/agentHost/common/agentHostUri.js'; export class WorkspaceFolderManagementContribution extends Disposable implements IWorkbenchContribution { @@ -76,7 +76,7 @@ export class WorkspaceFolderManagementContribution extends Disposable implements if (session.repository) { // Remote agent host sessions use a read-only FS provider that // should not be added as a workspace folder. - if (session.repository.scheme === AGENT_HOST_FS_SCHEME) { + if (session.repository.scheme === AGENT_HOST_SCHEME) { return undefined; } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostAuth.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostAuth.ts new file mode 100644 index 00000000000..75ad8c68014 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostAuth.ts @@ -0,0 +1,66 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ILogService } from '../../../../../../platform/log/common/log.js'; +import { IAuthenticationService } from '../../../../../services/authentication/common/authentication.js'; + +/** + * Resolves a bearer token for a protected resource by trying each + * authorization server in order. First attempts an exact scope match, + * then falls back to finding the session whose scopes are the narrowest + * superset of the requested scopes. + */ +export async function resolveTokenForResource( + resourceServer: URI, + authorizationServers: readonly string[], + scopes: readonly string[], + authenticationService: IAuthenticationService, + logService: ILogService, + logPrefix: string, +): Promise { + for (const server of authorizationServers) { + const serverUri = URI.parse(server); + const providerId = await authenticationService.getOrActivateProviderIdForServer(serverUri, resourceServer); + if (!providerId) { + logService.trace(`${logPrefix} No auth provider found for server: ${server}`); + continue; + } + logService.trace(`${logPrefix} Resolved auth provider '${providerId}' for server: ${server}`); + + // Try exact scope match first + const sessions = await authenticationService.getSessions(providerId, [...scopes], { authorizationServer: serverUri }, true); + if (sessions.length > 0) { + return sessions[0].accessToken; + } + + // Fall back: get all sessions and find the narrowest superset of requested scopes + const allSessions = await authenticationService.getSessions(providerId, undefined, { authorizationServer: serverUri }, true); + const requestedSet = new Set(scopes); + let bestToken: string | undefined; + let bestExtraScopes = Infinity; + for (const session of allSessions) { + const sessionScopes = new Set(session.scopes); + let isSuperset = true; + for (const scope of requestedSet) { + if (!sessionScopes.has(scope)) { + isSuperset = false; + break; + } + } + if (isSuperset) { + const extraScopes = sessionScopes.size - requestedSet.size; + if (extraScopes < bestExtraScopes) { + bestExtraScopes = extraScopes; + bestToken = session.accessToken; + } + } + } + if (bestToken) { + return bestToken; + } + } + return undefined; +} diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts index c87e45bead2..b8023374610 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts @@ -6,12 +6,16 @@ import { Disposable, DisposableMap, DisposableStore, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { URI } from '../../../../../../base/common/uri.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { AgentHostFileSystemProvider } from '../../../../../../platform/agentHost/common/agentHostFileSystemProvider.js'; import { IAgentHostService, AgentHostEnabledSettingId, type AgentProvider } from '../../../../../../platform/agentHost/common/agentService.js'; +import { AGENT_HOST_LABEL_FORMATTER, AGENT_HOST_SCHEME } from '../../../../../../platform/agentHost/common/agentHostUri.js'; import { isSessionAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { SessionClientState } from '../../../../../../platform/agentHost/common/state/sessionClientState.js'; import { ROOT_STATE_URI, type IAgentInfo, type IRootState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IDefaultAccountService } from '../../../../../../platform/defaultAccount/common/defaultAccount.js'; +import { IFileService } from '../../../../../../platform/files/common/files.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { ILabelService } from '../../../../../../platform/label/common/label.js'; import { ILogService, LogLevel } from '../../../../../../platform/log/common/log.js'; import { Registry } from '../../../../../../platform/registry/common/platform.js'; import { IWorkbenchContribution } from '../../../../../common/contributions.js'; @@ -19,6 +23,7 @@ import { IAuthenticationService } from '../../../../../services/authentication/c import { Extensions, IOutputChannel, IOutputChannelRegistry, IOutputService } from '../../../../../services/output/common/output.js'; import { IChatSessionsService } from '../../../common/chatSessionsService.js'; import { ILanguageModelsService } from '../../../common/languageModels.js'; +import { resolveTokenForResource } from './agentHostAuth.js'; import { AgentHostLanguageModelProvider } from './agentHostLanguageModelProvider.js'; import { AgentHostSessionHandler } from './agentHostSessionHandler.js'; import { AgentHostSessionListController } from './agentHostSessionListController.js'; @@ -55,6 +60,8 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr @ILanguageModelsService private readonly _languageModelsService: ILanguageModelsService, @IOutputService private readonly _outputService: IOutputService, @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IFileService private readonly _fileService: IFileService, + @ILabelService private readonly _labelService: ILabelService, @IConfigurationService configurationService: IConfigurationService, ) { super(); @@ -65,6 +72,15 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr this._setupIpcLogging(); + // Register a read-only filesystem provider for the local agent host + // so that agent-host-scheme URIs with 'local' authority can be resolved. + const fsProvider = this._register(new AgentHostFileSystemProvider()); + this._register(fsProvider.registerAuthority('local', this._agentHostService)); + this._register(this._fileService.registerProvider(AGENT_HOST_SCHEME, fsProvider)); + + // Display agent-host URIs with the original file path + this._register(this._labelService.registerFormatter(AGENT_HOST_LABEL_FORMATTER)); + // Shared client state for protocol reconciliation this._clientState = this._register(new SessionClientState(this._agentHostService.clientId, this._logService)); @@ -219,6 +235,7 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr fullName: agent.displayName, description: agent.description, connection: this._agentHostService, + connectionAuthority: 'local', resolveAuthentication: () => this._resolveAuthenticationInteractively(), })); store.add(this._chatSessionsService.registerChatSessionContentProvider(sessionType, sessionHandler)); @@ -269,24 +286,8 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr * Resolve a bearer token for a set of authorization servers using the * standard VS Code authentication service provider resolution. */ - private async _resolveTokenForResource(resourceServer: URI, authorizationServers: readonly string[], scopes: readonly string[]): Promise { - for (const server of authorizationServers) { - const serverUri = URI.parse(server); - const providerId = await this._authenticationService.getOrActivateProviderIdForServer(serverUri, resourceServer); - if (!providerId) { - this._logService.trace(`[AgentHost] No auth provider found for server: ${server}`); - continue; - } - this._logService.trace(`[AgentHost] Resolved auth provider '${providerId}' for server: ${server}`); - - const sessions = await this._authenticationService.getSessions(providerId, [...scopes], { authorizationServer: serverUri }, true); - if (sessions.length > 0) { - return sessions[0].accessToken; - } - - this._logService.trace(`[AgentHost] No sessions found for provider '${providerId}'`); - } - return undefined; + private _resolveTokenForResource(resourceServer: URI, authorizationServers: readonly string[], scopes: readonly string[]): Promise { + return resolveTokenForResource(resourceServer, authorizationServers, scopes, this._authenticationService, this._logService, '[AgentHost]'); } /** 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 c12d76c2766..af846c596bd 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -24,11 +24,12 @@ import { getToolKind, getToolLanguage } from '../../../../../../platform/agentHo import { AttachmentType, ToolCallStatus, TurnState, type IMessageAttachment } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { ChatAgentLocation, ChatModeKind } from '../../../common/constants.js'; import { IChatAgentData, IChatAgentImplementation, IChatAgentRequest, IChatAgentResult, IChatAgentService } from '../../../common/participants/chatAgents.js'; -import { IChatProgress, IChatToolInvocation, ToolConfirmKind } from '../../../common/chatService/chatService.js'; +import { IChatProgress, IChatService, IChatToolInvocation, ToolConfirmKind } from '../../../common/chatService/chatService.js'; import { ChatToolInvocation } from '../../../common/model/chatProgressTypes/chatToolInvocation.js'; import { IChatSession, IChatSessionContentProvider, IChatSessionHistoryItem } from '../../../common/chatSessionsService.js'; +import { toAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; import { getAgentHostIcon } from '../agentSessions.js'; -import { turnsToHistory, toolCallStateToInvocation, permissionToConfirmation, finalizeToolInvocation } from './stateToProgressAdapter.js'; +import { turnsToHistory, toolCallStateToInvocation, permissionToConfirmation, finalizeToolInvocation, type IToolCallFileEdit } from './stateToProgressAdapter.js'; // ============================================================================= // AgentHostSessionHandler — renderer-side handler for a single agent host @@ -89,6 +90,8 @@ export interface IAgentHostSessionHandlerConfig { readonly description: string; /** The agent connection to use for this handler. */ readonly connection: IAgentConnection; + /** Sanitized connection authority for constructing vscode-agent-host:// URIs. */ + readonly connectionAuthority: string; /** Extension identifier for the registered agent. Defaults to 'vscode.agent-host'. */ readonly extensionId?: string; /** Extension display name for the registered agent. Defaults to 'Agent Host'. */ @@ -119,6 +122,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC constructor( config: IAgentHostSessionHandlerConfig, @IChatAgentService private readonly _chatAgentService: IChatAgentService, + @IChatService private readonly _chatService: IChatService, @ILogService private readonly _logService: ILogService, @IProductService private readonly _productService: IProductService, @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, @@ -310,11 +314,14 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const done = new Promise(resolve => { resolveDone = resolve; }); let finished = false; - const finish = () => { + const pendingFileEdits: Promise[] = []; + const finish = async () => { if (finished) { return; } finished = true; + // Wait for any in-flight file edit operations before finalizing + await Promise.allSettled(pendingFileEdits); // Finalize any outstanding tool invocations for (const [, invocation] of activeToolInvocations) { invocation.didExecuteTool(undefined); @@ -370,7 +377,12 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } } else if (tc.status === ToolCallStatus.Completed || tc.status === ToolCallStatus.Cancelled) { activeToolInvocations.delete(toolCallId); - finalizeToolInvocation(existing, tc); + const fileEdits = finalizeToolInvocation(existing, tc); + if (fileEdits.length > 0) { + pendingFileEdits.push( + this._applyFileEdits(request.sessionResource, request, fileEdits, progress) + ); + } } else if (tc.status === ToolCallStatus.Running || tc.status === ToolCallStatus.PendingConfirmation) { // Tool transitioned from streaming to ready — update the invocation // with the now-available invocationMessage and toolSpecificData. @@ -434,6 +446,51 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC await done; } + // ---- File edit routing --------------------------------------------------- + + /** + * Routes file edits from completed tool calls through the editing session's + * external edits pipeline. Calls start/stop in sequence since the edit has + * already happened on the remote by the time we receive the tool completion. + */ + private async _applyFileEdits( + sessionResource: URI, + request: IChatAgentRequest, + fileEdits: IToolCallFileEdit[], + progress: (parts: IChatProgress[]) => void, + ): Promise { + const chatSession = this._chatService.getSession(sessionResource); + const editingSession = chatSession?.editingSession; + const response = chatSession?.getRequests().find(req => req.id === request.requestId)?.response; + if (!editingSession || !response) { + return; + } + + const authority = this._config.connectionAuthority; + const wrapUri = (uri: URI) => toAgentHostUri(uri, authority); + + for (const edit of fileEdits) { + const operationId = this._nextOperationId++; + const resource = wrapUri(edit.resource); + const beforeUri = wrapUri(edit.beforeContentUri); + const afterUri = wrapUri(edit.afterContentUri); + + const startProgress = await editingSession.startExternalEdits( + response, operationId, [resource], edit.undoStopId, + [beforeUri], + ); + progress(startProgress); + + const stopProgress = await editingSession.stopExternalEdits( + response, operationId, + [afterUri], + ); + progress(stopProgress); + } + } + + private _nextOperationId = 0; + // ---- Session resolution ------------------------------------------------- /** Maps a UI session resource to a backend provider URI. */ 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 bad97cd2521..8783104b7b5 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts @@ -4,7 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import { IMarkdownString, MarkdownString } from '../../../../../../base/common/htmlContent.js'; -import { PermissionKind, ToolCallStatus, TurnState, getToolOutputText, type ICompletedToolCall, type IPermissionRequest, type IToolCallState, type ITurn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { generateUuid } from '../../../../../../base/common/uuid.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { PermissionKind, ToolCallStatus, TurnState, getToolFileEdits, getToolOutputText, type ICompletedToolCall, type IPermissionRequest, type IToolCallState, type ITurn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { getToolKind, getToolLanguage } from '../../../../../../platform/agentHost/common/state/sessionReducers.js'; import { type IChatProgress, type IChatTerminalToolInvocationData, type IChatToolInputInvocationData, type IChatToolInvocationSerialized, ToolConfirmKind } from '../../../common/chatService/chatService.js'; import { type IChatSessionHistoryItem } from '../../../common/chatSessionsService.js'; @@ -197,11 +199,29 @@ export function permissionToConfirmation(perm: IPermissionRequest): ChatToolInvo return new ChatToolInvocation(preparedInvocation, toolData, perm.requestId, undefined, undefined); } +/** + * Data returned by {@link finalizeToolInvocation} describing file edits + * that should be routed through the editing session's external edits pipeline. + */ +export interface IToolCallFileEdit { + /** The real file URI on the remote (e.g., `file:///path/to/file`). */ + readonly resource: URI; + /** URI to read the before-snapshot content from. */ + readonly beforeContentUri: URI; + /** URI to read the after-content from (real file on remote via agenthost:// scheme). */ + readonly afterContentUri: URI; + /** Undo stop ID for grouping edits. */ + readonly undoStopId: string; +} + /** * Updates a live {@link ChatToolInvocation} with completion data from the * protocol's tool-call state, transitioning it to the completed state. + * + * Returns file edits that the caller should route through the editing + * session's external edits pipeline. */ -export function finalizeToolInvocation(invocation: ChatToolInvocation, tc: IToolCallState): void { +export function finalizeToolInvocation(invocation: ChatToolInvocation, tc: IToolCallState): IToolCallFileEdit[] { const isCompleted = tc.status === ToolCallStatus.Completed; const isCancelled = tc.status === ToolCallStatus.Cancelled; const isTerminal = invocation.toolSpecificData?.kind === 'terminal' || getToolKind(tc) === 'terminal'; @@ -224,4 +244,51 @@ export function finalizeToolInvocation(invocation: ChatToolInvocation, tc: ITool const errorMessage = isCompleted ? tc.error?.message : (isCancelled ? tc.reasonMessage : undefined); const errorString = typeof errorMessage === 'string' ? errorMessage : errorMessage?.markdown; invocation.didExecuteTool(isFailure ? { content: [], toolResultError: errorString } : undefined); + + // Extract file edits for the editing session pipeline + return isCompleted ? fileEditsToExternalEdits(tc) : []; +} + +/** + * Extracts file edit content entries from a completed tool call and + * converts them to {@link IToolCallFileEdit} data for routing through + * the editing session's external edits pipeline. + */ +function fileEditsToExternalEdits(tc: IToolCallState): IToolCallFileEdit[] { + if (tc.status !== ToolCallStatus.Completed) { + return []; + } + const edits = getToolFileEdits(tc); + if (edits.length === 0) { + return []; + } + const result: IToolCallFileEdit[] = []; + for (const edit of edits) { + const filePath = getFilePathFromToolInput(tc); + if (filePath) { + result.push({ + resource: URI.file(filePath), + beforeContentUri: URI.parse(edit.beforeURI), + afterContentUri: URI.parse(edit.afterURI), + undoStopId: generateUuid(), + }); + } + } + return result; +} + +/** + * Extracts the file path from a tool call's input parameters. + * Edit tools store the file path in JSON parameters as `path`. + */ +function getFilePathFromToolInput(tc: IToolCallState): string | undefined { + if (tc.status !== ToolCallStatus.Completed || !tc.toolInput) { + return undefined; + } + try { + const params = JSON.parse(tc.toolInput); + return typeof params.path === 'string' ? params.path : undefined; + } catch { + return undefined; + } } diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingDeletedFileEntry.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingDeletedFileEntry.ts index eeb4a37a52f..a88991de260 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingDeletedFileEntry.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingDeletedFileEntry.ts @@ -199,6 +199,10 @@ export class ChatEditingDeletedFileEntry extends AbstractChatEditingModifiedFile await this._fileService.writeFile(this.modifiedURI, VSBuffer.fromString(this._originalContent)); } + resetEditTrackerToInitialContent(): Promise { + return Promise.resolve(); + } + protected override async _areOriginalAndModifiedIdentical(): Promise { // A deleted file is never identical to its original (unless original was empty) return this._originalContent === ''; diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedDocumentEntry.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedDocumentEntry.ts index d7e1b62bb3c..2353e5c4890 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedDocumentEntry.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedDocumentEntry.ts @@ -219,6 +219,10 @@ export class ChatEditingModifiedDocumentEntry extends AbstractChatEditingModifie await this._textModelChangeService.resetDocumentValues(undefined, this.initialContent); } + async resetEditTrackerToInitialContent() { + await this._textModelChangeService.resetDocumentValues(this.initialContent, undefined); + } + protected override async _areOriginalAndModifiedIdentical(): Promise { return this._textModelChangeService.areOriginalAndModifiedIdentical(); } diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedFileEntry.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedFileEntry.ts index 2b02a675551..7ba9d6fb55d 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedFileEntry.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedFileEntry.ts @@ -370,7 +370,7 @@ export abstract class AbstractChatEditingModifiedFileEntry extends Disposable im // --- inital content abstract resetToInitialContent(): Promise; - + abstract resetEditTrackerToInitialContent(): Promise; abstract initialContent: string; /** diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedNotebookEntry.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedNotebookEntry.ts index 48073f03acf..b4c12adb8bb 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedNotebookEntry.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedNotebookEntry.ts @@ -946,6 +946,15 @@ export class ChatEditingModifiedNotebookEntry extends AbstractChatEditingModifie this.initializeModelsFromDiff(); } + async resetEditTrackerToInitialContent() { + if (this.initialContent) { + restoreSnapshot(this.originalModel, this.initialContent); + } + + this.updateCellDiffInfo([], undefined); + this.initializeModelsFromDiff(); + } + override async resetToInitialContent(): Promise { this.updateCellDiffInfo([], undefined); this.restoreSnapshotInModifiedModel(this.initialContent); diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts index 04343f023ce..82430843c75 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts @@ -663,7 +663,7 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio } } - async startExternalEdits(responseModel: IChatResponseModel, operationId: number, resources: URI[], undoStopId: string): Promise { + async startExternalEdits(responseModel: IChatResponseModel, operationId: number, resources: URI[], undoStopId: string, contentFor?: URI[]): Promise { const snapshots = new ResourceMap(); const acquiredLockPromises: DeferredPromise[] = []; const releaseLockPromises: DeferredPromise[] = []; @@ -673,7 +673,9 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio await chatEditingSessionIsReady(this); // Acquire locks for each resource and take snapshots - for (const resource of resources) { + for (let i = 0; i < resources.length; i++) { + const resource = resources[i]; + const contentSource = contentFor?.[i]; const releaseLock = new DeferredPromise(); releaseLockPromises.push(releaseLock); @@ -686,20 +688,37 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio return; } - const entry = await this._getOrCreateModifiedFileEntry(resource, NotExistBehavior.Abort, telemetryInfo); + let initialContent: string | undefined; + if (contentSource) { + // Read the before-content from the provided URI instead of disk + try { + const data = await this._fileService.readFile(contentSource); + initialContent = data.value.toString(); + } catch { + initialContent = ''; + } + } + + const entry = await this._getOrCreateModifiedFileEntry(resource, NotExistBehavior.Abort, telemetryInfo, initialContent); if (entry) { await this._acceptStreamingEditsStart(responseModel, undoStopId, resource); } - const notebookUri = CellUri.parse(resource)?.notebook || resource; progress.push(...createOpeningEditCodeBlock(resource, this._notebookService.hasSupportedNotebooks(notebookUri), undoStopId)); - // Save to disk to ensure disk state is current before external edits - await entry?.save(); - - // Take snapshot of current state - snapshots.set(resource, entry && this._getCurrentTextOrNotebookSnapshot(entry)); + if (initialContent !== undefined) { + if (entry) { + entry.initialContent = initialContent; + await entry.resetEditTrackerToInitialContent(); // in case it's reused + } + snapshots.set(resource, initialContent); + } else { + // Save to disk to ensure disk state is current before external edits + await entry?.save(); + // Take snapshot of current state + snapshots.set(resource, entry && this._getCurrentTextOrNotebookSnapshot(entry)); + } entry?.startExternalEdit(); acquiredLock.complete(); @@ -722,7 +741,7 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio return progress; } - async stopExternalEdits(responseModel: IChatResponseModel, operationId: number): Promise { + async stopExternalEdits(responseModel: IChatResponseModel, operationId: number, contentFor?: URI[]): Promise { const operation = this._externalEditOperations.get(operationId); if (!operation) { this._logService.warn(`stopExternalEdits called for unknown operation ${operationId}`); @@ -734,6 +753,18 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio const progress: IChatProgress[] = []; try { + // Build a map of resource -> contentFor URI + const contentForMap = new ResourceMap(); + if (contentFor) { + let idx = 0; + for (const [resource] of operation.snapshots) { + if (idx < contentFor.length && contentFor[idx]) { + contentForMap.set(resource, contentFor[idx]); + } + idx++; + } + } + // For each resource, compute the diff and create edit parts for (const [resource, beforeSnapshot] of operation.snapshots) { let entry = this._getEntry(resource); @@ -752,11 +783,21 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio continue; } - // Reload from disk to ensure in-memory model is in sync with file system - await entry.revertToDisk(); - - // Take new snapshot after external changes - const afterSnapshot = this._getCurrentTextOrNotebookSnapshot(entry); + let afterSnapshot: string; + const contentSource = contentForMap.get(resource); + if (contentSource) { + // Read after-content from the provided URI instead of disk + try { + const data = await this._fileService.readFile(contentSource); + afterSnapshot = data.value.toString(); + } catch { + afterSnapshot = ''; + } + } else { + // Reload from disk to ensure in-memory model is in sync with file system + await entry.revertToDisk(); + afterSnapshot = this._getCurrentTextOrNotebookSnapshot(entry) ?? ''; + } // Compute edits from the snapshots let edits: (TextEdit | ICellEditOperation)[] = []; diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index 8e7dc8dc246..1653dc8b33e 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -256,6 +256,13 @@ export interface IChatExternalEditsDto { undoStopId: string; start: boolean; /** true=start, false=stop */ resources: UriComponents[]; + /** + * When present, these URIs are read instead of the `resources` URIs + * (by-index) when capturing file snapshots. Used by the agent host + * to provide before/after content from the remote filesystem + * or from stored snapshots. + */ + contentFor?: UriComponents[]; } export interface IChatTaskDto { diff --git a/src/vs/workbench/contrib/chat/common/editing/chatEditingService.ts b/src/vs/workbench/contrib/chat/common/editing/chatEditingService.ts index 58b49b13fa5..0572f62a498 100644 --- a/src/vs/workbench/contrib/chat/common/editing/chatEditingService.ts +++ b/src/vs/workbench/contrib/chat/common/editing/chatEditingService.ts @@ -110,8 +110,8 @@ export interface IChatEditingSession extends IDisposable { * agents that make changes on-disk rather than streaming edits through the * chat session. */ - startExternalEdits(responseModel: IChatResponseModel, operationId: number, resources: URI[], undoStopId: string): Promise; - stopExternalEdits(responseModel: IChatResponseModel, operationId: number): Promise; + startExternalEdits(responseModel: IChatResponseModel, operationId: number, resources: URI[], undoStopId: string, contentFor?: URI[]): Promise; + stopExternalEdits(responseModel: IChatResponseModel, operationId: number, contentFor?: URI[]): Promise; /** * Gets the snapshot URI of a file at the request and _after_ changes made in the undo stop. 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 765158017c4..981e6a62813 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 @@ -184,6 +184,7 @@ function createContribution(disposables: DisposableStore) { fullName: 'Agent Host - Copilot', description: 'Copilot SDK agent running in a dedicated process', connection: agentHostService, + connectionAuthority: 'local', })); const contribution = disposables.add(instantiationService.createInstance(AgentHostContribution)); @@ -1380,6 +1381,7 @@ suite('AgentHostChatContribution', () => { fullName: 'Remote Copilot', description: 'Remote agent', connection: agentHostService, + connectionAuthority: 'local', extensionId: 'vscode.remote-agent-host', extensionDisplayName: 'Remote Agent Host', })); @@ -1400,6 +1402,7 @@ suite('AgentHostChatContribution', () => { fullName: 'Test', description: 'test', connection: agentHostService, + connectionAuthority: 'local', })); const registered = chatAgentService.registeredAgents.get('default-ext-test'); @@ -1418,6 +1421,7 @@ suite('AgentHostChatContribution', () => { fullName: 'Test', description: 'test', connection: agentHostService, + connectionAuthority: 'local', resolveWorkingDirectory: () => '/custom/working/dir', })); @@ -1466,6 +1470,7 @@ suite('AgentHostChatContribution', () => { fullName: 'Connection Test', description: 'test', connection: agentHostService, + connectionAuthority: 'local', })); // Verify it registered an agent diff --git a/src/vs/workbench/services/label/common/labelService.ts b/src/vs/workbench/services/label/common/labelService.ts index 47b509ff20f..a25505387fc 100644 --- a/src/vs/workbench/services/label/common/labelService.ts +++ b/src/vs/workbench/services/label/common/labelService.ts @@ -454,10 +454,23 @@ export class LabelService extends Disposable implements ILabelService { const i = resource.authority.indexOf('+'); return i === -1 ? resource.authority : resource.authority.slice(i + 1); } - case 'path': + case 'path': { + let pathValue = resource.path; + if (formatting.stripPathSegments) { + let pos = 0; + for (let i = 0; i < formatting.stripPathSegments; i++) { + const next = pathValue.indexOf('/', pos + 1); + if (next === -1) { + break; + } + pos = next; + } + pathValue = pathValue.substring(pos); + } return formatting.stripPathStartingSeparator - ? resource.path.slice(resource.path[0] === formatting.separator ? 1 : 0) - : resource.path; + ? pathValue.slice(pathValue[0] === formatting.separator ? 1 : 0) + : pathValue; + } default: { if (qsToken === 'query') { const { query } = resource;