diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md deleted file mode 120000 index ff807266877..00000000000 --- a/.claude/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -../.github/copilot-instructions.md \ No newline at end of file diff --git a/.claude/skills/launch b/.claude/skills/launch deleted file mode 120000 index b41e2b420ad..00000000000 --- a/.claude/skills/launch +++ /dev/null @@ -1 +0,0 @@ -../../.agents/skills/launch \ No newline at end of file diff --git a/.github/skills/chat-customizations-editor/SKILL.md b/.github/skills/chat-customizations-editor/SKILL.md index b90fb5b46cf..16f1d82da3a 100644 --- a/.github/skills/chat-customizations-editor/SKILL.md +++ b/.github/skills/chat-customizations-editor/SKILL.md @@ -34,6 +34,53 @@ Principle: the UI widgets read everything from the descriptor — no harness-spe Component explorer fixtures (see `component-fixtures` skill): `aiCustomizationListWidget.fixture.ts`, `aiCustomizationManagementEditor.fixture.ts` under `src/vs/workbench/test/browser/componentFixtures/`. +### Screenshotting specific tabs + +The management editor fixture supports a `selectedSection` option to render any tab. Each tab has Dark/Light variants auto-generated by `defineThemedFixtureGroup`. + +**Available fixture IDs** (use with `mcp_component-exp_screenshot`): + +| Fixture ID pattern | Tab shown | +|---|---| +| `chat/aiCustomizations/aiCustomizationManagementEditor/AgentsTab/{Dark,Light}` | Agents | +| `chat/aiCustomizations/aiCustomizationManagementEditor/SkillsTab/{Dark,Light}` | Skills | +| `chat/aiCustomizations/aiCustomizationManagementEditor/InstructionsTab/{Dark,Light}` | Instructions | +| `chat/aiCustomizations/aiCustomizationManagementEditor/HooksTab/{Dark,Light}` | Hooks | +| `chat/aiCustomizations/aiCustomizationManagementEditor/PromptsTab/{Dark,Light}` | Prompts | +| `chat/aiCustomizations/aiCustomizationManagementEditor/McpServersTab/{Dark,Light}` | MCP Servers | +| `chat/aiCustomizations/aiCustomizationManagementEditor/PluginsTab/{Dark,Light}` | Plugins | +| `chat/aiCustomizations/aiCustomizationManagementEditor/LocalHarness/{Dark,Light}` | Default (Agents, Local harness) | +| `chat/aiCustomizations/aiCustomizationManagementEditor/CliHarness/{Dark,Light}` | Default (Agents, CLI harness) | +| `chat/aiCustomizations/aiCustomizationManagementEditor/ClaudeHarness/{Dark,Light}` | Default (Agents, Claude harness) | +| `chat/aiCustomizations/aiCustomizationManagementEditor/Sessions/{Dark,Light}` | Sessions window variant | + +**Adding a new tab fixture:** Add a variant to the `defineThemedFixtureGroup` in `aiCustomizationManagementEditor.fixture.ts`: +```typescript +MyNewTab: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEditor(ctx, { + harness: CustomizationHarness.VSCode, + selectedSection: AICustomizationManagementSection.MySection, + }), +}), +``` + +The `selectedSection` calls `editor.selectSectionById()` after `setInput`, which navigates to the specified tab and re-layouts. + +### Populating test data + +Each customization type requires its own mock path in `createMockPromptsService`: +- **Agents** — `getCustomAgents()` returns agent objects +- **Skills** — `findAgentSkills()` returns `IAgentSkill[]` +- **Prompts** — `getPromptSlashCommands()` returns `IChatPromptSlashCommand[]` +- **Instructions/Hooks** — `listPromptFiles()` filtered by `PromptsType` +- **MCP Servers** — `mcpWorkspaceServers`/`mcpUserServers` arrays passed to `IMcpWorkbenchService` mock +- **Plugins** — `IPluginMarketplaceService.installedPlugins` and `IAgentPluginService.plugins` observables + +All test data lives in `allFiles` (prompt-based items) and the `mcpWorkspace/UserServers` arrays. Add enough items per category (8+) to invoke scrolling. + +### Running unit tests + ```bash ./scripts/test.sh --grep "applyStorageSourceFilter|customizationCounts" npm run compile-check-ts-native && npm run valid-layers-check diff --git a/.gitignore b/.gitignore index e5ca4dd32cc..65cb1937168 100644 --- a/.gitignore +++ b/.gitignore @@ -30,21 +30,16 @@ test/componentFixtures/.screenshots/* !test/componentFixtures/.screenshots/baseline/ dist .playwright-cli +.claude/ .agents/agents/*.local.md -.claude/agents/*.local.md .github/agents/*.local.md .agents/agents/*.local.agent.md -.claude/agents/*.local.agent.md .github/agents/*.local.agent.md .agents/hooks/*.local.json -.claude/hooks/*.local.json .github/hooks/*.local.json .agents/instructions/*.local.instructions.md -.claude/instructions/*.local.instructions.md .github/instructions/*.local.instructions.md .agents/prompts/*.local.prompt.md -.claude/prompts/*.local.prompt.md .github/prompts/*.local.prompt.md .agents/skills/.local/ -.claude/skills/.local/ .github/skills/.local/ diff --git a/build/npm/postinstall.ts b/build/npm/postinstall.ts index ae2651cd188..5ab9d682857 100644 --- a/build/npm/postinstall.ts +++ b/build/npm/postinstall.ts @@ -288,6 +288,37 @@ async function main() { fs.writeFileSync(stateFile, JSON.stringify(_state)); fs.writeFileSync(stateContentsFile, JSON.stringify(computeContents())); + + // Symlink .claude/ files to their canonical locations to test Claude agent harness + const claudeDir = path.join(root, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + + const claudeMdLink = path.join(claudeDir, 'CLAUDE.md'); + if (!fs.existsSync(claudeMdLink)) { + fs.symlinkSync(path.join('..', '.github', 'copilot-instructions.md'), claudeMdLink); + log('.', 'Symlinked .claude/CLAUDE.md -> .github/copilot-instructions.md'); + } + + const claudeSkillsLink = path.join(claudeDir, 'skills'); + if (!fs.existsSync(claudeSkillsLink)) { + fs.symlinkSync(path.join('..', '.agents', 'skills'), claudeSkillsLink); + log('.', 'Symlinked .claude/skills -> .agents/skills'); + } + + // Temporary: patch @github/copilot-sdk session.js to fix ESM import + // (missing .js extension on vscode-jsonrpc/node). Fixed upstream in v0.1.32. + // TODO: Remove once @github/copilot-sdk is updated to >=0.1.32 + for (const dir of ['', 'remote']) { + const sessionFile = path.join(root, dir, 'node_modules', '@github', 'copilot-sdk', 'dist', 'session.js'); + if (fs.existsSync(sessionFile)) { + const content = fs.readFileSync(sessionFile, 'utf8'); + const patched = content.replace(/from "vscode-jsonrpc\/node"/g, 'from "vscode-jsonrpc/node.js"'); + if (content !== patched) { + fs.writeFileSync(sessionFile, patched); + log(dir || '.', 'Patched @github/copilot-sdk session.js (vscode-jsonrpc ESM import fix)'); + } + } + } } main().catch(err => { diff --git a/package.json b/package.json index 35d2ad047be..5f398d4a7e7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", "version": "1.114.0", - "distro": "a981e7362565b4058f3bfd7604f8ab8c4f85101b", + "distro": "6c98cfe8dd3b4c159d8c9c331006a2d7c41872f0", "author": { "name": "Microsoft Corporation" }, diff --git a/src/bootstrap-import.ts b/src/bootstrap-import.ts index ec09ff88ba0..8ccabe764d1 100644 --- a/src/bootstrap-import.ts +++ b/src/bootstrap-import.ts @@ -30,14 +30,29 @@ export async function initialize(injectPath: string): Promise { const path = join(injectPackageJSONPath, `../node_modules/${name}/package.json`); const pkgJson = JSON.parse(String(await promises.readFile(path))); - // Determine the entry point: prefer exports["."].import for ESM, then main + // Determine the entry point: prefer exports["."].import for ESM, then main. + // Handle conditional export targets where exports["."].import/default + // can be a string or an object with a string `default` field. + // (Added for copilot-sdk) let main: string | undefined; if (pkgJson.exports?.['.']) { const dotExport = pkgJson.exports['.']; if (typeof dotExport === 'string') { main = dotExport; } else if (typeof dotExport === 'object' && dotExport !== null) { - main = dotExport.import ?? dotExport.default; + const resolveCondition = (v: unknown): string | undefined => { + if (typeof v === 'string') { + return v; + } + if (typeof v === 'object' && v !== null) { + const d = (v as { default?: unknown }).default; + if (typeof d === 'string') { + return d; + } + } + return undefined; + }; + main = resolveCondition(dotExport.import) ?? resolveCondition(dotExport.default); } } if (typeof main !== 'string') { diff --git a/src/vs/base/node/crypto.ts b/src/vs/base/node/crypto.ts index f1637f4057f..dee5f05fb3f 100644 --- a/src/vs/base/node/crypto.ts +++ b/src/vs/base/node/crypto.ts @@ -16,6 +16,7 @@ export async function checksum(path: string, sha256hash: string | undefined): Pr const done = createSingleCallFunction((err?: Error, result?: string) => { input.removeAllListeners(); hash.removeAllListeners(); + input.destroy(); if (err) { reject(err); diff --git a/src/vs/base/test/node/crypto.test.ts b/src/vs/base/test/node/crypto.test.ts index ed66c2c4b0d..37204dc6317 100644 --- a/src/vs/base/test/node/crypto.test.ts +++ b/src/vs/base/test/node/crypto.test.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import * as assert from 'assert'; import * as fs from 'fs'; import { tmpdir } from 'os'; import { join } from '../../common/path.js'; @@ -33,4 +34,14 @@ flakySuite('Crypto', () => { await checksum(testFile, 'a591a6d40bf420404a011733cfb7b190d62c65bf0bcda32b57b277d9ad9f146e'); }); + + test('checksum mismatch rejects', async () => { + const testFile = join(testDir, 'checksum-mismatch.txt'); + await Promises.writeFile(testFile, 'Hello World'); + + await assert.rejects( + () => checksum(testFile, 'wrong-hash'), + /Hash mismatch/ + ); + }); }); diff --git a/src/vs/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 3a04432c3fe..8485dc3dda7 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 29688ed0913..0fee9db5598 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'; @@ -77,8 +78,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); }); @@ -95,7 +106,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']; @@ -170,7 +181,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 faa7f4683d0..740530c4f2f 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'; @@ -87,6 +88,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, @@ -172,9 +178,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(); })); @@ -206,6 +213,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) { @@ -244,6 +252,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 e549489585f..61ebd8d1513 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -128,6 +128,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(); @@ -158,7 +159,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, @@ -431,4 +432,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/node/webClientServer.ts b/src/vs/server/node/webClientServer.ts index 028a056717b..c920340ed13 100644 --- a/src/vs/server/node/webClientServer.ts +++ b/src/vs/server/node/webClientServer.ts @@ -73,10 +73,29 @@ export async function serveFile(filePath: string, cacheControl: CacheControl, lo responseHeaders['Content-Type'] = textMimeType[extname(filePath)] || getMediaMime(filePath) || 'text/plain'; - res.writeHead(200, responseHeaders); - - // Data - createReadStream(filePath).pipe(res); + // Create the stream first and wait for it to open before sending + // headers so that errors (e.g. ENOENT race) can still produce a + // proper 404 response instead of aborting a half-sent 200. + const fileStream = createReadStream(filePath); + await new Promise((resolve, reject) => { + fileStream.on('error', reject); + fileStream.on('open', () => { + // File opened successfully - send headers and pipe + res.writeHead(200, responseHeaders); + fileStream.pipe(res); + // Destroy the read stream if the response is closed prematurely + // (e.g. client disconnect) to avoid leaking the file descriptor. + res.once('close', () => fileStream.destroy()); + fileStream.on('end', resolve); + // Replace the initial error handler now that headers are sent + fileStream.removeAllListeners('error'); + fileStream.on('error', error => { + logService.error(error); + console.error(error.toString()); + res.destroy(); + }); + }); + }); } catch (error) { if (error.code !== 'ENOENT') { logService.error(error); 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); + }); +}); diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts index 66f102e9d76..880b52eae3b 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts @@ -23,6 +23,7 @@ import { IConfigurationService } from '../../../../platform/configuration/common import { IHoverService } from '../../../../platform/hover/browser/hover.js'; import { localize, localize2 } from '../../../../nls.js'; import { AgentSessionsControl } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsControl.js'; +import { AgentSessionsDataSource } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.js'; import { AgentSessionsFilter, AgentSessionsGrouping, AgentSessionsSorting } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.js'; import { AgentSessionProviders, isAgentHostTarget } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; import { ISessionsManagementService, IsNewChatSessionContext } from './sessionsManagementService.js'; @@ -44,6 +45,7 @@ const SessionsViewFilterSubMenu = new MenuId('AgentSessionsViewFilterSubMenu'); const SessionsViewFilterOptionsSubMenu = new MenuId('AgentSessionsViewFilterOptionsSubMenu'); const SessionsViewGroupingContext = new RawContextKey('sessionsView.grouping', AgentSessionsGrouping.Repository); const SessionsViewSortingContext = new RawContextKey('sessionsView.sorting', AgentSessionsSorting.Created); +const IsRepositoryGroupCappedContext = new RawContextKey('sessionsView.isRepositoryGroupCapped', true); const GROUPING_STORAGE_KEY = 'agentSessions.grouping'; const SORTING_STORAGE_KEY = 'agentSessions.sorting'; @@ -52,10 +54,12 @@ export class AgenticSessionsViewPane extends ViewPane { private viewPaneContainer: HTMLElement | undefined; private sessionsControlContainer: HTMLElement | undefined; sessionsControl: AgentSessionsControl | undefined; + private sessionsFilter: AgentSessionsFilter | undefined; private currentGrouping: AgentSessionsGrouping = AgentSessionsGrouping.Repository; private currentSorting: AgentSessionsSorting = AgentSessionsSorting.Created; private groupingContextKey: IContextKey | undefined; private sortingContextKey: IContextKey | undefined; + private isRepositoryGroupCappedContextKey: IContextKey | undefined; constructor( options: IViewPaneOptions, @@ -92,6 +96,7 @@ export class AgenticSessionsViewPane extends ViewPane { this.groupingContextKey.set(this.currentGrouping); this.sortingContextKey = SessionsViewSortingContext.bindTo(contextKeyService); this.sortingContextKey.set(this.currentSorting); + this.isRepositoryGroupCappedContextKey = IsRepositoryGroupCappedContext.bindTo(contextKeyService); } protected override renderBody(parent: HTMLElement): void { @@ -119,7 +124,7 @@ export class AgenticSessionsViewPane extends ViewPane { const sessionsContainer = DOM.append(parent, $('.agent-sessions-container')); // Sessions Filter (actions go to the nested filter submenu) - const sessionsFilter = this._register(this.instantiationService.createInstance(AgentSessionsFilter, { + const sessionsFilter = this.sessionsFilter = this._register(this.instantiationService.createInstance(AgentSessionsFilter, { filterMenuId: SessionsViewFilterOptionsSubMenu, groupResults: () => this.currentGrouping, sortResults: () => this.currentSorting, @@ -130,6 +135,12 @@ export class AgenticSessionsViewPane extends ViewPane { ]), })); + // Sync context key with filter state + this._register(sessionsFilter.onDidChange(() => { + this.isRepositoryGroupCappedContextKey?.set(sessionsFilter.getExcludes().repositoryGroupCapped); + })); + this.isRepositoryGroupCappedContextKey?.set(sessionsFilter.getExcludes().repositoryGroupCapped); + // Sessions section (top, fills available space) const sessionsSection = DOM.append(sessionsContainer, $('.agent-sessions-section')); @@ -157,6 +168,7 @@ export class AgenticSessionsViewPane extends ViewPane { overrideStyles: this.getLocationBasedColors().listOverrideStyles, disableHover: true, enableApprovalRow: true, + repositoryGroupLimit: AgentSessionsDataSource.REPOSITORY_GROUP_LIMIT, getHoverPosition: () => this.getSessionHoverPosition(), trackActiveEditorSession: () => true, collapseOlderSections: () => true, @@ -266,6 +278,10 @@ export class AgenticSessionsViewPane extends ViewPane { this.sortingContextKey?.set(this.currentSorting); this.sessionsControl?.update(); } + + setRepositoryGroupCapped(capped: boolean): void { + this.sessionsFilter?.setRepositoryGroupCapped(capped); + } } // Register Cmd+N / Ctrl+N keybinding for new session in the agent sessions window @@ -411,6 +427,53 @@ registerAction2(class GroupByTimeAction extends Action2 { } }); +// Show top N or all sessions per repo group (radio pattern) +registerAction2(class ShowTopSessionsAction extends Action2 { + constructor() { + super({ + id: 'sessionsView.showTopSessions', + title: localize2('showRecentSessions', "Show Recent Sessions"), + category: SessionsCategories.Sessions, + toggled: IsRepositoryGroupCappedContext, + menu: [{ + id: SessionsViewFilterSubMenu, + group: '4_cap', + order: 0, + when: ContextKeyExpr.equals(SessionsViewGroupingContext.key, AgentSessionsGrouping.Repository), + }] + }); + } + + override run(accessor: ServicesAccessor) { + const viewsService = accessor.get(IViewsService); + const view = viewsService.getViewWithId(SessionsViewId); + view?.setRepositoryGroupCapped(true); + } +}); + +registerAction2(class ShowAllSessionsAction extends Action2 { + constructor() { + super({ + id: 'sessionsView.showAllSessions', + title: localize2('showAllSessions', "Show All Sessions"), + category: SessionsCategories.Sessions, + toggled: IsRepositoryGroupCappedContext.negate(), + menu: [{ + id: SessionsViewFilterSubMenu, + group: '4_cap', + order: 1, + when: ContextKeyExpr.equals(SessionsViewGroupingContext.key, AgentSessionsGrouping.Repository), + }] + }); + } + + override run(accessor: ServicesAccessor) { + const viewsService = accessor.get(IViewsService); + const view = viewsService.getViewWithId(SessionsViewId); + view?.setRepositoryGroupCapped(false); + } +}); + registerAction2(class RefreshAgentSessionsViewerAction extends Action2 { constructor() { super({ diff --git a/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts b/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts index f20aabab996..e183775db5c 100644 --- a/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts +++ b/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts @@ -8,6 +8,7 @@ import { Emitter, Event } from '../../../../../base/common/event.js'; import { IMarkdownString, isMarkdownString } from '../../../../../base/common/htmlContent.js'; import { stripIcons } from '../../../../../base/common/iconLabels.js'; import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { basename } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; import { AccessibleViewProviderId, AccessibleViewType, IAccessibleViewContentProvider } from '../../../../../platform/accessibility/browser/accessibleView.js'; @@ -21,7 +22,7 @@ import { IChatExtensionsContent, IChatModifiedFilesConfirmationData, IChatPullRe import { isResponseVM } from '../../common/model/chatViewModel.js'; import { IToolResultInputOutputDetails, IToolResultOutputDetails, isToolResultInputOutputDetails, isToolResultOutputDetails, toolContentToA11yString } from '../../common/tools/languageModelToolsService.js'; import { ChatTreeItem, IChatWidget, IChatWidgetService } from '../chat.js'; -import { Location } from '../../../../../editor/common/languages.js'; +import { isLocation, Location } from '../../../../../editor/common/languages.js'; export class ChatResponseAccessibleView implements IAccessibleViewImplementation { readonly priority = 100; @@ -299,6 +300,29 @@ class ChatResponseAccessibleProvider extends Disposable implements IAccessibleVi } break; } + case 'inlineReference': { + const ref = part.inlineReference; + let text: string; + if (URI.isUri(ref)) { + const name = part.name || basename(ref); + const isFileUri = ref.scheme === 'file'; + const path = isFileUri ? (ref.fsPath || ref.path) : ref.toString(true); + text = name !== path ? `${name} (${path})` : path; + } else if (isLocation(ref)) { + const name = part.name || basename(ref.uri); + const isFileUri = ref.uri.scheme === 'file'; + const basePath = isFileUri ? (ref.uri.fsPath || ref.uri.path) : ref.uri.toString(true); + const location = `${basePath}:${ref.range.startLineNumber}`; + text = `${name} (${location})`; + } else { + // IWorkspaceSymbol + const isFileUri = ref.location.uri.scheme === 'file'; + const basePath = isFileUri ? (ref.location.uri.fsPath || ref.location.uri.path) : ref.location.uri.toString(true); + text = `${ref.name} (${basePath}:${ref.location.range.startLineNumber})`; + } + contentParts.push(text); + break; + } case 'elicitation2': case 'elicitationSerialized': { const title = part.title; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts index 1f073a38862..809502a5cec 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts @@ -12,8 +12,8 @@ import { $, append, EventHelper, addDisposableListener, EventType, hide, setVisi import { StandardKeyboardEvent } from '../../../../../base/browser/keyboardEvent.js'; import { KeyCode } from '../../../../../base/common/keyCodes.js'; import { localize } from '../../../../../nls.js'; -import { AgentSessionSection, IAgentSession, IAgentSessionSection, IAgentSessionsModel, IMarshalledAgentSessionContext, isAgentSession, isAgentSessionSection } from './agentSessionsModel.js'; -import { AgentSessionListItem, AgentSessionRenderer, AgentSessionsAccessibilityProvider, AgentSessionsCompressionDelegate, AgentSessionsDataSource, AgentSessionsDragAndDrop, AgentSessionsIdentityProvider, AgentSessionsKeyboardNavigationLabelProvider, AgentSessionsListDelegate, AgentSessionSectionRenderer, AgentSessionSectionLabels, AgentSessionsSorter, getRepositoryName, IAgentSessionsFilter } from './agentSessionsViewer.js'; +import { AgentSessionSection, IAgentSession, IAgentSessionSection, IAgentSessionsModel, IMarshalledAgentSessionContext, isAgentSession, isAgentSessionSection, isAgentSessionShowMore } from './agentSessionsModel.js'; +import { AgentSessionListItem, AgentSessionRenderer, AgentSessionsAccessibilityProvider, AgentSessionsCompressionDelegate, AgentSessionsDataSource, AgentSessionsDragAndDrop, AgentSessionsIdentityProvider, AgentSessionsKeyboardNavigationLabelProvider, AgentSessionsListDelegate, AgentSessionSectionRenderer, AgentSessionSectionLabels, AgentSessionShowMoreRenderer, AgentSessionsSorter, getRepositoryName, IAgentSessionsFilter } from './agentSessionsViewer.js'; import { AgentSessionsGrouping, AgentSessionsSorting } from './agentSessionsFilter.js'; import { AgentSessionApprovalModel } from './agentSessionApprovalModel.js'; import { FuzzyScore } from '../../../../../base/common/filters.js'; @@ -49,6 +49,7 @@ export interface IAgentSessionsControlOptions { readonly source: string; readonly disableHover?: boolean; readonly enableApprovalRow?: boolean; + readonly repositoryGroupLimit?: number; getHoverPosition(): HoverPosition; trackActiveEditorSession(): boolean; @@ -79,6 +80,7 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo private emptyFilterMessage: HTMLElement | undefined; private sessionsList: WorkbenchCompressibleAsyncDataTree | undefined; + private sessionsDataSource: AgentSessionsDataSource | undefined; private static readonly RECENT_SESSIONS_FOR_EXPAND = 5; private sessionsListFindIsOpen = false; @@ -260,7 +262,7 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo isGroupedByRepository: () => this.options.filter.groupResults?.() === AgentSessionsGrouping.Repository, isSortedByUpdated: () => this.options.filter.sortResults?.() === AgentSessionsSorting.Updated, }, approvalModel, activeSessionResource)); - const sessionFilter = this._register(new AgentSessionsDataSource(this.options.filter, sorter)); + const sessionDataSource = this.sessionsDataSource = this._register(new AgentSessionsDataSource(this.options.filter, sorter, this.options.repositoryGroupLimit)); const list = this.sessionsList = this._register(this.instantiationService.createInstance(WorkbenchCompressibleAsyncDataTree, 'AgentSessionsView', container, @@ -269,8 +271,9 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo [ sessionRenderer, this.instantiationService.createInstance(AgentSessionSectionRenderer), + new AgentSessionShowMoreRenderer(), ], - sessionFilter, + sessionDataSource, { accessibilityProvider: new AgentSessionsAccessibilityProvider(), dnd: this.instantiationService.createInstance(AgentSessionsDragAndDrop), @@ -295,10 +298,14 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo } })); - this._register(sessionFilter.onDidGetChildren(count => { + this._register(sessionDataSource.onDidGetChildren(count => { this.updateEmpty(count === 0); })); + this._register(sessionDataSource.onDidExpandRepositoryGroup(() => { + this.update(); + })); + const model = this.agentSessionsService.model; this._register(this.options.filter.onDidChange(async () => { @@ -406,6 +413,11 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo return; // Section headers are not openable } + if (isAgentSessionShowMore(element)) { + this.sessionsDataSource?.expandRepositoryGroup(element.sectionLabel); + return; + } + this.telemetryService.publicLog2('agentSessionOpened', { providerType: element.providerType, source: this.options.source @@ -423,7 +435,7 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo } private async showContextMenu({ element, anchor, browserEvent }: ITreeContextMenuEvent): Promise { - if (!element) { + if (!element || isAgentSessionShowMore(element)) { return; } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts index eba6abdd656..5f0e8a74545 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts @@ -56,6 +56,7 @@ const DEFAULT_EXCLUDES: IAgentSessionsFilterExcludes = Object.freeze({ states: [] as const, archived: true as const /* archived are never excluded but toggle between expanded and collapsed */, read: false as const, + repositoryGroupCapped: true as const /* when true, repo groups are capped at a limit with a "show more" item */, }); export class AgentSessionsFilter extends Disposable implements Required { @@ -280,6 +281,15 @@ export class AgentSessionsFilter extends Disposable implements Required { + + static readonly TEMPLATE_ID = 'agent-session-show-more'; + static readonly HEIGHT = 26; + + readonly templateId = AgentSessionShowMoreRenderer.TEMPLATE_ID; + + renderTemplate(container: HTMLElement): IAgentSessionShowMoreTemplate { + const disposables = new DisposableStore(); + + const elements = h( + 'div.agent-session-show-more@container', + [h('span.agent-session-show-more-label@label')] + ); + + container.appendChild(elements.container); + + return { + container: elements.container, + label: elements.label, + disposables, + }; + } + + renderElement(element: ITreeNode, _index: number, template: IAgentSessionShowMoreTemplate): void { + template.label.textContent = localize('agentSessions.showMore', "Show {0} More...", element.element.remainingCount); + } + + renderCompressedElements(): void { + throw new Error('Should never happen since show-more is incompressible'); + } + + disposeElement(): void { } + + disposeTemplate(templateData: IAgentSessionShowMoreTemplate): void { + templateData.disposables.dispose(); + } +} + +//#endregion + export class AgentSessionsListDelegate implements IListVirtualDelegate { static readonly ITEM_HEIGHT = 54; @@ -668,6 +717,10 @@ export class AgentSessionsListDelegate implements IListVirtualDelegate { private static readonly CAPPED_SESSIONS_LIMIT = 3; + static readonly REPOSITORY_GROUP_LIMIT = 5; private readonly _onDidGetChildren = this._register(new Emitter()); readonly onDidGetChildren: Event = this._onDidGetChildren.event; + private readonly _onDidExpandRepositoryGroup = this._register(new Emitter()); + readonly onDidExpandRepositoryGroup: Event = this._onDidExpandRepositoryGroup.event; + + private readonly expandedRepositoryGroups = new Set(); + constructor( private readonly filter: IAgentSessionsFilter | undefined, private readonly sorter: ITreeSorter, + private readonly repositoryGroupLimit?: number, ) { super(); + + if (this.filter) { + let previousCapped = this.filter.getExcludes().repositoryGroupCapped; + this._register(this.filter.onDidChange(() => { + const currentCapped = this.filter!.getExcludes().repositoryGroupCapped; + // Only clear expanded state when capping transitions from off to on + if (currentCapped && !previousCapped) { + this.expandedRepositoryGroups.clear(); + } + previousCapped = currentCapped; + })); + } + } + + expandRepositoryGroup(sectionLabel: string): void { + this.expandedRepositoryGroups.add(sectionLabel); + this._onDidExpandRepositoryGroup.fire(); } hasChildren(element: IAgentSessionsModel | AgentSessionListItem): boolean { @@ -798,7 +884,7 @@ export class AgentSessionsDataSource extends Disposable implements IAsyncDataSou return element.sessions.length > 0; } - // Session element + // Session element or show more else { return false; } @@ -838,10 +924,16 @@ export class AgentSessionsDataSource extends Disposable implements IAsyncDataSou // Sessions section else if (isAgentSessionSection(element)) { + const isCappingEnabled = this.repositoryGroupLimit && this.filter?.getExcludes().repositoryGroupCapped; + if (isCappingEnabled && element.section === AgentSessionSection.Repository && !this.expandedRepositoryGroups.has(element.label) && element.sessions.length > this.repositoryGroupLimit) { + const visible = element.sessions.slice(0, this.repositoryGroupLimit); + const remainingCount = element.sessions.length - this.repositoryGroupLimit; + return [...visible, { showMore: true as const, sectionLabel: element.label, remainingCount }]; + } return element.sessions; } - // Session element + // Session element or show more else { return []; } @@ -852,7 +944,7 @@ export class AgentSessionsDataSource extends Disposable implements IAsyncDataSou const sorter = this.sorter; const sortedSessions = sorter instanceof AgentSessionsSorter - ? sessions.sort((a, b) => sorter.compare(a, b, isCapped /* special sorting for when results are capped to keep active ones top */)) + ? sessions.sort((a, b) => sorter.compare(a, b, true /* prioritize active sessions to keep in-progress/needs-input ones top within each group */)) : sessions.sort(sorter.compare.bind(sorter)); if (isCapped) { @@ -1232,6 +1324,10 @@ export class AgentSessionsIdentityProvider implements IIdentityProvider(); private galleryCts: CancellationTokenSource | undefined; private readonly delayedFilter = new Delayer(200); @@ -608,6 +610,11 @@ export class McpListWidget extends Disposable { this.galleryServers = []; this.filterServers(); } + + // Re-layout to account for the back link height change + if (this.lastHeight > 0) { + this.layout(this.lastHeight, this.lastWidth); + } } private async queryGallery(): Promise { @@ -882,6 +889,8 @@ export class McpListWidget extends Disposable { * Layouts the widget. */ layout(height: number, width: number): void { + this.lastHeight = height; + this.lastWidth = width; const sectionFooterHeight = this.sectionHeader.offsetHeight || 0; const searchBarHeight = this.searchAndButtonContainer.offsetHeight || 52; const backLinkHeight = this.browseMode ? (this.backLink.offsetHeight || 28) : 0; diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts index b5be4d09f9e..a51a9f6fb0e 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts @@ -298,6 +298,8 @@ export class PluginListWidget extends Disposable { private marketplaceItems: IMarketplacePluginItem[] = []; private searchQuery: string = ''; private browseMode: boolean = false; + private lastHeight: number = 0; + private lastWidth: number = 0; private readonly collapsedGroups = new Set(); private marketplaceCts: CancellationTokenSource | undefined; private readonly delayedFilter = new Delayer(200); @@ -528,6 +530,11 @@ export class PluginListWidget extends Disposable { this.marketplaceItems = []; this.filterPlugins(); } + + // Re-layout to account for the back link height change + if (this.lastHeight > 0) { + this.layout(this.lastHeight, this.lastWidth); + } } private async queryMarketplace(): Promise { @@ -701,6 +708,8 @@ export class PluginListWidget extends Disposable { } layout(height: number, width: number): void { + this.lastHeight = height; + this.lastWidth = width; const sectionFooterHeight = this.sectionHeader.offsetHeight || 0; const searchBarHeight = this.searchAndButtonContainer.offsetHeight || 52; const backLinkHeight = this.browseMode ? (this.backLink.offsetHeight || 28) : 0; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.ts index 2386b799b6c..f3acc772b0f 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.ts @@ -211,6 +211,15 @@ export class ChatTerminalToolConfirmationSubPart extends BaseChatToolInvocationS }, )); + if (terminalData.requestUnsandboxedExecution) { + const reasonText = (terminalData.requestUnsandboxedExecutionReason && terminalData.requestUnsandboxedExecutionReason.trim()) + || localize('chat.terminal.unsandboxedExecution.defaultReason', "The model did not provide a reason for requesting unsandboxed execution."); + const unsandboxedReasonMarkdown = new MarkdownString(undefined, { supportThemeIcons: true }); + unsandboxedReasonMarkdown.appendMarkdown(`$(${Codicon.info.id}) `); + unsandboxedReasonMarkdown.appendText(reasonText); + this._appendMarkdownPart(elements.disclaimer, unsandboxedReasonMarkdown, codeBlockRenderOptions); + } + if (disclaimer) { this._appendMarkdownPart(elements.disclaimer, disclaimer, codeBlockRenderOptions); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index ea350214eac..1705b9da628 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -114,6 +114,7 @@ import { isMcpToolInvocation } from './chatContentParts/toolInvocationParts/chat const $ = dom.$; const COPILOT_USERNAME = 'GitHub Copilot'; +const WORKING_CAUGHT_UP_DEBOUNCE_MS = 50; export interface IChatListItemTemplate { currentElement?: ChatTreeItem; @@ -1067,7 +1068,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer part.kind === 'toolInvocation' && !IChatToolInvocation.isComplete(part))) || (lastPart.kind === 'progressTask' && lastPart.deferred.isSettled) || @@ -1081,6 +1082,17 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer= WORKING_CAUGHT_UP_DEBOUNCE_MS; + } + private getChatFileChangesSummaryPart(element: IChatResponseViewModel): IChatChangesSummaryPart | undefined { if (!this.shouldShowFileChangesSummary(element)) { diff --git a/src/vs/workbench/contrib/chat/test/browser/accessibility/chatResponseAccessibleView.test.ts b/src/vs/workbench/contrib/chat/test/browser/accessibility/chatResponseAccessibleView.test.ts index ba7821dd358..b4ad1e4bf36 100644 --- a/src/vs/workbench/contrib/chat/test/browser/accessibility/chatResponseAccessibleView.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/accessibility/chatResponseAccessibleView.test.ts @@ -486,5 +486,164 @@ suite('ChatResponseAccessibleView', () => { assert.ok(content.includes('Response content')); assert.ok(content.includes('Thinking: Reasoning')); }); + + test('includes file path for URI inline references', () => { + const instantiationService = store.add(new TestInstantiationService()); + const storageService = store.add(new TestStorageService()); + + const inlineReferenceUri = URI.file('/path/to/index.ts'); + const responseItem = { + response: { + value: [ + { kind: 'markdownContent', content: new MarkdownString('See file ') }, + { kind: 'inlineReference', inlineReference: inlineReferenceUri, name: 'index.ts' }, + { kind: 'markdownContent', content: new MarkdownString(' for details') } + ] + }, + model: { onDidChange: Event.None }, + setVote: () => undefined + }; + const items = [responseItem]; + let focusedItem: unknown = responseItem; + + const widget = { + hasInputFocus: () => false, + focusResponseItem: () => { focusedItem = responseItem; }, + getFocus: () => focusedItem, + focus: (item: unknown) => { focusedItem = item; }, + viewModel: { getItems: () => items } + } as unknown as IChatWidget; + + const widgetService = { + _serviceBrand: undefined, + lastFocusedWidget: widget, + onDidAddWidget: Event.None, + onDidBackgroundSession: Event.None, + reveal: async () => true, + revealWidget: async () => widget, + getAllWidgets: () => [widget], + getWidgetByInputUri: () => widget, + openSession: async () => widget, + getWidgetBySessionResource: () => widget + } as unknown as IChatWidgetService; + + instantiationService.stub(IChatWidgetService, widgetService); + instantiationService.stub(IStorageService, storageService); + + const accessibleView = new ChatResponseAccessibleView(); + const provider = instantiationService.invokeFunction(accessor => accessibleView.getProvider(accessor)); + assert.ok(provider); + store.add(provider); + const content = provider.provideContent(); + const expectedPath = inlineReferenceUri.fsPath || inlineReferenceUri.path; + assert.ok(content.includes('index.ts')); + assert.ok(content.includes(expectedPath)); + assert.ok(content.includes('See file')); + assert.ok(content.includes('for details')); + }); + + test('includes file path and line number for Location inline references', () => { + const instantiationService = store.add(new TestInstantiationService()); + const storageService = store.add(new TestStorageService()); + + const fileLocation: Location = { + uri: URI.file('/src/app/main.ts'), + range: new Range(42, 1, 42, 20) + }; + + const responseItem = { + response: { + value: [ + { kind: 'markdownContent', content: new MarkdownString('Error at ') }, + { kind: 'inlineReference', inlineReference: fileLocation, name: 'main.ts' } + ] + }, + model: { onDidChange: Event.None }, + setVote: () => undefined + }; + const items = [responseItem]; + let focusedItem: unknown = responseItem; + + const widget = { + hasInputFocus: () => false, + focusResponseItem: () => { focusedItem = responseItem; }, + getFocus: () => focusedItem, + focus: (item: unknown) => { focusedItem = item; }, + viewModel: { getItems: () => items } + } as unknown as IChatWidget; + + const widgetService = { + _serviceBrand: undefined, + lastFocusedWidget: widget, + onDidAddWidget: Event.None, + onDidBackgroundSession: Event.None, + reveal: async () => true, + revealWidget: async () => widget, + getAllWidgets: () => [widget], + getWidgetByInputUri: () => widget, + openSession: async () => widget, + getWidgetBySessionResource: () => widget + } as unknown as IChatWidgetService; + + instantiationService.stub(IChatWidgetService, widgetService); + instantiationService.stub(IStorageService, storageService); + + const accessibleView = new ChatResponseAccessibleView(); + const provider = instantiationService.invokeFunction(accessor => accessibleView.getProvider(accessor)); + assert.ok(provider); + store.add(provider); + const content = provider.provideContent(); + assert.ok(content.includes('main.ts')); + assert.ok(content.includes('/src/app/main.ts:42')); + }); + + test('uses basename as name for URI inline references without explicit name', () => { + const instantiationService = store.add(new TestInstantiationService()); + const storageService = store.add(new TestStorageService()); + + const responseItem = { + response: { + value: [ + { kind: 'inlineReference', inlineReference: URI.file('/workspace/src/utils.ts') } + ] + }, + model: { onDidChange: Event.None }, + setVote: () => undefined + }; + const items = [responseItem]; + let focusedItem: unknown = responseItem; + + const widget = { + hasInputFocus: () => false, + focusResponseItem: () => { focusedItem = responseItem; }, + getFocus: () => focusedItem, + focus: (item: unknown) => { focusedItem = item; }, + viewModel: { getItems: () => items } + } as unknown as IChatWidget; + + const widgetService = { + _serviceBrand: undefined, + lastFocusedWidget: widget, + onDidAddWidget: Event.None, + onDidBackgroundSession: Event.None, + reveal: async () => true, + revealWidget: async () => widget, + getAllWidgets: () => [widget], + getWidgetByInputUri: () => widget, + openSession: async () => widget, + getWidgetBySessionResource: () => widget + } as unknown as IChatWidgetService; + + instantiationService.stub(IChatWidgetService, widgetService); + instantiationService.stub(IStorageService, storageService); + + const accessibleView = new ChatResponseAccessibleView(); + const provider = instantiationService.invokeFunction(accessor => accessibleView.getProvider(accessor)); + assert.ok(provider); + store.add(provider); + const content = provider.provideContent(); + assert.ok(content.includes('utils.ts')); + assert.ok(content.includes('/workspace/src/utils.ts')); + }); }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts index 56f217f3c3a..9177b5307f4 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts @@ -7,7 +7,7 @@ import assert from 'assert'; import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { AgentSessionsDataSource, AgentSessionListItem, IAgentSessionsFilter, sessionDateFromNow, getRepositoryName, AgentSessionsSorter, groupAgentSessionsByDate } from '../../../browser/agentSessions/agentSessionsViewer.js'; -import { AgentSessionSection, IAgentSession, IAgentSessionSection, IAgentSessionsModel, isAgentSessionSection } from '../../../browser/agentSessions/agentSessionsModel.js'; +import { AgentSessionSection, IAgentSession, IAgentSessionSection, IAgentSessionsModel, isAgentSessionSection, isAgentSessionShowMore } from '../../../browser/agentSessions/agentSessionsModel.js'; import { ChatSessionStatus } from '../../../common/chatSessionsService.js'; import { ITreeSorter } from '../../../../../../base/browser/ui/tree/tree.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; @@ -137,12 +137,13 @@ suite('AgentSessionsDataSource', () => { groupBy?: AgentSessionsGrouping; exclude?: (session: IAgentSession) => boolean; excludeRead?: boolean; + repositoryGroupCapped?: boolean; }): IAgentSessionsFilter { return { onDidChange: Event.None, groupResults: () => options.groupBy, exclude: options.exclude ?? (() => false), - getExcludes: () => ({ providers: [], states: [], archived: false, read: options.excludeRead ?? false }), + getExcludes: () => ({ providers: [], states: [], archived: false, read: options.excludeRead ?? false, repositoryGroupCapped: options.repositoryGroupCapped ?? true }), isDefault: () => true, reset: () => { }, }; @@ -984,6 +985,116 @@ suite('AgentSessionsDataSource', () => { }); }); + suite('repositoryGroupLimit', () => { + + test('caps repo group children at limit and appends show-more item', () => { + const now = Date.now(); + const sessions = Array.from({ length: 8 }, (_, i) => + createMockSession({ id: `s${i}`, metadata: { repositoryNwo: 'owner/vscode' }, startTime: now - i * 1000 }) + ); + + const filter = createMockFilter({ groupBy: AgentSessionsGrouping.Repository }); + const dataSource = disposables.add(new AgentSessionsDataSource(filter, createMockSorter(), 5)); + const model = createMockModel(sessions); + const topLevel = Array.from(dataSource.getChildren(model)); + const section = topLevel.find(item => isAgentSessionSection(item) && item.section === AgentSessionSection.Repository) as IAgentSessionSection; + assert.ok(section); + + const children = Array.from(dataSource.getChildren(section)); + assert.strictEqual(children.length, 6); // 5 sessions + 1 show-more + const showMore = children[5]; + assert.ok(isAgentSessionShowMore(showMore)); + assert.strictEqual(showMore.remainingCount, 3); + assert.strictEqual(showMore.sectionLabel, 'vscode'); + }); + + test('does not cap when group has fewer items than limit', () => { + const now = Date.now(); + const sessions = Array.from({ length: 3 }, (_, i) => + createMockSession({ id: `s${i}`, metadata: { repositoryNwo: 'owner/vscode' }, startTime: now - i * 1000 }) + ); + + const filter = createMockFilter({ groupBy: AgentSessionsGrouping.Repository }); + const dataSource = disposables.add(new AgentSessionsDataSource(filter, createMockSorter(), 5)); + const model = createMockModel(sessions); + const topLevel = Array.from(dataSource.getChildren(model)); + const section = topLevel.find(item => isAgentSessionSection(item) && item.section === AgentSessionSection.Repository) as IAgentSessionSection; + + const children = Array.from(dataSource.getChildren(section)); + assert.strictEqual(children.length, 3); + assert.ok(!children.some(isAgentSessionShowMore)); + }); + + test('expanding a group removes the cap', () => { + const now = Date.now(); + const sessions = Array.from({ length: 8 }, (_, i) => + createMockSession({ id: `s${i}`, metadata: { repositoryNwo: 'owner/vscode' }, startTime: now - i * 1000 }) + ); + + const filter = createMockFilter({ groupBy: AgentSessionsGrouping.Repository }); + const dataSource = disposables.add(new AgentSessionsDataSource(filter, createMockSorter(), 5)); + const model = createMockModel(sessions); + const topLevel = Array.from(dataSource.getChildren(model)); + const section = topLevel.find(item => isAgentSessionSection(item) && item.section === AgentSessionSection.Repository) as IAgentSessionSection; + + dataSource.expandRepositoryGroup('vscode'); + const children = Array.from(dataSource.getChildren(section)); + assert.strictEqual(children.length, 8); + assert.ok(!children.some(isAgentSessionShowMore)); + }); + + test('does not cap non-repository sections', () => { + const now = Date.now(); + const sessions = Array.from({ length: 8 }, (_, i) => + createMockSession({ id: `s${i}`, startTime: now - i * 1000 }) + ); + + const filter = createMockFilter({ groupBy: AgentSessionsGrouping.Date }); + const dataSource = disposables.add(new AgentSessionsDataSource(filter, createMockSorter(), 5)); + const model = createMockModel(sessions); + const topLevel = Array.from(dataSource.getChildren(model)); + const todaySection = topLevel.find(item => isAgentSessionSection(item) && item.section === AgentSessionSection.Today) as IAgentSessionSection; + + const children = Array.from(dataSource.getChildren(todaySection)); + assert.strictEqual(children.length, 8); + assert.ok(!children.some(isAgentSessionShowMore)); + }); + + test('does not cap when repositoryGroupLimit is not set', () => { + const now = Date.now(); + const sessions = Array.from({ length: 8 }, (_, i) => + createMockSession({ id: `s${i}`, metadata: { repositoryNwo: 'owner/vscode' }, startTime: now - i * 1000 }) + ); + + const filter = createMockFilter({ groupBy: AgentSessionsGrouping.Repository }); + const dataSource = disposables.add(new AgentSessionsDataSource(filter, createMockSorter())); + const model = createMockModel(sessions); + const topLevel = Array.from(dataSource.getChildren(model)); + const section = topLevel.find(item => isAgentSessionSection(item) && item.section === AgentSessionSection.Repository) as IAgentSessionSection; + + const children = Array.from(dataSource.getChildren(section)); + assert.strictEqual(children.length, 8); + assert.ok(!children.some(isAgentSessionShowMore)); + }); + + test('does not cap when repositoryGroupCapped filter is disabled', () => { + const now = Date.now(); + const sessions = Array.from({ length: 8 }, (_, i) => + createMockSession({ id: `s${i}`, metadata: { repositoryNwo: 'owner/vscode' }, startTime: now - i * 1000 }) + ); + + const filter = createMockFilter({ groupBy: AgentSessionsGrouping.Repository, repositoryGroupCapped: false }); + const dataSource = disposables.add(new AgentSessionsDataSource(filter, createMockSorter(), 5)); + const model = createMockModel(sessions); + const topLevel = Array.from(dataSource.getChildren(model)); + const section = topLevel.find(item => isAgentSessionSection(item) && item.section === AgentSessionSection.Repository) as IAgentSessionSection; + + const children = Array.from(dataSource.getChildren(section)); + assert.strictEqual(children.length, 8); + assert.ok(!children.some(isAgentSessionShowMore)); + }); + }); + suite('getRepositoryName', () => { test('returns metadata.name when owner and name are present', () => { diff --git a/src/vs/workbench/contrib/terminal/browser/terminal.ts b/src/vs/workbench/contrib/terminal/browser/terminal.ts index b54f86b3ad3..8bfd7b0d910 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminal.ts @@ -1154,7 +1154,7 @@ export interface ITerminalInstance extends IBaseTerminalInstance { */ sendPath(originalPath: string | URI, shouldExecute: boolean): Promise; - runCommand(command: string, shouldExecute?: boolean, commandId?: string): Promise; + runCommand(command: string, shouldExecute?: boolean, commandId?: string, bracketedPasteMode?: boolean): Promise; /** * Takes a path and returns the properly escaped path to send to a given shell. On Windows, this diff --git a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts index fb793b76ba8..ca7d1e3edeb 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts @@ -974,7 +974,7 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { }); } - async runCommand(commandLine: string, shouldExecute: boolean, commandId?: string): Promise { + async runCommand(commandLine: string, shouldExecute: boolean, commandId?: string, forceBracketedPasteMode?: boolean): Promise { let commandDetection = this.capabilities.get(TerminalCapability.CommandDetection); const siInjectionEnabled = this._configurationService.getValue(TerminalSettingId.ShellIntegrationEnabled) === true; const timeoutMs = getShellIntegrationTimeout( @@ -1020,8 +1020,9 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { // is being evaluated await timeout(100); } - // Use bracketed paste mode only when not running the command - await this.sendText(commandLine, shouldExecute, !shouldExecute); + // By default, use bracketed paste mode only when not running the command; callers can override + // this by explicitly enabling it via the bracketedPasteMode argument. + await this.sendText(commandLine, shouldExecute, !shouldExecute || forceBracketedPasteMode); } detachFromElement(): void { @@ -1344,10 +1345,10 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { this.focus(force); } - async sendText(text: string, shouldExecute: boolean, bracketedPasteMode?: boolean): Promise { + async sendText(text: string, shouldExecute: boolean, forceBracketedPasteMode?: boolean): Promise { // Apply bracketed paste sequences if the terminal has the mode enabled, this will prevent // the text from triggering keybindings and ensure new lines are handled properly - if (bracketedPasteMode && this.xterm?.raw.modes.bracketedPasteMode) { + if (forceBracketedPasteMode && this.xterm?.raw.modes.bracketedPasteMode) { text = `\x1b[200~${text}\x1b[201~`; } diff --git a/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts b/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts index 2d1dd15ce52..be681d6ef06 100644 --- a/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts +++ b/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts @@ -734,3 +734,22 @@ Registry.as(WorkbenchExtensions.ConfigurationMi return configurationKeyValuePairs; } }]); + +Registry.as(WorkbenchExtensions.ConfigurationMigration) + .registerConfigurationMigrations([{ + key: TerminalContribSettingId.DeprecatedTerminalSandboxNetwork, + migrateFn: (value: { allowedDomains?: string[]; deniedDomains?: string[]; allowTrustedDomains?: boolean }, valueAccessor) => { + const configurationKeyValuePairs: ConfigurationKeyValuePairs = []; + if (value?.allowedDomains !== undefined && valueAccessor(TerminalContribSettingId.TerminalSandboxNetworkAllowedDomains) === undefined) { + configurationKeyValuePairs.push([TerminalContribSettingId.TerminalSandboxNetworkAllowedDomains, { value: value.allowedDomains }]); + } + if (value?.deniedDomains !== undefined && valueAccessor(TerminalContribSettingId.TerminalSandboxNetworkDeniedDomains) === undefined) { + configurationKeyValuePairs.push([TerminalContribSettingId.TerminalSandboxNetworkDeniedDomains, { value: value.deniedDomains }]); + } + if (value?.allowTrustedDomains !== undefined && valueAccessor(TerminalContribSettingId.TerminalSandboxNetworkAllowTrustedDomains) === undefined) { + configurationKeyValuePairs.push([TerminalContribSettingId.TerminalSandboxNetworkAllowTrustedDomains, { value: value.allowTrustedDomains }]); + } + configurationKeyValuePairs.push([TerminalContribSettingId.DeprecatedTerminalSandboxNetwork, { value: undefined }]); + return configurationKeyValuePairs; + } + }]); diff --git a/src/vs/workbench/contrib/terminal/terminalContribExports.ts b/src/vs/workbench/contrib/terminal/terminalContribExports.ts index a24b204a899..babbac632fc 100644 --- a/src/vs/workbench/contrib/terminal/terminalContribExports.ts +++ b/src/vs/workbench/contrib/terminal/terminalContribExports.ts @@ -46,6 +46,10 @@ export const enum TerminalContribSettingId { EnableAutoApprove = TerminalChatAgentToolsSettingId.EnableAutoApprove, ShellIntegrationTimeout = TerminalChatAgentToolsSettingId.ShellIntegrationTimeout, OutputLocation = TerminalChatAgentToolsSettingId.OutputLocation, + DeprecatedTerminalSandboxNetwork = TerminalChatAgentToolsSettingId.DeprecatedTerminalSandboxNetwork, + TerminalSandboxNetworkAllowedDomains = TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowedDomains, + TerminalSandboxNetworkDeniedDomains = TerminalChatAgentToolsSettingId.TerminalSandboxNetworkDeniedDomains, + TerminalSandboxNetworkAllowTrustedDomains = TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowTrustedDomains, } // HACK: Export some context key strings from `terminalContrib/` that are depended upon elsewhere. diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/basicExecuteStrategy.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/basicExecuteStrategy.ts index 6de862cb380..c99f166ad62 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/basicExecuteStrategy.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/basicExecuteStrategy.ts @@ -129,7 +129,7 @@ export class BasicExecuteStrategy extends Disposable implements ITerminalExecute // occurs. this._log(`Executing command line \`${commandLine}\``); markerRecreation.dispose(); - this._instance.sendText(commandLine, true); + this._instance.sendText(commandLine, true, true); // Wait for the next end execution event - note that this may not correspond to the actual // execution requested diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/noneExecuteStrategy.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/noneExecuteStrategy.ts index f72379411ca..c1f1b210253 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/noneExecuteStrategy.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/noneExecuteStrategy.ts @@ -84,7 +84,7 @@ export class NoneExecuteStrategy extends Disposable implements ITerminalExecuteS this._log(`Executing command line \`${commandLine}\``); markerRecreation.dispose(); const startLine = this._startMarker.value?.line; - this._instance.sendText(commandLine, true); + this._instance.sendText(commandLine, true, true); // Wait for the cursor to move past the command line before // starting idle detection. Without this, the idle poll may diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/richExecuteStrategy.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/richExecuteStrategy.ts index 8d97c97f859..cd8ac8edaf2 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/richExecuteStrategy.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/richExecuteStrategy.ts @@ -85,7 +85,7 @@ export class RichExecuteStrategy extends Disposable implements ITerminalExecuteS // Execute the command this._log(`Executing command line \`${commandLine}\``); markerRecreation.dispose(); - this._instance.runCommand(commandLine, true, commandId); + this._instance.runCommand(commandLine, true, commandId, true); // Wait for the terminal to idle this._log('Waiting for done event'); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/terminal.chatAgentTools.contribution.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/terminal.chatAgentTools.contribution.ts index 5d1e90193ab..e8bd2742546 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/terminal.chatAgentTools.contribution.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/terminal.chatAgentTools.contribution.ts @@ -142,7 +142,9 @@ export class ChatAgentToolsContribution extends Disposable implements IWorkbench this._register(this._configurationService.onDidChangeConfiguration(e => { if ( e.affectsConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxEnabled) || - e.affectsConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetwork) + e.affectsConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowedDomains) || + e.affectsConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkDeniedDomains) || + e.affectsConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowTrustedDomains) ) { this._registerRunInTerminalTool(); } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sandboxOutputAnalyzer.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sandboxOutputAnalyzer.ts index 8b1687f1d35..684fcf2051e 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sandboxOutputAnalyzer.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sandboxOutputAnalyzer.ts @@ -37,7 +37,7 @@ export class SandboxOutputAnalyzer extends Disposable implements IOutputAnalyzer ? 'Command failed while running in sandboxed mode. If the command failed due to sandboxing:' : 'Command ran in sandboxed mode and may have been blocked by the sandbox. If the command failed due to sandboxing:'; return `${prefix} -- If it would be reasonable to extend the sandbox rules, work with the user to update allowWrite for file system access problems in ${fileSystemSetting}, or to add required domains to ${TerminalChatAgentToolsSettingId.TerminalSandboxNetwork}.allowedDomains. +- If it would be reasonable to extend the sandbox rules, work with the user to update allowWrite for file system access problems in ${fileSystemSetting}, or to add required domains to ${TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowedDomains}. - Otherwise, immediately retry the command with requestUnsandboxedExecution=true. Do NOT ask the user — setting this flag automatically shows a confirmation prompt to the user. Here is the output of the command:\n`; diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts index e55ce632fd1..221f2e29d20 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts @@ -21,7 +21,9 @@ export const enum TerminalChatAgentToolsSettingId { AutoReplyToPrompts = 'chat.tools.terminal.autoReplyToPrompts', OutputLocation = 'chat.tools.terminal.outputLocation', TerminalSandboxEnabled = 'chat.tools.terminal.sandbox.enabled', - TerminalSandboxNetwork = 'chat.tools.terminal.sandbox.network', + TerminalSandboxNetworkAllowedDomains = 'chat.tools.terminal.sandbox.network.allowedDomains', + TerminalSandboxNetworkDeniedDomains = 'chat.tools.terminal.sandbox.network.deniedDomains', + TerminalSandboxNetworkAllowTrustedDomains = 'chat.tools.terminal.sandbox.network.allowTrustedDomains', TerminalSandboxLinuxFileSystem = 'chat.tools.terminal.sandbox.linuxFileSystem', TerminalSandboxMacFileSystem = 'chat.tools.terminal.sandbox.macFileSystem', PreventShellHistory = 'chat.tools.terminal.preventShellHistory', @@ -32,6 +34,7 @@ export const enum TerminalChatAgentToolsSettingId { TerminalProfileMacOs = 'chat.tools.terminal.terminalProfile.osx', TerminalProfileWindows = 'chat.tools.terminal.terminalProfile.windows', + DeprecatedTerminalSandboxNetwork = 'chat.tools.terminal.sandbox.network', DeprecatedAutoApproveCompatible = 'chat.agent.terminal.autoApprove', DeprecatedAutoApprove1 = 'chat.agent.terminal.allowList', DeprecatedAutoApprove2 = 'chat.agent.terminal.denyList', @@ -531,33 +534,26 @@ export const terminalChatAgentToolsConfiguration: IStringDictionary(TerminalChatAgentToolsSettingId.TerminalSandboxNetwork) ?? {}; + const allowedDomainsSetting = this._configurationService.getValue(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowedDomains) ?? []; + const deniedDomainsSetting = this._configurationService.getValue(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkDeniedDomains) ?? []; + const allowTrustedDomains = this._configurationService.getValue(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowTrustedDomains) ?? false; const linuxFileSystemSetting = this._os === OperatingSystem.Linux ? this._configurationService.getValue<{ denyRead?: string[]; allowWrite?: string[]; denyWrite?: string[] }>(TerminalChatAgentToolsSettingId.TerminalSandboxLinuxFileSystem) ?? {} : {}; @@ -201,15 +204,15 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb const linuxAllowWrite = this._updateAllowWritePathsWithWorkspaceFolders(linuxFileSystemSetting.allowWrite); const macAllowWrite = this._updateAllowWritePathsWithWorkspaceFolders(macFileSystemSetting.allowWrite); - let allowedDomains = networkSetting.allowedDomains ?? []; - if (networkSetting.allowTrustedDomains) { + let allowedDomains = allowedDomainsSetting; + if (allowTrustedDomains) { allowedDomains = this._addTrustedDomainsToAllowedDomains(allowedDomains); } const sandboxSettings = { network: { allowedDomains, - deniedDomains: networkSetting.deniedDomains ?? [] + deniedDomains: deniedDomainsSetting }, filesystem: { denyRead: this._os === OperatingSystem.Macintosh ? macFileSystemSetting.denyRead : linuxFileSystemSetting.denyRead, @@ -279,14 +282,15 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb } public getResolvedNetworkDomains(): ITerminalSandboxResolvedNetworkDomains { - const networkSetting = this._configurationService.getValue(TerminalChatAgentToolsSettingId.TerminalSandboxNetwork) ?? {}; - let allowedDomains = networkSetting.allowedDomains ?? []; - if (networkSetting.allowTrustedDomains) { + let allowedDomains = this._configurationService.getValue(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowedDomains) ?? []; + const deniedDomains = this._configurationService.getValue(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkDeniedDomains) ?? []; + const allowTrustedDomains = this._configurationService.getValue(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowTrustedDomains) ?? false; + if (allowTrustedDomains) { allowedDomains = this._addTrustedDomainsToAllowedDomains(allowedDomains); } return { allowedDomains, - deniedDomains: networkSetting.deniedDomains ?? [] + deniedDomains }; } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts index 33d74053c2b..a85db3988f2 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts @@ -167,11 +167,9 @@ suite('TerminalSandboxService - allowTrustedDomains', () => { // Setup default configuration configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxEnabled, true); - configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetwork, { - allowedDomains: [], - deniedDomains: [], - allowTrustedDomains: false - }); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowedDomains, []); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkDeniedDomains, []); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowTrustedDomains, false); instantiationService.stub(IConfigurationService, configurationService); instantiationService.stub(IFileService, fileService); @@ -191,11 +189,9 @@ suite('TerminalSandboxService - allowTrustedDomains', () => { test('should filter out sole wildcard (*) from trusted domains', async () => { // Setup: Enable allowTrustedDomains and add * to trusted domains - configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetwork, { - allowedDomains: [], - deniedDomains: [], - allowTrustedDomains: true - }); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowedDomains, []); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkDeniedDomains, []); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowTrustedDomains, true); trustedDomainService.trustedDomains = ['*']; const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); @@ -211,11 +207,9 @@ suite('TerminalSandboxService - allowTrustedDomains', () => { test('should allow wildcards with domains like *.github.com', async () => { // Setup: Enable allowTrustedDomains and add *.github.com - configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetwork, { - allowedDomains: [], - deniedDomains: [], - allowTrustedDomains: true - }); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowedDomains, []); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkDeniedDomains, []); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowTrustedDomains, true); trustedDomainService.trustedDomains = ['*.github.com']; const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); @@ -232,11 +226,9 @@ suite('TerminalSandboxService - allowTrustedDomains', () => { test('should combine trusted domains with configured allowedDomains, filtering out *', async () => { // Setup: Enable allowTrustedDomains with multiple domains including * - configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetwork, { - allowedDomains: ['example.com'], - deniedDomains: [], - allowTrustedDomains: true - }); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowedDomains, ['example.com']); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkDeniedDomains, []); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowTrustedDomains, true); trustedDomainService.trustedDomains = ['*', '*.github.com', 'microsoft.com']; const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); @@ -256,11 +248,9 @@ suite('TerminalSandboxService - allowTrustedDomains', () => { test('should not include trusted domains when allowTrustedDomains is false', async () => { // Setup: Disable allowTrustedDomains - configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetwork, { - allowedDomains: ['example.com'], - deniedDomains: [], - allowTrustedDomains: false - }); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowedDomains, ['example.com']); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkDeniedDomains, []); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowTrustedDomains, false); trustedDomainService.trustedDomains = ['*', '*.github.com']; const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); @@ -277,11 +267,9 @@ suite('TerminalSandboxService - allowTrustedDomains', () => { test('should deduplicate domains when combining sources', async () => { // Setup: Same domain in both sources - configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetwork, { - allowedDomains: ['github.com', '*.github.com'], - deniedDomains: [], - allowTrustedDomains: true - }); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowedDomains, ['github.com', '*.github.com']); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkDeniedDomains, []); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowTrustedDomains, true); trustedDomainService.trustedDomains = ['*.github.com', 'github.com']; const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); @@ -299,11 +287,9 @@ suite('TerminalSandboxService - allowTrustedDomains', () => { test('should handle empty trusted domains list', async () => { // Setup: Empty trusted domains - configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetwork, { - allowedDomains: ['example.com'], - deniedDomains: [], - allowTrustedDomains: true - }); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowedDomains, ['example.com']); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkDeniedDomains, []); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowTrustedDomains, true); trustedDomainService.trustedDomains = []; const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); @@ -320,11 +306,9 @@ suite('TerminalSandboxService - allowTrustedDomains', () => { test('should handle only * in trusted domains', async () => { // Setup: Only * in trusted domains (edge case) - configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetwork, { - allowedDomains: [], - deniedDomains: [], - allowTrustedDomains: true - }); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowedDomains, []); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkDeniedDomains, []); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowTrustedDomains, true); trustedDomainService.trustedDomains = ['*']; const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts index 6aab2ceb354..607bf6a7925 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts @@ -1977,8 +1977,8 @@ suite('ChatAgentToolsContribution - tool registration refresh', () => { // Fire network config change configurationService.onDidChangeConfigurationEmitter.fire({ - affectsConfiguration: (key: string) => key === TerminalChatAgentToolsSettingId.TerminalSandboxNetwork, - affectedKeys: new Set([TerminalChatAgentToolsSettingId.TerminalSandboxNetwork]), + affectsConfiguration: (key: string) => key === TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowedDomains, + affectedKeys: new Set([TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowedDomains]), source: ConfigurationTarget.USER, change: null!, }); diff --git a/src/vs/workbench/test/browser/componentFixtures/aiCustomizationListWidget.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/aiCustomizationListWidget.fixture.ts index 4fa628e23b8..914726763fd 100644 --- a/src/vs/workbench/test/browser/componentFixtures/aiCustomizationListWidget.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/aiCustomizationListWidget.fixture.ts @@ -19,6 +19,7 @@ import { PromptsType } from '../../../contrib/chat/common/promptSyntax/promptTyp import { IPromptsService, IResolvedAgentFile, AgentFileType, PromptsStorage, IPromptPath } from '../../../contrib/chat/common/promptSyntax/service/promptsService.js'; import { AICustomizationManagementSection } from '../../../contrib/chat/browser/aiCustomization/aiCustomizationManagement.js'; import { AICustomizationListWidget } from '../../../contrib/chat/browser/aiCustomization/aiCustomizationListWidget.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; import { IPathService } from '../../../services/path/common/pathService.js'; import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from './fixtureUtils.js'; import { ParsedPromptFile, PromptHeader } from '../../../contrib/chat/common/promptSyntax/promptFileParser.js'; @@ -155,6 +156,7 @@ async function renderInstructionsTab(ctx: ComponentFixtureContext, instructionFi reg.defineInstance(IFileService, new class extends mock() { override readonly onDidFilesChange = Event.None; }()); + reg.defineInstance(IProductService, new class extends mock() { }()); reg.defineInstance(IPathService, new class extends mock() { override readonly defaultUriScheme = 'file'; override userHome(): URI; diff --git a/src/vs/workbench/test/browser/componentFixtures/aiCustomizationManagementEditor.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/aiCustomizationManagementEditor.fixture.ts index f57c42abd49..465246d562f 100644 --- a/src/vs/workbench/test/browser/componentFixtures/aiCustomizationManagementEditor.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/aiCustomizationManagementEditor.fixture.ts @@ -14,7 +14,7 @@ import { constObservable, observableValue } from '../../../../base/common/observ import { URI } from '../../../../base/common/uri.js'; import { mock } from '../../../../base/test/common/mock.js'; import { ITextModelService } from '../../../../editor/common/services/resolverService.js'; -import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { IDialogService, IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { IFileService } from '../../../../platform/files/common/files.js'; import { IListService, ListService } from '../../../../platform/list/browser/listService.js'; import { IQuickInputService } from '../../../../platform/quickinput/common/quickInput.js'; @@ -23,22 +23,28 @@ import { IMarkdownRendererService } from '../../../../platform/markdown/browser/ import { IWorkspace, IWorkspaceContextService, WorkbenchState } from '../../../../platform/workspace/common/workspace.js'; import { IEditorGroup } from '../../../services/editor/common/editorGroupsService.js'; import { IExtensionService } from '../../../services/extensions/common/extensions.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; import { IPathService } from '../../../services/path/common/pathService.js'; import { IWorkingCopyService } from '../../../services/workingCopy/common/workingCopyService.js'; import { IWebviewService } from '../../../contrib/webview/browser/webview.js'; import { IAICustomizationWorkspaceService, AICustomizationManagementSection } from '../../../contrib/chat/common/aiCustomizationWorkspaceService.js'; import { CustomizationHarness, ICustomizationHarnessService, IHarnessDescriptor, createVSCodeHarnessDescriptor, createClaudeHarnessDescriptor, createCliHarnessDescriptor, getCliUserRoots, getClaudeUserRoots } from '../../../contrib/chat/common/customizationHarnessService.js'; import { PromptsType } from '../../../contrib/chat/common/promptSyntax/promptTypes.js'; -import { IPromptsService, IResolvedAgentFile, AgentFileType, PromptsStorage } from '../../../contrib/chat/common/promptSyntax/service/promptsService.js'; +import { IPromptsService, IResolvedAgentFile, AgentFileType, PromptsStorage, IAgentSkill, IChatPromptSlashCommand } from '../../../contrib/chat/common/promptSyntax/service/promptsService.js'; import { ParsedPromptFile } from '../../../contrib/chat/common/promptSyntax/promptFileParser.js'; -import { IAgentPluginService } from '../../../contrib/chat/common/plugins/agentPluginService.js'; -import { IPluginMarketplaceService } from '../../../contrib/chat/common/plugins/pluginMarketplaceService.js'; +import { IAgentPluginService, IAgentPlugin } from '../../../contrib/chat/common/plugins/agentPluginService.js'; +import { IPluginMarketplaceService, IMarketplacePlugin, MarketplaceType, PluginSourceKind } from '../../../contrib/chat/common/plugins/pluginMarketplaceService.js'; +import { MarketplaceReferenceKind } from '../../../contrib/chat/common/plugins/marketplaceReference.js'; import { IPluginInstallService } from '../../../contrib/chat/common/plugins/pluginInstallService.js'; import { AICustomizationManagementEditor } from '../../../contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.js'; +import { ContributionEnablementState } from '../../../contrib/chat/common/enablement.js'; import { AICustomizationManagementEditorInput } from '../../../contrib/chat/browser/aiCustomization/aiCustomizationManagementEditorInput.js'; import { IMcpWorkbenchService, IWorkbenchMcpServer, IMcpService, McpServerInstallState } from '../../../contrib/mcp/common/mcpTypes.js'; import { IMcpRegistry } from '../../../contrib/mcp/common/mcpRegistryTypes.js'; import { IWorkbenchLocalMcpServer, LocalMcpServerScope } from '../../../services/mcp/common/mcpWorkbenchManagementService.js'; +import { McpListWidget } from '../../../contrib/chat/browser/aiCustomization/mcpListWidget.js'; +import { PluginListWidget } from '../../../contrib/chat/browser/aiCustomization/pluginListWidget.js'; +import { IIterativePager } from '../../../../base/common/paging.js'; import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from './fixtureUtils.js'; // Ensure theme colors & widget CSS are loaded @@ -98,8 +104,33 @@ function createMockPromptsService(files: IFixtureFile[], agentInstructions: IRes return new ParsedPromptFile(uri, header as never); } override async getSourceFolders() { return [] as never[]; } - override async findAgentSkills() { return [] as never[]; } - override async getPromptSlashCommands() { return [] as never[]; } + override async findAgentSkills(): Promise { + return files.filter(f => f.type === PromptsType.skill).map(f => ({ + uri: f.uri, + storage: f.storage, + name: f.name ?? 'skill', + description: f.description, + disableModelInvocation: false, + userInvocable: true, + when: undefined, + })); + } + override async getPromptSlashCommands(): Promise { + const promptFiles = files.filter(f => f.type === PromptsType.prompt); + const commands = await Promise.all(promptFiles.map(async f => { + const promptPath = { uri: f.uri, storage: f.storage, type: f.type }; + const parsedPromptFile = await this.parseNew(f.uri, CancellationToken.None); + return { + name: f.name ?? 'prompt', + description: f.description, + argumentHint: undefined, + promptPath: promptPath as IChatPromptSlashCommand['promptPath'], + parsedPromptFile, + when: undefined, + }; + })); + return commands; + } }(); } @@ -138,24 +169,71 @@ function makeLocalMcpServer(id: string, label: string, scope: LocalMcpServerScop // ============================================================================ const allFiles: IFixtureFile[] = [ - // Copilot instructions + // Instructions — workspace { uri: URI.file('/workspace/.github/instructions/coding-standards.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Coding Standards', description: 'Repository-wide coding standards' }, { uri: URI.file('/workspace/.github/instructions/testing.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Testing', description: 'Testing best practices', applyTo: '**/*.test.ts' }, + { uri: URI.file('/workspace/.github/instructions/security.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Security', description: 'Security review checklist', applyTo: 'src/auth/**' }, + { uri: URI.file('/workspace/.github/instructions/accessibility.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Accessibility', description: 'WCAG compliance guidelines', applyTo: '**/*.tsx' }, + { uri: URI.file('/workspace/.github/instructions/api-design.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'API Design', description: 'REST API design conventions' }, + { uri: URI.file('/workspace/.github/instructions/performance.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Performance', description: 'Performance optimization rules', applyTo: 'src/core/**' }, + { uri: URI.file('/workspace/.github/instructions/error-handling.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Error Handling', description: 'Error handling patterns' }, + { uri: URI.file('/workspace/.github/instructions/database.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Database', description: 'Database migration and query patterns', applyTo: 'src/db/**' }, + // Instructions — user { uri: URI.file('/home/dev/.copilot/instructions/my-style.instructions.md'), storage: PromptsStorage.user, type: PromptsType.instructions, name: 'My Style', description: 'Personal coding style' }, - // Claude rules + { uri: URI.file('/home/dev/.copilot/instructions/typescript-rules.instructions.md'), storage: PromptsStorage.user, type: PromptsType.instructions, name: 'TypeScript Rules', description: 'Strict TypeScript conventions' }, + { uri: URI.file('/home/dev/.copilot/instructions/commit-messages.instructions.md'), storage: PromptsStorage.user, type: PromptsType.instructions, name: 'Commit Messages', description: 'Conventional commit format' }, + // Instructions — Claude rules { uri: URI.file('/workspace/.claude/rules/code-style.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Code Style', description: 'Claude code style rules' }, { uri: URI.file('/workspace/.claude/rules/testing.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Testing', description: 'Claude testing conventions' }, { uri: URI.file('/home/dev/.claude/rules/personal.md'), storage: PromptsStorage.user, type: PromptsType.instructions, name: 'Personal', description: 'Personal rules' }, - // Agents + // Agents — workspace { uri: URI.file('/workspace/.github/agents/reviewer.agent.md'), storage: PromptsStorage.local, type: PromptsType.agent, name: 'Reviewer', description: 'Code review agent' }, { uri: URI.file('/workspace/.github/agents/documenter.agent.md'), storage: PromptsStorage.local, type: PromptsType.agent, name: 'Documenter', description: 'Documentation agent' }, - { uri: URI.file('/workspace/.claude/agents/planner.md'), storage: PromptsStorage.local, type: PromptsType.agent, name: 'Planner', description: 'Project planning agent' }, - // Skills + { uri: URI.file('/workspace/.github/agents/tester.agent.md'), storage: PromptsStorage.local, type: PromptsType.agent, name: 'Tester', description: 'Test generation and validation' }, + { uri: URI.file('/workspace/.github/agents/refactorer.agent.md'), storage: PromptsStorage.local, type: PromptsType.agent, name: 'Refactorer', description: 'Code refactoring specialist' }, + { uri: URI.file('/workspace/.github/agents/security-auditor.agent.md'), storage: PromptsStorage.local, type: PromptsType.agent, name: 'Security Auditor', description: 'Security vulnerability scanner' }, + { uri: URI.file('/workspace/.github/agents/api-designer.agent.md'), storage: PromptsStorage.local, type: PromptsType.agent, name: 'API Designer', description: 'REST and GraphQL API design' }, + { uri: URI.file('/workspace/.github/agents/performance-tuner.agent.md'), storage: PromptsStorage.local, type: PromptsType.agent, name: 'Performance Tuner', description: 'Performance profiling and optimization' }, + // Agents — user + { uri: URI.file('/home/dev/.copilot/agents/planner.agent.md'), storage: PromptsStorage.user, type: PromptsType.agent, name: 'Planner', description: 'Project planning agent' }, + { uri: URI.file('/home/dev/.copilot/agents/debugger.agent.md'), storage: PromptsStorage.user, type: PromptsType.agent, name: 'Debugger', description: 'Interactive debugging assistant' }, + { uri: URI.file('/home/dev/.copilot/agents/nls-helper.agent.md'), storage: PromptsStorage.user, type: PromptsType.agent, name: 'NLS Helper', description: 'Natural language searching code for clarity' }, + // Skills — workspace { uri: URI.file('/workspace/.github/skills/deploy/SKILL.md'), storage: PromptsStorage.local, type: PromptsType.skill, name: 'Deploy', description: 'Deployment automation' }, { uri: URI.file('/workspace/.github/skills/refactor/SKILL.md'), storage: PromptsStorage.local, type: PromptsType.skill, name: 'Refactor', description: 'Code refactoring patterns' }, - // Prompts + { uri: URI.file('/workspace/.github/skills/unit-tests/SKILL.md'), storage: PromptsStorage.local, type: PromptsType.skill, name: 'Unit Tests', description: 'Test generation and runner integration' }, + { uri: URI.file('/workspace/.github/skills/ci-fix/SKILL.md'), storage: PromptsStorage.local, type: PromptsType.skill, name: 'CI Fix', description: 'Diagnose and fix CI failures' }, + { uri: URI.file('/workspace/.github/skills/migration/SKILL.md'), storage: PromptsStorage.local, type: PromptsType.skill, name: 'Migration', description: 'Database migration generation' }, + { uri: URI.file('/workspace/.github/skills/accessibility/SKILL.md'), storage: PromptsStorage.local, type: PromptsType.skill, name: 'Accessibility', description: 'ARIA labels and keyboard navigation' }, + { uri: URI.file('/workspace/.github/skills/docker/SKILL.md'), storage: PromptsStorage.local, type: PromptsType.skill, name: 'Docker', description: 'Dockerfile and compose generation' }, + { uri: URI.file('/workspace/.github/skills/api-docs/SKILL.md'), storage: PromptsStorage.local, type: PromptsType.skill, name: 'API Docs', description: 'OpenAPI spec generation' }, + // Skills — user + { uri: URI.file('/home/dev/.copilot/skills/git-workflow/SKILL.md'), storage: PromptsStorage.user, type: PromptsType.skill, name: 'Git Workflow', description: 'Branch and PR workflows' }, + { uri: URI.file('/home/dev/.copilot/skills/code-review/SKILL.md'), storage: PromptsStorage.user, type: PromptsType.skill, name: 'Code Review', description: 'Structured code review checklist' }, + // Prompts — workspace { uri: URI.file('/workspace/.github/prompts/explain.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt, name: 'Explain', description: 'Explain selected code' }, { uri: URI.file('/workspace/.github/prompts/review.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt, name: 'Review', description: 'Review changes' }, + { uri: URI.file('/workspace/.github/prompts/fix-bug.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt, name: 'Fix Bug', description: 'Diagnose and fix a bug from issue' }, + { uri: URI.file('/workspace/.github/prompts/write-tests.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt, name: 'Write Tests', description: 'Generate unit tests for selection' }, + { uri: URI.file('/workspace/.github/prompts/add-docs.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt, name: 'Add Docs', description: 'Add JSDoc comments to functions' }, + { uri: URI.file('/workspace/.github/prompts/optimize.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt, name: 'Optimize', description: 'Optimize code for performance' }, + { uri: URI.file('/workspace/.github/prompts/convert-to-ts.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt, name: 'Convert to TS', description: 'Convert JavaScript to TypeScript' }, + { uri: URI.file('/workspace/.github/prompts/summarize-pr.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt, name: 'Summarize PR', description: 'Generate PR description from diff' }, + // Prompts — user + { uri: URI.file('/home/dev/.copilot/prompts/translate.prompt.md'), storage: PromptsStorage.user, type: PromptsType.prompt, name: 'Translate', description: 'Translate strings for i18n' }, + { uri: URI.file('/home/dev/.copilot/prompts/commit-msg.prompt.md'), storage: PromptsStorage.user, type: PromptsType.prompt, name: 'Commit Message', description: 'Generate conventional commit' }, + // Hooks — workspace + { uri: URI.file('/workspace/.github/hooks/pre-commit.json'), storage: PromptsStorage.local, type: PromptsType.hook, name: 'Pre-Commit Lint', description: 'Run linting before commit' }, + { uri: URI.file('/workspace/.github/hooks/post-save.json'), storage: PromptsStorage.local, type: PromptsType.hook, name: 'Post-Save Format', description: 'Auto-format on save' }, + { uri: URI.file('/workspace/.github/hooks/on-test-fail.json'), storage: PromptsStorage.local, type: PromptsType.hook, name: 'On Test Failure', description: 'Suggest fix when tests fail' }, + { uri: URI.file('/workspace/.github/hooks/pre-push.json'), storage: PromptsStorage.local, type: PromptsType.hook, name: 'Pre-Push Check', description: 'Run type-check before push' }, + { uri: URI.file('/workspace/.github/hooks/post-create.json'), storage: PromptsStorage.local, type: PromptsType.hook, name: 'Post-Create', description: 'Initialize boilerplate for new files' }, + { uri: URI.file('/workspace/.github/hooks/on-error.json'), storage: PromptsStorage.local, type: PromptsType.hook, name: 'On Error', description: 'Log and report unhandled errors' }, + { uri: URI.file('/workspace/.github/hooks/post-tool-call.json'), storage: PromptsStorage.local, type: PromptsType.hook, name: 'Post Tool Call', description: 'Echo confirmation after each tool call' }, + { uri: URI.file('/workspace/.github/hooks/on-build-fail.json'), storage: PromptsStorage.local, type: PromptsType.hook, name: 'On Build Failure', description: 'Auto-diagnose build errors' }, + // Hooks — user + { uri: URI.file('/home/dev/.copilot/hooks/daily-summary.json'), storage: PromptsStorage.user, type: PromptsType.hook, name: 'Daily Summary', description: 'Generate daily work summary' }, + { uri: URI.file('/home/dev/.copilot/hooks/backup-changes.json'), storage: PromptsStorage.user, type: PromptsType.hook, name: 'Backup Changes', description: 'Auto-stash uncommitted changes' }, ]; const agentInstructions: IResolvedAgentFile[] = [ @@ -167,9 +245,17 @@ const agentInstructions: IResolvedAgentFile[] = [ const mcpWorkspaceServers = [ makeLocalMcpServer('mcp-postgres', 'PostgreSQL', LocalMcpServerScope.Workspace, 'Database access'), makeLocalMcpServer('mcp-github', 'GitHub', LocalMcpServerScope.Workspace, 'GitHub API'), + makeLocalMcpServer('mcp-redis', 'Redis', LocalMcpServerScope.Workspace, 'In-memory data store'), + makeLocalMcpServer('mcp-docker', 'Docker', LocalMcpServerScope.Workspace, 'Container management'), + makeLocalMcpServer('mcp-slack', 'Slack', LocalMcpServerScope.Workspace, 'Team messaging'), + makeLocalMcpServer('mcp-jira', 'Jira', LocalMcpServerScope.Workspace, 'Issue tracking'), + makeLocalMcpServer('mcp-aws', 'AWS', LocalMcpServerScope.Workspace, 'Amazon Web Services'), + makeLocalMcpServer('mcp-graphql', 'GraphQL', LocalMcpServerScope.Workspace, 'GraphQL API gateway'), ]; const mcpUserServers = [ makeLocalMcpServer('mcp-web-search', 'Web Search', LocalMcpServerScope.User, 'Search the web'), + makeLocalMcpServer('mcp-filesystem', 'Filesystem', LocalMcpServerScope.User, 'Local file operations'), + makeLocalMcpServer('mcp-puppeteer', 'Puppeteer', LocalMcpServerScope.User, 'Browser automation'), ]; const mcpRuntimeServers = [ { definition: { id: 'github-copilot-mcp', label: 'GitHub Copilot' }, collection: { id: 'ext.github.copilot/mcp', label: 'ext.github.copilot/mcp' }, enablement: constObservable(2), connectionState: constObservable({ state: 2 }) }, @@ -180,6 +266,7 @@ interface IRenderEditorOptions { readonly isSessionsWindow?: boolean; readonly managementSections?: readonly AICustomizationManagementSection[]; readonly availableHarnesses?: readonly IHarnessDescriptor[]; + readonly selectedSection?: AICustomizationManagementSection; } // ============================================================================ @@ -277,7 +364,7 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor override readonly onDidChangeInputs = Event.None; }()); reg.defineInstance(IAgentPluginService, new class extends mock() { - override readonly plugins = constObservable([]); + override readonly plugins = constObservable(installedPlugins); override readonly enablementModel = undefined as never; }()); reg.defineInstance(IPluginMarketplaceService, new class extends mock() { @@ -285,6 +372,7 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor override readonly onDidChangeMarketplaces = Event.None; }()); reg.defineInstance(IPluginInstallService, new class extends mock() { }()); + reg.defineInstance(IProductService, new class extends mock() { }()); }, }); @@ -300,6 +388,210 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor } catch { // Expected in fixture — some services are partially mocked } + + if (options.selectedSection) { + editor.selectSectionById(options.selectedSection); + editor.layout(new Dimension(width, height)); + } +} + +// ============================================================================ +// MCP Browse Mode — standalone widget with gallery results +// ============================================================================ + +function makeGalleryServer(id: string, label: string, description: string, publisher: string): IWorkbenchMcpServer { + const galleryStub = new class extends mock>() { }(); + return new class extends mock() { + override readonly id = id; + override readonly name = id; + override readonly label = label; + override readonly description = description; + override readonly publisherDisplayName = publisher; + override readonly installState = McpServerInstallState.Uninstalled; + override readonly gallery = galleryStub; + override readonly local = undefined; + }(); +} + +const galleryServers = [ + makeGalleryServer('gallery-postgres', 'PostgreSQL', 'Access PostgreSQL databases with schema inspection and query tools', 'Microsoft'), + makeGalleryServer('gallery-github', 'GitHub', 'Repository management, issues, pull requests, and code search', 'GitHub'), + makeGalleryServer('gallery-slack', 'Slack', 'Send messages, manage channels, and search workspace history', 'Slack Technologies'), + makeGalleryServer('gallery-docker', 'Docker', 'Container lifecycle management and image operations', 'Docker Inc'), + makeGalleryServer('gallery-filesystem', 'Filesystem', 'Read, write, and navigate local files and directories', 'Microsoft'), + makeGalleryServer('gallery-brave', 'Brave Search', 'Web and local search powered by the Brave Search API', 'Brave Software'), + makeGalleryServer('gallery-puppeteer', 'Puppeteer', 'Browser automation with screenshots, navigation, and form filling', 'Google'), + makeGalleryServer('gallery-memory', 'Memory', 'Knowledge graph for persistent memory across conversations', 'Microsoft'), + makeGalleryServer('gallery-fetch', 'Fetch', 'Retrieve and convert web content to markdown for analysis', 'Microsoft'), + makeGalleryServer('gallery-sentry', 'Sentry', 'Error monitoring, issue tracking, and performance tracing', 'Sentry'), + makeGalleryServer('gallery-sqlite', 'SQLite', 'Query and manage SQLite databases with schema exploration', 'Community'), + makeGalleryServer('gallery-redis', 'Redis', 'In-memory data store operations and key management', 'Redis Ltd'), +]; + +async function renderMcpBrowseMode(ctx: ComponentFixtureContext): Promise { + const width = 650; + const height = 500; + ctx.container.style.width = `${width}px`; + ctx.container.style.height = `${height}px`; + + const instantiationService = createEditorServices(ctx.disposableStore, { + colorTheme: ctx.theme, + additionalServices: (reg) => { + registerWorkbenchServices(reg); + reg.define(IListService, ListService); + reg.defineInstance(IMcpWorkbenchService, new class extends mock() { + override readonly onChange = Event.None; + override readonly onReset = Event.None; + override readonly local: IWorkbenchMcpServer[] = []; + override async queryLocal() { return []; } + override canInstall() { return true as const; } + override async queryGallery(): Promise> { + return { + firstPage: { items: galleryServers, hasMore: false }, + async getNextPage() { return { items: [], hasMore: false }; }, + }; + } + }()); + reg.defineInstance(IMcpService, new class extends mock() { + override readonly servers = constObservable([] as never[]); + }()); + reg.defineInstance(IMcpRegistry, new class extends mock() { + override readonly collections = constObservable([]); + override readonly delegates = constObservable([]); + override readonly onDidChangeInputs = Event.None; + }()); + reg.defineInstance(IAgentPluginService, new class extends mock() { + override readonly plugins = constObservable([]); + }()); + reg.defineInstance(IDialogService, new class extends mock() { }()); + reg.defineInstance(IAICustomizationWorkspaceService, new class extends mock() { + override readonly isSessionsWindow = false; + override readonly activeProjectRoot = observableValue('root', URI.file('/workspace')); + override readonly hasOverrideProjectRoot = observableValue('hasOverride', false); + override getActiveProjectRoot() { return URI.file('/workspace'); } + override getStorageSourceFilter() { + return { sources: [PromptsStorage.local, PromptsStorage.user, PromptsStorage.extension, PromptsStorage.plugin] }; + } + }()); + reg.defineInstance(ICustomizationHarnessService, new class extends mock() { + override readonly activeHarness = observableValue('activeHarness', CustomizationHarness.VSCode); + override getActiveDescriptor() { return createVSCodeHarnessDescriptor([PromptsStorage.extension]); } + }()); + }, + }); + + const widget = ctx.disposableStore.add( + instantiationService.createInstance(McpListWidget) + ); + ctx.container.appendChild(widget.element); + widget.layout(height, width); + + // Click the Browse Marketplace button to enter browse mode + const browseButton = widget.element.querySelector('.list-add-button') as HTMLElement; + browseButton?.click(); + + // Wait for the gallery query to resolve + await new Promise(resolve => setTimeout(resolve, 50)); +} + +// ============================================================================ +// Plugin Browse Mode — standalone widget with marketplace results +// ============================================================================ + +function makeInstalledPlugin(name: string, uri: URI, enabled: boolean): IAgentPlugin { + return new class extends mock() { + override readonly uri = uri; + override readonly label = name; + override readonly enablement = constObservable(enabled ? ContributionEnablementState.EnabledProfile : ContributionEnablementState.DisabledProfile); + override readonly hooks = constObservable([]); + override readonly commands = constObservable([]); + override readonly skills = constObservable([]); + override readonly agents = constObservable([]); + override readonly instructions = constObservable([]); + override readonly mcpServerDefinitions = constObservable([]); + override remove() { } + }(); +} + +const installedPlugins: IAgentPlugin[] = [ + makeInstalledPlugin('Linear', URI.file('/workspace/.copilot/plugins/linear'), true), + makeInstalledPlugin('Sentry', URI.file('/workspace/.copilot/plugins/sentry'), true), + makeInstalledPlugin('Datadog', URI.file('/workspace/.copilot/plugins/datadog'), true), + makeInstalledPlugin('Notion', URI.file('/workspace/.copilot/plugins/notion'), true), + makeInstalledPlugin('Confluence', URI.file('/workspace/.copilot/plugins/confluence'), true), + makeInstalledPlugin('PagerDuty', URI.file('/workspace/.copilot/plugins/pagerduty'), false), + makeInstalledPlugin('LaunchDarkly', URI.file('/workspace/.copilot/plugins/launchdarkly'), true), + makeInstalledPlugin('CircleCI', URI.file('/workspace/.copilot/plugins/circleci'), true), + makeInstalledPlugin('Vercel', URI.file('/workspace/.copilot/plugins/vercel'), false), + makeInstalledPlugin('Supabase', URI.file('/workspace/.copilot/plugins/supabase'), true), +]; + +function makeMarketplacePlugin(name: string, description: string, repo: string): IMarketplacePlugin { + return { + name, + description, + version: '1.0.0', + source: repo, + sourceDescriptor: { kind: PluginSourceKind.GitHub, repo: `example/${repo}` }, + marketplace: 'copilot', + marketplaceReference: { rawValue: `example/${repo}`, displayLabel: repo, cloneUrl: `https://github.com/example/${repo}.git`, canonicalId: `github:example/${repo}`, cacheSegments: ['example', repo], kind: MarketplaceReferenceKind.GitHubShorthand }, + marketplaceType: MarketplaceType.Copilot, + }; +} + +const marketplacePlugins: IMarketplacePlugin[] = [ + makeMarketplacePlugin('Linear', 'Issue tracking and project management integration', 'linear-plugin'), + makeMarketplacePlugin('Sentry', 'Error monitoring and performance tracing', 'sentry-plugin'), + makeMarketplacePlugin('Datadog', 'Observability and monitoring dashboards', 'datadog-plugin'), + makeMarketplacePlugin('Notion', 'Knowledge base and documentation management', 'notion-plugin'), + makeMarketplacePlugin('Figma', 'Design system inspection and asset export', 'figma-plugin'), + makeMarketplacePlugin('Stripe', 'Payment processing and billing management', 'stripe-plugin'), + makeMarketplacePlugin('Twilio', 'Communication APIs for SMS and voice', 'twilio-plugin'), + makeMarketplacePlugin('Auth0', 'Identity and access management', 'auth0-plugin'), + makeMarketplacePlugin('Algolia', 'Search and discovery API integration', 'algolia-plugin'), + makeMarketplacePlugin('LaunchDarkly', 'Feature flag management and experimentation', 'launchdarkly-plugin'), + makeMarketplacePlugin('PlanetScale', 'Serverless MySQL database management', 'planetscale-plugin'), + makeMarketplacePlugin('Vercel', 'Deployment and preview environments', 'vercel-plugin'), +]; + +async function renderPluginBrowseMode(ctx: ComponentFixtureContext): Promise { + const width = 650; + const height = 500; + ctx.container.style.width = `${width}px`; + ctx.container.style.height = `${height}px`; + + const instantiationService = createEditorServices(ctx.disposableStore, { + colorTheme: ctx.theme, + additionalServices: (reg) => { + registerWorkbenchServices(reg); + reg.define(IListService, ListService); + reg.defineInstance(IAgentPluginService, new class extends mock() { + override readonly plugins = constObservable([] as readonly IAgentPlugin[]); + override readonly enablementModel = undefined!; + }()); + reg.defineInstance(IPluginMarketplaceService, new class extends mock() { + override readonly installedPlugins = constObservable([]); + override readonly onDidChangeMarketplaces = Event.None; + override async fetchMarketplacePlugins() { return marketplacePlugins; } + }()); + reg.defineInstance(IPluginInstallService, new class extends mock() { + override getPluginInstallUri() { return URI.file('/dev/null'); } + }()); + }, + }); + + const widget = ctx.disposableStore.add( + instantiationService.createInstance(PluginListWidget) + ); + ctx.container.appendChild(widget.element); + widget.layout(height, width); + + // Click the Browse Marketplace button to enter browse mode + const browseButton = widget.element.querySelector('.list-add-button') as HTMLElement; + browseButton?.click(); + + // Wait for the marketplace query to resolve + await new Promise(resolve => setTimeout(resolve, 50)); } // ============================================================================ @@ -350,4 +642,80 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { ], }), }), + + // MCP Servers tab with many servers to verify scrollable list layout + McpServersTab: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEditor(ctx, { + harness: CustomizationHarness.VSCode, + selectedSection: AICustomizationManagementSection.McpServers, + }), + }), + + // Agents tab — workspace and user agents, scrollable + AgentsTab: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEditor(ctx, { + harness: CustomizationHarness.VSCode, + selectedSection: AICustomizationManagementSection.Agents, + }), + }), + + // Skills tab — workspace and user skills, scrollable + SkillsTab: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEditor(ctx, { + harness: CustomizationHarness.VSCode, + selectedSection: AICustomizationManagementSection.Skills, + }), + }), + + // Instructions tab — many instructions with applyTo patterns, scrollable + InstructionsTab: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEditor(ctx, { + harness: CustomizationHarness.VSCode, + selectedSection: AICustomizationManagementSection.Instructions, + }), + }), + + // Hooks tab — workspace and user hooks, scrollable + HooksTab: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEditor(ctx, { + harness: CustomizationHarness.VSCode, + selectedSection: AICustomizationManagementSection.Hooks, + }), + }), + + // Prompts tab — workspace and user prompts, scrollable + PromptsTab: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEditor(ctx, { + harness: CustomizationHarness.VSCode, + selectedSection: AICustomizationManagementSection.Prompts, + }), + }), + + // Plugins tab + PluginsTab: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEditor(ctx, { + harness: CustomizationHarness.VSCode, + selectedSection: AICustomizationManagementSection.Plugins, + }), + }), + + // MCP browse/marketplace mode — standalone widget with gallery results, scrollable + // Verifies fix for https://github.com/microsoft/vscode/issues/304139 + McpBrowseMode: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: renderMcpBrowseMode, + }), + + // Plugin browse/marketplace mode — standalone widget with marketplace results, scrollable + PluginBrowseMode: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: renderPluginBrowseMode, + }), });