From 2bdd783838c908393b26d33374481a9ea921a928 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Sun, 22 Mar 2026 11:28:38 -0700 Subject: [PATCH 01/33] agentHost: data passing for edits --- .../platform/agentHost/common/agentService.ts | 17 +- .../agentHost/common/sessionDataService.ts | 47 +++++ .../state/protocol/action-origin.generated.ts | 2 +- .../common/state/protocol/actions.ts | 2 +- .../common/state/protocol/commands.ts | 71 ++++++- .../agentHost/common/state/protocol/errors.ts | 21 ++- .../common/state/protocol/messages.ts | 5 +- .../common/state/protocol/notifications.ts | 52 +++++- .../common/state/protocol/reducers.ts | 2 +- .../agentHost/common/state/protocol/state.ts | 113 +++++++++++- .../common/state/protocol/version/registry.ts | 3 +- .../agentHost/common/state/sessionState.ts | 1 + .../agentHost/node/agentEventMapper.ts | 9 +- .../platform/agentHost/node/agentHostMain.ts | 11 +- .../agentHost/node/agentHostServerMain.ts | 33 ++-- .../platform/agentHost/node/agentService.ts | 9 +- .../agentHost/node/agentSideEffects.ts | 28 ++- .../agentHost/node/copilot/copilotAgent.ts | 97 ++++++++-- .../node/copilot/copilotToolDisplay.ts | 22 +++ .../agentHost/node/copilot/fileEditTracker.ts | 173 ++++++++++++++++++ .../agentHost/node/protocolServerHandler.ts | 6 +- .../agentHost/node/sessionDataService.ts | 83 +++++++++ .../test/node/agentEventMapper.test.ts | 10 +- .../agentHost/test/node/agentService.test.ts | 10 +- .../test/node/agentSideEffects.test.ts | 8 + .../test/node/fileEditTracker.test.ts | 123 +++++++++++++ .../test/node/protocolServerHandler.test.ts | 3 + .../test/node/sessionDataService.test.ts | 82 +++++++++ 28 files changed, 968 insertions(+), 75 deletions(-) create mode 100644 src/vs/platform/agentHost/common/sessionDataService.ts create mode 100644 src/vs/platform/agentHost/node/copilot/fileEditTracker.ts create mode 100644 src/vs/platform/agentHost/node/sessionDataService.ts create mode 100644 src/vs/platform/agentHost/test/node/fileEditTracker.test.ts create mode 100644 src/vs/platform/agentHost/test/node/sessionDataService.test.ts diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 7cbe07bdd7e9f..e21f2c9cfd0df 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -9,7 +9,7 @@ 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 { AttachmentType, PermissionKind, type PolicyState } from './state/sessionState.js'; +import { AttachmentType, PermissionKind, type IToolCallResult, type PolicyState } from './state/sessionState.js'; // IPC contract between the renderer and the agent host utility process. // Defines all serializable event types, the IAgent provider interface, @@ -189,20 +189,9 @@ export interface IAgentToolStartEvent extends IAgentProgressEventBase { export interface IAgentToolCompleteEvent extends IAgentProgressEventBase { readonly type: 'tool_complete'; readonly toolCallId: string; - readonly success: boolean; - /** Message describing the completed tool invocation (e.g., "Ran `echo hello`"). */ - readonly pastTenseMessage: string; - /** Tool output content for display in the UI. */ - readonly toolOutput?: string; + /** Tool execution result, matching the protocol {@link IToolCallResult} shape. */ + readonly result: IToolCallResult; readonly isUserRequested?: boolean; - readonly result?: { - readonly content: string; - readonly detailedContent?: string; - }; - readonly error?: { - readonly message: string; - readonly code?: string; - }; /** Serialized JSON of tool-specific telemetry data. */ readonly toolTelemetry?: string; readonly parentToolCallId?: string; diff --git a/src/vs/platform/agentHost/common/sessionDataService.ts b/src/vs/platform/agentHost/common/sessionDataService.ts new file mode 100644 index 0000000000000..dcc52fde5565a --- /dev/null +++ b/src/vs/platform/agentHost/common/sessionDataService.ts @@ -0,0 +1,47 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { createDecorator } from '../../instantiation/common/instantiation.js'; + +export const ISessionDataService = createDecorator('sessionDataService'); + +/** + * Provides persistent, per-session data directories on disk. + * + * Each session gets a directory under `{userDataPath}/agentSessionData/{sessionId}/` + * where internal agent-host code can store arbitrary files (e.g. file snapshots). + * + * Directories are created lazily — callers should use {@link IFileService.createFolder} + * before writing files. Cleanup happens eagerly on session removal and via startup + * garbage collection for orphaned directories. + */ +export interface ISessionDataService { + readonly _serviceBrand: undefined; + + /** + * Returns the root data directory URI for a session. + * Does **not** create the directory on disk; callers use + * `IFileService.createFolder()` as needed. + */ + getSessionDataDir(session: URI): URI; + + /** + * Returns the root data directory URI for a session given its raw ID. + * Equivalent to {@link getSessionDataDir} but without requiring a full URI. + */ + getSessionDataDirById(sessionId: string): URI; + + /** + * Recursively deletes the data directory for a session, if it exists. + */ + deleteSessionData(session: URI): Promise; + + /** + * Deletes data directories that do not correspond to any known session. + * Called at startup; safe to call multiple times. + */ + cleanupOrphanedData(knownSessionIds: Set): Promise; +} diff --git a/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts b/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts index 8dfc004dcbdfa..4c38cb047de0e 100644 --- a/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts +++ b/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts @@ -5,7 +5,7 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -// Synced from agent-host-protocol @ 3116861 +// Synced from agent-host-protocol @ 409b385 // Generated from types/actions.ts — do not edit // Run `npm run generate` to regenerate. diff --git a/src/vs/platform/agentHost/common/state/protocol/actions.ts b/src/vs/platform/agentHost/common/state/protocol/actions.ts index 40f4b2734f426..3b5eb0b636e0c 100644 --- a/src/vs/platform/agentHost/common/state/protocol/actions.ts +++ b/src/vs/platform/agentHost/common/state/protocol/actions.ts @@ -5,7 +5,7 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -// Synced from agent-host-protocol @ 3116861 +// Synced from agent-host-protocol @ 409b385 import { ToolCallConfirmationReason, ToolCallCancellationReason, type URI, type StringOrMarkdown, type IAgentInfo, type IErrorInfo, type IUserMessage, type IResponsePart, type IToolCallResult, type IToolDefinition, type ISessionActiveClient, type IUsageInfo, type IPermissionRequest } from './state.js'; diff --git a/src/vs/platform/agentHost/common/state/protocol/commands.ts b/src/vs/platform/agentHost/common/state/protocol/commands.ts index 676841e07288b..6636c9b61da9f 100644 --- a/src/vs/platform/agentHost/common/state/protocol/commands.ts +++ b/src/vs/platform/agentHost/common/state/protocol/commands.ts @@ -5,7 +5,7 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -// Synced from agent-host-protocol @ 3116861 +// Synced from agent-host-protocol @ 409b385 import type { URI, ISnapshot, ISessionSummary, ITurn } from './state.js'; import type { IActionEnvelope, IStateAction } from './actions.js'; @@ -172,7 +172,7 @@ export interface ICreateSessionParams { /** Model ID to use */ model?: string; /** Working directory for the session */ - workingDirectory?: string; + workingDirectory?: URI; } // ─── disposeSession ────────────────────────────────────────────────────────── @@ -245,6 +245,8 @@ export const enum ContentEncoding { * @direction Client → Server * @messageType Request * @version 1 + * @throws `NotFound` (`-32008`) if the URI does not exist. + * @throws `PermissionDenied` (`-32009`) if the client is not permitted to read the URI. * @example * ```jsonc * // Client → Server @@ -255,25 +257,31 @@ export const enum ContentEncoding { * { "jsonrpc": "2.0", "id": 10, "result": { * "data": "iVBORw0KGgo...", * "encoding": "base64", - * "mimeType": "image/png" + * "contentType": "image/png" * }} * ``` */ export interface IFetchContentParams { /** Content URI from a `ContentRef` */ uri: string; + /** Preferred encoding for the returned data (default: server-chosen) */ + encoding?: ContentEncoding; } /** * Result of the `fetchContent` command. + * + * The server SHOULD honor the `encoding` requested in the params. If the + * server cannot provide the requested encoding, it MUST fall back to either + * `base64` or `utf-8`. */ export interface IFetchContentResult { /** Content encoded as a string */ data: string; /** How `data` is encoded */ encoding: ContentEncoding; - /** MIME type of the content */ - mimeType?: string; + /** Content type (e.g. `"image/png"`, `"text/plain"`) */ + contentType?: string; } // ─── browseDirectory ──────────────────────────────────────────────────────── @@ -293,6 +301,8 @@ export interface IFetchContentResult { * @direction Client → Server * @messageType Request * @version 1 + * @throws `NotFound` (`-32008`) if the directory does not exist. + * @throws `PermissionDenied` (`-32009`) if the client is not permitted to browse the directory. */ export interface IBrowseDirectoryParams { /** Directory URI on the server filesystem */ @@ -427,3 +437,54 @@ export interface IBrowseDirectoryEntry { /** Whether this entry is a directory */ isDirectory: boolean; } + +// ─── authenticate ──────────────────────────────────────────────────────────── + +/** + * Pushes a Bearer token for a protected resource. The `resource` field MUST + * match an `IProtectedResourceMetadata.resource` value declared by an agent + * in `IAgentInfo.protectedResources`. + * + * Tokens are delivered using [RFC 6750](https://datatracker.ietf.org/doc/html/rfc6750) + * (Bearer Token Usage) semantics. The client obtains the token from the + * authorization server(s) listed in the resource's metadata and pushes it + * to the server via this command. + * + * @category Commands + * @method authenticate + * @direction Client → Server + * @messageType Request + * @version 1 + * @see {@link /specification/authentication | Authentication} + * @example + * ```jsonc + * // Client → Server + * { "jsonrpc": "2.0", "id": 3, "method": "authenticate", + * "params": { "resource": "https://api.github.com", "token": "gho_xxxx" } } + * + * // Server → Client (success) + * { "jsonrpc": "2.0", "id": 3, "result": {} } + * + * // Server → Client (failure — invalid token) + * { "jsonrpc": "2.0", "id": 3, "error": { "code": -32007, "message": "Invalid token" } } + * ``` + */ +export interface IAuthenticateParams { + /** + * The protected resource identifier. MUST match a `resource` value from + * `IProtectedResourceMetadata` declared in `IAgentInfo.protectedResources`. + */ + resource: string; + /** Bearer token obtained from the resource's authorization server */ + token: string; +} + +/** + * Result of the `authenticate` command. + * + * An empty object on success. If the token is invalid or the resource is + * unrecognized, the server MUST return a JSON-RPC error (e.g. `AuthRequired` + * `-32007` or `InvalidParams` `-32602`). + */ +export interface IAuthenticateResult { +} diff --git a/src/vs/platform/agentHost/common/state/protocol/errors.ts b/src/vs/platform/agentHost/common/state/protocol/errors.ts index 638189c2bc14d..152b84bd97248 100644 --- a/src/vs/platform/agentHost/common/state/protocol/errors.ts +++ b/src/vs/platform/agentHost/common/state/protocol/errors.ts @@ -5,7 +5,7 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -// Synced from agent-host-protocol @ 3116861 +// Synced from agent-host-protocol @ 409b385 // ─── Standard JSON-RPC Codes ───────────────────────────────────────────────── @@ -48,6 +48,25 @@ export const AhpErrorCodes = { UnsupportedProtocolVersion: -32005, /** The requested content URI does not exist */ ContentNotFound: -32006, + /** + * A command failed because the client has not authenticated for a required + * protected resource. The `data` field of the JSON-RPC error SHOULD contain + * an `IProtectedResourceMetadata[]` array describing the resources that + * require authentication. + * + * @see {@link /specification/authentication | Authentication} + */ + AuthRequired: -32007, + /** The requested file, folder, or URI does not exist */ + NotFound: -32008, + /** + * The client is not permitted to access the requested resource. + * + * Servers SHOULD return this when a client attempts to read or browse + * a path outside the allowed set (e.g. outside the session's working + * directory or workspace roots). + */ + PermissionDenied: -32009, } as const; /** Union type of all AHP application error codes. */ diff --git a/src/vs/platform/agentHost/common/state/protocol/messages.ts b/src/vs/platform/agentHost/common/state/protocol/messages.ts index 395da78f6ea08..edbb71701d13b 100644 --- a/src/vs/platform/agentHost/common/state/protocol/messages.ts +++ b/src/vs/platform/agentHost/common/state/protocol/messages.ts @@ -5,9 +5,9 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -// Synced from agent-host-protocol @ 3116861 +// Synced from agent-host-protocol @ 409b385 -import type { IInitializeParams, IInitializeResult, IReconnectParams, IReconnectResult, ISubscribeParams, ISubscribeResult, ICreateSessionParams, IDisposeSessionParams, IListSessionsParams, IListSessionsResult, IFetchContentParams, IFetchContentResult, IBrowseDirectoryParams, IBrowseDirectoryResult, IFetchTurnsParams, IFetchTurnsResult, IUnsubscribeParams, IDispatchActionParams } from './commands.js'; +import type { IInitializeParams, IInitializeResult, IReconnectParams, IReconnectResult, ISubscribeParams, ISubscribeResult, ICreateSessionParams, IDisposeSessionParams, IListSessionsParams, IListSessionsResult, IFetchContentParams, IFetchContentResult, IBrowseDirectoryParams, IBrowseDirectoryResult, IFetchTurnsParams, IFetchTurnsResult, IUnsubscribeParams, IDispatchActionParams, IAuthenticateParams, IAuthenticateResult } from './commands.js'; import type { IActionEnvelope } from './actions.js'; import type { IProtocolNotification } from './notifications.js'; @@ -67,6 +67,7 @@ export interface ICommandMap { 'fetchContent': { params: IFetchContentParams; result: IFetchContentResult }; 'browseDirectory': { params: IBrowseDirectoryParams; result: IBrowseDirectoryResult }; 'fetchTurns': { params: IFetchTurnsParams; result: IFetchTurnsResult }; + 'authenticate': { params: IAuthenticateParams; result: IAuthenticateResult }; } // ─── Notification Maps ─────────────────────────────────────────────────────── diff --git a/src/vs/platform/agentHost/common/state/protocol/notifications.ts b/src/vs/platform/agentHost/common/state/protocol/notifications.ts index 3a55ca3b65842..ea497c9127b20 100644 --- a/src/vs/platform/agentHost/common/state/protocol/notifications.ts +++ b/src/vs/platform/agentHost/common/state/protocol/notifications.ts @@ -5,10 +5,22 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -// Synced from agent-host-protocol @ 3116861 +// Synced from agent-host-protocol @ 409b385 import type { URI, ISessionSummary } from './state.js'; +/** + * Reason why authentication is required. + * + * @category Protocol Notifications + */ +export const enum AuthRequiredReason { + /** The client has not yet authenticated for the resource */ + Required = 'required', + /** A previously valid token has expired or been revoked */ + Expired = 'expired', +} + // ─── Protocol Notifications ────────────────────────────────────────────────── /** @@ -19,6 +31,7 @@ import type { URI, ISessionSummary } from './state.js'; export const enum NotificationType { SessionAdded = 'notify/sessionAdded', SessionRemoved = 'notify/sessionRemoved', + AuthRequired = 'notify/authRequired', } /** @@ -78,9 +91,44 @@ export interface ISessionRemovedNotification { session: URI; } +/** + * Sent by the server when a protected resource requires (re-)authentication. + * + * This notification is sent when a previously valid token expires or is + * revoked, or when the server discovers a new authentication requirement. + * Clients should obtain a fresh token and push it via the `authenticate` + * command. + * + * @category Protocol Notifications + * @version 1 + * @see {@link /specification/authentication | Authentication} + * @example + * ```json + * { + * "jsonrpc": "2.0", + * "method": "notification", + * "params": { + * "notification": { + * "type": "notify/authRequired", + * "resource": "https://api.github.com", + * "reason": "expired" + * } + * } + * } + * ``` + */ +export interface IAuthRequiredNotification { + type: NotificationType.AuthRequired; + /** The protected resource identifier that requires authentication */ + resource: string; + /** Why authentication is required */ + reason?: AuthRequiredReason; +} + /** * Discriminated union of all protocol notifications. */ export type IProtocolNotification = | ISessionAddedNotification - | ISessionRemovedNotification; + | ISessionRemovedNotification + | IAuthRequiredNotification; diff --git a/src/vs/platform/agentHost/common/state/protocol/reducers.ts b/src/vs/platform/agentHost/common/state/protocol/reducers.ts index ce78d37dc400e..4aa21b64e8b42 100644 --- a/src/vs/platform/agentHost/common/state/protocol/reducers.ts +++ b/src/vs/platform/agentHost/common/state/protocol/reducers.ts @@ -5,7 +5,7 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -// Synced from agent-host-protocol @ 3116861 +// Synced from agent-host-protocol @ 409b385 import { ActionType } from './actions.js'; import { SessionLifecycle, SessionStatus, TurnState, ToolCallStatus, ToolCallConfirmationReason, ToolCallCancellationReason, type IRootState, type ISessionState, type IToolCallState, type IToolCallCompletedState, type IToolCallCancelledState, type ITurn } from './state.js'; diff --git a/src/vs/platform/agentHost/common/state/protocol/state.ts b/src/vs/platform/agentHost/common/state/protocol/state.ts index a037ca22059ba..a2d6e1f8a5008 100644 --- a/src/vs/platform/agentHost/common/state/protocol/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/state.ts @@ -5,7 +5,7 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -// Synced from agent-host-protocol @ 3116861 +// Synced from agent-host-protocol @ 409b385 // ─── Type Aliases ──────────────────────────────────────────────────────────── @@ -20,6 +20,72 @@ export type URI = string; */ export type StringOrMarkdown = string | { markdown: string }; +// ─── Protected Resource Metadata (RFC 9728) ───────────────────────────────── + +/** + * Describes a protected resource's authentication requirements using + * [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) (OAuth 2.0 + * Protected Resource Metadata) semantics. + * + * Field names use snake_case to match the RFC 9728 JSON format. + * + * @category Authentication + * @see {@link https://datatracker.ietf.org/doc/html/rfc9728 | RFC 9728} + */ +export interface IProtectedResourceMetadata { + /** + * REQUIRED. The protected resource's resource identifier, a URL using the + * `https` scheme with no fragment component (e.g. `"https://api.github.com"`). + */ + resource: string; + + /** OPTIONAL. Human-readable name of the protected resource. */ + resource_name?: string; + + /** OPTIONAL. JSON array of OAuth authorization server identifier URLs. */ + authorization_servers?: string[]; + + /** OPTIONAL. URL of the protected resource's JWK Set document. */ + jwks_uri?: string; + + /** RECOMMENDED. JSON array of OAuth 2.0 scope values used in authorization requests. */ + scopes_supported?: string[]; + + /** OPTIONAL. JSON array of Bearer Token presentation methods supported. */ + bearer_methods_supported?: string[]; + + /** OPTIONAL. JSON array of JWS signing algorithms supported. */ + resource_signing_alg_values_supported?: string[]; + + /** OPTIONAL. JSON array of JWE encryption algorithms (alg) supported. */ + resource_encryption_alg_values_supported?: string[]; + + /** OPTIONAL. JSON array of JWE encryption algorithms (enc) supported. */ + resource_encryption_enc_values_supported?: string[]; + + /** OPTIONAL. URL of human-readable documentation for the resource. */ + resource_documentation?: string; + + /** OPTIONAL. URL of the resource's data-usage policy. */ + resource_policy_uri?: string; + + /** OPTIONAL. URL of the resource's terms of service. */ + resource_tos_uri?: string; + + /** + * AHP extension. Whether authentication is required for this resource. + * + * - `true` (default) — the agent cannot be used without a valid token. + * The server SHOULD return `AuthRequired` (`-32007`) if the client + * attempts to use the agent without authenticating. + * - `false` — the agent works without authentication but MAY offer + * enhanced capabilities when a token is provided. + * + * Clients SHOULD treat an absent field the same as `true`. + */ + required?: boolean; +} + // ─── Root State ────────────────────────────────────────────────────────────── /** @@ -57,6 +123,18 @@ export interface IAgentInfo { description: string; /** Available models for this agent */ models: ISessionModelInfo[]; + /** + * Protected resources this agent requires authentication for. + * + * Each entry describes an OAuth 2.0 protected resource using + * [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) semantics. + * Clients should obtain tokens from the declared `authorization_servers` + * and push them via the `authenticate` command before creating sessions + * with this agent. + * + * @see {@link /specification/authentication | Authentication} + */ + protectedResources?: IProtectedResourceMetadata[]; } /** @@ -117,6 +195,8 @@ export interface ISessionState { serverTools?: IToolDefinition[]; /** The client currently providing tools and interactive capabilities to this session */ activeClient?: ISessionActiveClient; + /** The working directory URI for this session */ + workingDirectory?: URI; /** Completed turns */ turns: ITurn[]; /** Currently in-progress turn */ @@ -158,6 +238,8 @@ export interface ISessionSummary { modifiedAt: number; /** Currently selected model */ model?: string; + /** The working directory URI for this session */ + workingDirectory?: URI; } // ─── Turn Types ────────────────────────────────────────────────────────────── @@ -578,6 +660,7 @@ export interface IToolAnnotations { export const enum ToolResultContentType { Text = 'text', Binary = 'binary', + FileEdit = 'fileEdit', } /** @@ -608,17 +691,41 @@ export interface IToolResultBinaryContent { contentType: string; } +/** + * Describes a file modification performed by a tool. + * + * Clients can use the `beforeURI`/`afterURI` pair to render a diff view. + * + * @category Tool Result Content + */ +export interface IToolResultFileEditContent { + type: ToolResultContentType.FileEdit; + /** URI of the file content before the edit */ + beforeURI: URI; + /** URI of the file content after the edit */ + afterURI: URI; + /** Optional diff display metadata */ + diff?: { + /** Number of items added (e.g., lines for text files, cells for notebooks) */ + added?: number; + /** Number of items removed (e.g., lines for text files, cells for notebooks) */ + removed?: number; + }; +} + /** * Content block in a tool result. * - * Mirrors the content blocks in MCP `CallToolResult.content`, plus `IContentRef` - * for lazy-loading large results (an AHP extension). + * Mirrors the content blocks in MCP `CallToolResult.content`, plus + * `IContentRef` for lazy-loading large results and `IToolResultFileEditContent` + * for file edit diffs (AHP extensions). * * @category Tool Result Content */ export type IToolResultContent = | IToolResultTextContent | IToolResultBinaryContent + | IToolResultFileEditContent | IContentRef; // ─── Permission Types ──────────────────────────────────────────────────────── diff --git a/src/vs/platform/agentHost/common/state/protocol/version/registry.ts b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts index 94193f199304e..1e6dcd41b1c4c 100644 --- a/src/vs/platform/agentHost/common/state/protocol/version/registry.ts +++ b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts @@ -5,7 +5,7 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -// Synced from agent-host-protocol @ 3116861 +// Synced from agent-host-protocol @ 409b385 import { ActionType, type IStateAction } from '../actions.js'; import { NotificationType, type IProtocolNotification } from '../notifications.js'; @@ -69,6 +69,7 @@ export function isActionKnownToVersion(action: IStateAction, clientVersion: numb export const NOTIFICATION_INTRODUCED_IN: { readonly [K in IProtocolNotification['type']]: number } = { [NotificationType.SessionAdded]: 1, [NotificationType.SessionRemoved]: 1, + [NotificationType.AuthRequired]: 1, }; /** diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index f63a17bde4b1c..b2b53dda8730b 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -54,6 +54,7 @@ export { type IToolDefinition, type IToolResultBinaryContent, type IToolResultContent, + type IToolResultFileEditContent, type IToolResultTextContent, type ITurn, type IUsageInfo, diff --git a/src/vs/platform/agentHost/node/agentEventMapper.ts b/src/vs/platform/agentHost/node/agentEventMapper.ts index 5f378e7b73945..02da5331a5519 100644 --- a/src/vs/platform/agentHost/node/agentEventMapper.ts +++ b/src/vs/platform/agentHost/node/agentEventMapper.ts @@ -28,7 +28,7 @@ import { type IPermissionRequestAction, type IReasoningAction, } from '../common/state/sessionActions.js'; -import { ToolCallConfirmationReason, ToolResultContentType, type URI } from '../common/state/sessionState.js'; +import { ToolCallConfirmationReason, type URI } from '../common/state/sessionState.js'; /** * Maps a flat {@link IAgentProgressEvent} from the agent host into @@ -81,12 +81,7 @@ export function mapProgressEventToActions(event: IAgentProgressEvent, session: U session, turnId, toolCallId: e.toolCallId, - result: { - success: e.success, - pastTenseMessage: e.pastTenseMessage, - content: e.toolOutput !== undefined ? [{ type: ToolResultContentType.Text, text: e.toolOutput }] : undefined, - error: e.error, - }, + result: e.result, } satisfies IToolCallCompleteAction; } diff --git a/src/vs/platform/agentHost/node/agentHostMain.ts b/src/vs/platform/agentHost/node/agentHostMain.ts index 69cd971c22ede..0b13525ff30c7 100644 --- a/src/vs/platform/agentHost/node/agentHostMain.ts +++ b/src/vs/platform/agentHost/node/agentHostMain.ts @@ -29,6 +29,7 @@ import { localize } from '../../../nls.js'; import { FileService } from '../../files/common/fileService.js'; import { DiskFileSystemProvider } from '../../files/node/diskFileSystemProvider.js'; import { Schemas } from '../../../base/common/network.js'; +import { SessionDataService } from './sessionDataService.js'; // Entry point for the agent host utility process. // Sets up IPC, logging, and registers agent providers (Copilot). @@ -61,11 +62,14 @@ function startAgentHost(): void { const fileService = disposables.add(new FileService(logService)); disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new DiskFileSystemProvider(logService)))); + // Session data service + const sessionDataService = new SessionDataService(URI.file(environmentService.userDataPath), fileService, logService); + // Create the real service implementation that lives in this process let agentService: AgentService; try { - agentService = new AgentService(logService, fileService); - agentService.registerProvider(new CopilotAgent(logService)); + agentService = new AgentService(logService, fileService, sessionDataService); + agentService.registerProvider(new CopilotAgent(logService, fileService, sessionDataService)); } catch (err) { logService.error('Failed to create AgentService', err); throw err; @@ -157,6 +161,9 @@ async function startWebSocketServer(agentService: AgentService, logService: ILog handleBrowseDirectory(uri) { return agentService.browseDirectory(URI.parse(uri)); }, + handleFetchContent(uri) { + return agentService.fetchContent(uri); + }, getDefaultDirectory() { return URI.file(os.homedir()).toString(); }, diff --git a/src/vs/platform/agentHost/node/agentHostServerMain.ts b/src/vs/platform/agentHost/node/agentHostServerMain.ts index b02c9944711da..8622b1c244b66 100644 --- a/src/vs/platform/agentHost/node/agentHostServerMain.ts +++ b/src/vs/platform/agentHost/node/agentHostServerMain.ts @@ -16,6 +16,7 @@ globalThis._VSCODE_FILE_ROOT = fileURLToPath(new URL('../../../..', import.meta. import * as fs from 'fs'; import { DisposableStore } from '../../../base/common/lifecycle.js'; import { observableValue } from '../../../base/common/observable.js'; +import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; import { localize } from '../../../nls.js'; import { NativeEnvironmentService } from '../../environment/node/environmentService.js'; @@ -35,8 +36,11 @@ import { SessionStateManager } from './sessionStateManager.js'; import { WebSocketProtocolServer } from './webSocketTransport.js'; import { ProtocolServerHandler } from './protocolServerHandler.js'; import { FileService } from '../../files/common/fileService.js'; +import { IFileService } from '../../files/common/files.js'; import { DiskFileSystemProvider } from '../../files/node/diskFileSystemProvider.js'; import { Schemas } from '../../../base/common/network.js'; +import { ISessionDataService } from '../common/sessionDataService.js'; +import { SessionDataService } from './sessionDataService.js'; /** Log to stderr so messages appear in the terminal alongside the process. */ function log(msg: string): void { @@ -112,7 +116,12 @@ async function main(): Promise { const options = parseServerOptions(); const disposables = new DisposableStore(); - // Services — production logging unless --quiet + // Services + const productService: IProductService = { _serviceBrand: undefined, ...product }; + const args = parseArgs(process.argv.slice(2), OPTIONS); + const environmentService = new NativeEnvironmentService(args, productService); + + // Logging — production logging unless --quiet let logService: ILogService; let loggerService: LoggerService | undefined; @@ -120,10 +129,7 @@ async function main(): Promise { logService = new NullLogService(); } else { const services = new ServiceCollection(); - const productService: IProductService = { _serviceBrand: undefined, ...product }; services.set(IProductService, productService); - const args = parseArgs(process.argv.slice(2), OPTIONS); - const environmentService = new NativeEnvironmentService(args, productService); services.set(INativeEnvironmentService, environmentService); loggerService = new LoggerService(getLogLevel(environmentService), environmentService.logsHome); const logger = loggerService.createLogger('agenthost-server', { name: localize('agentHostServer', "Agent Host Server") }); @@ -147,6 +153,9 @@ async function main(): Promise { const fileService = disposables.add(new FileService(logService)); disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new DiskFileSystemProvider(logService)))); + // Session data service + const sessionDataService = new SessionDataService(URI.file(environmentService.userDataPath), fileService, logService); + // Shared side-effect handler const sideEffects = disposables.add(new AgentSideEffects(stateManager, { getAgent(session) { @@ -154,6 +163,7 @@ async function main(): Promise { return provider ? agents.get(provider) : agents.values().next().value; }, agents: registeredAgents, + sessionDataService, }, logService, fileService)); function registerAgent(agent: IAgent): void { @@ -166,14 +176,13 @@ async function main(): Promise { // Register agents if (!options.quiet) { // Production agents (require DI) - const services = new ServiceCollection(); - const productService: IProductService = { _serviceBrand: undefined, ...product }; - services.set(IProductService, productService); - const args = parseArgs(process.argv.slice(2), OPTIONS); - const environmentService = new NativeEnvironmentService(args, productService); - services.set(INativeEnvironmentService, environmentService); - services.set(ILogService, logService); - const instantiationService = new InstantiationService(services); + const diServices = new ServiceCollection(); + diServices.set(IProductService, productService); + diServices.set(INativeEnvironmentService, environmentService); + diServices.set(ILogService, logService); + diServices.set(IFileService, fileService); + diServices.set(ISessionDataService, sessionDataService); + const instantiationService = new InstantiationService(diServices); const copilotAgent = disposables.add(instantiationService.createInstance(CopilotAgent)); registerAgent(copilotAgent); log('CopilotAgent registered'); diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 8b375c9cc957a..22500947c99b2 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -10,8 +10,9 @@ import { URI } from '../../../base/common/uri.js'; import { IFileService } from '../../files/common/files.js'; import { ILogService } from '../../log/common/log.js'; import { AgentProvider, AgentSession, IAgent, IAgentCreateSessionConfig, IAgentDescriptor, IAgentService, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult, IResourceMetadata } from '../common/agentService.js'; +import { ISessionDataService } from '../common/sessionDataService.js'; import { ActionType, 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 { SessionStatus, type ISessionSummary } from '../common/state/sessionState.js'; import { AgentSideEffects } from './agentSideEffects.js'; import { SessionStateManager } from './sessionStateManager.js'; @@ -54,6 +55,7 @@ export class AgentService extends Disposable implements IAgentService { constructor( private readonly _logService: ILogService, private readonly _fileService: IFileService, + private readonly _sessionDataService: ISessionDataService, ) { super(); this._logService.info('AgentService initialized'); @@ -168,6 +170,7 @@ export class AgentService extends Disposable implements IAgentService { this._sessionToProvider.delete(session.toString()); } this._stateManager.removeSession(session.toString()); + this._sessionDataService.deleteSessionData(session); } // ---- Protocol methods --------------------------------------------------- @@ -201,6 +204,10 @@ export class AgentService extends Disposable implements IAgentService { return this._sideEffects.handleBrowseDirectory(uri.toString()); } + async fetchContent(uri: string): Promise { + return this._sideEffects.handleFetchContent(uri); + } + async shutdown(): Promise { this._logService.info('AgentService: shutting down all providers...'); const promises: Promise[] = []; diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 3bdab46e55d7b..9a23ecdb1ee5b 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -10,14 +10,16 @@ import { URI } from '../../../base/common/uri.js'; import { IFileService } from '../../files/common/files.js'; import { ILogService } from '../../log/common/log.js'; import { IAgent, IAgentAttachment, IAuthenticateParams, IAuthenticateResult, IResourceMetadata } from '../common/agentService.js'; +import { ISessionDataService } from '../common/sessionDataService.js'; import { ActionType, ISessionAction } from '../common/state/sessionActions.js'; -import { AHP_PROVIDER_NOT_FOUND, IBrowseDirectoryResult, ICreateSessionParams, IDirectoryEntry, JSON_RPC_INTERNAL_ERROR, ProtocolError } from '../common/state/sessionProtocol.js'; +import { AhpErrorCodes, AHP_PROVIDER_NOT_FOUND, ContentEncoding, IBrowseDirectoryResult, ICreateSessionParams, IDirectoryEntry, IFetchContentResult, ProtocolError } from '../common/state/sessionProtocol.js'; import { SessionStatus, type ISessionModelInfo, 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'; @@ -29,6 +31,8 @@ export interface IAgentSideEffectsOptions { readonly getAgent: (session: ProtocolURI) => IAgent | undefined; /** Observable set of registered agents. Triggers `root/agentsChanged` when it changes. */ readonly agents: IObservable; + /** Session data service for cleaning up per-session data on disposal. */ + readonly sessionDataService: ISessionDataService; } /** @@ -208,6 +212,7 @@ export class AgentSideEffects extends Disposable implements IProtocolSideEffectH const agent = this._options.getAgent(session); agent?.disposeSession(URI.parse(session)).catch(() => { }); this._stateManager.removeSession(session); + this._options.sessionDataService.deleteSessionData(URI.parse(session)); } async handleListSessions(): Promise { @@ -252,11 +257,11 @@ export class AgentSideEffects extends Disposable implements IProtocolSideEffectH try { stat = await this._fileService.resolve(URI.parse(uri)); } catch { - throw new ProtocolError(JSON_RPC_INTERNAL_ERROR, `Directory not found: ${uri.toString()}`); + throw new ProtocolError(AhpErrorCodes.NotFound, `Directory not found: ${uri.toString()}`); } if (!stat.isDirectory) { - throw new ProtocolError(JSON_RPC_INTERNAL_ERROR, `Not a directory: ${uri.toString()}`); + throw new ProtocolError(AhpErrorCodes.NotFound, `Not a directory: ${uri.toString()}`); } const entries: IDirectoryEntry[] = (stat.children ?? []).map(child => ({ @@ -270,6 +275,23 @@ export class AgentSideEffects extends Disposable implements IProtocolSideEffectH return URI.file(os.homedir()).toString(); } + 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); + return { + data: content.value.toString(), + encoding: ContentEncoding.Utf8, + contentType: 'text/plain', + }; + } catch { + throw new ProtocolError(AhpErrorCodes.ContentNotFound, `Content not found: ${uri}`); + } + } + override dispose(): void { this._pendingPermissions.clear(); super.dispose(); diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 030b3cfe2c7d0..6bdb4aa458a6f 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -13,11 +13,14 @@ import type { IAuthorizationProtectedResourceMetadata } from '../../../../base/c import { delimiter, dirname } from '../../../../base/common/path.js'; import { URI } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; +import { IFileService } from '../../../files/common/files.js'; import { ILogService } from '../../../log/common/log.js'; import { AgentSession, IAgent, IAgentAttachment, IAgentCreateSessionConfig, IAgentDescriptor, IAgentMessageEvent, IAgentModelInfo, IAgentProgressEvent, IAgentSessionMetadata, IAgentToolCompleteEvent, IAgentToolStartEvent } from '../../common/agentService.js'; -import { PermissionKind, type PolicyState } from '../../common/state/sessionState.js'; +import { ISessionDataService } from '../../common/sessionDataService.js'; +import { PermissionKind, ToolResultContentType, type IToolResultContent, type PolicyState } from '../../common/state/sessionState.js'; import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; -import { getInvocationMessage, getPastTenseMessage, getShellLanguage, getToolDisplayName, getToolInputString, getToolKind, isHiddenTool } from './copilotToolDisplay.js'; +import { getEditFilePath, getInvocationMessage, getPastTenseMessage, getShellLanguage, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool } from './copilotToolDisplay.js'; +import { FileEditTracker } from './fileEditTracker.js'; function tryStringify(value: unknown): string | undefined { try { @@ -46,9 +49,13 @@ export class CopilotAgent extends Disposable implements IAgent { private readonly _pendingPermissions = new Map }>(); /** Working directory per session, used when resuming. */ private readonly _sessionWorkingDirs = new Map(); + /** File edit trackers per session, keyed by raw session ID. */ + private readonly _editTrackers = new Map(); constructor( @ILogService private readonly _logService: ILogService, + @IFileService private readonly _fileService: IFileService, + @ISessionDataService private readonly _sessionDataService: ISessionDataService, ) { super(); } @@ -195,6 +202,7 @@ export class CopilotAgent extends Disposable implements IAgent { streaming: true, workingDirectory: config?.workingDirectory, onPermissionRequest: (request, invocation) => this._handlePermissionRequest(request, invocation), + hooks: this._createSessionHooks(), }); const wrapper = this._trackSession(raw); @@ -352,6 +360,46 @@ export class CopilotAgent extends Disposable implements IAgent { } } + private _getOrCreateEditTracker(rawSessionId: string): FileEditTracker { + let tracker = this._editTrackers.get(rawSessionId); + if (!tracker) { + tracker = new FileEditTracker(rawSessionId, this._sessionDataService, this._fileService, this._logService); + this._editTrackers.set(rawSessionId, tracker); + } + return tracker; + } + + /** + * Creates SDK session hooks for pre/post tool use. The `onPreToolUse` + * hook snapshots files before edit tools run. The `onPostToolUse` hook + * snapshots the after-content so that it's ready synchronously when + * `onToolComplete` fires. + */ + private _createSessionHooks() { + 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); + if (filePath) { + const tracker = this._getOrCreateEditTracker(invocation.sessionId); + await tracker.trackEditStart(filePath); + } + } + }, + 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); + if (filePath) { + const tracker = this._editTrackers.get(invocation.sessionId); + await tracker?.completeEdit(filePath); + } + } + }, + }; + } + private _trackSession(raw: CopilotSession, sessionIdOverride?: string): CopilotSessionWrapper { const wrapper = new CopilotSessionWrapper(raw); const rawId = sessionIdOverride ?? wrapper.sessionId; @@ -404,6 +452,7 @@ export class CopilotAgent extends Disposable implements IAgent { const trackingKey = `${rawId}:${e.data.toolCallId}`; this._activeToolCalls.set(trackingKey, { toolName: e.data.toolName, displayName, parameters }); const toolKind = getToolKind(e.data.toolName); + this._onDidSessionProgress.fire({ session, type: 'tool_start', @@ -431,16 +480,33 @@ export class CopilotAgent extends Disposable implements IAgent { this._activeToolCalls.delete(trackingKey); const displayName = tracked.displayName; const toolOutput = e.data.error?.message ?? e.data.result?.content; + + const content: IToolResultContent[] = []; + if (toolOutput !== undefined) { + content.push({ type: ToolResultContentType.Text, text: toolOutput }); + } + + // File edit data was already prepared by the onPostToolUse hook + const tracker = this._editTrackers.get(rawId); + const filePath = isEditTool(tracked.toolName) ? getEditFilePath(tracked.parameters) : undefined; + if (tracker && filePath) { + const fileEdit = tracker.takeCompletedEdit(filePath); + if (fileEdit) { + content.push(fileEdit); + } + } + this._onDidSessionProgress.fire({ session, type: 'tool_complete', toolCallId: e.data.toolCallId, - success: e.data.success, - pastTenseMessage: getPastTenseMessage(tracked?.toolName ?? '', displayName, tracked?.parameters, e.data.success), - toolOutput, + result: { + success: e.data.success, + pastTenseMessage: getPastTenseMessage(tracked.toolName, displayName, tracked.parameters, e.data.success), + content: content.length > 0 ? content : undefined, + error: e.data.error, + }, isUserRequested: e.data.isUserRequested, - result: e.data.result, - error: e.data.error, toolTelemetry: e.data.toolTelemetry !== undefined ? tryStringify(e.data.toolTelemetry) : undefined, parentToolCallId: e.data.parentToolCallId, }); @@ -613,6 +679,7 @@ export class CopilotAgent extends Disposable implements IAgent { const raw = await client.resumeSession(sessionId, { onPermissionRequest: (request, invocation) => this._handlePermissionRequest(request, invocation), workingDirectory: this._sessionWorkingDirs.get(sessionId), + hooks: this._createSessionHooks(), }); return this._trackSession(raw, sessionId); } @@ -677,16 +744,22 @@ export class CopilotAgent extends Disposable implements IAgent { } toolInfoByCallId.delete(d.toolCallId); const displayName = getToolDisplayName(info.toolName); + const toolOutput = d.error?.message ?? d.result?.content; + const content: IToolResultContent[] = []; + if (toolOutput !== undefined) { + content.push({ type: ToolResultContentType.Text, text: toolOutput }); + } result.push({ session, type: 'tool_complete', toolCallId: d.toolCallId, - success: d.success, - pastTenseMessage: getPastTenseMessage(info.toolName, displayName, info.parameters, d.success), - toolOutput: d.error?.message ?? d.result?.content, + result: { + success: d.success, + pastTenseMessage: getPastTenseMessage(info.toolName, displayName, info.parameters, d.success), + content: content.length > 0 ? content : undefined, + error: d.error, + }, isUserRequested: d.isUserRequested, - result: d.result, - error: d.error, toolTelemetry: d.toolTelemetry !== undefined ? tryStringify(d.toolTelemetry) : undefined, }); } diff --git a/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts b/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts index 3a181d85abc3f..7702844d41825 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts @@ -71,6 +71,28 @@ interface ICopilotGlobToolArgs { path?: string; } +/** Set of tool names that perform file edits. */ +const EDIT_TOOL_NAMES: ReadonlySet = new Set([ + CopilotToolName.Edit, + CopilotToolName.Write, + CopilotToolName.Patch, +]); + +/** + * Returns true if the tool modifies files on disk. + */ +export function isEditTool(toolName: string): boolean { + return EDIT_TOOL_NAMES.has(toolName); +} + +/** + * Extracts the target file path from an edit tool's parameters, if available. + */ +export function getEditFilePath(parameters: Record | undefined): string | undefined { + const args = parameters as ICopilotFileToolArgs | undefined; + return args?.file_path; +} + /** Set of tool names that execute shell commands (bash or powershell). */ const SHELL_TOOL_NAMES: ReadonlySet = new Set([ CopilotToolName.Bash, diff --git a/src/vs/platform/agentHost/node/copilot/fileEditTracker.ts b/src/vs/platform/agentHost/node/copilot/fileEditTracker.ts new file mode 100644 index 0000000000000..5d65e6e5ca870 --- /dev/null +++ b/src/vs/platform/agentHost/node/copilot/fileEditTracker.ts @@ -0,0 +1,173 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { VSBuffer } from '../../../../base/common/buffer.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IFileService } from '../../../files/common/files.js'; +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/after content is stored in the session data directory under + * `file-edits/{editKey}/before` and `file-edits/{editKey}/after`. + * + * Content is addressable via URIs of the form: + * `agenthost-content:///{sessionId}/file-edits/{editKey}/before` + */ +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 }>(); + + /** + * Completed edits keyed by file path. The `onPostToolUse` hook stores + * entries here; `takeCompletedEdit` retrieves them synchronously from + * the `onToolComplete` handler. + */ + private readonly _completedEdits = new Map(); + + constructor( + private readonly _sessionId: string, + private readonly _sessionDataService: ISessionDataService, + private readonly _fileService: IFileService, + private readonly _logService: ILogService, + ) { } + + /** + * Call from the `onPreToolUse` hook before an edit tool runs. + * Snapshots the file's current content as the "before" state. + * The hook blocks the SDK until this returns, ensuring the snapshot + * captures pre-edit content. + * + * @param filePath - Absolute path of the file being edited. + */ + async trackEditStart(filePath: string): Promise { + const editKey = generateEditKey(); + const sessionDataDir = this._sessionDataService.getSessionDataDirById(this._sessionId); + const beforeUri = URI.joinPath(sessionDataDir, 'file-edits', editKey, 'before'); + + const snapshotDone = this._snapshotFile(filePath, beforeUri); + this._pendingEdits.set(filePath, { editKey, 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}. + * + * @param filePath - Absolute path of the file that was edited. + */ + async completeEdit(filePath: string): Promise { + const pending = this._pendingEdits.get(filePath); + if (!pending) { + return; + } + this._pendingEdits.delete(filePath); + await pending.snapshotDone; + + 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; + + 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), + }, + }); + } + + /** + * Synchronously retrieves and removes a completed edit for the given + * file path. Call from the `onToolComplete` handler to include the + * edit in the tool result without async work. + */ + takeCompletedEdit(filePath: string): IToolResultFileEditContent | undefined { + const edit = this._completedEdits.get(filePath); + if (edit) { + this._completedEdits.delete(filePath); + } + return edit; + } + + private async _snapshotFile(filePath: string, targetUri: URI): Promise { + try { + const content = await this._fileService.readFile(URI.file(filePath)); + await this._fileService.writeFile(targetUri, content.value); + } catch (err) { + this._logService.trace(`[FileEditTracker] Could not read file for snapshot: ${filePath}`, err); + 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; +function generateEditKey(): string { + return `${Date.now()}-${_editKeyCounter++}`; +} diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index ef47a34c59f37..cc6d1d413b76b 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -19,6 +19,7 @@ import { type IAhpServerNotification, type IBrowseDirectoryResult, type ICreateSessionParams, + type IFetchContentResult, type IInitializeParams, type IJsonRpcResponse, type IReconnectParams, @@ -325,8 +326,8 @@ export class ProtocolServerHandler extends Disposable { browseDirectory: async (_client, params) => { return this._sideEffectHandler.handleBrowseDirectory(params.uri); }, - fetchContent: async () => { - throw new Error('fetchContent not implemented'); + fetchContent: async (_client, params) => { + return this._sideEffectHandler.handleFetchContent(params.uri); }, }; @@ -435,6 +436,7 @@ export interface IProtocolSideEffectHandler { handleGetResourceMetadata(): IResourceMetadata; handleAuthenticate(params: IAuthenticateParams): Promise; handleBrowseDirectory(uri: URI): Promise; + handleFetchContent(uri: string): Promise; /** Returns the server's default browsing directory, if available. */ getDefaultDirectory?(): URI; /** Refresh models from all providers (VS Code extension method). */ diff --git a/src/vs/platform/agentHost/node/sessionDataService.ts b/src/vs/platform/agentHost/node/sessionDataService.ts new file mode 100644 index 0000000000000..5bbcb89dc076b --- /dev/null +++ b/src/vs/platform/agentHost/node/sessionDataService.ts @@ -0,0 +1,83 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { IFileService } from '../../files/common/files.js'; +import { ILogService } from '../../log/common/log.js'; +import { AgentSession } from '../common/agentService.js'; +import { ISessionDataService } from '../common/sessionDataService.js'; + +/** + * Implementation of {@link ISessionDataService} that stores per-session data + * under `{userDataPath}/agentSessionData/{sessionId}/`. + */ +export class SessionDataService implements ISessionDataService { + declare readonly _serviceBrand: undefined; + + private readonly _basePath: URI; + + constructor( + userDataPath: URI, + @IFileService private readonly _fileService: IFileService, + @ILogService private readonly _logService: ILogService, + ) { + this._basePath = URI.joinPath(userDataPath, 'agentSessionData'); + } + + getSessionDataDir(session: URI): URI { + return this.getSessionDataDirById(AgentSession.id(session)); + } + + getSessionDataDirById(sessionId: string): URI { + const sanitized = sessionId.replace(/[^a-zA-Z0-9_.-]/g, '-'); + return URI.joinPath(this._basePath, sanitized); + } + + async deleteSessionData(session: URI): Promise { + const dir = this.getSessionDataDir(session); + try { + if (await this._fileService.exists(dir)) { + await this._fileService.del(dir, { recursive: true }); + this._logService.trace(`[SessionDataService] Deleted session data: ${dir.toString()}`); + } + } catch (err) { + this._logService.warn(`[SessionDataService] Failed to delete session data: ${dir.toString()}`, err); + } + } + + async cleanupOrphanedData(knownSessionIds: Set): Promise { + try { + const exists = await this._fileService.exists(this._basePath); + if (!exists) { + return; + } + + const stat = await this._fileService.resolve(this._basePath); + if (!stat.children) { + return; + } + + const deletions: Promise[] = []; + for (const child of stat.children) { + if (!child.isDirectory) { + continue; + } + const name = child.name; + if (!knownSessionIds.has(name)) { + this._logService.trace(`[SessionDataService] Cleaning up orphaned session data: ${name}`); + deletions.push( + this._fileService.del(child.resource, { recursive: true }).catch(err => { + this._logService.warn(`[SessionDataService] Failed to clean up orphaned data: ${name}`, err); + }) + ); + } + } + + await Promise.all(deletions); + } catch (err) { + this._logService.warn('[SessionDataService] Failed to run orphan cleanup', err); + } + } +} diff --git a/src/vs/platform/agentHost/test/node/agentEventMapper.test.ts b/src/vs/platform/agentHost/test/node/agentEventMapper.test.ts index fe8993fea3e5d..3177fcb48f2d1 100644 --- a/src/vs/platform/agentHost/test/node/agentEventMapper.test.ts +++ b/src/vs/platform/agentHost/test/node/agentEventMapper.test.ts @@ -31,7 +31,7 @@ import type { ITurnCompleteAction, IUsageAction, } from '../../common/state/sessionActions.js'; -import { PermissionKind } from '../../common/state/sessionState.js'; +import { PermissionKind, ToolResultContentType } from '../../common/state/sessionState.js'; import { mapProgressEventToActions } from '../../node/agentEventMapper.js'; /** Helper: flatten the result of mapProgressEventToActions into an array. */ @@ -104,9 +104,11 @@ suite('AgentEventMapper', () => { session, type: 'tool_complete', toolCallId: 'tc-1', - success: true, - pastTenseMessage: 'Read file successfully', - toolOutput: 'file contents here', + result: { + success: true, + pastTenseMessage: 'Read file successfully', + content: [{ type: ToolResultContentType.Text, text: 'file contents here' }], + }, }; const actions = mapToArray(mapProgressEventToActions(event, session.toString(), turnId)); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 72b776d5e86f5..0998e84bbfed9 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -10,6 +10,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { NullLogService } from '../../../log/common/log.js'; import { FileService } from '../../../files/common/fileService.js'; import { AgentSession } from '../../common/agentService.js'; +import { ISessionDataService } from '../../common/sessionDataService.js'; import { ActionType, IActionEnvelope } from '../../common/state/sessionActions.js'; import { AgentService } from '../../node/agentService.js'; import { MockAgent } from './mockAgent.js'; @@ -21,7 +22,14 @@ suite('AgentService (node dispatcher)', () => { let copilotAgent: MockAgent; setup(() => { - service = disposables.add(new AgentService(new NullLogService(), disposables.add(new FileService(new NullLogService())))); + const nullSessionDataService: ISessionDataService = { + _serviceBrand: undefined, + getSessionDataDir: () => URI.parse('inmemory:/session-data'), + getSessionDataDirById: () => URI.parse('inmemory:/session-data'), + deleteSessionData: async () => { }, + cleanupOrphanedData: async () => { }, + }; + service = disposables.add(new AgentService(new NullLogService(), disposables.add(new FileService(new NullLogService())), nullSessionDataService)); copilotAgent = new MockAgent('copilot'); disposables.add(toDisposable(() => copilotAgent.dispose())); }); diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 72536646a26b1..582ebeade5a15 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -14,6 +14,7 @@ import { FileService } from '../../../files/common/fileService.js'; import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; import { NullLogService } from '../../../log/common/log.js'; import { AgentSession, IAgent } from '../../common/agentService.js'; +import { ISessionDataService } from '../../common/sessionDataService.js'; import { ActionType, IActionEnvelope, ISessionAction } from '../../common/state/sessionActions.js'; import { PermissionKind, SessionStatus } from '../../common/state/sessionState.js'; import { AgentSideEffects } from '../../node/agentSideEffects.js'; @@ -69,6 +70,13 @@ suite('AgentSideEffects', () => { sideEffects = disposables.add(new AgentSideEffects(stateManager, { getAgent: () => agent, agents: agentList, + sessionDataService: { + _serviceBrand: undefined, + getSessionDataDir: () => URI.from({ scheme: Schemas.inMemory, path: '/session-data' }), + getSessionDataDirById: () => URI.from({ scheme: Schemas.inMemory, path: '/session-data' }), + deleteSessionData: async () => { }, + cleanupOrphanedData: async () => { }, + } satisfies ISessionDataService, }, new NullLogService(), fileService)); }); diff --git a/src/vs/platform/agentHost/test/node/fileEditTracker.test.ts b/src/vs/platform/agentHost/test/node/fileEditTracker.test.ts new file mode 100644 index 0000000000000..3fad7ee4a7016 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/fileEditTracker.test.ts @@ -0,0 +1,123 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { VSBuffer } from '../../../../base/common/buffer.js'; +import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../base/common/network.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { FileService } from '../../../files/common/fileService.js'; +import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; +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'; + +suite('FileEditTracker', () => { + + const disposables = new DisposableStore(); + let fileService: FileService; + let sessionDataService: ISessionDataService; + let tracker: FileEditTracker; + + const basePath = URI.from({ scheme: Schemas.inMemory, path: '/userData' }); + + setup(() => { + fileService = disposables.add(new FileService(new NullLogService())); + disposables.add(fileService.registerProvider(Schemas.inMemory, disposables.add(new InMemoryFileSystemProvider()))); + sessionDataService = new SessionDataService(basePath, fileService, new NullLogService()); + tracker = new FileEditTracker('test-session', sessionDataService, fileService, new NullLogService()); + }); + + teardown(() => disposables.clear()); + ensureNoDisposablesAreLeakedInTestSuite(); + + test('tracks edit start and complete for existing file', async () => { + const sourceFs = disposables.add(new InMemoryFileSystemProvider()); + disposables.add(fileService.registerProvider(Schemas.file, sourceFs)); + await fileService.writeFile(URI.file('/workspace/test.txt'), VSBuffer.fromString('original content\nline 2')); + + await tracker.trackEditStart('/workspace/test.txt'); + await fileService.writeFile(URI.file('/workspace/test.txt'), VSBuffer.fromString('modified content\nline 2\nline 3')); + await tracker.completeEdit('/workspace/test.txt'); + + 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); + }); + + test('tracks edit for newly created file (no before content)', async () => { + const sourceFs = disposables.add(new InMemoryFileSystemProvider()); + disposables.add(fileService.registerProvider(Schemas.file, sourceFs)); + + await tracker.trackEditStart('/workspace/new-file.txt'); + await fileService.writeFile(URI.file('/workspace/new-file.txt'), VSBuffer.fromString('new file\ncontent')); + await tracker.completeEdit('/workspace/new-file.txt'); + + const fileEdit = tracker.takeCompletedEdit('/workspace/new-file.txt'); + assert.ok(fileEdit); + assert.strictEqual(fileEdit.diff?.added, 2); + assert.strictEqual(fileEdit.diff?.removed, 0); + }); + + test('takeCompletedEdit returns undefined for unknown file path', () => { + const result = tracker.takeCompletedEdit('/nonexistent'); + assert.strictEqual(result, undefined); + }); + + test('content URIs use the correct scheme and format', 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 tracker.trackEditStart('/workspace/file.ts'); + await fileService.writeFile(URI.file('/workspace/file.ts'), VSBuffer.fromString('new code')); + 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); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index 3eac4562547b2..ad98acd79b4e2 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -92,6 +92,9 @@ class MockSideEffectHandler implements IProtocolSideEffectHandler { getDefaultDirectory(): string { return URI.file('/home/testuser').toString(); } + async handleFetchContent(_uri: string): Promise<{ data: string; encoding: 'utf-8'; contentType?: string }> { + throw new Error('Not implemented'); + } } // ---- Helpers ---------------------------------------------------------------- diff --git a/src/vs/platform/agentHost/test/node/sessionDataService.test.ts b/src/vs/platform/agentHost/test/node/sessionDataService.test.ts new file mode 100644 index 0000000000000..5adefa25f084d --- /dev/null +++ b/src/vs/platform/agentHost/test/node/sessionDataService.test.ts @@ -0,0 +1,82 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { VSBuffer } from '../../../../base/common/buffer.js'; +import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../base/common/network.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { FileService } from '../../../files/common/fileService.js'; +import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { AgentSession } from '../../common/agentService.js'; +import { SessionDataService } from '../../node/sessionDataService.js'; + +suite('SessionDataService', () => { + + const disposables = new DisposableStore(); + let fileService: FileService; + let service: SessionDataService; + const basePath = URI.from({ scheme: Schemas.inMemory, path: '/userData' }); + + setup(() => { + fileService = disposables.add(new FileService(new NullLogService())); + disposables.add(fileService.registerProvider(Schemas.inMemory, disposables.add(new InMemoryFileSystemProvider()))); + service = new SessionDataService(basePath, fileService, new NullLogService()); + }); + + teardown(() => disposables.clear()); + ensureNoDisposablesAreLeakedInTestSuite(); + + test('getSessionDataDir returns correct URI', () => { + const session = AgentSession.uri('copilot', 'abc-123'); + const dir = service.getSessionDataDir(session); + assert.strictEqual(dir.toString(), URI.joinPath(basePath, 'agentSessionData', 'abc-123').toString()); + }); + + test('getSessionDataDir sanitizes unsafe characters', () => { + const session = AgentSession.uri('copilot', 'foo/bar:baz\\qux'); + const dir = service.getSessionDataDir(session); + assert.strictEqual(dir.toString(), URI.joinPath(basePath, 'agentSessionData', 'foo-bar-baz-qux').toString()); + }); + + test('deleteSessionData removes directory', async () => { + const session = AgentSession.uri('copilot', 'session-1'); + const dir = service.getSessionDataDir(session); + await fileService.createFolder(dir); + await fileService.writeFile(URI.joinPath(dir, 'snapshot.json'), VSBuffer.fromString('{}')); + + assert.ok(await fileService.exists(dir)); + await service.deleteSessionData(session); + assert.ok(!(await fileService.exists(dir))); + }); + + test('deleteSessionData is a no-op when directory does not exist', async () => { + const session = AgentSession.uri('copilot', 'nonexistent'); + // Should not throw + await service.deleteSessionData(session); + }); + + test('cleanupOrphanedData deletes orphans but keeps known sessions', async () => { + const baseDir = URI.joinPath(basePath, 'agentSessionData'); + await fileService.createFolder(URI.joinPath(baseDir, 'keep-1')); + await fileService.createFolder(URI.joinPath(baseDir, 'keep-2')); + await fileService.createFolder(URI.joinPath(baseDir, 'orphan-1')); + await fileService.createFolder(URI.joinPath(baseDir, 'orphan-2')); + + await service.cleanupOrphanedData(new Set(['keep-1', 'keep-2'])); + + assert.ok(await fileService.exists(URI.joinPath(baseDir, 'keep-1'))); + assert.ok(await fileService.exists(URI.joinPath(baseDir, 'keep-2'))); + assert.ok(!(await fileService.exists(URI.joinPath(baseDir, 'orphan-1')))); + assert.ok(!(await fileService.exists(URI.joinPath(baseDir, 'orphan-2')))); + }); + + test('cleanupOrphanedData is a no-op when base directory does not exist', async () => { + // Should not throw + await service.cleanupOrphanedData(new Set()); + }); +}); From c72e8d2173ef791cb562408aa5804705e29f35b3 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Thu, 19 Mar 2026 16:59:30 -0600 Subject: [PATCH 02/33] Prioritize active sessions in sorting for improved user experience --- .../contrib/chat/browser/agentSessions/agentSessionsViewer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts index 67b46dcc345d2..3958daa410fd5 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts @@ -852,7 +852,7 @@ export class AgentSessionsDataSource extends Disposable implements IAsyncDataSou const sorter = this.sorter; const sortedSessions = sorter instanceof AgentSessionsSorter - ? sessions.sort((a, b) => sorter.compare(a, b, isCapped /* special sorting for when results are capped to keep active ones top */)) + ? sessions.sort((a, b) => sorter.compare(a, b, true /* prioritize active sessions to keep in-progress/needs-input ones top within each group */)) : sessions.sort(sorter.compare.bind(sorter)); if (isCapped) { From ebe83b28f866b89ff2e3e31c12dcb852558a4300 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Mon, 23 Mar 2026 14:11:31 -0700 Subject: [PATCH 03/33] Add "Show More" functionality for capped repository groups in agent sessions --- .../sessions/browser/sessionsViewPane.ts | 2 + .../agentSessions/agentSessionsControl.ts | 24 +++-- .../agentSessions/agentSessionsModel.ts | 14 +++ .../agentSessions/agentSessionsViewer.ts | 101 ++++++++++++++++-- .../media/agentsessionsviewer.css | 13 +++ .../agentSessionsDataSource.test.ts | 95 +++++++++++++++- 6 files changed, 235 insertions(+), 14 deletions(-) diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts index 66f102e9d76a8..3af5a41ef2a5a 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts @@ -23,6 +23,7 @@ import { IConfigurationService } from '../../../../platform/configuration/common import { IHoverService } from '../../../../platform/hover/browser/hover.js'; import { localize, localize2 } from '../../../../nls.js'; import { AgentSessionsControl } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsControl.js'; +import { AgentSessionsDataSource } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.js'; import { AgentSessionsFilter, AgentSessionsGrouping, AgentSessionsSorting } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.js'; import { AgentSessionProviders, isAgentHostTarget } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; import { ISessionsManagementService, IsNewChatSessionContext } from './sessionsManagementService.js'; @@ -157,6 +158,7 @@ export class AgenticSessionsViewPane extends ViewPane { overrideStyles: this.getLocationBasedColors().listOverrideStyles, disableHover: true, enableApprovalRow: true, + repositoryGroupLimit: AgentSessionsDataSource.REPOSITORY_GROUP_LIMIT, getHoverPosition: () => this.getSessionHoverPosition(), trackActiveEditorSession: () => true, collapseOlderSections: () => true, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts index 1f073a38862d6..809502a5cecd5 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts @@ -12,8 +12,8 @@ import { $, append, EventHelper, addDisposableListener, EventType, hide, setVisi import { StandardKeyboardEvent } from '../../../../../base/browser/keyboardEvent.js'; import { KeyCode } from '../../../../../base/common/keyCodes.js'; import { localize } from '../../../../../nls.js'; -import { AgentSessionSection, IAgentSession, IAgentSessionSection, IAgentSessionsModel, IMarshalledAgentSessionContext, isAgentSession, isAgentSessionSection } from './agentSessionsModel.js'; -import { AgentSessionListItem, AgentSessionRenderer, AgentSessionsAccessibilityProvider, AgentSessionsCompressionDelegate, AgentSessionsDataSource, AgentSessionsDragAndDrop, AgentSessionsIdentityProvider, AgentSessionsKeyboardNavigationLabelProvider, AgentSessionsListDelegate, AgentSessionSectionRenderer, AgentSessionSectionLabels, AgentSessionsSorter, getRepositoryName, IAgentSessionsFilter } from './agentSessionsViewer.js'; +import { AgentSessionSection, IAgentSession, IAgentSessionSection, IAgentSessionsModel, IMarshalledAgentSessionContext, isAgentSession, isAgentSessionSection, isAgentSessionShowMore } from './agentSessionsModel.js'; +import { AgentSessionListItem, AgentSessionRenderer, AgentSessionsAccessibilityProvider, AgentSessionsCompressionDelegate, AgentSessionsDataSource, AgentSessionsDragAndDrop, AgentSessionsIdentityProvider, AgentSessionsKeyboardNavigationLabelProvider, AgentSessionsListDelegate, AgentSessionSectionRenderer, AgentSessionSectionLabels, AgentSessionShowMoreRenderer, AgentSessionsSorter, getRepositoryName, IAgentSessionsFilter } from './agentSessionsViewer.js'; import { AgentSessionsGrouping, AgentSessionsSorting } from './agentSessionsFilter.js'; import { AgentSessionApprovalModel } from './agentSessionApprovalModel.js'; import { FuzzyScore } from '../../../../../base/common/filters.js'; @@ -49,6 +49,7 @@ export interface IAgentSessionsControlOptions { readonly source: string; readonly disableHover?: boolean; readonly enableApprovalRow?: boolean; + readonly repositoryGroupLimit?: number; getHoverPosition(): HoverPosition; trackActiveEditorSession(): boolean; @@ -79,6 +80,7 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo private emptyFilterMessage: HTMLElement | undefined; private sessionsList: WorkbenchCompressibleAsyncDataTree | undefined; + private sessionsDataSource: AgentSessionsDataSource | undefined; private static readonly RECENT_SESSIONS_FOR_EXPAND = 5; private sessionsListFindIsOpen = false; @@ -260,7 +262,7 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo isGroupedByRepository: () => this.options.filter.groupResults?.() === AgentSessionsGrouping.Repository, isSortedByUpdated: () => this.options.filter.sortResults?.() === AgentSessionsSorting.Updated, }, approvalModel, activeSessionResource)); - const sessionFilter = this._register(new AgentSessionsDataSource(this.options.filter, sorter)); + const sessionDataSource = this.sessionsDataSource = this._register(new AgentSessionsDataSource(this.options.filter, sorter, this.options.repositoryGroupLimit)); const list = this.sessionsList = this._register(this.instantiationService.createInstance(WorkbenchCompressibleAsyncDataTree, 'AgentSessionsView', container, @@ -269,8 +271,9 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo [ sessionRenderer, this.instantiationService.createInstance(AgentSessionSectionRenderer), + new AgentSessionShowMoreRenderer(), ], - sessionFilter, + sessionDataSource, { accessibilityProvider: new AgentSessionsAccessibilityProvider(), dnd: this.instantiationService.createInstance(AgentSessionsDragAndDrop), @@ -295,10 +298,14 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo } })); - this._register(sessionFilter.onDidGetChildren(count => { + this._register(sessionDataSource.onDidGetChildren(count => { this.updateEmpty(count === 0); })); + this._register(sessionDataSource.onDidExpandRepositoryGroup(() => { + this.update(); + })); + const model = this.agentSessionsService.model; this._register(this.options.filter.onDidChange(async () => { @@ -406,6 +413,11 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo return; // Section headers are not openable } + if (isAgentSessionShowMore(element)) { + this.sessionsDataSource?.expandRepositoryGroup(element.sectionLabel); + return; + } + this.telemetryService.publicLog2('agentSessionOpened', { providerType: element.providerType, source: this.options.source @@ -423,7 +435,7 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo } private async showContextMenu({ element, anchor, browserEvent }: ITreeContextMenuEvent): Promise { - if (!element) { + if (!element || isAgentSessionShowMore(element)) { return; } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts index 9115dea9a4015..b98bfa148afe5 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts @@ -195,6 +195,20 @@ export function isAgentSessionSection(obj: unknown): obj is IAgentSessionSection return typeof candidate.section === 'string' && Array.isArray(candidate.sessions); } +/** + * A "Show N More..." item that appears as the last child + * of a capped repository group section. + */ +export interface IAgentSessionShowMore { + readonly showMore: true; + readonly sectionLabel: string; + readonly remainingCount: number; +} + +export function isAgentSessionShowMore(obj: unknown): obj is IAgentSessionShowMore { + return (obj as IAgentSessionShowMore)?.showMore === true; +} + export interface IMarshalledAgentSessionContext { readonly $mid: MarshalledId.AgentSessionContext; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts index 3958daa410fd5..9950399ce8420 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts @@ -14,7 +14,7 @@ import { ICompressedTreeNode } from '../../../../../base/browser/ui/tree/compres import { ICompressibleKeyboardNavigationLabelProvider, ICompressibleTreeRenderer } from '../../../../../base/browser/ui/tree/objectTree.js'; import { ITreeNode, ITreeElementRenderDetails, IAsyncDataSource, ITreeSorter, ITreeDragAndDrop, ITreeDragOverReaction } from '../../../../../base/browser/ui/tree/tree.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; -import { AgentSessionSection, AgentSessionStatus, getAgentChangesSummary, hasValidDiff, IAgentSession, IAgentSessionSection, IAgentSessionsModel, isAgentSession, isAgentSessionSection, isAgentSessionsModel, isSessionInProgressStatus } from './agentSessionsModel.js'; +import { AgentSessionSection, AgentSessionStatus, getAgentChangesSummary, hasValidDiff, IAgentSession, IAgentSessionSection, IAgentSessionShowMore, IAgentSessionsModel, isAgentSession, isAgentSessionSection, isAgentSessionShowMore, isAgentSessionsModel, isSessionInProgressStatus } from './agentSessionsModel.js'; import { IconLabel } from '../../../../../base/browser/ui/iconLabel/iconLabel.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { Codicon } from '../../../../../base/common/codicons.js'; @@ -50,7 +50,7 @@ import { defaultButtonStyles } from '../../../../../platform/theme/browser/defau import { AgentSessionApprovalModel } from './agentSessionApprovalModel.js'; import { BugIndicatingError } from '../../../../../base/common/errors.js'; -export type AgentSessionListItem = IAgentSession | IAgentSessionSection; +export type AgentSessionListItem = IAgentSession | IAgentSessionSection | IAgentSessionShowMore; //#region Agent Session Renderer @@ -656,6 +656,55 @@ export class AgentSessionSectionRenderer implements ICompressibleTreeRenderer { + + static readonly TEMPLATE_ID = 'agent-session-show-more'; + static readonly HEIGHT = 26; + + readonly templateId = AgentSessionShowMoreRenderer.TEMPLATE_ID; + + renderTemplate(container: HTMLElement): IAgentSessionShowMoreTemplate { + const disposables = new DisposableStore(); + + const elements = h( + 'div.agent-session-show-more@container', + [h('span.agent-session-show-more-label@label')] + ); + + container.appendChild(elements.container); + + return { + container: elements.container, + label: elements.label, + disposables, + }; + } + + renderElement(element: ITreeNode, _index: number, template: IAgentSessionShowMoreTemplate): void { + template.label.textContent = localize('agentSessions.showMore', "Show {0} More...", element.element.remainingCount); + } + + renderCompressedElements(): void { + throw new Error('Should never happen since show-more is incompressible'); + } + + disposeElement(): void { } + + disposeTemplate(templateData: IAgentSessionShowMoreTemplate): void { + templateData.disposables.dispose(); + } +} + +//#endregion + export class AgentSessionsListDelegate implements IListVirtualDelegate { static readonly ITEM_HEIGHT = 54; @@ -668,6 +717,10 @@ export class AgentSessionsListDelegate implements IListVirtualDelegate { private static readonly CAPPED_SESSIONS_LIMIT = 3; + static readonly REPOSITORY_GROUP_LIMIT = 5; private readonly _onDidGetChildren = this._register(new Emitter()); readonly onDidGetChildren: Event = this._onDidGetChildren.event; + private readonly _onDidExpandRepositoryGroup = this._register(new Emitter()); + readonly onDidExpandRepositoryGroup: Event = this._onDidExpandRepositoryGroup.event; + + private readonly expandedRepositoryGroups = new Set(); + constructor( private readonly filter: IAgentSessionsFilter | undefined, private readonly sorter: ITreeSorter, + private readonly repositoryGroupLimit?: number, ) { super(); } + expandRepositoryGroup(sectionLabel: string): void { + this.expandedRepositoryGroups.add(sectionLabel); + this._onDidExpandRepositoryGroup.fire(); + } + hasChildren(element: IAgentSessionsModel | AgentSessionListItem): boolean { // Sessions model @@ -798,7 +871,7 @@ export class AgentSessionsDataSource extends Disposable implements IAsyncDataSou return element.sessions.length > 0; } - // Session element + // Session element or show more else { return false; } @@ -838,10 +911,16 @@ export class AgentSessionsDataSource extends Disposable implements IAsyncDataSou // Sessions section else if (isAgentSessionSection(element)) { + const limit = this.repositoryGroupLimit; + if (limit && element.section === AgentSessionSection.Repository && !this.expandedRepositoryGroups.has(element.label) && element.sessions.length > limit) { + const visible = element.sessions.slice(0, limit); + const remainingCount = element.sessions.length - limit; + return [...visible, { showMore: true as const, sectionLabel: element.label, remainingCount }]; + } return element.sessions; } - // Session element + // Session element or show more else { return []; } @@ -1232,6 +1311,10 @@ export class AgentSessionsIdentityProvider implements IIdentityProvider { }); }); + suite('repositoryGroupLimit', () => { + + test('caps repo group children at limit and appends show-more item', () => { + const now = Date.now(); + const sessions = Array.from({ length: 8 }, (_, i) => + createMockSession({ id: `s${i}`, metadata: { repositoryNwo: 'owner/vscode' }, startTime: now - i * 1000 }) + ); + + const filter = createMockFilter({ groupBy: AgentSessionsGrouping.Repository }); + const dataSource = disposables.add(new AgentSessionsDataSource(filter, createMockSorter(), 5)); + const model = createMockModel(sessions); + const topLevel = Array.from(dataSource.getChildren(model)); + const section = topLevel.find(item => isAgentSessionSection(item) && item.section === AgentSessionSection.Repository) as IAgentSessionSection; + assert.ok(section); + + const children = Array.from(dataSource.getChildren(section)); + assert.strictEqual(children.length, 6); // 5 sessions + 1 show-more + const showMore = children[5]; + assert.ok(isAgentSessionShowMore(showMore)); + assert.strictEqual(showMore.remainingCount, 3); + assert.strictEqual(showMore.sectionLabel, 'vscode'); + }); + + test('does not cap when group has fewer items than limit', () => { + const now = Date.now(); + const sessions = Array.from({ length: 3 }, (_, i) => + createMockSession({ id: `s${i}`, metadata: { repositoryNwo: 'owner/vscode' }, startTime: now - i * 1000 }) + ); + + const filter = createMockFilter({ groupBy: AgentSessionsGrouping.Repository }); + const dataSource = disposables.add(new AgentSessionsDataSource(filter, createMockSorter(), 5)); + const model = createMockModel(sessions); + const topLevel = Array.from(dataSource.getChildren(model)); + const section = topLevel.find(item => isAgentSessionSection(item) && item.section === AgentSessionSection.Repository) as IAgentSessionSection; + + const children = Array.from(dataSource.getChildren(section)); + assert.strictEqual(children.length, 3); + assert.ok(!children.some(isAgentSessionShowMore)); + }); + + test('expanding a group removes the cap', () => { + const now = Date.now(); + const sessions = Array.from({ length: 8 }, (_, i) => + createMockSession({ id: `s${i}`, metadata: { repositoryNwo: 'owner/vscode' }, startTime: now - i * 1000 }) + ); + + const filter = createMockFilter({ groupBy: AgentSessionsGrouping.Repository }); + const dataSource = disposables.add(new AgentSessionsDataSource(filter, createMockSorter(), 5)); + const model = createMockModel(sessions); + const topLevel = Array.from(dataSource.getChildren(model)); + const section = topLevel.find(item => isAgentSessionSection(item) && item.section === AgentSessionSection.Repository) as IAgentSessionSection; + + dataSource.expandRepositoryGroup('vscode'); + const children = Array.from(dataSource.getChildren(section)); + assert.strictEqual(children.length, 8); + assert.ok(!children.some(isAgentSessionShowMore)); + }); + + test('does not cap non-repository sections', () => { + const now = Date.now(); + const sessions = Array.from({ length: 8 }, (_, i) => + createMockSession({ id: `s${i}`, startTime: now - i * 1000 }) + ); + + const filter = createMockFilter({ groupBy: AgentSessionsGrouping.Date }); + const dataSource = disposables.add(new AgentSessionsDataSource(filter, createMockSorter(), 5)); + const model = createMockModel(sessions); + const topLevel = Array.from(dataSource.getChildren(model)); + const todaySection = topLevel.find(item => isAgentSessionSection(item) && item.section === AgentSessionSection.Today) as IAgentSessionSection; + + const children = Array.from(dataSource.getChildren(todaySection)); + assert.strictEqual(children.length, 8); + assert.ok(!children.some(isAgentSessionShowMore)); + }); + + test('does not cap when repositoryGroupLimit is not set', () => { + const now = Date.now(); + const sessions = Array.from({ length: 8 }, (_, i) => + createMockSession({ id: `s${i}`, metadata: { repositoryNwo: 'owner/vscode' }, startTime: now - i * 1000 }) + ); + + const filter = createMockFilter({ groupBy: AgentSessionsGrouping.Repository }); + const dataSource = disposables.add(new AgentSessionsDataSource(filter, createMockSorter())); + const model = createMockModel(sessions); + const topLevel = Array.from(dataSource.getChildren(model)); + const section = topLevel.find(item => isAgentSessionSection(item) && item.section === AgentSessionSection.Repository) as IAgentSessionSection; + + const children = Array.from(dataSource.getChildren(section)); + assert.strictEqual(children.length, 8); + assert.ok(!children.some(isAgentSessionShowMore)); + }); + }); + suite('getRepositoryName', () => { test('returns metadata.name when owner and name are present', () => { From bb2dcc7750f27c68933f6d0d3dc839a2906834de Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Mon, 23 Mar 2026 14:37:37 -0700 Subject: [PATCH 04/33] Implement repository group capping with "Show More" functionality in agent sessions --- .../agentSessions/agentSessionsFilter.ts | 27 +++++++++++++++++++ .../agentSessions/agentSessionsViewer.ts | 18 ++++++++++--- .../agentSessionsDataSource.test.ts | 20 +++++++++++++- 3 files changed, 60 insertions(+), 5 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts index eba6abdd656c6..4a33a06dacaf7 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts @@ -56,6 +56,7 @@ const DEFAULT_EXCLUDES: IAgentSessionsFilterExcludes = Object.freeze({ states: [] as const, archived: true as const /* archived are never excluded but toggle between expanded and collapsed */, read: false as const, + repositoryGroupCapped: true as const /* when true, repo groups are capped at a limit with a "show more" item */, }); export class AgentSessionsFilter extends Disposable implements Required { @@ -143,6 +144,7 @@ export class AgentSessionsFilter extends Disposable implements Required { + // Clear expanded state when capping is re-enabled + if (this.filter?.getExcludes().repositoryGroupCapped) { + this.expandedRepositoryGroups.clear(); + } + })); + } } expandRepositoryGroup(sectionLabel: string): void { @@ -911,10 +921,10 @@ export class AgentSessionsDataSource extends Disposable implements IAsyncDataSou // Sessions section else if (isAgentSessionSection(element)) { - const limit = this.repositoryGroupLimit; - if (limit && element.section === AgentSessionSection.Repository && !this.expandedRepositoryGroups.has(element.label) && element.sessions.length > limit) { - const visible = element.sessions.slice(0, limit); - const remainingCount = element.sessions.length - limit; + const isCappingEnabled = this.repositoryGroupLimit && this.filter?.getExcludes().repositoryGroupCapped; + if (isCappingEnabled && element.section === AgentSessionSection.Repository && !this.expandedRepositoryGroups.has(element.label) && element.sessions.length > this.repositoryGroupLimit) { + const visible = element.sessions.slice(0, this.repositoryGroupLimit); + const remainingCount = element.sessions.length - this.repositoryGroupLimit; return [...visible, { showMore: true as const, sectionLabel: element.label, remainingCount }]; } return element.sessions; diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts index 1124363eb9056..9177b5307f4d9 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts @@ -137,12 +137,13 @@ suite('AgentSessionsDataSource', () => { groupBy?: AgentSessionsGrouping; exclude?: (session: IAgentSession) => boolean; excludeRead?: boolean; + repositoryGroupCapped?: boolean; }): IAgentSessionsFilter { return { onDidChange: Event.None, groupResults: () => options.groupBy, exclude: options.exclude ?? (() => false), - getExcludes: () => ({ providers: [], states: [], archived: false, read: options.excludeRead ?? false }), + getExcludes: () => ({ providers: [], states: [], archived: false, read: options.excludeRead ?? false, repositoryGroupCapped: options.repositoryGroupCapped ?? true }), isDefault: () => true, reset: () => { }, }; @@ -1075,6 +1076,23 @@ suite('AgentSessionsDataSource', () => { assert.strictEqual(children.length, 8); assert.ok(!children.some(isAgentSessionShowMore)); }); + + test('does not cap when repositoryGroupCapped filter is disabled', () => { + const now = Date.now(); + const sessions = Array.from({ length: 8 }, (_, i) => + createMockSession({ id: `s${i}`, metadata: { repositoryNwo: 'owner/vscode' }, startTime: now - i * 1000 }) + ); + + const filter = createMockFilter({ groupBy: AgentSessionsGrouping.Repository, repositoryGroupCapped: false }); + const dataSource = disposables.add(new AgentSessionsDataSource(filter, createMockSorter(), 5)); + const model = createMockModel(sessions); + const topLevel = Array.from(dataSource.getChildren(model)); + const section = topLevel.find(item => isAgentSessionSection(item) && item.section === AgentSessionSection.Repository) as IAgentSessionSection; + + const children = Array.from(dataSource.getChildren(section)); + assert.strictEqual(children.length, 8); + assert.ok(!children.some(isAgentSessionShowMore)); + }); }); suite('getRepositoryName', () => { From 424f656151e296e595022ddad84df12c52b1cf44 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Mon, 23 Mar 2026 14:40:18 -0700 Subject: [PATCH 05/33] Update agent session item styling for improved alignment and color --- .../chat/browser/agentSessions/media/agentsessionsviewer.css | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css b/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css index 1b8bc367320eb..094e1fd59848f 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css @@ -325,8 +325,9 @@ display: flex; align-items: center; font-size: 12px; - color: var(--vscode-textLink-foreground); - padding: 0 12px; + color: rgb(191, 191, 191); + /* align with session item text: 6px item padding + 16px icon + 6px main-col padding */ + padding: 0 6px 0 28px; cursor: pointer; &:hover { From 70389077ba6e2ddc3608217adbe9f847edd1bd3c Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Mon, 23 Mar 2026 14:54:37 -0700 Subject: [PATCH 06/33] Enhance "Show More" label styling with hover effect for better visibility --- .../browser/agentSessions/media/agentsessionsviewer.css | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css b/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css index 094e1fd59848f..6eba1eeec418f 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css @@ -329,8 +329,13 @@ /* align with session item text: 6px item padding + 16px icon + 6px main-col padding */ padding: 0 6px 0 28px; cursor: pointer; + text-decoration: none; - &:hover { + .agent-session-show-more-label { + text-decoration: none; + } + + &:hover .agent-session-show-more-label { text-decoration: underline; } } From 59c4a85bf885360dc31b2d6aaecd43bf36335daa Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Mon, 23 Mar 2026 14:58:17 -0700 Subject: [PATCH 07/33] Add repository group capping functionality with toggle actions in sessions view --- .../sessions/browser/sessionsViewPane.ts | 61 ++++++++++++++++++- .../agentSessions/agentSessionsFilter.ts | 29 ++------- 2 files changed, 66 insertions(+), 24 deletions(-) diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts index 3af5a41ef2a5a..623b2e5a83144 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts @@ -45,6 +45,7 @@ const SessionsViewFilterSubMenu = new MenuId('AgentSessionsViewFilterSubMenu'); const SessionsViewFilterOptionsSubMenu = new MenuId('AgentSessionsViewFilterOptionsSubMenu'); const SessionsViewGroupingContext = new RawContextKey('sessionsView.grouping', AgentSessionsGrouping.Repository); const SessionsViewSortingContext = new RawContextKey('sessionsView.sorting', AgentSessionsSorting.Created); +const IsRepositoryGroupCappedContext = new RawContextKey('sessionsView.isRepositoryGroupCapped', true); const GROUPING_STORAGE_KEY = 'agentSessions.grouping'; const SORTING_STORAGE_KEY = 'agentSessions.sorting'; @@ -53,10 +54,12 @@ export class AgenticSessionsViewPane extends ViewPane { private viewPaneContainer: HTMLElement | undefined; private sessionsControlContainer: HTMLElement | undefined; sessionsControl: AgentSessionsControl | undefined; + private sessionsFilter: AgentSessionsFilter | undefined; private currentGrouping: AgentSessionsGrouping = AgentSessionsGrouping.Repository; private currentSorting: AgentSessionsSorting = AgentSessionsSorting.Created; private groupingContextKey: IContextKey | undefined; private sortingContextKey: IContextKey | undefined; + private isRepositoryGroupCappedContextKey: IContextKey | undefined; constructor( options: IViewPaneOptions, @@ -93,6 +96,7 @@ export class AgenticSessionsViewPane extends ViewPane { this.groupingContextKey.set(this.currentGrouping); this.sortingContextKey = SessionsViewSortingContext.bindTo(contextKeyService); this.sortingContextKey.set(this.currentSorting); + this.isRepositoryGroupCappedContextKey = IsRepositoryGroupCappedContext.bindTo(contextKeyService); } protected override renderBody(parent: HTMLElement): void { @@ -120,7 +124,7 @@ export class AgenticSessionsViewPane extends ViewPane { const sessionsContainer = DOM.append(parent, $('.agent-sessions-container')); // Sessions Filter (actions go to the nested filter submenu) - const sessionsFilter = this._register(this.instantiationService.createInstance(AgentSessionsFilter, { + const sessionsFilter = this.sessionsFilter = this._register(this.instantiationService.createInstance(AgentSessionsFilter, { filterMenuId: SessionsViewFilterOptionsSubMenu, groupResults: () => this.currentGrouping, sortResults: () => this.currentSorting, @@ -131,6 +135,12 @@ export class AgenticSessionsViewPane extends ViewPane { ]), })); + // Sync context key with filter state + this._register(sessionsFilter.onDidChange(() => { + this.isRepositoryGroupCappedContextKey?.set(sessionsFilter.getExcludes().repositoryGroupCapped); + })); + this.isRepositoryGroupCappedContextKey?.set(sessionsFilter.getExcludes().repositoryGroupCapped); + // Sessions section (top, fills available space) const sessionsSection = DOM.append(sessionsContainer, $('.agent-sessions-section')); @@ -268,6 +278,10 @@ export class AgenticSessionsViewPane extends ViewPane { this.sortingContextKey?.set(this.currentSorting); this.sessionsControl?.update(); } + + toggleRepositoryGroupCapped(): void { + this.sessionsFilter?.setRepositoryGroupCapped(!this.sessionsFilter.getExcludes().repositoryGroupCapped); + } } // Register Cmd+N / Ctrl+N keybinding for new session in the agent sessions window @@ -413,6 +427,51 @@ registerAction2(class GroupByTimeAction extends Action2 { } }); +// Toggle between showing top 5 or all sessions per repo group +registerAction2(class ShowTopSessionsAction extends Action2 { + constructor() { + super({ + id: 'sessionsView.showTopSessions', + title: localize2('showTopSessions', "Show Top {0} Sessions", AgentSessionsDataSource.REPOSITORY_GROUP_LIMIT), + category: SessionsCategories.Sessions, + menu: [{ + id: SessionsViewFilterSubMenu, + group: '4_cap', + order: 0, + when: IsRepositoryGroupCappedContext.negate(), + }] + }); + } + + override run(accessor: ServicesAccessor) { + const viewsService = accessor.get(IViewsService); + const view = viewsService.getViewWithId(SessionsViewId); + view?.toggleRepositoryGroupCapped(); + } +}); + +registerAction2(class ShowAllSessionsAction extends Action2 { + constructor() { + super({ + id: 'sessionsView.showAllSessions', + title: localize2('showAllSessions', "Show All Sessions"), + category: SessionsCategories.Sessions, + menu: [{ + id: SessionsViewFilterSubMenu, + group: '4_cap', + order: 0, + when: IsRepositoryGroupCappedContext, + }] + }); + } + + override run(accessor: ServicesAccessor) { + const viewsService = accessor.get(IViewsService); + const view = viewsService.getViewWithId(SessionsViewId); + view?.toggleRepositoryGroupCapped(); + } +}); + registerAction2(class RefreshAgentSessionsViewerAction extends Action2 { constructor() { super({ diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts index 4a33a06dacaf7..5f0e8a7454562 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts @@ -144,7 +144,6 @@ export class AgentSessionsFilter extends Disposable implements Required Date: Mon, 23 Mar 2026 15:01:58 -0700 Subject: [PATCH 08/33] Refactor repository group capping methods for clarity and consistency in sessions view --- .../contrib/sessions/browser/sessionsViewPane.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts index 623b2e5a83144..d3ee4f4fd396e 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts @@ -279,8 +279,8 @@ export class AgenticSessionsViewPane extends ViewPane { this.sessionsControl?.update(); } - toggleRepositoryGroupCapped(): void { - this.sessionsFilter?.setRepositoryGroupCapped(!this.sessionsFilter.getExcludes().repositoryGroupCapped); + setRepositoryGroupCapped(capped: boolean): void { + this.sessionsFilter?.setRepositoryGroupCapped(capped); } } @@ -427,18 +427,18 @@ registerAction2(class GroupByTimeAction extends Action2 { } }); -// Toggle between showing top 5 or all sessions per repo group +// Show top N or all sessions per repo group (radio pattern) registerAction2(class ShowTopSessionsAction extends Action2 { constructor() { super({ id: 'sessionsView.showTopSessions', title: localize2('showTopSessions', "Show Top {0} Sessions", AgentSessionsDataSource.REPOSITORY_GROUP_LIMIT), category: SessionsCategories.Sessions, + toggled: IsRepositoryGroupCappedContext, menu: [{ id: SessionsViewFilterSubMenu, group: '4_cap', order: 0, - when: IsRepositoryGroupCappedContext.negate(), }] }); } @@ -446,7 +446,7 @@ registerAction2(class ShowTopSessionsAction extends Action2 { override run(accessor: ServicesAccessor) { const viewsService = accessor.get(IViewsService); const view = viewsService.getViewWithId(SessionsViewId); - view?.toggleRepositoryGroupCapped(); + view?.setRepositoryGroupCapped(true); } }); @@ -456,11 +456,11 @@ registerAction2(class ShowAllSessionsAction extends Action2 { id: 'sessionsView.showAllSessions', title: localize2('showAllSessions', "Show All Sessions"), category: SessionsCategories.Sessions, + toggled: IsRepositoryGroupCappedContext.negate(), menu: [{ id: SessionsViewFilterSubMenu, group: '4_cap', - order: 0, - when: IsRepositoryGroupCappedContext, + order: 1, }] }); } @@ -468,7 +468,7 @@ registerAction2(class ShowAllSessionsAction extends Action2 { override run(accessor: ServicesAccessor) { const viewsService = accessor.get(IViewsService); const view = viewsService.getViewWithId(SessionsViewId); - view?.toggleRepositoryGroupCapped(); + view?.setRepositoryGroupCapped(false); } }); From 28f5880a55327242092b06eca60961bfcdb97022 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Mon, 23 Mar 2026 15:02:25 -0700 Subject: [PATCH 09/33] Enhance "Show More" label styling with !important rule for consistent text decoration --- .../browser/agentSessions/media/agentsessionsviewer.css | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css b/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css index 6eba1eeec418f..828b12262e376 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css @@ -329,14 +329,14 @@ /* align with session item text: 6px item padding + 16px icon + 6px main-col padding */ padding: 0 6px 0 28px; cursor: pointer; - text-decoration: none; + text-decoration: none !important; .agent-session-show-more-label { - text-decoration: none; + text-decoration: none !important; } &:hover .agent-session-show-more-label { - text-decoration: underline; + text-decoration: underline !important; } } From 02633fa5d91f257d0fe6e98063a04dce698236b0 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Mon, 23 Mar 2026 15:05:00 -0700 Subject: [PATCH 10/33] Remove redundant text decoration rules for "Show More" label in agent sessions viewer --- .../browser/agentSessions/media/agentsessionsviewer.css | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css b/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css index 828b12262e376..fa0075cac33c6 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css @@ -330,14 +330,6 @@ padding: 0 6px 0 28px; cursor: pointer; text-decoration: none !important; - - .agent-session-show-more-label { - text-decoration: none !important; - } - - &:hover .agent-session-show-more-label { - text-decoration: underline !important; - } } .agent-session-section { From 85e9d41a57bfc77494d7a6bf33a7f267532a3a5a Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Mon, 23 Mar 2026 15:15:03 -0700 Subject: [PATCH 11/33] agentHost: ui side of edits --- .../common}/agentHostFileSystemProvider.ts | 104 +++++++-------- .../platform/agentHost/common/agentHostUri.ts | 118 ++++++++++++++++++ .../platform/agentHost/common/agentService.ts | 8 +- .../agentHost/common/state/sessionState.ts | 18 +++ .../electron-browser/agentHostService.ts | 5 +- .../agentHost/node/agentSideEffects.ts | 11 +- .../agentHost/node/copilot/copilotAgent.ts | 6 +- .../node/copilot/copilotToolDisplay.ts | 38 +++--- .../agentHost/node/copilot/fileEditTracker.ts | 87 +++---------- .../agentHostFileSystemProvider.test.ts | 28 ++--- .../test/node/fileEditTracker.test.ts | 59 +++------ src/vs/platform/label/common/label.ts | 6 + .../browser/remoteAgentHost.contribution.ts | 68 ++++------ .../browser/workspaceFolderManagement.ts | 4 +- .../agentSessions/agentHost/agentHostAuth.ts | 66 ++++++++++ .../agentHost/agentHostChatContribution.ts | 37 +++--- .../agentHost/agentHostSessionHandler.ts | 65 +++++++++- .../agentHost/stateToProgressAdapter.ts | 71 ++++++++++- .../chatEditingDeletedFileEntry.ts | 4 + .../chatEditingModifiedDocumentEntry.ts | 4 + .../chatEditingModifiedFileEntry.ts | 2 +- .../chatEditingModifiedNotebookEntry.ts | 9 ++ .../browser/chatEditing/chatEditingSession.ts | 71 ++++++++--- .../chat/common/chatService/chatService.ts | 7 ++ .../chat/common/editing/chatEditingService.ts | 4 +- .../agentHostChatContribution.test.ts | 5 + .../services/label/common/labelService.ts | 19 ++- 27 files changed, 616 insertions(+), 308 deletions(-) rename src/vs/{sessions/contrib/remoteAgentHost/browser => platform/agentHost/common}/agentHostFileSystemProvider.ts (58%) create mode 100644 src/vs/platform/agentHost/common/agentHostUri.ts rename src/vs/{sessions/contrib/remoteAgentHost/test/browser => platform/agentHost/test/common}/agentHostFileSystemProvider.test.ts (72%) create mode 100644 src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostAuth.ts 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 4b6b21adaea51..f194e543d279f 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 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] + * ``` * - * 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. + * 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 0000000000000..49a172fac0f2e --- /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 e21f2c9cfd0df..6309134eb5768 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 b2b53dda8730b..4725e250dba82 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 da706baae2f0a..0e93d66c6766a 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 9a23ecdb1ee5b..a5df7e1223f79 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 6bdb4aa458a6f..b482b88afbbaa 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 7702844d41825..397c4b0fb71e2 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 5d65e6e5ca870..b7e959c5a807a 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 b4396b54d5939..e2efbd33cdb9b 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 3fad7ee4a7016..8679dab91e452 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 1da00285dc852..11d3f7c7d9261 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 92941d06af8a8..66ff48bdbbf42 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 5c998b718322f..fe3a528e0badd 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 0000000000000..75ad8c68014f7 --- /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 c87e45bead21a..b80233746103a 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 c12d76c276644..af846c596bd27 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 bad97cd25217c..8783104b7b54a 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 eeb4a37a52ff7..a88991de260f9 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 d7e1b62bb3c24..2353e5c489088 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 2b02a675551c0..7ba9d6fb55d71 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 48073f03acf1a..b4c12adb8bbbb 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 04343f023ce73..82430843c753c 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 8e7dc8dc24637..1653dc8b33ed3 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 58b49b13fa570..0572f62a498d3 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 765158017c426..981e6a62813d2 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 47b509ff20f17..a25505387fcdc 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; From dc493680111b8080acc789ead174d380816ef301 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Mon, 23 Mar 2026 15:38:21 -0700 Subject: [PATCH 12/33] tests --- .../agentHostFileSystemProvider.test.ts | 40 +++++- .../agentSessions/agentHostAuth.test.ts | 114 ++++++++++++++++++ .../agentHostChatContribution.test.ts | 6 + .../stateToProgressAdapter.test.ts | 113 +++++++++++++++++ .../services/label/test/browser/label.test.ts | 47 ++++++++ 5 files changed, 319 insertions(+), 1 deletion(-) create mode 100644 src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostAuth.test.ts diff --git a/src/vs/platform/agentHost/test/common/agentHostFileSystemProvider.test.ts b/src/vs/platform/agentHost/test/common/agentHostFileSystemProvider.test.ts index e666a4b958560..658ae2169e704 100644 --- a/src/vs/platform/agentHost/test/common/agentHostFileSystemProvider.test.ts +++ b/src/vs/platform/agentHost/test/common/agentHostFileSystemProvider.test.ts @@ -7,7 +7,7 @@ import assert from 'assert'; 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'; +import { AGENT_HOST_SCHEME, agentHostAuthority, fromAgentHostUri, toAgentHostUri } from '../../common/agentHostUri.js'; suite('AgentHostFileSystemProvider - URI helpers', () => { @@ -90,3 +90,41 @@ suite('AgentHostAuthority - encoding', () => { } }); }); + +suite('toAgentHostUri / fromAgentHostUri', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('round-trips a file URI', () => { + const original = URI.file('/home/user/project/file.ts'); + const wrapped = toAgentHostUri(original, 'my-server'); + assert.strictEqual(wrapped.scheme, AGENT_HOST_SCHEME); + assert.strictEqual(wrapped.authority, 'my-server'); + + const unwrapped = fromAgentHostUri(wrapped); + assert.strictEqual(unwrapped.scheme, 'file'); + assert.strictEqual(unwrapped.path, original.path); + }); + + test('round-trips a URI with authority', () => { + const original = URI.from({ scheme: 'agenthost-content', authority: 'session1', path: '/snap/before' }); + const wrapped = toAgentHostUri(original, 'remote-host'); + const unwrapped = fromAgentHostUri(wrapped); + assert.strictEqual(unwrapped.scheme, 'agenthost-content'); + assert.strictEqual(unwrapped.authority, 'session1'); + assert.strictEqual(unwrapped.path, '/snap/before'); + }); + + test('local authority returns original URI unchanged', () => { + const original = URI.file('/workspace/test.ts'); + const result = toAgentHostUri(original, 'local'); + assert.strictEqual(result.toString(), original.toString()); + }); + + test('fromAgentHostUri handles malformed path gracefully', () => { + const uri = URI.from({ scheme: AGENT_HOST_SCHEME, authority: 'host', path: '/file' }); + const result = fromAgentHostUri(uri); + // Should not throw — falls back to extracting scheme only + assert.strictEqual(result.scheme, 'file'); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostAuth.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostAuth.test.ts new file mode 100644 index 0000000000000..464eb0d8095ac --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostAuth.test.ts @@ -0,0 +1,114 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { URI } from '../../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../../../../platform/log/common/log.js'; +import { IAuthenticationService } from '../../../../../services/authentication/common/authentication.js'; +import { resolveTokenForResource } from '../../../browser/agentSessions/agentHost/agentHostAuth.js'; + +function createMockAuthService(overrides: { + getOrActivateProviderIdForServer?: (serverUri: URI, resourceUri: URI) => Promise; + getSessions?: (providerId: string, scopes: string[] | undefined, options: any, activate: boolean) => Promise; +}): IAuthenticationService { + return { + getOrActivateProviderIdForServer: overrides.getOrActivateProviderIdForServer ?? (() => Promise.resolve(undefined)), + getSessions: overrides.getSessions ?? (() => Promise.resolve([])), + } as unknown as IAuthenticationService; +} + +suite('resolveTokenForResource', () => { + + const log = new NullLogService(); + const resource = URI.parse('https://api.example.com'); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('returns undefined when no authorization servers provided', async () => { + const authService = createMockAuthService({}); + const token = await resolveTokenForResource(resource, [], ['read'], authService, log, 'test'); + assert.strictEqual(token, undefined); + }); + + test('returns undefined when no provider matches the server', async () => { + const authService = createMockAuthService({ + getOrActivateProviderIdForServer: () => Promise.resolve(undefined), + }); + const token = await resolveTokenForResource(resource, ['https://auth.example.com'], ['read'], authService, log, 'test'); + assert.strictEqual(token, undefined); + }); + + test('returns token from exact scope match', async () => { + const authService = createMockAuthService({ + getOrActivateProviderIdForServer: () => Promise.resolve('provider-1'), + getSessions: (_providerId, scopes) => { + if (scopes && scopes.length === 1 && scopes[0] === 'read') { + return Promise.resolve([{ scopes: ['read'], accessToken: 'exact-token' }]); + } + return Promise.resolve([]); + }, + }); + const token = await resolveTokenForResource(resource, ['https://auth.example.com'], ['read'], authService, log, 'test'); + assert.strictEqual(token, 'exact-token'); + }); + + test('falls back to narrowest superset session when exact match fails', async () => { + const authService = createMockAuthService({ + getOrActivateProviderIdForServer: () => Promise.resolve('provider-1'), + getSessions: (_providerId, scopes) => { + if (scopes !== undefined) { + // Exact match returns empty + return Promise.resolve([]); + } + // All sessions — return two superset options + return Promise.resolve([ + { scopes: ['read', 'write', 'admin'], accessToken: 'wide-token' }, + { scopes: ['read', 'write'], accessToken: 'narrow-token' }, + ]); + }, + }); + const token = await resolveTokenForResource(resource, ['https://auth.example.com'], ['read'], authService, log, 'test'); + assert.strictEqual(token, 'narrow-token'); + }); + + test('returns undefined when no session has matching scopes', async () => { + const authService = createMockAuthService({ + getOrActivateProviderIdForServer: () => Promise.resolve('provider-1'), + getSessions: (_providerId, scopes) => { + if (scopes !== undefined) { + return Promise.resolve([]); + } + // No session contains the 'read' scope + return Promise.resolve([ + { scopes: ['write'], accessToken: 'wrong-token' }, + ]); + }, + }); + const token = await resolveTokenForResource(resource, ['https://auth.example.com'], ['read'], authService, log, 'test'); + assert.strictEqual(token, undefined); + }); + + test('tries multiple authorization servers in order', async () => { + const calls: string[] = []; + const authService = createMockAuthService({ + getOrActivateProviderIdForServer: (serverUri) => { + calls.push(serverUri.toString()); + if (serverUri.toString() === 'https://auth2.example.com/') { + return Promise.resolve('provider-2'); + } + return Promise.resolve(undefined); + }, + getSessions: () => Promise.resolve([{ scopes: ['read'], accessToken: 'server2-token' }]), + }); + const token = await resolveTokenForResource( + resource, + ['https://auth1.example.com', 'https://auth2.example.com'], + ['read'], authService, log, 'test', + ); + assert.strictEqual(token, 'server2-token'); + assert.strictEqual(calls.length, 2); + }); +}); 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 981e6a62813d2..e09ea5e0e8ba0 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 @@ -31,6 +31,10 @@ import { IOutputService } from '../../../../../services/output/common/output.js' import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; import { AgentHostContribution, AgentHostSessionListController, AgentHostSessionHandler } from '../../../browser/agentSessions/agentHost/agentHostChatContribution.js'; import { AgentHostLanguageModelProvider } from '../../../browser/agentSessions/agentHost/agentHostLanguageModelProvider.js'; +import { IFileService } from '../../../../../../platform/files/common/files.js'; +import { TestFileService } from '../../../../../test/common/workbenchTestServices.js'; +import { ILabelService } from '../../../../../../platform/label/common/label.js'; +import { MockLabelService } from '../../../../../services/label/test/common/mockLabelService.js'; // ---- Mock agent host service ------------------------------------------------ @@ -155,6 +159,8 @@ function createTestServices(disposables: DisposableStore) { instantiationService.stub(ILogService, new NullLogService()); instantiationService.stub(IProductService, { quality: 'insider' }); instantiationService.stub(IChatAgentService, chatAgentService); + instantiationService.stub(IFileService, TestFileService); + instantiationService.stub(ILabelService, MockLabelService); instantiationService.stub(IChatSessionsService, { registerChatSessionItemController: () => toDisposable(() => { }), registerChatSessionContentProvider: () => toDisposable(() => { }), diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts index aa4337eaa4976..60fee327515fe 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { ToolCallStatus, ToolCallConfirmationReason, PermissionKind, ToolResultContentType, TurnState, type ICompletedToolCall, type IPermissionRequest, type IToolCallRunningState, type ITurn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IChatToolInvocationSerialized, type IChatMarkdownContent } from '../../../common/chatService/chatService.js'; @@ -305,5 +306,117 @@ suite('stateToProgressAdapter', () => { // Should not throw }); + + test('returns file edits from completed tool call with FileEdit content', () => { + const tc = createToolCallState({ status: ToolCallStatus.Running }); + const invocation = toolCallStateToInvocation(tc); + + const fileEdits = finalizeToolInvocation(invocation, { + status: ToolCallStatus.Completed, + toolCallId: 'tc-1', + toolName: 'edit_file', + displayName: 'Edit File', + invocationMessage: 'Editing file...', + confirmed: ToolCallConfirmationReason.NotNeeded, + success: true, + pastTenseMessage: 'Edited file', + toolInput: JSON.stringify({ path: '/home/user/file.ts' }), + content: [{ + type: ToolResultContentType.FileEdit, + beforeURI: 'agenthost-content:///session/snap/before', + afterURI: 'agenthost-content:///session/snap/after', + }], + }); + + assert.strictEqual(fileEdits.length, 1); + assert.strictEqual(fileEdits[0].resource.fsPath.replace(/\\/g, '/'), '/home/user/file.ts'); + assert.strictEqual(fileEdits[0].beforeContentUri.toString(), URI.parse('agenthost-content:///session/snap/before').toString()); + assert.strictEqual(fileEdits[0].afterContentUri.toString(), URI.parse('agenthost-content:///session/snap/after').toString()); + assert.ok(fileEdits[0].undoStopId); + }); + + test('returns empty file edits for cancelled tool call', () => { + const tc = createToolCallState({ status: ToolCallStatus.Running }); + const invocation = toolCallStateToInvocation(tc); + + const fileEdits = finalizeToolInvocation(invocation, { + status: ToolCallStatus.Cancelled, + toolCallId: 'tc-1', + toolName: 'edit_file', + displayName: 'Edit File', + invocationMessage: 'Editing file...', + confirmed: ToolCallConfirmationReason.NotNeeded, + reasonMessage: 'User cancelled', + }); + + assert.strictEqual(fileEdits.length, 0); + }); + + test('returns empty file edits when tool has no FileEdit content', () => { + const tc = createToolCallState({ status: ToolCallStatus.Running }); + const invocation = toolCallStateToInvocation(tc); + + const fileEdits = finalizeToolInvocation(invocation, { + status: ToolCallStatus.Completed, + toolCallId: 'tc-1', + toolName: 'test_tool', + displayName: 'Test Tool', + invocationMessage: 'Running test tool...', + confirmed: ToolCallConfirmationReason.NotNeeded, + success: true, + pastTenseMessage: 'Ran test tool', + content: [{ type: ToolResultContentType.Text, text: 'output' }], + }); + + assert.strictEqual(fileEdits.length, 0); + }); + + test('returns empty file edits when toolInput has no path', () => { + const tc = createToolCallState({ status: ToolCallStatus.Running }); + const invocation = toolCallStateToInvocation(tc); + + const fileEdits = finalizeToolInvocation(invocation, { + status: ToolCallStatus.Completed, + toolCallId: 'tc-1', + toolName: 'edit_file', + displayName: 'Edit File', + invocationMessage: 'Editing file...', + confirmed: ToolCallConfirmationReason.NotNeeded, + success: true, + pastTenseMessage: 'Edited', + toolInput: JSON.stringify({ content: 'no path field' }), + content: [{ + type: ToolResultContentType.FileEdit, + beforeURI: 'agenthost-content:///before', + afterURI: 'agenthost-content:///after', + }], + }); + + assert.strictEqual(fileEdits.length, 0); + }); + + test('returns empty file edits when toolInput is invalid JSON', () => { + const tc = createToolCallState({ status: ToolCallStatus.Running }); + const invocation = toolCallStateToInvocation(tc); + + const fileEdits = finalizeToolInvocation(invocation, { + status: ToolCallStatus.Completed, + toolCallId: 'tc-1', + toolName: 'edit_file', + displayName: 'Edit File', + invocationMessage: 'Editing file...', + confirmed: ToolCallConfirmationReason.NotNeeded, + success: true, + pastTenseMessage: 'Edited', + toolInput: 'not json', + content: [{ + type: ToolResultContentType.FileEdit, + beforeURI: 'agenthost-content:///before', + afterURI: 'agenthost-content:///after', + }], + }); + + assert.strictEqual(fileEdits.length, 0); + }); }); }); diff --git a/src/vs/workbench/services/label/test/browser/label.test.ts b/src/vs/workbench/services/label/test/browser/label.test.ts index bcf14160d8f87..15980ac8d3843 100644 --- a/src/vs/workbench/services/label/test/browser/label.test.ts +++ b/src/vs/workbench/services/label/test/browser/label.test.ts @@ -326,6 +326,53 @@ suite('multi-root workspace', () => { }); }); + test('stripPathSegments strips leading path segments', () => { + labelService.registerFormatter({ + scheme: 'vscode-agent-host', + formatting: { + label: '${path}', + separator: '/', + stripPathSegments: 2 + } + }); + + const uri = URI.from({ scheme: 'vscode-agent-host', authority: 'my-server', path: '/file//home/user/project/file.ts' }); + const generated = labelService.getUriLabel(uri, { relative: false }); + assert.strictEqual(generated, '/home/user/project/file.ts'); + }); + + test('stripPathSegments combined with stripPathStartingSeparator', () => { + labelService.registerFormatter({ + scheme: 'vscode-agent-host', + formatting: { + label: '${path}', + separator: '/', + stripPathSegments: 2, + stripPathStartingSeparator: true + } + }); + + const uri = URI.from({ scheme: 'vscode-agent-host', authority: 'my-server', path: '/file//home/user/file.ts' }); + const generated = labelService.getUriLabel(uri, { relative: false }); + assert.strictEqual(generated, 'home/user/file.ts'); + }); + + test('stripPathSegments with fewer segments than requested', () => { + labelService.registerFormatter({ + scheme: 'test-strip', + formatting: { + label: '${path}', + separator: '/', + stripPathSegments: 5 + } + }); + + const uri = URI.from({ scheme: 'test-strip', path: '/a/b' }); + const generated = labelService.getUriLabel(uri, { relative: false }); + // Should strip as many as possible without crashing + assert.strictEqual(generated, '/b'); + }); + test('relative label without formatter', () => { const rootFolder = URI.parse('myscheme://myauthority/'); From 5785d325af3291f12b7aaa3c51514b87f57b6d5b Mon Sep 17 00:00:00 2001 From: Justin Chen <54879025+justschen@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:45:32 -0700 Subject: [PATCH 13/33] add debounce for working shimmer (#304285) * add debounce for working shimmer * Update src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * revert ccr --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../chat/browser/widget/chatListRenderer.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index ea350214eac24..1705b9da628bc 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -114,6 +114,7 @@ import { isMcpToolInvocation } from './chatContentParts/toolInvocationParts/chat const $ = dom.$; const COPILOT_USERNAME = 'GitHub Copilot'; +const WORKING_CAUGHT_UP_DEBOUNCE_MS = 50; export interface IChatListItemTemplate { currentElement?: ChatTreeItem; @@ -1067,7 +1068,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer part.kind === 'toolInvocation' && !IChatToolInvocation.isComplete(part))) || (lastPart.kind === 'progressTask' && lastPart.deferred.isSettled) || @@ -1081,6 +1082,17 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer= WORKING_CAUGHT_UP_DEBOUNCE_MS; + } + private getChatFileChangesSummaryPart(element: IChatResponseViewModel): IChatChangesSummaryPart | undefined { if (!this.shouldShowFileChangesSummary(element)) { From c3231e5f45a5c3500aa759d1870c8abb8c3cbe08 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:46:01 -0700 Subject: [PATCH 14/33] chat customizations: fix clipping (#304281) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: re-layout list when toggling browse mode in MCP/plugin widgets When toggling browse mode, the back link appears/disappears but layout() was never re-called, causing the list height to not account for the changed chrome. This clipped the last entry. Cache the last layout dimensions and re-call layout() after toggling browse mode. Fixes https://github.com/microsoft/vscode/issues/304139 * fix: register IProductService in AI customization component fixtures The AICustomizationListWidget recently added a dependency on IProductService but the component fixtures were not updated, causing all AI customization fixtures to fail with 'depends on UNKNOWN service productService'. * fix: address PR review — remove manual layout() in fixtures, fix null! in ParsedPromptFile --- .../chat-customizations-editor/SKILL.md | 47 +++ .../browser/aiCustomization/mcpListWidget.ts | 9 + .../aiCustomization/pluginListWidget.ts | 9 + .../aiCustomizationListWidget.fixture.ts | 2 + ...aiCustomizationManagementEditor.fixture.ts | 394 +++++++++++++++++- 5 files changed, 448 insertions(+), 13 deletions(-) diff --git a/.github/skills/chat-customizations-editor/SKILL.md b/.github/skills/chat-customizations-editor/SKILL.md index b90fb5b46cf01..16f1d82da3a17 100644 --- a/.github/skills/chat-customizations-editor/SKILL.md +++ b/.github/skills/chat-customizations-editor/SKILL.md @@ -34,6 +34,53 @@ Principle: the UI widgets read everything from the descriptor — no harness-spe Component explorer fixtures (see `component-fixtures` skill): `aiCustomizationListWidget.fixture.ts`, `aiCustomizationManagementEditor.fixture.ts` under `src/vs/workbench/test/browser/componentFixtures/`. +### Screenshotting specific tabs + +The management editor fixture supports a `selectedSection` option to render any tab. Each tab has Dark/Light variants auto-generated by `defineThemedFixtureGroup`. + +**Available fixture IDs** (use with `mcp_component-exp_screenshot`): + +| Fixture ID pattern | Tab shown | +|---|---| +| `chat/aiCustomizations/aiCustomizationManagementEditor/AgentsTab/{Dark,Light}` | Agents | +| `chat/aiCustomizations/aiCustomizationManagementEditor/SkillsTab/{Dark,Light}` | Skills | +| `chat/aiCustomizations/aiCustomizationManagementEditor/InstructionsTab/{Dark,Light}` | Instructions | +| `chat/aiCustomizations/aiCustomizationManagementEditor/HooksTab/{Dark,Light}` | Hooks | +| `chat/aiCustomizations/aiCustomizationManagementEditor/PromptsTab/{Dark,Light}` | Prompts | +| `chat/aiCustomizations/aiCustomizationManagementEditor/McpServersTab/{Dark,Light}` | MCP Servers | +| `chat/aiCustomizations/aiCustomizationManagementEditor/PluginsTab/{Dark,Light}` | Plugins | +| `chat/aiCustomizations/aiCustomizationManagementEditor/LocalHarness/{Dark,Light}` | Default (Agents, Local harness) | +| `chat/aiCustomizations/aiCustomizationManagementEditor/CliHarness/{Dark,Light}` | Default (Agents, CLI harness) | +| `chat/aiCustomizations/aiCustomizationManagementEditor/ClaudeHarness/{Dark,Light}` | Default (Agents, Claude harness) | +| `chat/aiCustomizations/aiCustomizationManagementEditor/Sessions/{Dark,Light}` | Sessions window variant | + +**Adding a new tab fixture:** Add a variant to the `defineThemedFixtureGroup` in `aiCustomizationManagementEditor.fixture.ts`: +```typescript +MyNewTab: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEditor(ctx, { + harness: CustomizationHarness.VSCode, + selectedSection: AICustomizationManagementSection.MySection, + }), +}), +``` + +The `selectedSection` calls `editor.selectSectionById()` after `setInput`, which navigates to the specified tab and re-layouts. + +### Populating test data + +Each customization type requires its own mock path in `createMockPromptsService`: +- **Agents** — `getCustomAgents()` returns agent objects +- **Skills** — `findAgentSkills()` returns `IAgentSkill[]` +- **Prompts** — `getPromptSlashCommands()` returns `IChatPromptSlashCommand[]` +- **Instructions/Hooks** — `listPromptFiles()` filtered by `PromptsType` +- **MCP Servers** — `mcpWorkspaceServers`/`mcpUserServers` arrays passed to `IMcpWorkbenchService` mock +- **Plugins** — `IPluginMarketplaceService.installedPlugins` and `IAgentPluginService.plugins` observables + +All test data lives in `allFiles` (prompt-based items) and the `mcpWorkspace/UserServers` arrays. Add enough items per category (8+) to invoke scrolling. + +### Running unit tests + ```bash ./scripts/test.sh --grep "applyStorageSourceFilter|customizationCounts" npm run compile-check-ts-native && npm run valid-layers-check diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts index 3ccfc1cc3ff74..a4b197e51e1b4 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts @@ -382,6 +382,8 @@ export class McpListWidget extends Disposable { private galleryServers: IWorkbenchMcpServer[] = []; private searchQuery: string = ''; private browseMode: boolean = false; + private lastHeight: number = 0; + private lastWidth: number = 0; private readonly collapsedGroups = new Set(); private galleryCts: CancellationTokenSource | undefined; private readonly delayedFilter = new Delayer(200); @@ -608,6 +610,11 @@ export class McpListWidget extends Disposable { this.galleryServers = []; this.filterServers(); } + + // Re-layout to account for the back link height change + if (this.lastHeight > 0) { + this.layout(this.lastHeight, this.lastWidth); + } } private async queryGallery(): Promise { @@ -882,6 +889,8 @@ export class McpListWidget extends Disposable { * Layouts the widget. */ layout(height: number, width: number): void { + this.lastHeight = height; + this.lastWidth = width; const sectionFooterHeight = this.sectionHeader.offsetHeight || 0; const searchBarHeight = this.searchAndButtonContainer.offsetHeight || 52; const backLinkHeight = this.browseMode ? (this.backLink.offsetHeight || 28) : 0; diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts index b5be4d09f9e27..a51a9f6fb0e0b 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts @@ -298,6 +298,8 @@ export class PluginListWidget extends Disposable { private marketplaceItems: IMarketplacePluginItem[] = []; private searchQuery: string = ''; private browseMode: boolean = false; + private lastHeight: number = 0; + private lastWidth: number = 0; private readonly collapsedGroups = new Set(); private marketplaceCts: CancellationTokenSource | undefined; private readonly delayedFilter = new Delayer(200); @@ -528,6 +530,11 @@ export class PluginListWidget extends Disposable { this.marketplaceItems = []; this.filterPlugins(); } + + // Re-layout to account for the back link height change + if (this.lastHeight > 0) { + this.layout(this.lastHeight, this.lastWidth); + } } private async queryMarketplace(): Promise { @@ -701,6 +708,8 @@ export class PluginListWidget extends Disposable { } layout(height: number, width: number): void { + this.lastHeight = height; + this.lastWidth = width; const sectionFooterHeight = this.sectionHeader.offsetHeight || 0; const searchBarHeight = this.searchAndButtonContainer.offsetHeight || 52; const backLinkHeight = this.browseMode ? (this.backLink.offsetHeight || 28) : 0; diff --git a/src/vs/workbench/test/browser/componentFixtures/aiCustomizationListWidget.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/aiCustomizationListWidget.fixture.ts index 4fa628e23b8d1..914726763fdc6 100644 --- a/src/vs/workbench/test/browser/componentFixtures/aiCustomizationListWidget.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/aiCustomizationListWidget.fixture.ts @@ -19,6 +19,7 @@ import { PromptsType } from '../../../contrib/chat/common/promptSyntax/promptTyp import { IPromptsService, IResolvedAgentFile, AgentFileType, PromptsStorage, IPromptPath } from '../../../contrib/chat/common/promptSyntax/service/promptsService.js'; import { AICustomizationManagementSection } from '../../../contrib/chat/browser/aiCustomization/aiCustomizationManagement.js'; import { AICustomizationListWidget } from '../../../contrib/chat/browser/aiCustomization/aiCustomizationListWidget.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; import { IPathService } from '../../../services/path/common/pathService.js'; import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from './fixtureUtils.js'; import { ParsedPromptFile, PromptHeader } from '../../../contrib/chat/common/promptSyntax/promptFileParser.js'; @@ -155,6 +156,7 @@ async function renderInstructionsTab(ctx: ComponentFixtureContext, instructionFi reg.defineInstance(IFileService, new class extends mock() { override readonly onDidFilesChange = Event.None; }()); + reg.defineInstance(IProductService, new class extends mock() { }()); reg.defineInstance(IPathService, new class extends mock() { override readonly defaultUriScheme = 'file'; override userHome(): URI; diff --git a/src/vs/workbench/test/browser/componentFixtures/aiCustomizationManagementEditor.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/aiCustomizationManagementEditor.fixture.ts index f57c42abd4986..465246d562f10 100644 --- a/src/vs/workbench/test/browser/componentFixtures/aiCustomizationManagementEditor.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/aiCustomizationManagementEditor.fixture.ts @@ -14,7 +14,7 @@ import { constObservable, observableValue } from '../../../../base/common/observ import { URI } from '../../../../base/common/uri.js'; import { mock } from '../../../../base/test/common/mock.js'; import { ITextModelService } from '../../../../editor/common/services/resolverService.js'; -import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { IDialogService, IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { IFileService } from '../../../../platform/files/common/files.js'; import { IListService, ListService } from '../../../../platform/list/browser/listService.js'; import { IQuickInputService } from '../../../../platform/quickinput/common/quickInput.js'; @@ -23,22 +23,28 @@ import { IMarkdownRendererService } from '../../../../platform/markdown/browser/ import { IWorkspace, IWorkspaceContextService, WorkbenchState } from '../../../../platform/workspace/common/workspace.js'; import { IEditorGroup } from '../../../services/editor/common/editorGroupsService.js'; import { IExtensionService } from '../../../services/extensions/common/extensions.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; import { IPathService } from '../../../services/path/common/pathService.js'; import { IWorkingCopyService } from '../../../services/workingCopy/common/workingCopyService.js'; import { IWebviewService } from '../../../contrib/webview/browser/webview.js'; import { IAICustomizationWorkspaceService, AICustomizationManagementSection } from '../../../contrib/chat/common/aiCustomizationWorkspaceService.js'; import { CustomizationHarness, ICustomizationHarnessService, IHarnessDescriptor, createVSCodeHarnessDescriptor, createClaudeHarnessDescriptor, createCliHarnessDescriptor, getCliUserRoots, getClaudeUserRoots } from '../../../contrib/chat/common/customizationHarnessService.js'; import { PromptsType } from '../../../contrib/chat/common/promptSyntax/promptTypes.js'; -import { IPromptsService, IResolvedAgentFile, AgentFileType, PromptsStorage } from '../../../contrib/chat/common/promptSyntax/service/promptsService.js'; +import { IPromptsService, IResolvedAgentFile, AgentFileType, PromptsStorage, IAgentSkill, IChatPromptSlashCommand } from '../../../contrib/chat/common/promptSyntax/service/promptsService.js'; import { ParsedPromptFile } from '../../../contrib/chat/common/promptSyntax/promptFileParser.js'; -import { IAgentPluginService } from '../../../contrib/chat/common/plugins/agentPluginService.js'; -import { IPluginMarketplaceService } from '../../../contrib/chat/common/plugins/pluginMarketplaceService.js'; +import { IAgentPluginService, IAgentPlugin } from '../../../contrib/chat/common/plugins/agentPluginService.js'; +import { IPluginMarketplaceService, IMarketplacePlugin, MarketplaceType, PluginSourceKind } from '../../../contrib/chat/common/plugins/pluginMarketplaceService.js'; +import { MarketplaceReferenceKind } from '../../../contrib/chat/common/plugins/marketplaceReference.js'; import { IPluginInstallService } from '../../../contrib/chat/common/plugins/pluginInstallService.js'; import { AICustomizationManagementEditor } from '../../../contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.js'; +import { ContributionEnablementState } from '../../../contrib/chat/common/enablement.js'; import { AICustomizationManagementEditorInput } from '../../../contrib/chat/browser/aiCustomization/aiCustomizationManagementEditorInput.js'; import { IMcpWorkbenchService, IWorkbenchMcpServer, IMcpService, McpServerInstallState } from '../../../contrib/mcp/common/mcpTypes.js'; import { IMcpRegistry } from '../../../contrib/mcp/common/mcpRegistryTypes.js'; import { IWorkbenchLocalMcpServer, LocalMcpServerScope } from '../../../services/mcp/common/mcpWorkbenchManagementService.js'; +import { McpListWidget } from '../../../contrib/chat/browser/aiCustomization/mcpListWidget.js'; +import { PluginListWidget } from '../../../contrib/chat/browser/aiCustomization/pluginListWidget.js'; +import { IIterativePager } from '../../../../base/common/paging.js'; import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from './fixtureUtils.js'; // Ensure theme colors & widget CSS are loaded @@ -98,8 +104,33 @@ function createMockPromptsService(files: IFixtureFile[], agentInstructions: IRes return new ParsedPromptFile(uri, header as never); } override async getSourceFolders() { return [] as never[]; } - override async findAgentSkills() { return [] as never[]; } - override async getPromptSlashCommands() { return [] as never[]; } + override async findAgentSkills(): Promise { + return files.filter(f => f.type === PromptsType.skill).map(f => ({ + uri: f.uri, + storage: f.storage, + name: f.name ?? 'skill', + description: f.description, + disableModelInvocation: false, + userInvocable: true, + when: undefined, + })); + } + override async getPromptSlashCommands(): Promise { + const promptFiles = files.filter(f => f.type === PromptsType.prompt); + const commands = await Promise.all(promptFiles.map(async f => { + const promptPath = { uri: f.uri, storage: f.storage, type: f.type }; + const parsedPromptFile = await this.parseNew(f.uri, CancellationToken.None); + return { + name: f.name ?? 'prompt', + description: f.description, + argumentHint: undefined, + promptPath: promptPath as IChatPromptSlashCommand['promptPath'], + parsedPromptFile, + when: undefined, + }; + })); + return commands; + } }(); } @@ -138,24 +169,71 @@ function makeLocalMcpServer(id: string, label: string, scope: LocalMcpServerScop // ============================================================================ const allFiles: IFixtureFile[] = [ - // Copilot instructions + // Instructions — workspace { uri: URI.file('/workspace/.github/instructions/coding-standards.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Coding Standards', description: 'Repository-wide coding standards' }, { uri: URI.file('/workspace/.github/instructions/testing.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Testing', description: 'Testing best practices', applyTo: '**/*.test.ts' }, + { uri: URI.file('/workspace/.github/instructions/security.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Security', description: 'Security review checklist', applyTo: 'src/auth/**' }, + { uri: URI.file('/workspace/.github/instructions/accessibility.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Accessibility', description: 'WCAG compliance guidelines', applyTo: '**/*.tsx' }, + { uri: URI.file('/workspace/.github/instructions/api-design.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'API Design', description: 'REST API design conventions' }, + { uri: URI.file('/workspace/.github/instructions/performance.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Performance', description: 'Performance optimization rules', applyTo: 'src/core/**' }, + { uri: URI.file('/workspace/.github/instructions/error-handling.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Error Handling', description: 'Error handling patterns' }, + { uri: URI.file('/workspace/.github/instructions/database.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Database', description: 'Database migration and query patterns', applyTo: 'src/db/**' }, + // Instructions — user { uri: URI.file('/home/dev/.copilot/instructions/my-style.instructions.md'), storage: PromptsStorage.user, type: PromptsType.instructions, name: 'My Style', description: 'Personal coding style' }, - // Claude rules + { uri: URI.file('/home/dev/.copilot/instructions/typescript-rules.instructions.md'), storage: PromptsStorage.user, type: PromptsType.instructions, name: 'TypeScript Rules', description: 'Strict TypeScript conventions' }, + { uri: URI.file('/home/dev/.copilot/instructions/commit-messages.instructions.md'), storage: PromptsStorage.user, type: PromptsType.instructions, name: 'Commit Messages', description: 'Conventional commit format' }, + // Instructions — Claude rules { uri: URI.file('/workspace/.claude/rules/code-style.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Code Style', description: 'Claude code style rules' }, { uri: URI.file('/workspace/.claude/rules/testing.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Testing', description: 'Claude testing conventions' }, { uri: URI.file('/home/dev/.claude/rules/personal.md'), storage: PromptsStorage.user, type: PromptsType.instructions, name: 'Personal', description: 'Personal rules' }, - // Agents + // Agents — workspace { uri: URI.file('/workspace/.github/agents/reviewer.agent.md'), storage: PromptsStorage.local, type: PromptsType.agent, name: 'Reviewer', description: 'Code review agent' }, { uri: URI.file('/workspace/.github/agents/documenter.agent.md'), storage: PromptsStorage.local, type: PromptsType.agent, name: 'Documenter', description: 'Documentation agent' }, - { uri: URI.file('/workspace/.claude/agents/planner.md'), storage: PromptsStorage.local, type: PromptsType.agent, name: 'Planner', description: 'Project planning agent' }, - // Skills + { uri: URI.file('/workspace/.github/agents/tester.agent.md'), storage: PromptsStorage.local, type: PromptsType.agent, name: 'Tester', description: 'Test generation and validation' }, + { uri: URI.file('/workspace/.github/agents/refactorer.agent.md'), storage: PromptsStorage.local, type: PromptsType.agent, name: 'Refactorer', description: 'Code refactoring specialist' }, + { uri: URI.file('/workspace/.github/agents/security-auditor.agent.md'), storage: PromptsStorage.local, type: PromptsType.agent, name: 'Security Auditor', description: 'Security vulnerability scanner' }, + { uri: URI.file('/workspace/.github/agents/api-designer.agent.md'), storage: PromptsStorage.local, type: PromptsType.agent, name: 'API Designer', description: 'REST and GraphQL API design' }, + { uri: URI.file('/workspace/.github/agents/performance-tuner.agent.md'), storage: PromptsStorage.local, type: PromptsType.agent, name: 'Performance Tuner', description: 'Performance profiling and optimization' }, + // Agents — user + { uri: URI.file('/home/dev/.copilot/agents/planner.agent.md'), storage: PromptsStorage.user, type: PromptsType.agent, name: 'Planner', description: 'Project planning agent' }, + { uri: URI.file('/home/dev/.copilot/agents/debugger.agent.md'), storage: PromptsStorage.user, type: PromptsType.agent, name: 'Debugger', description: 'Interactive debugging assistant' }, + { uri: URI.file('/home/dev/.copilot/agents/nls-helper.agent.md'), storage: PromptsStorage.user, type: PromptsType.agent, name: 'NLS Helper', description: 'Natural language searching code for clarity' }, + // Skills — workspace { uri: URI.file('/workspace/.github/skills/deploy/SKILL.md'), storage: PromptsStorage.local, type: PromptsType.skill, name: 'Deploy', description: 'Deployment automation' }, { uri: URI.file('/workspace/.github/skills/refactor/SKILL.md'), storage: PromptsStorage.local, type: PromptsType.skill, name: 'Refactor', description: 'Code refactoring patterns' }, - // Prompts + { uri: URI.file('/workspace/.github/skills/unit-tests/SKILL.md'), storage: PromptsStorage.local, type: PromptsType.skill, name: 'Unit Tests', description: 'Test generation and runner integration' }, + { uri: URI.file('/workspace/.github/skills/ci-fix/SKILL.md'), storage: PromptsStorage.local, type: PromptsType.skill, name: 'CI Fix', description: 'Diagnose and fix CI failures' }, + { uri: URI.file('/workspace/.github/skills/migration/SKILL.md'), storage: PromptsStorage.local, type: PromptsType.skill, name: 'Migration', description: 'Database migration generation' }, + { uri: URI.file('/workspace/.github/skills/accessibility/SKILL.md'), storage: PromptsStorage.local, type: PromptsType.skill, name: 'Accessibility', description: 'ARIA labels and keyboard navigation' }, + { uri: URI.file('/workspace/.github/skills/docker/SKILL.md'), storage: PromptsStorage.local, type: PromptsType.skill, name: 'Docker', description: 'Dockerfile and compose generation' }, + { uri: URI.file('/workspace/.github/skills/api-docs/SKILL.md'), storage: PromptsStorage.local, type: PromptsType.skill, name: 'API Docs', description: 'OpenAPI spec generation' }, + // Skills — user + { uri: URI.file('/home/dev/.copilot/skills/git-workflow/SKILL.md'), storage: PromptsStorage.user, type: PromptsType.skill, name: 'Git Workflow', description: 'Branch and PR workflows' }, + { uri: URI.file('/home/dev/.copilot/skills/code-review/SKILL.md'), storage: PromptsStorage.user, type: PromptsType.skill, name: 'Code Review', description: 'Structured code review checklist' }, + // Prompts — workspace { uri: URI.file('/workspace/.github/prompts/explain.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt, name: 'Explain', description: 'Explain selected code' }, { uri: URI.file('/workspace/.github/prompts/review.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt, name: 'Review', description: 'Review changes' }, + { uri: URI.file('/workspace/.github/prompts/fix-bug.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt, name: 'Fix Bug', description: 'Diagnose and fix a bug from issue' }, + { uri: URI.file('/workspace/.github/prompts/write-tests.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt, name: 'Write Tests', description: 'Generate unit tests for selection' }, + { uri: URI.file('/workspace/.github/prompts/add-docs.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt, name: 'Add Docs', description: 'Add JSDoc comments to functions' }, + { uri: URI.file('/workspace/.github/prompts/optimize.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt, name: 'Optimize', description: 'Optimize code for performance' }, + { uri: URI.file('/workspace/.github/prompts/convert-to-ts.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt, name: 'Convert to TS', description: 'Convert JavaScript to TypeScript' }, + { uri: URI.file('/workspace/.github/prompts/summarize-pr.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt, name: 'Summarize PR', description: 'Generate PR description from diff' }, + // Prompts — user + { uri: URI.file('/home/dev/.copilot/prompts/translate.prompt.md'), storage: PromptsStorage.user, type: PromptsType.prompt, name: 'Translate', description: 'Translate strings for i18n' }, + { uri: URI.file('/home/dev/.copilot/prompts/commit-msg.prompt.md'), storage: PromptsStorage.user, type: PromptsType.prompt, name: 'Commit Message', description: 'Generate conventional commit' }, + // Hooks — workspace + { uri: URI.file('/workspace/.github/hooks/pre-commit.json'), storage: PromptsStorage.local, type: PromptsType.hook, name: 'Pre-Commit Lint', description: 'Run linting before commit' }, + { uri: URI.file('/workspace/.github/hooks/post-save.json'), storage: PromptsStorage.local, type: PromptsType.hook, name: 'Post-Save Format', description: 'Auto-format on save' }, + { uri: URI.file('/workspace/.github/hooks/on-test-fail.json'), storage: PromptsStorage.local, type: PromptsType.hook, name: 'On Test Failure', description: 'Suggest fix when tests fail' }, + { uri: URI.file('/workspace/.github/hooks/pre-push.json'), storage: PromptsStorage.local, type: PromptsType.hook, name: 'Pre-Push Check', description: 'Run type-check before push' }, + { uri: URI.file('/workspace/.github/hooks/post-create.json'), storage: PromptsStorage.local, type: PromptsType.hook, name: 'Post-Create', description: 'Initialize boilerplate for new files' }, + { uri: URI.file('/workspace/.github/hooks/on-error.json'), storage: PromptsStorage.local, type: PromptsType.hook, name: 'On Error', description: 'Log and report unhandled errors' }, + { uri: URI.file('/workspace/.github/hooks/post-tool-call.json'), storage: PromptsStorage.local, type: PromptsType.hook, name: 'Post Tool Call', description: 'Echo confirmation after each tool call' }, + { uri: URI.file('/workspace/.github/hooks/on-build-fail.json'), storage: PromptsStorage.local, type: PromptsType.hook, name: 'On Build Failure', description: 'Auto-diagnose build errors' }, + // Hooks — user + { uri: URI.file('/home/dev/.copilot/hooks/daily-summary.json'), storage: PromptsStorage.user, type: PromptsType.hook, name: 'Daily Summary', description: 'Generate daily work summary' }, + { uri: URI.file('/home/dev/.copilot/hooks/backup-changes.json'), storage: PromptsStorage.user, type: PromptsType.hook, name: 'Backup Changes', description: 'Auto-stash uncommitted changes' }, ]; const agentInstructions: IResolvedAgentFile[] = [ @@ -167,9 +245,17 @@ const agentInstructions: IResolvedAgentFile[] = [ const mcpWorkspaceServers = [ makeLocalMcpServer('mcp-postgres', 'PostgreSQL', LocalMcpServerScope.Workspace, 'Database access'), makeLocalMcpServer('mcp-github', 'GitHub', LocalMcpServerScope.Workspace, 'GitHub API'), + makeLocalMcpServer('mcp-redis', 'Redis', LocalMcpServerScope.Workspace, 'In-memory data store'), + makeLocalMcpServer('mcp-docker', 'Docker', LocalMcpServerScope.Workspace, 'Container management'), + makeLocalMcpServer('mcp-slack', 'Slack', LocalMcpServerScope.Workspace, 'Team messaging'), + makeLocalMcpServer('mcp-jira', 'Jira', LocalMcpServerScope.Workspace, 'Issue tracking'), + makeLocalMcpServer('mcp-aws', 'AWS', LocalMcpServerScope.Workspace, 'Amazon Web Services'), + makeLocalMcpServer('mcp-graphql', 'GraphQL', LocalMcpServerScope.Workspace, 'GraphQL API gateway'), ]; const mcpUserServers = [ makeLocalMcpServer('mcp-web-search', 'Web Search', LocalMcpServerScope.User, 'Search the web'), + makeLocalMcpServer('mcp-filesystem', 'Filesystem', LocalMcpServerScope.User, 'Local file operations'), + makeLocalMcpServer('mcp-puppeteer', 'Puppeteer', LocalMcpServerScope.User, 'Browser automation'), ]; const mcpRuntimeServers = [ { definition: { id: 'github-copilot-mcp', label: 'GitHub Copilot' }, collection: { id: 'ext.github.copilot/mcp', label: 'ext.github.copilot/mcp' }, enablement: constObservable(2), connectionState: constObservable({ state: 2 }) }, @@ -180,6 +266,7 @@ interface IRenderEditorOptions { readonly isSessionsWindow?: boolean; readonly managementSections?: readonly AICustomizationManagementSection[]; readonly availableHarnesses?: readonly IHarnessDescriptor[]; + readonly selectedSection?: AICustomizationManagementSection; } // ============================================================================ @@ -277,7 +364,7 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor override readonly onDidChangeInputs = Event.None; }()); reg.defineInstance(IAgentPluginService, new class extends mock() { - override readonly plugins = constObservable([]); + override readonly plugins = constObservable(installedPlugins); override readonly enablementModel = undefined as never; }()); reg.defineInstance(IPluginMarketplaceService, new class extends mock() { @@ -285,6 +372,7 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor override readonly onDidChangeMarketplaces = Event.None; }()); reg.defineInstance(IPluginInstallService, new class extends mock() { }()); + reg.defineInstance(IProductService, new class extends mock() { }()); }, }); @@ -300,6 +388,210 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor } catch { // Expected in fixture — some services are partially mocked } + + if (options.selectedSection) { + editor.selectSectionById(options.selectedSection); + editor.layout(new Dimension(width, height)); + } +} + +// ============================================================================ +// MCP Browse Mode — standalone widget with gallery results +// ============================================================================ + +function makeGalleryServer(id: string, label: string, description: string, publisher: string): IWorkbenchMcpServer { + const galleryStub = new class extends mock>() { }(); + return new class extends mock() { + override readonly id = id; + override readonly name = id; + override readonly label = label; + override readonly description = description; + override readonly publisherDisplayName = publisher; + override readonly installState = McpServerInstallState.Uninstalled; + override readonly gallery = galleryStub; + override readonly local = undefined; + }(); +} + +const galleryServers = [ + makeGalleryServer('gallery-postgres', 'PostgreSQL', 'Access PostgreSQL databases with schema inspection and query tools', 'Microsoft'), + makeGalleryServer('gallery-github', 'GitHub', 'Repository management, issues, pull requests, and code search', 'GitHub'), + makeGalleryServer('gallery-slack', 'Slack', 'Send messages, manage channels, and search workspace history', 'Slack Technologies'), + makeGalleryServer('gallery-docker', 'Docker', 'Container lifecycle management and image operations', 'Docker Inc'), + makeGalleryServer('gallery-filesystem', 'Filesystem', 'Read, write, and navigate local files and directories', 'Microsoft'), + makeGalleryServer('gallery-brave', 'Brave Search', 'Web and local search powered by the Brave Search API', 'Brave Software'), + makeGalleryServer('gallery-puppeteer', 'Puppeteer', 'Browser automation with screenshots, navigation, and form filling', 'Google'), + makeGalleryServer('gallery-memory', 'Memory', 'Knowledge graph for persistent memory across conversations', 'Microsoft'), + makeGalleryServer('gallery-fetch', 'Fetch', 'Retrieve and convert web content to markdown for analysis', 'Microsoft'), + makeGalleryServer('gallery-sentry', 'Sentry', 'Error monitoring, issue tracking, and performance tracing', 'Sentry'), + makeGalleryServer('gallery-sqlite', 'SQLite', 'Query and manage SQLite databases with schema exploration', 'Community'), + makeGalleryServer('gallery-redis', 'Redis', 'In-memory data store operations and key management', 'Redis Ltd'), +]; + +async function renderMcpBrowseMode(ctx: ComponentFixtureContext): Promise { + const width = 650; + const height = 500; + ctx.container.style.width = `${width}px`; + ctx.container.style.height = `${height}px`; + + const instantiationService = createEditorServices(ctx.disposableStore, { + colorTheme: ctx.theme, + additionalServices: (reg) => { + registerWorkbenchServices(reg); + reg.define(IListService, ListService); + reg.defineInstance(IMcpWorkbenchService, new class extends mock() { + override readonly onChange = Event.None; + override readonly onReset = Event.None; + override readonly local: IWorkbenchMcpServer[] = []; + override async queryLocal() { return []; } + override canInstall() { return true as const; } + override async queryGallery(): Promise> { + return { + firstPage: { items: galleryServers, hasMore: false }, + async getNextPage() { return { items: [], hasMore: false }; }, + }; + } + }()); + reg.defineInstance(IMcpService, new class extends mock() { + override readonly servers = constObservable([] as never[]); + }()); + reg.defineInstance(IMcpRegistry, new class extends mock() { + override readonly collections = constObservable([]); + override readonly delegates = constObservable([]); + override readonly onDidChangeInputs = Event.None; + }()); + reg.defineInstance(IAgentPluginService, new class extends mock() { + override readonly plugins = constObservable([]); + }()); + reg.defineInstance(IDialogService, new class extends mock() { }()); + reg.defineInstance(IAICustomizationWorkspaceService, new class extends mock() { + override readonly isSessionsWindow = false; + override readonly activeProjectRoot = observableValue('root', URI.file('/workspace')); + override readonly hasOverrideProjectRoot = observableValue('hasOverride', false); + override getActiveProjectRoot() { return URI.file('/workspace'); } + override getStorageSourceFilter() { + return { sources: [PromptsStorage.local, PromptsStorage.user, PromptsStorage.extension, PromptsStorage.plugin] }; + } + }()); + reg.defineInstance(ICustomizationHarnessService, new class extends mock() { + override readonly activeHarness = observableValue('activeHarness', CustomizationHarness.VSCode); + override getActiveDescriptor() { return createVSCodeHarnessDescriptor([PromptsStorage.extension]); } + }()); + }, + }); + + const widget = ctx.disposableStore.add( + instantiationService.createInstance(McpListWidget) + ); + ctx.container.appendChild(widget.element); + widget.layout(height, width); + + // Click the Browse Marketplace button to enter browse mode + const browseButton = widget.element.querySelector('.list-add-button') as HTMLElement; + browseButton?.click(); + + // Wait for the gallery query to resolve + await new Promise(resolve => setTimeout(resolve, 50)); +} + +// ============================================================================ +// Plugin Browse Mode — standalone widget with marketplace results +// ============================================================================ + +function makeInstalledPlugin(name: string, uri: URI, enabled: boolean): IAgentPlugin { + return new class extends mock() { + override readonly uri = uri; + override readonly label = name; + override readonly enablement = constObservable(enabled ? ContributionEnablementState.EnabledProfile : ContributionEnablementState.DisabledProfile); + override readonly hooks = constObservable([]); + override readonly commands = constObservable([]); + override readonly skills = constObservable([]); + override readonly agents = constObservable([]); + override readonly instructions = constObservable([]); + override readonly mcpServerDefinitions = constObservable([]); + override remove() { } + }(); +} + +const installedPlugins: IAgentPlugin[] = [ + makeInstalledPlugin('Linear', URI.file('/workspace/.copilot/plugins/linear'), true), + makeInstalledPlugin('Sentry', URI.file('/workspace/.copilot/plugins/sentry'), true), + makeInstalledPlugin('Datadog', URI.file('/workspace/.copilot/plugins/datadog'), true), + makeInstalledPlugin('Notion', URI.file('/workspace/.copilot/plugins/notion'), true), + makeInstalledPlugin('Confluence', URI.file('/workspace/.copilot/plugins/confluence'), true), + makeInstalledPlugin('PagerDuty', URI.file('/workspace/.copilot/plugins/pagerduty'), false), + makeInstalledPlugin('LaunchDarkly', URI.file('/workspace/.copilot/plugins/launchdarkly'), true), + makeInstalledPlugin('CircleCI', URI.file('/workspace/.copilot/plugins/circleci'), true), + makeInstalledPlugin('Vercel', URI.file('/workspace/.copilot/plugins/vercel'), false), + makeInstalledPlugin('Supabase', URI.file('/workspace/.copilot/plugins/supabase'), true), +]; + +function makeMarketplacePlugin(name: string, description: string, repo: string): IMarketplacePlugin { + return { + name, + description, + version: '1.0.0', + source: repo, + sourceDescriptor: { kind: PluginSourceKind.GitHub, repo: `example/${repo}` }, + marketplace: 'copilot', + marketplaceReference: { rawValue: `example/${repo}`, displayLabel: repo, cloneUrl: `https://github.com/example/${repo}.git`, canonicalId: `github:example/${repo}`, cacheSegments: ['example', repo], kind: MarketplaceReferenceKind.GitHubShorthand }, + marketplaceType: MarketplaceType.Copilot, + }; +} + +const marketplacePlugins: IMarketplacePlugin[] = [ + makeMarketplacePlugin('Linear', 'Issue tracking and project management integration', 'linear-plugin'), + makeMarketplacePlugin('Sentry', 'Error monitoring and performance tracing', 'sentry-plugin'), + makeMarketplacePlugin('Datadog', 'Observability and monitoring dashboards', 'datadog-plugin'), + makeMarketplacePlugin('Notion', 'Knowledge base and documentation management', 'notion-plugin'), + makeMarketplacePlugin('Figma', 'Design system inspection and asset export', 'figma-plugin'), + makeMarketplacePlugin('Stripe', 'Payment processing and billing management', 'stripe-plugin'), + makeMarketplacePlugin('Twilio', 'Communication APIs for SMS and voice', 'twilio-plugin'), + makeMarketplacePlugin('Auth0', 'Identity and access management', 'auth0-plugin'), + makeMarketplacePlugin('Algolia', 'Search and discovery API integration', 'algolia-plugin'), + makeMarketplacePlugin('LaunchDarkly', 'Feature flag management and experimentation', 'launchdarkly-plugin'), + makeMarketplacePlugin('PlanetScale', 'Serverless MySQL database management', 'planetscale-plugin'), + makeMarketplacePlugin('Vercel', 'Deployment and preview environments', 'vercel-plugin'), +]; + +async function renderPluginBrowseMode(ctx: ComponentFixtureContext): Promise { + const width = 650; + const height = 500; + ctx.container.style.width = `${width}px`; + ctx.container.style.height = `${height}px`; + + const instantiationService = createEditorServices(ctx.disposableStore, { + colorTheme: ctx.theme, + additionalServices: (reg) => { + registerWorkbenchServices(reg); + reg.define(IListService, ListService); + reg.defineInstance(IAgentPluginService, new class extends mock() { + override readonly plugins = constObservable([] as readonly IAgentPlugin[]); + override readonly enablementModel = undefined!; + }()); + reg.defineInstance(IPluginMarketplaceService, new class extends mock() { + override readonly installedPlugins = constObservable([]); + override readonly onDidChangeMarketplaces = Event.None; + override async fetchMarketplacePlugins() { return marketplacePlugins; } + }()); + reg.defineInstance(IPluginInstallService, new class extends mock() { + override getPluginInstallUri() { return URI.file('/dev/null'); } + }()); + }, + }); + + const widget = ctx.disposableStore.add( + instantiationService.createInstance(PluginListWidget) + ); + ctx.container.appendChild(widget.element); + widget.layout(height, width); + + // Click the Browse Marketplace button to enter browse mode + const browseButton = widget.element.querySelector('.list-add-button') as HTMLElement; + browseButton?.click(); + + // Wait for the marketplace query to resolve + await new Promise(resolve => setTimeout(resolve, 50)); } // ============================================================================ @@ -350,4 +642,80 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { ], }), }), + + // MCP Servers tab with many servers to verify scrollable list layout + McpServersTab: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEditor(ctx, { + harness: CustomizationHarness.VSCode, + selectedSection: AICustomizationManagementSection.McpServers, + }), + }), + + // Agents tab — workspace and user agents, scrollable + AgentsTab: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEditor(ctx, { + harness: CustomizationHarness.VSCode, + selectedSection: AICustomizationManagementSection.Agents, + }), + }), + + // Skills tab — workspace and user skills, scrollable + SkillsTab: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEditor(ctx, { + harness: CustomizationHarness.VSCode, + selectedSection: AICustomizationManagementSection.Skills, + }), + }), + + // Instructions tab — many instructions with applyTo patterns, scrollable + InstructionsTab: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEditor(ctx, { + harness: CustomizationHarness.VSCode, + selectedSection: AICustomizationManagementSection.Instructions, + }), + }), + + // Hooks tab — workspace and user hooks, scrollable + HooksTab: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEditor(ctx, { + harness: CustomizationHarness.VSCode, + selectedSection: AICustomizationManagementSection.Hooks, + }), + }), + + // Prompts tab — workspace and user prompts, scrollable + PromptsTab: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEditor(ctx, { + harness: CustomizationHarness.VSCode, + selectedSection: AICustomizationManagementSection.Prompts, + }), + }), + + // Plugins tab + PluginsTab: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEditor(ctx, { + harness: CustomizationHarness.VSCode, + selectedSection: AICustomizationManagementSection.Plugins, + }), + }), + + // MCP browse/marketplace mode — standalone widget with gallery results, scrollable + // Verifies fix for https://github.com/microsoft/vscode/issues/304139 + McpBrowseMode: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: renderMcpBrowseMode, + }), + + // Plugin browse/marketplace mode — standalone widget with marketplace results, scrollable + PluginBrowseMode: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: renderPluginBrowseMode, + }), }); From 5a791c66df14380b290733af553722f3497c95d2 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Mon, 23 Mar 2026 18:57:03 -0400 Subject: [PATCH 15/33] Use bracketed paste mode for `run_in_terminal` tool (#304268) fixes #296955 --- src/vs/workbench/contrib/terminal/browser/terminal.ts | 2 +- .../contrib/terminal/browser/terminalInstance.ts | 11 ++++++----- .../browser/executeStrategy/basicExecuteStrategy.ts | 2 +- .../browser/executeStrategy/noneExecuteStrategy.ts | 2 +- .../browser/executeStrategy/richExecuteStrategy.ts | 2 +- 5 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/vs/workbench/contrib/terminal/browser/terminal.ts b/src/vs/workbench/contrib/terminal/browser/terminal.ts index b54f86b3ad3ac..8bfd7b0d9107b 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminal.ts @@ -1154,7 +1154,7 @@ export interface ITerminalInstance extends IBaseTerminalInstance { */ sendPath(originalPath: string | URI, shouldExecute: boolean): Promise; - runCommand(command: string, shouldExecute?: boolean, commandId?: string): Promise; + runCommand(command: string, shouldExecute?: boolean, commandId?: string, bracketedPasteMode?: boolean): Promise; /** * Takes a path and returns the properly escaped path to send to a given shell. On Windows, this diff --git a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts index fb793b76ba84c..ca7d1e3edebc3 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts @@ -974,7 +974,7 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { }); } - async runCommand(commandLine: string, shouldExecute: boolean, commandId?: string): Promise { + async runCommand(commandLine: string, shouldExecute: boolean, commandId?: string, forceBracketedPasteMode?: boolean): Promise { let commandDetection = this.capabilities.get(TerminalCapability.CommandDetection); const siInjectionEnabled = this._configurationService.getValue(TerminalSettingId.ShellIntegrationEnabled) === true; const timeoutMs = getShellIntegrationTimeout( @@ -1020,8 +1020,9 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { // is being evaluated await timeout(100); } - // Use bracketed paste mode only when not running the command - await this.sendText(commandLine, shouldExecute, !shouldExecute); + // By default, use bracketed paste mode only when not running the command; callers can override + // this by explicitly enabling it via the bracketedPasteMode argument. + await this.sendText(commandLine, shouldExecute, !shouldExecute || forceBracketedPasteMode); } detachFromElement(): void { @@ -1344,10 +1345,10 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { this.focus(force); } - async sendText(text: string, shouldExecute: boolean, bracketedPasteMode?: boolean): Promise { + async sendText(text: string, shouldExecute: boolean, forceBracketedPasteMode?: boolean): Promise { // Apply bracketed paste sequences if the terminal has the mode enabled, this will prevent // the text from triggering keybindings and ensure new lines are handled properly - if (bracketedPasteMode && this.xterm?.raw.modes.bracketedPasteMode) { + if (forceBracketedPasteMode && this.xterm?.raw.modes.bracketedPasteMode) { text = `\x1b[200~${text}\x1b[201~`; } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/basicExecuteStrategy.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/basicExecuteStrategy.ts index 6de862cb38000..c99f166ad627f 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/basicExecuteStrategy.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/basicExecuteStrategy.ts @@ -129,7 +129,7 @@ export class BasicExecuteStrategy extends Disposable implements ITerminalExecute // occurs. this._log(`Executing command line \`${commandLine}\``); markerRecreation.dispose(); - this._instance.sendText(commandLine, true); + this._instance.sendText(commandLine, true, true); // Wait for the next end execution event - note that this may not correspond to the actual // execution requested diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/noneExecuteStrategy.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/noneExecuteStrategy.ts index f72379411cae6..c1f1b210253b7 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/noneExecuteStrategy.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/noneExecuteStrategy.ts @@ -84,7 +84,7 @@ export class NoneExecuteStrategy extends Disposable implements ITerminalExecuteS this._log(`Executing command line \`${commandLine}\``); markerRecreation.dispose(); const startLine = this._startMarker.value?.line; - this._instance.sendText(commandLine, true); + this._instance.sendText(commandLine, true, true); // Wait for the cursor to move past the command line before // starting idle detection. Without this, the idle poll may diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/richExecuteStrategy.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/richExecuteStrategy.ts index 8d97c97f85911..cd8ac8edaf218 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/richExecuteStrategy.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/richExecuteStrategy.ts @@ -85,7 +85,7 @@ export class RichExecuteStrategy extends Disposable implements ITerminalExecuteS // Execute the command this._log(`Executing command line \`${commandLine}\``); markerRecreation.dispose(); - this._instance.runCommand(commandLine, true, commandId); + this._instance.runCommand(commandLine, true, commandId, true); // Wait for the terminal to idle this._log('Waiting for done event'); From 3d843ae9b6bbc1b0fa3bb510f3c502f742eba063 Mon Sep 17 00:00:00 2001 From: Alexandru Dima Date: Tue, 24 Mar 2026 00:16:23 +0100 Subject: [PATCH 16/33] Flatten sandbox network settings (#304287) Flatten sandbox network settings (#304270) Split the nested `chat.tools.terminal.sandbox.network` object setting into three flat settings: - `chat.tools.terminal.sandbox.network.allowedDomains` - `chat.tools.terminal.sandbox.network.deniedDomains` - `chat.tools.terminal.sandbox.network.allowTrustedDomains` Add configuration migration to automatically migrate existing values. The old setting is preserved as deprecated with a pointer to the new ones. Fixes #304232 --- .../terminal/common/terminalConfiguration.ts | 19 ++++++ .../terminal/terminalContribExports.ts | 4 ++ .../terminal.chatAgentTools.contribution.ts | 4 +- .../browser/tools/sandboxOutputAnalyzer.ts | 2 +- .../terminalChatAgentToolsConfiguration.ts | 64 +++++++++++-------- .../chatAgentTools/common/terminalSandbox.ts | 11 ---- .../common/terminalSandboxService.ts | 24 ++++--- .../browser/terminalSandboxService.test.ts | 64 +++++++------------ .../runInTerminalTool.test.ts | 4 +- 9 files changed, 103 insertions(+), 93 deletions(-) diff --git a/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts b/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts index 2d1dd15ce5217..be681d6ef0618 100644 --- a/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts +++ b/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts @@ -734,3 +734,22 @@ Registry.as(WorkbenchExtensions.ConfigurationMi return configurationKeyValuePairs; } }]); + +Registry.as(WorkbenchExtensions.ConfigurationMigration) + .registerConfigurationMigrations([{ + key: TerminalContribSettingId.DeprecatedTerminalSandboxNetwork, + migrateFn: (value: { allowedDomains?: string[]; deniedDomains?: string[]; allowTrustedDomains?: boolean }, valueAccessor) => { + const configurationKeyValuePairs: ConfigurationKeyValuePairs = []; + if (value?.allowedDomains !== undefined && valueAccessor(TerminalContribSettingId.TerminalSandboxNetworkAllowedDomains) === undefined) { + configurationKeyValuePairs.push([TerminalContribSettingId.TerminalSandboxNetworkAllowedDomains, { value: value.allowedDomains }]); + } + if (value?.deniedDomains !== undefined && valueAccessor(TerminalContribSettingId.TerminalSandboxNetworkDeniedDomains) === undefined) { + configurationKeyValuePairs.push([TerminalContribSettingId.TerminalSandboxNetworkDeniedDomains, { value: value.deniedDomains }]); + } + if (value?.allowTrustedDomains !== undefined && valueAccessor(TerminalContribSettingId.TerminalSandboxNetworkAllowTrustedDomains) === undefined) { + configurationKeyValuePairs.push([TerminalContribSettingId.TerminalSandboxNetworkAllowTrustedDomains, { value: value.allowTrustedDomains }]); + } + configurationKeyValuePairs.push([TerminalContribSettingId.DeprecatedTerminalSandboxNetwork, { value: undefined }]); + return configurationKeyValuePairs; + } + }]); diff --git a/src/vs/workbench/contrib/terminal/terminalContribExports.ts b/src/vs/workbench/contrib/terminal/terminalContribExports.ts index a24b204a899d5..babbac632fcc8 100644 --- a/src/vs/workbench/contrib/terminal/terminalContribExports.ts +++ b/src/vs/workbench/contrib/terminal/terminalContribExports.ts @@ -46,6 +46,10 @@ export const enum TerminalContribSettingId { EnableAutoApprove = TerminalChatAgentToolsSettingId.EnableAutoApprove, ShellIntegrationTimeout = TerminalChatAgentToolsSettingId.ShellIntegrationTimeout, OutputLocation = TerminalChatAgentToolsSettingId.OutputLocation, + DeprecatedTerminalSandboxNetwork = TerminalChatAgentToolsSettingId.DeprecatedTerminalSandboxNetwork, + TerminalSandboxNetworkAllowedDomains = TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowedDomains, + TerminalSandboxNetworkDeniedDomains = TerminalChatAgentToolsSettingId.TerminalSandboxNetworkDeniedDomains, + TerminalSandboxNetworkAllowTrustedDomains = TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowTrustedDomains, } // HACK: Export some context key strings from `terminalContrib/` that are depended upon elsewhere. diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/terminal.chatAgentTools.contribution.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/terminal.chatAgentTools.contribution.ts index 5d1e90193abb4..e8bd274254649 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/terminal.chatAgentTools.contribution.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/terminal.chatAgentTools.contribution.ts @@ -142,7 +142,9 @@ export class ChatAgentToolsContribution extends Disposable implements IWorkbench this._register(this._configurationService.onDidChangeConfiguration(e => { if ( e.affectsConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxEnabled) || - e.affectsConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetwork) + e.affectsConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowedDomains) || + e.affectsConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkDeniedDomains) || + e.affectsConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowTrustedDomains) ) { this._registerRunInTerminalTool(); } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sandboxOutputAnalyzer.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sandboxOutputAnalyzer.ts index 8b1687f1d3545..684fcf2051e59 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sandboxOutputAnalyzer.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sandboxOutputAnalyzer.ts @@ -37,7 +37,7 @@ export class SandboxOutputAnalyzer extends Disposable implements IOutputAnalyzer ? 'Command failed while running in sandboxed mode. If the command failed due to sandboxing:' : 'Command ran in sandboxed mode and may have been blocked by the sandbox. If the command failed due to sandboxing:'; return `${prefix} -- If it would be reasonable to extend the sandbox rules, work with the user to update allowWrite for file system access problems in ${fileSystemSetting}, or to add required domains to ${TerminalChatAgentToolsSettingId.TerminalSandboxNetwork}.allowedDomains. +- If it would be reasonable to extend the sandbox rules, work with the user to update allowWrite for file system access problems in ${fileSystemSetting}, or to add required domains to ${TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowedDomains}. - Otherwise, immediately retry the command with requestUnsandboxedExecution=true. Do NOT ask the user — setting this flag automatically shows a confirmation prompt to the user. Here is the output of the command:\n`; diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts index e55ce632fd19d..221f2e29d2068 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts @@ -21,7 +21,9 @@ export const enum TerminalChatAgentToolsSettingId { AutoReplyToPrompts = 'chat.tools.terminal.autoReplyToPrompts', OutputLocation = 'chat.tools.terminal.outputLocation', TerminalSandboxEnabled = 'chat.tools.terminal.sandbox.enabled', - TerminalSandboxNetwork = 'chat.tools.terminal.sandbox.network', + TerminalSandboxNetworkAllowedDomains = 'chat.tools.terminal.sandbox.network.allowedDomains', + TerminalSandboxNetworkDeniedDomains = 'chat.tools.terminal.sandbox.network.deniedDomains', + TerminalSandboxNetworkAllowTrustedDomains = 'chat.tools.terminal.sandbox.network.allowTrustedDomains', TerminalSandboxLinuxFileSystem = 'chat.tools.terminal.sandbox.linuxFileSystem', TerminalSandboxMacFileSystem = 'chat.tools.terminal.sandbox.macFileSystem', PreventShellHistory = 'chat.tools.terminal.preventShellHistory', @@ -32,6 +34,7 @@ export const enum TerminalChatAgentToolsSettingId { TerminalProfileMacOs = 'chat.tools.terminal.terminalProfile.osx', TerminalProfileWindows = 'chat.tools.terminal.terminalProfile.windows', + DeprecatedTerminalSandboxNetwork = 'chat.tools.terminal.sandbox.network', DeprecatedAutoApproveCompatible = 'chat.agent.terminal.autoApprove', DeprecatedAutoApprove1 = 'chat.agent.terminal.allowList', DeprecatedAutoApprove2 = 'chat.agent.terminal.denyList', @@ -531,33 +534,26 @@ export const terminalChatAgentToolsConfiguration: IStringDictionary(TerminalChatAgentToolsSettingId.TerminalSandboxNetwork) ?? {}; + const allowedDomainsSetting = this._configurationService.getValue(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowedDomains) ?? []; + const deniedDomainsSetting = this._configurationService.getValue(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkDeniedDomains) ?? []; + const allowTrustedDomains = this._configurationService.getValue(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowTrustedDomains) ?? false; const linuxFileSystemSetting = this._os === OperatingSystem.Linux ? this._configurationService.getValue<{ denyRead?: string[]; allowWrite?: string[]; denyWrite?: string[] }>(TerminalChatAgentToolsSettingId.TerminalSandboxLinuxFileSystem) ?? {} : {}; @@ -201,15 +204,15 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb const linuxAllowWrite = this._updateAllowWritePathsWithWorkspaceFolders(linuxFileSystemSetting.allowWrite); const macAllowWrite = this._updateAllowWritePathsWithWorkspaceFolders(macFileSystemSetting.allowWrite); - let allowedDomains = networkSetting.allowedDomains ?? []; - if (networkSetting.allowTrustedDomains) { + let allowedDomains = allowedDomainsSetting; + if (allowTrustedDomains) { allowedDomains = this._addTrustedDomainsToAllowedDomains(allowedDomains); } const sandboxSettings = { network: { allowedDomains, - deniedDomains: networkSetting.deniedDomains ?? [] + deniedDomains: deniedDomainsSetting }, filesystem: { denyRead: this._os === OperatingSystem.Macintosh ? macFileSystemSetting.denyRead : linuxFileSystemSetting.denyRead, @@ -279,14 +282,15 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb } public getResolvedNetworkDomains(): ITerminalSandboxResolvedNetworkDomains { - const networkSetting = this._configurationService.getValue(TerminalChatAgentToolsSettingId.TerminalSandboxNetwork) ?? {}; - let allowedDomains = networkSetting.allowedDomains ?? []; - if (networkSetting.allowTrustedDomains) { + let allowedDomains = this._configurationService.getValue(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowedDomains) ?? []; + const deniedDomains = this._configurationService.getValue(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkDeniedDomains) ?? []; + const allowTrustedDomains = this._configurationService.getValue(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowTrustedDomains) ?? false; + if (allowTrustedDomains) { allowedDomains = this._addTrustedDomainsToAllowedDomains(allowedDomains); } return { allowedDomains, - deniedDomains: networkSetting.deniedDomains ?? [] + deniedDomains }; } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts index 33d74053c2b3f..a85db3988f2eb 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts @@ -167,11 +167,9 @@ suite('TerminalSandboxService - allowTrustedDomains', () => { // Setup default configuration configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxEnabled, true); - configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetwork, { - allowedDomains: [], - deniedDomains: [], - allowTrustedDomains: false - }); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowedDomains, []); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkDeniedDomains, []); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowTrustedDomains, false); instantiationService.stub(IConfigurationService, configurationService); instantiationService.stub(IFileService, fileService); @@ -191,11 +189,9 @@ suite('TerminalSandboxService - allowTrustedDomains', () => { test('should filter out sole wildcard (*) from trusted domains', async () => { // Setup: Enable allowTrustedDomains and add * to trusted domains - configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetwork, { - allowedDomains: [], - deniedDomains: [], - allowTrustedDomains: true - }); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowedDomains, []); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkDeniedDomains, []); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowTrustedDomains, true); trustedDomainService.trustedDomains = ['*']; const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); @@ -211,11 +207,9 @@ suite('TerminalSandboxService - allowTrustedDomains', () => { test('should allow wildcards with domains like *.github.com', async () => { // Setup: Enable allowTrustedDomains and add *.github.com - configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetwork, { - allowedDomains: [], - deniedDomains: [], - allowTrustedDomains: true - }); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowedDomains, []); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkDeniedDomains, []); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowTrustedDomains, true); trustedDomainService.trustedDomains = ['*.github.com']; const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); @@ -232,11 +226,9 @@ suite('TerminalSandboxService - allowTrustedDomains', () => { test('should combine trusted domains with configured allowedDomains, filtering out *', async () => { // Setup: Enable allowTrustedDomains with multiple domains including * - configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetwork, { - allowedDomains: ['example.com'], - deniedDomains: [], - allowTrustedDomains: true - }); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowedDomains, ['example.com']); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkDeniedDomains, []); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowTrustedDomains, true); trustedDomainService.trustedDomains = ['*', '*.github.com', 'microsoft.com']; const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); @@ -256,11 +248,9 @@ suite('TerminalSandboxService - allowTrustedDomains', () => { test('should not include trusted domains when allowTrustedDomains is false', async () => { // Setup: Disable allowTrustedDomains - configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetwork, { - allowedDomains: ['example.com'], - deniedDomains: [], - allowTrustedDomains: false - }); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowedDomains, ['example.com']); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkDeniedDomains, []); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowTrustedDomains, false); trustedDomainService.trustedDomains = ['*', '*.github.com']; const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); @@ -277,11 +267,9 @@ suite('TerminalSandboxService - allowTrustedDomains', () => { test('should deduplicate domains when combining sources', async () => { // Setup: Same domain in both sources - configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetwork, { - allowedDomains: ['github.com', '*.github.com'], - deniedDomains: [], - allowTrustedDomains: true - }); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowedDomains, ['github.com', '*.github.com']); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkDeniedDomains, []); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowTrustedDomains, true); trustedDomainService.trustedDomains = ['*.github.com', 'github.com']; const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); @@ -299,11 +287,9 @@ suite('TerminalSandboxService - allowTrustedDomains', () => { test('should handle empty trusted domains list', async () => { // Setup: Empty trusted domains - configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetwork, { - allowedDomains: ['example.com'], - deniedDomains: [], - allowTrustedDomains: true - }); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowedDomains, ['example.com']); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkDeniedDomains, []); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowTrustedDomains, true); trustedDomainService.trustedDomains = []; const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); @@ -320,11 +306,9 @@ suite('TerminalSandboxService - allowTrustedDomains', () => { test('should handle only * in trusted domains', async () => { // Setup: Only * in trusted domains (edge case) - configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetwork, { - allowedDomains: [], - deniedDomains: [], - allowTrustedDomains: true - }); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowedDomains, []); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkDeniedDomains, []); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowTrustedDomains, true); trustedDomainService.trustedDomains = ['*']; const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts index 6aab2ceb354bb..607bf6a79254f 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts @@ -1977,8 +1977,8 @@ suite('ChatAgentToolsContribution - tool registration refresh', () => { // Fire network config change configurationService.onDidChangeConfigurationEmitter.fire({ - affectsConfiguration: (key: string) => key === TerminalChatAgentToolsSettingId.TerminalSandboxNetwork, - affectedKeys: new Set([TerminalChatAgentToolsSettingId.TerminalSandboxNetwork]), + affectsConfiguration: (key: string) => key === TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowedDomains, + affectedKeys: new Set([TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowedDomains]), source: ConfigurationTarget.USER, change: null!, }); From 184c7f1631f7d5e415f7d7b04bc4ce7e6d407fc7 Mon Sep 17 00:00:00 2001 From: Tay Date: Mon, 23 Mar 2026 16:17:29 -0700 Subject: [PATCH 17/33] fix: destroy read streams to prevent file descriptor leaks (#303395) * fix: destroy read streams to prevent file descriptor leaks Two stream leak fixes found via static analysis of the control flow graph: 1. crypto.ts: createReadStream() used for checksum computation was never explicitly destroyed. The stream was piped to a hash and event listeners were removed in the done() callback, but the underlying fd was left to GC. Added input.destroy() to the done() callback to ensure immediate cleanup on both success and error paths. 2. webClientServer.ts: createReadStream().pipe(res) leaked the file descriptor when the HTTP response was closed prematurely (e.g. client disconnect). The pipe does not automatically destroy the source stream when the destination closes. Added res.on('close') handler to destroy the file stream. * fix: address review feedback on stream error handling webClientServer.ts: - Add fileStream.on('error') handler for async read errors that occur after the try/catch (e.g. ENOENT race, permission change mid-read) - Handle both pre-header and post-header error cases - Use res.once('close') instead of res.on('close') for cleanup intent crypto.test.ts: - Replace try/catch with assert.rejects() and regex matcher - Spy on fs.createReadStream to verify destroy() is actually called - Restore original createReadStream in finally block * fix(test): remove ESM-incompatible fs mock from crypto test The test was monkey-patching `fs.createReadStream` on the ESM module namespace, which is read-only at runtime (TypeError). Also removes the unused `Readable` import that caused TS6133. Simplified the test to verify the observable contract (mismatch rejection) without attempting to spy on stream internals. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: defer response headers until read stream opens Create the read stream and wait for its 'open' event before calling res.writeHead(200). Previously, headers were sent before the stream was created, making res.headersSent always true in the error handler and the 404 branch unreachable. A TOCTOU race between stat() and createReadStream() would result in a 200 being aborted instead of a proper 404. Now, pre-header errors reject into the existing catch block (which sends 404), and post-header errors correctly call res.destroy(). --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Alexandru Dima Co-authored-by: Alex Dima --- src/vs/base/node/crypto.ts | 1 + src/vs/base/test/node/crypto.test.ts | 11 +++++++++++ src/vs/server/node/webClientServer.ts | 27 +++++++++++++++++++++++---- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/vs/base/node/crypto.ts b/src/vs/base/node/crypto.ts index f1637f4057f48..dee5f05fb3fa7 100644 --- a/src/vs/base/node/crypto.ts +++ b/src/vs/base/node/crypto.ts @@ -16,6 +16,7 @@ export async function checksum(path: string, sha256hash: string | undefined): Pr const done = createSingleCallFunction((err?: Error, result?: string) => { input.removeAllListeners(); hash.removeAllListeners(); + input.destroy(); if (err) { reject(err); diff --git a/src/vs/base/test/node/crypto.test.ts b/src/vs/base/test/node/crypto.test.ts index ed66c2c4b0d67..37204dc631755 100644 --- a/src/vs/base/test/node/crypto.test.ts +++ b/src/vs/base/test/node/crypto.test.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import * as assert from 'assert'; import * as fs from 'fs'; import { tmpdir } from 'os'; import { join } from '../../common/path.js'; @@ -33,4 +34,14 @@ flakySuite('Crypto', () => { await checksum(testFile, 'a591a6d40bf420404a011733cfb7b190d62c65bf0bcda32b57b277d9ad9f146e'); }); + + test('checksum mismatch rejects', async () => { + const testFile = join(testDir, 'checksum-mismatch.txt'); + await Promises.writeFile(testFile, 'Hello World'); + + await assert.rejects( + () => checksum(testFile, 'wrong-hash'), + /Hash mismatch/ + ); + }); }); diff --git a/src/vs/server/node/webClientServer.ts b/src/vs/server/node/webClientServer.ts index 028a056717b1e..c920340ed1304 100644 --- a/src/vs/server/node/webClientServer.ts +++ b/src/vs/server/node/webClientServer.ts @@ -73,10 +73,29 @@ export async function serveFile(filePath: string, cacheControl: CacheControl, lo responseHeaders['Content-Type'] = textMimeType[extname(filePath)] || getMediaMime(filePath) || 'text/plain'; - res.writeHead(200, responseHeaders); - - // Data - createReadStream(filePath).pipe(res); + // Create the stream first and wait for it to open before sending + // headers so that errors (e.g. ENOENT race) can still produce a + // proper 404 response instead of aborting a half-sent 200. + const fileStream = createReadStream(filePath); + await new Promise((resolve, reject) => { + fileStream.on('error', reject); + fileStream.on('open', () => { + // File opened successfully - send headers and pipe + res.writeHead(200, responseHeaders); + fileStream.pipe(res); + // Destroy the read stream if the response is closed prematurely + // (e.g. client disconnect) to avoid leaking the file descriptor. + res.once('close', () => fileStream.destroy()); + fileStream.on('end', resolve); + // Replace the initial error handler now that headers are sent + fileStream.removeAllListeners('error'); + fileStream.on('error', error => { + logService.error(error); + console.error(error.toString()); + res.destroy(); + }); + }); + }); } catch (error) { if (error.code !== 'ENOENT') { logService.error(error); From d5aa1c409e21dd36bd9f6b8410ad97881c5e8d5b Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Mon, 23 Mar 2026 16:19:45 -0700 Subject: [PATCH 18/33] fix merge --- .../agentHost/common/agentHostFileSystemProvider.ts | 2 +- src/vs/platform/agentHost/common/agentService.ts | 2 +- .../agentHost/electron-browser/agentHostService.ts | 2 +- .../remoteAgentHostProtocolClient.ts | 7 +++++++ .../electron-browser/remoteAgentHostServiceImpl.ts | 2 +- src/vs/platform/agentHost/node/agentHostMain.ts | 2 +- src/vs/platform/agentHost/node/agentService.ts | 5 +++-- src/vs/platform/agentHost/node/agentSideEffects.ts | 2 +- .../agentHost/node/protocolServerHandler.ts | 2 +- .../agentHost/test/node/agentSideEffects.test.ts | 2 ++ src/vs/platform/agentHost/test/node/mockAgent.ts | 4 ++-- .../test/node/protocolServerHandler.test.ts | 7 ++++--- .../contrib/chat/browser/workspacePicker.ts | 13 ++++++------- .../chat/test/browser/workspacePicker.test.ts | 6 +++--- .../browser/remoteAgentHostPicker.ts | 6 +++--- .../agentSessions/stateToProgressAdapter.test.ts | 4 ++-- 16 files changed, 39 insertions(+), 29 deletions(-) diff --git a/src/vs/platform/agentHost/common/agentHostFileSystemProvider.ts b/src/vs/platform/agentHost/common/agentHostFileSystemProvider.ts index f194e543d279f..c6edb18837934 100644 --- a/src/vs/platform/agentHost/common/agentHostFileSystemProvider.ts +++ b/src/vs/platform/agentHost/common/agentHostFileSystemProvider.ts @@ -110,7 +110,7 @@ export class AgentHostFileSystemProvider extends Disposable implements IFileSyst const connection = this._getConnection(resource.authority); try { const originalUri = fromAgentHostUri(resource); - const result = await connection.fetchContent(originalUri.toString()); + const result = await connection.fetchContent(originalUri); return VSBuffer.fromString(result.data).buffer; } catch (err) { throw createFileSystemProviderError( diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 938c34fb08f78..3a04432c3fe56 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -444,7 +444,7 @@ export interface IAgentService { * 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; + fetchContent(uri: URI): Promise; } /** diff --git a/src/vs/platform/agentHost/electron-browser/agentHostService.ts b/src/vs/platform/agentHost/electron-browser/agentHostService.ts index 0e93d66c6766a..b414308ebe845 100644 --- a/src/vs/platform/agentHost/electron-browser/agentHostService.ts +++ b/src/vs/platform/agentHost/electron-browser/agentHostService.ts @@ -119,7 +119,7 @@ class AgentHostServiceClient extends Disposable implements IAgentHostService { browseDirectory(uri: URI): Promise { return this._proxy.browseDirectory(uri); } - fetchContent(uri: string): Promise { + fetchContent(uri: URI): Promise { return this._proxy.fetchContent(uri); } async restartAgentHost(): Promise { diff --git a/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts index b2cfa7eee0c7e..de400d715319f 100644 --- a/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts @@ -200,6 +200,13 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC return await this._sendRequest('browseDirectory', { uri: uri.toString() }); } + /** + * Fetch the content of a file on the remote host's filesystem. + */ + async fetchContent(uri: URI): Promise { + return this._sendRequest('fetchContent', { uri: uri.toString() }); + } + private _handleMessage(msg: IProtocolMessage): void { if (isJsonRpcResponse(msg)) { const pending = this._pendingRequests.get(msg.id); diff --git a/src/vs/platform/agentHost/electron-browser/remoteAgentHostServiceImpl.ts b/src/vs/platform/agentHost/electron-browser/remoteAgentHostServiceImpl.ts index 70d8a9817a444..b9bc307963258 100644 --- a/src/vs/platform/agentHost/electron-browser/remoteAgentHostServiceImpl.ts +++ b/src/vs/platform/agentHost/electron-browser/remoteAgentHostServiceImpl.ts @@ -19,11 +19,11 @@ import { IRemoteAgentHostService, RemoteAgentHostsEnabledSettingId, RemoteAgentHostsSettingId, - normalizeRemoteAgentHostAddress, type IRemoteAgentHostConnectionInfo, type IRemoteAgentHostEntry, } from '../common/remoteAgentHostService.js'; import { RemoteAgentHostProtocolClient } from './remoteAgentHostProtocolClient.js'; +import { normalizeRemoteAgentHostAddress } from '../common/agentHostUri.js'; /** Tracks a single remote connection through its lifecycle. */ interface IConnectionEntry { diff --git a/src/vs/platform/agentHost/node/agentHostMain.ts b/src/vs/platform/agentHost/node/agentHostMain.ts index f8e2e0e3b0150..29688ed09138b 100644 --- a/src/vs/platform/agentHost/node/agentHostMain.ts +++ b/src/vs/platform/agentHost/node/agentHostMain.ts @@ -163,7 +163,7 @@ async function startWebSocketServer(agentService: AgentService, logService: ILog return agentService.browseDirectory(URI.parse(uri)); }, handleFetchContent(uri) { - return agentService.fetchContent(uri); + return agentService.fetchContent(URI.parse(uri)); }, getDefaultDirectory() { return URI.file(os.homedir()).toString(); diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 02f0ccae21a91..91b310ec9db13 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -64,6 +64,7 @@ export class AgentService extends Disposable implements IAgentService { this._register(this._stateManager.onDidEmitNotification(e => this._onDidNotification.fire(e))); this._sideEffects = this._register(new AgentSideEffects(this._stateManager, { getAgent: session => this._findProviderForSession(session), + sessionDataService: this._sessionDataService, agents: this._agents, }, this._logService, this._fileService)); } @@ -205,8 +206,8 @@ export class AgentService extends Disposable implements IAgentService { return this._sideEffects.handleBrowseDirectory(uri.toString()); } - async fetchContent(uri: string): Promise { - return this._sideEffects.handleFetchContent(uri); + async fetchContent(uri: URI): Promise { + return this._sideEffects.handleFetchContent(uri.toString()); } async shutdown(): Promise { diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index d03e61c7029c4..a26dc5150f67f 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -275,7 +275,7 @@ export class AgentSideEffects extends Disposable implements IProtocolSideEffectH return URI.file(os.homedir()).toString(); } - async handleFetchContent(uri: string): Promise { + async handleFetchContent(uri: ProtocolURI): Promise { try { const content = await this._fileService.readFile(URI.parse(uri)); return { diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index 3947cf8f0ca34..faa7f4683d095 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -436,7 +436,7 @@ export interface IProtocolSideEffectHandler { handleGetResourceMetadata(): IResourceMetadata; handleAuthenticate(params: IAuthenticateParams): Promise; handleBrowseDirectory(uri: URI): Promise; - handleFetchContent(uri: string): Promise; + handleFetchContent(uri: URI): Promise; /** Returns the server's default browsing directory, if available. */ getDefaultDirectory?(): URI; /** Refresh models from all providers (VS Code extension method). */ diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 582ebeade5a15..43aff21b078d0 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -111,6 +111,7 @@ suite('AgentSideEffects', () => { const noAgentSideEffects = disposables.add(new AgentSideEffects(stateManager, { getAgent: () => undefined, agents: emptyAgents, + sessionDataService: {} as ISessionDataService, }, new NullLogService(), fileService)); const envelopes: IActionEnvelope[] = []; @@ -256,6 +257,7 @@ suite('AgentSideEffects', () => { const noAgentSideEffects = disposables.add(new AgentSideEffects(stateManager, { getAgent: () => undefined, agents: emptyAgents, + sessionDataService: {} as ISessionDataService, }, new NullLogService(), fileService)); await assert.rejects( diff --git a/src/vs/platform/agentHost/test/node/mockAgent.ts b/src/vs/platform/agentHost/test/node/mockAgent.ts index 0bd861655f6ad..70c528a19c751 100644 --- a/src/vs/platform/agentHost/test/node/mockAgent.ts +++ b/src/vs/platform/agentHost/test/node/mockAgent.ts @@ -7,7 +7,7 @@ import { Emitter } from '../../../../base/common/event.js'; import { URI } from '../../../../base/common/uri.js'; import type { IAuthorizationProtectedResourceMetadata } from '../../../../base/common/oauth.js'; import { AgentSession, type AgentProvider, type IAgent, type IAgentAttachment, type IAgentCreateSessionConfig, type IAgentDescriptor, type IAgentMessageEvent, type IAgentModelInfo, type IAgentProgressEvent, type IAgentSessionMetadata, type IAgentToolCompleteEvent, type IAgentToolStartEvent } from '../../common/agentService.js'; -import { PermissionKind } from '../../common/state/sessionState.js'; +import { PermissionKind, ToolResultContentType } from '../../common/state/sessionState.js'; /** * General-purpose mock agent for unit tests. Tracks all method calls @@ -146,7 +146,7 @@ export class ScriptedMockAgent implements IAgent { case 'use-tool': this._fireSequence(session, [ { type: 'tool_start', session, toolCallId: 'tc-1', toolName: 'echo_tool', displayName: 'Echo Tool', invocationMessage: 'Running echo tool...' }, - { type: 'tool_complete', session, toolCallId: 'tc-1', success: true, pastTenseMessage: 'Ran echo tool', toolOutput: 'echoed' }, + { type: 'tool_complete', session, toolCallId: 'tc-1', result: { pastTenseMessage: 'Ran echo tool', content: [{ type: ToolResultContentType.Text, text: 'echoed' }], success: true } }, { type: 'delta', session, messageId: 'msg-1', content: 'Tool done.' }, { type: 'idle', session }, ]); diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index ad98acd79b4e2..e549489585f68 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -9,10 +9,11 @@ import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; +import { IFetchContentResult } from '../../common/state/protocol/commands.js'; import { ActionType, type ISessionAction } from '../../common/state/sessionActions.js'; -import { isJsonRpcNotification, isJsonRpcResponse, JSON_RPC_INTERNAL_ERROR, ProtocolError, type ICreateSessionParams, type IInitializeResult, type IProtocolMessage, type IAhpNotification, type IReconnectResult, type IStateSnapshot } from '../../common/state/sessionProtocol.js'; -import { SessionStatus, type ISessionSummary } from '../../common/state/sessionState.js'; import { PROTOCOL_VERSION } from '../../common/state/sessionCapabilities.js'; +import { isJsonRpcNotification, isJsonRpcResponse, JSON_RPC_INTERNAL_ERROR, ProtocolError, type IAhpNotification, type ICreateSessionParams, type IInitializeResult, type IProtocolMessage, type IReconnectResult, type IStateSnapshot } from '../../common/state/sessionProtocol.js'; +import { SessionStatus, type ISessionSummary } from '../../common/state/sessionState.js'; import type { IProtocolServer, IProtocolTransport } from '../../common/state/sessionTransport.js'; import { ProtocolServerHandler, type IProtocolSideEffectHandler } from '../../node/protocolServerHandler.js'; import { SessionStateManager } from '../../node/sessionStateManager.js'; @@ -92,7 +93,7 @@ class MockSideEffectHandler implements IProtocolSideEffectHandler { getDefaultDirectory(): string { return URI.file('/home/testuser').toString(); } - async handleFetchContent(_uri: string): Promise<{ data: string; encoding: 'utf-8'; contentType?: string }> { + async handleFetchContent(_uri: string): Promise { throw new Error('Not implemented'); } } diff --git a/src/vs/sessions/contrib/chat/browser/workspacePicker.ts b/src/vs/sessions/contrib/chat/browser/workspacePicker.ts index ad81beb645a7a..603f9c3e118c9 100644 --- a/src/vs/sessions/contrib/chat/browser/workspacePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/workspacePicker.ts @@ -21,9 +21,8 @@ import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js' import { GITHUB_REMOTE_FILE_SCHEME, SessionWorkspace } from '../../sessions/common/sessionWorkspace.js'; import { IRemoteAgentHostService, RemoteAgentHostsEnabledSettingId } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { agentHostAuthority } from '../../remoteAgentHost/browser/remoteAgentHost.contribution.js'; -import { AGENT_HOST_FS_SCHEME } from '../../remoteAgentHost/browser/agentHostFileSystemProvider.js'; import { pickRemoteAgentHostFolder } from '../../remoteAgentHost/browser/remoteAgentHostPicker.js'; +import { AGENT_HOST_SCHEME, agentHostAuthority } from '../../../../platform/agentHost/common/agentHostUri.js'; const OPEN_REPO_COMMAND = 'github.copilot.chat.cloudSessions.openRepository'; const STORAGE_KEY_LAST_PROJECT = 'sessions.lastPickedProject'; @@ -344,9 +343,9 @@ export class WorkspacePicker extends Disposable { // Split into folders, repos, and remotes, sort each group alphabetically const isStoredFolder = (p: IStoredProject) => { const scheme = URI.revive(p.uri).scheme; - return scheme !== GITHUB_REMOTE_FILE_SCHEME && scheme !== AGENT_HOST_FS_SCHEME; + return scheme !== GITHUB_REMOTE_FILE_SCHEME && scheme !== AGENT_HOST_SCHEME; }; - const isStoredRemote = (p: IStoredProject) => URI.revive(p.uri).scheme === AGENT_HOST_FS_SCHEME; + const isStoredRemote = (p: IStoredProject) => URI.revive(p.uri).scheme === AGENT_HOST_SCHEME; const folders = allProjects.filter(p => isStoredFolder(p)).sort((a, b) => this._getStoredProjectLabel(a).localeCompare(this._getStoredProjectLabel(b))); const repos = allProjects.filter(p => !isStoredFolder(p) && !isStoredRemote(p)).sort((a, b) => this._getStoredProjectLabel(a).localeCompare(this._getStoredProjectLabel(b))); const remotes = allProjects.filter(p => isStoredRemote(p)).sort((a, b) => this._getStoredProjectLabel(a).localeCompare(this._getStoredProjectLabel(b))); @@ -448,7 +447,7 @@ export class WorkspacePicker extends Disposable { private _getStoredProjectLabel(project: IStoredProject): string { const uri = URI.revive(project.uri); // TODO@roblourens HACK - if (uri.scheme === AGENT_HOST_FS_SCHEME) { + if (uri.scheme === AGENT_HOST_SCHEME) { const folderName = basename(uri) || uri.path || '/'; const remoteName = this._getRemoteName(uri.authority) ?? project.remoteName ?? uri.authority; return `${folderName} [${remoteName}]`; @@ -475,7 +474,7 @@ export class WorkspacePicker extends Disposable { private _toStored(project: SessionWorkspace): IStoredProject { const uri = project.uri; const stored: IStoredProject = { uri: uri.toJSON() }; - if (uri.scheme === AGENT_HOST_FS_SCHEME) { + if (uri.scheme === AGENT_HOST_SCHEME) { const remoteName = this._getRemoteName(uri.authority); if (remoteName) { return { ...stored, remoteName }; @@ -493,7 +492,7 @@ export class WorkspacePicker extends Disposable { * it from the recents list so labels remain stable across restarts. */ private _withCachedRemoteName(stored: IStoredProject): IStoredProject { - if (!stored.remoteName && URI.revive(stored.uri).scheme === AGENT_HOST_FS_SCHEME) { + if (!stored.remoteName && URI.revive(stored.uri).scheme === AGENT_HOST_SCHEME) { const cached = this._recentProjects.find(p => this._isSameProject(p, stored)); if (cached?.remoteName) { return { ...stored, remoteName: cached.remoteName }; diff --git a/src/vs/sessions/contrib/chat/test/browser/workspacePicker.test.ts b/src/vs/sessions/contrib/chat/test/browser/workspacePicker.test.ts index 795df8bd5859e..a742dee973e3d 100644 --- a/src/vs/sessions/contrib/chat/test/browser/workspacePicker.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/workspacePicker.test.ts @@ -23,8 +23,8 @@ import { IQuickInputService, IQuickPickItem } from '../../../../../platform/quic import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { WorkspacePicker } from '../../browser/workspacePicker.js'; import { SessionWorkspace, GITHUB_REMOTE_FILE_SCHEME } from '../../../sessions/common/sessionWorkspace.js'; -import { AGENT_HOST_FS_SCHEME, agentHostUri } from '../../../remoteAgentHost/browser/agentHostFileSystemProvider.js'; -import { agentHostAuthority } from '../../../remoteAgentHost/browser/remoteAgentHost.contribution.js'; +import { AGENT_HOST_SCHEME, agentHostAuthority } from '../../../../../platform/agentHost/common/agentHostUri.js'; +import { agentHostUri } from '../../../../../platform/agentHost/common/agentHostFileSystemProvider.js'; suite('WorkspacePicker', () => { @@ -127,7 +127,7 @@ suite('WorkspacePicker', () => { assert.ok(picker.selectedProject); assert.strictEqual(picker.selectedProject.isRemoteAgentHost, true); - assert.strictEqual(picker.selectedProject.uri.scheme, AGENT_HOST_FS_SCHEME); + assert.strictEqual(picker.selectedProject.uri.scheme, AGENT_HOST_SCHEME); assert.strictEqual(picker.selectedProject.uri.path, '/home/user/project'); }); diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostPicker.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostPicker.ts index 0575b345becf6..c7d1cf21d41e3 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostPicker.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostPicker.ts @@ -5,13 +5,13 @@ import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; +import { agentHostUri } from '../../../../platform/agentHost/common/agentHostFileSystemProvider.js'; +import { AGENT_HOST_SCHEME, agentHostAuthority } from '../../../../platform/agentHost/common/agentHostUri.js'; import { IParsedRemoteAgentHostInput, IRemoteAgentHostService, parseRemoteAgentHostInput, RemoteAgentHostInputValidationError } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { INotificationService } from '../../../../platform/notification/common/notification.js'; import { IQuickInputService, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js'; -import { agentHostAuthority } from './remoteAgentHost.contribution.js'; -import { AGENT_HOST_FS_SCHEME, agentHostUri } from './agentHostFileSystemProvider.js'; interface IRemoteAgentHostPickItem extends IQuickPickItem { readonly remoteType: 'existing' | 'add'; @@ -190,7 +190,7 @@ async function pickFolderOnRemote( canSelectFolders: true, canSelectMany: false, title: localize('selectRemoteFolder', "Select Folder on {0}", selectedName), - availableFileSystems: [AGENT_HOST_FS_SCHEME], + availableFileSystems: [AGENT_HOST_SCHEME], defaultUri, }); return selected?.[0]; diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts index 60fee327515fe..51263b10fec8e 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts @@ -6,7 +6,7 @@ import assert from 'assert'; import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { ToolCallStatus, ToolCallConfirmationReason, PermissionKind, ToolResultContentType, TurnState, type ICompletedToolCall, type IPermissionRequest, type IToolCallRunningState, type ITurn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { ToolCallStatus, ToolCallConfirmationReason, PermissionKind, ToolResultContentType, TurnState, type ICompletedToolCall, type IPermissionRequest, type IToolCallRunningState, type ITurn, ToolCallCancellationReason } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IChatToolInvocationSerialized, type IChatMarkdownContent } from '../../../common/chatService/chatService.js'; import { ToolDataSource } from '../../../common/tools/languageModelToolsService.js'; import { turnsToHistory, toolCallStateToInvocation, permissionToConfirmation, finalizeToolInvocation } from '../../../browser/agentSessions/agentHost/stateToProgressAdapter.js'; @@ -345,7 +345,7 @@ suite('stateToProgressAdapter', () => { toolName: 'edit_file', displayName: 'Edit File', invocationMessage: 'Editing file...', - confirmed: ToolCallConfirmationReason.NotNeeded, + reason: ToolCallCancellationReason.Denied, reasonMessage: 'User cancelled', }); From 60f0814b324ac04d2a05f855bef8e996a921681f Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:27:17 -0700 Subject: [PATCH 19/33] update distro to 6c98cfe8 (#304298) update distro to https://github.com/microsoft/vscode-distro/commit/6c98cfe8dd3b4c159d8c9c331006a2d7c41872f0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 35d2ad047be56..5f398d4a7e7c4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", "version": "1.114.0", - "distro": "a981e7362565b4058f3bfd7604f8ab8c4f85101b", + "distro": "6c98cfe8dd3b4c159d8c9c331006a2d7c41872f0", "author": { "name": "Microsoft Corporation" }, From 69fd6c626e6693f1194952233e6b33e96f9c45b3 Mon Sep 17 00:00:00 2001 From: Alexandru Dima Date: Tue, 24 Mar 2026 00:38:47 +0100 Subject: [PATCH 20/33] Show unsandboxed execution reason in terminal tool confirmation (#304302) Display the model-provided reason for requesting sandbox bypass in the terminal command confirmation widget. Fixes #304205 --- .../chatTerminalToolConfirmationSubPart.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.ts index 2386b799b6c20..f3acc772b0f86 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.ts @@ -211,6 +211,15 @@ export class ChatTerminalToolConfirmationSubPart extends BaseChatToolInvocationS }, )); + if (terminalData.requestUnsandboxedExecution) { + const reasonText = (terminalData.requestUnsandboxedExecutionReason && terminalData.requestUnsandboxedExecutionReason.trim()) + || localize('chat.terminal.unsandboxedExecution.defaultReason', "The model did not provide a reason for requesting unsandboxed execution."); + const unsandboxedReasonMarkdown = new MarkdownString(undefined, { supportThemeIcons: true }); + unsandboxedReasonMarkdown.appendMarkdown(`$(${Codicon.info.id}) `); + unsandboxedReasonMarkdown.appendText(reasonText); + this._appendMarkdownPart(elements.disclaimer, unsandboxedReasonMarkdown, codeBlockRenderOptions); + } + if (disclaimer) { this._appendMarkdownPart(elements.disclaimer, disclaimer, codeBlockRenderOptions); } From a0edb50c964cd9752bd064905f54d2b17644b65f Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Mon, 23 Mar 2026 16:39:30 -0700 Subject: [PATCH 21/33] comments and build --- src/vs/platform/agentHost/common/agentHostUri.ts | 9 ++++----- .../agentHost/node/copilot/fileEditTracker.ts | 9 +-------- .../chat/test/browser/workspacePicker.test.ts | 12 ++++++------ .../contrib/sessions/common/sessionWorkspace.ts | 10 +++------- 4 files changed, 14 insertions(+), 26 deletions(-) diff --git a/src/vs/platform/agentHost/common/agentHostUri.ts b/src/vs/platform/agentHost/common/agentHostUri.ts index 48aa69b9c86fd..773bce4e67643 100644 --- a/src/vs/platform/agentHost/common/agentHostUri.ts +++ b/src/vs/platform/agentHost/common/agentHostUri.ts @@ -43,7 +43,7 @@ export function toAgentHostUri(originalUri: URI, connectionAuthority: string): U return URI.from({ scheme: AGENT_HOST_SCHEME, authority: connectionAuthority, - path: `/${originalUri.scheme}${originalAuthority ? `/${originalAuthority}` : ''}${originalUri.path}`, + path: `/${originalUri.scheme}/${originalAuthority || '-'}${originalUri.path}`, }); } @@ -74,13 +74,12 @@ export function fromAgentHostUri(agentHostUri: URI): URI { } 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; + if (originalAuthority === '-') { originalAuthority = ''; } + const originalPath = path.substring(authorityEnd); + return URI.from({ scheme: originalScheme, authority: originalAuthority || undefined, diff --git a/src/vs/platform/agentHost/node/copilot/fileEditTracker.ts b/src/vs/platform/agentHost/node/copilot/fileEditTracker.ts index b7e959c5a807a..8ee6b5a089329 100644 --- a/src/vs/platform/agentHost/node/copilot/fileEditTracker.ts +++ b/src/vs/platform/agentHost/node/copilot/fileEditTracker.ts @@ -12,14 +12,7 @@ import { ToolResultContentType, type IToolResultFileEditContent } from '../../co /** * Tracks file edits made by tools in a session by snapshotting file content - * before each edit tool invocation. - * - * 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` - * - * The after-content is the real file on the agent host, accessible via - * the `agenthost://` filesystem provider. + * before and after each edit tool invocation. */ export class FileEditTracker { diff --git a/src/vs/sessions/contrib/chat/test/browser/workspacePicker.test.ts b/src/vs/sessions/contrib/chat/test/browser/workspacePicker.test.ts index a742dee973e3d..e957ab5bd76da 100644 --- a/src/vs/sessions/contrib/chat/test/browser/workspacePicker.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/workspacePicker.test.ts @@ -128,7 +128,7 @@ suite('WorkspacePicker', () => { assert.ok(picker.selectedProject); assert.strictEqual(picker.selectedProject.isRemoteAgentHost, true); assert.strictEqual(picker.selectedProject.uri.scheme, AGENT_HOST_SCHEME); - assert.strictEqual(picker.selectedProject.uri.path, '/home/user/project'); + assert.strictEqual(picker.selectedProject.uri.path, '/file/-/home/user/project'); }); test('setSelectedProject with GitHub repo URI', () => { @@ -155,7 +155,7 @@ suite('WorkspacePicker', () => { assert.ok(fired); assert.strictEqual(fired.isRemoteAgentHost, true); - assert.strictEqual(fired.uri.path, '/remote/path'); + assert.strictEqual(fired.uri.path, '/file/-/remote/path'); }); test('onDidSelectProject does not fire when fireEvent is false', () => { @@ -217,7 +217,7 @@ suite('WorkspacePicker', () => { const picker2 = ds.add(instantiationService.createInstance(WorkspacePicker)); assert.ok(picker2.selectedProject); assert.strictEqual(picker2.selectedProject.isRemoteAgentHost, true); - assert.strictEqual(picker2.selectedProject.uri.path, '/home/user/project'); + assert.strictEqual(picker2.selectedProject.uri.path, '/file/-/home/user/project'); assert.strictEqual(picker2.selectedProject.uri.authority, authority); }); @@ -303,7 +303,7 @@ suite('WorkspacePicker', () => { assert.strictEqual(openDialogOptions.title, 'Select Folder on My Host'); assert.strictEqual(openDialogOptions.defaultUri?.toString(), agentHostUri(agentHostAuthority('ws://myhost:9090'), '/home/user').toString()); assert.ok(picker.selectedProject); - assert.strictEqual(picker.selectedProject.uri.path, '/home/user/project'); + assert.strictEqual(picker.selectedProject.uri.path, '/file/-/home/user/project'); }); test('Browse Remotes can add a remote and continue to folder selection', async () => { @@ -344,7 +344,7 @@ suite('WorkspacePicker', () => { assert.strictEqual(openDialogOptions.title, 'Select Folder on Loopback'); assert.strictEqual(openDialogOptions.defaultUri?.toString(), agentHostUri(agentHostAuthority('127.0.0.1:8089'), '/home/loopback').toString()); assert.ok(picker.selectedProject); - assert.strictEqual(picker.selectedProject.uri.path, '/home/loopback/project'); + assert.strictEqual(picker.selectedProject.uri.path, '/file/-/home/loopback/project'); }); test('Browse Remotes with no configured entries goes straight to add remote', async () => { @@ -376,6 +376,6 @@ suite('WorkspacePicker', () => { connectionToken: undefined, }]); assert.ok(picker.selectedProject); - assert.strictEqual(picker.selectedProject.uri.path, '/workspace/myproject'); + assert.strictEqual(picker.selectedProject.uri.path, '/file/-/workspace/myproject'); }); }); diff --git a/src/vs/sessions/contrib/sessions/common/sessionWorkspace.ts b/src/vs/sessions/contrib/sessions/common/sessionWorkspace.ts index aa5a5ba3a44b2..82884a8d550f7 100644 --- a/src/vs/sessions/contrib/sessions/common/sessionWorkspace.ts +++ b/src/vs/sessions/contrib/sessions/common/sessionWorkspace.ts @@ -4,16 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import { URI } from '../../../../base/common/uri.js'; +import { AGENT_HOST_SCHEME } from '../../../../platform/agentHost/common/agentHostUri.js'; import { IGitRepository } from '../../../../workbench/contrib/git/common/gitService.js'; -export const GITHUB_REMOTE_FILE_SCHEME = 'github-remote-file'; +export { AGENT_HOST_SCHEME }; -/** - * URI scheme for agent host remote filesystems. - * Must match {@link AGENT_HOST_FS_SCHEME} in `agentHostFileSystemProvider.ts` - * (which lives in the `browser` layer and cannot be imported here). - */ -export const AGENT_HOST_SCHEME = 'agenthost'; +export const GITHUB_REMOTE_FILE_SCHEME = 'github-remote-file'; /** * Represents a workspace (folder or repository) for a session. From 762894cd1f8cb530bd39952e3c02117d8f9b5e4e Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Mon, 23 Mar 2026 17:09:04 -0700 Subject: [PATCH 22/33] Rename "Show Top 5 Sessions" to "Show Recent Sessions" in filter menu Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts index d3ee4f4fd396e..1e6bfe58c4187 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts @@ -432,7 +432,7 @@ registerAction2(class ShowTopSessionsAction extends Action2 { constructor() { super({ id: 'sessionsView.showTopSessions', - title: localize2('showTopSessions', "Show Top {0} Sessions", AgentSessionsDataSource.REPOSITORY_GROUP_LIMIT), + title: localize2('showRecentSessions', "Show Recent Sessions"), category: SessionsCategories.Sessions, toggled: IsRepositoryGroupCappedContext, menu: [{ From e288f0d14856f2632c5067e30eb69a83f9d67bc3 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Mon, 23 Mar 2026 17:15:47 -0700 Subject: [PATCH 23/33] Address PR review feedback: scope cap actions, fix expanded state, make show-more focusable, use theme color MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Only show "Show Recent/All Sessions" actions when grouped by repository - Only clear expanded repo groups on capped transition (false→true) - Make show-more items selectable for keyboard activation - Replace hardcoded rgb(191,191,191) with --vscode-descriptionForeground Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../contrib/sessions/browser/sessionsViewPane.ts | 2 ++ .../chat/browser/agentSessions/agentSessionsViewer.ts | 9 ++++++--- .../browser/agentSessions/media/agentsessionsviewer.css | 2 +- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts index 1e6bfe58c4187..880b52eae3b5f 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts @@ -439,6 +439,7 @@ registerAction2(class ShowTopSessionsAction extends Action2 { id: SessionsViewFilterSubMenu, group: '4_cap', order: 0, + when: ContextKeyExpr.equals(SessionsViewGroupingContext.key, AgentSessionsGrouping.Repository), }] }); } @@ -461,6 +462,7 @@ registerAction2(class ShowAllSessionsAction extends Action2 { id: SessionsViewFilterSubMenu, group: '4_cap', order: 1, + when: ContextKeyExpr.equals(SessionsViewGroupingContext.key, AgentSessionsGrouping.Repository), }] }); } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts index 1971e6a3773ec..914abbc894e52 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts @@ -855,11 +855,14 @@ export class AgentSessionsDataSource extends Disposable implements IAsyncDataSou super(); if (this.filter) { + let previousCapped = this.filter.getExcludes().repositoryGroupCapped; this._register(this.filter.onDidChange(() => { - // Clear expanded state when capping is re-enabled - if (this.filter?.getExcludes().repositoryGroupCapped) { + const currentCapped = this.filter!.getExcludes().repositoryGroupCapped; + // Only clear expanded state when capping transitions from off to on + if (currentCapped && !previousCapped) { this.expandedRepositoryGroups.clear(); } + previousCapped = currentCapped; })); } } @@ -1333,7 +1336,7 @@ export class AgentSessionsIdentityProvider implements IIdentityProvider Date: Mon, 23 Mar 2026 17:26:19 -0700 Subject: [PATCH 24/33] padding --- .../chat/browser/agentSessions/media/agentsessionsviewer.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css b/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css index 57991d167149e..42aaf118f9f99 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css @@ -327,7 +327,7 @@ font-size: 12px; color: var(--vscode-descriptionForeground); /* align with session item text: 6px item padding + 16px icon + 6px main-col padding */ - padding: 0 6px 0 28px; + padding: 0 6px 0 24px; cursor: pointer; text-decoration: none !important; } From 970e788816f892794940b30483967b12eb0aac83 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Mon, 23 Mar 2026 17:29:02 -0700 Subject: [PATCH 25/33] color change --- .../chat/browser/agentSessions/media/agentsessionsviewer.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css b/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css index 42aaf118f9f99..f52fe9f03c4dd 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css @@ -325,7 +325,7 @@ display: flex; align-items: center; font-size: 12px; - color: var(--vscode-descriptionForeground); + color: var(--vscode-foreground); /* align with session item text: 6px item padding + 16px icon + 6px main-col padding */ padding: 0 6px 0 24px; cursor: pointer; From c19696babb1ed61cd394682d24ad34dd73d71664 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Mon, 23 Mar 2026 17:42:34 -0700 Subject: [PATCH 26/33] Fix loading copilot-sdk 0.2 (#304310) * Fix loading copilot-sdk 0.2 * Comment Co-authored-by: Copilot * comment --------- Co-authored-by: Copilot --- src/bootstrap-import.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/bootstrap-import.ts b/src/bootstrap-import.ts index ec09ff88ba0b5..8ccabe764d178 100644 --- a/src/bootstrap-import.ts +++ b/src/bootstrap-import.ts @@ -30,14 +30,29 @@ export async function initialize(injectPath: string): Promise { const path = join(injectPackageJSONPath, `../node_modules/${name}/package.json`); const pkgJson = JSON.parse(String(await promises.readFile(path))); - // Determine the entry point: prefer exports["."].import for ESM, then main + // Determine the entry point: prefer exports["."].import for ESM, then main. + // Handle conditional export targets where exports["."].import/default + // can be a string or an object with a string `default` field. + // (Added for copilot-sdk) let main: string | undefined; if (pkgJson.exports?.['.']) { const dotExport = pkgJson.exports['.']; if (typeof dotExport === 'string') { main = dotExport; } else if (typeof dotExport === 'object' && dotExport !== null) { - main = dotExport.import ?? dotExport.default; + const resolveCondition = (v: unknown): string | undefined => { + if (typeof v === 'string') { + return v; + } + if (typeof v === 'object' && v !== null) { + const d = (v as { default?: unknown }).default; + if (typeof d === 'string') { + return d; + } + } + return undefined; + }; + main = resolveCondition(dotExport.import) ?? resolveCondition(dotExport.default); } } if (typeof main !== 'string') { From a2d7169e3b62e472db34236bc9c7fcd6c0b9b96d Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Mon, 23 Mar 2026 17:43:19 -0700 Subject: [PATCH 27/33] Keep agent host alive while there are active websocket connections (#304271) * Keep agent host alive while there are active websocket connections Instead of shutting it down whenever there are no agents running Co-authored-by: Copilot * Fix disposable handling --------- Co-authored-by: Copilot --- src/vs/platform/agentHost/architecture.md | 964 ++++++++++++++---- .../platform/agentHost/common/agentService.ts | 2 + .../platform/agentHost/node/agentHostMain.ts | 18 +- .../agentHost/node/protocolServerHandler.ts | 11 +- .../test/node/protocolServerHandler.test.ts | 44 +- src/vs/server/node/serverAgentHostManager.ts | 58 +- .../test/node/serverAgentHostManager.test.ts | 205 ++++ 7 files changed, 1071 insertions(+), 231 deletions(-) create mode 100644 src/vs/server/test/node/serverAgentHostManager.test.ts diff --git a/src/vs/platform/agentHost/architecture.md b/src/vs/platform/agentHost/architecture.md index e10015a7c5bf8..f5bfec457e9b6 100644 --- a/src/vs/platform/agentHost/architecture.md +++ b/src/vs/platform/agentHost/architecture.md @@ -1,224 +1,762 @@ -# Agent host process architecture +# Remote Agent Host - Architecture Reference -> **Keep this document in sync with the code.** If you change the IPC contract, add new event types, modify the process lifecycle, or restructure files, update this document as part of the same change. +This file describes the key types in the remote agent host system, from the +agent host process itself up through the sessions app integration layer. -For design decisions, see [design.md](design.md). For the client-server state protocol, see [protocol.md](protocol.md). For chat session wiring, see [sessions.md](sessions.md). +The system has four layers: -## Overview +1. **Agent host process** (`platform/agentHost/node/`) + The utility process that hosts agent backends (e.g. Copilot SDK). + Owns the authoritative state tree and dispatches to IAgent providers. -The agent host runs as either an Electron **utility process** (desktop) or a **standalone WebSocket server** (headless / development). It hosts agent backends (CopilotAgent, MockAgent) and exposes session state to clients through two communication layers: +2. **Platform services** (`platform/agentHost/common/`, `electron-browser/`) + Service interfaces and IPC plumbing that expose the agent host to the + renderer. Local connections use MessagePort; remote ones use WebSocket. -1. **MessagePort / ProxyChannel** (desktop only) -- the renderer connects directly to the utility process via MessagePort. `AgentHostServiceClient` proxies `IAgentService` methods and forwards action/notification events. -2. **WebSocket / JSON-RPC protocol** (standalone server) -- multiple clients connect over WebSocket. Session state is synchronized via actions, subscriptions, and write-ahead reconciliation. See [protocol.md](protocol.md) for the full specification. +3. **Workbench contributions** (`workbench/contrib/chat/browser/agentSessions/agentHost/`) + Shared UI adapters that bridge the agent host protocol with the chat UI: + session handlers, session list controllers, language model providers, and + state-to-progress adapters. Used by both local and remote agent hosts. -In both modes, the server holds an authoritative state tree (`SessionStateManager`) mutated by actions flowing through pure reducers. Raw `IAgentProgressEvent`s from agent backends are mapped to state actions via `agentEventMapper.ts`. - -The entire feature is gated behind the `chat.agentHost.enabled` setting (default `false`). When disabled, the process is not spawned and no agents are registered. - -## Process Model - -``` -+--------------------------------------------------------------+ -| Renderer Window (Desktop) | -| | -| AgentHostContribution (discovers agents via listAgents()) | -| +-- per agent: SessionHandler, ListCtrl, LMProvider | -| +-- SessionClientState (write-ahead reconciliation) | -| +-- stateToProgressAdapter (state -> IChatProgress[]) | -| | -| AgentHostServiceClient (IAgentHostService singleton) | -| +-- ProxyChannel over delayed MessagePort | -| (revive() applied to event payloads) | -+---------------- MessagePort (direct) -------------------------+ -| Agent Host Utility Process (agentHostMain.ts) | -| -- or -- | -| Standalone Server (agentHostServerMain.ts) | -| | -| SessionStateManager (server-authoritative state tree) | -| +-- rootReducer / sessionReducer | -| +-- action envelope sequencing | -| | -| ProtocolServerHandler (JSON-RPC routing, broadcasts) | -| +-- per-client subscriptions, replay buffer | -| | -| Agent registry (Map) | -| +-- CopilotAgent (id='copilot') | -| | +-- CopilotClient (@github/copilot-sdk) | -| +-- ScriptedMockAgent (id='mock', opt-in via flag) | -| | -| agentEventMapper.ts | -| +-- IAgentProgressEvent -> ISessionAction mapping | -+---------------- UtilityProcess lifecycle ---------------------+ -| Main Process (Desktop only) | -| | -| ElectronAgentHostStarter (IAgentHostStarter) | -| +-- Spawns utility process, brokers MessagePort to windows | -| AgentHostProcessManager | -| +-- Lazy start on first window connection, crash recovery | -+---------------------------------------------------------------+ -``` - -## File Layout +4. **Sessions app orchestrator** (`sessions/contrib/remoteAgentHost/`) + The contribution that discovers remote agent hosts, dynamically registers + them as chat session types, and provides the remote filesystem provider. ``` -src/vs/platform/agentHost/ -+-- common/ -| +-- agent.ts # IAgentHostStarter, IAgentHostConnection (starter contract) -| +-- agentService.ts # IAgent, IAgentService, IAgentHostService interfaces, -| # IPC data types, IAgentProgressEvent union, -| # AgentSession namespace (URI helpers), -| # AgentHostEnabledSettingId -| +-- state/ -| +-- sessionState.ts # Immutable state types (RootState, SessionState, Turn, etc.) -| +-- sessionActions.ts # Action discriminated union + ActionEnvelope + Notifications -| +-- sessionReducers.ts # Pure reducer functions (rootReducer, sessionReducer) -| +-- sessionProtocol.ts # JSON-RPC message types, request params/results -| +-- sessionCapabilities.ts # Version constants + ProtocolCapabilities -| +-- sessionClientState.ts # Client-side state manager with write-ahead reconciliation -| +-- sessionTransport.ts # IProtocolTransport / IProtocolServer abstractions -| +-- versions/ -| +-- v1.ts # v1 wire format types (tip -- editable, compiler-enforced compat) -| +-- versionRegistry.ts # Compile-time compat checks + runtime action->version map -+-- electron-browser/ -| +-- agentHostService.ts # AgentHostServiceClient (renderer singleton, direct MessagePort) -+-- electron-main/ -| +-- electronAgentHostStarter.ts # Spawns utility process, brokers MessagePort connections -+-- node/ -| +-- agentHostMain.ts # Entry point inside the Electron utility process -| +-- agentHostServerMain.ts # Entry point for standalone WebSocket server -| +-- agentService.ts # AgentService: dispatches to registered IAgent providers -| +-- agentHostService.ts # AgentHostProcessManager: lifecycle, crash recovery -| +-- agentEventMapper.ts # Maps IAgentProgressEvent -> ISessionAction -| +-- sessionStateManager.ts # Server-authoritative state tree + reducer dispatch -| +-- protocolServerHandler.ts # JSON-RPC routing, client subscriptions, action broadcast -| +-- webSocketTransport.ts # WebSocket IProtocolTransport + IProtocolServer impl -| +-- nodeAgentHostStarter.ts # Node.js (non-Electron) starter -| +-- copilot/ -| +-- copilotAgent.ts # CopilotAgent: IAgent backed by Copilot SDK -| +-- copilotSessionWrapper.ts -| +-- copilotToolDisplay.ts # Copilot-specific tool name -> display string mapping -+-- test/ - +-- (test files) - -src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/ -+-- agentHostChatContribution.ts # AgentHostContribution: discovers agents, registers dynamically -+-- agentHostLanguageModelProvider.ts # ILanguageModelChatProvider for SDK models -+-- agentHostSessionHandler.ts # AgentHostSessionHandler: generic, config-driven -+-- agentHostSessionListController.ts # Lists persisted sessions from agent host -+-- stateToProgressAdapter.ts # Converts protocol state -> IChatProgress[] for chat UI - -src/vs/workbench/contrib/chat/electron-browser/ -+-- chat.contribution.ts # Desktop-only: registers AgentHostContribution +┌───────────────────────────────────────────────────────────────────────────┐ +│ Sessions App (Layer 4) │ +│ RemoteAgentHostContribution │ +│ per-connection → SessionClientState + agent registrations │ +│ AgentHostFileSystemProvider (agenthost:// scheme) │ +├──────────────────────────────────────┬────────────────────────────────────┤ +│ Workbench Contributions (3) │ Workbench Contributions (3) │ +│ AgentHostContribution (local) │ (shared adapters) │ +│ SessionClientState │ AgentHostSessionHandler │ +│ per-agent registrations │ AgentHostSessionListController │ +│ │ AgentHostLanguageModelProvider │ +├──────────────────────────────────────┴────────────────────────────────────┤ +│ Platform Services (Layer 2) │ +│ IAgentHostService (local, MessagePort) │ +│ IRemoteAgentHostService (remote, WebSocket) │ +│ └─ both implement IAgentConnection │ +├───────────────────────────────────────────────────────────────────────────┤ +│ Agent Host Process (Layer 1) │ +│ AgentService → SessionStateManager → IAgent (Copilot SDK) │ +│ ProtocolServerHandler (WebSocket protocol bridge) │ +│ AgentSideEffects (action dispatch + progress event routing) │ +└───────────────────────────────────────────────────────────────────────────┘ ``` -## Session URIs - -Sessions are identified by URIs where the **scheme is the provider name** and the **path is the raw session ID**: `copilot:/`. Helper functions in the `AgentSession` namespace: - -| Helper | Purpose | -|---|---| -| `AgentSession.uri(provider, rawId)` | Create a session URI | -| `AgentSession.id(session)` | Extract raw session ID from URI | -| `AgentSession.provider(session)` | Extract provider name from URI scheme | - -The renderer uses UI resource schemes (`agent-host-copilot`) for session resources. The `AgentHostSessionHandler` converts these to provider URIs before IPC calls. - -## Communication Layers - -### Layer 1: IAgent interface (internal) - -The `IAgent` interface in `agentService.ts` is what each agent backend implements. It fires `IAgentProgressEvent`s (raw SDK events) and exposes methods for session management: - -| Method | Description | -|---|---| -| `createSession(config?)` | Create a new session (returns session URI) | -| `sendMessage(session, prompt, attachments?)` | Send a user message | -| `abortSession(session)` | Abort the current turn | -| `respondToPermissionRequest(requestId, approved)` | Grant/deny a permission | -| `getDescriptor()` | Return agent metadata | -| `listModels()` | List available models | -| `listSessions()` | List persisted sessions | -| `setAuthToken(token)` | Set auth credentials | -| `changeModel?(session, model)` | Change model for a session | - -### Layer 2: Sessions state protocol (client-facing) - -The server maps raw `IAgentProgressEvent`s to state actions via `agentEventMapper.ts`, dispatches them through `SessionStateManager`, and broadcasts to subscribed clients. See [protocol.md](protocol.md) for the full JSON-RPC specification, action types, state model, and versioning. - -### Layer 3: MessagePort relay (desktop renderer) - -`AgentHostServiceClient` in `electron-browser/agentHostService.ts` connects to the utility process via MessagePort and proxies `IAgentService` methods. It also forwards action envelopes and notifications as events so the renderer can feed them into `SessionClientState`. - -## How It Works - -### Setting Gate - -The `chat.agentHost.enabled` setting (default `false`) controls the entire feature: -- **Main process** (`app.ts`): skips creating `ElectronAgentHostStarter` + `AgentHostProcessManager` -- **Renderer proxy** (`AgentHostServiceClient`): skips MessagePort connection -- **Contribution** (`AgentHostContribution`): returns early without discovering or registering agents - -### Startup (lazy) - -1. `ElectronAgentHostStarter` is created in `app.ts` (if setting enabled) and handed to `AgentHostProcessManager`. -2. The utility process is **not** spawned until the first window requests a MessagePort connection. -3. On start, the starter spawns the utility process with entry point `vs/platform/agent/node/agentHostMain`. -4. Each renderer window gets its own MessagePort via `acquirePort('vscode:createAgentHostMessageChannel', ...)`. - -### Standalone Server Mode - -The agent host can also run as a standalone WebSocket server (`agentHostServerMain.ts`): - -```bash -node out/vs/platform/agentHost/node/agentHostServerMain.js [--port ] [--enable-mock-agent] +```typescript + +// ============================================================================= +// LAYER 1: Agent Host Process (platform/agentHost/node/) +// ============================================================================= + +/** + * Implemented by each agent backend (e.g. the Copilot SDK wrapper). + * The agent host process can host multiple providers, though currently + * only `copilot` is supported. + * + * Registered with {@link AgentService.registerProvider}. Provider progress + * events are wired to the state manager through {@link AgentSideEffects}. + * + * File: `platform/agentHost/common/agentService.ts` + */ +interface IAgent { + /** Unique provider identifier (e.g. `'copilot'`). */ + readonly id: AgentProvider; + /** Fires when the provider streams progress for a session. */ + readonly onDidSessionProgress: Event; + createSession(config?: IAgentCreateSessionConfig): Promise; + sendMessage(session: URI, prompt: string, attachments?: IAgentAttachment[]): Promise; + getSessionMessages(session: URI): Promise; + disposeSession(session: URI): Promise; + abortSession(session: URI): Promise; + changeModel(session: URI, model: string): Promise; + respondToPermissionRequest(requestId: string, approved: boolean): void; + getDescriptor(): IAgentDescriptor; + listModels(): Promise; + listSessions(): Promise; + getProtectedResources(): IAuthorizationProtectedResourceMetadata[]; + authenticate(resource: string, token: string): Promise; + shutdown(): Promise; + dispose(): void; +} + +/** + * The agent service implementation that runs inside the agent host utility + * process. Dispatches to registered {@link IAgent} providers based on the + * provider identifier in the session URI scheme. + * + * Owns the {@link SessionStateManager} (authoritative state tree) and + * {@link AgentSideEffects} (action routing + progress event mapping). + * + * When `VSCODE_AGENT_HOST_PORT` is set, the process also starts a + * {@link ProtocolServerHandler} over WebSocket for external clients. + * + * File: `platform/agentHost/node/agentService.ts` + */ +interface AgentService extends IAgentService { + /** Exposes the state manager for co-hosting a WebSocket protocol server. */ + readonly stateManager: SessionStateManager; + /** Register a new agent backend provider. */ + registerProvider(provider: IAgent): void; +} + +/** + * Server-side authoritative state manager for the sessions process protocol. + * + * Maintains the root state (agent list + active session count) and per-session + * state trees. Applies actions through pure reducers, assigns monotonic + * sequence numbers, and emits {@link IActionEnvelope}s for subscribed clients. + * + * Consumed by both the IPC proxy (for local clients) and + * {@link ProtocolServerHandler} (for WebSocket clients). Both paths share + * the same state, so local and remote clients see identical state. + * + * File: `platform/agentHost/node/sessionStateManager.ts` + */ +interface SessionStateManager { + readonly rootState: IRootState; + readonly serverSeq: number; + readonly onDidEmitEnvelope: Event; + readonly onDidEmitNotification: Event; + getSessionState(session: string): ISessionState | undefined; + getSnapshot(resource: string): IStateSnapshot | undefined; + createSession(summary: ISessionSummary): ISessionState; + removeSession(session: string): void; + applyAction(action: IStateAction, origin: IActionOrigin): void; +} + +/** + * Shared side-effect handler that routes client-dispatched actions to the + * correct {@link IAgent} backend, handles session create/dispose/list + * operations, and wires agent progress events to the state manager. + * + * Also implements {@link IProtocolSideEffectHandler} so the WebSocket + * {@link ProtocolServerHandler} can delegate side effects to the same logic. + * + * File: `platform/agentHost/node/agentSideEffects.ts` + */ +interface AgentSideEffects extends IProtocolSideEffectHandler { + /** Connects an IAgent's progress events to the state manager. */ + registerProgressListener(provider: IAgent): IDisposable; +} + +/** + * Server-side protocol handler for WebSocket clients. Routes JSON-RPC + * messages to the {@link SessionStateManager}, manages client subscriptions, + * and broadcasts action envelopes to subscribed clients. + * + * Handles the initialize/reconnect handshake, subscribe/unsubscribe, + * dispatchAction, createSession, disposeSession, and browseDirectory commands. + * + * Exposes {@link onDidChangeConnectionCount} so the server process can + * track how many external clients are connected (used by + * {@link ServerAgentHostManager} for lifetime management). + * + * File: `platform/agentHost/node/protocolServerHandler.ts` + */ +interface ProtocolServerHandler { + /** Fires with the current client count when a client connects or disconnects. */ + readonly onDidChangeConnectionCount: Event; +} + +/** + * Side-effect handler interface for protocol commands that require + * business logic beyond pure state management. Implemented by + * {@link AgentSideEffects} and consumed by {@link ProtocolServerHandler}. + * + * File: `platform/agentHost/node/protocolServerHandler.ts` + */ +interface IProtocolSideEffectHandler { + handleAction(action: ISessionAction): void; + handleCreateSession(command: ICreateSessionParams): Promise; + handleDisposeSession(session: string): void; + handleListSessions(): Promise; + handleGetResourceMetadata(): IResourceMetadata; + handleAuthenticate(params: IAuthenticateParams): Promise; + handleBrowseDirectory(uri: string): Promise; + getDefaultDirectory(): string; +} + +/** + * Main-process service that manages the agent host utility process lifecycle: + * lazy start on first connection request, crash recovery (up to 5 restarts), + * and logger channel forwarding. + * + * The renderer communicates with the utility process directly via MessagePort; + * this class does not relay any agent service calls. + * + * File: `platform/agentHost/node/agentHostService.ts` + */ +interface AgentHostProcessManager { + // Internal lifecycle management - start, restart, logger forwarding. +} + +/** + * Server-specific agent host manager. Eagerly starts the agent host process, + * handles crash recovery, and tracks both active agent sessions and connected + * WebSocket clients via {@link IServerLifetimeService} to keep the server + * alive while either signal is active. + * + * The lifetime token is held when: + * - there are active agent sessions (turns in progress), OR + * - there are WebSocket clients connected to the agent host + * + * The token is released (allowing server auto-shutdown) only when both + * active sessions = 0 AND connected clients = 0. + * + * Session count comes from `root/activeSessionsChanged` actions via + * {@link IAgentService.onDidAction}. Client connection count comes from + * a separate IPC channel ({@link AgentHostIpcChannels.ConnectionTracker}) + * that is not part of the agent host protocol -- it is a server-only + * process-management concern. + * + * File: `server/node/serverAgentHostManager.ts` + */ +interface ServerAgentHostManager { + // Tracks _hasActiveSessions + _connectionCount, updates lifetime token + // when either changes. +} + +/** + * Abstracts the utility process creation so the same lifecycle management + * works for both Electron utility processes and Node child processes. + * + * File: `platform/agentHost/common/agent.ts` + */ +interface IAgentHostStarter extends IDisposable { + readonly onRequestConnection?: Event; + readonly onWillShutdown?: Event; + /** Creates the agent host process and connects to it. */ + start(): IAgentHostConnection; +} + +/** + * The connection returned by {@link IAgentHostStarter.start}. Provides + * an IPC channel client and process exit events. + * + * File: `platform/agentHost/common/agent.ts` + */ +interface IAgentHostConnection { + readonly client: IChannelClient; + readonly store: DisposableStore; + readonly onDidProcessExit: Event<{ code: number; signal: string }>; +} + +// ============================================================================= +// LAYER 2: Platform Services (platform/agentHost/common/ & electron-browser/) +// ============================================================================= + +/** + * Core protocol surface for communicating with an agent host. Methods are + * proxied across MessagePort (local) or implemented over WebSocket (remote). + * + * State synchronization uses the subscribe/unsubscribe/dispatchAction pattern. + * Clients observe root state (discovered agents, models) and session state + * via subscriptions, and mutate state by dispatching actions (e.g. + * `session/turnStarted`, `session/turnCancelled`). + * + * File: `platform/agentHost/common/agentService.ts` + */ +interface IAgentService { + listAgents(): Promise; + getResourceMetadata(): Promise; + authenticate(params: IAuthenticateParams): Promise; + refreshModels(): Promise; + listSessions(): Promise; + createSession(config?: IAgentCreateSessionConfig): Promise; + disposeSession(session: URI): Promise; + shutdown(): Promise; + + // ---- Protocol methods ---- + subscribe(resource: URI): Promise; + unsubscribe(resource: URI): void; + readonly onDidAction: Event; + readonly onDidNotification: Event; + dispatchAction(action: ISessionAction, clientId: string, clientSeq: number): void; + browseDirectory(uri: URI): Promise; +} + +/** + * A concrete connection to an agent host - local utility process or remote + * WebSocket. Extends {@link IAgentService} with a `clientId` used for + * write-ahead reconciliation of optimistic actions. + * + * Both {@link IAgentHostService} (local) and per-connection objects from + * {@link IRemoteAgentHostService} (remote) satisfy this contract. The + * workbench contributions ({@link AgentHostSessionHandler}, etc.) program + * against this single interface. + * + * File: `platform/agentHost/common/agentService.ts` + */ +interface IAgentConnection extends IAgentService { + /** Unique client identifier, used as origin in action envelopes. */ + readonly clientId: string; +} + +/** + * The local agent host service - wraps the utility process connection and + * provides lifecycle events. The renderer talks to the utility process + * directly via MessagePort using ProxyChannel. + * + * Registered as a singleton service. Also implements {@link IAgentConnection} + * so it can be used interchangeably with remote connections. + * + * File: `platform/agentHost/common/agentService.ts` (interface) + * File: `platform/agentHost/electron-browser/agentHostService.ts` (implementation) + */ +interface IAgentHostService extends IAgentConnection { + readonly onAgentHostExit: Event; + readonly onAgentHostStart: Event; + restartAgentHost(): Promise; +} + +/** + * Manages connections to one or more remote agent host processes over + * WebSocket. Each connection is identified by its address string (from the + * `chat.remoteAgentHosts` setting) and exposed as an {@link IAgentConnection}. + * + * The implementation reads the setting, creates a + * {@link RemoteAgentHostProtocolClient} per address, reconnects when the + * setting changes, and fires `onDidChangeConnections` when connections are + * established or lost. + * + * File: `platform/agentHost/common/remoteAgentHostService.ts` (interface) + * File: `platform/agentHost/electron-browser/remoteAgentHostServiceImpl.ts` (implementation) + */ +interface IRemoteAgentHostService { + readonly onDidChangeConnections: Event; + readonly connections: readonly IRemoteAgentHostConnectionInfo[]; + getConnection(address: string): IAgentConnection | undefined; +} + +/** + * Metadata about a single remote connection - address, friendly name, + * client ID from the handshake, and the remote machine's home directory. + * + * File: `platform/agentHost/common/remoteAgentHostService.ts` + */ +interface IRemoteAgentHostConnectionInfo { + readonly address: string; + readonly name: string; + readonly clientId: string; + readonly defaultDirectory?: string; +} + +/** + * An entry in the `chat.remoteAgentHosts` setting. + * + * File: `platform/agentHost/common/remoteAgentHostService.ts` + */ +interface IRemoteAgentHostEntry { + readonly address: string; + readonly name: string; + readonly connectionToken?: string; +} + +/** + * A protocol-level client for a single remote agent host connection. + * Manages the WebSocket transport, handshake (initialize command with + * protocol version exchange), subscriptions, action dispatch, and + * JSON-RPC request/response correlation. + * + * Implements {@link IAgentConnection} so consumers can program against + * a single interface regardless of whether the agent host is local or remote. + * + * File: `platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts` + */ +interface RemoteAgentHostProtocolClient extends IAgentConnection { + readonly defaultDirectory: string | undefined; + readonly onDidClose: Event; + connect(): Promise; +} + +// ============================================================================= +// LAYER 2: State Protocol Types (platform/agentHost/common/state/) +// ============================================================================= + +/** + * Root state: the top-level state tree subscribed to at `agenthost:/root`. + * Contains the list of discovered agent backends and active session count. + * Mutated by `root/agentsChanged` and `root/activeSessionsChanged` actions. + * + * File: `platform/agentHost/common/state/sessionState.ts` (re-exported from protocol) + */ +interface IRootState { + readonly agents: readonly IAgentInfo[]; + readonly activeSessions: number; +} + +/** + * Describes an agent backend discovered via root state subscription. + * Each agent exposes a provider name, display metadata, and available models. + * + * File: `platform/agentHost/common/state/sessionState.ts` (re-exported from protocol) + */ +interface IAgentInfo { + readonly provider: string; + readonly displayName: string; + readonly description: string; + readonly models: readonly ISessionModelInfo[]; +} + +/** + * Per-session state tree. Contains the session summary, lifecycle, completed + * turns, active turn (if any), and server tools. Mutated by session actions + * like `session/turnStarted`, `session/delta`, `session/toolCallStart`, etc. + * + * File: `platform/agentHost/common/state/sessionState.ts` (re-exported from protocol) + */ +interface ISessionState { + readonly summary: ISessionSummary; + readonly lifecycle: SessionLifecycle; + readonly turns: readonly ITurn[]; + readonly activeTurn: IActiveTurn | undefined; +} + +/** + * An envelope wrapping a state action with origin metadata and a monotonic + * server sequence number. Clients use the origin to distinguish their own + * echoed actions from concurrent actions from other clients/the server. + * + * File: `platform/agentHost/common/state/sessionActions.ts` (re-exported from protocol) + */ +interface IActionEnvelope { + readonly action: IStateAction; + readonly origin: IActionOrigin; + readonly serverSeq: number; +} + +/** + * A state snapshot returned by the subscribe command. Contains the current + * state at the given resource URI and the server sequence number at + * snapshot time. The client should process subsequent envelopes with + * `serverSeq > fromSeq`. + * + * File: `platform/agentHost/common/state/sessionProtocol.ts` (re-exported from protocol) + */ +interface IStateSnapshot { + readonly resource: string; + readonly state: IRootState | ISessionState; + readonly fromSeq: number; +} + +/** + * Client-side state manager with write-ahead reconciliation. + * + * Maintains confirmed state (last server-acknowledged), a pending action + * queue (optimistically applied), and reconciles when the server echoes + * actions back (possibly interleaved with actions from other sources). + * Operates on two kinds of subscribable state: + * - Root state (agents + models) - server-only mutations, no write-ahead. + * - Session state - mixed: client-sendable actions get write-ahead, + * server-only actions are applied directly. + * + * Usage: + * 1. `handleSnapshot()` - apply initial state from subscribe response. + * 2. `applyOptimistic()` - optimistically apply a client action. + * 3. `receiveEnvelope()` - process a server action envelope. + * 4. `receiveNotification()` - process an ephemeral notification. + * + * File: `platform/agentHost/common/state/sessionClientState.ts` + */ +interface SessionClientState { + readonly clientId: string; + readonly rootState: IRootState | undefined; + readonly onDidChangeRootState: Event; + readonly onDidChangeSessionState: Event<{ session: string; state: ISessionState }>; + readonly onDidReceiveNotification: Event; + getSessionState(session: string): ISessionState | undefined; + handleSnapshot(resource: string, state: IRootState | ISessionState, fromSeq: number): void; + applyOptimistic(action: ISessionAction): number; + receiveEnvelope(envelope: IActionEnvelope): void; + receiveNotification(notification: INotification): void; + unsubscribe(resource: string): void; +} + +/** + * A bidirectional transport for protocol messages (JSON-RPC 2.0 framing). + * Implementations handle serialization, framing, and connection management. + * Concrete implementations: MessagePort (ProxyChannel), WebSocket, stdio. + * + * File: `platform/agentHost/common/state/sessionTransport.ts` + */ +interface IProtocolTransport extends IDisposable { + readonly onMessage: Event; + readonly onClose: Event; + send(message: IProtocolMessage): void; +} + +/** + * Server-side transport that accepts multiple client connections. + * Each connected client gets its own {@link IProtocolTransport}. + * + * File: `platform/agentHost/common/state/sessionTransport.ts` + */ +interface IProtocolServer extends IDisposable { + readonly onConnection: Event; + readonly address: string | undefined; +} + +// ============================================================================= +// LAYER 3: Workbench Contributions (workbench/contrib/chat/browser/agentSessions/agentHost/) +// ============================================================================= + +/** + * Renderer-side handler for a single agent host chat session type. + * Bridges the protocol state layer with the chat UI: + * + * - Subscribes to session state via {@link IAgentConnection} + * - Derives `IChatProgress[]` from immutable state changes in + * {@link SessionClientState} + * - Dispatches client actions (`turnStarted`, `permissionResolved`, + * `turnCancelled`) back to the server + * - Registers a dynamic chat agent via {@link IChatAgentService} + * + * Works with both local and remote connections via the {@link IAgentConnection} + * interface passed in the config. + * + * File: `workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts` + */ +interface AgentHostSessionHandler extends IChatSessionContentProvider { + provideChatSessionContent(sessionResource: URI, token: CancellationToken): Promise; +} + +/** + * Configuration for an {@link AgentHostSessionHandler} instance. + * Contains the agent identity, displayName, the connection to use, + * and optional callbacks for resolving working directories and + * interactive authentication. + * + * File: `workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts` + */ +interface IAgentHostSessionHandlerConfig { + readonly provider: AgentProvider; + readonly agentId: string; + readonly sessionType: string; + readonly fullName: string; + readonly description: string; + readonly connection: IAgentConnection; + readonly extensionId?: string; + readonly extensionDisplayName?: string; + /** Resolve a working directory for a new session (e.g. from active session's repository URI). */ + readonly resolveWorkingDirectory?: (resourceKey: string) => string | undefined; + /** Trigger interactive authentication when the server rejects with auth-required. */ + readonly resolveAuthentication?: () => Promise; +} + +/** + * Provides session list items for the chat sessions sidebar by querying + * active sessions from an agent host connection. Listens to protocol + * notifications (`notify/sessionAdded`, `notify/sessionRemoved`) for + * incremental updates, and refreshes on `session/turnComplete` actions. + * + * Works with both local and remote agent host connections via + * {@link IAgentConnection}. + * + * File: `workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts` + */ +interface AgentHostSessionListController extends IChatSessionItemController { + readonly items: readonly IChatSessionItem[]; + readonly onDidChangeChatSessionItems: Event; + refresh(token: CancellationToken): Promise; +} + +/** + * Exposes models available from the agent host process as selectable + * language models in the chat model picker. Models come from root state + * (via {@link IAgentInfo.models}) and are published with IDs prefixed + * by the session type (e.g. `remote-localhost__8081-copilot:claude-sonnet-4-20250514`). + * + * File: `workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts` + */ +interface AgentHostLanguageModelProvider extends ILanguageModelChatProvider { + /** Called when models change in root state to push updates to the model picker. */ + updateModels(models: readonly ISessionModelInfo[]): void; +} + +/** + * The local agent host contribution (for the workbench, not the sessions app). + * Discovers agents from the local agent host process and registers each one + * as a chat session type. Gated on the `chat.agentHost.enabled` setting. + * + * Uses the same shared adapters ({@link AgentHostSessionHandler}, etc.) + * but connects via {@link IAgentHostService} (MessagePort) instead of + * {@link IRemoteAgentHostService} (WebSocket). + * + * File: `workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts` + */ +interface AgentHostContribution extends IWorkbenchContribution { + // Registers per-agent: chat session contribution, session list controller, + // session handler, and language model provider - same 4 registrations + // as RemoteAgentHostContribution but for the local agent host. +} + +// ============================================================================= +// LAYER 4: Sessions App Orchestrator (sessions/contrib/remoteAgentHost/) +// ============================================================================= + +/** + * Central orchestrator for remote agent hosts in the sessions app. + * + * For each active remote connection: + * 1. Creates a {@link SessionClientState} for write-ahead reconciliation + * 2. Subscribes to `agenthost:/root` to discover available agents + * 3. For each discovered copilot agent, performs four registrations: + * - Chat session contribution (via {@link IChatSessionsService}) + * - Session list controller ({@link AgentHostSessionListController}) + * - Session content provider ({@link AgentHostSessionHandler}) + * - Language model provider ({@link AgentHostLanguageModelProvider}) + * 4. Registers authority→address mappings for the filesystem provider + * 5. Authenticates connections using RFC 9728 resource metadata + * + * Reconciles when connections change (added/removed/name changed) + * and when the default auth account or auth sessions change. + * + * File: `sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts` + */ +interface RemoteAgentHostContribution extends IWorkbenchContribution { + // Per-connection state tracked in a DisposableMap +} + +/** + * Read-only {@link IFileSystemProvider} registered under the `agenthost` + * scheme. Proxies `stat` and `readdir` calls through the agent host + * protocol's `browseDirectory` RPC. + * + * The URI authority identifies the remote connection (sanitized address), + * the URI path is the remote filesystem path. Authority-to-address mappings + * are registered by {@link RemoteAgentHostContribution} via + * `registerAuthority(authority, address)`. + * + * File: `sessions/contrib/remoteAgentHost/browser/agentHostFileSystemProvider.ts` + */ +interface AgentHostFileSystemProvider extends IFileSystemProvider { + /** Register a mapping from sanitized URI authority to remote address. */ + registerAuthority(authority: string, address: string): IDisposable; +} + +// ============================================================================= +// Naming Conventions & URI Schemes +// ============================================================================= + +/** + * Remote addresses are encoded into URI-safe authority strings: + * - `localhost:8081` → `localhost__8081` + * - `http://127.0.0.1:3000` → `b64-aHR0cDovLzEyNy4wLjAuMTozMDAw` + * + * | Context | Scheme | Example | + * |--------------------------|------------------|-------------------------------------------------| + * | Session resource (UI) | `` | `remote-localhost__8081-copilot:/untitled-abc` | + * | Backend session (server) | `` | `copilot:/abc-123` | + * | Root state subscription | (string literal) | `agenthost:/root` | + * | Remote filesystem | `agenthost` | `agenthost://localhost__8081/home/user/project` | + * | Language model ID | - | `remote-localhost__8081-copilot:claude-sonnet-4-20250514` | + * + * Session type naming: `remote-${authority}-${provider}` for remote, + * `agent-host-${provider}` for local. + */ + +// ============================================================================= +// IPC & Auth Data Types (platform/agentHost/common/agentService.ts) +// ============================================================================= + +/** Metadata describing an agent backend, discovered over IPC or root state. */ +interface IAgentDescriptor { + readonly provider: AgentProvider; + readonly displayName: string; + readonly description: string; + /** @deprecated Use IResourceMetadata from getResourceMetadata() instead. */ + readonly requiresAuth: boolean; +} + +/** Serializable model information from the agent host. */ +interface IAgentModelInfo { + readonly provider: AgentProvider; + readonly id: string; + readonly name: string; + readonly maxContextWindow: number; + readonly supportsVision: boolean; + readonly supportsReasoningEffort: boolean; +} + +/** Configuration for creating a new session. */ +interface IAgentCreateSessionConfig { + readonly provider?: AgentProvider; + readonly model?: string; + readonly session?: URI; + readonly workingDirectory?: string; +} + +/** Metadata for an existing session (returned by listSessions). */ +interface IAgentSessionMetadata { + readonly session: URI; + readonly startTime: number; + readonly modifiedTime: number; + readonly summary?: string; + readonly workingDirectory?: string; +} + +/** Serializable attachment passed alongside a message to the agent host. */ +interface IAgentAttachment { + readonly type: AttachmentType; + readonly path: string; + readonly displayName?: string; + readonly text?: string; + readonly selection?: { + readonly start: { readonly line: number; readonly character: number }; + readonly end: { readonly line: number; readonly character: number }; + }; +} + +/** + * Describes the agent host as an OAuth 2.0 protected resource (RFC 9728). + * Clients resolve tokens via the VS Code authentication service. + */ +interface IResourceMetadata { + readonly resources: readonly IAuthorizationProtectedResourceMetadata[]; +} + +/** + * Parameters for the `authenticate` command (RFC 6750 bearer token delivery). + */ +interface IAuthenticateParams { + readonly resource: string; + readonly token: string; +} + +/** + * Result of the `authenticate` command. + */ +interface IAuthenticateResult { + readonly authenticated: boolean; +} + +// ============================================================================= +// Progress Events (platform/agentHost/common/agentService.ts) +// ============================================================================= + +/** + * Discriminated union of progress events streamed from the agent host. + * The state-to-progress adapter ({@link stateToProgressAdapter.ts}) + * translates protocol state changes into `IChatProgress[]` for the chat UI, + * but these events are also used in the IPC path for the old event-based API. + * + * Types: `delta`, `message`, `idle`, `tool_start`, `tool_complete`, + * `title_changed`, `error`, `usage`, `permission_request`, `reasoning`. + */ +type IAgentProgressEvent = + | IAgentDeltaEvent + | IAgentMessageEvent + | IAgentIdleEvent + | IAgentToolStartEvent + | IAgentToolCompleteEvent + | IAgentTitleChangedEvent + | IAgentErrorEvent + | IAgentUsageEvent + | IAgentPermissionRequestEvent + | IAgentReasoningEvent; ``` - -This mode creates a `WebSocketProtocolServer` and `ProtocolServerHandler` directly without Electron. Useful for development and headless scenarios. - -### Dynamic Agent Discovery - -On startup (if the setting is enabled), `AgentHostContribution` calls `listAgents()` to discover available backends from the agent host process. Each returned `IAgentDescriptor` contains: - -| Field | Purpose | -|---|---| -| `provider` | Agent provider ID (`'copilot'`) | -| `displayName` | Human-readable name for UI | -| `description` | Description string | -| `requiresAuth` | Whether the renderer should push a GitHub auth token | - -For each descriptor, the contribution dynamically registers: -- Chat session contribution (type = `agent-host-{provider}`) -- `AgentHostSessionHandler` configured with the descriptor's metadata -- `AgentHostSessionListController` for the session sidebar -- `AgentHostLanguageModelProvider` for the model picker -- Auth token wiring (only if `requiresAuth` is true) - -### Auth Token Flow - -Only agents with `requiresAuth: true` (currently Copilot) get auth wiring: -1. On startup and on account/session changes, retrieves the GitHub OAuth token -2. Pushes it to the agent host via `IAgentHostService.setAuthToken(token)` -3. `CopilotAgent` passes it to `CopilotClient({ githubToken })` on next client creation - -### Crash Recovery - -`AgentHostProcessManager` monitors the utility process exit. On unexpected termination, it automatically restarts (up to 5 times). - -## Build / Packaging - -| File | Purpose | -|---|---| -| `build/next/index.ts` | Agent host entry point in esbuild config | -| `build/buildfile.ts` | Agent host entry point in legacy bundler config | -| `build/gulpfile.vscode.ts` | Strip wrong-arch copilot packages; ASAR unpack copilot binaries | -| `build/.moduleignore` | Strip unnecessary copilot prebuilds/ripgrep/clipboard | -| `build/darwin/create-universal-app.ts` | macOS universal binary support for copilot CLI | -| `build/darwin/verify-macho.ts` | Skip copilot binaries in Mach-O verification | - -## Closest Analogs - -| Component | Pattern | Key Difference | -|---|---|---| -| **Pty Host** | Singleton utility process, MessagePort, lazy start, crash recovery | Also has heartbeat monitoring and reconnect logic | -| **Shared Process** | Singleton utility process, MessagePort | Much heavier, hosts many services | -| **Extension Host** | Per-window utility process, custom `RPCProtocol` | Uses custom RPC, not standard channels | diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 94b2c6cb56d93..35e4358c7e3ab 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -20,6 +20,8 @@ export const enum AgentHostIpcChannels { AgentHost = 'agentHost', /** Channel for log forwarding from the agent host process */ Logger = 'agentHostLogger', + /** Channel for WebSocket client connection count (server process management only) */ + ConnectionTracker = 'agentHostConnectionTracker', } /** Configuration key that controls whether the agent host process is spawned. */ diff --git a/src/vs/platform/agentHost/node/agentHostMain.ts b/src/vs/platform/agentHost/node/agentHostMain.ts index 20df9b7216754..4ccb579401722 100644 --- a/src/vs/platform/agentHost/node/agentHostMain.ts +++ b/src/vs/platform/agentHost/node/agentHostMain.ts @@ -7,6 +7,7 @@ import { ProxyChannel } from '../../../base/parts/ipc/common/ipc.js'; import { Server as ChildProcessServer } from '../../../base/parts/ipc/node/ipc.cp.js'; import { Server as UtilityProcessServer } from '../../../base/parts/ipc/node/ipc.mp.js'; import { isUtilityProcess } from '../../../base/parts/sandbox/node/electronTypes.js'; +import { Emitter } from '../../../base/common/event.js'; import { DisposableStore } from '../../../base/common/lifecycle.js'; import { URI } from '../../../base/common/uri.js'; import * as os from 'os'; @@ -73,8 +74,18 @@ function startAgentHost(): void { const agentChannel = ProxyChannel.fromService(agentService, disposables); server.registerChannel(AgentHostIpcChannels.AgentHost, agentChannel); + // Expose the WebSocket client connection count to the parent process via IPC. + // This is NOT part of the agent host protocol -- it is only used by the + // server process to manage the agent host process lifetime. + const connectionCountEmitter = disposables.add(new Emitter()); + const connectionTrackerChannel = ProxyChannel.fromService( + { onDidChangeConnectionCount: connectionCountEmitter.event }, + disposables, + ); + server.registerChannel(AgentHostIpcChannels.ConnectionTracker, connectionTrackerChannel); + // Start WebSocket server for external clients if configured - startWebSocketServer(agentService, logService, disposables).catch(err => { + startWebSocketServer(agentService, logService, disposables, count => connectionCountEmitter.fire(count)).catch(err => { logService.error('Failed to start WebSocket server', err); }); @@ -91,7 +102,7 @@ function startAgentHost(): void { * This reuses the same {@link AgentService} and {@link SessionStateManager} * that the IPC channel uses, so both IPC and WebSocket clients share state. */ -async function startWebSocketServer(agentService: AgentService, logService: ILogService, disposables: DisposableStore): Promise { +async function startWebSocketServer(agentService: AgentService, logService: ILogService, disposables: DisposableStore, onConnectionCountChanged: (count: number) => void): Promise { const port = process.env['VSCODE_AGENT_HOST_PORT']; const socketPath = process.env['VSCODE_AGENT_HOST_SOCKET_PATH']; @@ -163,7 +174,8 @@ async function startWebSocketServer(agentService: AgentService, logService: ILog }, }; - disposables.add(new ProtocolServerHandler(agentService.stateManager, wsServer, sideEffects, logService)); + const protocolHandler = disposables.add(new ProtocolServerHandler(agentService.stateManager, wsServer, sideEffects, logService)); + disposables.add(protocolHandler.onDidChangeConnectionCount(onConnectionCountChanged)); const listenTarget = socketPath ?? `${host}:${port}`; logService.info(`[AgentHost] WebSocket server listening on ${listenTarget}`); diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index a7e1ecc5b591e..a8985739015d2 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Emitter } from '../../../base/common/event.js'; import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; import { ILogService } from '../../log/common/log.js'; import type { IAgentDescriptor, IAuthenticateParams, IAuthenticateResult, IResourceMetadata } from '../common/agentService.js'; @@ -86,6 +87,11 @@ export class ProtocolServerHandler extends Disposable { private readonly _clients = new Map(); private readonly _replayBuffer: IActionEnvelope[] = []; + private readonly _onDidChangeConnectionCount = this._register(new Emitter()); + + /** Fires with the current client count whenever a client connects or disconnects. */ + readonly onDidChangeConnectionCount = this._onDidChangeConnectionCount.event; + constructor( private readonly _stateManager: SessionStateManager, private readonly _server: IProtocolServer, @@ -171,9 +177,10 @@ export class ProtocolServerHandler extends Disposable { })); disposables.add(transport.onClose(() => { - if (client) { + if (client && this._clients.get(client.clientId) === client) { this._logService.info(`[ProtocolServer] Client disconnected: ${client.clientId}`); this._clients.delete(client.clientId); + this._onDidChangeConnectionCount.fire(this._clients.size); } disposables.dispose(); })); @@ -205,6 +212,7 @@ export class ProtocolServerHandler extends Disposable { disposables, }; this._clients.set(params.clientId, client); + this._onDidChangeConnectionCount.fire(this._clients.size); const snapshots: IStateSnapshot[] = []; if (params.initialSubscriptions) { @@ -243,6 +251,7 @@ export class ProtocolServerHandler extends Disposable { disposables, }; this._clients.set(params.clientId, client); + this._onDidChangeConnectionCount.fire(this._clients.size); const oldestBuffered = this._replayBuffer.length > 0 ? this._replayBuffer[0].serverSeq : this._stateManager.serverSeq; const canReplay = params.lastSeenServerSeq >= oldestBuffered; diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index 3eac4562547b2..d433bbdc60f59 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -124,6 +124,7 @@ suite('ProtocolServerHandler', () => { let stateManager: SessionStateManager; let server: MockProtocolServer; let sideEffects: MockSideEffectHandler; + let handler: ProtocolServerHandler; const sessionUri = URI.from({ scheme: 'copilot', path: '/test-session' }).toString(); @@ -154,7 +155,7 @@ suite('ProtocolServerHandler', () => { stateManager = disposables.add(new SessionStateManager(new NullLogService())); server = disposables.add(new MockProtocolServer()); sideEffects = new MockSideEffectHandler(); - disposables.add(new ProtocolServerHandler( + disposables.add(handler = new ProtocolServerHandler( stateManager, server, sideEffects, @@ -427,4 +428,45 @@ suite('ProtocolServerHandler', () => { sideEffects.handleAuthenticate = origHandler; }); + + // ---- Connection count event ----------------------------------------- + + test('onDidChangeConnectionCount fires on connect and disconnect', () => { + const counts: number[] = []; + disposables.add(handler.onDidChangeConnectionCount(c => counts.push(c))); + + const transport = connectClient('client-count-1'); + connectClient('client-count-2'); + transport.simulateClose(); + + assert.deepStrictEqual(counts, [1, 2, 1]); + }); + + test('onDidChangeConnectionCount is not decremented by stale reconnect close', () => { + const counts: number[] = []; + disposables.add(handler.onDidChangeConnectionCount(c => counts.push(c))); + + // Connect + const transport1 = connectClient('client-rc'); + assert.deepStrictEqual(counts, [1]); + + // Reconnect with same clientId (new transport) + const transport2 = new MockProtocolTransport(); + server.simulateConnection(transport2); + transport2.simulateMessage(request(1, 'reconnect', { + clientId: 'client-rc', + lastSeenServerSeq: 0, + subscriptions: [], + })); + // Count is unchanged because same clientId was overwritten + assert.deepStrictEqual(counts, [1, 1]); + + // Old transport closes - should NOT decrement since it's stale + transport1.simulateClose(); + assert.deepStrictEqual(counts, [1, 1]); + + // New transport closes - should decrement + transport2.simulateClose(); + assert.deepStrictEqual(counts, [1, 1, 0]); + }); }); diff --git a/src/vs/server/node/serverAgentHostManager.ts b/src/vs/server/node/serverAgentHostManager.ts index 1c3cc424e2f24..dff86352208c4 100644 --- a/src/vs/server/node/serverAgentHostManager.ts +++ b/src/vs/server/node/serverAgentHostManager.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Event } from '../../base/common/event.js'; import { Disposable, MutableDisposable, toDisposable } from '../../base/common/lifecycle.js'; import { ProxyChannel } from '../../base/parts/ipc/common/ipc.js'; import { IAgentHostConnection, IAgentHostStarter } from '../../platform/agentHost/common/agent.js'; @@ -16,14 +17,26 @@ export const IServerAgentHostManager = createDecorator( /** * Server-specific agent host manager. Eagerly starts the agent host process, - * handles crash recovery, and tracks active agent sessions via - * {@link IServerLifetimeService} to keep the server alive while work is - * in progress. + * handles crash recovery, and tracks both active agent sessions and connected + * WebSocket clients via {@link IServerLifetimeService} to keep the server + * alive while either signal is active. + * + * The lifetime token is held when active sessions > 0 OR connected clients > 0. + * It is released only when both are zero. */ export interface IServerAgentHostManager { readonly _serviceBrand: undefined; } +/** + * Proxy interface for the connection tracker IPC channel exposed by the agent + * host process. This is NOT part of the agent host protocol -- it is a + * server-only process-management concern. + */ +interface IConnectionTrackerService { + readonly onDidChangeConnectionCount: Event; +} + enum Constants { MaxRestarts = 5, } @@ -33,9 +46,12 @@ export class ServerAgentHostManager extends Disposable implements IServerAgentHo private _restartCount = 0; - /** Lifetime token for when agent sessions are active. */ + /** Lifetime token held while sessions are active or clients are connected. */ private readonly _lifetimeToken = this._register(new MutableDisposable()); + private _hasActiveSessions = false; + private _connectionCount = 0; + constructor( private readonly _starter: IAgentHostStarter, @ILogService private readonly _logService: ILogService, @@ -53,14 +69,17 @@ export class ServerAgentHostManager extends Disposable implements IServerAgentHo this._logService.info('ServerAgentHostManager: agent host started'); // Connect logger channel so agent host logs appear in the output channel - this._register(new RemoteLoggerChannelClient(this._loggerService, connection.client.getChannel(AgentHostIpcChannels.Logger))); + connection.store.add(new RemoteLoggerChannelClient(this._loggerService, connection.client.getChannel(AgentHostIpcChannels.Logger))); this._trackActiveSessions(connection); + this._trackClientConnections(connection); // Handle unexpected exit - this._register(connection.onDidProcessExit(e => { + connection.store.add(connection.onDidProcessExit(e => { if (!this._store.isDisposed) { - // Sessions are gone when the process exits + // Both signals are gone when the process exits + this._hasActiveSessions = false; + this._connectionCount = 0; this._lifetimeToken.clear(); if (this._restartCount <= Constants.MaxRestarts) { @@ -79,14 +98,27 @@ export class ServerAgentHostManager extends Disposable implements IServerAgentHo private _trackActiveSessions(connection: IAgentHostConnection): void { const agentService = ProxyChannel.toService(connection.client.getChannel(AgentHostIpcChannels.AgentHost)); - this._register(agentService.onDidAction(envelope => { + connection.store.add(agentService.onDidAction(envelope => { if (envelope.action.type === 'root/activeSessionsChanged') { - if (envelope.action.activeSessions > 0) { - this._lifetimeToken.value ??= this._serverLifetimeService.active('AgentSession'); - } else { - this._lifetimeToken.clear(); - } + this._hasActiveSessions = envelope.action.activeSessions > 0; + this._updateLifetimeToken(); } })); } + + private _trackClientConnections(connection: IAgentHostConnection): void { + const connectionTracker = ProxyChannel.toService(connection.client.getChannel(AgentHostIpcChannels.ConnectionTracker)); + connection.store.add(connectionTracker.onDidChangeConnectionCount(count => { + this._connectionCount = count; + this._updateLifetimeToken(); + })); + } + + private _updateLifetimeToken(): void { + if (this._hasActiveSessions || this._connectionCount > 0) { + this._lifetimeToken.value ??= this._serverLifetimeService.active('AgentHost'); + } else { + this._lifetimeToken.clear(); + } + } } diff --git a/src/vs/server/test/node/serverAgentHostManager.test.ts b/src/vs/server/test/node/serverAgentHostManager.test.ts new file mode 100644 index 0000000000000..81ec4b695e631 --- /dev/null +++ b/src/vs/server/test/node/serverAgentHostManager.test.ts @@ -0,0 +1,205 @@ +/*--------------------------------------------------------------------------------------------- + * 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, IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../base/test/common/utils.js'; +import { IChannel, IChannelClient } from '../../../base/parts/ipc/common/ipc.js'; +import { IAgentHostConnection, IAgentHostStarter } from '../../../platform/agentHost/common/agent.js'; +import { AgentHostIpcChannels } from '../../../platform/agentHost/common/agentService.js'; +import { NullLogService, NullLoggerService } from '../../../platform/log/common/log.js'; +import { ServerAgentHostManager } from '../../node/serverAgentHostManager.js'; +import { IServerLifetimeService } from '../../node/serverLifetimeService.js'; + +// ---- Mock helpers ----------------------------------------------------------- + +class MockChannel implements IChannel { + private readonly _listeners = new Map>(); + private readonly _callResults = new Map(); + + getEmitter(event: string): Emitter { + let emitter = this._listeners.get(event); + if (!emitter) { + emitter = new Emitter(); + this._listeners.set(event, emitter); + } + return emitter; + } + + setCallResult(command: string, value: unknown): void { + this._callResults.set(command, value); + } + + call(command: string, _arg?: unknown): Promise { + return Promise.resolve((this._callResults.get(command) ?? undefined) as T); + } + + listen(event: string, _arg?: unknown): Event { + return this.getEmitter(event).event as Event; + } + + dispose(): void { + for (const emitter of this._listeners.values()) { + emitter.dispose(); + } + this._listeners.clear(); + } +} + +class MockAgentHostStarter implements IAgentHostStarter { + private readonly _onDidProcessExit = new Emitter<{ code: number; signal: string }>(); + + readonly agentHostChannel = new MockChannel(); + readonly loggerChannel: MockChannel; + readonly connectionTrackerChannel = new MockChannel(); + + constructor() { + this.loggerChannel = new MockChannel(); + this.loggerChannel.setCallResult('getRegisteredLoggers', []); + } + + start(): IAgentHostConnection { + const store = new DisposableStore(); + const client: IChannelClient = { + getChannel: (name: string): T => { + switch (name) { + case AgentHostIpcChannels.AgentHost: + return this.agentHostChannel as unknown as T; + case AgentHostIpcChannels.Logger: + return this.loggerChannel as unknown as T; + case AgentHostIpcChannels.ConnectionTracker: + return this.connectionTrackerChannel as unknown as T; + default: + throw new Error(`Unknown channel: ${name}`); + } + }, + }; + return { + client, + store, + onDidProcessExit: this._onDidProcessExit.event, + }; + } + + fireProcessExit(code: number): void { + this._onDidProcessExit.fire({ code, signal: '' }); + } + + dispose(): void { + this._onDidProcessExit.dispose(); + this.agentHostChannel.dispose(); + this.loggerChannel.dispose(); + this.connectionTrackerChannel.dispose(); + } +} + +class MockServerLifetimeService implements IServerLifetimeService { + declare readonly _serviceBrand: undefined; + + private _activeCount = 0; + + get hasActiveConsumers(): boolean { + return this._activeCount > 0; + } + + active(_consumer: string): IDisposable { + this._activeCount++; + return toDisposable(() => { this._activeCount--; }); + } + + delay(): void { } +} + +suite('ServerAgentHostManager', () => { + const ds = ensureNoDisposablesAreLeakedInTestSuite(); + + let starter: MockAgentHostStarter; + let lifetimeService: MockServerLifetimeService; + + setup(() => { + starter = new MockAgentHostStarter(); + lifetimeService = new MockServerLifetimeService(); + }); + + function createManager(): ServerAgentHostManager { + return ds.add(new ServerAgentHostManager( + starter, + new NullLogService(), + ds.add(new NullLoggerService()), + lifetimeService, + )); + } + + function fireActiveSessions(count: number): void { + starter.agentHostChannel.getEmitter('onDidAction').fire({ + action: { type: 'root/activeSessionsChanged', activeSessions: count }, + serverSeq: 1, + origin: undefined, + }); + } + + function fireConnectionCount(count: number): void { + starter.connectionTrackerChannel.getEmitter('onDidChangeConnectionCount').fire(count); + } + + test('no lifetime token initially', () => { + createManager(); + assert.strictEqual(lifetimeService.hasActiveConsumers, false); + }); + + test('acquires token when sessions become active', () => { + createManager(); + fireActiveSessions(1); + assert.strictEqual(lifetimeService.hasActiveConsumers, true); + }); + + test('acquires token when clients connect (no active sessions)', () => { + createManager(); + fireConnectionCount(2); + assert.strictEqual(lifetimeService.hasActiveConsumers, true); + }); + + test('releases token only when both sessions and connections are zero', () => { + createManager(); + + // Sessions active, no connections + fireActiveSessions(1); + assert.strictEqual(lifetimeService.hasActiveConsumers, true); + + // Connections appear too + fireConnectionCount(1); + assert.strictEqual(lifetimeService.hasActiveConsumers, true); + + // Sessions go idle, but connections remain + fireActiveSessions(0); + assert.strictEqual(lifetimeService.hasActiveConsumers, true); + + // Connections drop to zero -- now both are idle + fireConnectionCount(0); + assert.strictEqual(lifetimeService.hasActiveConsumers, false); + }); + + test('releases token only when connections drop after sessions already idle', () => { + createManager(); + + fireConnectionCount(3); + assert.strictEqual(lifetimeService.hasActiveConsumers, true); + + fireConnectionCount(0); + assert.strictEqual(lifetimeService.hasActiveConsumers, false); + }); + + test('process exit resets both signals and clears token', () => { + createManager(); + + fireActiveSessions(2); + fireConnectionCount(1); + assert.strictEqual(lifetimeService.hasActiveConsumers, true); + + starter.fireProcessExit(1); + assert.strictEqual(lifetimeService.hasActiveConsumers, false); + }); +}); From f2597ce29be87dec86793531bfe866e25e10fa07 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Mon, 23 Mar 2026 18:29:24 -0700 Subject: [PATCH 28/33] fix test --- .../browser/accessibility/chatResponseAccessibleView.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/chat/test/browser/accessibility/chatResponseAccessibleView.test.ts b/src/vs/workbench/contrib/chat/test/browser/accessibility/chatResponseAccessibleView.test.ts index b4ad1e4bf36a6..1f972250457d7 100644 --- a/src/vs/workbench/contrib/chat/test/browser/accessibility/chatResponseAccessibleView.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/accessibility/chatResponseAccessibleView.test.ts @@ -594,7 +594,7 @@ suite('ChatResponseAccessibleView', () => { store.add(provider); const content = provider.provideContent(); assert.ok(content.includes('main.ts')); - assert.ok(content.includes('/src/app/main.ts:42')); + assert.ok(content.replaceAll('\\', '/').includes('src/app/main.ts:42')); }); test('uses basename as name for URI inline references without explicit name', () => { @@ -643,7 +643,7 @@ suite('ChatResponseAccessibleView', () => { store.add(provider); const content = provider.provideContent(); assert.ok(content.includes('utils.ts')); - assert.ok(content.includes('/workspace/src/utils.ts')); + assert.ok(content.replaceAll('\\', '/').includes('workspace/src/utils.ts')); }); }); }); From f6558461a3474793021037fd2b3d43d1ff4e1421 Mon Sep 17 00:00:00 2001 From: Alexandru Dima Date: Tue, 24 Mar 2026 02:40:57 +0100 Subject: [PATCH 29/33] Revert "Accessible View: include file paths for inline references in chat responses" (#304331) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert "Accessible View: include file paths for inline references in chat res…" This reverts commit 29359eff779965205e489587261e5ff3c632d43b. --- .../chatResponseAccessibleView.ts | 26 +-- .../chatResponseAccessibleView.test.ts | 159 ------------------ 2 files changed, 1 insertion(+), 184 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts b/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts index e183775db5cae..f20aabab9961d 100644 --- a/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts +++ b/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts @@ -8,7 +8,6 @@ import { Emitter, Event } from '../../../../../base/common/event.js'; import { IMarkdownString, isMarkdownString } from '../../../../../base/common/htmlContent.js'; import { stripIcons } from '../../../../../base/common/iconLabels.js'; import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; -import { basename } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; import { AccessibleViewProviderId, AccessibleViewType, IAccessibleViewContentProvider } from '../../../../../platform/accessibility/browser/accessibleView.js'; @@ -22,7 +21,7 @@ import { IChatExtensionsContent, IChatModifiedFilesConfirmationData, IChatPullRe import { isResponseVM } from '../../common/model/chatViewModel.js'; import { IToolResultInputOutputDetails, IToolResultOutputDetails, isToolResultInputOutputDetails, isToolResultOutputDetails, toolContentToA11yString } from '../../common/tools/languageModelToolsService.js'; import { ChatTreeItem, IChatWidget, IChatWidgetService } from '../chat.js'; -import { isLocation, Location } from '../../../../../editor/common/languages.js'; +import { Location } from '../../../../../editor/common/languages.js'; export class ChatResponseAccessibleView implements IAccessibleViewImplementation { readonly priority = 100; @@ -300,29 +299,6 @@ class ChatResponseAccessibleProvider extends Disposable implements IAccessibleVi } break; } - case 'inlineReference': { - const ref = part.inlineReference; - let text: string; - if (URI.isUri(ref)) { - const name = part.name || basename(ref); - const isFileUri = ref.scheme === 'file'; - const path = isFileUri ? (ref.fsPath || ref.path) : ref.toString(true); - text = name !== path ? `${name} (${path})` : path; - } else if (isLocation(ref)) { - const name = part.name || basename(ref.uri); - const isFileUri = ref.uri.scheme === 'file'; - const basePath = isFileUri ? (ref.uri.fsPath || ref.uri.path) : ref.uri.toString(true); - const location = `${basePath}:${ref.range.startLineNumber}`; - text = `${name} (${location})`; - } else { - // IWorkspaceSymbol - const isFileUri = ref.location.uri.scheme === 'file'; - const basePath = isFileUri ? (ref.location.uri.fsPath || ref.location.uri.path) : ref.location.uri.toString(true); - text = `${ref.name} (${basePath}:${ref.location.range.startLineNumber})`; - } - contentParts.push(text); - break; - } case 'elicitation2': case 'elicitationSerialized': { const title = part.title; diff --git a/src/vs/workbench/contrib/chat/test/browser/accessibility/chatResponseAccessibleView.test.ts b/src/vs/workbench/contrib/chat/test/browser/accessibility/chatResponseAccessibleView.test.ts index b4ad1e4bf36a6..ba7821dd3585a 100644 --- a/src/vs/workbench/contrib/chat/test/browser/accessibility/chatResponseAccessibleView.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/accessibility/chatResponseAccessibleView.test.ts @@ -486,164 +486,5 @@ suite('ChatResponseAccessibleView', () => { assert.ok(content.includes('Response content')); assert.ok(content.includes('Thinking: Reasoning')); }); - - test('includes file path for URI inline references', () => { - const instantiationService = store.add(new TestInstantiationService()); - const storageService = store.add(new TestStorageService()); - - const inlineReferenceUri = URI.file('/path/to/index.ts'); - const responseItem = { - response: { - value: [ - { kind: 'markdownContent', content: new MarkdownString('See file ') }, - { kind: 'inlineReference', inlineReference: inlineReferenceUri, name: 'index.ts' }, - { kind: 'markdownContent', content: new MarkdownString(' for details') } - ] - }, - model: { onDidChange: Event.None }, - setVote: () => undefined - }; - const items = [responseItem]; - let focusedItem: unknown = responseItem; - - const widget = { - hasInputFocus: () => false, - focusResponseItem: () => { focusedItem = responseItem; }, - getFocus: () => focusedItem, - focus: (item: unknown) => { focusedItem = item; }, - viewModel: { getItems: () => items } - } as unknown as IChatWidget; - - const widgetService = { - _serviceBrand: undefined, - lastFocusedWidget: widget, - onDidAddWidget: Event.None, - onDidBackgroundSession: Event.None, - reveal: async () => true, - revealWidget: async () => widget, - getAllWidgets: () => [widget], - getWidgetByInputUri: () => widget, - openSession: async () => widget, - getWidgetBySessionResource: () => widget - } as unknown as IChatWidgetService; - - instantiationService.stub(IChatWidgetService, widgetService); - instantiationService.stub(IStorageService, storageService); - - const accessibleView = new ChatResponseAccessibleView(); - const provider = instantiationService.invokeFunction(accessor => accessibleView.getProvider(accessor)); - assert.ok(provider); - store.add(provider); - const content = provider.provideContent(); - const expectedPath = inlineReferenceUri.fsPath || inlineReferenceUri.path; - assert.ok(content.includes('index.ts')); - assert.ok(content.includes(expectedPath)); - assert.ok(content.includes('See file')); - assert.ok(content.includes('for details')); - }); - - test('includes file path and line number for Location inline references', () => { - const instantiationService = store.add(new TestInstantiationService()); - const storageService = store.add(new TestStorageService()); - - const fileLocation: Location = { - uri: URI.file('/src/app/main.ts'), - range: new Range(42, 1, 42, 20) - }; - - const responseItem = { - response: { - value: [ - { kind: 'markdownContent', content: new MarkdownString('Error at ') }, - { kind: 'inlineReference', inlineReference: fileLocation, name: 'main.ts' } - ] - }, - model: { onDidChange: Event.None }, - setVote: () => undefined - }; - const items = [responseItem]; - let focusedItem: unknown = responseItem; - - const widget = { - hasInputFocus: () => false, - focusResponseItem: () => { focusedItem = responseItem; }, - getFocus: () => focusedItem, - focus: (item: unknown) => { focusedItem = item; }, - viewModel: { getItems: () => items } - } as unknown as IChatWidget; - - const widgetService = { - _serviceBrand: undefined, - lastFocusedWidget: widget, - onDidAddWidget: Event.None, - onDidBackgroundSession: Event.None, - reveal: async () => true, - revealWidget: async () => widget, - getAllWidgets: () => [widget], - getWidgetByInputUri: () => widget, - openSession: async () => widget, - getWidgetBySessionResource: () => widget - } as unknown as IChatWidgetService; - - instantiationService.stub(IChatWidgetService, widgetService); - instantiationService.stub(IStorageService, storageService); - - const accessibleView = new ChatResponseAccessibleView(); - const provider = instantiationService.invokeFunction(accessor => accessibleView.getProvider(accessor)); - assert.ok(provider); - store.add(provider); - const content = provider.provideContent(); - assert.ok(content.includes('main.ts')); - assert.ok(content.includes('/src/app/main.ts:42')); - }); - - test('uses basename as name for URI inline references without explicit name', () => { - const instantiationService = store.add(new TestInstantiationService()); - const storageService = store.add(new TestStorageService()); - - const responseItem = { - response: { - value: [ - { kind: 'inlineReference', inlineReference: URI.file('/workspace/src/utils.ts') } - ] - }, - model: { onDidChange: Event.None }, - setVote: () => undefined - }; - const items = [responseItem]; - let focusedItem: unknown = responseItem; - - const widget = { - hasInputFocus: () => false, - focusResponseItem: () => { focusedItem = responseItem; }, - getFocus: () => focusedItem, - focus: (item: unknown) => { focusedItem = item; }, - viewModel: { getItems: () => items } - } as unknown as IChatWidget; - - const widgetService = { - _serviceBrand: undefined, - lastFocusedWidget: widget, - onDidAddWidget: Event.None, - onDidBackgroundSession: Event.None, - reveal: async () => true, - revealWidget: async () => widget, - getAllWidgets: () => [widget], - getWidgetByInputUri: () => widget, - openSession: async () => widget, - getWidgetBySessionResource: () => widget - } as unknown as IChatWidgetService; - - instantiationService.stub(IChatWidgetService, widgetService); - instantiationService.stub(IStorageService, storageService); - - const accessibleView = new ChatResponseAccessibleView(); - const provider = instantiationService.invokeFunction(accessor => accessibleView.getProvider(accessor)); - assert.ok(provider); - store.add(provider); - const content = provider.provideContent(); - assert.ok(content.includes('utils.ts')); - assert.ok(content.includes('/workspace/src/utils.ts')); - }); }); }); From c82bc515c2572b028f991a991b74d3f2fac383a3 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Mon, 23 Mar 2026 20:14:44 -0700 Subject: [PATCH 30/33] More specific log string for cli agent host proxy (#304339) --- cli/src/commands/agent_host.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cli/src/commands/agent_host.rs b/cli/src/commands/agent_host.rs index 955e13f7c6876..a5291281ba06e 100644 --- a/cli/src/commands/agent_host.rs +++ b/cli/src/commands/agent_host.rs @@ -104,7 +104,8 @@ pub async fn agent_host(ctx: CommandContext, mut args: AgentHostArgs) -> Result< if let Some(ct) = &args.connection_token { url.push_str(&format!("?tkn={ct}")); } - ctx.log.result(format!("Listening on {url}")); + ctx.log + .result(format!("Agent host proxy listening on {url}")); let manager_for_svc = manager.clone(); let make_svc = move || { From efb9c8bab877667443e382913f034664ec2b3857 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Mon, 23 Mar 2026 20:15:12 -0700 Subject: [PATCH 31/33] Remove outdated docs/instructions for remote agent host (#304341) --- .../remoteAgentHost.instructions.md | 35 - src/vs/platform/agentHost/architecture.md | 762 ------------------ .../contrib/remoteAgentHost/ARCHITECTURE.md | 328 -------- 3 files changed, 1125 deletions(-) delete mode 100644 .github/instructions/remoteAgentHost.instructions.md delete mode 100644 src/vs/platform/agentHost/architecture.md delete mode 100644 src/vs/sessions/contrib/remoteAgentHost/ARCHITECTURE.md diff --git a/.github/instructions/remoteAgentHost.instructions.md b/.github/instructions/remoteAgentHost.instructions.md deleted file mode 100644 index aa3f24b290eda..0000000000000 --- a/.github/instructions/remoteAgentHost.instructions.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -description: Architecture documentation for remote agent host connections. Use when working in `src/vs/sessions/contrib/remoteAgentHost` -applyTo: src/vs/sessions/contrib/remoteAgentHost/** ---- - -# Remote Agent Host - -The remote agent host feature connects the sessions app to agent host processes running on other machines over WebSocket. - -## Key Files - -- `ARCHITECTURE.md` - full architecture documentation (URI conventions, registration flow, data flow diagram) -- `REMOTE_AGENT_HOST_RECONNECTION.md` - reconnection lifecycle spec (15 numbered requirements) -- `browser/remoteAgentHost.contribution.ts` - central orchestrator -- `browser/agentHostFileSystemProvider.ts` - read-only FS provider for remote browsing - -## Architecture Documentation - -When making changes to this feature area, **review and update `ARCHITECTURE.md`** if your changes affect: - -- Connection lifecycle (connect, disconnect, reconnect) -- Agent registration flow -- URI conventions or naming -- Session creation flow -- The data flow diagram - -The doc lives at `src/vs/sessions/contrib/remoteAgentHost/ARCHITECTURE.md`. - -## Related Code Outside This Folder - -- `src/vs/platform/agentHost/common/remoteAgentHostService.ts` - service interface (`IRemoteAgentHostService`) -- `src/vs/platform/agentHost/electron-browser/remoteAgentHostServiceImpl.ts` - Electron implementation -- `src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts` - WebSocket protocol client -- `src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts` - session list sidebar -- `src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts` - session content provider diff --git a/src/vs/platform/agentHost/architecture.md b/src/vs/platform/agentHost/architecture.md deleted file mode 100644 index f5bfec457e9b6..0000000000000 --- a/src/vs/platform/agentHost/architecture.md +++ /dev/null @@ -1,762 +0,0 @@ -# Remote Agent Host - Architecture Reference - -This file describes the key types in the remote agent host system, from the -agent host process itself up through the sessions app integration layer. - -The system has four layers: - -1. **Agent host process** (`platform/agentHost/node/`) - The utility process that hosts agent backends (e.g. Copilot SDK). - Owns the authoritative state tree and dispatches to IAgent providers. - -2. **Platform services** (`platform/agentHost/common/`, `electron-browser/`) - Service interfaces and IPC plumbing that expose the agent host to the - renderer. Local connections use MessagePort; remote ones use WebSocket. - -3. **Workbench contributions** (`workbench/contrib/chat/browser/agentSessions/agentHost/`) - Shared UI adapters that bridge the agent host protocol with the chat UI: - session handlers, session list controllers, language model providers, and - state-to-progress adapters. Used by both local and remote agent hosts. - -4. **Sessions app orchestrator** (`sessions/contrib/remoteAgentHost/`) - The contribution that discovers remote agent hosts, dynamically registers - them as chat session types, and provides the remote filesystem provider. - -``` -┌───────────────────────────────────────────────────────────────────────────┐ -│ Sessions App (Layer 4) │ -│ RemoteAgentHostContribution │ -│ per-connection → SessionClientState + agent registrations │ -│ AgentHostFileSystemProvider (agenthost:// scheme) │ -├──────────────────────────────────────┬────────────────────────────────────┤ -│ Workbench Contributions (3) │ Workbench Contributions (3) │ -│ AgentHostContribution (local) │ (shared adapters) │ -│ SessionClientState │ AgentHostSessionHandler │ -│ per-agent registrations │ AgentHostSessionListController │ -│ │ AgentHostLanguageModelProvider │ -├──────────────────────────────────────┴────────────────────────────────────┤ -│ Platform Services (Layer 2) │ -│ IAgentHostService (local, MessagePort) │ -│ IRemoteAgentHostService (remote, WebSocket) │ -│ └─ both implement IAgentConnection │ -├───────────────────────────────────────────────────────────────────────────┤ -│ Agent Host Process (Layer 1) │ -│ AgentService → SessionStateManager → IAgent (Copilot SDK) │ -│ ProtocolServerHandler (WebSocket protocol bridge) │ -│ AgentSideEffects (action dispatch + progress event routing) │ -└───────────────────────────────────────────────────────────────────────────┘ -``` - -```typescript - -// ============================================================================= -// LAYER 1: Agent Host Process (platform/agentHost/node/) -// ============================================================================= - -/** - * Implemented by each agent backend (e.g. the Copilot SDK wrapper). - * The agent host process can host multiple providers, though currently - * only `copilot` is supported. - * - * Registered with {@link AgentService.registerProvider}. Provider progress - * events are wired to the state manager through {@link AgentSideEffects}. - * - * File: `platform/agentHost/common/agentService.ts` - */ -interface IAgent { - /** Unique provider identifier (e.g. `'copilot'`). */ - readonly id: AgentProvider; - /** Fires when the provider streams progress for a session. */ - readonly onDidSessionProgress: Event; - createSession(config?: IAgentCreateSessionConfig): Promise; - sendMessage(session: URI, prompt: string, attachments?: IAgentAttachment[]): Promise; - getSessionMessages(session: URI): Promise; - disposeSession(session: URI): Promise; - abortSession(session: URI): Promise; - changeModel(session: URI, model: string): Promise; - respondToPermissionRequest(requestId: string, approved: boolean): void; - getDescriptor(): IAgentDescriptor; - listModels(): Promise; - listSessions(): Promise; - getProtectedResources(): IAuthorizationProtectedResourceMetadata[]; - authenticate(resource: string, token: string): Promise; - shutdown(): Promise; - dispose(): void; -} - -/** - * The agent service implementation that runs inside the agent host utility - * process. Dispatches to registered {@link IAgent} providers based on the - * provider identifier in the session URI scheme. - * - * Owns the {@link SessionStateManager} (authoritative state tree) and - * {@link AgentSideEffects} (action routing + progress event mapping). - * - * When `VSCODE_AGENT_HOST_PORT` is set, the process also starts a - * {@link ProtocolServerHandler} over WebSocket for external clients. - * - * File: `platform/agentHost/node/agentService.ts` - */ -interface AgentService extends IAgentService { - /** Exposes the state manager for co-hosting a WebSocket protocol server. */ - readonly stateManager: SessionStateManager; - /** Register a new agent backend provider. */ - registerProvider(provider: IAgent): void; -} - -/** - * Server-side authoritative state manager for the sessions process protocol. - * - * Maintains the root state (agent list + active session count) and per-session - * state trees. Applies actions through pure reducers, assigns monotonic - * sequence numbers, and emits {@link IActionEnvelope}s for subscribed clients. - * - * Consumed by both the IPC proxy (for local clients) and - * {@link ProtocolServerHandler} (for WebSocket clients). Both paths share - * the same state, so local and remote clients see identical state. - * - * File: `platform/agentHost/node/sessionStateManager.ts` - */ -interface SessionStateManager { - readonly rootState: IRootState; - readonly serverSeq: number; - readonly onDidEmitEnvelope: Event; - readonly onDidEmitNotification: Event; - getSessionState(session: string): ISessionState | undefined; - getSnapshot(resource: string): IStateSnapshot | undefined; - createSession(summary: ISessionSummary): ISessionState; - removeSession(session: string): void; - applyAction(action: IStateAction, origin: IActionOrigin): void; -} - -/** - * Shared side-effect handler that routes client-dispatched actions to the - * correct {@link IAgent} backend, handles session create/dispose/list - * operations, and wires agent progress events to the state manager. - * - * Also implements {@link IProtocolSideEffectHandler} so the WebSocket - * {@link ProtocolServerHandler} can delegate side effects to the same logic. - * - * File: `platform/agentHost/node/agentSideEffects.ts` - */ -interface AgentSideEffects extends IProtocolSideEffectHandler { - /** Connects an IAgent's progress events to the state manager. */ - registerProgressListener(provider: IAgent): IDisposable; -} - -/** - * Server-side protocol handler for WebSocket clients. Routes JSON-RPC - * messages to the {@link SessionStateManager}, manages client subscriptions, - * and broadcasts action envelopes to subscribed clients. - * - * Handles the initialize/reconnect handshake, subscribe/unsubscribe, - * dispatchAction, createSession, disposeSession, and browseDirectory commands. - * - * Exposes {@link onDidChangeConnectionCount} so the server process can - * track how many external clients are connected (used by - * {@link ServerAgentHostManager} for lifetime management). - * - * File: `platform/agentHost/node/protocolServerHandler.ts` - */ -interface ProtocolServerHandler { - /** Fires with the current client count when a client connects or disconnects. */ - readonly onDidChangeConnectionCount: Event; -} - -/** - * Side-effect handler interface for protocol commands that require - * business logic beyond pure state management. Implemented by - * {@link AgentSideEffects} and consumed by {@link ProtocolServerHandler}. - * - * File: `platform/agentHost/node/protocolServerHandler.ts` - */ -interface IProtocolSideEffectHandler { - handleAction(action: ISessionAction): void; - handleCreateSession(command: ICreateSessionParams): Promise; - handleDisposeSession(session: string): void; - handleListSessions(): Promise; - handleGetResourceMetadata(): IResourceMetadata; - handleAuthenticate(params: IAuthenticateParams): Promise; - handleBrowseDirectory(uri: string): Promise; - getDefaultDirectory(): string; -} - -/** - * Main-process service that manages the agent host utility process lifecycle: - * lazy start on first connection request, crash recovery (up to 5 restarts), - * and logger channel forwarding. - * - * The renderer communicates with the utility process directly via MessagePort; - * this class does not relay any agent service calls. - * - * File: `platform/agentHost/node/agentHostService.ts` - */ -interface AgentHostProcessManager { - // Internal lifecycle management - start, restart, logger forwarding. -} - -/** - * Server-specific agent host manager. Eagerly starts the agent host process, - * handles crash recovery, and tracks both active agent sessions and connected - * WebSocket clients via {@link IServerLifetimeService} to keep the server - * alive while either signal is active. - * - * The lifetime token is held when: - * - there are active agent sessions (turns in progress), OR - * - there are WebSocket clients connected to the agent host - * - * The token is released (allowing server auto-shutdown) only when both - * active sessions = 0 AND connected clients = 0. - * - * Session count comes from `root/activeSessionsChanged` actions via - * {@link IAgentService.onDidAction}. Client connection count comes from - * a separate IPC channel ({@link AgentHostIpcChannels.ConnectionTracker}) - * that is not part of the agent host protocol -- it is a server-only - * process-management concern. - * - * File: `server/node/serverAgentHostManager.ts` - */ -interface ServerAgentHostManager { - // Tracks _hasActiveSessions + _connectionCount, updates lifetime token - // when either changes. -} - -/** - * Abstracts the utility process creation so the same lifecycle management - * works for both Electron utility processes and Node child processes. - * - * File: `platform/agentHost/common/agent.ts` - */ -interface IAgentHostStarter extends IDisposable { - readonly onRequestConnection?: Event; - readonly onWillShutdown?: Event; - /** Creates the agent host process and connects to it. */ - start(): IAgentHostConnection; -} - -/** - * The connection returned by {@link IAgentHostStarter.start}. Provides - * an IPC channel client and process exit events. - * - * File: `platform/agentHost/common/agent.ts` - */ -interface IAgentHostConnection { - readonly client: IChannelClient; - readonly store: DisposableStore; - readonly onDidProcessExit: Event<{ code: number; signal: string }>; -} - -// ============================================================================= -// LAYER 2: Platform Services (platform/agentHost/common/ & electron-browser/) -// ============================================================================= - -/** - * Core protocol surface for communicating with an agent host. Methods are - * proxied across MessagePort (local) or implemented over WebSocket (remote). - * - * State synchronization uses the subscribe/unsubscribe/dispatchAction pattern. - * Clients observe root state (discovered agents, models) and session state - * via subscriptions, and mutate state by dispatching actions (e.g. - * `session/turnStarted`, `session/turnCancelled`). - * - * File: `platform/agentHost/common/agentService.ts` - */ -interface IAgentService { - listAgents(): Promise; - getResourceMetadata(): Promise; - authenticate(params: IAuthenticateParams): Promise; - refreshModels(): Promise; - listSessions(): Promise; - createSession(config?: IAgentCreateSessionConfig): Promise; - disposeSession(session: URI): Promise; - shutdown(): Promise; - - // ---- Protocol methods ---- - subscribe(resource: URI): Promise; - unsubscribe(resource: URI): void; - readonly onDidAction: Event; - readonly onDidNotification: Event; - dispatchAction(action: ISessionAction, clientId: string, clientSeq: number): void; - browseDirectory(uri: URI): Promise; -} - -/** - * A concrete connection to an agent host - local utility process or remote - * WebSocket. Extends {@link IAgentService} with a `clientId` used for - * write-ahead reconciliation of optimistic actions. - * - * Both {@link IAgentHostService} (local) and per-connection objects from - * {@link IRemoteAgentHostService} (remote) satisfy this contract. The - * workbench contributions ({@link AgentHostSessionHandler}, etc.) program - * against this single interface. - * - * File: `platform/agentHost/common/agentService.ts` - */ -interface IAgentConnection extends IAgentService { - /** Unique client identifier, used as origin in action envelopes. */ - readonly clientId: string; -} - -/** - * The local agent host service - wraps the utility process connection and - * provides lifecycle events. The renderer talks to the utility process - * directly via MessagePort using ProxyChannel. - * - * Registered as a singleton service. Also implements {@link IAgentConnection} - * so it can be used interchangeably with remote connections. - * - * File: `platform/agentHost/common/agentService.ts` (interface) - * File: `platform/agentHost/electron-browser/agentHostService.ts` (implementation) - */ -interface IAgentHostService extends IAgentConnection { - readonly onAgentHostExit: Event; - readonly onAgentHostStart: Event; - restartAgentHost(): Promise; -} - -/** - * Manages connections to one or more remote agent host processes over - * WebSocket. Each connection is identified by its address string (from the - * `chat.remoteAgentHosts` setting) and exposed as an {@link IAgentConnection}. - * - * The implementation reads the setting, creates a - * {@link RemoteAgentHostProtocolClient} per address, reconnects when the - * setting changes, and fires `onDidChangeConnections` when connections are - * established or lost. - * - * File: `platform/agentHost/common/remoteAgentHostService.ts` (interface) - * File: `platform/agentHost/electron-browser/remoteAgentHostServiceImpl.ts` (implementation) - */ -interface IRemoteAgentHostService { - readonly onDidChangeConnections: Event; - readonly connections: readonly IRemoteAgentHostConnectionInfo[]; - getConnection(address: string): IAgentConnection | undefined; -} - -/** - * Metadata about a single remote connection - address, friendly name, - * client ID from the handshake, and the remote machine's home directory. - * - * File: `platform/agentHost/common/remoteAgentHostService.ts` - */ -interface IRemoteAgentHostConnectionInfo { - readonly address: string; - readonly name: string; - readonly clientId: string; - readonly defaultDirectory?: string; -} - -/** - * An entry in the `chat.remoteAgentHosts` setting. - * - * File: `platform/agentHost/common/remoteAgentHostService.ts` - */ -interface IRemoteAgentHostEntry { - readonly address: string; - readonly name: string; - readonly connectionToken?: string; -} - -/** - * A protocol-level client for a single remote agent host connection. - * Manages the WebSocket transport, handshake (initialize command with - * protocol version exchange), subscriptions, action dispatch, and - * JSON-RPC request/response correlation. - * - * Implements {@link IAgentConnection} so consumers can program against - * a single interface regardless of whether the agent host is local or remote. - * - * File: `platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts` - */ -interface RemoteAgentHostProtocolClient extends IAgentConnection { - readonly defaultDirectory: string | undefined; - readonly onDidClose: Event; - connect(): Promise; -} - -// ============================================================================= -// LAYER 2: State Protocol Types (platform/agentHost/common/state/) -// ============================================================================= - -/** - * Root state: the top-level state tree subscribed to at `agenthost:/root`. - * Contains the list of discovered agent backends and active session count. - * Mutated by `root/agentsChanged` and `root/activeSessionsChanged` actions. - * - * File: `platform/agentHost/common/state/sessionState.ts` (re-exported from protocol) - */ -interface IRootState { - readonly agents: readonly IAgentInfo[]; - readonly activeSessions: number; -} - -/** - * Describes an agent backend discovered via root state subscription. - * Each agent exposes a provider name, display metadata, and available models. - * - * File: `platform/agentHost/common/state/sessionState.ts` (re-exported from protocol) - */ -interface IAgentInfo { - readonly provider: string; - readonly displayName: string; - readonly description: string; - readonly models: readonly ISessionModelInfo[]; -} - -/** - * Per-session state tree. Contains the session summary, lifecycle, completed - * turns, active turn (if any), and server tools. Mutated by session actions - * like `session/turnStarted`, `session/delta`, `session/toolCallStart`, etc. - * - * File: `platform/agentHost/common/state/sessionState.ts` (re-exported from protocol) - */ -interface ISessionState { - readonly summary: ISessionSummary; - readonly lifecycle: SessionLifecycle; - readonly turns: readonly ITurn[]; - readonly activeTurn: IActiveTurn | undefined; -} - -/** - * An envelope wrapping a state action with origin metadata and a monotonic - * server sequence number. Clients use the origin to distinguish their own - * echoed actions from concurrent actions from other clients/the server. - * - * File: `platform/agentHost/common/state/sessionActions.ts` (re-exported from protocol) - */ -interface IActionEnvelope { - readonly action: IStateAction; - readonly origin: IActionOrigin; - readonly serverSeq: number; -} - -/** - * A state snapshot returned by the subscribe command. Contains the current - * state at the given resource URI and the server sequence number at - * snapshot time. The client should process subsequent envelopes with - * `serverSeq > fromSeq`. - * - * File: `platform/agentHost/common/state/sessionProtocol.ts` (re-exported from protocol) - */ -interface IStateSnapshot { - readonly resource: string; - readonly state: IRootState | ISessionState; - readonly fromSeq: number; -} - -/** - * Client-side state manager with write-ahead reconciliation. - * - * Maintains confirmed state (last server-acknowledged), a pending action - * queue (optimistically applied), and reconciles when the server echoes - * actions back (possibly interleaved with actions from other sources). - * Operates on two kinds of subscribable state: - * - Root state (agents + models) - server-only mutations, no write-ahead. - * - Session state - mixed: client-sendable actions get write-ahead, - * server-only actions are applied directly. - * - * Usage: - * 1. `handleSnapshot()` - apply initial state from subscribe response. - * 2. `applyOptimistic()` - optimistically apply a client action. - * 3. `receiveEnvelope()` - process a server action envelope. - * 4. `receiveNotification()` - process an ephemeral notification. - * - * File: `platform/agentHost/common/state/sessionClientState.ts` - */ -interface SessionClientState { - readonly clientId: string; - readonly rootState: IRootState | undefined; - readonly onDidChangeRootState: Event; - readonly onDidChangeSessionState: Event<{ session: string; state: ISessionState }>; - readonly onDidReceiveNotification: Event; - getSessionState(session: string): ISessionState | undefined; - handleSnapshot(resource: string, state: IRootState | ISessionState, fromSeq: number): void; - applyOptimistic(action: ISessionAction): number; - receiveEnvelope(envelope: IActionEnvelope): void; - receiveNotification(notification: INotification): void; - unsubscribe(resource: string): void; -} - -/** - * A bidirectional transport for protocol messages (JSON-RPC 2.0 framing). - * Implementations handle serialization, framing, and connection management. - * Concrete implementations: MessagePort (ProxyChannel), WebSocket, stdio. - * - * File: `platform/agentHost/common/state/sessionTransport.ts` - */ -interface IProtocolTransport extends IDisposable { - readonly onMessage: Event; - readonly onClose: Event; - send(message: IProtocolMessage): void; -} - -/** - * Server-side transport that accepts multiple client connections. - * Each connected client gets its own {@link IProtocolTransport}. - * - * File: `platform/agentHost/common/state/sessionTransport.ts` - */ -interface IProtocolServer extends IDisposable { - readonly onConnection: Event; - readonly address: string | undefined; -} - -// ============================================================================= -// LAYER 3: Workbench Contributions (workbench/contrib/chat/browser/agentSessions/agentHost/) -// ============================================================================= - -/** - * Renderer-side handler for a single agent host chat session type. - * Bridges the protocol state layer with the chat UI: - * - * - Subscribes to session state via {@link IAgentConnection} - * - Derives `IChatProgress[]` from immutable state changes in - * {@link SessionClientState} - * - Dispatches client actions (`turnStarted`, `permissionResolved`, - * `turnCancelled`) back to the server - * - Registers a dynamic chat agent via {@link IChatAgentService} - * - * Works with both local and remote connections via the {@link IAgentConnection} - * interface passed in the config. - * - * File: `workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts` - */ -interface AgentHostSessionHandler extends IChatSessionContentProvider { - provideChatSessionContent(sessionResource: URI, token: CancellationToken): Promise; -} - -/** - * Configuration for an {@link AgentHostSessionHandler} instance. - * Contains the agent identity, displayName, the connection to use, - * and optional callbacks for resolving working directories and - * interactive authentication. - * - * File: `workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts` - */ -interface IAgentHostSessionHandlerConfig { - readonly provider: AgentProvider; - readonly agentId: string; - readonly sessionType: string; - readonly fullName: string; - readonly description: string; - readonly connection: IAgentConnection; - readonly extensionId?: string; - readonly extensionDisplayName?: string; - /** Resolve a working directory for a new session (e.g. from active session's repository URI). */ - readonly resolveWorkingDirectory?: (resourceKey: string) => string | undefined; - /** Trigger interactive authentication when the server rejects with auth-required. */ - readonly resolveAuthentication?: () => Promise; -} - -/** - * Provides session list items for the chat sessions sidebar by querying - * active sessions from an agent host connection. Listens to protocol - * notifications (`notify/sessionAdded`, `notify/sessionRemoved`) for - * incremental updates, and refreshes on `session/turnComplete` actions. - * - * Works with both local and remote agent host connections via - * {@link IAgentConnection}. - * - * File: `workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts` - */ -interface AgentHostSessionListController extends IChatSessionItemController { - readonly items: readonly IChatSessionItem[]; - readonly onDidChangeChatSessionItems: Event; - refresh(token: CancellationToken): Promise; -} - -/** - * Exposes models available from the agent host process as selectable - * language models in the chat model picker. Models come from root state - * (via {@link IAgentInfo.models}) and are published with IDs prefixed - * by the session type (e.g. `remote-localhost__8081-copilot:claude-sonnet-4-20250514`). - * - * File: `workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts` - */ -interface AgentHostLanguageModelProvider extends ILanguageModelChatProvider { - /** Called when models change in root state to push updates to the model picker. */ - updateModels(models: readonly ISessionModelInfo[]): void; -} - -/** - * The local agent host contribution (for the workbench, not the sessions app). - * Discovers agents from the local agent host process and registers each one - * as a chat session type. Gated on the `chat.agentHost.enabled` setting. - * - * Uses the same shared adapters ({@link AgentHostSessionHandler}, etc.) - * but connects via {@link IAgentHostService} (MessagePort) instead of - * {@link IRemoteAgentHostService} (WebSocket). - * - * File: `workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts` - */ -interface AgentHostContribution extends IWorkbenchContribution { - // Registers per-agent: chat session contribution, session list controller, - // session handler, and language model provider - same 4 registrations - // as RemoteAgentHostContribution but for the local agent host. -} - -// ============================================================================= -// LAYER 4: Sessions App Orchestrator (sessions/contrib/remoteAgentHost/) -// ============================================================================= - -/** - * Central orchestrator for remote agent hosts in the sessions app. - * - * For each active remote connection: - * 1. Creates a {@link SessionClientState} for write-ahead reconciliation - * 2. Subscribes to `agenthost:/root` to discover available agents - * 3. For each discovered copilot agent, performs four registrations: - * - Chat session contribution (via {@link IChatSessionsService}) - * - Session list controller ({@link AgentHostSessionListController}) - * - Session content provider ({@link AgentHostSessionHandler}) - * - Language model provider ({@link AgentHostLanguageModelProvider}) - * 4. Registers authority→address mappings for the filesystem provider - * 5. Authenticates connections using RFC 9728 resource metadata - * - * Reconciles when connections change (added/removed/name changed) - * and when the default auth account or auth sessions change. - * - * File: `sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts` - */ -interface RemoteAgentHostContribution extends IWorkbenchContribution { - // Per-connection state tracked in a DisposableMap -} - -/** - * Read-only {@link IFileSystemProvider} registered under the `agenthost` - * scheme. Proxies `stat` and `readdir` calls through the agent host - * protocol's `browseDirectory` RPC. - * - * The URI authority identifies the remote connection (sanitized address), - * the URI path is the remote filesystem path. Authority-to-address mappings - * are registered by {@link RemoteAgentHostContribution} via - * `registerAuthority(authority, address)`. - * - * File: `sessions/contrib/remoteAgentHost/browser/agentHostFileSystemProvider.ts` - */ -interface AgentHostFileSystemProvider extends IFileSystemProvider { - /** Register a mapping from sanitized URI authority to remote address. */ - registerAuthority(authority: string, address: string): IDisposable; -} - -// ============================================================================= -// Naming Conventions & URI Schemes -// ============================================================================= - -/** - * Remote addresses are encoded into URI-safe authority strings: - * - `localhost:8081` → `localhost__8081` - * - `http://127.0.0.1:3000` → `b64-aHR0cDovLzEyNy4wLjAuMTozMDAw` - * - * | Context | Scheme | Example | - * |--------------------------|------------------|-------------------------------------------------| - * | Session resource (UI) | `` | `remote-localhost__8081-copilot:/untitled-abc` | - * | Backend session (server) | `` | `copilot:/abc-123` | - * | Root state subscription | (string literal) | `agenthost:/root` | - * | Remote filesystem | `agenthost` | `agenthost://localhost__8081/home/user/project` | - * | Language model ID | - | `remote-localhost__8081-copilot:claude-sonnet-4-20250514` | - * - * Session type naming: `remote-${authority}-${provider}` for remote, - * `agent-host-${provider}` for local. - */ - -// ============================================================================= -// IPC & Auth Data Types (platform/agentHost/common/agentService.ts) -// ============================================================================= - -/** Metadata describing an agent backend, discovered over IPC or root state. */ -interface IAgentDescriptor { - readonly provider: AgentProvider; - readonly displayName: string; - readonly description: string; - /** @deprecated Use IResourceMetadata from getResourceMetadata() instead. */ - readonly requiresAuth: boolean; -} - -/** Serializable model information from the agent host. */ -interface IAgentModelInfo { - readonly provider: AgentProvider; - readonly id: string; - readonly name: string; - readonly maxContextWindow: number; - readonly supportsVision: boolean; - readonly supportsReasoningEffort: boolean; -} - -/** Configuration for creating a new session. */ -interface IAgentCreateSessionConfig { - readonly provider?: AgentProvider; - readonly model?: string; - readonly session?: URI; - readonly workingDirectory?: string; -} - -/** Metadata for an existing session (returned by listSessions). */ -interface IAgentSessionMetadata { - readonly session: URI; - readonly startTime: number; - readonly modifiedTime: number; - readonly summary?: string; - readonly workingDirectory?: string; -} - -/** Serializable attachment passed alongside a message to the agent host. */ -interface IAgentAttachment { - readonly type: AttachmentType; - readonly path: string; - readonly displayName?: string; - readonly text?: string; - readonly selection?: { - readonly start: { readonly line: number; readonly character: number }; - readonly end: { readonly line: number; readonly character: number }; - }; -} - -/** - * Describes the agent host as an OAuth 2.0 protected resource (RFC 9728). - * Clients resolve tokens via the VS Code authentication service. - */ -interface IResourceMetadata { - readonly resources: readonly IAuthorizationProtectedResourceMetadata[]; -} - -/** - * Parameters for the `authenticate` command (RFC 6750 bearer token delivery). - */ -interface IAuthenticateParams { - readonly resource: string; - readonly token: string; -} - -/** - * Result of the `authenticate` command. - */ -interface IAuthenticateResult { - readonly authenticated: boolean; -} - -// ============================================================================= -// Progress Events (platform/agentHost/common/agentService.ts) -// ============================================================================= - -/** - * Discriminated union of progress events streamed from the agent host. - * The state-to-progress adapter ({@link stateToProgressAdapter.ts}) - * translates protocol state changes into `IChatProgress[]` for the chat UI, - * but these events are also used in the IPC path for the old event-based API. - * - * Types: `delta`, `message`, `idle`, `tool_start`, `tool_complete`, - * `title_changed`, `error`, `usage`, `permission_request`, `reasoning`. - */ -type IAgentProgressEvent = - | IAgentDeltaEvent - | IAgentMessageEvent - | IAgentIdleEvent - | IAgentToolStartEvent - | IAgentToolCompleteEvent - | IAgentTitleChangedEvent - | IAgentErrorEvent - | IAgentUsageEvent - | IAgentPermissionRequestEvent - | IAgentReasoningEvent; -``` diff --git a/src/vs/sessions/contrib/remoteAgentHost/ARCHITECTURE.md b/src/vs/sessions/contrib/remoteAgentHost/ARCHITECTURE.md deleted file mode 100644 index bdb4dbf486f99..0000000000000 --- a/src/vs/sessions/contrib/remoteAgentHost/ARCHITECTURE.md +++ /dev/null @@ -1,328 +0,0 @@ -# Remote Agent Host Chat Agents - Architecture - -This document describes how remote agent host chat agents are registered, how -sessions are created, and the URI/target conventions used throughout the system. - -## Overview - -A **remote agent host** is a VS Code agent host process running on another -machine, connected over WebSocket. The user configures remote addresses in the -`chat.remoteAgentHosts` setting. Each remote host may expose one or more agent -backends (currently only the `copilot` provider is supported). The system -discovers these agents, dynamically registers them as chat session types, and -creates sessions that stream turns via the agent host protocol. - -``` -┌─────────────┐ WebSocket ┌───────────────────┐ -│ VS Code │ ◄──────────────► │ Remote Agent Host │ -│ (client) │ AHP protocol │ (server) │ -└─────────────┘ └───────────────────┘ -``` - -## Connection Lifecycle - -### 1. Configuration - -Connections are configured via the `chat.remoteAgentHosts` setting: - -```jsonc -"chat.remoteAgentHosts": [ - { "address": "http://192.168.1.10:3000", "name": "dev-box", "connectionToken": "..." } -] -``` - -Each entry is an `IRemoteAgentHostEntry` with `address`, `name`, and optional -`connectionToken`. - -### 2. Service Layer - -`IRemoteAgentHostService` (`src/vs/platform/agentHost/common/remoteAgentHostService.ts`) -manages WebSocket connections. The Electron implementation reads the setting, -creates `RemoteAgentHostProtocolClient` instances for each address, and fires -`onDidChangeConnections` when connections are established or lost. - -Each connection satisfies the `IAgentConnection` interface (which extends -`IAgentService`), providing: - -- `subscribe(resource)` / `unsubscribe(resource)` - state subscriptions -- `dispatchAction(action, clientId, seq)` - send client actions -- `onDidAction` / `onDidNotification` - receive server events -- `createSession(config)` - create a new backend session -- `browseDirectory(uri)` - list remote filesystem contents -- `clientId` - unique connection identifier for optimistic reconciliation - -### 3. Connection Metadata - -Each active connection exposes `IRemoteAgentHostConnectionInfo`: - -```typescript -{ - address: string; // e.g. "http://192.168.1.10:3000" - name: string; // e.g. "dev-box" (from setting) - clientId: string; // assigned during handshake - defaultDirectory?: string; // home directory on the remote machine -} -``` - -## Agent Discovery - -### Root State Subscription - -`RemoteAgentHostContribution` (`src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts`) -is the central orchestrator. For each connection, it subscribes to `ROOT_STATE_URI` -(`agenthost:/root`) to discover available agents. - -The root state (`IRootState`) contains: - -```typescript -{ - agents: IAgentInfo[]; // discovered agent backends - activeSessions?: number; // count of active sessions -} -``` - -Each `IAgentInfo` describes an agent: - -```typescript -{ - provider: string; // e.g. "copilot" - displayName: string; // e.g. "Copilot" - description: string; - models: ISessionModelInfo[]; // available language models -} -``` - -### Authority Encoding - -Remote addresses are encoded into URI-safe authority strings via -`agentHostAuthority(address)`: - -- Alphanumeric addresses pass through unchanged -- "Normal" addresses (`[a-zA-Z0-9.:-]`) get colons replaced with `__` -- Everything else is url-safe base64 encoded with a `b64-` prefix - -Examples: -- `localhost:8081` → `localhost__8081` -- `192.168.1.1:8080` → `192.168.1.1__8080` -- `http://127.0.0.1:3000` → `b64-aHR0cDovLzEyNy4wLjAuMTozMDAw` - -## Agent Registration - -When `_registerAgent()` is called for a discovered copilot agent from address `X`: - -### Naming Conventions - -| Concept | Value | Example | -|---------|-------|---------| -| **Authority** | `agentHostAuthority(address)` | `localhost__8081` | -| **Session type** | `remote-${authority}-${provider}` | `remote-localhost__8081-copilot` | -| **Agent ID** | same as session type | `remote-localhost__8081-copilot` | -| **Vendor** | same as session type | `remote-localhost__8081-copilot` | -| **Display name** | `configuredName \|\| "${displayName} (${address})"` | `dev-box` | - -### Four Registrations Per Agent - -1. **Chat session contribution** - via `IChatSessionsService.registerChatSessionContribution()`: - ```typescript - { type: sessionType, name: agentId, displayName, canDelegate: true, requiresCustomModels: true } - ``` - -2. **Session list controller** - `AgentHostSessionListController` handles the - sidebar session list. Lists sessions via `connection.listSessions()`, listens - for `notify/sessionAdded` and `notify/sessionRemoved` notifications. - -3. **Session handler** - `AgentHostSessionHandler` implements - `IChatSessionContentProvider`, bridging the agent host protocol to chat UI - progress events. Also registers a _dynamic chat agent_ via - `IChatAgentService.registerDynamicAgent()`. - -4. **Language model provider** - `AgentHostLanguageModelProvider` registers - models under the vendor descriptor. Model IDs are prefixed with the session - type (e.g., `remote-localhost__8081-copilot:claude-sonnet-4-20250514`). - -## URI Conventions - -| Context | Scheme | Format | Example | -|---------|--------|--------|---------| -| New session resource | `` | `:/untitled-` | `remote-localhost__8081-copilot:/untitled-abc` | -| Existing session | `` | `:/` | `remote-localhost__8081-copilot:/abc-123` | -| Backend session state | `` | `:/` | `copilot:/abc-123` | -| Root state subscription | (string) | `agenthost:/root` | - | -| Remote filesystem | `agenthost` | `agenthost:///` | `agenthost://localhost__8081/home/user/project` | -| Language model ID | - | `:` | `remote-localhost__8081-copilot:claude-sonnet-4-20250514` | - -### Key distinction: session resource vs backend session URI - -- The **session resource** URI uses the session type as its scheme - (e.g., `remote-localhost__8081-copilot:/untitled-abc`). This is the URI visible to - the chat UI and session management. -- The **backend session** URI uses the provider as its scheme - (e.g., `copilot:/abc-123`). This is sent over the agent host protocol to the - server. The `AgentSession.uri(provider, rawId)` helper creates these. - -The `AgentHostSessionHandler` translates between the two: -```typescript -private _resolveSessionUri(sessionResource: URI): URI { - const rawId = sessionResource.path.substring(1); - return AgentSession.uri(this._config.provider, rawId); -} -``` - -## Session Creation Flow - -### 1. User Selects or Adds a Remote Workspace - -In the `WorkspacePicker`, the user clicks **"Browse Remotes..."**. The picker -shows existing connected remotes and an **"Add Remote..."** action. When the -user chooses **"Add Remote..."**, they: - -1. Paste a host, `host:port`, or WebSocket URL. -2. Enter a display name for that remote. -3. The parsed address is written into `chat.remoteAgentHosts`. -4. The client waits for the new remote connection to come up. -5. The user is immediately taken into the remote folder picker. - -Supported address inputs include raw `host:port`, `ws://` / `wss://` URLs, and -log-line text such as `Listening on ws://127.0.0.1:8089`. If the input includes -`?tkn=...`, the token is extracted into `connectionToken` and the stored address -is normalized without that query parameter. - -After choosing an existing remote or successfully adding a new one, the user -picks a folder on the remote filesystem. This produces a `SessionWorkspace` -with an `agenthost://` URI: - -``` -agenthost://localhost__8081/home/user/myproject - ↑ authority ↑ remote filesystem path -``` - -### 2. Session Target Resolution - -`NewChatWidget._createNewSession()` detects `project.isRemoteAgentHost` and -resolves the matching session type via `getRemoteAgentHostSessionTarget()` -(defined in `remoteAgentHost.contribution.ts`): - -```typescript -// authority "localhost__8081" → find connection → "remote-localhost__8081-copilot" -const target = getRemoteAgentHostSessionTarget(connections, authority); -``` - -### 3. Resource URI Generation - -`getResourceForNewChatSession()` creates the session resource: - -```typescript -URI.from({ scheme: target, path: `/untitled-${generateUuid()}` }) -// → remote-localhost__8081-copilot:/untitled-abc-123 -``` - -### 4. Session Object Creation - -`SessionsManagementService.createNewSessionForTarget()` creates an -`AgentHostNewSession` (when the `agentHost` option is set). This is a -lightweight `INewSession` that supports local model and mode pickers but -skips isolation mode, branch, and cloud option groups. -The project URI is set on the session, making it available as -`activeSessionItem.repository`. - -### 5. Backend Session Creation (Deferred) - -`AgentHostSessionHandler` defers backend session creation until the first turn -(for "untitled" sessions), so the user-selected model is available: - -```typescript -const session = await connection.createSession({ - model: rawModelId, - provider: 'copilot', - workingDirectory: '/home/user/myproject', // from activeSession.repository.path -}); -``` - -### 6. Working Directory Resolution - -The `resolveWorkingDirectory` callback in `RemoteAgentHostContribution` reads -the active session's repository URI path: - -```typescript -const resolveWorkingDirectory = (resourceKey: string): string | undefined => { - const activeSessionItem = this._sessionsManagementService.getActiveSession(); - if (activeSessionItem?.repository) { - return activeSessionItem.repository.path; - // For agenthost://authority/home/user/project → "/home/user/project" - } - return undefined; -}; -``` - -## Turn Handling - -When the user sends a message, `AgentHostSessionHandler._handleTurn()`: - -1. Converts variable entries to `IAgentAttachment[]` (file, directory, selection) -2. Dispatches `session/modelChanged` if the model differs from current -3. Dispatches `session/turnStarted` with the user message + attachments -4. Listens to `SessionClientState.onDidChangeSessionState` and translates - the `activeTurn` state changes into `IChatProgress[]` events: - -| Server State | Chat Progress | -|-------------|---------------| -| `streamingText` | `markdownContent` | -| `reasoning` | `thinking` | -| `toolCalls` (new) | `ChatToolInvocation` created | -| `toolCalls` (completed) | `ChatToolInvocation` finalized | -| `pendingPermissions` | `awaitConfirmation()` prompt | - -5. On cancellation, dispatches `session/turnCancelled` - -## Filesystem Provider - -`AgentHostFileSystemProvider` is a read-only `IFileSystemProvider` registered -under the `agenthost` scheme. It proxies `stat` and `readdir` calls through -`connection.browseDirectory(uri)` RPC. - -- The URI authority identifies the remote connection (sanitized address) -- The URI path is the remote filesystem path -- Authority-to-address mappings are registered by `RemoteAgentHostContribution` - via `registerAuthority(authority, address)` - -## Data Flow Diagram - -``` -Settings (chat.remoteAgentHosts) - │ - ▼ -RemoteAgentHostService (WebSocket connections) - │ - ▼ -RemoteAgentHostContribution - │ - ├─► subscribe(ROOT_STATE_URI) → IRootState.agents - │ │ - │ ▼ - │ _registerAgent() for each copilot agent: - │ ├─► registerChatSessionContribution() - │ ├─► registerChatSessionItemController() - │ ├─► registerChatSessionContentProvider() - │ └─► registerLanguageModelProvider() - │ - └─► registerProvider(AGENT_HOST_FS_SCHEME, fsProvider) - -User picks remote workspace in WorkspacePicker - │ - ▼ -NewChatWidget._createNewSession(project) - │ target = getRemoteAgentHostSessionTarget(connections, authority) - ▼ -SessionsManagementService.createNewSessionForTarget() - │ creates AgentHostNewSession - ▼ -User sends message - │ - ▼ -AgentHostSessionHandler._handleTurn() - │ resolves working directory - │ creates backend session (if untitled) - │ dispatches session/turnStarted - ▼ -connection ← streams state changes → IChatProgress[] -``` From 35358544a0315915b6c34533fa2f606b275d276f Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Mon, 23 Mar 2026 20:15:23 -0700 Subject: [PATCH 32/33] IPC logging for agent hosts (#304338) Co-authored-by: Copilot --- .../platform/agentHost/common/agentService.ts | 3 + .../remoteAgentHostService.test.ts | 2 + .../browser/remoteAgentHost.contribution.ts | 88 +++----- .../agentHost/agentHostChatContribution.ts | 111 +++------- .../agentHost/loggingAgentConnection.ts | 198 ++++++++++++++++++ .../contrib/chat/browser/chat.contribution.ts | 9 +- 6 files changed, 268 insertions(+), 143 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/loggingAgentConnection.ts diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 8485dc3dda73a..2cbe17fb7591f 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -27,6 +27,9 @@ export const enum AgentHostIpcChannels { /** Configuration key that controls whether the agent host process is spawned. */ export const AgentHostEnabledSettingId = 'chat.agentHost.enabled'; +/** Configuration key that controls whether per-host IPC traffic output channels are created. */ +export const AgentHostIpcLoggingSettingId = 'chat.agentHost.ipcLoggingEnabled'; + // ---- IPC data types (serializable across MessagePort) ----------------------- export interface IAgentSessionMetadata { diff --git a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts index fa136f96ec04d..7b24e3b6a1ca3 100644 --- a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts @@ -131,6 +131,7 @@ suite('RemoteAgentHostService', () => { test('parses supported remote host inputs', () => { assert.deepStrictEqual([ parseRemoteAgentHostInput('Listening on ws://127.0.0.1:8089'), + parseRemoteAgentHostInput('Agent host proxy listening on ws://127.0.0.1:8089'), parseRemoteAgentHostInput('127.0.0.1:8089'), parseRemoteAgentHostInput('ws://127.0.0.1:8089'), parseRemoteAgentHostInput('ws://127.0.0.1:40147?tkn=c9d12867-da33-425e-8d39-0d071e851597'), @@ -139,6 +140,7 @@ suite('RemoteAgentHostService', () => { { parsed: { address: '127.0.0.1:8089', connectionToken: undefined, suggestedName: '127.0.0.1:8089' } }, { parsed: { address: '127.0.0.1:8089', connectionToken: undefined, suggestedName: '127.0.0.1:8089' } }, { parsed: { address: '127.0.0.1:8089', connectionToken: undefined, suggestedName: '127.0.0.1:8089' } }, + { parsed: { address: '127.0.0.1:8089', connectionToken: undefined, suggestedName: '127.0.0.1:8089' } }, { parsed: { address: '127.0.0.1:40147', connectionToken: 'c9d12867-da33-425e-8d39-0d071e851597', suggestedName: '127.0.0.1:40147' } }, { parsed: { address: 'wss://secure.example.com', connectionToken: undefined, suggestedName: 'secure.example.com' } }, ]); diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts index cae102ff718bd..9735d31f22936 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts @@ -18,18 +18,18 @@ import { IDefaultAccountService } from '../../../../platform/defaultAccount/comm 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 { ILogService } from '../../../../platform/log/common/log.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.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 { LoggingAgentConnection } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/loggingAgentConnection.js'; import { AgentSessionTarget } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.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'; /** @@ -56,14 +56,17 @@ class ConnectionState extends Disposable { readonly clientState: SessionClientState; readonly agents = this._register(new DisposableMap()); readonly modelProviders = new Map(); + readonly loggedConnection: LoggingAgentConnection; constructor( clientId: string, readonly name: string | undefined, logService: ILogService, + loggedConnection: LoggingAgentConnection, ) { super(); this.clientState = this.store.add(new SessionClientState(clientId, logService)); + this.loggedConnection = this.store.add(loggedConnection); } } @@ -92,7 +95,6 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc @IChatSessionsService private readonly _chatSessionsService: IChatSessionsService, @ILanguageModelsService private readonly _languageModelsService: ILanguageModelsService, @ILogService private readonly _logService: ILogService, - @IOutputService private readonly _outputService: IOutputService, @IInstantiationService private readonly _instantiationService: IInstantiationService, @IAuthenticationService private readonly _authenticationService: IAuthenticationService, @IDefaultAccountService private readonly _defaultAccountService: IDefaultAccountService, @@ -156,7 +158,11 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc return; } - const connState = new ConnectionState(connection.clientId, name, this._logService); + const sanitized = agentHostAuthority(address); + const channelId = `agentHostIpc.remote.${sanitized}`; + const channelLabel = `Agent Host (${name || address})`; + const loggedConnection = this._instantiationService.createInstance(LoggingAgentConnection, connection, channelId, channelLabel); + const connState = new ConnectionState(connection.clientId, name, this._logService, loggedConnection); this._connections.set(address, connState); const store = connState.store; @@ -165,39 +171,38 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc store.add(this._fsProvider.registerAuthority(authority, connection)); // Forward non-session actions to client state - store.add(connection.onDidAction(envelope => { + store.add(loggedConnection.onDidAction(envelope => { if (!isSessionAction(envelope.action)) { connState.clientState.receiveEnvelope(envelope); } - this._traceIpc(address, 'onDidAction', envelope); })); // Forward notifications to client state - store.add(connection.onDidNotification(n => { + store.add(loggedConnection.onDidNotification(n => { connState.clientState.receiveNotification(n); - this._traceIpc(address, 'onDidNotification', n); })); // React to root state changes (agent discovery) store.add(connState.clientState.onDidChangeRootState(rootState => { - this._handleRootStateChange(address, connection, rootState); + this._handleRootStateChange(address, loggedConnection, rootState); })); // Subscribe to root state - connection.subscribe(URI.parse(ROOT_STATE_URI)).then(snapshot => { + loggedConnection.subscribe(URI.parse(ROOT_STATE_URI)).then(snapshot => { if (store.isDisposed) { return; } connState.clientState.handleSnapshot(ROOT_STATE_URI, snapshot.state, snapshot.fromSeq); }).catch(err => { this._logService.error(`[RemoteAgentHost] Failed to subscribe to root state for ${address}`, err); + loggedConnection.logError('subscribe(root)', err); }); // Authenticate with this new connection - this._authenticateWithConnection(connection); + this._authenticateWithConnection(loggedConnection); } - private _handleRootStateChange(address: string, connection: IAgentConnection, rootState: IRootState): void { + private _handleRootStateChange(address: string, loggedConnection: LoggingAgentConnection, rootState: IRootState): void { const connState = this._connections.get(address); if (!connState) { return; @@ -216,7 +221,7 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc // Register new agents, push model updates to existing ones for (const agent of rootState.agents) { if (!connState.agents.has(agent.provider)) { - this._registerAgent(address, connection, agent, connState.name); + this._registerAgent(address, loggedConnection, agent, connState.name); } else { const modelProvider = connState.modelProviders.get(agent.provider); modelProvider?.updateModels(agent.models); @@ -224,7 +229,7 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc } } - private _registerAgent(address: string, connection: IAgentConnection, agent: IAgentInfo, configuredName: string | undefined): void { + private _registerAgent(address: string, loggedConnection: LoggingAgentConnection, agent: IAgentInfo, configuredName: string | undefined): void { // Only register copilot agents; other provider types are not supported if (agent.provider !== 'copilot') { this._logService.warn(`[RemoteAgentHost] Ignoring unsupported agent provider '${agent.provider}' from ${address}`); @@ -281,7 +286,7 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc // Session list controller (unified) const listController = agentStore.add(this._instantiationService.createInstance( - AgentHostSessionListController, sessionType, agent.provider, connection, displayName)); + AgentHostSessionListController, sessionType, agent.provider, loggedConnection, displayName)); agentStore.add(this._chatSessionsService.registerChatSessionItemController(sessionType, listController)); // Session handler (unified) @@ -292,12 +297,12 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc sessionType, fullName: displayName, description: agent.description, - connection, + connection: loggedConnection, connectionAuthority: sanitized, extensionId: 'vscode.remote-agent-host', extensionDisplayName: 'Remote Agent Host', resolveWorkingDirectory, - resolveAuthentication: () => this._resolveAuthenticationInteractively(connection), + resolveAuthentication: () => this._resolveAuthenticationInteractively(loggedConnection), })); agentStore.add(this._chatSessionsService.registerChatSessionContentProvider(sessionType, sessionHandler)); @@ -315,11 +320,8 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc } private _authenticateAllConnections(): void { - for (const address of this._connections.keys()) { - const connection = this._remoteAgentHostService.getConnection(address); - if (connection) { - this._authenticateWithConnection(connection); - } + for (const [, connState] of this._connections) { + this._authenticateWithConnection(connState.loggedConnection); } } @@ -328,21 +330,22 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc * and authenticate using matching tokens resolved via the standard * VS Code authentication service (same flow as MCP auth). */ - private async _authenticateWithConnection(connection: IAgentConnection): Promise { + private async _authenticateWithConnection(loggedConnection: LoggingAgentConnection): Promise { try { - const metadata = await connection.getResourceMetadata(); + const metadata = await loggedConnection.getResourceMetadata(); for (const resource of metadata.resources) { const resourceUri = URI.parse(resource.resource); const token = await this._resolveTokenForResource(resourceUri, resource.authorization_servers ?? [], resource.scopes_supported ?? []); if (token) { this._logService.info(`[RemoteAgentHost] Authenticating for resource: ${resource.resource}`); - await connection.authenticate({ resource: resource.resource, token }); + await loggedConnection.authenticate({ resource: resource.resource, token }); } else { this._logService.info(`[RemoteAgentHost] No token resolved for resource: ${resource.resource}`); } } } catch (err) { this._logService.error('[RemoteAgentHost] Failed to authenticate with connection', err); + loggedConnection.logError('authenticateWithConnection', err); } } @@ -358,9 +361,9 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc * Interactively prompt the user to authenticate when the server requires it. * Returns true if authentication succeeded. */ - private async _resolveAuthenticationInteractively(connection: IAgentConnection): Promise { + private async _resolveAuthenticationInteractively(loggedConnection: LoggingAgentConnection): Promise { try { - const metadata = await connection.getResourceMetadata(); + const metadata = await loggedConnection.getResourceMetadata(); for (const resource of metadata.resources) { for (const server of resource.authorization_servers ?? []) { const serverUri = URI.parse(server); @@ -376,7 +379,7 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc authorizationServer: serverUri, }); - await connection.authenticate({ + await loggedConnection.authenticate({ resource: resource.resource, token: session.accessToken, }); @@ -386,37 +389,10 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc } } catch (err) { this._logService.error('[RemoteAgentHost] Interactive authentication failed', err); + loggedConnection.logError('resolveAuthenticationInteractively', err); } return false; } - - private _traceIpc(address: string, method: string, data?: unknown): void { - if (this._logService.getLevel() !== LogLevel.Trace) { - return; - } - - const channel = this._outputService.getChannel('agentHostIpc'); - if (!channel) { - return; - } - - const timestamp = new Date().toISOString(); - let payload: string; - try { - payload = data !== undefined ? JSON.stringify(data, (_key, value) => { - if (value && typeof value === 'object' && (value as { $mid?: unknown }).$mid !== undefined && (value as { scheme?: unknown }).scheme !== undefined) { - return URI.revive(value).toString(); - } - return value; - }, 2) : ''; - } catch { - payload = String(data); - } - - channel.append(`[${timestamp}] [trace] ** [remote:${address}] ${method}${payload ? `\n${payload}` : ''}\n`); - } - - } registerWorkbenchContribution2(RemoteAgentHostContribution.ID, RemoteAgentHostContribution, WorkbenchPhase.AfterRestored); 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 b80233746103a..4db5423a66511 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts @@ -16,17 +16,16 @@ import { IDefaultAccountService } from '../../../../../../platform/defaultAccoun 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 { ILogService } from '../../../../../../platform/log/common/log.js'; import { IWorkbenchContribution } from '../../../../../common/contributions.js'; import { IAuthenticationService } from '../../../../../services/authentication/common/authentication.js'; -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'; +import { LoggingAgentConnection } from './loggingAgentConnection.js'; export { AgentHostSessionHandler } from './agentHostSessionHandler.js'; export { AgentHostSessionListController } from './agentHostSessionListController.js'; @@ -42,10 +41,7 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr static readonly ID = 'workbench.contrib.agentHostContribution'; - private static readonly _outputChannelId = 'agentHostIpc'; - - private _outputChannel: IOutputChannel | undefined; - private _isChannelRegistered = false; + private _loggedConnection: LoggingAgentConnection | undefined; private _clientState: SessionClientState | undefined; private readonly _agentRegistrations = this._register(new DisposableMap()); /** Model providers keyed by agent provider, for pushing model updates. */ @@ -58,7 +54,6 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr @IAuthenticationService private readonly _authenticationService: IAuthenticationService, @ILogService private readonly _logService: ILogService, @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, @@ -70,7 +65,12 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr return; } - this._setupIpcLogging(); + // Wrap the agent host service with logging to a dedicated output channel + this._loggedConnection = this._register(this._instantiationService.createInstance( + LoggingAgentConnection, + this._agentHostService, + 'agentHostIpc.local', + 'Agent Host (Local)')); // Register a read-only filesystem provider for the local agent host // so that agent-host-scheme URIs with 'local' authority can be resolved. @@ -85,7 +85,7 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr this._clientState = this._register(new SessionClientState(this._agentHostService.clientId, this._logService)); // Forward action envelopes from the host to client state - this._register(this._agentHostService.onDidAction(envelope => { + this._register(this._loggedConnection.onDidAction(envelope => { // Only root actions are relevant here; session actions are // handled by individual session handlers. if (!isSessionAction(envelope.action)) { @@ -94,7 +94,7 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr })); // Forward notifications to client state - this._register(this._agentHostService.onDidNotification(n => { + this._register(this._loggedConnection.onDidNotification(n => { this._clientState!.receiveNotification(n); })); @@ -106,80 +106,17 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr this._initializeAndSubscribe(); } - // ---- IPC output channel (trace-level only) ------------------------------ - - private _setupIpcLogging(): void { - this._updateOutputChannel(); - this._register(this._logService.onDidChangeLogLevel(() => this._updateOutputChannel())); - - // Subscribe to action / notification streams for IPC logging - this._register(this._agentHostService.onDidAction(e => { - this._traceIpc('event', 'onDidAction', e); - })); - this._register(this._agentHostService.onDidNotification(e => { - this._traceIpc('event', 'onDidNotification', e); - })); - } - - private _updateOutputChannel(): void { - const isTrace = this._logService.getLevel() === LogLevel.Trace; - const registry = Registry.as(Extensions.OutputChannels); - - if (isTrace && !this._isChannelRegistered) { - registry.registerChannel({ - id: AgentHostContribution._outputChannelId, - label: 'Agent Host IPC', - log: false, - languageId: 'log', - }); - this._isChannelRegistered = true; - this._outputChannel = undefined; // force re-fetch - } else if (!isTrace && this._isChannelRegistered) { - registry.removeChannel(AgentHostContribution._outputChannelId); - this._isChannelRegistered = false; - this._outputChannel = undefined; - } - } - - private _traceIpc(direction: 'call' | 'result' | 'event', method: string, data?: unknown): void { - if (this._logService.getLevel() !== LogLevel.Trace) { - return; - } - - if (!this._outputChannel) { - this._outputChannel = this._outputService.getChannel(AgentHostContribution._outputChannelId); - if (!this._outputChannel) { - return; - } - } - - const timestamp = new Date().toISOString(); - let payload: string; - try { - payload = data !== undefined ? JSON.stringify(data, (_key, value) => { - if (value && typeof value === 'object' && (value as { $mid?: unknown }).$mid !== undefined && (value as { scheme?: unknown }).scheme !== undefined) { - return URI.revive(value).toString(); - } - return value; - }, 2) : ''; - } catch { - payload = String(data); - } - - const arrow = direction === 'call' ? '>>' : direction === 'result' ? '<<' : '**'; - this._outputChannel.append(`[${timestamp}] [trace] ${arrow} ${method}${payload ? `\n${payload}` : ''}\n`); - } - private async _initializeAndSubscribe(): Promise { try { - const snapshot = await this._agentHostService.subscribe(URI.parse(ROOT_STATE_URI)); + const snapshot = await this._loggedConnection!.subscribe(URI.parse(ROOT_STATE_URI)); if (this._store.isDisposed) { return; } - // Feed snapshot into client state — fires onDidChangeRootState + // Feed snapshot into client state - fires onDidChangeRootState this._clientState!.handleSnapshot(ROOT_STATE_URI, snapshot.state, snapshot.fromSeq); } catch (err) { this._logService.error('[AgentHost] Failed to subscribe to root state', err); + this._loggedConnection!.logError('subscribe(root)', err); } } @@ -224,7 +161,7 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr })); // Session list controller - const listController = store.add(this._instantiationService.createInstance(AgentHostSessionListController, sessionType, agent.provider, this._agentHostService, undefined)); + const listController = store.add(this._instantiationService.createInstance(AgentHostSessionListController, sessionType, agent.provider, this._loggedConnection!, undefined)); store.add(this._chatSessionsService.registerChatSessionItemController(sessionType, listController)); // Session handler @@ -234,7 +171,7 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr sessionType, fullName: agent.displayName, description: agent.description, - connection: this._agentHostService, + connection: this._loggedConnection!, connectionAuthority: 'local', resolveAuthentication: () => this._resolveAuthenticationInteractively(), })); @@ -251,11 +188,11 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr store.add(this._languageModelsService.registerLanguageModelProvider(vendor, modelProvider)); // Push auth token and refresh models from server - this._authenticateWithServer().then(() => this._agentHostService.refreshModels()).catch(() => { /* best-effort */ }); + this._authenticateWithServer().then(() => this._loggedConnection!.refreshModels()).catch(() => { /* best-effort */ }); store.add(this._defaultAccountService.onDidChangeDefaultAccount(() => - this._authenticateWithServer().then(() => this._agentHostService.refreshModels()).catch(() => { /* best-effort */ }))); + this._authenticateWithServer().then(() => this._loggedConnection!.refreshModels()).catch(() => { /* best-effort */ }))); store.add(this._authenticationService.onDidChangeSessions(() => - this._authenticateWithServer().then(() => this._agentHostService.refreshModels()).catch(() => { /* best-effort */ }))); + this._authenticateWithServer().then(() => this._loggedConnection!.refreshModels()).catch(() => { /* best-effort */ }))); } /** @@ -265,20 +202,21 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr */ private async _authenticateWithServer(): Promise { try { - const metadata = await this._agentHostService.getResourceMetadata(); + const metadata = await this._loggedConnection!.getResourceMetadata(); this._logService.trace(`[AgentHost] Resource metadata: ${metadata.resources.length} resource(s)`); for (const resource of metadata.resources) { const resourceUri = URI.parse(resource.resource); const token = await this._resolveTokenForResource(resourceUri, resource.authorization_servers ?? [], resource.scopes_supported ?? []); if (token) { this._logService.info(`[AgentHost] Authenticating for resource: ${resource.resource}`); - await this._agentHostService.authenticate({ resource: resource.resource, token }); + await this._loggedConnection!.authenticate({ resource: resource.resource, token }); } else { this._logService.info(`[AgentHost] No token resolved for resource: ${resource.resource}`); } } } catch (err) { this._logService.error('[AgentHost] Failed to authenticate with server', err); + this._loggedConnection!.logError('authenticateWithServer', err); } } @@ -298,7 +236,7 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr */ private async _resolveAuthenticationInteractively(): Promise { try { - const metadata = await this._agentHostService.getResourceMetadata(); + const metadata = await this._loggedConnection!.getResourceMetadata(); for (const resource of metadata.resources) { for (const server of resource.authorization_servers ?? []) { const serverUri = URI.parse(server); @@ -315,7 +253,7 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr authorizationServer: serverUri, }); - await this._agentHostService.authenticate({ + await this._loggedConnection!.authenticate({ resource: resource.resource, token: session.accessToken, }); @@ -325,6 +263,7 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr } } catch (err) { this._logService.error('[AgentHost] Interactive authentication failed', err); + this._loggedConnection!.logError('resolveAuthenticationInteractively', err); } return false; } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/loggingAgentConnection.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/loggingAgentConnection.ts new file mode 100644 index 0000000000000..0126655253956 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/loggingAgentConnection.ts @@ -0,0 +1,198 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { Disposable } from '../../../../../../base/common/lifecycle.js'; +import { URI, UriComponents } from '../../../../../../base/common/uri.js'; +import { Registry } from '../../../../../../platform/registry/common/platform.js'; +import { IAgentConnection, IAgentCreateSessionConfig, IAgentDescriptor, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult, IResourceMetadata, AgentHostIpcLoggingSettingId } from '../../../../../../platform/agentHost/common/agentService.js'; +import type { IActionEnvelope, INotification, ISessionAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; +import type { IBrowseDirectoryResult, IFetchContentResult, IStateSnapshot } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; +import { Extensions, IOutputChannel, IOutputChannelRegistry, IOutputService } from '../../../../../services/output/common/output.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; + +/** + * JSON replacer that serializes revived URI objects to their string form, + * keeping the rest of the payload intact. + */ +function uriReplacer(_key: string, value: unknown): unknown { + if (value && typeof value === 'object' && (value as { $mid?: unknown }).$mid !== undefined && (value as { scheme?: unknown }).scheme !== undefined) { + return URI.revive(value as UriComponents).toString(); + } + return value; +} + +function formatPayload(data: unknown): string { + if (data === undefined) { + return ''; + } + try { + return JSON.stringify(data, uriReplacer, 2); + } catch { + return String(data); + } +} + +/** + * A logging wrapper around an {@link IAgentConnection} that writes all IPC + * traffic to a dedicated output channel. Used by both local and remote agent + * host contributions to provide per-host IPC tracing. + * + * The output channel is registered on construction and removed on dispose, + * so its lifetime matches the connection. + * + * All method calls, results, errors, and events are logged with arrows: + * - `>>` for outgoing calls + * - `<<` for results + * - `!!` for errors + * - `**` for events (onDidAction, onDidNotification) + */ +export class LoggingAgentConnection extends Disposable implements IAgentConnection { + + declare readonly _serviceBrand: undefined; + + private _outputChannel: IOutputChannel | undefined; + private readonly _enabled: boolean; + + readonly clientId: string; + readonly onDidAction: Event; + readonly onDidNotification: Event; + + constructor( + private readonly _inner: IAgentConnection, + private readonly _channelId: string, + private readonly _channelLabel: string, + @IOutputService private readonly _outputService: IOutputService, + @IConfigurationService configurationService: IConfigurationService, + ) { + super(); + this.clientId = _inner.clientId; + this._enabled = !!configurationService.getValue(AgentHostIpcLoggingSettingId); + + if (this._enabled) { + // Register the output channel + const registry = Registry.as(Extensions.OutputChannels); + registry.registerChannel({ + id: this._channelId, + label: this._channelLabel, + log: false, + languageId: 'log', + }); + this._register({ dispose: () => registry.removeChannel(this._channelId) }); + } + + // Wrap events with logging + const onDidActionEmitter = this._register(new Emitter()); + this._register(_inner.onDidAction(e => { + this._log('**', 'onDidAction', e); + onDidActionEmitter.fire(e); + })); + this.onDidAction = onDidActionEmitter.event; + + const onDidNotificationEmitter = this._register(new Emitter()); + this._register(_inner.onDidNotification(e => { + this._log('**', 'onDidNotification', e); + onDidNotificationEmitter.fire(e); + })); + this.onDidNotification = onDidNotificationEmitter.event; + } + + // ---- IAgentConnection method proxies with logging ----------------------- + + async listAgents(): Promise { + return this._logCall('listAgents', undefined, () => this._inner.listAgents()); + } + + async getResourceMetadata(): Promise { + return this._logCall('getResourceMetadata', undefined, () => this._inner.getResourceMetadata()); + } + + async authenticate(params: IAuthenticateParams): Promise { + return this._logCall('authenticate', params, () => this._inner.authenticate(params)); + } + + async refreshModels(): Promise { + return this._logCall('refreshModels', undefined, () => this._inner.refreshModels()); + } + + async listSessions(): Promise { + return this._logCall('listSessions', undefined, () => this._inner.listSessions()); + } + + async createSession(config?: IAgentCreateSessionConfig): Promise { + return this._logCall('createSession', config, () => this._inner.createSession(config)); + } + + async disposeSession(session: URI): Promise { + return this._logCall('disposeSession', session, () => this._inner.disposeSession(session)); + } + + async shutdown(): Promise { + return this._logCall('shutdown', undefined, () => this._inner.shutdown()); + } + + async subscribe(resource: URI): Promise { + return this._logCall('subscribe', resource, () => this._inner.subscribe(resource)); + } + + unsubscribe(resource: URI): void { + this._log('>>', 'unsubscribe', resource); + this._inner.unsubscribe(resource); + } + + dispatchAction(action: ISessionAction, clientId: string, clientSeq: number): void { + this._log('>>', 'dispatchAction', { action, clientId, clientSeq }); + this._inner.dispatchAction(action, clientId, clientSeq); + } + + async browseDirectory(uri: URI): Promise { + return this._logCall('browseDirectory', uri, () => this._inner.browseDirectory(uri)); + } + + async fetchContent(uri: URI): Promise { + return this._logCall('fetchContent', uri, () => this._inner.fetchContent(uri)); + } + + // ---- Public logging API for callers' catch blocks ----------------------- + + /** + * Log an error to the output channel. Use this from caller catch blocks + * so connection errors appear in the per-host channel. + */ + logError(context: string, error: unknown): void { + this._log('!!', context, error instanceof Error ? error.message : String(error)); + } + + // ---- Internal helpers --------------------------------------------------- + + private async _logCall(method: string, params: unknown, fn: () => Promise): Promise { + this._log('>>', method, params); + try { + const result = await fn(); + this._log('<<', method, result); + return result; + } catch (err) { + this._log('!!', method, err instanceof Error ? err.message : String(err)); + throw err; + } + } + + private _log(arrow: string, method: string, data?: unknown): void { + if (!this._enabled) { + return; + } + + if (!this._outputChannel) { + this._outputChannel = this._outputService.getChannel(this._channelId); + if (!this._outputChannel) { + return; + } + } + + const timestamp = new Date().toISOString(); + const payload = formatPayload(data); + this._outputChannel.append(`[${timestamp}] ${arrow} ${method}${payload ? `\n${payload}` : ''}\n`); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 7ba000b63cf73..ae428bc9fd746 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -8,7 +8,7 @@ import { Disposable, DisposableMap, DisposableStore } from '../../../../base/com import { Schemas } from '../../../../base/common/network.js'; import { isMacintosh } from '../../../../base/common/platform.js'; import { PolicyCategory } from '../../../../base/common/policy.js'; -import { AgentHostEnabledSettingId } from '../../../../platform/agentHost/common/agentService.js'; +import { AgentHostEnabledSettingId, AgentHostIpcLoggingSettingId } from '../../../../platform/agentHost/common/agentService.js'; import { registerEditorFeature } from '../../../../editor/common/editorFeatures.js'; import * as nls from '../../../../nls.js'; import { AccessibleViewRegistry } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; @@ -730,6 +730,13 @@ configurationRegistry.registerConfiguration({ tags: ['experimental', 'advanced'], included: product.quality !== 'stable', }, + [AgentHostIpcLoggingSettingId]: { + type: 'boolean', + description: nls.localize('chat.agentHost.ipcLogging', "When enabled, logs all IPC traffic for each agent host to a dedicated output channel."), + default: false, + tags: ['experimental', 'advanced'], + included: product.quality !== 'stable', + }, [ChatConfiguration.PlanAgentDefaultModel]: { type: 'string', description: nls.localize('chat.planAgent.defaultModel.description', "Select the default language model to use for the Plan agent from the available providers."), From cdf4f2f548f0cd60f7804b6837a0512a15a02889 Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Tue, 24 Mar 2026 15:08:25 +1100 Subject: [PATCH 33/33] feat: updates for Copilot CLI to include mode instructions (#303962) * feat: updates for Copilot CLI to include mode instructions * Update src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/vs/workbench/contrib/chat/common/chatSessionsService.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Updates * Updates * Updates * Updates --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../api/browser/mainThreadChatSessions.ts | 4 +- .../workbench/api/common/extHost.protocol.ts | 3 +- .../api/common/extHostChatAgents2.ts | 3 +- .../api/common/extHostChatSessions.ts | 2 + .../api/common/extHostTypeConverters.ts | 24 +++- src/vs/workbench/api/common/extHostTypes.ts | 1 + .../browser/mainThreadChatSessions.test.ts | 68 ++++++++++ .../test/common/extHostTypeConverters.test.ts | 125 +++++++++++++++++- .../chatSessions/chatSessions.contribution.ts | 5 + .../browser/widget/input/chatInputPart.ts | 17 ++- .../common/chatService/chatServiceImpl.ts | 50 ++++++- .../chat/common/chatSessionsService.ts | 7 +- ...scode.proposed.chatParticipantPrivate.d.ts | 7 +- 13 files changed, 303 insertions(+), 13 deletions(-) diff --git a/src/vs/workbench/api/browser/mainThreadChatSessions.ts b/src/vs/workbench/api/browser/mainThreadChatSessions.ts index 91214f4dd63c1..38eece78d22b7 100644 --- a/src/vs/workbench/api/browser/mainThreadChatSessions.ts +++ b/src/vs/workbench/api/browser/mainThreadChatSessions.ts @@ -137,7 +137,8 @@ export class ObservableChatSession extends Disposable implements IChatSession { variableData: variables ? { variables } : undefined, id: turn.id, modelId: turn.modelId, - }; + modeInstructions: turn.modeInstructions ? revive(turn.modeInstructions) : undefined, + } satisfies IChatSessionRequestHistoryItem; } return { @@ -326,6 +327,7 @@ export class ObservableChatSession extends Disposable implements IChatSession { command: request.command, variableData: undefined, modelId: request.modelId, + modeInstructions: request.modeInstructions, }; } diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index d02ae88a0f12e..a5ba1c969ddc9 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -58,7 +58,7 @@ import { CallHierarchyItem } from '../../contrib/callHierarchy/common/callHierar import { IChatAgentMetadata, IChatAgentRequest, IChatAgentResult, UserSelectedTools } from '../../contrib/chat/common/participants/chatAgents.js'; import { ICodeMapperRequest, ICodeMapperResult } from '../../contrib/chat/common/editing/chatCodeMapperService.js'; import { IChatContextItem } from '../../contrib/chat/common/contextContrib/chatContext.js'; -import { IChatProgressHistoryResponseContent, IChatRequestVariableData } from '../../contrib/chat/common/model/chatModel.js'; +import { IChatProgressHistoryResponseContent, IChatRequestModeInstructions, IChatRequestVariableData } from '../../contrib/chat/common/model/chatModel.js'; import { ChatResponseClearToPreviousToolInvocationReason, IChatContentInlineReference, IChatExternalEditsDto, IChatFollowup, IChatMultiDiffData, IChatMultiDiffDataSerialized, IChatNotebookEdit, IChatProgress, IChatTask, IChatTaskDto, IChatUserActionEvent, IChatVoteAction } from '../../contrib/chat/common/chatService/chatService.js'; import { IChatSessionItem, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem } from '../../contrib/chat/common/chatSessionsService.js'; import { IChatRequestVariableValue } from '../../contrib/chat/common/attachments/chatVariables.js'; @@ -3584,6 +3584,7 @@ export type IChatSessionHistoryItemDto = { command?: string; variableData?: Dto; modelId?: string; + modeInstructions?: Dto; } | { type: 'response'; parts: IChatProgressDto[]; diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index 12df8ec2edded..f67a043941f9b 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -908,7 +908,8 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS } const editedFileEvents = isProposedApiEnabled(extension, 'chatParticipantPrivate') ? h.request.editedFileEvents : undefined; - const turn = new extHostTypes.ChatRequestTurn(h.request.message, h.request.command, varsWithoutTools, h.request.agentId, toolReferences, editedFileEvents, h.request.requestId); + const modeInstructions2 = isProposedApiEnabled(extension, 'chatParticipantPrivate') && h.request.modeInstructions ? typeConvert.ChatRequestModeInstructions.to(h.request.modeInstructions) : undefined; + const turn = new extHostTypes.ChatRequestTurn(h.request.message, h.request.command, varsWithoutTools, h.request.agentId, toolReferences, editedFileEvents, h.request.requestId, undefined, modeInstructions2); res.push(turn); // RESPONSE turn diff --git a/src/vs/workbench/api/common/extHostChatSessions.ts b/src/vs/workbench/api/common/extHostChatSessions.ts index 93fb3ecebf5e1..f6f8c52756b5b 100644 --- a/src/vs/workbench/api/common/extHostChatSessions.ts +++ b/src/vs/workbench/api/common/extHostChatSessions.ts @@ -692,6 +692,7 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio undefined, request.id, request.modelId, + typeConvert.ChatRequestModeInstructions.to(request.modeInstructions), ); } @@ -730,6 +731,7 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio command: turn.command, variableData: variables.length > 0 ? { variables } : undefined, modelId: turn.modelId, + modeInstructions: typeConvert.ChatRequestModeInstructions.from(turn.modeInstructions2), }; } diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index b4c1b27df3904..7c5949ab1318f 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -3610,13 +3610,33 @@ namespace ChatLanguageModelToolReferences { } export namespace ChatRequestModeInstructions { - export function to(mode: IChatRequestModeInstructions | undefined): vscode.ChatRequestModeInstructions | undefined { + export function to(mode: IChatRequestModeInstructions | Dto | undefined): vscode.ChatRequestModeInstructions | undefined { if (mode) { return { uri: URI.revive(mode.uri), name: mode.name, content: mode.content, - toolReferences: ChatLanguageModelToolReferences.to(mode.toolReferences), + toolReferences: ChatLanguageModelToolReferences.to(revive(mode.toolReferences)), + metadata: mode.metadata, + isBuiltin: mode.isBuiltin, + }; + } + return undefined; + } + + export function from(mode: vscode.ChatRequestModeInstructions | undefined): IChatRequestModeInstructions | undefined { + if (mode) { + return { + uri: mode.uri, + name: mode.name, + content: mode.content, + toolReferences: mode.toolReferences?.map(ref => ({ + kind: 'tool' as const, + id: ref.name, + name: ref.name, + value: undefined, + range: ref.range ? { start: ref.range[0], endExclusive: ref.range[1] } : undefined, + })) ?? [], metadata: mode.metadata, isBuiltin: mode.isBuiltin, }; diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index 25860457deba0..3d02ded161b0b 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -3530,6 +3530,7 @@ export class ChatRequestTurn implements vscode.ChatRequestTurn2 { readonly editedFileEvents?: vscode.ChatRequestEditedFileEvent[], readonly id?: string, readonly modelId?: string, + readonly modeInstructions2?: vscode.ChatRequestModeInstructions, ) { } } diff --git a/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts b/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts index 9d6908373089f..72b161f9b7a25 100644 --- a/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts +++ b/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts @@ -9,6 +9,7 @@ import type * as vscode from 'vscode'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Event } from '../../../../base/common/event.js'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { MarshalledId } from '../../../../base/common/marshallingIds.js'; import { URI } from '../../../../base/common/uri.js'; import { asSinonMethodStub } from '../../../../base/test/common/sinonUtils.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; @@ -177,6 +178,72 @@ suite('ObservableChatSession', function () { assert.ok(session.requestHandler); }); + test('initialization revives modeInstructions in history', async function () { + const sessionContent = createSessionContent({ + history: [ + { + type: 'request', + prompt: 'Hello', + participant: 'test', + modeInstructions: { + uri: { $mid: MarshalledId.Uri, scheme: 'file', path: '/custom-agent' }, + name: 'my-agent', + content: 'instructions', + toolReferences: [], + isBuiltin: false, + }, + }, + ], + }); + + const session = disposables.add(await createInitializedSession(sessionContent)); + const requestItem = session.history[0]; + assert.strictEqual(requestItem.type, 'request'); + if (requestItem.type === 'request') { + assert.ok(requestItem.modeInstructions); + assert.ok(URI.isUri(requestItem.modeInstructions.uri)); + assert.strictEqual(requestItem.modeInstructions.name, 'my-agent'); + assert.strictEqual(requestItem.modeInstructions.isBuiltin, false); + } + }); + + test('toRequestDto passes modeInstructions through', async function () { + const session = disposables.add(await createInitializedSession(createSessionContent({ hasForkHandler: true }))); + assert.ok(session.forkSession); + + const modeInstructions = { + uri: URI.parse('file:///custom-agent'), + name: 'my-agent', + content: 'agent instructions', + toolReferences: [], + isBuiltin: false, + }; + const request: IChatSessionRequestHistoryItem = { + type: 'request', + id: 'req-1', + prompt: 'Hello with mode', + participant: 'participant', + modeInstructions, + }; + + const forkedItem = { + resource: URI.file('/tmp/forked.md'), + label: 'Forked', + changes: [], + timing: { + created: 123, + lastRequestStarted: 234, + lastRequestEnded: 345, + }, + }; + asSinonMethodStub(proxy.$forkChatSession).resolves(forkedItem); + await session.forkSession?.(request, CancellationToken.None); + + const call = asSinonMethodStub(proxy.$forkChatSession).firstCall; + const sentDto = call.args[2] as IChatSessionRequestHistoryItemDto; + assert.deepStrictEqual(sentDto.modeInstructions, modeInstructions); + }); + test('initialization sets forkSession and revives forked items', async function () { const session = disposables.add(await createInitializedSession(createSessionContent({ hasForkHandler: true }))); assert.ok(session.forkSession); @@ -208,6 +275,7 @@ suite('ObservableChatSession', function () { command: undefined, variableData: undefined, modelId: undefined, + modeInstructions: undefined, }; const result = await session.forkSession?.(request, CancellationToken.None); diff --git a/src/vs/workbench/api/test/common/extHostTypeConverters.test.ts b/src/vs/workbench/api/test/common/extHostTypeConverters.test.ts index ed0e1ceae1652..d013b2adfceaa 100644 --- a/src/vs/workbench/api/test/common/extHostTypeConverters.test.ts +++ b/src/vs/workbench/api/test/common/extHostTypeConverters.test.ts @@ -7,8 +7,10 @@ import assert from 'assert'; import { URI, UriComponents } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { IconPathDto } from '../../common/extHost.protocol.js'; -import { IconPath } from '../../common/extHostTypeConverters.js'; +import { ChatRequestModeInstructions, IconPath } from '../../common/extHostTypeConverters.js'; import { ThemeColor, ThemeIcon } from '../../common/extHostTypes.js'; +import { IChatRequestModeInstructions } from '../../../contrib/chat/common/model/chatModel.js'; +import { Dto } from '../../../services/extensions/common/proxyIdentifier.js'; suite('extHostTypeConverters', function () { ensureNoDisposablesAreLeakedInTestSuite(); @@ -120,4 +122,125 @@ suite('extHostTypeConverters', function () { }); }); }); + + suite('ChatRequestModeInstructions', function () { + test('to returns undefined for undefined input', function () { + assert.strictEqual(ChatRequestModeInstructions.to(undefined), undefined); + }); + + test('from returns undefined for undefined input', function () { + assert.strictEqual(ChatRequestModeInstructions.from(undefined), undefined); + }); + + test('to converts IChatRequestModeInstructions to API type', function () { + const uri = URI.parse('file:///custom-agent'); + const input: IChatRequestModeInstructions = { + uri, + name: 'test-mode', + content: 'test content', + toolReferences: [{ + kind: 'tool', + id: 'tool1', + name: 'tool1', + value: undefined, + range: { start: 0, endExclusive: 5 }, + }], + metadata: { key: 'value' }, + isBuiltin: false, + }; + + const result = ChatRequestModeInstructions.to(input)!; + assert.deepStrictEqual(result, { + uri, + name: 'test-mode', + content: 'test content', + toolReferences: [{ name: 'tool1', range: [0, 5] }], + metadata: { key: 'value' }, + isBuiltin: false, + }); + }); + + test('to handles Dto with UriComponents', function () { + const input: Dto = { + uri: { scheme: 'file', path: '/custom-agent' } as UriComponents, + name: 'test-mode', + content: 'test content', + toolReferences: [], + metadata: undefined, + isBuiltin: true, + }; + + const result = ChatRequestModeInstructions.to(input)!; + assert.ok(URI.isUri(result.uri)); + assert.strictEqual(result.name, 'test-mode'); + assert.strictEqual(result.isBuiltin, true); + assert.deepStrictEqual(result.toolReferences, []); + }); + + test('from converts API type to IChatRequestModeInstructions', function () { + const uri = URI.parse('file:///custom-agent'); + const input = { + uri, + name: 'test-mode', + content: 'test content', + toolReferences: [{ name: 'tool1', range: [0, 5] as [number, number] }], + metadata: { key: 'value' }, + isBuiltin: false, + }; + + const result = ChatRequestModeInstructions.from(input)!; + assert.deepStrictEqual(result, { + uri, + name: 'test-mode', + content: 'test content', + toolReferences: [{ + kind: 'tool', + id: 'tool1', + name: 'tool1', + value: undefined, + range: { start: 0, endExclusive: 5 }, + }], + metadata: { key: 'value' }, + isBuiltin: false, + }); + }); + + test('from handles missing toolReferences', function () { + const input = { + name: 'test-mode', + content: 'test content', + }; + + const result = ChatRequestModeInstructions.from(input)!; + assert.deepStrictEqual(result.toolReferences, []); + }); + + test('roundtrip from -> to preserves data', function () { + const uri = URI.parse('file:///custom-agent'); + const apiInput = { + uri, + name: 'roundtrip-mode', + content: 'roundtrip content', + toolReferences: [ + { name: 'tool1' }, + { name: 'tool2', range: [10, 20] as [number, number] }, + ], + metadata: { flag: true }, + isBuiltin: false, + }; + + const internal = ChatRequestModeInstructions.from(apiInput)!; + const backToApi = ChatRequestModeInstructions.to(internal)!; + + assert.strictEqual(backToApi.name, apiInput.name); + assert.strictEqual(backToApi.content, apiInput.content); + assert.strictEqual(backToApi.isBuiltin, apiInput.isBuiltin); + assert.strictEqual(backToApi.uri?.toString(), uri.toString()); + assert.strictEqual(backToApi.toolReferences?.length, 2); + assert.strictEqual(backToApi.toolReferences?.[0].name, 'tool1'); + assert.strictEqual(backToApi.toolReferences?.[0].range, undefined); + assert.strictEqual(backToApi.toolReferences?.[1].name, 'tool2'); + assert.deepStrictEqual(backToApi.toolReferences?.[1].range, [10, 20]); + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts index e74df0c7bcf74..eed955d90f45a 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -221,6 +221,11 @@ const extensionPoint = ExtensionsRegistry.registerExtensionPoint !!req.modeInfo)?.modeInfo; + if (modeInfo && modeInfo.modeInstructions?.uri) { + this.setChatMode(modeInfo.modeInstructions.uri.toString()); + } + if (!lastModelId) { return; } @@ -1623,8 +1637,9 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge // Handle agent option from session - set initial mode if (customAgentTarget) { + const contribution = ctx && this.chatSessionsService.getChatSessionContribution(getChatSessionType(ctx.chatSessionResource)); const agentOption = this.chatSessionsService.getSessionOption(ctx.chatSessionResource, agentOptionId); - if (typeof agentOption !== 'undefined') { + if (typeof agentOption !== 'undefined' && !contribution?.useRequestToPopulateBuiltInPickers) { const agentId = (typeof agentOption === 'string' ? agentOption : agentOption.id) || ChatMode.Agent.id; const currentMode = this._currentModeObservable.get(); const isDefaultAgent = agentId === ChatMode.Agent.id; diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index a568beff46956..8867a746b59de 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -36,7 +36,7 @@ import { IMcpService } from '../../../mcp/common/mcpTypes.js'; import { awaitStatsForSession } from '../chat.js'; import { IChatAgentCommand, IChatAgentData, IChatAgentHistoryEntry, IChatAgentRequest, IChatAgentResult, IChatAgentService } from '../participants/chatAgents.js'; import { chatEditingSessionIsReady } from '../editing/chatEditingService.js'; -import { ChatModel, ChatRequestModel, ChatRequestRemovalReason, IChatModel, IChatRequestModel, IChatRequestVariableData, IChatResponseModel, IExportableChatData, ISerializableChatData, ISerializableChatDataIn, ISerializableChatsData, ISerializedChatDataReference, normalizeSerializableChatData, toChatHistoryContent, updateRanges } from '../model/chatModel.js'; +import { ChatModel, ChatRequestModel, ChatRequestRemovalReason, IChatModel, IChatRequestModel, IChatRequestModeInfo, IChatRequestVariableData, IChatResponseModel, IExportableChatData, ISerializableChatData, ISerializableChatDataIn, ISerializableChatsData, ISerializedChatDataReference, normalizeSerializableChatData, toChatHistoryContent, updateRanges, ISerializableChatModelInputState } from '../model/chatModel.js'; import { ChatModelStore, IStartSessionProps } from '../model/chatModelStore.js'; import { chatAgentLeader, ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestSlashCommandPart, ChatRequestTextPart, chatSubcommandLeader, getPromptText, IParsedChatRequest } from '../requestParser/chatParserTypes.js'; import { ChatRequestParser } from '../requestParser/chatRequestParser.js'; @@ -56,6 +56,8 @@ import { IPromptsService } from '../promptSyntax/service/promptsService.js'; import { AGENT_DEBUG_LOG_ENABLED_SETTING, AGENT_DEBUG_LOG_FILE_LOGGING_ENABLED_SETTING, TROUBLESHOOT_COMMAND_NAME, TROUBLESHOOT_SKILL_PATH, COPILOT_SKILL_URI_SCHEME } from '../promptSyntax/promptTypes.js'; import { ChatRequestHooks, mergeHooks } from '../promptSyntax/hookSchema.js'; import { ResourceMap } from '../../../../../base/common/map.js'; +import { findLast } from '../../../../../base/common/arraysFind.js'; +import { ChatMode } from '../chatModes.js'; const serializedChatKey = 'interactive.sessions'; @@ -607,12 +609,44 @@ export class ChatService extends Disposable implements IChatService { return existingRef; } } - const chatSessionType = getChatSessionType(sessionResource); + const contribution = this.chatSessionService.getChatSessionContribution(chatSessionType); + const modelId = findLast(providedSession.history.filter(m => m.type === 'request'), req => req.modelId)?.modelId; + const agentUri = findLast(providedSession.history.filter(m => m.type === 'request'), req => req.modeInstructions?.uri)?.modeInstructions?.uri; + let initialData: ISerializedChatDataReference | undefined = undefined; + if ((modelId || agentUri) && contribution?.useRequestToPopulateBuiltInPickers) { + const mode: ISerializableChatModelInputState['mode'] = agentUri ? { kind: ChatModeKind.Agent, id: agentUri.toString() } : { kind: ChatModeKind.Agent, id: ChatMode.Agent.id }; + const modelMetadata = modelId ? this.languageModelsService.lookupLanguageModel(modelId) : undefined; + const selectedModel: ISerializableChatModelInputState['selectedModel'] = modelId && modelMetadata ? { identifier: modelId, metadata: modelMetadata } : undefined; + // This is used to initialize the state of the chat input box, with the selected model, mode, etc + initialData = { + serializer: new ChatSessionOperationLog(), + value: { + creationDate: Date.now(), + initialLocation: undefined, + customTitle: undefined, + requests: [], + responderUsername: '', + sessionId: '', + version: 3, + hasPendingEdits: undefined, + inputState: { + attachments: [], + contrib: {}, + inputText: '', + mode, + selectedModel: selectedModel, + selections: [] + }, + pendingRequests: undefined, + repoData: undefined + } + }; + } // Contributed sessions do not use UI tools const modelRef = this._sessionModels.acquireOrCreate({ - initialData: undefined, + initialData, location, sessionResource: sessionResource, canUseTools: false, @@ -656,10 +690,17 @@ export class ChatService extends Disposable implements IChatService { message.participant ? this.chatAgentService.getAgent(message.participant) // TODO(jospicer): Remove and always hardcode? : this.chatAgentService.getAgent(chatSessionType); + const modeInfo = message.modeInstructions ? { + kind: ChatModeKind.Agent, + isBuiltin: message.modeInstructions.isBuiltin ?? false, + modeInstructions: message.modeInstructions, + modeId: 'custom', + applyCodeBlockSuggestionId: undefined, + } satisfies IChatRequestModeInfo : undefined; lastRequest = model.addRequest(parsedRequest, message.variableData ?? { variables: [] }, 0, // attempt - undefined, + modeInfo, agent, undefined, // slashCommand undefined, // confirmation @@ -1510,6 +1551,7 @@ export class ChatService extends Disposable implements IChatService { variables: updateRanges(request.variableData, promptTextResult.diff), // TODO bit of a hack location: ChatAgentLocation.Chat, editedFileEvents: request.editedFileEvents, + modeInstructions: request.modeInfo?.modeInstructions, }; history.push({ request: historyRequest, response: toChatHistoryContent(request.response.response.value), result: request.response.result ?? {} }); } diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index bc68d0d77c77e..da8fcc2020a84 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -13,7 +13,7 @@ import { URI } from '../../../../base/common/uri.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { IChatAgentAttachmentCapabilities, IChatAgentRequest } from './participants/chatAgents.js'; import { IChatEditingSession } from './editing/chatEditingService.js'; -import { IChatModel, IChatRequestVariableData, ISerializableChatModelInputState } from './model/chatModel.js'; +import { IChatModel, IChatRequestModeInstructions, IChatRequestVariableData, ISerializableChatModelInputState } from './model/chatModel.js'; import { IChatProgress, IChatSessionTiming } from './chatService/chatService.js'; import { Target } from './promptSyntax/promptTypes.js'; @@ -104,6 +104,10 @@ export interface IChatSessionsExtensionPoint { * for this session type. Defaults to false when not specified. */ readonly autoAttachReferences?: boolean; + /** + * When true, uses the incoming request's mode instructions to populate the built-in pickers such as Agent and Model pickers. When false, the pickers are populated based on the session type as they have been before. This is useful for testing the new ChatRequestTurn2-based flow for populating pickers. + */ + readonly useRequestToPopulateBuiltInPickers?: boolean; } export interface IChatSessionItem { @@ -147,6 +151,7 @@ export type IChatSessionHistoryItem = { command?: string; variableData?: IChatRequestVariableData; modelId?: string; + modeInstructions?: IChatRequestModeInstructions; } | { type: 'response'; parts: IChatProgress[]; diff --git a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts index 32d02e5104860..ba9dee1439d1b 100644 --- a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts +++ b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts @@ -199,10 +199,15 @@ declare module 'vscode' { */ readonly modelId?: string; + /** + * The mode instructions that were active for this request, if any. + */ + readonly modeInstructions2?: ChatRequestModeInstructions; + /** * @hidden */ - constructor(prompt: string, command: string | undefined, references: ChatPromptReference[], participant: string, toolReferences: ChatLanguageModelToolReference[], editedFileEvents: ChatRequestEditedFileEvent[] | undefined, id: string | undefined, modelId: string | undefined); + constructor(prompt: string, command: string | undefined, references: ChatPromptReference[], participant: string, toolReferences: ChatLanguageModelToolReference[], editedFileEvents: ChatRequestEditedFileEvent[] | undefined, id: string | undefined, modelId: string | undefined, modeInstructions2: ChatRequestModeInstructions | undefined); } export class ChatResponseTurn2 {