diff --git a/build/lib/i18n.resources.json b/build/lib/i18n.resources.json index 76c1462e389..ade53b78639 100644 --- a/build/lib/i18n.resources.json +++ b/build/lib/i18n.resources.json @@ -668,6 +668,10 @@ "name": "vs/sessions/contrib/logs", "project": "vscode-sessions" }, + { + "name": "vs/sessions/contrib/remoteAgentHost", + "project": "vscode-sessions" + }, { "name": "vs/sessions/contrib/sessions", "project": "vscode-sessions" diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 7cbe07bdd7e..94b2c6cb56d 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -32,6 +32,7 @@ export interface IAgentSessionMetadata { readonly startTime: number; readonly modifiedTime: number; readonly summary?: string; + readonly workingDirectory?: string; } export type AgentProvider = string; 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 8dfc004dcbd..4c38cb047de 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 40f4b2734f4..3b5eb0b636e 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 676841e0728..34c445623f7 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 ────────────────────────────────────────────────────────── @@ -255,25 +255,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 ──────────────────────────────────────────────────────── @@ -427,3 +433,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 638189c2bc1..d8f1d609b78 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,15 @@ 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, } 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 395da78f6ea..edbb71701d1 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 3a55ca3b658..ea497c9127b 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 ce78d37dc40..4aa21b64e8b 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 a037ca22059..a2d6e1f8a50 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 94193f19930..1e6dcd41b1c 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/sessionActions.ts b/src/vs/platform/agentHost/common/state/sessionActions.ts index e1242a2a995..7cf64cba44a 100644 --- a/src/vs/platform/agentHost/common/state/sessionActions.ts +++ b/src/vs/platform/agentHost/common/state/sessionActions.ts @@ -49,8 +49,10 @@ export { export { NotificationType, + AuthRequiredReason, type ISessionAddedNotification, type ISessionRemovedNotification, + type IAuthRequiredNotification, } from './protocol/notifications.js'; // ---- Local aliases for short names ------------------------------------------ diff --git a/src/vs/platform/agentHost/common/state/versions/versionRegistry.ts b/src/vs/platform/agentHost/common/state/versions/versionRegistry.ts index ed6aa21ebcd..5412b4f608b 100644 --- a/src/vs/platform/agentHost/common/state/versions/versionRegistry.ts +++ b/src/vs/platform/agentHost/common/state/versions/versionRegistry.ts @@ -54,6 +54,7 @@ export const ACTION_INTRODUCED_IN: { readonly [K in IStateAction['type']]: numbe export const NOTIFICATION_INTRODUCED_IN: { readonly [K in INotification['type']]: number } = { 'notify/sessionAdded': 1, 'notify/sessionRemoved': 1, + 'notify/authRequired': 1, }; // ---- Runtime filtering helpers ---------------------------------------------- diff --git a/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts index bbe0e722662..b2cfa7eee0c 100644 --- a/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts @@ -87,7 +87,17 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC clientId: this._clientId, }); this._serverSeq = result.serverSeq; - this._defaultDirectory = result.defaultDirectory; + // defaultDirectory arrives from the protocol as either a URI string + // (e.g. "file:///Users/roblou") or a serialized URI object + // ({ scheme, path, ... }). Extract just the filesystem path. + if (result.defaultDirectory) { + const dir = result.defaultDirectory; + if (typeof dir === 'string') { + this._defaultDirectory = URI.parse(dir).path; + } else { + this._defaultDirectory = URI.revive(dir).path; + } + } } /** @@ -179,6 +189,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC startTime: s.createdAt, modifiedTime: s.modifiedAt, summary: s.title, + workingDirectory: typeof s.workingDirectory === 'string' ? s.workingDirectory : undefined, })); } diff --git a/src/vs/platform/agentHost/node/agentHostMain.ts b/src/vs/platform/agentHost/node/agentHostMain.ts index 69cd971c22e..20df9b72167 100644 --- a/src/vs/platform/agentHost/node/agentHostMain.ts +++ b/src/vs/platform/agentHost/node/agentHostMain.ts @@ -145,6 +145,7 @@ async function startWebSocketServer(agentService: AgentService, logService: ILog status: SessionStatus.Idle, createdAt: s.startTime, modifiedAt: s.modifiedTime, + workingDirectory: s.workingDirectory, })); }, diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 8b375c9cc95..7dad86b7ad3 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -153,6 +153,7 @@ export class AgentService extends Disposable implements IAgentService { status: SessionStatus.Idle, createdAt: Date.now(), modifiedAt: Date.now(), + workingDirectory: config?.workingDirectory, }; this._stateManager.createSession(summary); this._stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: session.toString() }); diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 3bdab46e55d..efe3e9c8276 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -199,6 +199,7 @@ export class AgentSideEffects extends Disposable implements IProtocolSideEffectH status: SessionStatus.Idle, createdAt: Date.now(), modifiedAt: Date.now(), + workingDirectory: command.workingDirectory, }; this._stateManager.createSession(summary); this._stateManager.dispatchServerAction({ type: ActionType.SessionReady, session }); diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 030b3cfe2c7..95c0d966482 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -156,11 +156,12 @@ export class CopilotAgent extends Disposable implements IAgent { this._logService.info('[Copilot] Listing sessions...'); const client = await this._ensureClient(); const sessions = await client.listSessions(); - const result = sessions.map(s => ({ + const result: IAgentSessionMetadata[] = sessions.map(s => ({ session: AgentSession.uri(this.id, s.sessionId), startTime: s.startTime.getTime(), modifiedTime: s.modifiedTime.getTime(), summary: s.summary, + workingDirectory: typeof s.context?.cwd === 'string' ? s.context.cwd : undefined, })); this._logService.info(`[Copilot] Found ${result.length} sessions`); return result; diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index ef47a34c59f..a7e1ecc5b59 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -54,7 +54,7 @@ function jsonRpcErrorFrom(id: number, err: unknown): IJsonRpcResponse { * Methods handled by the request dispatcher. Excludes `initialize` and * `reconnect` which are handled during the handshake phase. */ -type RequestMethod = Exclude; +type RequestMethod = Exclude; /** * Typed handler map: each key is a request method, each value is a handler diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index edb09c2187c..331edec68ab 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -37,7 +37,7 @@ import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js' import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { localize } from '../../../../nls.js'; import * as aria from '../../../../base/browser/ui/aria/aria.js'; -import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; +import { AgentSessionProviders, AgentSessionTarget } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; import { ChatSessionPosition, getResourceForNewChatSession } from '../../../../workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.js'; import { ChatSessionPickerActionItem, IChatSessionPickerDelegate } from '../../../../workbench/contrib/chat/browser/chatSessions/chatSessionPickerActionItem.js'; @@ -56,7 +56,7 @@ import { NewChatContextAttachments } from './newChatContextAttachments.js'; import { IGitService } from '../../../../workbench/contrib/git/common/gitService.js'; import { SessionTypePicker, IsolationPicker } from './sessionTargetPicker.js'; import { BranchPicker } from './branchPicker.js'; -import { INewSession, ISessionOptionGroup, RemoteNewSession } from './newSession.js'; +import { AgentHostNewSession, INewSession, ISessionOptionGroup, RemoteNewSession } from './newSession.js'; import { CloudModelPicker } from './modelPicker.js'; import { WorkspacePicker } from './workspacePicker.js'; import { SessionWorkspace } from '../../sessions/common/sessionWorkspace.js'; @@ -70,6 +70,8 @@ import { ChatHistoryNavigator } from '../../../../workbench/contrib/chat/common/ import { IHistoryNavigationWidget } from '../../../../base/browser/history.js'; import { NewChatPermissionPicker } from './newChatPermissionPicker.js'; import { registerAndCreateHistoryNavigationContext, IHistoryNavigationContext } from '../../../../platform/history/browser/contextScopedHistoryWidget.js'; +import { IRemoteAgentHostService } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { getRemoteAgentHostSessionTarget } from '../../remoteAgentHost/browser/remoteAgentHost.contribution.js'; const STORAGE_KEY_DRAFT_STATE = 'sessions.draftState'; const MIN_EDITOR_HEIGHT = 50; @@ -180,6 +182,7 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { @IGitService private readonly gitService: IGitService, @IStorageService private readonly storageService: IStorageService, @IWorkspaceTrustRequestService private readonly workspaceTrustRequestService: IWorkspaceTrustRequestService, + @IRemoteAgentHostService private readonly remoteAgentHostService: IRemoteAgentHostService, ) { super(); this._history = this._register(this.instantiationService.createInstance(ChatHistoryNavigator, ChatAgentLocation.Chat)); @@ -325,7 +328,20 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { } private async _createNewSession(project?: SessionWorkspace): Promise { - const target = project?.isRepo ? AgentSessionProviders.Cloud : AgentSessionProviders.Background; + const isAgentHost = project?.isRemoteAgentHost ?? false; + let target: AgentSessionTarget; + if (isAgentHost) { + // Find the matching remote agent host session type from the URI authority + // TODO@roblourens HACK - view should not do this + const remoteTarget = getRemoteAgentHostSessionTarget(this.remoteAgentHostService.connections, project!.uri.authority); + if (!remoteTarget) { + this.logService.error(`Failed to find remote agent host session type for authority: ${project!.uri.authority}`); + return; + } + target = remoteTarget; + } else { + target = project?.isRepo ? AgentSessionProviders.Cloud : AgentSessionProviders.Background; + } const resource = getResourceForNewChatSession({ type: target, @@ -334,7 +350,7 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { }); try { - const session = await this.sessionsManagementService.createNewSessionForTarget(target, resource); + const session = await this.sessionsManagementService.createNewSessionForTarget(target, resource, { agentHost: isAgentHost }); if (project) { session.setProject(project); } @@ -370,7 +386,9 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { this._sessionTypePicker.setProject(session.project); - if (session instanceof RemoteNewSession) { + if (session instanceof AgentHostNewSession) { + this._renderAgentHostSessionPickers(); + } else if (session instanceof RemoteNewSession) { this._renderRemoteSessionPickers(session, true); listeners.add(session.onDidChangeOptionGroups(() => { this._renderRemoteSessionPickers(session); @@ -688,6 +706,24 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { this._workspacePicker.render(pickersRow); } + // --- Agent Host session pickers --- + + /** + * Agent Host sessions use the standard model picker and mode picker + * but don't need repo, folder, isolation, branch, or cloud option pickers. + */ + private _renderAgentHostSessionPickers(): void { + this._clearAllPickers(); + if (this._localModelPickerContainer) { + this._localModelPickerContainer.style.display = ''; + } + this._modePicker.setVisible(true); + this._permissionPicker.setVisible(false); + this._cloudModelPicker.setVisible(false); + this._branchPicker.setVisible(false); + this._isolationPicker.setVisible(false); + } + // --- Local session pickers --- private _renderLocalSessionPickers(): void { @@ -960,11 +996,11 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { * For Local/Background targets, checks the folder picker. * For other targets, checks extension-contributed repo/folder option groups. */ - private _hasRequiredRepoOrFolderSelection(_sessionType: AgentSessionProviders): boolean { + private _hasRequiredRepoOrFolderSelection(_sessionType: AgentSessionTarget): boolean { return !!this._newSession.value?.project; } - private _openRepoOrFolderPicker(_sessionType: AgentSessionProviders): void { + private _openRepoOrFolderPicker(_sessionType: AgentSessionTarget): void { this._workspacePicker.showPicker(); } diff --git a/src/vs/sessions/contrib/chat/browser/newSession.ts b/src/vs/sessions/contrib/chat/browser/newSession.ts index b6d439ec696..d567b8ffbb8 100644 --- a/src/vs/sessions/contrib/chat/browser/newSession.ts +++ b/src/vs/sessions/contrib/chat/browser/newSession.ts @@ -9,7 +9,7 @@ import { URI } from '../../../../base/common/uri.js'; import { IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { IsolationMode } from './sessionTargetPicker.js'; import { SessionWorkspace } from '../../sessions/common/sessionWorkspace.js'; -import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; +import { AgentSessionProviders, AgentSessionTarget } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; @@ -32,7 +32,7 @@ export interface ISessionOptionGroup { */ export interface INewSession extends IDisposable { readonly resource: URI; - readonly target: AgentSessionProviders; + readonly target: AgentSessionTarget; readonly project: SessionWorkspace | undefined; readonly isolationMode: IsolationMode | undefined; readonly branch: string | undefined; @@ -206,7 +206,7 @@ export class RemoteNewSession extends Disposable implements INewSession { constructor( readonly resource: URI, - readonly target: AgentSessionProviders, + readonly target: AgentSessionTarget, @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, @IContextKeyService private readonly contextKeyService: IContextKeyService, ) { @@ -365,3 +365,78 @@ function isModelOptionGroup(group: IChatSessionProviderOptionGroup): boolean { function isRepositoriesOptionGroup(group: IChatSessionProviderOptionGroup): boolean { return group.id === 'repositories'; } + +/** + * New session for agent host sessions (local or remote agent host processes). + * Agent host sessions use local model and mode pickers but don't need + * isolation mode, branch selection, or cloud option groups. + */ +export class AgentHostNewSession extends Disposable implements INewSession { + + private _project: SessionWorkspace | undefined; + private _modelId: string | undefined; + private _mode: IChatMode | undefined; + private _query: string | undefined; + private _attachedContext: IChatRequestVariableEntry[] | undefined; + + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange: Event = this._onDidChange.event; + + readonly selectedOptions = new Map(); + + get project(): SessionWorkspace | undefined { return this._project; } + get isolationMode(): undefined { return undefined; } + get branch(): undefined { return undefined; } + get modelId(): string | undefined { return this._modelId; } + get mode(): IChatMode | undefined { return this._mode; } + get query(): string | undefined { return this._query; } + get attachedContext(): IChatRequestVariableEntry[] | undefined { return this._attachedContext; } + get disabled(): boolean { return false; } + + constructor( + readonly resource: URI, + readonly target: AgentSessionTarget, + ) { + super(); + } + + setProject(project: SessionWorkspace): void { + this._project = project; + this._onDidChange.fire('repoUri'); + } + + setIsolationMode(_mode: IsolationMode): void { + // No-op for agent host sessions + } + + setBranch(_branch: string | undefined): void { + // No-op for agent host sessions + } + + setModelId(modelId: string | undefined): void { + this._modelId = modelId; + } + + setMode(mode: IChatMode | undefined): void { + if (this._mode?.id !== mode?.id) { + this._mode = mode; + this._onDidChange.fire('agent'); + } + } + + setQuery(query: string): void { + this._query = query; + } + + setAttachedContext(context: IChatRequestVariableEntry[] | undefined): void { + this._attachedContext = context; + } + + setOption(optionId: string, value: IChatSessionProviderOptionItem | string): void { + if (typeof value === 'string') { + this.selectedOptions.set(optionId, { id: value, name: value }); + } else { + this.selectedOptions.set(optionId, value); + } + } +} diff --git a/src/vs/sessions/contrib/chat/browser/workspacePicker.ts b/src/vs/sessions/contrib/chat/browser/workspacePicker.ts index a31509424fe..83d70b4c131 100644 --- a/src/vs/sessions/contrib/chat/browser/workspacePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/workspacePicker.ts @@ -18,6 +18,10 @@ import { IStorageService, StorageScope, StorageTarget } from '../../../../platfo import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { GITHUB_REMOTE_FILE_SCHEME, SessionWorkspace } from '../../sessions/common/sessionWorkspace.js'; +import { IRemoteAgentHostService } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { IQuickInputService } from '../../../../platform/quickinput/common/quickInput.js'; +import { agentHostAuthority } from '../../remoteAgentHost/browser/remoteAgentHost.contribution.js'; +import { AGENT_HOST_FS_SCHEME, agentHostUri } from '../../remoteAgentHost/browser/agentHostFileSystemProvider.js'; const OPEN_REPO_COMMAND = 'github.copilot.chat.cloudSessions.openRepository'; const STORAGE_KEY_LAST_PROJECT = 'sessions.lastPickedProject'; @@ -33,6 +37,7 @@ const LEGACY_STORAGE_KEY_RECENT_REPOS = 'agentSessions.recentlyPickedRepos'; const COMMAND_BROWSE_FOLDERS = 'command:browseFolders'; const COMMAND_BROWSE_REPOS = 'command:browseRepos'; +const COMMAND_BROWSE_REMOTE_AGENT_HOSTS = 'command:browseRemoteAgentHosts'; /** * Serializable form of a project entry for storage. @@ -40,6 +45,8 @@ const COMMAND_BROWSE_REPOS = 'command:browseRepos'; interface IStoredProject { readonly uri: UriComponents; readonly checked?: boolean; + /** Cached display name for remote agent host connections. */ + readonly remoteName?: string; } /** @@ -72,6 +79,8 @@ export class WorkspacePicker extends Disposable { @IFileDialogService private readonly fileDialogService: IFileDialogService, @ICommandService private readonly commandService: ICommandService, @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, + @IRemoteAgentHostService private readonly remoteAgentHostService: IRemoteAgentHostService, + @IQuickInputService private readonly quickInputService: IQuickInputService, ) { super(); @@ -200,6 +209,8 @@ export class WorkspacePicker extends Disposable { this._browseForFolder(); } else if (uriStr === COMMAND_BROWSE_REPOS) { this._browseForRepo(); + } else if (uriStr === COMMAND_BROWSE_REMOTE_AGENT_HOSTS) { + this._browseForRemoteAgentHost(); } else { this._selectProject(this._fromStored(item)); } @@ -256,7 +267,7 @@ export class WorkspacePicker extends Disposable { private _selectProject(project: SessionWorkspace, fireEvent = true): void { this._selectedProject = project; - const stored = this._toStored(project); + const stored = this._withCachedRemoteName(this._toStored(project)); this._addToRecents(stored); this.storageService.store(STORAGE_KEY_LAST_PROJECT, JSON.stringify(stored), StorageScope.PROFILE, StorageTarget.MACHINE); this._updateTriggerLabel(); @@ -292,7 +303,65 @@ export class WorkspacePicker extends Disposable { } } + private async _browseForRemoteAgentHost(): Promise { + const connections = this.remoteAgentHostService.connections; + if (connections.length === 0) { + return; + } + + // Show remote picker even with a single connection so the user + // can see which remote they are connecting to. + let selectedAddress: string; + let selectedName: string; + let defaultDirectory: string | undefined; + { + const picks = connections.map(c => ({ + label: c.name, + description: c.address, + address: c.address, + defaultDirectory: c.defaultDirectory, + })); + + const picked = await this.quickInputService.pick(picks, { + title: localize('selectRemote', "Select Remote"), + placeHolder: localize('selectRemotePlaceholder', "Choose a remote agent host"), + }); + if (!picked) { + return; + } + selectedAddress = picked.address; + selectedName = picked.label; + defaultDirectory = picked.defaultDirectory; + } + + // Open a folder picker scoped to the remote filesystem. + // The defaultUri carries both the scheme (agenthost) and authority + // (sanitized address), so SimpleFileDialog stays scoped to this + // particular remote connection. + const authority = agentHostAuthority(selectedAddress); + const defaultUri = defaultDirectory + ? agentHostUri(authority, defaultDirectory) + : agentHostUri(authority, '/'); + + try { + const selected = await this.fileDialogService.showOpenDialog({ + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + title: localize('selectRemoteFolder', "Select Folder on {0}", selectedName), + availableFileSystems: [AGENT_HOST_FS_SCHEME], + defaultUri, + }); + if (selected?.[0]) { + this._selectProject(new SessionWorkspace(selected[0])); + } + } catch { + // dialog was cancelled or failed + } + } + private _addToRecents(stored: IStoredProject): void { + stored = this._withCachedRemoteName(stored); this._recentProjects = [ stored, ...this._recentProjects.filter(p => !this._isSameProject(p, stored)), @@ -305,51 +374,65 @@ export class WorkspacePicker extends Disposable { } private _buildItems(): IActionListItem[] { - const seen = new Set(); const items: IActionListItem[] = []; // Collect all projects (current + recents), deduped const allProjects: IStoredProject[] = []; if (this._selectedProject) { - const stored = this._toStored(this._selectedProject); - seen.add(this._projectKey(stored)); + const stored = this._withCachedRemoteName(this._toStored(this._selectedProject)); allProjects.push(stored); } for (const project of this._recentProjects) { - const key = this._projectKey(project); - if (!seen.has(key)) { - seen.add(key); + if (!allProjects.some(p => this._isSameProject(p, project))) { allProjects.push(project); } } - // Split into folders and repos, sort each group alphabetically - const isStoredFolder = (p: IStoredProject) => URI.revive(p.uri).scheme !== GITHUB_REMOTE_FILE_SCHEME; + // 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; + }; + const isStoredRemote = (p: IStoredProject) => URI.revive(p.uri).scheme === AGENT_HOST_FS_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)).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))); - const selectedKey = this._selectedProject ? this._projectKey(this._toStored(this._selectedProject)) : undefined; + const selectedStored = this._selectedProject ? this._toStored(this._selectedProject) : undefined; + const isSelected = (p: IStoredProject) => !!selectedStored && this._isSameProject(p, selectedStored); // Folders first for (const project of folders) { - const isSelected = selectedKey !== undefined && this._projectKey(project) === selectedKey; + const selected = isSelected(project); items.push({ kind: ActionListItemKind.Action, label: this._getStoredProjectLabel(project), group: { title: '', icon: Codicon.folder }, - item: isSelected ? { ...project, checked: true } : project, + item: selected ? { ...project, checked: true } : project, onRemove: () => this._removeProject(project), }); } // Then repos for (const project of repos) { - const isSelected = selectedKey !== undefined && this._projectKey(project) === selectedKey; + const selected = isSelected(project); items.push({ kind: ActionListItemKind.Action, label: this._getStoredProjectLabel(project), group: { title: '', icon: Codicon.repo }, - item: isSelected ? { ...project, checked: true } : project, + item: selected ? { ...project, checked: true } : project, + onRemove: () => this._removeProject(project), + }); + } + + // Then remotes + for (const project of remotes) { + const selected = isSelected(project); + items.push({ + kind: ActionListItemKind.Action, + label: this._getStoredProjectLabel(project), + group: { title: '', icon: Codicon.remote }, + item: selected ? { ...project, checked: true } : project, onRemove: () => this._removeProject(project), }); } @@ -370,6 +453,14 @@ export class WorkspacePicker extends Disposable { group: { title: '', icon: Codicon.repo }, item: { uri: URI.parse(COMMAND_BROWSE_REPOS).toJSON() }, }); + if (this.remoteAgentHostService.connections.length > 0) { + items.push({ + kind: ActionListItemKind.Action, + label: localize('browseRemotes', "Browse Remotes..."), + group: { title: '', icon: Codicon.remote }, + item: { uri: URI.parse(COMMAND_BROWSE_REMOTE_AGENT_HOSTS).toJSON() }, + }); + } return items; } @@ -387,7 +478,9 @@ export class WorkspacePicker extends Disposable { dom.clearNode(this._triggerElement); const project = this._selectedProject; const label = project ? this._getProjectLabel(project) : localize('pickWorkspace', "Pick a Workspace"); - const icon = project ? (project.isFolder ? Codicon.folder : Codicon.repo) : Codicon.project; + const icon = project + ? (project.isRemoteAgentHost ? Codicon.remote : project.isFolder ? Codicon.folder : Codicon.repo) + : Codicon.project; dom.append(this._triggerElement, renderIcon(icon)); const labelSpan = dom.append(this._triggerElement, dom.$('span.sessions-chat-dropdown-label')); @@ -396,11 +489,17 @@ export class WorkspacePicker extends Disposable { } private _getProjectLabel(project: SessionWorkspace): string { - return this._getStoredProjectLabel({ uri: project.uri.toJSON() }); + return this._getStoredProjectLabel(this._withCachedRemoteName(this._toStored(project))); } private _getStoredProjectLabel(project: IStoredProject): string { const uri = URI.revive(project.uri); + // TODO@roblourens HACK + if (uri.scheme === AGENT_HOST_FS_SCHEME) { + const folderName = basename(uri) || uri.path || '/'; + const remoteName = this._getRemoteName(uri.authority) ?? project.remoteName ?? uri.authority; + return `${folderName} [${remoteName}]`; + } if (uri.scheme !== GITHUB_REMOTE_FILE_SCHEME) { return basename(uri); } @@ -408,18 +507,46 @@ export class WorkspacePicker extends Disposable { return uri.path.substring(1).replace(/\/HEAD$/, ''); } + /** + * Resolves a sanitized authority back to a user-facing remote name. + */ + private _getRemoteName(authority: string): string | undefined { + for (const conn of this.remoteAgentHostService.connections) { + if (agentHostAuthority(conn.address) === authority) { + return conn.name; + } + } + return undefined; + } + private _toStored(project: SessionWorkspace): IStoredProject { - return { - uri: project.uri.toJSON(), - }; + const uri = project.uri; + const stored: IStoredProject = { uri: uri.toJSON() }; + if (uri.scheme === AGENT_HOST_FS_SCHEME) { + const remoteName = this._getRemoteName(uri.authority); + if (remoteName) { + return { ...stored, remoteName }; + } + } + return stored; } private _fromStored(stored: IStoredProject): SessionWorkspace { return new SessionWorkspace(URI.revive(stored.uri)); } - private _projectKey(project: IStoredProject): string { - return URI.revive(project.uri).toString(); + /** + * If the stored project is missing a cached remoteName, tries to recover + * 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) { + const cached = this._recentProjects.find(p => this._isSameProject(p, stored)); + if (cached?.remoteName) { + return { ...stored, remoteName: cached.remoteName }; + } + } + return stored; } private _isSameProject(a: IStoredProject, b: IStoredProject): boolean { diff --git a/src/vs/sessions/contrib/chat/test/browser/workspacePicker.test.ts b/src/vs/sessions/contrib/chat/test/browser/workspacePicker.test.ts new file mode 100644 index 00000000000..60f669931b3 --- /dev/null +++ b/src/vs/sessions/contrib/chat/test/browser/workspacePicker.test.ts @@ -0,0 +1,196 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Event } from '../../../../../base/common/event.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { InMemoryStorageService, IStorageService } from '../../../../../platform/storage/common/storage.js'; +import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; +import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; +import { ExtUri } from '../../../../../base/common/resources.js'; +import { IRemoteAgentHostService, IRemoteAgentHostConnectionInfo } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.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'; + +suite('WorkspacePicker', () => { + + const ds = ensureNoDisposablesAreLeakedInTestSuite(); + let instantiationService: TestInstantiationService; + let connections: IRemoteAgentHostConnectionInfo[]; + + setup(() => { + instantiationService = ds.add(new TestInstantiationService()); + connections = []; + + instantiationService.stub(IStorageService, ds.add(new InMemoryStorageService())); + instantiationService.stub(IActionWidgetService, new class extends mock() { + override get isVisible() { return false; } + }); + instantiationService.stub(IFileDialogService, new class extends mock() { }); + instantiationService.stub(ICommandService, new class extends mock() { }); + instantiationService.stub(IUriIdentityService, new class extends mock() { + override readonly extUri = new ExtUri(uri => false); + }); + instantiationService.stub(IRemoteAgentHostService, new class extends mock() { + override readonly onDidChangeConnections = Event.None; + override get connections() { return connections; } + override getConnection() { return undefined; } + }); + instantiationService.stub(IQuickInputService, new class extends mock() { }); + }); + + function createPicker(): WorkspacePicker { + return ds.add(instantiationService.createInstance(WorkspacePicker)); + } + + test('setSelectedProject with local folder', () => { + const picker = createPicker(); + const folder = new SessionWorkspace(URI.file('/home/user/project')); + + picker.setSelectedProject(folder); + + assert.ok(picker.selectedProject); + assert.strictEqual(picker.selectedProject.isFolder, true); + assert.strictEqual(picker.selectedProject.uri.path, '/home/user/project'); + }); + + test('setSelectedProject with remote agent host URI', () => { + const picker = createPicker(); + const authority = agentHostAuthority('http://myremote:3000'); + const remoteUri = agentHostUri(authority, '/home/user/project'); + const project = new SessionWorkspace(remoteUri); + + picker.setSelectedProject(project); + + 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.path, '/home/user/project'); + }); + + test('setSelectedProject with GitHub repo URI', () => { + const picker = createPicker(); + const repoUri = URI.from({ scheme: GITHUB_REMOTE_FILE_SCHEME, authority: 'github', path: '/owner/repo/HEAD' }); + const project = new SessionWorkspace(repoUri); + + picker.setSelectedProject(project); + + assert.ok(picker.selectedProject); + assert.strictEqual(picker.selectedProject.isRepo, true); + }); + + test('onDidSelectProject fires when project is selected', () => { + const picker = createPicker(); + const authority = agentHostAuthority('http://myremote:3000'); + const remoteUri = agentHostUri(authority, '/remote/path'); + const project = new SessionWorkspace(remoteUri); + + let fired: SessionWorkspace | undefined; + ds.add(picker.onDidSelectProject(p => { fired = p; })); + + picker.setSelectedProject(project, true); + + assert.ok(fired); + assert.strictEqual(fired.isRemoteAgentHost, true); + assert.strictEqual(fired.uri.path, '/remote/path'); + }); + + test('onDidSelectProject does not fire when fireEvent is false', () => { + const picker = createPicker(); + const project = new SessionWorkspace(URI.file('/some/folder')); + + let fired = false; + ds.add(picker.onDidSelectProject(() => { fired = true; })); + + picker.setSelectedProject(project, false); + + assert.strictEqual(fired, false); + assert.ok(picker.selectedProject); + }); + + test('clearSelection clears the selected project', () => { + const picker = createPicker(); + picker.setSelectedProject(new SessionWorkspace(URI.file('/folder')), false); + + assert.ok(picker.selectedProject); + + picker.clearSelection(); + + assert.strictEqual(picker.selectedProject, undefined); + }); + + test('removeFromRecents clears selection if it matches', () => { + const picker = createPicker(); + const uri = URI.file('/folder'); + picker.setSelectedProject(new SessionWorkspace(uri), false); + + picker.removeFromRecents(uri); + + assert.strictEqual(picker.selectedProject, undefined); + }); + + test('removeFromRecents preserves selection if it does not match', () => { + const picker = createPicker(); + const selectedUri = URI.file('/selected'); + picker.setSelectedProject(new SessionWorkspace(selectedUri), false); + + picker.removeFromRecents(URI.file('/other')); + + assert.ok(picker.selectedProject); + assert.strictEqual(picker.selectedProject.uri.path, '/selected'); + }); + + test('remote project persists and restores from storage', () => { + const storageService = ds.add(new InMemoryStorageService()); + instantiationService.stub(IStorageService, storageService); + + // Create picker and select a remote project + const picker1 = ds.add(instantiationService.createInstance(WorkspacePicker)); + const authority = agentHostAuthority('http://myremote:3000'); + const remoteUri = agentHostUri(authority, '/home/user/project'); + picker1.setSelectedProject(new SessionWorkspace(remoteUri), false); + + // Create a second picker -- it should restore from storage + 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.authority, authority); + }); + + test('trigger label uses cached remoteName when connection is unavailable', () => { + const storageService = ds.add(new InMemoryStorageService()); + instantiationService.stub(IStorageService, storageService); + + const address = 'http://myremote:3000'; + const authority = agentHostAuthority(address); + + // Simulate a live connection so remoteName gets cached + connections = [{ address, name: 'macbook', clientId: 'test-client' }]; + const picker1 = ds.add(instantiationService.createInstance(WorkspacePicker)); + const remoteUri = agentHostUri(authority, '/home/user/project'); + picker1.setSelectedProject(new SessionWorkspace(remoteUri), false); + + // Simulate startup with no connections available + connections = []; + const picker2 = ds.add(instantiationService.createInstance(WorkspacePicker)); + + // Render and check the trigger label uses cached "macbook", not encoded authority + const container = document.createElement('div'); + picker2.render(container); + const label = container.querySelector('.sessions-chat-dropdown-label'); + assert.ok(label); + assert.strictEqual(label.textContent, 'project [macbook]'); + }); +}); diff --git a/src/vs/sessions/contrib/remoteAgentHost/ARCHITECTURE.md b/src/vs/sessions/contrib/remoteAgentHost/ARCHITECTURE.md new file mode 100644 index 00000000000..aae3798a801 --- /dev/null +++ b/src/vs/sessions/contrib/remoteAgentHost/ARCHITECTURE.md @@ -0,0 +1,309 @@ +# 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 +- Others are url-safe base64 encoded with a `b64-` prefix + +Example: `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)` | `b64-aHR0cA` | +| **Session type** | `remote-${authority}-${provider}` | `remote-b64-aHR0cA-copilot` | +| **Agent ID** | same as session type | `remote-b64-aHR0cA-copilot` | +| **Vendor** | same as session type | `remote-b64-aHR0cA-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-b64-xxx-copilot:claude-sonnet-4-20250514`). + +## URI Conventions + +| Context | Scheme | Format | Example | +|---------|--------|--------|---------| +| New session resource | `` | `:/untitled-` | `remote-b64-xxx-copilot:/untitled-abc` | +| Existing session | `` | `:/` | `remote-b64-xxx-copilot:/abc-123` | +| Backend session state | `` | `:/` | `copilot:/abc-123` | +| Root state subscription | (string) | `agenthost:/root` | - | +| Remote filesystem | `agenthost` | `agenthost:///` | `agenthost://b64-aHR0cA/home/user/project` | +| Language model ID | - | `:` | `remote-b64-xxx-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-b64-xxx-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 a Remote Workspace + +In the `WorkspacePicker`, the user clicks **"Browse Remotes..."**, selects a +remote host, then picks a folder on the remote filesystem. This produces a +`SessionWorkspace` with an `agenthost://` URI: + +``` +agenthost://b64-aHR0cA/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 "b64-aHR0cA" → find connection → "remote-b64-aHR0cA-copilot" +const target = getRemoteAgentHostSessionTarget(connections, authority); +``` + +### 3. Resource URI Generation + +`getResourceForNewChatSession()` creates the session resource: + +```typescript +URI.from({ scheme: target, path: `/untitled-${generateUuid()}` }) +// → remote-b64-aHR0cA-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[] +``` diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/agentHostFileSystemProvider.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/agentHostFileSystemProvider.ts index 4b6b21adaea..97ec8078107 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/agentHostFileSystemProvider.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/agentHostFileSystemProvider.ts @@ -33,7 +33,8 @@ export const AGENT_HOST_FS_SCHEME = 'agenthost'; * Build an agenthost URI for a given address and path. */ export function agentHostUri(authority: string, path: string): URI { - return URI.from({ scheme: AGENT_HOST_FS_SCHEME, authority, path: path || '/' }); + const normalizedPath = !path ? '/' : path.startsWith('/') ? path : `/${path}`; + return URI.from({ scheme: AGENT_HOST_FS_SCHEME, authority, path: normalizedPath }); } /** diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts index 92941d06af8..4971236cb5a 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts @@ -15,9 +15,10 @@ import { type AgentProvider, type IAgentConnection } from '../../../../platform/ 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 { IRemoteAgentHostConnectionInfo, IRemoteAgentHostService, RemoteAgentHostsSettingId } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { AgentSessionTarget } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; import { ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js'; import { AgentHostLanguageModelProvider } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.js'; import { AgentHostSessionHandler } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.js'; @@ -25,6 +26,9 @@ import { AgentHostSessionListController } from '../../../../workbench/contrib/ch 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'; +import * as nls from '../../../../nls.js'; +import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; /** * Encode a remote address into an identifier that is safe for use in @@ -41,6 +45,24 @@ export function agentHostAuthority(address: string): string { return 'b64-' + encodeBase64(VSBuffer.fromString(address), false, true); } +/** + * Given a sanitized URI authority, resolves the corresponding agent host + * session target string by looking up the matching connection. + * + * Returns `undefined` if no connection matches the authority. + */ +export function getRemoteAgentHostSessionTarget( + connections: readonly IRemoteAgentHostConnectionInfo[], + authority: string, +): AgentSessionTarget | undefined { + for (const conn of connections) { + if (agentHostAuthority(conn.address) === authority) { + return `remote-${agentHostAuthority(conn.address)}-copilot`; + } + } + return undefined; +} + /** Per-connection state bundle, disposed when a connection is removed. */ class ConnectionState extends Disposable { readonly store = this._register(new DisposableStore()); @@ -419,3 +441,23 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc } registerWorkbenchContribution2(RemoteAgentHostContribution.ID, RemoteAgentHostContribution, WorkbenchPhase.AfterRestored); + +Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ + properties: { + [RemoteAgentHostsSettingId]: { + type: 'array', + items: { + type: 'object', + properties: { + address: { type: 'string', description: nls.localize('chat.remoteAgentHosts.address', "The address of the remote agent host (e.g. \"localhost:3000\").") }, + name: { type: 'string', description: nls.localize('chat.remoteAgentHosts.name', "A display name for this remote agent host.") }, + connectionToken: { type: 'string', description: nls.localize('chat.remoteAgentHosts.connectionToken', "An optional connection token for authenticating with the remote agent host.") }, + }, + required: ['address', 'name'], + }, + description: nls.localize('chat.remoteAgentHosts', "A list of remote agent host addresses to connect to (e.g. \"localhost:3000\")."), + default: [], + tags: ['experimental', 'advanced'], + }, + }, +}); diff --git a/src/vs/sessions/contrib/remoteAgentHost/test/browser/agentHostFileSystemProvider.test.ts b/src/vs/sessions/contrib/remoteAgentHost/test/browser/agentHostFileSystemProvider.test.ts index b4396b54d59..e22ba6e32fc 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/test/browser/agentHostFileSystemProvider.test.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/test/browser/agentHostFileSystemProvider.test.ts @@ -25,6 +25,13 @@ suite('AgentHostFileSystemProvider - URI helpers', () => { assert.strictEqual(uri.path, '/'); }); + test('agentHostUri normalizes path without leading slash', () => { + 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'); + }); + test('agentHostRemotePath extracts the path component', () => { const uri = URI.from({ scheme: AGENT_HOST_FS_SCHEME, authority: 'host', path: '/some/path' }); assert.strictEqual(agentHostRemotePath(uri), '/some/path'); diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts index 1a5a8810b33..c2b35c960f7 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts @@ -20,8 +20,8 @@ import { ChatAgentLocation, ChatModeKind, ChatPermissionLevel } from '../../../. import { IAgentSession, isAgentSession } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js'; import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; -import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; -import { INewSession, CopilotCLISession, RemoteNewSession } from '../../chat/browser/newSession.js'; +import { AgentSessionProviders, AgentSessionTarget } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; +import { INewSession, CopilotCLISession, RemoteNewSession, AgentHostNewSession } from '../../chat/browser/newSession.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { isBuiltinChatMode } from '../../../../workbench/contrib/chat/common/chatModes.js'; import { ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js'; @@ -92,7 +92,7 @@ export interface ISessionsManagementService { * Create a pending session object for the given target type. * Local sessions collect options locally; remote sessions notify the extension. */ - createNewSessionForTarget(target: AgentSessionProviders, sessionResource: URI, defaultRepoUri?: URI): Promise; + createNewSessionForTarget(target: AgentSessionTarget, sessionResource: URI, options?: { defaultRepoUri?: URI; agentHost?: boolean }): Promise; /** * Open a new session, apply options, and send the initial request. @@ -265,14 +265,16 @@ export class SessionsManagementService extends Disposable implements ISessionsMa await this.instantiationService.invokeFunction(openSessionDefault, existingSession, openOptions); } - async createNewSessionForTarget(target: AgentSessionProviders, sessionResource: URI, defaultRepoUri?: URI): Promise { + async createNewSessionForTarget(target: AgentSessionTarget, sessionResource: URI, options?: { defaultRepoUri?: URI; agentHost?: boolean }): Promise { if (!this.isNewChatSessionContext.get()) { this.isNewChatSessionContext.set(true); } let newSession: INewSession; if (target === AgentSessionProviders.Background) { - newSession = this.instantiationService.createInstance(CopilotCLISession, sessionResource, defaultRepoUri); + newSession = this.instantiationService.createInstance(CopilotCLISession, sessionResource, options?.defaultRepoUri); + } else if (options?.agentHost) { + newSession = new AgentHostNewSession(sessionResource, target); } else { newSession = this.instantiationService.createInstance(RemoteNewSession, sessionResource, target); } diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts index 7d964ee9465..66f102e9d76 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts @@ -24,7 +24,7 @@ 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 { AgentSessionsFilter, AgentSessionsGrouping, AgentSessionsSorting } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.js'; -import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; +import { AgentSessionProviders, isAgentHostTarget } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; import { ISessionsManagementService, IsNewChatSessionContext } from './sessionsManagementService.js'; import { Action2, ISubmenuItem, MenuId, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; @@ -124,6 +124,7 @@ export class AgenticSessionsViewPane extends ViewPane { groupResults: () => this.currentGrouping, sortResults: () => this.currentSorting, allowedProviders: [AgentSessionProviders.Background, AgentSessionProviders.Cloud], + overrideExclude: session => isAgentHostTarget(session.providerType) ? false : undefined, providerLabelOverrides: new Map([ [AgentSessionProviders.Background, localize('chat.session.providerLabel.background', "Copilot CLI")], ]), diff --git a/src/vs/sessions/contrib/sessions/common/sessionWorkspace.ts b/src/vs/sessions/contrib/sessions/common/sessionWorkspace.ts index fe7fe1c2b41..aa5a5ba3a44 100644 --- a/src/vs/sessions/contrib/sessions/common/sessionWorkspace.ts +++ b/src/vs/sessions/contrib/sessions/common/sessionWorkspace.ts @@ -8,9 +8,16 @@ import { IGitRepository } from '../../../../workbench/contrib/git/common/gitServ export const GITHUB_REMOTE_FILE_SCHEME = 'github-remote-file'; +/** + * 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'; + /** * Represents a workspace (folder or repository) for a session. - * The workspace type (folder vs repo) is derived from the URI scheme. + * The workspace type (folder vs repo vs remote agent host) is derived from the URI scheme. */ export class SessionWorkspace { @@ -24,7 +31,7 @@ export class SessionWorkspace { /** Whether this is a local folder workspace. */ get isFolder(): boolean { - return this.uri.scheme !== GITHUB_REMOTE_FILE_SCHEME; + return this.uri.scheme !== GITHUB_REMOTE_FILE_SCHEME && this.uri.scheme !== AGENT_HOST_SCHEME; } /** Whether this is a remote repository workspace. */ @@ -32,6 +39,11 @@ export class SessionWorkspace { return this.uri.scheme === GITHUB_REMOTE_FILE_SCHEME; } + /** Whether this is a remote agent host workspace. */ + get isRemoteAgentHost(): boolean { + return this.uri.scheme === AGENT_HOST_SCHEME; + } + /** Returns a new SessionWorkspace with the repository updated. */ withRepository(repository: IGitRepository | undefined): SessionWorkspace { return new SessionWorkspace(this.uri, repository); diff --git a/src/vs/sessions/contrib/sessions/test/common/sessionWorkspace.test.ts b/src/vs/sessions/contrib/sessions/test/common/sessionWorkspace.test.ts new file mode 100644 index 00000000000..8b9883f1b7b --- /dev/null +++ b/src/vs/sessions/contrib/sessions/test/common/sessionWorkspace.test.ts @@ -0,0 +1,46 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { AGENT_HOST_SCHEME, GITHUB_REMOTE_FILE_SCHEME, SessionWorkspace } from '../../common/sessionWorkspace.js'; +import type { IGitRepository } from '../../../../../workbench/contrib/git/common/gitService.js'; + +suite('SessionWorkspace', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('local folder is classified as isFolder', () => { + const ws = new SessionWorkspace(URI.file('/home/user/project')); + assert.strictEqual(ws.isFolder, true); + assert.strictEqual(ws.isRepo, false); + assert.strictEqual(ws.isRemoteAgentHost, false); + }); + + test('GitHub repo is classified as isRepo', () => { + const ws = new SessionWorkspace(URI.from({ scheme: GITHUB_REMOTE_FILE_SCHEME, authority: 'github', path: '/owner/repo/HEAD' })); + assert.strictEqual(ws.isFolder, false); + assert.strictEqual(ws.isRepo, true); + assert.strictEqual(ws.isRemoteAgentHost, false); + }); + + test('agent host URI is classified as isRemoteAgentHost', () => { + const ws = new SessionWorkspace(URI.from({ scheme: AGENT_HOST_SCHEME, authority: 'b64-test', path: '/home/user/project' })); + assert.strictEqual(ws.isFolder, false); + assert.strictEqual(ws.isRepo, false); + assert.strictEqual(ws.isRemoteAgentHost, true); + }); + + test('withRepository preserves URI and updates repository', () => { + const uri = URI.from({ scheme: AGENT_HOST_SCHEME, authority: 'b64-test', path: '/proj' }); + const ws = new SessionWorkspace(uri); + const repo = { rootUri: URI.file('/repo') } as IGitRepository; + const ws2 = ws.withRepository(repo); + assert.strictEqual(ws2.uri.toString(), uri.toString()); + assert.strictEqual(ws2.isRemoteAgentHost, true); + assert.strictEqual(ws2.repository, repo); + }); +}); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts index cf43a98126c..095117618df 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts @@ -41,12 +41,14 @@ export class AgentHostSessionListController extends Disposable implements IChatS this._register(this._connection.onDidNotification(n => { if (n.type === 'notify/sessionAdded' && n.summary.provider === this._provider) { const rawId = AgentSession.id(n.summary.resource); + const workingDir = typeof n.summary.workingDirectory === 'string' ? n.summary.workingDirectory : undefined; const item: IChatSessionItem = { resource: URI.from({ scheme: this._sessionType, path: `/${rawId}` }), label: n.summary.title ?? `Session ${rawId.substring(0, 8)}`, description: this._description, iconPath: getAgentHostIcon(this._productService), status: ChatSessionStatus.Completed, + metadata: this._buildMetadata(workingDir), timing: { created: n.summary.createdAt, lastRequestStarted: n.summary.modifiedAt, @@ -89,6 +91,7 @@ export class AgentHostSessionListController extends Disposable implements IChatS description: this._description, iconPath: getAgentHostIcon(this._productService), status: ChatSessionStatus.Completed, + metadata: this._buildMetadata(s.workingDirectory), timing: { created: s.startTime, lastRequestStarted: s.modifiedTime, @@ -100,4 +103,15 @@ export class AgentHostSessionListController extends Disposable implements IChatS } this._onDidChangeChatSessionItems.fire({ addedOrUpdated: this._items }); } + + private _buildMetadata(workingDirectory?: string): { readonly [key: string]: unknown } | undefined { + if (!this._description) { + return undefined; + } + const result: { [key: string]: unknown } = { remoteAgentHost: this._description }; + if (workingDirectory) { + result.workingDirectoryPath = workingDirectory; + } + return result; + } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts index 0e86de722e6..b58ce96c3c3 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts @@ -21,6 +21,14 @@ export enum AgentSessionProviders { AgentHostCopilot = 'agent-host-copilot', } +/** + * A session target is either a well-known {@link AgentSessionProviders} enum + * value or a dynamic string for dynamically-registered providers (e.g. remote + * agent hosts like `remote-{authority}-copilot`). + * TODO@roblourens HACK + */ +export type AgentSessionTarget = AgentSessionProviders | (string & {}); + export function isBuiltInAgentSessionProvider(provider: string): boolean { return provider === AgentSessionProviders.Local || provider === AgentSessionProviders.Background || diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts index 46185e866ab..369ad0e89b1 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts @@ -984,6 +984,19 @@ export class AgentSessionsDataSource extends Disposable implements IAsyncDataSou export function getRepositoryName(session: IAgentSession): string | undefined { const metadata = session.metadata; if (metadata) { + // Remote agent host sessions: group by folder + remote name (e.g. "myproject [dev-box]") + const remoteAgentHost = metadata.remoteAgentHost as string | undefined; + if (remoteAgentHost) { + const workingDir = metadata.workingDirectoryPath as string | undefined; + if (workingDir) { + const folderName = extractRepoNameFromPath(workingDir); + if (folderName) { + return `${folderName} [${remoteAgentHost}]`; + } + } + return remoteAgentHost; + } + // Cloud sessions: metadata.owner + metadata.name const owner = metadata.owner as string | undefined; const name = metadata.name as string | undefined; diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 39b5e9d8b74..85c65da6d67 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -9,7 +9,6 @@ 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 { RemoteAgentHostsSettingId } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; import { registerEditorFeature } from '../../../../editor/common/editorFeatures.js'; import * as nls from '../../../../nls.js'; import { AccessibleViewRegistry } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; @@ -728,23 +727,7 @@ configurationRegistry.registerConfiguration({ type: 'boolean', description: nls.localize('chat.agentHost.enabled', "When enabled, some agents run in a separate agent host process."), default: false, - tags: ['experimental'], - included: product.quality !== 'stable', - }, - [RemoteAgentHostsSettingId]: { - type: 'array', - items: { - type: 'object', - properties: { - address: { type: 'string', description: nls.localize('chat.remoteAgentHosts.address', "The address of the remote agent host (e.g. \"localhost:3000\").") }, - name: { type: 'string', description: nls.localize('chat.remoteAgentHosts.name', "A display name for this remote agent host.") }, - connectionToken: { type: 'string', description: nls.localize('chat.remoteAgentHosts.connectionToken', "An optional connection token for authenticating with the remote agent host.") }, - }, - required: ['address', 'name'], - }, - description: nls.localize('chat.remoteAgentHosts', "A list of remote agent host addresses to connect to (e.g. \"localhost:3000\")."), - default: [], - tags: ['experimental'], + tags: ['experimental', 'advanced'], included: product.quality !== 'stable', }, [ChatConfiguration.PlanAgentDefaultModel]: { diff --git a/src/vs/workbench/services/dialogs/browser/simpleFileDialog.ts b/src/vs/workbench/services/dialogs/browser/simpleFileDialog.ts index a5236c4b082..90bfdf715b5 100644 --- a/src/vs/workbench/services/dialogs/browser/simpleFileDialog.ts +++ b/src/vs/workbench/services/dialogs/browser/simpleFileDialog.ts @@ -130,6 +130,14 @@ export class SimpleFileDialog extends Disposable implements ISimpleFileDialog { private badPath: string | undefined; private remoteAgentEnvironment: IRemoteAgentEnvironment | null | undefined; private separator: string = '/'; + + /** + * When set, the dialog is scoped to a specific URI authority (e.g. + * for browsing an `agenthost://{authority}/...` filesystem that + * uses per-connection authorities rather than the global + * {@link remoteAuthority}). + */ + private scopedAuthority: string | undefined; private readonly onBusyChangeEmitter = this._register(new Emitter()); private updatingPromise: CancelablePromise | undefined; @@ -191,6 +199,7 @@ export class SimpleFileDialog extends Disposable implements ISimpleFileDialog { public async showOpenDialog(options: IOpenDialogOptions = {}): Promise { this.scheme = this.getScheme(options.availableFileSystems, options.defaultUri); + this.scopedAuthority = this.getScopedAuthority(options.defaultUri); this.userHome = await this.getUserHome(); this.trueHome = await this.getUserHome(true); const newOptions = this.getOptions(options); @@ -207,6 +216,7 @@ export class SimpleFileDialog extends Disposable implements ISimpleFileDialog { public async showSaveDialog(options: ISaveDialogOptions): Promise { this.scheme = this.getScheme(options.availableFileSystems, options.defaultUri); + this.scopedAuthority = this.getScopedAuthority(options.defaultUri); this.userHome = await this.getUserHome(); this.trueHome = await this.getUserHome(true); this.requiresTrailing = true; @@ -251,6 +261,12 @@ export class SimpleFileDialog extends Disposable implements ISimpleFileDialog { if (!path.startsWith('\\\\')) { path = path.replace(/\\/g, '/'); } + // When scoped to a specific authority (e.g. agenthost://host/...), + // construct the URI directly with the authority to avoid + // toLocalResource stripping or replacing it. + if (this.scopedAuthority) { + return URI.from({ scheme: this.scheme, authority: this.scopedAuthority, path, query: hintUri?.query, fragment: hintUri?.fragment }); + } const uri: URI = this.scheme === Schemas.file ? URI.file(path) : URI.from({ scheme: this.scheme, path, query: hintUri?.query, fragment: hintUri?.fragment }); // If the default scheme is file, then we don't care about the remote authority or the hint authority const authority = (uri.scheme === Schemas.file) ? undefined : (this.remoteAuthority ?? hintUri?.authority); @@ -272,6 +288,24 @@ export class SimpleFileDialog extends Disposable implements ISimpleFileDialog { return Schemas.file; } + /** + * Returns the per-URI authority from {@link defaultUri} if the dialog + * should be scoped to a specific authority (e.g. `agenthost://host/...`). + * + * Returns `undefined` when the authority matches the global + * {@link remoteAuthority} (standard SSH remotes), since that path is + * already handled by the existing logic. + */ + private getScopedAuthority(defaultUri: URI | undefined): string | undefined { + if (defaultUri + && defaultUri.scheme === this.scheme + && defaultUri.authority + && defaultUri.authority !== this.remoteAuthority) { + return defaultUri.authority; + } + return undefined; + } + private async getRemoteAgentEnvironment(): Promise { if (this.remoteAgentEnvironment === undefined) { this.remoteAgentEnvironment = await this.remoteAgentService.getEnvironment(); @@ -280,6 +314,12 @@ export class SimpleFileDialog extends Disposable implements ISimpleFileDialog { } protected getUserHome(trueHome = false): Promise { + // When scoped to a custom authority, the platform userHome is not + // meaningful (it would return a local file:// path). Use the root + // of the scoped filesystem as the home directory instead. + if (this.scopedAuthority) { + return Promise.resolve(URI.from({ scheme: this.scheme, authority: this.scopedAuthority, path: '/' })); + } return trueHome ? this.pathService.userHome({ preferLocal: this.scheme === Schemas.file }) : this.fileDialogService.preferredHome(this.scheme); @@ -295,9 +335,9 @@ export class SimpleFileDialog extends Disposable implements ISimpleFileDialog { private async pickResource(isSave: boolean = false): Promise { this.allowFolderSelection = !!this.options.canSelectFolders; this.allowFileSelection = !!this.options.canSelectFiles; - this.separator = this.labelService.getSeparator(this.scheme, this.remoteAuthority); + this.separator = this.scopedAuthority ? '/' : this.labelService.getSeparator(this.scheme, this.remoteAuthority); this.hidden = false; - this.isWindows = await this.checkIsWindowsOS(); + this.isWindows = this.scopedAuthority ? false : await this.checkIsWindowsOS(); let homedir: URI = this.options.defaultUri ? this.options.defaultUri : this.workspaceContextService.getWorkspace().folders[0].uri; let stat: IFileStatWithPartialMetadata | undefined; const ext: string = resources.extname(homedir); @@ -983,7 +1023,14 @@ export class SimpleFileDialog extends Disposable implements ISimpleFileDialog { } private pathFromUri(uri: URI, endWithSeparator: boolean = false): string { - let result: string = normalizeDriveLetter(uri.fsPath, this.isWindows).replace(/\n/g, ''); + // For authority-scoped schemes, use the raw path component instead + // of fsPath, which would prepend the authority as a UNC prefix. + let result: string; + if (this.scopedAuthority) { + result = uri.path.replace(/\n/g, ''); + } else { + result = normalizeDriveLetter(uri.fsPath, this.isWindows).replace(/\n/g, ''); + } if (this.separator === '/') { result = result.replace(/\\/g, this.separator); } else { @@ -1024,7 +1071,11 @@ export class SimpleFileDialog extends Disposable implements ISimpleFileDialog { } private async createBackItem(currFolder: URI): Promise { - const fileRepresentationCurr = this.currentFolder.with({ scheme: Schemas.file, authority: '' }); + // For authority-scoped URIs, compare within the original scheme so + // that the authority is preserved and the root is detected correctly. + const compareScheme = this.scopedAuthority ? this.scheme : Schemas.file; + const compareAuthority = this.scopedAuthority ?? ''; + const fileRepresentationCurr = this.currentFolder.with({ scheme: compareScheme, authority: compareAuthority }); const fileRepresentationParent = resources.dirname(fileRepresentationCurr); if (!resources.isEqual(fileRepresentationCurr, fileRepresentationParent)) { const parentFolder = resources.dirname(currFolder);