feat(vscode): Add ACP Chat Participant for VS Code#15501
feat(vscode): Add ACP Chat Participant for VS Code#15501rudironsoni wants to merge 3 commits intoanomalyco:devfrom
Conversation
|
Thanks for updating your PR! It now meets our contributing guidelines. 👍 |
eca00bd to
af0930d
Compare
There was a problem hiding this comment.
Pull request overview
Adds a new VS Code extension feature set to integrate OpenCode as a native VS Code Chat participant backed by an ACP (JSON-RPC over stdio) client/process layer, with accompanying storage/session plumbing and extensive tests.
Changes:
- Introduces ACP protocol/client/connection/process implementations for stdio JSON-RPC communication.
- Adds VS Code-side components for activation, chat participant registration, request handling, and storage/session persistence.
- Adds unit + integration test suites and CI workflow coverage for the VS Code SDK package.
Reviewed changes
Copilot reviewed 29 out of 29 changed files in this pull request and generated 14 comments.
Show a summary per file
| File | Description |
|---|---|
| sdks/vscode/src/vscode/storage.ts | Implements transcript + session index persistence under storageUri, plus workspaceState caching. |
| sdks/vscode/src/vscode/storage.test.ts | Unit tests for storage behaviors (index, transcripts, retention, flush). |
| sdks/vscode/src/vscode/session.ts | Adds a session manager that stores session metadata in workspaceState and uses ACP sessions. |
| sdks/vscode/src/vscode/session.test.ts | Unit tests for session creation/reuse/title update/delete logic. |
| sdks/vscode/src/vscode/participant.ts | Registers the @opencode chat participant and defines its request handler. |
| sdks/vscode/src/vscode/participant.test.ts | Unit tests for participant metadata, commands, mentions, and activation integration. |
| sdks/vscode/src/vscode/handler.ts | Implements a chat request handler that builds ACP prompts from VS Code chat history and streams updates. |
| sdks/vscode/src/vscode/handler.test.ts | Unit tests for prompt building, streaming, cancellation, and error mapping. |
| sdks/vscode/src/vscode/activation.ts | Adds on-demand ACP process startup/stop logic with lifecycle tracking. |
| sdks/vscode/src/vscode/activation.test.ts | Unit tests for activation state transitions, concurrency, and disposal behavior. |
| sdks/vscode/src/integration/chat.test.ts | Adds basic integration test scaffolding for chat API availability. |
| sdks/vscode/src/integration/activation.test.ts | Adds basic integration test scaffolding for activation and progress APIs. |
| sdks/vscode/src/fixtures/mockAcpServer.ts | Introduces a mock ACP server intended for integration testing. |
| sdks/vscode/src/extension.ts | Wires activation controller + chat participant registration into extension activation. |
| sdks/vscode/src/core/types.ts | Re-exports storage-related types via a core types module. |
| sdks/vscode/src/core/session/key.ts | Placeholder for future session key management. |
| sdks/vscode/src/core/prompt/builder.ts | Placeholder for future prompt builder. |
| sdks/vscode/src/acp/protocol.ts | Defines ACP protocol types and error codes. |
| sdks/vscode/src/acp/process.ts | Implements spawning/monitoring of the opencode acp subprocess with restart logic. |
| sdks/vscode/src/acp/process.test.ts | Unit tests for process spawn/stdio/healthcheck/restart/stop logic. |
| sdks/vscode/src/acp/connection.ts | Implements JSON-RPC request/response + notification handling over streams. |
| sdks/vscode/src/acp/connection.test.ts | Unit tests for connection buffering, timeouts, notifications, and disposal. |
| sdks/vscode/src/acp/client.ts | Implements high-level ACP client APIs over the JSON-RPC connection. |
| sdks/vscode/src/acp/client.test.ts | Unit tests for client initialization, session APIs, streaming updates, cancel, and disposal. |
| sdks/vscode/package.json | Contributes the chatParticipants entry for @opencode and retains extension commands/scripts. |
| sdks/vscode/PLAN.md | Documents the intended architecture/phases for ACP-based chat integration. |
| sdks/vscode/.vscode-test.mjs | Updates VS Code test-cli configuration to separate unit vs integration suites. |
| .github/workflows/vscode-test.yml | Adds CI workflow to build + run VS Code extension tests on Linux/Windows. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| this.participant.iconPath = vscode.Uri.file(this.context.asAbsolutePath("images/icon.png")) | ||
| this.participant.followupProvider = this.createFollowupProvider() |
There was a problem hiding this comment.
register() sets iconPath and followupProvider on the internal vscode.ChatParticipant, but the public iconPath / followupProvider fields on OpenCodeChatParticipant are never assigned. This makes those properties always undefined (and the unit tests that read them will fail). Either remove these fields, or assign them in register() and keep them in sync with this.participant.
| this.participant.iconPath = vscode.Uri.file(this.context.asAbsolutePath("images/icon.png")) | |
| this.participant.followupProvider = this.createFollowupProvider() | |
| const iconPath = vscode.Uri.file(this.context.asAbsolutePath("images/icon.png")) | |
| const followupProvider = this.createFollowupProvider() | |
| this.iconPath = iconPath | |
| this.followupProvider = followupProvider | |
| this.participant.iconPath = iconPath | |
| this.participant.followupProvider = followupProvider |
sdks/vscode/src/vscode/handler.ts
Outdated
| private setupStreaming(sessionId: string, stream: vscode.ChatResponseStream): void { | ||
| this.client.onSessionUpdate((updateSessionId, update) => { | ||
| if (updateSessionId !== sessionId) { | ||
| return | ||
| } | ||
|
|
||
| this.handleSessionUpdate(update, stream) | ||
| }) | ||
| } |
There was a problem hiding this comment.
setupStreaming registers a new client.onSessionUpdate listener on every handle() call, but never unregisters it. Over multiple chat turns this will accumulate listeners and duplicate streaming output (and leak memory). Prefer registering once per handler instance and routing by sessionId, or change AcpClient.onSessionUpdate to return a disposable/unsubscribe function and dispose it when the request completes.
sdks/vscode/src/vscode/activation.ts
Outdated
| if (this.activeSessions > 0 && this.restartCount < this.config.maxRestarts) { | ||
| this.restartCount++ | ||
| this.state = ActivationState.STARTING | ||
|
|
||
| vscode.window.showWarningMessage( | ||
| `OpenCode process crashed. Restarting... (attempt ${this.restartCount}/${this.config.maxRestarts})`, | ||
| ) | ||
| } else { | ||
| this.state = ActivationState.ERROR | ||
| this.cleanup() | ||
| } |
There was a problem hiding this comment.
handleProcessCrash changes controller state and shows a warning, but it doesn't actually re-initialize JsonRpcConnection/AcpClient after an AcpProcess restart. Since AcpProcess can auto-restart internally, the controller will keep a connection/client wired to the old stdio streams and future requests will fail. Consider handling process.onRestart/onSpawn to rebuild the connection/client, or disable internal auto-restart and perform a full restart sequence here (including awaiting cleanup()).
| if (this.activeSessions > 0 && this.restartCount < this.config.maxRestarts) { | |
| this.restartCount++ | |
| this.state = ActivationState.STARTING | |
| vscode.window.showWarningMessage( | |
| `OpenCode process crashed. Restarting... (attempt ${this.restartCount}/${this.config.maxRestarts})`, | |
| ) | |
| } else { | |
| this.state = ActivationState.ERROR | |
| this.cleanup() | |
| } | |
| // Treat any crash as a hard failure: move to ERROR and clean up resources. | |
| this.state = ActivationState.ERROR | |
| this.cleanup() | |
| vscode.window.showWarningMessage("OpenCode process crashed and has been stopped.") |
sdks/vscode/src/extension.ts
Outdated
| } | ||
| }) | ||
|
|
||
| context.subscriptions.push(openTerminalDisposable, addFilepathDisposable) |
There was a problem hiding this comment.
openNewTerminalDisposable is created but never added to context.subscriptions, so it won't be disposed on extension deactivation/reload. Add it to the context.subscriptions.push(...) call alongside the other command disposables.
| context.subscriptions.push(openTerminalDisposable, addFilepathDisposable) | |
| context.subscriptions.push(openNewTerminalDisposable, openTerminalDisposable, addFilepathDisposable) |
sdks/vscode/.vscode-test.mjs
Outdated
| import { defineConfig, configureVirtualLab } from "@vscode/test-cli" | ||
|
|
||
| export default defineConfig({ | ||
| files: "out/test/**/*.test.js", | ||
| }) | ||
| /** | ||
| * VS Code Test CLI configuration. | ||
| * Supports multiple test configurations: | ||
| * - unit: Unit tests with mocked VS Code APIs | ||
| * - integration: Integration tests requiring Extension Host | ||
| */ | ||
| export default defineConfig([ | ||
| // Unit tests (default) | ||
| { | ||
| label: "unit", | ||
| files: "out/src/acp/*.test.js", | ||
| }, | ||
| { | ||
| label: "unit", | ||
| files: "out/src/vscode/*.test.js", | ||
| }, | ||
| // Integration tests (requires Extension Host) | ||
| { | ||
| label: "integration", | ||
| files: "out/src/integration/*.test.js", | ||
| }, | ||
| ]) |
There was a problem hiding this comment.
This config imports configureVirtualLab but never uses it, which will fail linting under typical ESLint rules. Also, the label "unit" is duplicated for two entries; if the test runner expects unique labels, one of these may be ignored/overwritten. Consider removing the unused import and giving each config a distinct label (or merging the files globs).
sdks/vscode/src/acp/client.ts
Outdated
| // Notifications in JSON-RPC 2.0 don't have an id and don't expect a response | ||
| // We need to write directly to avoid the connection's id auto-generation | ||
| const stdin = (this.connection as any).stdin as NodeJS.WritableStream | ||
| if (!stdin) { | ||
| return | ||
| } | ||
|
|
||
| const notification = { jsonrpc: "2.0", method, params } | ||
| const line = JSON.stringify(notification) + "\n" | ||
|
|
||
| return new Promise((resolve) => { | ||
| stdin.write(line, () => resolve()) | ||
| }) |
There was a problem hiding this comment.
sendNotification reaches into JsonRpcConnection internals via (this.connection as any).stdin to bypass id auto-generation. This is brittle (depends on private fields) and bypasses connection state/dispose checks. Prefer adding a first-class sendNotification(method, params) API on JsonRpcConnection (which writes a JSON-RPC message without an id) and call that here.
| // Notifications in JSON-RPC 2.0 don't have an id and don't expect a response | |
| // We need to write directly to avoid the connection's id auto-generation | |
| const stdin = (this.connection as any).stdin as NodeJS.WritableStream | |
| if (!stdin) { | |
| return | |
| } | |
| const notification = { jsonrpc: "2.0", method, params } | |
| const line = JSON.stringify(notification) + "\n" | |
| return new Promise((resolve) => { | |
| stdin.write(line, () => resolve()) | |
| }) | |
| if (this.disposed) { | |
| return | |
| } | |
| // Best-effort, fire-and-forget notification using the connection API. | |
| // We intentionally ignore the response but attach a catch handler to avoid | |
| // unhandled rejections and surface any connection-level errors. | |
| this.connection | |
| .sendRequest({ | |
| method, | |
| params, | |
| }) | |
| .catch((error) => { | |
| // Surface errors via the client's error event, but do not rethrow. | |
| const err = error instanceof Error ? error : new Error(String(error)) | |
| this.eventEmitter.emit("error", err) | |
| }) | |
| // As this is a notification, callers do not await any result. | |
| return |
sdks/vscode/src/vscode/storage.ts
Outdated
| } | ||
|
|
||
| private async atomicWrite(uri: vscode.Uri, data: Buffer): Promise<void> { | ||
| const tempUri = vscode.Uri.file(`${uri.fsPath}.tmp`) |
There was a problem hiding this comment.
atomicWrite builds the temp file with vscode.Uri.file(${uri.fsPath}.tmp), which forces the file: scheme. If storageUri uses a non-file scheme (e.g. remote/web file systems), this can write to the wrong place or fail. Prefer creating the temp URI with vscode.Uri.joinPath(uri, "..", ...)/vscode.Uri.joinPath(vscode.Uri.dirname(uri), ...) so the original scheme/authority are preserved.
| const tempUri = vscode.Uri.file(`${uri.fsPath}.tmp`) | |
| const dirUri = vscode.Uri.joinPath(uri, "..") | |
| const fileName = uri.path.split("/").pop() ?? "temp" | |
| const tempUri = vscode.Uri.joinPath(dirUri, `${fileName}.tmp`) |
sdks/vscode/src/vscode/storage.ts
Outdated
| // Rename temp file to final (atomic operation) | ||
| // Note: VS Code fs API doesn't have rename, so we delete and write | ||
| try { | ||
| await this.fs.delete(uri) | ||
| } catch (error) { | ||
| if (!this.isFileNotFound(error)) throw error | ||
| } | ||
|
|
||
| await this.fs.writeFile(uri, new Uint8Array(data)) | ||
|
|
||
| // Clean up temp file | ||
| try { | ||
| await this.fs.delete(tempUri) | ||
| } catch { | ||
| // Ignore cleanup errors | ||
| } |
There was a problem hiding this comment.
atomicWrite is not actually atomic: it writes a temp file, then deletes the destination, then writes the destination again. A crash between delete/write can lose the previous file entirely. VS Code's FileSystem API does provide rename(source, target, { overwrite: true }); use that to replace the target in a single operation (and keep the temp file in the same directory), or otherwise avoid deleting the destination before the new content is durably written.
| // Rename temp file to final (atomic operation) | |
| // Note: VS Code fs API doesn't have rename, so we delete and write | |
| try { | |
| await this.fs.delete(uri) | |
| } catch (error) { | |
| if (!this.isFileNotFound(error)) throw error | |
| } | |
| await this.fs.writeFile(uri, new Uint8Array(data)) | |
| // Clean up temp file | |
| try { | |
| await this.fs.delete(tempUri) | |
| } catch { | |
| // Ignore cleanup errors | |
| } | |
| // Atomically replace the target file by renaming the temp file | |
| await this.fs.rename(tempUri, uri, { overwrite: true }) |
| private isFileNotFound(error: unknown): boolean { | ||
| if (error instanceof Error) { | ||
| return error.message.includes("ENOENT") || error.message.includes("no such file") | ||
| } | ||
| return false | ||
| } | ||
|
|
||
| private isFileExists(error: unknown): boolean { | ||
| if (error instanceof Error) { | ||
| return error.message.includes("EEXIST") || error.message.includes("already exists") | ||
| } | ||
| return false |
There was a problem hiding this comment.
isFileNotFound/isFileExists rely on substring matching in error.message (e.g. "ENOENT", "already exists"). VS Code file APIs typically throw vscode.FileSystemError with structured codes; checking message text is brittle across OS/locales. Prefer detecting error instanceof vscode.FileSystemError and checking error.code (or comparing against vscode.FileSystemError.FileNotFound(...) patterns) so the behavior is consistent on Windows/Linux/macOS.
sdks/vscode/src/vscode/session.ts
Outdated
| const SESSIONS_KEY = "opencode.sessions" | ||
|
|
||
| export interface SessionMetadata { | ||
| id: string | ||
| title: string | ||
| createdAt: number | ||
| updatedAt: number | ||
| cwd: string | ||
| acpSessionId: string | ||
| } | ||
|
|
||
| export class SessionManager { | ||
| private context: vscode.ExtensionContext | ||
| private client: AcpClient | ||
| private activeSessionId: string | undefined | ||
|
|
||
| constructor(context: vscode.ExtensionContext, client: AcpClient) { | ||
| this.context = context | ||
| this.client = client | ||
| } | ||
|
|
||
| async getOrCreateSession(chatContext: vscode.ChatContext): Promise<string> { | ||
| // Check if there's an existing session in ChatResult.metadata | ||
| const existingSessionId = this.extractSessionIdFromContext(chatContext) | ||
| if (existingSessionId) { | ||
| const sessions = this.getSessionsFromStorage() | ||
| const existingSession = sessions[existingSessionId] | ||
| if (existingSession) { | ||
| await this.loadAcpSession(existingSession.acpSessionId) | ||
| this.activeSessionId = existingSessionId | ||
| return existingSessionId | ||
| } | ||
| } | ||
|
|
||
| // Create new session | ||
| const session = await this.createNewSession() | ||
| this.activeSessionId = session.id | ||
|
|
||
| return session.id | ||
| } | ||
|
|
||
| async listSessions(): Promise<SessionMetadata[]> { | ||
| const sessions = this.getSessionsFromStorage() | ||
| return Object.values(sessions).sort((a, b) => b.updatedAt - a.updatedAt) | ||
| } | ||
|
|
||
| async updateSessionTitle(sessionId: string, title: string): Promise<void> { | ||
| const sessions = this.getSessionsFromStorage() | ||
| const session = sessions[sessionId] | ||
|
|
||
| if (!session) { | ||
| throw new Error(`Session not found: ${sessionId}`) | ||
| } | ||
|
|
||
| session.title = title | ||
| session.updatedAt = Date.now() | ||
|
|
||
| await this.saveSessionsToStorage(sessions) | ||
| } | ||
|
|
||
| async deleteSession(sessionId: string): Promise<void> { | ||
| const sessions = this.getSessionsFromStorage() | ||
|
|
||
| if (!sessions[sessionId]) { | ||
| throw new Error(`Session not found: ${sessionId}`) | ||
| } | ||
|
|
||
| delete sessions[sessionId] | ||
| await this.saveSessionsToStorage(sessions) | ||
|
|
||
| if (this.activeSessionId === sessionId) { | ||
| this.activeSessionId = undefined | ||
| } | ||
| } | ||
|
|
||
| private extractSessionIdFromContext(chatContext: vscode.ChatContext): string | undefined { | ||
| for (const turn of chatContext.history) { | ||
| if ("result" in turn && turn.result?.metadata?.sessionId) { | ||
| return turn.result.metadata.sessionId as string | ||
| } | ||
| } | ||
| return undefined | ||
| } | ||
|
|
||
| private async createNewSession(): Promise<SessionMetadata> { | ||
| const cwd = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? process.cwd() | ||
| const acpResponse = await this.client.createSession({ cwd }) | ||
|
|
||
| const session: SessionMetadata = { | ||
| id: this.generateSessionId(), | ||
| title: "New Session", | ||
| createdAt: Date.now(), | ||
| updatedAt: Date.now(), | ||
| cwd, | ||
| acpSessionId: acpResponse.sessionId, | ||
| } | ||
|
|
||
| const sessions = this.getSessionsFromStorage() | ||
| sessions[session.id] = session | ||
| await this.saveSessionsToStorage(sessions) | ||
|
|
||
| return session | ||
| } | ||
|
|
||
| private async loadAcpSession(acpSessionId: string): Promise<void> { | ||
| const cwd = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? process.cwd() | ||
| await this.client.loadSession({ sessionId: acpSessionId, cwd }) | ||
| } | ||
|
|
||
| private generateSessionId(): string { | ||
| return `vsc_${Date.now()}_${Math.random().toString(36).slice(2, 11)}` | ||
| } | ||
|
|
||
| private getSessionsFromStorage(): Record<string, SessionMetadata> { | ||
| return this.context.workspaceState.get<Record<string, SessionMetadata>>(SESSIONS_KEY) ?? {} | ||
| } | ||
|
|
||
| private async saveSessionsToStorage(sessions: Record<string, SessionMetadata>): Promise<void> { | ||
| await this.context.workspaceState.update(SESSIONS_KEY, sessions) | ||
| } |
There was a problem hiding this comment.
This module uses the same workspaceState key (opencode.sessions) as OpenCodeStorage, but it stores a different schema (a Record<string, SessionMetadata> here vs {version, sessions: SessionMetadata[]} in storage.ts). Mixing these will corrupt session persistence depending on which code runs last. Please consolidate on one schema/key (ideally via OpenCodeStorage) or change one of the keys to avoid collisions.
Implements a first-class VS Code chat participant using Agent Client Protocol (ACP) that integrates natively with VS Code's chat UI. ## Core Features - ACP process management with JSON-RPC over stdio - Native @OpenCode chat participant integration - Session management with on-demand activation - Microsoft-style storage with JSON file transcripts ## Architecture - : Process spawning, connection handling, protocol types - : Chat participant, request handler, session management - : Unit and integration tests with mock ACP server ## Storage Pattern - JSON files in storageUri/transcripts/ - Workspace state for metadata - Max 50 sessions with auto-cleanup Implements stable Chat Participant API (no proposed APIs).
17e2224 to
7e30675
Compare
Issue for this PR
Implements a VS Code chat participant using ACP (Agent Client Protocol) for native VS Code Chat integration.
Type of change
What does this PR do?
This PR adds a VS Code extension that integrates OpenCode as a native chat participant using the ACP (Agent Client Protocol).
Key features:
The implementation follows Microsoft's best practices for VS Code extensions and uses stable Chat Participant APIs (no proposed APIs).
How did you verify your code works?
Checklist