diff --git a/src/vs/platform/agentHost/architecture.md b/src/vs/platform/agentHost/architecture.md index e10015a7c5b..f5bfec457e9 100644 --- a/src/vs/platform/agentHost/architecture.md +++ b/src/vs/platform/agentHost/architecture.md @@ -1,224 +1,762 @@ -# Agent host process architecture +# Remote Agent Host - Architecture Reference -> **Keep this document in sync with the code.** If you change the IPC contract, add new event types, modify the process lifecycle, or restructure files, update this document as part of the same change. +This file describes the key types in the remote agent host system, from the +agent host process itself up through the sessions app integration layer. -For design decisions, see [design.md](design.md). For the client-server state protocol, see [protocol.md](protocol.md). For chat session wiring, see [sessions.md](sessions.md). +The system has four layers: -## Overview +1. **Agent host process** (`platform/agentHost/node/`) + The utility process that hosts agent backends (e.g. Copilot SDK). + Owns the authoritative state tree and dispatches to IAgent providers. -The agent host runs as either an Electron **utility process** (desktop) or a **standalone WebSocket server** (headless / development). It hosts agent backends (CopilotAgent, MockAgent) and exposes session state to clients through two communication layers: +2. **Platform services** (`platform/agentHost/common/`, `electron-browser/`) + Service interfaces and IPC plumbing that expose the agent host to the + renderer. Local connections use MessagePort; remote ones use WebSocket. -1. **MessagePort / ProxyChannel** (desktop only) -- the renderer connects directly to the utility process via MessagePort. `AgentHostServiceClient` proxies `IAgentService` methods and forwards action/notification events. -2. **WebSocket / JSON-RPC protocol** (standalone server) -- multiple clients connect over WebSocket. Session state is synchronized via actions, subscriptions, and write-ahead reconciliation. See [protocol.md](protocol.md) for the full specification. +3. **Workbench contributions** (`workbench/contrib/chat/browser/agentSessions/agentHost/`) + Shared UI adapters that bridge the agent host protocol with the chat UI: + session handlers, session list controllers, language model providers, and + state-to-progress adapters. Used by both local and remote agent hosts. -In both modes, the server holds an authoritative state tree (`SessionStateManager`) mutated by actions flowing through pure reducers. Raw `IAgentProgressEvent`s from agent backends are mapped to state actions via `agentEventMapper.ts`. - -The entire feature is gated behind the `chat.agentHost.enabled` setting (default `false`). When disabled, the process is not spawned and no agents are registered. - -## Process Model +4. **Sessions app orchestrator** (`sessions/contrib/remoteAgentHost/`) + The contribution that discovers remote agent hosts, dynamically registers + them as chat session types, and provides the remote filesystem provider. ``` -+--------------------------------------------------------------+ -| Renderer Window (Desktop) | -| | -| AgentHostContribution (discovers agents via listAgents()) | -| +-- per agent: SessionHandler, ListCtrl, LMProvider | -| +-- SessionClientState (write-ahead reconciliation) | -| +-- stateToProgressAdapter (state -> IChatProgress[]) | -| | -| AgentHostServiceClient (IAgentHostService singleton) | -| +-- ProxyChannel over delayed MessagePort | -| (revive() applied to event payloads) | -+---------------- MessagePort (direct) -------------------------+ -| Agent Host Utility Process (agentHostMain.ts) | -| -- or -- | -| Standalone Server (agentHostServerMain.ts) | -| | -| SessionStateManager (server-authoritative state tree) | -| +-- rootReducer / sessionReducer | -| +-- action envelope sequencing | -| | -| ProtocolServerHandler (JSON-RPC routing, broadcasts) | -| +-- per-client subscriptions, replay buffer | -| | -| Agent registry (Map) | -| +-- CopilotAgent (id='copilot') | -| | +-- CopilotClient (@github/copilot-sdk) | -| +-- ScriptedMockAgent (id='mock', opt-in via flag) | -| | -| agentEventMapper.ts | -| +-- IAgentProgressEvent -> ISessionAction mapping | -+---------------- UtilityProcess lifecycle ---------------------+ -| Main Process (Desktop only) | -| | -| ElectronAgentHostStarter (IAgentHostStarter) | -| +-- Spawns utility process, brokers MessagePort to windows | -| AgentHostProcessManager | -| +-- Lazy start on first window connection, crash recovery | -+---------------------------------------------------------------+ +┌───────────────────────────────────────────────────────────────────────────┐ +│ Sessions App (Layer 4) │ +│ RemoteAgentHostContribution │ +│ per-connection → SessionClientState + agent registrations │ +│ AgentHostFileSystemProvider (agenthost:// scheme) │ +├──────────────────────────────────────┬────────────────────────────────────┤ +│ Workbench Contributions (3) │ Workbench Contributions (3) │ +│ AgentHostContribution (local) │ (shared adapters) │ +│ SessionClientState │ AgentHostSessionHandler │ +│ per-agent registrations │ AgentHostSessionListController │ +│ │ AgentHostLanguageModelProvider │ +├──────────────────────────────────────┴────────────────────────────────────┤ +│ Platform Services (Layer 2) │ +│ IAgentHostService (local, MessagePort) │ +│ IRemoteAgentHostService (remote, WebSocket) │ +│ └─ both implement IAgentConnection │ +├───────────────────────────────────────────────────────────────────────────┤ +│ Agent Host Process (Layer 1) │ +│ AgentService → SessionStateManager → IAgent (Copilot SDK) │ +│ ProtocolServerHandler (WebSocket protocol bridge) │ +│ AgentSideEffects (action dispatch + progress event routing) │ +└───────────────────────────────────────────────────────────────────────────┘ ``` -## File Layout +```typescript +// ============================================================================= +// LAYER 1: Agent Host Process (platform/agentHost/node/) +// ============================================================================= + +/** + * Implemented by each agent backend (e.g. the Copilot SDK wrapper). + * The agent host process can host multiple providers, though currently + * only `copilot` is supported. + * + * Registered with {@link AgentService.registerProvider}. Provider progress + * events are wired to the state manager through {@link AgentSideEffects}. + * + * File: `platform/agentHost/common/agentService.ts` + */ +interface IAgent { + /** Unique provider identifier (e.g. `'copilot'`). */ + readonly id: AgentProvider; + /** Fires when the provider streams progress for a session. */ + readonly onDidSessionProgress: Event; + createSession(config?: IAgentCreateSessionConfig): Promise; + sendMessage(session: URI, prompt: string, attachments?: IAgentAttachment[]): Promise; + getSessionMessages(session: URI): Promise; + disposeSession(session: URI): Promise; + abortSession(session: URI): Promise; + changeModel(session: URI, model: string): Promise; + respondToPermissionRequest(requestId: string, approved: boolean): void; + getDescriptor(): IAgentDescriptor; + listModels(): Promise; + listSessions(): Promise; + getProtectedResources(): IAuthorizationProtectedResourceMetadata[]; + authenticate(resource: string, token: string): Promise; + shutdown(): Promise; + dispose(): void; +} + +/** + * The agent service implementation that runs inside the agent host utility + * process. Dispatches to registered {@link IAgent} providers based on the + * provider identifier in the session URI scheme. + * + * Owns the {@link SessionStateManager} (authoritative state tree) and + * {@link AgentSideEffects} (action routing + progress event mapping). + * + * When `VSCODE_AGENT_HOST_PORT` is set, the process also starts a + * {@link ProtocolServerHandler} over WebSocket for external clients. + * + * File: `platform/agentHost/node/agentService.ts` + */ +interface AgentService extends IAgentService { + /** Exposes the state manager for co-hosting a WebSocket protocol server. */ + readonly stateManager: SessionStateManager; + /** Register a new agent backend provider. */ + registerProvider(provider: IAgent): void; +} + +/** + * Server-side authoritative state manager for the sessions process protocol. + * + * Maintains the root state (agent list + active session count) and per-session + * state trees. Applies actions through pure reducers, assigns monotonic + * sequence numbers, and emits {@link IActionEnvelope}s for subscribed clients. + * + * Consumed by both the IPC proxy (for local clients) and + * {@link ProtocolServerHandler} (for WebSocket clients). Both paths share + * the same state, so local and remote clients see identical state. + * + * File: `platform/agentHost/node/sessionStateManager.ts` + */ +interface SessionStateManager { + readonly rootState: IRootState; + readonly serverSeq: number; + readonly onDidEmitEnvelope: Event; + readonly onDidEmitNotification: Event; + getSessionState(session: string): ISessionState | undefined; + getSnapshot(resource: string): IStateSnapshot | undefined; + createSession(summary: ISessionSummary): ISessionState; + removeSession(session: string): void; + applyAction(action: IStateAction, origin: IActionOrigin): void; +} + +/** + * Shared side-effect handler that routes client-dispatched actions to the + * correct {@link IAgent} backend, handles session create/dispose/list + * operations, and wires agent progress events to the state manager. + * + * Also implements {@link IProtocolSideEffectHandler} so the WebSocket + * {@link ProtocolServerHandler} can delegate side effects to the same logic. + * + * File: `platform/agentHost/node/agentSideEffects.ts` + */ +interface AgentSideEffects extends IProtocolSideEffectHandler { + /** Connects an IAgent's progress events to the state manager. */ + registerProgressListener(provider: IAgent): IDisposable; +} + +/** + * Server-side protocol handler for WebSocket clients. Routes JSON-RPC + * messages to the {@link SessionStateManager}, manages client subscriptions, + * and broadcasts action envelopes to subscribed clients. + * + * Handles the initialize/reconnect handshake, subscribe/unsubscribe, + * dispatchAction, createSession, disposeSession, and browseDirectory commands. + * + * Exposes {@link onDidChangeConnectionCount} so the server process can + * track how many external clients are connected (used by + * {@link ServerAgentHostManager} for lifetime management). + * + * File: `platform/agentHost/node/protocolServerHandler.ts` + */ +interface ProtocolServerHandler { + /** Fires with the current client count when a client connects or disconnects. */ + readonly onDidChangeConnectionCount: Event; +} + +/** + * Side-effect handler interface for protocol commands that require + * business logic beyond pure state management. Implemented by + * {@link AgentSideEffects} and consumed by {@link ProtocolServerHandler}. + * + * File: `platform/agentHost/node/protocolServerHandler.ts` + */ +interface IProtocolSideEffectHandler { + handleAction(action: ISessionAction): void; + handleCreateSession(command: ICreateSessionParams): Promise; + handleDisposeSession(session: string): void; + handleListSessions(): Promise; + handleGetResourceMetadata(): IResourceMetadata; + handleAuthenticate(params: IAuthenticateParams): Promise; + handleBrowseDirectory(uri: string): Promise; + getDefaultDirectory(): string; +} + +/** + * Main-process service that manages the agent host utility process lifecycle: + * lazy start on first connection request, crash recovery (up to 5 restarts), + * and logger channel forwarding. + * + * The renderer communicates with the utility process directly via MessagePort; + * this class does not relay any agent service calls. + * + * File: `platform/agentHost/node/agentHostService.ts` + */ +interface AgentHostProcessManager { + // Internal lifecycle management - start, restart, logger forwarding. +} + +/** + * Server-specific agent host manager. Eagerly starts the agent host process, + * handles crash recovery, and tracks both active agent sessions and connected + * WebSocket clients via {@link IServerLifetimeService} to keep the server + * alive while either signal is active. + * + * The lifetime token is held when: + * - there are active agent sessions (turns in progress), OR + * - there are WebSocket clients connected to the agent host + * + * The token is released (allowing server auto-shutdown) only when both + * active sessions = 0 AND connected clients = 0. + * + * Session count comes from `root/activeSessionsChanged` actions via + * {@link IAgentService.onDidAction}. Client connection count comes from + * a separate IPC channel ({@link AgentHostIpcChannels.ConnectionTracker}) + * that is not part of the agent host protocol -- it is a server-only + * process-management concern. + * + * File: `server/node/serverAgentHostManager.ts` + */ +interface ServerAgentHostManager { + // Tracks _hasActiveSessions + _connectionCount, updates lifetime token + // when either changes. +} + +/** + * Abstracts the utility process creation so the same lifecycle management + * works for both Electron utility processes and Node child processes. + * + * File: `platform/agentHost/common/agent.ts` + */ +interface IAgentHostStarter extends IDisposable { + readonly onRequestConnection?: Event; + readonly onWillShutdown?: Event; + /** Creates the agent host process and connects to it. */ + start(): IAgentHostConnection; +} + +/** + * The connection returned by {@link IAgentHostStarter.start}. Provides + * an IPC channel client and process exit events. + * + * File: `platform/agentHost/common/agent.ts` + */ +interface IAgentHostConnection { + readonly client: IChannelClient; + readonly store: DisposableStore; + readonly onDidProcessExit: Event<{ code: number; signal: string }>; +} + +// ============================================================================= +// LAYER 2: Platform Services (platform/agentHost/common/ & electron-browser/) +// ============================================================================= + +/** + * Core protocol surface for communicating with an agent host. Methods are + * proxied across MessagePort (local) or implemented over WebSocket (remote). + * + * State synchronization uses the subscribe/unsubscribe/dispatchAction pattern. + * Clients observe root state (discovered agents, models) and session state + * via subscriptions, and mutate state by dispatching actions (e.g. + * `session/turnStarted`, `session/turnCancelled`). + * + * File: `platform/agentHost/common/agentService.ts` + */ +interface IAgentService { + listAgents(): Promise; + getResourceMetadata(): Promise; + authenticate(params: IAuthenticateParams): Promise; + refreshModels(): Promise; + listSessions(): Promise; + createSession(config?: IAgentCreateSessionConfig): Promise; + disposeSession(session: URI): Promise; + shutdown(): Promise; + + // ---- Protocol methods ---- + subscribe(resource: URI): Promise; + unsubscribe(resource: URI): void; + readonly onDidAction: Event; + readonly onDidNotification: Event; + dispatchAction(action: ISessionAction, clientId: string, clientSeq: number): void; + browseDirectory(uri: URI): Promise; +} + +/** + * A concrete connection to an agent host - local utility process or remote + * WebSocket. Extends {@link IAgentService} with a `clientId` used for + * write-ahead reconciliation of optimistic actions. + * + * Both {@link IAgentHostService} (local) and per-connection objects from + * {@link IRemoteAgentHostService} (remote) satisfy this contract. The + * workbench contributions ({@link AgentHostSessionHandler}, etc.) program + * against this single interface. + * + * File: `platform/agentHost/common/agentService.ts` + */ +interface IAgentConnection extends IAgentService { + /** Unique client identifier, used as origin in action envelopes. */ + readonly clientId: string; +} + +/** + * The local agent host service - wraps the utility process connection and + * provides lifecycle events. The renderer talks to the utility process + * directly via MessagePort using ProxyChannel. + * + * Registered as a singleton service. Also implements {@link IAgentConnection} + * so it can be used interchangeably with remote connections. + * + * File: `platform/agentHost/common/agentService.ts` (interface) + * File: `platform/agentHost/electron-browser/agentHostService.ts` (implementation) + */ +interface IAgentHostService extends IAgentConnection { + readonly onAgentHostExit: Event; + readonly onAgentHostStart: Event; + restartAgentHost(): Promise; +} + +/** + * Manages connections to one or more remote agent host processes over + * WebSocket. Each connection is identified by its address string (from the + * `chat.remoteAgentHosts` setting) and exposed as an {@link IAgentConnection}. + * + * The implementation reads the setting, creates a + * {@link RemoteAgentHostProtocolClient} per address, reconnects when the + * setting changes, and fires `onDidChangeConnections` when connections are + * established or lost. + * + * File: `platform/agentHost/common/remoteAgentHostService.ts` (interface) + * File: `platform/agentHost/electron-browser/remoteAgentHostServiceImpl.ts` (implementation) + */ +interface IRemoteAgentHostService { + readonly onDidChangeConnections: Event; + readonly connections: readonly IRemoteAgentHostConnectionInfo[]; + getConnection(address: string): IAgentConnection | undefined; +} + +/** + * Metadata about a single remote connection - address, friendly name, + * client ID from the handshake, and the remote machine's home directory. + * + * File: `platform/agentHost/common/remoteAgentHostService.ts` + */ +interface IRemoteAgentHostConnectionInfo { + readonly address: string; + readonly name: string; + readonly clientId: string; + readonly defaultDirectory?: string; +} + +/** + * An entry in the `chat.remoteAgentHosts` setting. + * + * File: `platform/agentHost/common/remoteAgentHostService.ts` + */ +interface IRemoteAgentHostEntry { + readonly address: string; + readonly name: string; + readonly connectionToken?: string; +} + +/** + * A protocol-level client for a single remote agent host connection. + * Manages the WebSocket transport, handshake (initialize command with + * protocol version exchange), subscriptions, action dispatch, and + * JSON-RPC request/response correlation. + * + * Implements {@link IAgentConnection} so consumers can program against + * a single interface regardless of whether the agent host is local or remote. + * + * File: `platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts` + */ +interface RemoteAgentHostProtocolClient extends IAgentConnection { + readonly defaultDirectory: string | undefined; + readonly onDidClose: Event; + connect(): Promise; +} + +// ============================================================================= +// LAYER 2: State Protocol Types (platform/agentHost/common/state/) +// ============================================================================= + +/** + * Root state: the top-level state tree subscribed to at `agenthost:/root`. + * Contains the list of discovered agent backends and active session count. + * Mutated by `root/agentsChanged` and `root/activeSessionsChanged` actions. + * + * File: `platform/agentHost/common/state/sessionState.ts` (re-exported from protocol) + */ +interface IRootState { + readonly agents: readonly IAgentInfo[]; + readonly activeSessions: number; +} + +/** + * Describes an agent backend discovered via root state subscription. + * Each agent exposes a provider name, display metadata, and available models. + * + * File: `platform/agentHost/common/state/sessionState.ts` (re-exported from protocol) + */ +interface IAgentInfo { + readonly provider: string; + readonly displayName: string; + readonly description: string; + readonly models: readonly ISessionModelInfo[]; +} + +/** + * Per-session state tree. Contains the session summary, lifecycle, completed + * turns, active turn (if any), and server tools. Mutated by session actions + * like `session/turnStarted`, `session/delta`, `session/toolCallStart`, etc. + * + * File: `platform/agentHost/common/state/sessionState.ts` (re-exported from protocol) + */ +interface ISessionState { + readonly summary: ISessionSummary; + readonly lifecycle: SessionLifecycle; + readonly turns: readonly ITurn[]; + readonly activeTurn: IActiveTurn | undefined; +} + +/** + * An envelope wrapping a state action with origin metadata and a monotonic + * server sequence number. Clients use the origin to distinguish their own + * echoed actions from concurrent actions from other clients/the server. + * + * File: `platform/agentHost/common/state/sessionActions.ts` (re-exported from protocol) + */ +interface IActionEnvelope { + readonly action: IStateAction; + readonly origin: IActionOrigin; + readonly serverSeq: number; +} + +/** + * A state snapshot returned by the subscribe command. Contains the current + * state at the given resource URI and the server sequence number at + * snapshot time. The client should process subsequent envelopes with + * `serverSeq > fromSeq`. + * + * File: `platform/agentHost/common/state/sessionProtocol.ts` (re-exported from protocol) + */ +interface IStateSnapshot { + readonly resource: string; + readonly state: IRootState | ISessionState; + readonly fromSeq: number; +} + +/** + * Client-side state manager with write-ahead reconciliation. + * + * Maintains confirmed state (last server-acknowledged), a pending action + * queue (optimistically applied), and reconciles when the server echoes + * actions back (possibly interleaved with actions from other sources). + * Operates on two kinds of subscribable state: + * - Root state (agents + models) - server-only mutations, no write-ahead. + * - Session state - mixed: client-sendable actions get write-ahead, + * server-only actions are applied directly. + * + * Usage: + * 1. `handleSnapshot()` - apply initial state from subscribe response. + * 2. `applyOptimistic()` - optimistically apply a client action. + * 3. `receiveEnvelope()` - process a server action envelope. + * 4. `receiveNotification()` - process an ephemeral notification. + * + * File: `platform/agentHost/common/state/sessionClientState.ts` + */ +interface SessionClientState { + readonly clientId: string; + readonly rootState: IRootState | undefined; + readonly onDidChangeRootState: Event; + readonly onDidChangeSessionState: Event<{ session: string; state: ISessionState }>; + readonly onDidReceiveNotification: Event; + getSessionState(session: string): ISessionState | undefined; + handleSnapshot(resource: string, state: IRootState | ISessionState, fromSeq: number): void; + applyOptimistic(action: ISessionAction): number; + receiveEnvelope(envelope: IActionEnvelope): void; + receiveNotification(notification: INotification): void; + unsubscribe(resource: string): void; +} + +/** + * A bidirectional transport for protocol messages (JSON-RPC 2.0 framing). + * Implementations handle serialization, framing, and connection management. + * Concrete implementations: MessagePort (ProxyChannel), WebSocket, stdio. + * + * File: `platform/agentHost/common/state/sessionTransport.ts` + */ +interface IProtocolTransport extends IDisposable { + readonly onMessage: Event; + readonly onClose: Event; + send(message: IProtocolMessage): void; +} + +/** + * Server-side transport that accepts multiple client connections. + * Each connected client gets its own {@link IProtocolTransport}. + * + * File: `platform/agentHost/common/state/sessionTransport.ts` + */ +interface IProtocolServer extends IDisposable { + readonly onConnection: Event; + readonly address: string | undefined; +} + +// ============================================================================= +// LAYER 3: Workbench Contributions (workbench/contrib/chat/browser/agentSessions/agentHost/) +// ============================================================================= + +/** + * Renderer-side handler for a single agent host chat session type. + * Bridges the protocol state layer with the chat UI: + * + * - Subscribes to session state via {@link IAgentConnection} + * - Derives `IChatProgress[]` from immutable state changes in + * {@link SessionClientState} + * - Dispatches client actions (`turnStarted`, `permissionResolved`, + * `turnCancelled`) back to the server + * - Registers a dynamic chat agent via {@link IChatAgentService} + * + * Works with both local and remote connections via the {@link IAgentConnection} + * interface passed in the config. + * + * File: `workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts` + */ +interface AgentHostSessionHandler extends IChatSessionContentProvider { + provideChatSessionContent(sessionResource: URI, token: CancellationToken): Promise; +} + +/** + * Configuration for an {@link AgentHostSessionHandler} instance. + * Contains the agent identity, displayName, the connection to use, + * and optional callbacks for resolving working directories and + * interactive authentication. + * + * File: `workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts` + */ +interface IAgentHostSessionHandlerConfig { + readonly provider: AgentProvider; + readonly agentId: string; + readonly sessionType: string; + readonly fullName: string; + readonly description: string; + readonly connection: IAgentConnection; + readonly extensionId?: string; + readonly extensionDisplayName?: string; + /** Resolve a working directory for a new session (e.g. from active session's repository URI). */ + readonly resolveWorkingDirectory?: (resourceKey: string) => string | undefined; + /** Trigger interactive authentication when the server rejects with auth-required. */ + readonly resolveAuthentication?: () => Promise; +} + +/** + * Provides session list items for the chat sessions sidebar by querying + * active sessions from an agent host connection. Listens to protocol + * notifications (`notify/sessionAdded`, `notify/sessionRemoved`) for + * incremental updates, and refreshes on `session/turnComplete` actions. + * + * Works with both local and remote agent host connections via + * {@link IAgentConnection}. + * + * File: `workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts` + */ +interface AgentHostSessionListController extends IChatSessionItemController { + readonly items: readonly IChatSessionItem[]; + readonly onDidChangeChatSessionItems: Event; + refresh(token: CancellationToken): Promise; +} + +/** + * Exposes models available from the agent host process as selectable + * language models in the chat model picker. Models come from root state + * (via {@link IAgentInfo.models}) and are published with IDs prefixed + * by the session type (e.g. `remote-localhost__8081-copilot:claude-sonnet-4-20250514`). + * + * File: `workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts` + */ +interface AgentHostLanguageModelProvider extends ILanguageModelChatProvider { + /** Called when models change in root state to push updates to the model picker. */ + updateModels(models: readonly ISessionModelInfo[]): void; +} + +/** + * The local agent host contribution (for the workbench, not the sessions app). + * Discovers agents from the local agent host process and registers each one + * as a chat session type. Gated on the `chat.agentHost.enabled` setting. + * + * Uses the same shared adapters ({@link AgentHostSessionHandler}, etc.) + * but connects via {@link IAgentHostService} (MessagePort) instead of + * {@link IRemoteAgentHostService} (WebSocket). + * + * File: `workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts` + */ +interface AgentHostContribution extends IWorkbenchContribution { + // Registers per-agent: chat session contribution, session list controller, + // session handler, and language model provider - same 4 registrations + // as RemoteAgentHostContribution but for the local agent host. +} + +// ============================================================================= +// LAYER 4: Sessions App Orchestrator (sessions/contrib/remoteAgentHost/) +// ============================================================================= + +/** + * Central orchestrator for remote agent hosts in the sessions app. + * + * For each active remote connection: + * 1. Creates a {@link SessionClientState} for write-ahead reconciliation + * 2. Subscribes to `agenthost:/root` to discover available agents + * 3. For each discovered copilot agent, performs four registrations: + * - Chat session contribution (via {@link IChatSessionsService}) + * - Session list controller ({@link AgentHostSessionListController}) + * - Session content provider ({@link AgentHostSessionHandler}) + * - Language model provider ({@link AgentHostLanguageModelProvider}) + * 4. Registers authority→address mappings for the filesystem provider + * 5. Authenticates connections using RFC 9728 resource metadata + * + * Reconciles when connections change (added/removed/name changed) + * and when the default auth account or auth sessions change. + * + * File: `sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts` + */ +interface RemoteAgentHostContribution extends IWorkbenchContribution { + // Per-connection state tracked in a DisposableMap +} + +/** + * Read-only {@link IFileSystemProvider} registered under the `agenthost` + * scheme. Proxies `stat` and `readdir` calls through the agent host + * protocol's `browseDirectory` RPC. + * + * The URI authority identifies the remote connection (sanitized address), + * the URI path is the remote filesystem path. Authority-to-address mappings + * are registered by {@link RemoteAgentHostContribution} via + * `registerAuthority(authority, address)`. + * + * File: `sessions/contrib/remoteAgentHost/browser/agentHostFileSystemProvider.ts` + */ +interface AgentHostFileSystemProvider extends IFileSystemProvider { + /** Register a mapping from sanitized URI authority to remote address. */ + registerAuthority(authority: string, address: string): IDisposable; +} + +// ============================================================================= +// Naming Conventions & URI Schemes +// ============================================================================= + +/** + * Remote addresses are encoded into URI-safe authority strings: + * - `localhost:8081` → `localhost__8081` + * - `http://127.0.0.1:3000` → `b64-aHR0cDovLzEyNy4wLjAuMTozMDAw` + * + * | Context | Scheme | Example | + * |--------------------------|------------------|-------------------------------------------------| + * | Session resource (UI) | `` | `remote-localhost__8081-copilot:/untitled-abc` | + * | Backend session (server) | `` | `copilot:/abc-123` | + * | Root state subscription | (string literal) | `agenthost:/root` | + * | Remote filesystem | `agenthost` | `agenthost://localhost__8081/home/user/project` | + * | Language model ID | - | `remote-localhost__8081-copilot:claude-sonnet-4-20250514` | + * + * Session type naming: `remote-${authority}-${provider}` for remote, + * `agent-host-${provider}` for local. + */ + +// ============================================================================= +// IPC & Auth Data Types (platform/agentHost/common/agentService.ts) +// ============================================================================= + +/** Metadata describing an agent backend, discovered over IPC or root state. */ +interface IAgentDescriptor { + readonly provider: AgentProvider; + readonly displayName: string; + readonly description: string; + /** @deprecated Use IResourceMetadata from getResourceMetadata() instead. */ + readonly requiresAuth: boolean; +} + +/** Serializable model information from the agent host. */ +interface IAgentModelInfo { + readonly provider: AgentProvider; + readonly id: string; + readonly name: string; + readonly maxContextWindow: number; + readonly supportsVision: boolean; + readonly supportsReasoningEffort: boolean; +} + +/** Configuration for creating a new session. */ +interface IAgentCreateSessionConfig { + readonly provider?: AgentProvider; + readonly model?: string; + readonly session?: URI; + readonly workingDirectory?: string; +} + +/** Metadata for an existing session (returned by listSessions). */ +interface IAgentSessionMetadata { + readonly session: URI; + readonly startTime: number; + readonly modifiedTime: number; + readonly summary?: string; + readonly workingDirectory?: string; +} + +/** Serializable attachment passed alongside a message to the agent host. */ +interface IAgentAttachment { + readonly type: AttachmentType; + readonly path: string; + readonly displayName?: string; + readonly text?: string; + readonly selection?: { + readonly start: { readonly line: number; readonly character: number }; + readonly end: { readonly line: number; readonly character: number }; + }; +} + +/** + * Describes the agent host as an OAuth 2.0 protected resource (RFC 9728). + * Clients resolve tokens via the VS Code authentication service. + */ +interface IResourceMetadata { + readonly resources: readonly IAuthorizationProtectedResourceMetadata[]; +} + +/** + * Parameters for the `authenticate` command (RFC 6750 bearer token delivery). + */ +interface IAuthenticateParams { + readonly resource: string; + readonly token: string; +} + +/** + * Result of the `authenticate` command. + */ +interface IAuthenticateResult { + readonly authenticated: boolean; +} + +// ============================================================================= +// Progress Events (platform/agentHost/common/agentService.ts) +// ============================================================================= + +/** + * Discriminated union of progress events streamed from the agent host. + * The state-to-progress adapter ({@link stateToProgressAdapter.ts}) + * translates protocol state changes into `IChatProgress[]` for the chat UI, + * but these events are also used in the IPC path for the old event-based API. + * + * Types: `delta`, `message`, `idle`, `tool_start`, `tool_complete`, + * `title_changed`, `error`, `usage`, `permission_request`, `reasoning`. + */ +type IAgentProgressEvent = + | IAgentDeltaEvent + | IAgentMessageEvent + | IAgentIdleEvent + | IAgentToolStartEvent + | IAgentToolCompleteEvent + | IAgentTitleChangedEvent + | IAgentErrorEvent + | IAgentUsageEvent + | IAgentPermissionRequestEvent + | IAgentReasoningEvent; ``` -src/vs/platform/agentHost/ -+-- common/ -| +-- agent.ts # IAgentHostStarter, IAgentHostConnection (starter contract) -| +-- agentService.ts # IAgent, IAgentService, IAgentHostService interfaces, -| # IPC data types, IAgentProgressEvent union, -| # AgentSession namespace (URI helpers), -| # AgentHostEnabledSettingId -| +-- state/ -| +-- sessionState.ts # Immutable state types (RootState, SessionState, Turn, etc.) -| +-- sessionActions.ts # Action discriminated union + ActionEnvelope + Notifications -| +-- sessionReducers.ts # Pure reducer functions (rootReducer, sessionReducer) -| +-- sessionProtocol.ts # JSON-RPC message types, request params/results -| +-- sessionCapabilities.ts # Version constants + ProtocolCapabilities -| +-- sessionClientState.ts # Client-side state manager with write-ahead reconciliation -| +-- sessionTransport.ts # IProtocolTransport / IProtocolServer abstractions -| +-- versions/ -| +-- v1.ts # v1 wire format types (tip -- editable, compiler-enforced compat) -| +-- versionRegistry.ts # Compile-time compat checks + runtime action->version map -+-- electron-browser/ -| +-- agentHostService.ts # AgentHostServiceClient (renderer singleton, direct MessagePort) -+-- electron-main/ -| +-- electronAgentHostStarter.ts # Spawns utility process, brokers MessagePort connections -+-- node/ -| +-- agentHostMain.ts # Entry point inside the Electron utility process -| +-- agentHostServerMain.ts # Entry point for standalone WebSocket server -| +-- agentService.ts # AgentService: dispatches to registered IAgent providers -| +-- agentHostService.ts # AgentHostProcessManager: lifecycle, crash recovery -| +-- agentEventMapper.ts # Maps IAgentProgressEvent -> ISessionAction -| +-- sessionStateManager.ts # Server-authoritative state tree + reducer dispatch -| +-- protocolServerHandler.ts # JSON-RPC routing, client subscriptions, action broadcast -| +-- webSocketTransport.ts # WebSocket IProtocolTransport + IProtocolServer impl -| +-- nodeAgentHostStarter.ts # Node.js (non-Electron) starter -| +-- copilot/ -| +-- copilotAgent.ts # CopilotAgent: IAgent backed by Copilot SDK -| +-- copilotSessionWrapper.ts -| +-- copilotToolDisplay.ts # Copilot-specific tool name -> display string mapping -+-- test/ - +-- (test files) - -src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/ -+-- agentHostChatContribution.ts # AgentHostContribution: discovers agents, registers dynamically -+-- agentHostLanguageModelProvider.ts # ILanguageModelChatProvider for SDK models -+-- agentHostSessionHandler.ts # AgentHostSessionHandler: generic, config-driven -+-- agentHostSessionListController.ts # Lists persisted sessions from agent host -+-- stateToProgressAdapter.ts # Converts protocol state -> IChatProgress[] for chat UI - -src/vs/workbench/contrib/chat/electron-browser/ -+-- chat.contribution.ts # Desktop-only: registers AgentHostContribution -``` - -## Session URIs - -Sessions are identified by URIs where the **scheme is the provider name** and the **path is the raw session ID**: `copilot:/`. Helper functions in the `AgentSession` namespace: - -| Helper | Purpose | -|---|---| -| `AgentSession.uri(provider, rawId)` | Create a session URI | -| `AgentSession.id(session)` | Extract raw session ID from URI | -| `AgentSession.provider(session)` | Extract provider name from URI scheme | - -The renderer uses UI resource schemes (`agent-host-copilot`) for session resources. The `AgentHostSessionHandler` converts these to provider URIs before IPC calls. - -## Communication Layers - -### Layer 1: IAgent interface (internal) - -The `IAgent` interface in `agentService.ts` is what each agent backend implements. It fires `IAgentProgressEvent`s (raw SDK events) and exposes methods for session management: - -| Method | Description | -|---|---| -| `createSession(config?)` | Create a new session (returns session URI) | -| `sendMessage(session, prompt, attachments?)` | Send a user message | -| `abortSession(session)` | Abort the current turn | -| `respondToPermissionRequest(requestId, approved)` | Grant/deny a permission | -| `getDescriptor()` | Return agent metadata | -| `listModels()` | List available models | -| `listSessions()` | List persisted sessions | -| `setAuthToken(token)` | Set auth credentials | -| `changeModel?(session, model)` | Change model for a session | - -### Layer 2: Sessions state protocol (client-facing) - -The server maps raw `IAgentProgressEvent`s to state actions via `agentEventMapper.ts`, dispatches them through `SessionStateManager`, and broadcasts to subscribed clients. See [protocol.md](protocol.md) for the full JSON-RPC specification, action types, state model, and versioning. - -### Layer 3: MessagePort relay (desktop renderer) - -`AgentHostServiceClient` in `electron-browser/agentHostService.ts` connects to the utility process via MessagePort and proxies `IAgentService` methods. It also forwards action envelopes and notifications as events so the renderer can feed them into `SessionClientState`. - -## How It Works - -### Setting Gate - -The `chat.agentHost.enabled` setting (default `false`) controls the entire feature: -- **Main process** (`app.ts`): skips creating `ElectronAgentHostStarter` + `AgentHostProcessManager` -- **Renderer proxy** (`AgentHostServiceClient`): skips MessagePort connection -- **Contribution** (`AgentHostContribution`): returns early without discovering or registering agents - -### Startup (lazy) - -1. `ElectronAgentHostStarter` is created in `app.ts` (if setting enabled) and handed to `AgentHostProcessManager`. -2. The utility process is **not** spawned until the first window requests a MessagePort connection. -3. On start, the starter spawns the utility process with entry point `vs/platform/agent/node/agentHostMain`. -4. Each renderer window gets its own MessagePort via `acquirePort('vscode:createAgentHostMessageChannel', ...)`. - -### Standalone Server Mode - -The agent host can also run as a standalone WebSocket server (`agentHostServerMain.ts`): - -```bash -node out/vs/platform/agentHost/node/agentHostServerMain.js [--port ] [--enable-mock-agent] -``` - -This mode creates a `WebSocketProtocolServer` and `ProtocolServerHandler` directly without Electron. Useful for development and headless scenarios. - -### Dynamic Agent Discovery - -On startup (if the setting is enabled), `AgentHostContribution` calls `listAgents()` to discover available backends from the agent host process. Each returned `IAgentDescriptor` contains: - -| Field | Purpose | -|---|---| -| `provider` | Agent provider ID (`'copilot'`) | -| `displayName` | Human-readable name for UI | -| `description` | Description string | -| `requiresAuth` | Whether the renderer should push a GitHub auth token | - -For each descriptor, the contribution dynamically registers: -- Chat session contribution (type = `agent-host-{provider}`) -- `AgentHostSessionHandler` configured with the descriptor's metadata -- `AgentHostSessionListController` for the session sidebar -- `AgentHostLanguageModelProvider` for the model picker -- Auth token wiring (only if `requiresAuth` is true) - -### Auth Token Flow - -Only agents with `requiresAuth: true` (currently Copilot) get auth wiring: -1. On startup and on account/session changes, retrieves the GitHub OAuth token -2. Pushes it to the agent host via `IAgentHostService.setAuthToken(token)` -3. `CopilotAgent` passes it to `CopilotClient({ githubToken })` on next client creation - -### Crash Recovery - -`AgentHostProcessManager` monitors the utility process exit. On unexpected termination, it automatically restarts (up to 5 times). - -## Build / Packaging - -| File | Purpose | -|---|---| -| `build/next/index.ts` | Agent host entry point in esbuild config | -| `build/buildfile.ts` | Agent host entry point in legacy bundler config | -| `build/gulpfile.vscode.ts` | Strip wrong-arch copilot packages; ASAR unpack copilot binaries | -| `build/.moduleignore` | Strip unnecessary copilot prebuilds/ripgrep/clipboard | -| `build/darwin/create-universal-app.ts` | macOS universal binary support for copilot CLI | -| `build/darwin/verify-macho.ts` | Skip copilot binaries in Mach-O verification | - -## Closest Analogs - -| Component | Pattern | Key Difference | -|---|---|---| -| **Pty Host** | Singleton utility process, MessagePort, lazy start, crash recovery | Also has heartbeat monitoring and reconnect logic | -| **Shared Process** | Singleton utility process, MessagePort | Much heavier, hosts many services | -| **Extension Host** | Per-window utility process, custom `RPCProtocol` | Uses custom RPC, not standard channels | diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 94b2c6cb56d..35e4358c7e3 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -20,6 +20,8 @@ export const enum AgentHostIpcChannels { AgentHost = 'agentHost', /** Channel for log forwarding from the agent host process */ Logger = 'agentHostLogger', + /** Channel for WebSocket client connection count (server process management only) */ + ConnectionTracker = 'agentHostConnectionTracker', } /** Configuration key that controls whether the agent host process is spawned. */ diff --git a/src/vs/platform/agentHost/node/agentHostMain.ts b/src/vs/platform/agentHost/node/agentHostMain.ts index 20df9b72167..4ccb5794017 100644 --- a/src/vs/platform/agentHost/node/agentHostMain.ts +++ b/src/vs/platform/agentHost/node/agentHostMain.ts @@ -7,6 +7,7 @@ import { ProxyChannel } from '../../../base/parts/ipc/common/ipc.js'; import { Server as ChildProcessServer } from '../../../base/parts/ipc/node/ipc.cp.js'; import { Server as UtilityProcessServer } from '../../../base/parts/ipc/node/ipc.mp.js'; import { isUtilityProcess } from '../../../base/parts/sandbox/node/electronTypes.js'; +import { Emitter } from '../../../base/common/event.js'; import { DisposableStore } from '../../../base/common/lifecycle.js'; import { URI } from '../../../base/common/uri.js'; import * as os from 'os'; @@ -73,8 +74,18 @@ function startAgentHost(): void { const agentChannel = ProxyChannel.fromService(agentService, disposables); server.registerChannel(AgentHostIpcChannels.AgentHost, agentChannel); + // Expose the WebSocket client connection count to the parent process via IPC. + // This is NOT part of the agent host protocol -- it is only used by the + // server process to manage the agent host process lifetime. + const connectionCountEmitter = disposables.add(new Emitter()); + const connectionTrackerChannel = ProxyChannel.fromService( + { onDidChangeConnectionCount: connectionCountEmitter.event }, + disposables, + ); + server.registerChannel(AgentHostIpcChannels.ConnectionTracker, connectionTrackerChannel); + // Start WebSocket server for external clients if configured - startWebSocketServer(agentService, logService, disposables).catch(err => { + startWebSocketServer(agentService, logService, disposables, count => connectionCountEmitter.fire(count)).catch(err => { logService.error('Failed to start WebSocket server', err); }); @@ -91,7 +102,7 @@ function startAgentHost(): void { * This reuses the same {@link AgentService} and {@link SessionStateManager} * that the IPC channel uses, so both IPC and WebSocket clients share state. */ -async function startWebSocketServer(agentService: AgentService, logService: ILogService, disposables: DisposableStore): Promise { +async function startWebSocketServer(agentService: AgentService, logService: ILogService, disposables: DisposableStore, onConnectionCountChanged: (count: number) => void): Promise { const port = process.env['VSCODE_AGENT_HOST_PORT']; const socketPath = process.env['VSCODE_AGENT_HOST_SOCKET_PATH']; @@ -163,7 +174,8 @@ async function startWebSocketServer(agentService: AgentService, logService: ILog }, }; - disposables.add(new ProtocolServerHandler(agentService.stateManager, wsServer, sideEffects, logService)); + const protocolHandler = disposables.add(new ProtocolServerHandler(agentService.stateManager, wsServer, sideEffects, logService)); + disposables.add(protocolHandler.onDidChangeConnectionCount(onConnectionCountChanged)); const listenTarget = socketPath ?? `${host}:${port}`; logService.info(`[AgentHost] WebSocket server listening on ${listenTarget}`); diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index a7e1ecc5b59..a8985739015 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Emitter } from '../../../base/common/event.js'; import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; import { ILogService } from '../../log/common/log.js'; import type { IAgentDescriptor, IAuthenticateParams, IAuthenticateResult, IResourceMetadata } from '../common/agentService.js'; @@ -86,6 +87,11 @@ export class ProtocolServerHandler extends Disposable { private readonly _clients = new Map(); private readonly _replayBuffer: IActionEnvelope[] = []; + private readonly _onDidChangeConnectionCount = this._register(new Emitter()); + + /** Fires with the current client count whenever a client connects or disconnects. */ + readonly onDidChangeConnectionCount = this._onDidChangeConnectionCount.event; + constructor( private readonly _stateManager: SessionStateManager, private readonly _server: IProtocolServer, @@ -171,9 +177,10 @@ export class ProtocolServerHandler extends Disposable { })); disposables.add(transport.onClose(() => { - if (client) { + if (client && this._clients.get(client.clientId) === client) { this._logService.info(`[ProtocolServer] Client disconnected: ${client.clientId}`); this._clients.delete(client.clientId); + this._onDidChangeConnectionCount.fire(this._clients.size); } disposables.dispose(); })); @@ -205,6 +212,7 @@ export class ProtocolServerHandler extends Disposable { disposables, }; this._clients.set(params.clientId, client); + this._onDidChangeConnectionCount.fire(this._clients.size); const snapshots: IStateSnapshot[] = []; if (params.initialSubscriptions) { @@ -243,6 +251,7 @@ export class ProtocolServerHandler extends Disposable { disposables, }; this._clients.set(params.clientId, client); + this._onDidChangeConnectionCount.fire(this._clients.size); const oldestBuffered = this._replayBuffer.length > 0 ? this._replayBuffer[0].serverSeq : this._stateManager.serverSeq; const canReplay = params.lastSeenServerSeq >= oldestBuffered; diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index 3eac4562547..d433bbdc60f 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -124,6 +124,7 @@ suite('ProtocolServerHandler', () => { let stateManager: SessionStateManager; let server: MockProtocolServer; let sideEffects: MockSideEffectHandler; + let handler: ProtocolServerHandler; const sessionUri = URI.from({ scheme: 'copilot', path: '/test-session' }).toString(); @@ -154,7 +155,7 @@ suite('ProtocolServerHandler', () => { stateManager = disposables.add(new SessionStateManager(new NullLogService())); server = disposables.add(new MockProtocolServer()); sideEffects = new MockSideEffectHandler(); - disposables.add(new ProtocolServerHandler( + disposables.add(handler = new ProtocolServerHandler( stateManager, server, sideEffects, @@ -427,4 +428,45 @@ suite('ProtocolServerHandler', () => { sideEffects.handleAuthenticate = origHandler; }); + + // ---- Connection count event ----------------------------------------- + + test('onDidChangeConnectionCount fires on connect and disconnect', () => { + const counts: number[] = []; + disposables.add(handler.onDidChangeConnectionCount(c => counts.push(c))); + + const transport = connectClient('client-count-1'); + connectClient('client-count-2'); + transport.simulateClose(); + + assert.deepStrictEqual(counts, [1, 2, 1]); + }); + + test('onDidChangeConnectionCount is not decremented by stale reconnect close', () => { + const counts: number[] = []; + disposables.add(handler.onDidChangeConnectionCount(c => counts.push(c))); + + // Connect + const transport1 = connectClient('client-rc'); + assert.deepStrictEqual(counts, [1]); + + // Reconnect with same clientId (new transport) + const transport2 = new MockProtocolTransport(); + server.simulateConnection(transport2); + transport2.simulateMessage(request(1, 'reconnect', { + clientId: 'client-rc', + lastSeenServerSeq: 0, + subscriptions: [], + })); + // Count is unchanged because same clientId was overwritten + assert.deepStrictEqual(counts, [1, 1]); + + // Old transport closes - should NOT decrement since it's stale + transport1.simulateClose(); + assert.deepStrictEqual(counts, [1, 1]); + + // New transport closes - should decrement + transport2.simulateClose(); + assert.deepStrictEqual(counts, [1, 1, 0]); + }); }); diff --git a/src/vs/server/node/serverAgentHostManager.ts b/src/vs/server/node/serverAgentHostManager.ts index 1c3cc424e2f..dff86352208 100644 --- a/src/vs/server/node/serverAgentHostManager.ts +++ b/src/vs/server/node/serverAgentHostManager.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Event } from '../../base/common/event.js'; import { Disposable, MutableDisposable, toDisposable } from '../../base/common/lifecycle.js'; import { ProxyChannel } from '../../base/parts/ipc/common/ipc.js'; import { IAgentHostConnection, IAgentHostStarter } from '../../platform/agentHost/common/agent.js'; @@ -16,14 +17,26 @@ export const IServerAgentHostManager = createDecorator( /** * Server-specific agent host manager. Eagerly starts the agent host process, - * handles crash recovery, and tracks active agent sessions via - * {@link IServerLifetimeService} to keep the server alive while work is - * in progress. + * handles crash recovery, and tracks both active agent sessions and connected + * WebSocket clients via {@link IServerLifetimeService} to keep the server + * alive while either signal is active. + * + * The lifetime token is held when active sessions > 0 OR connected clients > 0. + * It is released only when both are zero. */ export interface IServerAgentHostManager { readonly _serviceBrand: undefined; } +/** + * Proxy interface for the connection tracker IPC channel exposed by the agent + * host process. This is NOT part of the agent host protocol -- it is a + * server-only process-management concern. + */ +interface IConnectionTrackerService { + readonly onDidChangeConnectionCount: Event; +} + enum Constants { MaxRestarts = 5, } @@ -33,9 +46,12 @@ export class ServerAgentHostManager extends Disposable implements IServerAgentHo private _restartCount = 0; - /** Lifetime token for when agent sessions are active. */ + /** Lifetime token held while sessions are active or clients are connected. */ private readonly _lifetimeToken = this._register(new MutableDisposable()); + private _hasActiveSessions = false; + private _connectionCount = 0; + constructor( private readonly _starter: IAgentHostStarter, @ILogService private readonly _logService: ILogService, @@ -53,14 +69,17 @@ export class ServerAgentHostManager extends Disposable implements IServerAgentHo this._logService.info('ServerAgentHostManager: agent host started'); // Connect logger channel so agent host logs appear in the output channel - this._register(new RemoteLoggerChannelClient(this._loggerService, connection.client.getChannel(AgentHostIpcChannels.Logger))); + connection.store.add(new RemoteLoggerChannelClient(this._loggerService, connection.client.getChannel(AgentHostIpcChannels.Logger))); this._trackActiveSessions(connection); + this._trackClientConnections(connection); // Handle unexpected exit - this._register(connection.onDidProcessExit(e => { + connection.store.add(connection.onDidProcessExit(e => { if (!this._store.isDisposed) { - // Sessions are gone when the process exits + // Both signals are gone when the process exits + this._hasActiveSessions = false; + this._connectionCount = 0; this._lifetimeToken.clear(); if (this._restartCount <= Constants.MaxRestarts) { @@ -79,14 +98,27 @@ export class ServerAgentHostManager extends Disposable implements IServerAgentHo private _trackActiveSessions(connection: IAgentHostConnection): void { const agentService = ProxyChannel.toService(connection.client.getChannel(AgentHostIpcChannels.AgentHost)); - this._register(agentService.onDidAction(envelope => { + connection.store.add(agentService.onDidAction(envelope => { if (envelope.action.type === 'root/activeSessionsChanged') { - if (envelope.action.activeSessions > 0) { - this._lifetimeToken.value ??= this._serverLifetimeService.active('AgentSession'); - } else { - this._lifetimeToken.clear(); - } + this._hasActiveSessions = envelope.action.activeSessions > 0; + this._updateLifetimeToken(); } })); } + + private _trackClientConnections(connection: IAgentHostConnection): void { + const connectionTracker = ProxyChannel.toService(connection.client.getChannel(AgentHostIpcChannels.ConnectionTracker)); + connection.store.add(connectionTracker.onDidChangeConnectionCount(count => { + this._connectionCount = count; + this._updateLifetimeToken(); + })); + } + + private _updateLifetimeToken(): void { + if (this._hasActiveSessions || this._connectionCount > 0) { + this._lifetimeToken.value ??= this._serverLifetimeService.active('AgentHost'); + } else { + this._lifetimeToken.clear(); + } + } } diff --git a/src/vs/server/test/node/serverAgentHostManager.test.ts b/src/vs/server/test/node/serverAgentHostManager.test.ts new file mode 100644 index 00000000000..81ec4b695e6 --- /dev/null +++ b/src/vs/server/test/node/serverAgentHostManager.test.ts @@ -0,0 +1,205 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Emitter, Event } from '../../../base/common/event.js'; +import { DisposableStore, IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../base/test/common/utils.js'; +import { IChannel, IChannelClient } from '../../../base/parts/ipc/common/ipc.js'; +import { IAgentHostConnection, IAgentHostStarter } from '../../../platform/agentHost/common/agent.js'; +import { AgentHostIpcChannels } from '../../../platform/agentHost/common/agentService.js'; +import { NullLogService, NullLoggerService } from '../../../platform/log/common/log.js'; +import { ServerAgentHostManager } from '../../node/serverAgentHostManager.js'; +import { IServerLifetimeService } from '../../node/serverLifetimeService.js'; + +// ---- Mock helpers ----------------------------------------------------------- + +class MockChannel implements IChannel { + private readonly _listeners = new Map>(); + private readonly _callResults = new Map(); + + getEmitter(event: string): Emitter { + let emitter = this._listeners.get(event); + if (!emitter) { + emitter = new Emitter(); + this._listeners.set(event, emitter); + } + return emitter; + } + + setCallResult(command: string, value: unknown): void { + this._callResults.set(command, value); + } + + call(command: string, _arg?: unknown): Promise { + return Promise.resolve((this._callResults.get(command) ?? undefined) as T); + } + + listen(event: string, _arg?: unknown): Event { + return this.getEmitter(event).event as Event; + } + + dispose(): void { + for (const emitter of this._listeners.values()) { + emitter.dispose(); + } + this._listeners.clear(); + } +} + +class MockAgentHostStarter implements IAgentHostStarter { + private readonly _onDidProcessExit = new Emitter<{ code: number; signal: string }>(); + + readonly agentHostChannel = new MockChannel(); + readonly loggerChannel: MockChannel; + readonly connectionTrackerChannel = new MockChannel(); + + constructor() { + this.loggerChannel = new MockChannel(); + this.loggerChannel.setCallResult('getRegisteredLoggers', []); + } + + start(): IAgentHostConnection { + const store = new DisposableStore(); + const client: IChannelClient = { + getChannel: (name: string): T => { + switch (name) { + case AgentHostIpcChannels.AgentHost: + return this.agentHostChannel as unknown as T; + case AgentHostIpcChannels.Logger: + return this.loggerChannel as unknown as T; + case AgentHostIpcChannels.ConnectionTracker: + return this.connectionTrackerChannel as unknown as T; + default: + throw new Error(`Unknown channel: ${name}`); + } + }, + }; + return { + client, + store, + onDidProcessExit: this._onDidProcessExit.event, + }; + } + + fireProcessExit(code: number): void { + this._onDidProcessExit.fire({ code, signal: '' }); + } + + dispose(): void { + this._onDidProcessExit.dispose(); + this.agentHostChannel.dispose(); + this.loggerChannel.dispose(); + this.connectionTrackerChannel.dispose(); + } +} + +class MockServerLifetimeService implements IServerLifetimeService { + declare readonly _serviceBrand: undefined; + + private _activeCount = 0; + + get hasActiveConsumers(): boolean { + return this._activeCount > 0; + } + + active(_consumer: string): IDisposable { + this._activeCount++; + return toDisposable(() => { this._activeCount--; }); + } + + delay(): void { } +} + +suite('ServerAgentHostManager', () => { + const ds = ensureNoDisposablesAreLeakedInTestSuite(); + + let starter: MockAgentHostStarter; + let lifetimeService: MockServerLifetimeService; + + setup(() => { + starter = new MockAgentHostStarter(); + lifetimeService = new MockServerLifetimeService(); + }); + + function createManager(): ServerAgentHostManager { + return ds.add(new ServerAgentHostManager( + starter, + new NullLogService(), + ds.add(new NullLoggerService()), + lifetimeService, + )); + } + + function fireActiveSessions(count: number): void { + starter.agentHostChannel.getEmitter('onDidAction').fire({ + action: { type: 'root/activeSessionsChanged', activeSessions: count }, + serverSeq: 1, + origin: undefined, + }); + } + + function fireConnectionCount(count: number): void { + starter.connectionTrackerChannel.getEmitter('onDidChangeConnectionCount').fire(count); + } + + test('no lifetime token initially', () => { + createManager(); + assert.strictEqual(lifetimeService.hasActiveConsumers, false); + }); + + test('acquires token when sessions become active', () => { + createManager(); + fireActiveSessions(1); + assert.strictEqual(lifetimeService.hasActiveConsumers, true); + }); + + test('acquires token when clients connect (no active sessions)', () => { + createManager(); + fireConnectionCount(2); + assert.strictEqual(lifetimeService.hasActiveConsumers, true); + }); + + test('releases token only when both sessions and connections are zero', () => { + createManager(); + + // Sessions active, no connections + fireActiveSessions(1); + assert.strictEqual(lifetimeService.hasActiveConsumers, true); + + // Connections appear too + fireConnectionCount(1); + assert.strictEqual(lifetimeService.hasActiveConsumers, true); + + // Sessions go idle, but connections remain + fireActiveSessions(0); + assert.strictEqual(lifetimeService.hasActiveConsumers, true); + + // Connections drop to zero -- now both are idle + fireConnectionCount(0); + assert.strictEqual(lifetimeService.hasActiveConsumers, false); + }); + + test('releases token only when connections drop after sessions already idle', () => { + createManager(); + + fireConnectionCount(3); + assert.strictEqual(lifetimeService.hasActiveConsumers, true); + + fireConnectionCount(0); + assert.strictEqual(lifetimeService.hasActiveConsumers, false); + }); + + test('process exit resets both signals and clears token', () => { + createManager(); + + fireActiveSessions(2); + fireConnectionCount(1); + assert.strictEqual(lifetimeService.hasActiveConsumers, true); + + starter.fireProcessExit(1); + assert.strictEqual(lifetimeService.hasActiveConsumers, false); + }); +});