Skip to content

feat(vscode): Add ACP Chat Participant for VS Code#15501

Open
rudironsoni wants to merge 3 commits intoanomalyco:devfrom
rudironsoni:feature/vscode-acp-chat-participant
Open

feat(vscode): Add ACP Chat Participant for VS Code#15501
rudironsoni wants to merge 3 commits intoanomalyco:devfrom
rudironsoni:feature/vscode-acp-chat-participant

Conversation

@rudironsoni
Copy link

@rudironsoni rudironsoni commented Feb 28, 2026

Issue for this PR

Implements a VS Code chat participant using ACP (Agent Client Protocol) for native VS Code Chat integration.

Type of change

  • New feature

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:

  • ACP Protocol: Uses JSON-RPC over stdio for communication (not HTTP)
  • Chat Participant: Registers opencode in VS Code's chat UI
  • On-Demand Activation: OpenCode process starts only when user sends first message
  • Microsoft-Style Storage: JSON files in storageUri/transcripts/, max 50 sessions
  • Streaming Responses: Real-time streaming of AI responses
  • Session Persistence: Sessions survive VS Code restarts
  • 50+ Tests: Full test coverage for all major components

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?

  • Unit tests: 50+ tests for ACP process, connection, client, handler, session, storage, activation
  • Integration tests: Mock ACP server for testing
  • TypeScript: All types compile without errors

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

@github-actions github-actions bot added needs:compliance This means the issue will auto-close after 2 hours. and removed needs:compliance This means the issue will auto-close after 2 hours. labels Feb 28, 2026
@github-actions
Copy link
Contributor

Thanks for updating your PR! It now meets our contributing guidelines. 👍

Copilot AI review requested due to automatic review settings March 1, 2026 00:04
@rudironsoni rudironsoni force-pushed the feature/vscode-acp-chat-participant branch from eca00bd to af0930d Compare March 1, 2026 00:04
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines 41 to 42
this.participant.iconPath = vscode.Uri.file(this.context.asAbsolutePath("images/icon.png"))
this.participant.followupProvider = this.createFollowupProvider()
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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

Copilot uses AI. Check for mistakes.
Comment on lines 184 to 192
private setupStreaming(sessionId: string, stream: vscode.ChatResponseStream): void {
this.client.onSessionUpdate((updateSessionId, update) => {
if (updateSessionId !== sessionId) {
return
}

this.handleSessionUpdate(update, stream)
})
}
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines 202 to 212
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()
}
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()).

Suggested change
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.")

Copilot uses AI. Check for mistakes.
}
})

context.subscriptions.push(openTerminalDisposable, addFilepathDisposable)
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
context.subscriptions.push(openTerminalDisposable, addFilepathDisposable)
context.subscriptions.push(openNewTerminalDisposable, openTerminalDisposable, addFilepathDisposable)

Copilot uses AI. Check for mistakes.
Comment on lines 1 to 24
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",
},
])
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines 199 to 211
// 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())
})
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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

Copilot uses AI. Check for mistakes.
}

private async atomicWrite(uri: vscode.Uri, data: Buffer): Promise<void> {
const tempUri = vscode.Uri.file(`${uri.fsPath}.tmp`)
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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`)

Copilot uses AI. Check for mistakes.
Comment on lines 242 to 257
// 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
}
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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 })

Copilot uses AI. Check for mistakes.
Comment on lines 269 to 280
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
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines 4 to 123
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)
}
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
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).
@rudironsoni rudironsoni force-pushed the feature/vscode-acp-chat-participant branch from 17e2224 to 7e30675 Compare March 1, 2026 02:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants