More progress

This commit is contained in:
cte 2026-01-10 23:24:54 -08:00
parent a3f02bdefd
commit 4ccca5b88f
36 changed files with 10674 additions and 0 deletions

View file

@ -171,6 +171,103 @@ Tokens are valid for 90 days. The CLI will prompt you to re-authenticate when yo
| `roo auth logout` | Clear stored authentication token |
| `roo auth status` | Show current authentication status |
## ACP (Agent Client Protocol) Integration
The CLI supports the [Agent Client Protocol (ACP)](https://agentclientprotocol.com), allowing ACP-compatible editors like [Zed](https://zed.dev) to use Roo Code as their AI coding assistant.
### Running ACP Server Mode
Start the CLI in ACP server mode:
```bash
roo acp [options]
```
**ACP Options:**
| Option | Description | Default |
| --------------------------- | -------------------------------------------- | ----------------------------- |
| `-e, --extension <path>` | Path to the extension bundle directory | Auto-detected |
| `-p, --provider <provider>` | API provider (anthropic, openai, openrouter) | `openrouter` |
| `-m, --model <model>` | Model to use | `anthropic/claude-sonnet-4.5` |
| `-M, --mode <mode>` | Initial mode (code, architect, ask, debug) | `code` |
| `-k, --api-key <key>` | API key for the LLM provider | From env var |
### Configuring Zed
Add the following to your Zed settings (`settings.json`):
```json
{
"agent_servers": {
"Roo Code": {
"command": "roo",
"args": ["acp"]
}
}
}
```
If you need to specify options:
```json
{
"agent_servers": {
"Roo Code": {
"command": "roo",
"args": ["acp", "-e", "/path/to/extension", "-m", "anthropic/claude-sonnet-4.5"]
}
}
}
```
### ACP Authentication
When using ACP mode, authentication can be handled through:
1. **Roo Code Cloud** - Sign in via the ACP auth flow (opens browser)
2. **API Key** - Set `OPENROUTER_API_KEY` environment variable
The ACP client will prompt you to authenticate if needed.
### ACP Features
- **Session Management**: Each ACP session creates an isolated Roo Code instance
- **Tool Calls**: File operations, commands, and other tools are surfaced through ACP permission requests
- **Mode Switching**: Switch between code, architect, ask, and debug modes
- **Streaming**: Real-time streaming of agent output and thoughts
- **Image Support**: Send images as part of prompts
### ACP Architecture
```
┌─────────────────┐
│ ACP Client │
│ (Zed, etc.) │
└────────┬────────┘
│ JSON-RPC over stdio
┌─────────────────┐
│ RooCodeAgent │
│ (acp.Agent) │
└────────┬────────┘
┌────────┴────────┐
│ AcpSession │
│ (per session) │
└────────┬────────┘
┌────────┴────────┐
│ ExtensionHost │
│ + vscode-shim │
└────────┬────────┘
┌────────┴────────┐
│ Extension │
│ Bundle │
└─────────────────┘
```
## Environment Variables
The CLI will look for API keys in environment variables if not provided via `--api-key`:

View file

@ -21,6 +21,7 @@
"clean": "rimraf dist .turbo"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.12.0",
"@inkjs/ui": "^2.0.0",
"@roo-code/core": "workspace:^",
"@roo-code/types": "workspace:^",

View file

@ -0,0 +1,272 @@
/**
* Tests for RooCodeAgent
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
import type * as acp from "@agentclientprotocol/sdk"
import { RooCodeAgent, type RooCodeAgentOptions } from "../agent.js"
// Mock the auth module
vi.mock("@/commands/auth/index.js", () => ({
login: vi.fn().mockResolvedValue({ success: true }),
logout: vi.fn().mockResolvedValue({ success: true }),
status: vi.fn().mockResolvedValue({ authenticated: false }),
}))
// Mock AcpSession
vi.mock("../session.js", () => ({
AcpSession: {
create: vi.fn().mockResolvedValue({
prompt: vi.fn().mockResolvedValue({ stopReason: "end_turn" }),
cancel: vi.fn(),
setMode: vi.fn(),
dispose: vi.fn().mockResolvedValue(undefined),
getSessionId: vi.fn().mockReturnValue("test-session-id"),
}),
},
}))
describe("RooCodeAgent", () => {
let agent: RooCodeAgent
let mockConnection: acp.AgentSideConnection
const defaultOptions: RooCodeAgentOptions = {
extensionPath: "/test/extension",
provider: "openrouter",
apiKey: "test-key",
model: "test-model",
mode: "code",
}
beforeEach(() => {
// Create a mock connection
mockConnection = {
sessionUpdate: vi.fn().mockResolvedValue(undefined),
requestPermission: vi.fn().mockResolvedValue({
outcome: { outcome: "selected", optionId: "allow" },
}),
readTextFile: vi.fn().mockResolvedValue({ content: "test content" }),
writeTextFile: vi.fn().mockResolvedValue({}),
createTerminal: vi.fn(),
extMethod: vi.fn(),
extNotification: vi.fn(),
signal: new AbortController().signal,
closed: Promise.resolve(),
} as unknown as acp.AgentSideConnection
agent = new RooCodeAgent(defaultOptions, mockConnection)
})
afterEach(() => {
vi.clearAllMocks()
})
describe("initialize", () => {
it("should return protocol version and capabilities", async () => {
const result = await agent.initialize({
protocolVersion: 1,
})
expect(result.protocolVersion).toBeDefined()
expect(result.agentCapabilities).toBeDefined()
expect(result.agentCapabilities?.loadSession).toBe(false)
expect(result.agentCapabilities?.promptCapabilities?.image).toBe(true)
})
it("should return auth methods", async () => {
const result = await agent.initialize({
protocolVersion: 1,
})
expect(result.authMethods).toBeDefined()
expect(result.authMethods).toHaveLength(2)
const methods = result.authMethods!
expect(methods[0]!.id).toBe("roo-cloud")
expect(methods[1]!.id).toBe("api-key")
})
it("should store client capabilities", async () => {
const clientCapabilities: acp.ClientCapabilities = {
fs: {
readTextFile: true,
writeTextFile: true,
},
}
await agent.initialize({
protocolVersion: 1,
clientCapabilities,
})
// Capabilities should be stored for use in newSession
// This is tested indirectly through the session creation
})
})
describe("authenticate", () => {
it("should handle API key authentication", async () => {
// Agent has API key from options
const result = await agent.authenticate({
methodId: "api-key",
})
expect(result).toEqual({})
})
it("should throw for invalid auth method", async () => {
await expect(
agent.authenticate({
methodId: "invalid-method",
}),
).rejects.toThrow()
})
})
describe("newSession", () => {
it("should create a new session", async () => {
// First authenticate
await agent.authenticate({ methodId: "api-key" })
const result = await agent.newSession({
cwd: "/test/workspace",
mcpServers: [],
})
expect(result.sessionId).toBeDefined()
expect(typeof result.sessionId).toBe("string")
})
it("should throw auth error when not authenticated and no API key", async () => {
// Create agent without API key
const agentWithoutKey = new RooCodeAgent({ ...defaultOptions, apiKey: undefined }, mockConnection)
// Mock environment to not have API key
const originalEnv = process.env.OPENROUTER_API_KEY
delete process.env.OPENROUTER_API_KEY
try {
await expect(
agentWithoutKey.newSession({
cwd: "/test/workspace",
mcpServers: [],
}),
).rejects.toThrow()
} finally {
if (originalEnv) {
process.env.OPENROUTER_API_KEY = originalEnv
}
}
})
})
describe("prompt", () => {
it("should forward prompt to session", async () => {
// Setup
await agent.authenticate({ methodId: "api-key" })
const { sessionId } = await agent.newSession({
cwd: "/test/workspace",
mcpServers: [],
})
// Execute
const result = await agent.prompt({
sessionId,
prompt: [{ type: "text", text: "Hello, world!" }],
})
// Verify
expect(result.stopReason).toBe("end_turn")
})
it("should throw for invalid session ID", async () => {
await expect(
agent.prompt({
sessionId: "invalid-session",
prompt: [{ type: "text", text: "Hello" }],
}),
).rejects.toThrow("Session not found")
})
})
describe("cancel", () => {
it("should cancel session prompt", async () => {
// Setup
await agent.authenticate({ methodId: "api-key" })
const { sessionId } = await agent.newSession({
cwd: "/test/workspace",
mcpServers: [],
})
// Execute - should not throw
await agent.cancel({ sessionId })
})
it("should handle cancel for non-existent session gracefully", async () => {
// Should not throw for invalid session
await agent.cancel({ sessionId: "non-existent" })
})
})
describe("setSessionMode", () => {
it("should set session mode", async () => {
// Setup
await agent.authenticate({ methodId: "api-key" })
const { sessionId } = await agent.newSession({
cwd: "/test/workspace",
mcpServers: [],
})
// Execute
const result = await agent.setSessionMode({
sessionId,
modeId: "architect",
})
// Verify
expect(result).toEqual({})
})
it("should throw for invalid mode", async () => {
// Setup
await agent.authenticate({ methodId: "api-key" })
const { sessionId } = await agent.newSession({
cwd: "/test/workspace",
mcpServers: [],
})
// Execute
await expect(
agent.setSessionMode({
sessionId,
modeId: "invalid-mode",
}),
).rejects.toThrow("Unknown mode")
})
it("should throw for invalid session", async () => {
await expect(
agent.setSessionMode({
sessionId: "invalid-session",
modeId: "code",
}),
).rejects.toThrow("Session not found")
})
})
describe("dispose", () => {
it("should dispose all sessions", async () => {
// Setup
await agent.authenticate({ methodId: "api-key" })
await agent.newSession({ cwd: "/test/workspace1", mcpServers: [] })
await agent.newSession({ cwd: "/test/workspace2", mcpServers: [] })
// Execute
await agent.dispose()
// Verify - creating new session should work (sessions map is cleared)
// The next newSession would create a fresh session
})
})
})

View file

@ -0,0 +1,138 @@
import { describe, it, expect, beforeEach } from "vitest"
import { DeltaTracker } from "../delta-tracker.js"
describe("DeltaTracker", () => {
let tracker: DeltaTracker
beforeEach(() => {
tracker = new DeltaTracker()
})
describe("getDelta", () => {
it("returns full text on first call for a new id", () => {
const delta = tracker.getDelta("msg1", "Hello World")
expect(delta).toBe("Hello World")
})
it("returns only new content on subsequent calls", () => {
tracker.getDelta("msg1", "Hello")
const delta = tracker.getDelta("msg1", "Hello World")
expect(delta).toBe(" World")
})
it("returns empty string when text unchanged", () => {
tracker.getDelta("msg1", "Hello")
const delta = tracker.getDelta("msg1", "Hello")
expect(delta).toBe("")
})
it("tracks multiple ids independently", () => {
tracker.getDelta("msg1", "Hello")
tracker.getDelta("msg2", "Goodbye")
const delta1 = tracker.getDelta("msg1", "Hello World")
const delta2 = tracker.getDelta("msg2", "Goodbye World")
expect(delta1).toBe(" World")
expect(delta2).toBe(" World")
})
it("works with numeric ids (timestamps)", () => {
const ts1 = 1234567890
const ts2 = 1234567891
tracker.getDelta(ts1, "First message")
tracker.getDelta(ts2, "Second message")
const delta1 = tracker.getDelta(ts1, "First message updated")
const delta2 = tracker.getDelta(ts2, "Second message updated")
expect(delta1).toBe(" updated")
expect(delta2).toBe(" updated")
})
it("handles incremental streaming correctly", () => {
// Simulate streaming tokens
expect(tracker.getDelta("msg", "H")).toBe("H")
expect(tracker.getDelta("msg", "He")).toBe("e")
expect(tracker.getDelta("msg", "Hel")).toBe("l")
expect(tracker.getDelta("msg", "Hell")).toBe("l")
expect(tracker.getDelta("msg", "Hello")).toBe("o")
})
})
describe("peekDelta", () => {
it("returns delta without updating tracking", () => {
tracker.getDelta("msg1", "Hello")
// Peek should show the delta
expect(tracker.peekDelta("msg1", "Hello World")).toBe(" World")
// But tracking should be unchanged, so getDelta still returns full delta
expect(tracker.getDelta("msg1", "Hello World")).toBe(" World")
// Now peek should show empty
expect(tracker.peekDelta("msg1", "Hello World")).toBe("")
})
})
describe("reset", () => {
it("clears all tracking", () => {
tracker.getDelta("msg1", "Hello")
tracker.getDelta("msg2", "World")
tracker.reset()
// After reset, should get full text again
expect(tracker.getDelta("msg1", "Hello")).toBe("Hello")
expect(tracker.getDelta("msg2", "World")).toBe("World")
})
})
describe("resetId", () => {
it("clears tracking for specific id only", () => {
tracker.getDelta("msg1", "Hello")
tracker.getDelta("msg2", "World")
tracker.resetId("msg1")
// msg1 should be reset
expect(tracker.getDelta("msg1", "Hello")).toBe("Hello")
// msg2 should still be tracked
expect(tracker.getDelta("msg2", "World")).toBe("")
})
})
describe("getPosition", () => {
it("returns 0 for untracked ids", () => {
expect(tracker.getPosition("unknown")).toBe(0)
})
it("returns current position for tracked ids", () => {
tracker.getDelta("msg1", "Hello")
expect(tracker.getPosition("msg1")).toBe(5)
tracker.getDelta("msg1", "Hello World")
expect(tracker.getPosition("msg1")).toBe(11)
})
})
describe("edge cases", () => {
it("handles empty strings", () => {
expect(tracker.getDelta("msg1", "")).toBe("")
expect(tracker.getDelta("msg1", "Hello")).toBe("Hello")
})
it("handles unicode correctly", () => {
tracker.getDelta("msg1", "Hello 👋")
const delta = tracker.getDelta("msg1", "Hello 👋 World 🌍")
expect(delta).toBe(" World 🌍")
})
it("handles multiline text", () => {
tracker.getDelta("msg1", "Line 1\n")
const delta = tracker.getDelta("msg1", "Line 1\nLine 2\n")
expect(delta).toBe("Line 2\n")
})
})
})

View file

@ -0,0 +1,265 @@
/**
* Tests for AcpSession
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
import type * as acp from "@agentclientprotocol/sdk"
// Mock the ExtensionHost before importing AcpSession
vi.mock("@/agent/extension-host.js", () => {
const mockClient = {
on: vi.fn().mockReturnThis(),
off: vi.fn().mockReturnThis(),
respond: vi.fn(),
approve: vi.fn(),
reject: vi.fn(),
}
return {
ExtensionHost: vi.fn().mockImplementation(() => ({
client: mockClient,
activate: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn().mockResolvedValue(undefined),
sendToExtension: vi.fn(),
})),
}
})
// Import after mocking
import { AcpSession, type AcpSessionOptions } from "../session.js"
import { ExtensionHost } from "@/agent/extension-host.js"
describe("AcpSession", () => {
let mockConnection: acp.AgentSideConnection
const defaultOptions: AcpSessionOptions = {
extensionPath: "/test/extension",
provider: "openrouter",
apiKey: "test-api-key",
model: "test-model",
mode: "code",
}
beforeEach(() => {
// Create a mock connection
mockConnection = {
sessionUpdate: vi.fn().mockResolvedValue(undefined),
requestPermission: vi.fn().mockResolvedValue({
outcome: { outcome: "selected", optionId: "allow" },
}),
readTextFile: vi.fn().mockResolvedValue({ content: "test content" }),
writeTextFile: vi.fn().mockResolvedValue({}),
createTerminal: vi.fn(),
extMethod: vi.fn(),
extNotification: vi.fn(),
signal: new AbortController().signal,
closed: Promise.resolve(),
} as unknown as acp.AgentSideConnection
vi.clearAllMocks()
})
afterEach(() => {
vi.clearAllMocks()
})
describe("create", () => {
it("should create a session with a unique ID", async () => {
const session = await AcpSession.create(
"test-session-1",
"/test/workspace",
mockConnection,
undefined,
defaultOptions,
)
expect(session).toBeDefined()
expect(session.getSessionId()).toBe("test-session-1")
})
it("should create ExtensionHost with correct config", async () => {
await AcpSession.create("test-session-2", "/test/workspace", mockConnection, undefined, defaultOptions)
expect(ExtensionHost).toHaveBeenCalledWith(
expect.objectContaining({
extensionPath: "/test/extension",
workspacePath: "/test/workspace",
provider: "openrouter",
apiKey: "test-api-key",
model: "test-model",
mode: "code",
}),
)
})
it("should accept client capabilities", async () => {
const clientCapabilities: acp.ClientCapabilities = {
fs: {
readTextFile: true,
writeTextFile: true,
},
}
const session = await AcpSession.create(
"test-session-3",
"/test/workspace",
mockConnection,
clientCapabilities,
defaultOptions,
)
expect(session).toBeDefined()
})
it("should activate the extension host", async () => {
await AcpSession.create("test-session-4", "/test/workspace", mockConnection, undefined, defaultOptions)
const mockHostInstance = vi.mocked(ExtensionHost).mock.results[0]!.value
expect(mockHostInstance.activate).toHaveBeenCalled()
})
})
describe("prompt", () => {
it("should send a task to the extension host", async () => {
const session = await AcpSession.create(
"test-session",
"/test/workspace",
mockConnection,
undefined,
defaultOptions,
)
const mockHostInstance = vi.mocked(ExtensionHost).mock.results[0]!.value
// Start the prompt (don't await - it waits for taskCompleted event)
const promptPromise = session.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "Hello, world!" }],
})
// Verify the task was sent
expect(mockHostInstance.sendToExtension).toHaveBeenCalledWith(
expect.objectContaining({
type: "newTask",
text: "Hello, world!",
}),
)
// Cancel to resolve the promise
session.cancel()
const result = await promptPromise
expect(result.stopReason).toBe("cancelled")
})
it("should handle image prompts", async () => {
const session = await AcpSession.create(
"test-session",
"/test/workspace",
mockConnection,
undefined,
defaultOptions,
)
const mockHostInstance = vi.mocked(ExtensionHost).mock.results[0]!.value
const promptPromise = session.prompt({
sessionId: "test-session",
prompt: [
{ type: "text", text: "Describe this image" },
{ type: "image", mimeType: "image/png", data: "base64data" },
],
})
// Images are extracted as raw base64 data, text includes [image content] placeholder
expect(mockHostInstance.sendToExtension).toHaveBeenCalledWith(
expect.objectContaining({
type: "newTask",
images: expect.arrayContaining(["base64data"]),
}),
)
session.cancel()
await promptPromise
})
})
describe("cancel", () => {
it("should send cancel message to extension host", async () => {
const session = await AcpSession.create(
"test-session",
"/test/workspace",
mockConnection,
undefined,
defaultOptions,
)
const mockHostInstance = vi.mocked(ExtensionHost).mock.results[0]!.value
// Start a prompt first
const promptPromise = session.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "Hello" }],
})
// Cancel
session.cancel()
expect(mockHostInstance.sendToExtension).toHaveBeenCalledWith({ type: "cancelTask" })
await promptPromise
})
})
describe("setMode", () => {
it("should update the session mode", async () => {
const session = await AcpSession.create(
"test-session",
"/test/workspace",
mockConnection,
undefined,
defaultOptions,
)
const mockHostInstance = vi.mocked(ExtensionHost).mock.results[0]!.value
session.setMode("architect")
expect(mockHostInstance.sendToExtension).toHaveBeenCalledWith({
type: "updateSettings",
updatedSettings: { mode: "architect" },
})
})
})
describe("dispose", () => {
it("should dispose the extension host", async () => {
const session = await AcpSession.create(
"test-session",
"/test/workspace",
mockConnection,
undefined,
defaultOptions,
)
const mockHostInstance = vi.mocked(ExtensionHost).mock.results[0]!.value
await session.dispose()
expect(mockHostInstance.dispose).toHaveBeenCalled()
})
})
describe("getSessionId", () => {
it("should return the session ID", async () => {
const session = await AcpSession.create(
"my-unique-session-id",
"/test/workspace",
mockConnection,
undefined,
defaultOptions,
)
expect(session.getSessionId()).toBe("my-unique-session-id")
})
})
})

View file

@ -0,0 +1,287 @@
import type * as acp from "@agentclientprotocol/sdk"
import { describe, it, expect, beforeEach, vi } from "vitest"
import { TerminalManager } from "../terminal-manager.js"
// Mock the ACP SDK
vi.mock("@agentclientprotocol/sdk", () => ({
TerminalHandle: class {
id: string
constructor(id: string) {
this.id = id
}
async currentOutput() {
return { output: "test output", truncated: false }
}
async waitForExit() {
return { exitCode: 0, signal: null }
}
async kill() {
return {}
}
async release() {
return {}
}
},
}))
// Type definitions for mock objects
interface MockTerminalHandle {
id: string
currentOutput: ReturnType<typeof vi.fn>
waitForExit: ReturnType<typeof vi.fn>
kill: ReturnType<typeof vi.fn>
release: ReturnType<typeof vi.fn>
}
interface MockConnection {
createTerminal: ReturnType<typeof vi.fn>
mockHandle: MockTerminalHandle
}
// Create a mock connection
function createMockConnection(): MockConnection {
const mockHandle: MockTerminalHandle = {
id: "term_mock123",
currentOutput: vi.fn().mockResolvedValue({ output: "test output", truncated: false }),
waitForExit: vi.fn().mockResolvedValue({ exitCode: 0, signal: null }),
kill: vi.fn().mockResolvedValue({}),
release: vi.fn().mockResolvedValue({}),
}
return {
createTerminal: vi.fn().mockResolvedValue(mockHandle),
mockHandle,
}
}
describe("TerminalManager", () => {
describe("parseCommand", () => {
let manager: TerminalManager
beforeEach(() => {
const mockConnection = createMockConnection()
manager = new TerminalManager("session123", mockConnection as unknown as acp.AgentSideConnection)
})
it("parses a simple command without arguments", () => {
const result = manager.parseCommand("ls")
expect(result.executable).toBe("ls")
expect(result.args).toEqual([])
expect(result.fullCommand).toBe("ls")
expect(result.cwd).toBeUndefined()
})
it("parses a command with arguments", () => {
const result = manager.parseCommand("ls -la /tmp")
expect(result.executable).toBe("ls")
expect(result.args).toEqual(["-la", "/tmp"])
expect(result.fullCommand).toBe("ls -la /tmp")
})
it("parses cd + command pattern", () => {
const result = manager.parseCommand("cd /home/user && npm install")
expect(result.cwd).toBe("/home/user")
expect(result.executable).toBe("npm")
expect(result.args).toEqual(["install"])
})
it("handles cd with complex path", () => {
const result = manager.parseCommand("cd /path/to/project && git status")
expect(result.cwd).toBe("/path/to/project")
expect(result.executable).toBe("git")
expect(result.args).toEqual(["status"])
})
it("wraps commands with shell operators in a shell", () => {
const result = manager.parseCommand("echo hello | grep h")
expect(result.executable).toBe("/bin/sh")
expect(result.args).toEqual(["-c", "echo hello | grep h"])
})
it("wraps commands with && in a shell", () => {
const result = manager.parseCommand("npm install && npm test")
expect(result.executable).toBe("/bin/sh")
expect(result.args).toEqual(["-c", "npm install && npm test"])
})
it("wraps commands with semicolons in a shell", () => {
const result = manager.parseCommand("echo a; echo b")
expect(result.executable).toBe("/bin/sh")
expect(result.args).toEqual(["-c", "echo a; echo b"])
})
it("wraps commands with redirects in a shell", () => {
const result = manager.parseCommand("echo hello > output.txt")
expect(result.executable).toBe("/bin/sh")
expect(result.args).toEqual(["-c", "echo hello > output.txt"])
})
it("handles whitespace-only input", () => {
const result = manager.parseCommand(" ")
expect(result.executable).toBe("")
expect(result.args).toEqual([])
})
it("trims leading and trailing whitespace", () => {
const result = manager.parseCommand(" ls -la ")
expect(result.executable).toBe("ls")
expect(result.args).toEqual(["-la"])
})
it("handles npm commands", () => {
const result = manager.parseCommand("npm run test")
expect(result.executable).toBe("npm")
expect(result.args).toEqual(["run", "test"])
})
it("handles npx commands", () => {
const result = manager.parseCommand("npx vitest run src/test.ts")
expect(result.executable).toBe("npx")
expect(result.args).toEqual(["vitest", "run", "src/test.ts"])
})
})
describe("terminal lifecycle", () => {
it("creates a terminal and tracks it", async () => {
const mockConnection = createMockConnection()
const manager = new TerminalManager("session123", mockConnection as unknown as acp.AgentSideConnection)
const result = await manager.createTerminal("ls -la", "/home/user")
expect(mockConnection.createTerminal).toHaveBeenCalledWith({
sessionId: "session123",
command: "ls",
args: ["-la"],
cwd: "/home/user",
})
expect(result.terminalId).toBe("term_mock123")
expect(manager.hasTerminal("term_mock123")).toBe(true)
expect(manager.activeCount).toBe(1)
})
it("releases a terminal and removes from tracking", async () => {
const mockConnection = createMockConnection()
const manager = new TerminalManager("session123", mockConnection as unknown as acp.AgentSideConnection)
await manager.createTerminal("ls", "/tmp")
expect(manager.hasTerminal("term_mock123")).toBe(true)
const released = await manager.releaseTerminal("term_mock123")
expect(released).toBe(true)
expect(manager.hasTerminal("term_mock123")).toBe(false)
expect(manager.activeCount).toBe(0)
})
it("releases all terminals", async () => {
const mockConnection = createMockConnection()
let terminalCount = 0
// Mock multiple terminal creations
mockConnection.createTerminal = vi.fn().mockImplementation(() => {
terminalCount++
return Promise.resolve({
id: `term_${terminalCount}`,
currentOutput: vi.fn().mockResolvedValue({ output: "", truncated: false }),
waitForExit: vi.fn().mockResolvedValue({ exitCode: 0, signal: null }),
kill: vi.fn().mockResolvedValue({}),
release: vi.fn().mockResolvedValue({}),
})
})
const manager = new TerminalManager("session123", mockConnection as unknown as acp.AgentSideConnection)
await manager.createTerminal("ls", "/tmp")
await manager.createTerminal("pwd", "/home")
expect(manager.activeCount).toBe(2)
await manager.releaseAll()
expect(manager.activeCount).toBe(0)
})
it("returns null for unknown terminal operations", async () => {
const mockConnection = createMockConnection()
const manager = new TerminalManager("session123", mockConnection as unknown as acp.AgentSideConnection)
const output = await manager.getOutput("unknown_terminal")
expect(output).toBeNull()
const exitResult = await manager.waitForExit("unknown_terminal")
expect(exitResult).toBeNull()
const killResult = await manager.killTerminal("unknown_terminal")
expect(killResult).toBe(false)
const releaseResult = await manager.releaseTerminal("unknown_terminal")
expect(releaseResult).toBe(false)
})
it("gets terminal info", async () => {
const mockConnection = createMockConnection()
const manager = new TerminalManager("session123", mockConnection as unknown as acp.AgentSideConnection)
await manager.createTerminal("ls -la", "/home/user", "tool-123")
const info = manager.getTerminalInfo("term_mock123")
expect(info).toBeDefined()
expect(info?.command).toBe("ls -la")
expect(info?.cwd).toBe("/home/user")
expect(info?.toolCallId).toBe("tool-123")
})
it("gets active terminal IDs", async () => {
const mockConnection = createMockConnection()
let terminalCount = 0
mockConnection.createTerminal = vi.fn().mockImplementation(() => {
terminalCount++
return Promise.resolve({
id: `term_${terminalCount}`,
currentOutput: vi.fn().mockResolvedValue({ output: "", truncated: false }),
waitForExit: vi.fn().mockResolvedValue({ exitCode: 0, signal: null }),
kill: vi.fn().mockResolvedValue({}),
release: vi.fn().mockResolvedValue({}),
})
})
const manager = new TerminalManager("session123", mockConnection as unknown as acp.AgentSideConnection)
await manager.createTerminal("ls", "/tmp")
await manager.createTerminal("pwd", "/home")
const ids = manager.getActiveTerminalIds()
expect(ids).toHaveLength(2)
expect(ids).toContain("term_1")
expect(ids).toContain("term_2")
})
it("waits for terminal exit and returns result", async () => {
const mockConnection = createMockConnection()
const manager = new TerminalManager("session123", mockConnection as unknown as acp.AgentSideConnection)
await manager.createTerminal("ls", "/tmp")
const result = await manager.waitForExit("term_mock123")
expect(result).toEqual({
exitCode: 0,
signal: null,
output: "test output",
})
})
it("kills a terminal", async () => {
const mockConnection = createMockConnection()
const manager = new TerminalManager("session123", mockConnection as unknown as acp.AgentSideConnection)
await manager.createTerminal("sleep 60", "/tmp")
const killed = await manager.killTerminal("term_mock123")
expect(killed).toBe(true)
expect(mockConnection.mockHandle.kill).toHaveBeenCalled()
})
})
})

View file

@ -0,0 +1,475 @@
/**
* Tests for ACP Message Translator
*/
import { describe, it, expect } from "vitest"
import type { ClineMessage } from "@roo-code/types"
import {
translateToAcpUpdate,
parseToolFromMessage,
mapToolKind,
isPermissionAsk,
isCompletionAsk,
extractPromptText,
extractPromptImages,
createPermissionOptions,
buildToolCallFromMessage,
} from "../translator.js"
describe("translateToAcpUpdate", () => {
it("should translate text say messages", () => {
const message: ClineMessage = {
ts: 12345,
type: "say",
say: "text",
text: "Hello, world!",
}
const result = translateToAcpUpdate(message)
expect(result).toEqual({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "Hello, world!" },
})
})
it("should translate reasoning say messages", () => {
const message: ClineMessage = {
ts: 12345,
type: "say",
say: "reasoning",
text: "I'm thinking about this...",
}
const result = translateToAcpUpdate(message)
expect(result).toEqual({
sessionUpdate: "agent_thought_chunk",
content: { type: "text", text: "I'm thinking about this..." },
})
})
it("should translate error say messages", () => {
const message: ClineMessage = {
ts: 12345,
type: "say",
say: "error",
text: "Something went wrong",
}
const result = translateToAcpUpdate(message)
expect(result).toEqual({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "Error: Something went wrong" },
})
})
it("should return null for completion_result", () => {
const message: ClineMessage = {
ts: 12345,
type: "say",
say: "completion_result",
text: "Task completed",
}
const result = translateToAcpUpdate(message)
expect(result).toBeNull()
})
it("should return null for ask messages", () => {
const message: ClineMessage = {
ts: 12345,
type: "ask",
ask: "tool",
text: "Approve this tool?",
}
const result = translateToAcpUpdate(message)
expect(result).toBeNull()
})
})
describe("parseToolFromMessage", () => {
it("should parse JSON tool messages", () => {
const message: ClineMessage = {
ts: 12345,
type: "say",
say: "shell_integration_warning",
text: JSON.stringify({
tool: "read_file",
path: "/test/file.txt",
}),
}
const result = parseToolFromMessage(message)
expect(result).not.toBeNull()
expect(result?.name).toBe("read_file")
// Title is now human-readable based on tool name and filename
expect(result?.title).toBe("Read file.txt")
expect(result?.locations).toHaveLength(1)
expect(result!.locations[0]!.path).toBe("/test/file.txt")
})
it("should extract tool name from text content", () => {
const message: ClineMessage = {
ts: 12345,
type: "say",
say: "shell_integration_warning",
text: "Using write_file to create the file",
}
const result = parseToolFromMessage(message)
expect(result).not.toBeNull()
expect(result?.name).toBe("write_file")
})
it("should return null for empty text", () => {
const message: ClineMessage = {
ts: 12345,
type: "say",
say: "shell_integration_warning",
text: "",
}
const result = parseToolFromMessage(message)
expect(result).toBeNull()
})
})
describe("mapToolKind", () => {
it("should map read operations", () => {
expect(mapToolKind("read_file")).toBe("read")
expect(mapToolKind("list_files")).toBe("read")
expect(mapToolKind("inspect_code")).toBe("read")
expect(mapToolKind("get_info")).toBe("read")
})
it("should map edit operations", () => {
expect(mapToolKind("write_to_file")).toBe("edit")
expect(mapToolKind("apply_diff")).toBe("edit")
expect(mapToolKind("modify_file")).toBe("edit")
expect(mapToolKind("create_file")).toBe("edit")
})
it("should map delete operations", () => {
expect(mapToolKind("delete_file")).toBe("delete")
expect(mapToolKind("remove_directory")).toBe("delete")
})
it("should map move operations", () => {
expect(mapToolKind("move_file")).toBe("move")
expect(mapToolKind("rename_file")).toBe("move")
expect(mapToolKind("move_directory")).toBe("move")
})
it("should map search operations", () => {
expect(mapToolKind("search_files")).toBe("search")
expect(mapToolKind("find_references")).toBe("search")
expect(mapToolKind("grep_code")).toBe("search")
})
it("should map execute operations", () => {
expect(mapToolKind("execute_command")).toBe("execute")
expect(mapToolKind("run_script")).toBe("execute")
})
it("should map think operations", () => {
expect(mapToolKind("think")).toBe("think")
expect(mapToolKind("reasoning_step")).toBe("think")
expect(mapToolKind("plan_execution")).toBe("think")
expect(mapToolKind("analyze_code")).toBe("think")
})
it("should map fetch operations", () => {
expect(mapToolKind("browser_action")).toBe("fetch")
expect(mapToolKind("fetch_url")).toBe("fetch")
expect(mapToolKind("web_request")).toBe("fetch")
expect(mapToolKind("http_get")).toBe("fetch")
})
it("should map switch_mode operations", () => {
expect(mapToolKind("switch_mode")).toBe("switch_mode")
expect(mapToolKind("switchMode")).toBe("switch_mode")
expect(mapToolKind("set_mode")).toBe("switch_mode")
})
it("should return other for unknown operations", () => {
expect(mapToolKind("unknown_tool")).toBe("other")
expect(mapToolKind("custom_operation")).toBe("other")
})
})
describe("isPermissionAsk", () => {
it("should return true for permission-required asks", () => {
expect(isPermissionAsk("tool")).toBe(true)
expect(isPermissionAsk("command")).toBe(true)
expect(isPermissionAsk("browser_action_launch")).toBe(true)
expect(isPermissionAsk("use_mcp_server")).toBe(true)
})
it("should return false for other asks", () => {
expect(isPermissionAsk("followup")).toBe(false)
expect(isPermissionAsk("completion_result")).toBe(false)
expect(isPermissionAsk("api_req_failed")).toBe(false)
})
})
describe("isCompletionAsk", () => {
it("should return true for completion asks", () => {
expect(isCompletionAsk("completion_result")).toBe(true)
expect(isCompletionAsk("api_req_failed")).toBe(true)
expect(isCompletionAsk("mistake_limit_reached")).toBe(true)
})
it("should return false for other asks", () => {
expect(isCompletionAsk("tool")).toBe(false)
expect(isCompletionAsk("followup")).toBe(false)
expect(isCompletionAsk("command")).toBe(false)
})
})
describe("extractPromptText", () => {
it("should extract text from text blocks", () => {
const prompt = [
{ type: "text" as const, text: "Hello" },
{ type: "text" as const, text: "World" },
]
const result = extractPromptText(prompt)
expect(result).toBe("Hello\nWorld")
})
it("should handle resource_link blocks", () => {
const prompt = [
{ type: "text" as const, text: "Check this file:" },
{
type: "resource_link" as const,
uri: "file:///test/file.txt",
name: "file.txt",
mimeType: "text/plain",
},
]
const result = extractPromptText(prompt)
expect(result).toContain("@file:///test/file.txt")
})
it("should handle image blocks", () => {
const prompt = [
{ type: "text" as const, text: "Look at this:" },
{
type: "image" as const,
data: "base64data",
mimeType: "image/png",
},
]
const result = extractPromptText(prompt)
expect(result).toContain("[image content]")
})
})
describe("extractPromptImages", () => {
it("should extract image data", () => {
const prompt = [
{ type: "text" as const, text: "Check this:" },
{
type: "image" as const,
data: "base64data1",
mimeType: "image/png",
},
{
type: "image" as const,
data: "base64data2",
mimeType: "image/jpeg",
},
]
const result = extractPromptImages(prompt)
expect(result).toHaveLength(2)
expect(result[0]).toBe("base64data1")
expect(result[1]).toBe("base64data2")
})
it("should return empty array when no images", () => {
const prompt = [{ type: "text" as const, text: "No images here" }]
const result = extractPromptImages(prompt)
expect(result).toHaveLength(0)
})
})
describe("createPermissionOptions", () => {
it("should include always allow for tool asks", () => {
const options = createPermissionOptions("tool")
expect(options).toHaveLength(3)
expect(options[0]!.optionId).toBe("allow_always")
expect(options[0]!.kind).toBe("allow_always")
})
it("should include always allow for command asks", () => {
const options = createPermissionOptions("command")
expect(options).toHaveLength(3)
expect(options[0]!.optionId).toBe("allow_always")
})
it("should have basic options for other asks", () => {
const options = createPermissionOptions("browser_action_launch")
expect(options).toHaveLength(2)
expect(options[0]!.optionId).toBe("allow")
expect(options[1]!.optionId).toBe("reject")
})
})
describe("buildToolCallFromMessage", () => {
it("should build a valid tool call", () => {
const message: ClineMessage = {
ts: 12345,
type: "say",
say: "shell_integration_warning",
text: JSON.stringify({
tool: "read_file",
path: "/test/file.txt",
}),
}
const result = buildToolCallFromMessage(message)
expect(result.toolCallId).toBe("tool-12345")
// Title is now human-readable based on tool name and filename
expect(result.title).toBe("Read file.txt")
expect(result.kind).toBe("read")
expect(result.status).toBe("pending")
expect(result.locations).toHaveLength(1)
})
it("should handle messages without text", () => {
const message: ClineMessage = {
ts: 12345,
type: "say",
say: "shell_integration_warning",
}
const result = buildToolCallFromMessage(message)
expect(result.toolCallId).toBe("tool-12345")
expect(result.kind).toBe("other")
})
it("should not include search path as location for search tools", () => {
const message: ClineMessage = {
ts: 12345,
type: "say",
say: "shell_integration_warning",
text: JSON.stringify({
tool: "searchFiles",
path: "src",
regex: ".*",
filePattern: "*utils*",
}),
}
const result = buildToolCallFromMessage(message, "/workspace/project")
// Search path "src" should NOT become a location
expect(result.kind).toBe("search")
expect(result.locations).toHaveLength(0)
})
it("should extract file paths from search results content", () => {
const message: ClineMessage = {
ts: 12345,
type: "say",
say: "shell_integration_warning",
text: JSON.stringify({
tool: "search_files",
path: "cli",
regex: ".*",
content:
"Found 2 results.\n\n# src/utils/helpers.ts\n 1 | export function helper() {}\n\n# src/components/Button.tsx\n 5 | const Button = () => {}",
}),
}
const result = buildToolCallFromMessage(message, "/workspace")
expect(result.kind).toBe("search")
// Should extract file paths from the search results
expect(result.locations!).toHaveLength(2)
expect(result.locations![0]!.path).toBe("/workspace/src/utils/helpers.ts")
expect(result.locations![1]!.path).toBe("/workspace/src/components/Button.tsx")
})
it("should include directory path for list_files tools", () => {
const message: ClineMessage = {
ts: 12345,
type: "say",
say: "shell_integration_warning",
text: JSON.stringify({
tool: "list_files",
path: "src/components",
}),
}
const result = buildToolCallFromMessage(message, "/workspace")
expect(result.kind).toBe("read")
// Directory path should be included for list_files
expect(result.locations!).toHaveLength(1)
expect(result.locations![0]!.path).toBe("/workspace/src/components")
})
it("should handle codebase_search tool", () => {
const message: ClineMessage = {
ts: 12345,
type: "say",
say: "shell_integration_warning",
text: JSON.stringify({
tool: "codebase_search",
query: "find all utils",
path: ".",
content: "# lib/utils.js\n 10 | function util() {}",
}),
}
const result = buildToolCallFromMessage(message, "/project")
expect(result.kind).toBe("search")
expect(result.locations!).toHaveLength(1)
expect(result.locations![0]!.path).toBe("/project/lib/utils.js")
})
it("should deduplicate file paths in search results", () => {
const message: ClineMessage = {
ts: 12345,
type: "say",
say: "shell_integration_warning",
text: JSON.stringify({
tool: "searchFiles",
path: "src",
content: "# src/file.ts\n 1 | match1\n\n# src/file.ts\n 5 | match2\n\n# src/other.ts\n 3 | match3",
}),
}
const result = buildToolCallFromMessage(message, "/workspace")
// Should deduplicate: src/file.ts appears twice but should only be included once
expect(result.locations!).toHaveLength(2)
expect(result.locations![0]!.path).toBe("/workspace/src/file.ts")
expect(result.locations![1]!.path).toBe("/workspace/src/other.ts")
})
})

View file

@ -0,0 +1,381 @@
/**
* Tests for UpdateBuffer
*
* Verifies that the buffer correctly batches text chunk updates
* while passing through other updates immediately.
*/
import type * as acp from "@agentclientprotocol/sdk"
import { UpdateBuffer } from "../update-buffer.js"
type SessionUpdate = acp.SessionNotification["update"]
describe("UpdateBuffer", () => {
let sentUpdates: Array<{ sessionUpdate: string; content?: unknown }>
let sendUpdate: (update: SessionUpdate) => Promise<void>
beforeEach(() => {
sentUpdates = []
sendUpdate = vi.fn(async (update) => {
sentUpdates.push(update)
})
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
describe("text chunk buffering", () => {
it("should buffer agent_message_chunk updates", async () => {
const buffer = new UpdateBuffer(sendUpdate, {
minBufferSize: 100,
flushDelayMs: 50,
})
await buffer.queueUpdate({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "Hello" },
})
// Should not be sent immediately
expect(sentUpdates).toHaveLength(0)
expect(buffer.getBufferSizes().message).toBe(5)
})
it("should buffer agent_thought_chunk updates", async () => {
const buffer = new UpdateBuffer(sendUpdate, {
minBufferSize: 100,
flushDelayMs: 50,
})
await buffer.queueUpdate({
sessionUpdate: "agent_thought_chunk",
content: { type: "text", text: "Thinking..." },
})
// Should not be sent immediately
expect(sentUpdates).toHaveLength(0)
expect(buffer.getBufferSizes().thought).toBe(11)
})
it("should batch multiple text chunks together", async () => {
const buffer = new UpdateBuffer(sendUpdate, {
minBufferSize: 100,
flushDelayMs: 50,
})
await buffer.queueUpdate({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "Hello " },
})
await buffer.queueUpdate({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "World" },
})
expect(sentUpdates).toHaveLength(0)
expect(buffer.getBufferSizes().message).toBe(11)
// Flush and check combined content
await buffer.flush()
expect(sentUpdates).toHaveLength(1)
expect(sentUpdates[0]).toEqual({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "Hello World" },
})
})
})
describe("size threshold flushing", () => {
it("should flush when buffer reaches minBufferSize", async () => {
const buffer = new UpdateBuffer(sendUpdate, {
minBufferSize: 10,
flushDelayMs: 1000, // Long delay to ensure size triggers flush
})
await buffer.queueUpdate({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "Hello World!" }, // 12 chars, exceeds 10
})
// Should have flushed due to size
expect(sentUpdates).toHaveLength(1)
expect(sentUpdates[0]).toEqual({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "Hello World!" },
})
})
it("should consider combined buffer sizes", async () => {
const buffer = new UpdateBuffer(sendUpdate, {
minBufferSize: 15,
flushDelayMs: 1000,
})
await buffer.queueUpdate({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "Hello" }, // 5 chars
})
await buffer.queueUpdate({
sessionUpdate: "agent_thought_chunk",
content: { type: "text", text: "Thinking!" }, // 9 chars, total 14
})
// Not flushed yet (14 < 15)
expect(sentUpdates).toHaveLength(0)
await buffer.queueUpdate({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "X" }, // 1 more, total 15
})
// Should have flushed (15 >= 15)
expect(sentUpdates).toHaveLength(2) // message and thought
})
})
describe("time threshold flushing", () => {
it("should flush after flushDelayMs", async () => {
const buffer = new UpdateBuffer(sendUpdate, {
minBufferSize: 1000,
flushDelayMs: 50,
})
await buffer.queueUpdate({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "Hello" },
})
expect(sentUpdates).toHaveLength(0)
// Advance time past the flush delay
await vi.advanceTimersByTimeAsync(60)
expect(sentUpdates).toHaveLength(1)
expect(sentUpdates[0]).toEqual({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "Hello" },
})
})
it("should reset timer on new content", async () => {
const buffer = new UpdateBuffer(sendUpdate, {
minBufferSize: 1000,
flushDelayMs: 50,
})
await buffer.queueUpdate({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "A" },
})
// Advance 30ms (not enough to flush)
await vi.advanceTimersByTimeAsync(30)
expect(sentUpdates).toHaveLength(0)
// Add more content (should NOT reset timer in current impl)
await buffer.queueUpdate({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "B" },
})
// Advance another 30ms (total 60ms from first queue)
await vi.advanceTimersByTimeAsync(30)
// Should have flushed
expect(sentUpdates).toHaveLength(1)
expect(sentUpdates[0]!.content).toEqual({ type: "text", text: "AB" })
})
})
describe("non-bufferable updates", () => {
it("should send tool_call updates immediately", async () => {
const buffer = new UpdateBuffer(sendUpdate, {
minBufferSize: 1000,
flushDelayMs: 1000,
})
await buffer.queueUpdate({
sessionUpdate: "tool_call",
toolCallId: "test-123",
title: "Test Tool",
kind: "read",
status: "in_progress",
})
// Should be sent immediately
expect(sentUpdates).toHaveLength(1)
expect(sentUpdates[0]!.sessionUpdate).toBe("tool_call")
})
it("should send tool_call_update updates immediately", async () => {
const buffer = new UpdateBuffer(sendUpdate, {
minBufferSize: 1000,
flushDelayMs: 1000,
})
await buffer.queueUpdate({
sessionUpdate: "tool_call_update",
toolCallId: "test-123",
status: "completed",
})
expect(sentUpdates).toHaveLength(1)
expect(sentUpdates[0]!.sessionUpdate).toBe("tool_call_update")
})
it("should flush buffered content before sending non-bufferable update", async () => {
const buffer = new UpdateBuffer(sendUpdate, {
minBufferSize: 1000,
flushDelayMs: 1000,
})
// Buffer some text first
await buffer.queueUpdate({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "Before tool" },
})
// Send tool call - should flush text first
await buffer.queueUpdate({
sessionUpdate: "tool_call",
toolCallId: "test-123",
title: "Test Tool",
kind: "read",
status: "in_progress",
})
// Text should come first, then tool call
expect(sentUpdates).toHaveLength(2)
expect(sentUpdates[0]!.sessionUpdate).toBe("agent_message_chunk")
expect(sentUpdates[1]!.sessionUpdate).toBe("tool_call")
})
})
describe("flush method", () => {
it("should flush all buffered content", async () => {
const buffer = new UpdateBuffer(sendUpdate, {
minBufferSize: 1000,
flushDelayMs: 1000,
})
await buffer.queueUpdate({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "Message" },
})
await buffer.queueUpdate({
sessionUpdate: "agent_thought_chunk",
content: { type: "text", text: "Thought" },
})
expect(sentUpdates).toHaveLength(0)
await buffer.flush()
expect(sentUpdates).toHaveLength(2)
expect(sentUpdates[0]!.sessionUpdate).toBe("agent_message_chunk")
expect(sentUpdates[1]!.sessionUpdate).toBe("agent_thought_chunk")
})
it("should be idempotent when buffer is empty", async () => {
const buffer = new UpdateBuffer(sendUpdate, {
minBufferSize: 1000,
flushDelayMs: 1000,
})
await buffer.flush()
await buffer.flush()
await buffer.flush()
expect(sentUpdates).toHaveLength(0)
})
})
describe("reset method", () => {
it("should clear all buffered content", async () => {
const buffer = new UpdateBuffer(sendUpdate, {
minBufferSize: 1000,
flushDelayMs: 1000,
})
await buffer.queueUpdate({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "Hello" },
})
expect(buffer.getBufferSizes().message).toBe(5)
buffer.reset()
expect(buffer.getBufferSizes().message).toBe(0)
expect(buffer.getBufferSizes().thought).toBe(0)
// Flushing should send nothing
await buffer.flush()
expect(sentUpdates).toHaveLength(0)
})
it("should cancel pending flush timer", async () => {
const buffer = new UpdateBuffer(sendUpdate, {
minBufferSize: 1000,
flushDelayMs: 50,
})
await buffer.queueUpdate({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "Hello" },
})
buffer.reset()
// Advance past flush delay
await vi.advanceTimersByTimeAsync(100)
// Nothing should have been sent
expect(sentUpdates).toHaveLength(0)
})
})
describe("default options", () => {
it("should use defaults (200 chars, 500ms)", async () => {
const buffer = new UpdateBuffer(sendUpdate)
// Default minBufferSize is 200
const longText = "A".repeat(199)
await buffer.queueUpdate({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: longText },
})
// Not flushed yet (199 < 200)
expect(sentUpdates).toHaveLength(0)
await buffer.queueUpdate({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "B" }, // 200 total
})
// Should have flushed (200 >= 200)
expect(sentUpdates).toHaveLength(1)
})
it("should flush after 500ms by default", async () => {
const buffer = new UpdateBuffer(sendUpdate)
await buffer.queueUpdate({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "Hello" },
})
// Not flushed at 400ms
await vi.advanceTimersByTimeAsync(400)
expect(sentUpdates).toHaveLength(0)
// Flushed at 500ms
await vi.advanceTimersByTimeAsync(150)
expect(sentUpdates).toHaveLength(1)
})
})
})

318
apps/cli/src/acp/agent.ts Normal file
View file

@ -0,0 +1,318 @@
/**
* RooCodeAgent
*
* Implements the ACP Agent interface to expose Roo Code as an ACP-compatible agent.
* This allows ACP clients like Zed to use Roo Code as their AI coding assistant.
*/
import * as acp from "@agentclientprotocol/sdk"
import { randomUUID } from "node:crypto"
import { login, status } from "@/commands/auth/index.js"
import { AcpSession, type AcpSessionOptions } from "./session.js"
import { acpLog } from "./logger.js"
// =============================================================================
// Types
// =============================================================================
export interface RooCodeAgentOptions {
/** Path to the extension bundle */
extensionPath: string
/** API provider (defaults to openrouter) */
provider?: string
/** API key (optional, may come from environment) */
apiKey?: string
/** Model to use (defaults to a sensible default) */
model?: string
/** Initial mode (defaults to code) */
mode?: string
}
// =============================================================================
// Auth Method IDs
// =============================================================================
const AUTH_METHODS = {
ROO_CLOUD: "roo-cloud",
API_KEY: "api-key",
} as const
// =============================================================================
// Available Modes
// =============================================================================
const AVAILABLE_MODES: acp.SessionMode[] = [
{
id: "code",
name: "Code",
description: "Write, modify, and refactor code",
},
{
id: "architect",
name: "Architect",
description: "Plan and design system architecture",
},
{
id: "ask",
name: "Ask",
description: "Ask questions and get explanations",
},
{
id: "debug",
name: "Debug",
description: "Debug issues and troubleshoot problems",
},
]
// =============================================================================
// RooCodeAgent Class
// =============================================================================
/**
* RooCodeAgent implements the ACP Agent interface.
*
* It manages multiple sessions, each with its own ExtensionHost instance,
* and handles protocol-level operations like initialization and authentication.
*/
export class RooCodeAgent implements acp.Agent {
private sessions: Map<string, AcpSession> = new Map()
private clientCapabilities: acp.ClientCapabilities | undefined
private isAuthenticated = false
constructor(
private readonly options: RooCodeAgentOptions,
private readonly connection: acp.AgentSideConnection,
) {}
// ===========================================================================
// Initialization
// ===========================================================================
/**
* Initialize the agent and exchange capabilities with the client.
*/
async initialize(params: acp.InitializeRequest): Promise<acp.InitializeResponse> {
acpLog.request("initialize", { protocolVersion: params.protocolVersion })
this.clientCapabilities = params.clientCapabilities
acpLog.debug("Agent", "Client capabilities", this.clientCapabilities)
// Check if already authenticated via environment or existing credentials
const authStatus = await status({ verbose: false })
this.isAuthenticated = authStatus.authenticated
acpLog.debug("Agent", `Auth status: ${this.isAuthenticated ? "authenticated" : "not authenticated"}`)
const response: acp.InitializeResponse = {
protocolVersion: acp.PROTOCOL_VERSION,
authMethods: [
{
id: AUTH_METHODS.ROO_CLOUD,
name: "Sign in with Roo Code Cloud",
description: "Sign in with your Roo Code Cloud account for access to all features",
},
{
id: AUTH_METHODS.API_KEY,
name: "Use API Key",
description: "Use an API key directly (set OPENROUTER_API_KEY or similar environment variable)",
},
],
agentCapabilities: {
loadSession: false,
promptCapabilities: {
image: true,
embeddedContext: true,
},
},
}
acpLog.response("initialize", response)
return response
}
// ===========================================================================
// Authentication
// ===========================================================================
/**
* Authenticate with the specified method.
*/
async authenticate(params: acp.AuthenticateRequest): Promise<acp.AuthenticateResponse | void> {
acpLog.request("authenticate", { methodId: params.methodId })
switch (params.methodId) {
case AUTH_METHODS.ROO_CLOUD: {
acpLog.info("Agent", "Starting Roo Code Cloud login flow")
// Trigger Roo Code Cloud login flow
const result = await login({ verbose: false })
if (!result.success) {
acpLog.error("Agent", "Roo Code Cloud login failed")
throw acp.RequestError.authRequired(undefined, "Failed to authenticate with Roo Code Cloud")
}
this.isAuthenticated = true
acpLog.info("Agent", "Roo Code Cloud login successful")
break
}
case AUTH_METHODS.API_KEY: {
// API key authentication - verify key exists
const apiKey = this.options.apiKey || process.env.OPENROUTER_API_KEY
if (!apiKey) {
acpLog.error("Agent", "No API key found")
throw acp.RequestError.authRequired(
undefined,
"No API key found. Set OPENROUTER_API_KEY environment variable.",
)
}
this.isAuthenticated = true
acpLog.info("Agent", "API key authentication successful")
break
}
default:
acpLog.error("Agent", `Unknown auth method: ${params.methodId}`)
throw acp.RequestError.invalidParams(undefined, `Unknown auth method: ${params.methodId}`)
}
acpLog.response("authenticate", {})
return {}
}
// ===========================================================================
// Session Management
// ===========================================================================
/**
* Create a new session.
*/
async newSession(params: acp.NewSessionRequest): Promise<acp.NewSessionResponse> {
acpLog.request("newSession", { cwd: params.cwd })
// Require authentication
if (!this.isAuthenticated) {
// Check if API key is available
const apiKey = this.options.apiKey || process.env.OPENROUTER_API_KEY
if (!apiKey) {
acpLog.error("Agent", "newSession failed: not authenticated and no API key")
throw acp.RequestError.authRequired()
}
this.isAuthenticated = true
}
const sessionId = randomUUID()
acpLog.info("Agent", `Creating new session: ${sessionId}`)
const sessionOptions: AcpSessionOptions = {
extensionPath: this.options.extensionPath,
provider: this.options.provider || "openrouter",
apiKey: this.options.apiKey || process.env.OPENROUTER_API_KEY,
model: this.options.model || "anthropic/claude-sonnet-4-20250514",
mode: this.options.mode || "code",
}
acpLog.debug("Agent", "Session options", {
extensionPath: sessionOptions.extensionPath,
provider: sessionOptions.provider,
model: sessionOptions.model,
mode: sessionOptions.mode,
})
const session = await AcpSession.create(
sessionId,
params.cwd,
this.connection,
this.clientCapabilities,
sessionOptions,
)
this.sessions.set(sessionId, session)
acpLog.info("Agent", `Session created successfully: ${sessionId}`)
const response = { sessionId }
acpLog.response("newSession", response)
return response
}
// ===========================================================================
// Prompt Handling
// ===========================================================================
/**
* Process a prompt request.
*/
async prompt(params: acp.PromptRequest): Promise<acp.PromptResponse> {
acpLog.request("prompt", {
sessionId: params.sessionId,
promptLength: params.prompt?.length ?? 0,
})
const session = this.sessions.get(params.sessionId)
if (!session) {
acpLog.error("Agent", `prompt failed: session not found: ${params.sessionId}`)
throw acp.RequestError.invalidParams(undefined, `Session not found: ${params.sessionId}`)
}
const response = await session.prompt(params)
acpLog.response("prompt", response)
return response
}
// ===========================================================================
// Session Control
// ===========================================================================
/**
* Cancel an ongoing prompt.
*/
async cancel(params: acp.CancelNotification): Promise<void> {
acpLog.request("cancel", { sessionId: params.sessionId })
const session = this.sessions.get(params.sessionId)
if (session) {
session.cancel()
acpLog.info("Agent", `Cancelled session: ${params.sessionId}`)
} else {
acpLog.warn("Agent", `cancel: session not found: ${params.sessionId}`)
}
}
/**
* Set the session mode.
*/
async setSessionMode(params: acp.SetSessionModeRequest): Promise<acp.SetSessionModeResponse | void> {
acpLog.request("setSessionMode", { sessionId: params.sessionId, modeId: params.modeId })
const session = this.sessions.get(params.sessionId)
if (!session) {
acpLog.error("Agent", `setSessionMode failed: session not found: ${params.sessionId}`)
throw acp.RequestError.invalidParams(undefined, `Session not found: ${params.sessionId}`)
}
const mode = AVAILABLE_MODES.find((m) => m.id === params.modeId)
if (!mode) {
acpLog.error("Agent", `setSessionMode failed: unknown mode: ${params.modeId}`)
throw acp.RequestError.invalidParams(undefined, `Unknown mode: ${params.modeId}`)
}
session.setMode(params.modeId)
acpLog.info("Agent", `Set session ${params.sessionId} mode to: ${params.modeId}`)
acpLog.response("setSessionMode", {})
return {}
}
// ===========================================================================
// Cleanup
// ===========================================================================
/**
* Dispose of all sessions and cleanup.
*/
async dispose(): Promise<void> {
acpLog.info("Agent", `Disposing ${this.sessions.size} sessions`)
const disposals = Array.from(this.sessions.values()).map((session) => session.dispose())
await Promise.all(disposals)
this.sessions.clear()
acpLog.info("Agent", "All sessions disposed")
}
}

View file

@ -0,0 +1,71 @@
/**
* DeltaTracker - Utility for computing text deltas
*
* Tracks what portion of text content has been sent and returns only
* the new (delta) portion on subsequent calls. This ensures streaming
* content is sent incrementally without duplication.
*
* @example
* ```ts
* const tracker = new DeltaTracker()
*
* tracker.getDelta("msg1", "Hello") // returns "Hello"
* tracker.getDelta("msg1", "Hello World") // returns " World"
* tracker.getDelta("msg1", "Hello World!") // returns "!"
*
* tracker.reset() // Clear all tracking for new prompt
* ```
*/
export class DeltaTracker {
private positions: Map<string | number, number> = new Map()
/**
* Get the delta (new portion) of text that hasn't been sent yet.
* Automatically updates internal tracking when there's new content.
*
* @param id - Unique identifier for the content stream (e.g., message timestamp)
* @param fullText - The full accumulated text so far
* @returns The new portion of text (delta), or empty string if nothing new
*/
getDelta(id: string | number, fullText: string): string {
const lastPos = this.positions.get(id) ?? 0
const delta = fullText.slice(lastPos)
if (delta.length > 0) {
this.positions.set(id, fullText.length)
}
return delta
}
/**
* Check if there would be a delta without updating tracking.
* Useful for conditional logic without side effects.
*/
peekDelta(id: string | number, fullText: string): string {
const lastPos = this.positions.get(id) ?? 0
return fullText.slice(lastPos)
}
/**
* Reset all tracking. Call when starting a new prompt/session.
*/
reset(): void {
this.positions.clear()
}
/**
* Reset tracking for a specific ID only.
*/
resetId(id: string | number): void {
this.positions.delete(id)
}
/**
* Get the current tracked position for an ID.
* Returns 0 if not tracked.
*/
getPosition(id: string | number): number {
return this.positions.get(id) ?? 0
}
}

View file

@ -0,0 +1,84 @@
# Agent Plan
> How Agents communicate their execution plans
Plans are execution strategies for complex tasks that require multiple steps.
Agents may share plans with Clients through [`session/update`](./prompt-turn#3-agent-reports-output) notifications, providing real-time visibility into their thinking and progress.
## Creating Plans
When the language model creates an execution plan, the Agent **SHOULD** report it to the Client:
```json theme={null}
{
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": "sess_abc123def456",
"update": {
"sessionUpdate": "plan",
"entries": [
{
"content": "Analyze the existing codebase structure",
"priority": "high",
"status": "pending"
},
{
"content": "Identify components that need refactoring",
"priority": "high",
"status": "pending"
},
{
"content": "Create unit tests for critical functions",
"priority": "medium",
"status": "pending"
}
]
}
}
}
```
<ParamField path="entries" type="PlanEntry[]" required>
An array of [plan entries](#plan-entries) representing the tasks to be
accomplished
</ParamField>
## Plan Entries
Each plan entry represents a specific task or goal within the overall execution strategy:
<ParamField path="content" type="string" required>
A human-readable description of what this task aims to accomplish
</ParamField>
<ParamField path="priority" type="PlanEntryPriority" required>
The relative importance of this task.
- `high`
- `medium`
- `low`
</ParamField>
<ParamField path="status" type="PlanEntryStatus" required>
The current [execution status](#status) of this task
- `pending`
- `in_progress`
- `completed`
</ParamField>
## Updating Plans
As the Agent progresses through the plan, it **SHOULD** report updates by sending more `session/update` notifications with the same structure.
The Agent **MUST** send a complete list of all plan entries in each update and their current status. The Client **MUST** replace the current plan completely.
### Dynamic Planning
Plans can evolve during execution. The Agent **MAY** add, remove, or modify plan entries as it discovers new requirements or completes tasks, allowing it to adapt based on what it learns.
---
> To find navigation and other pages in this documentation, fetch the llms.txt file at: https://agentclientprotocol.com/llms.txt

View file

@ -0,0 +1,207 @@
# Content
> Understanding content blocks in the Agent Client Protocol
Content blocks represent displayable information that flows through the Agent Client Protocol. They provide a structured way to handle various types of user-facing content—whether it's text from language models, images for analysis, or embedded resources for context.
Content blocks appear in:
- User prompts sent via [`session/prompt`](./prompt-turn#1-user-message)
- Language model output streamed through [`session/update`](./prompt-turn#3-agent-reports-output) notifications
- Progress updates and results from [tool calls](./tool-calls)
## Content Types
The Agent Client Protocol uses the same `ContentBlock` structure as the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/specification/2025-06-18/schema#contentblock).
This design choice enables Agents to seamlessly forward content from MCP tool outputs without transformation.
### Text Content
Plain text messages form the foundation of most interactions.
```json theme={null}
{
"type": "text",
"text": "What's the weather like today?"
}
```
All Agents **MUST** support text content blocks when included in prompts.
<ParamField path="text" type="string" required>
The text content to display
</ParamField>
<ParamField path="annotations" type="Annotations">
Optional metadata about how the content should be used or displayed. [Learn
more](https://modelcontextprotocol.io/specification/2025-06-18/server/resources#annotations).
</ParamField>
### Image Content <Icon icon="asterisk" size="14" />
Images can be included for visual context or analysis.
```json theme={null}
{
"type": "image",
"mimeType": "image/png",
"data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB..."
}
```
<Icon icon="asterisk" size="14" /> Requires the `image` [prompt
capability](./initialization#prompt-capabilities) when included in prompts.
<ParamField path="data" type="string" required>
Base64-encoded image data
</ParamField>
<ParamField path="mimeType" type="string" required>
The MIME type of the image (e.g., "image/png", "image/jpeg")
</ParamField>
<ParamField path="uri" type="string">
Optional URI reference for the image source
</ParamField>
<ParamField path="annotations" type="Annotations">
Optional metadata about how the content should be used or displayed. [Learn
more](https://modelcontextprotocol.io/specification/2025-06-18/server/resources#annotations).
</ParamField>
### Audio Content <Icon icon="asterisk" size="14" />
Audio data for transcription or analysis.
```json theme={null}
{
"type": "audio",
"mimeType": "audio/wav",
"data": "UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAAB..."
}
```
<Icon icon="asterisk" size="14" /> Requires the `audio` [prompt
capability](./initialization#prompt-capabilities) when included in prompts.
<ParamField path="data" type="string" required>
Base64-encoded audio data
</ParamField>
<ParamField path="mimeType" type="string" required>
The MIME type of the audio (e.g., "audio/wav", "audio/mp3")
</ParamField>
<ParamField path="annotations" type="Annotations">
Optional metadata about how the content should be used or displayed. [Learn
more](https://modelcontextprotocol.io/specification/2025-06-18/server/resources#annotations).
</ParamField>
### Embedded Resource <Icon icon="asterisk" size="14" />
Complete resource contents embedded directly in the message.
```json theme={null}
{
"type": "resource",
"resource": {
"uri": "file:///home/user/script.py",
"mimeType": "text/x-python",
"text": "def hello():\n print('Hello, world!')"
}
}
```
This is the preferred way to include context in prompts, such as when using @-mentions to reference files or other resources.
By embedding the content directly in the request, Clients can include context from sources that the Agent may not have direct access to.
<Icon icon="asterisk" size="14" /> Requires the `embeddedContext` [prompt
capability](./initialization#prompt-capabilities) when included in prompts.
<ParamField path="resource" type="EmbeddedResourceResource" required>
The embedded resource contents, which can be either:
<Expandable title="Text Resource">
<ParamField path="uri" type="string" required>
The URI identifying the resource
</ParamField>
<ParamField path="text" type="string" required>
The text content of the resource
</ParamField>
<ParamField path="mimeType" type="string">
Optional MIME type of the text content
</ParamField>
</Expandable>
<Expandable title="Blob Resource">
<ParamField path="uri" type="string" required>
The URI identifying the resource
</ParamField>
<ParamField path="blob" type="string" required>
Base64-encoded binary data
</ParamField>
<ParamField path="mimeType" type="string">
Optional MIME type of the blob
</ParamField>
</Expandable>
</ParamField>
<ParamField path="annotations" type="Annotations">
Optional metadata about how the content should be used or displayed. [Learn
more](https://modelcontextprotocol.io/specification/2025-06-18/server/resources#annotations).
</ParamField>
### Resource Link
References to resources that the Agent can access.
```json theme={null}
{
"type": "resource_link",
"uri": "file:///home/user/document.pdf",
"name": "document.pdf",
"mimeType": "application/pdf",
"size": 1024000
}
```
<ParamField path="uri" type="string" required>
The URI of the resource
</ParamField>
<ParamField path="name" type="string" required>
A human-readable name for the resource
</ParamField>
<ParamField path="mimeType" type="string">
The MIME type of the resource
</ParamField>
<ParamField path="title" type="string">
Optional display title for the resource
</ParamField>
<ParamField path="description" type="string">
Optional description of the resource contents
</ParamField>
<ParamField path="size" type="integer">
Optional size of the resource in bytes
</ParamField>
<ParamField path="annotations" type="Annotations">
Optional metadata about how the content should be used or displayed. [Learn
more](https://modelcontextprotocol.io/specification/2025-06-18/server/resources#annotations).
</ParamField>
---
> To find navigation and other pages in this documentation, fetch the llms.txt file at: https://agentclientprotocol.com/llms.txt

View file

@ -0,0 +1,137 @@
# Extensibility
> Adding custom data and capabilities
The Agent Client Protocol provides built-in extension mechanisms that allow implementations to add custom functionality while maintaining compatibility with the core protocol. These mechanisms ensure that Agents and Clients can innovate without breaking interoperability.
## The `_meta` Field
All types in the protocol include a `_meta` field with type `{ [key: string]: unknown }` that implementations can use to attach custom information. This includes requests, responses, notifications, and even nested types like content blocks, tool calls, plan entries, and capability objects.
```json theme={null}
{
"jsonrpc": "2.0",
"id": 1,
"method": "session/prompt",
"params": {
"sessionId": "sess_abc123def456",
"prompt": [
{
"type": "text",
"text": "Hello, world!"
}
],
"_meta": {
"traceparent": "00-80e1afed08e019fc1110464cfa66635c-7a085853722dc6d2-01",
"zed.dev/debugMode": true
}
}
}
```
Clients may propagate fields to the agent for correlation purposes, such as `requestId`. The following root-level keys in `_meta` **SHOULD** be reserved for [W3C trace context](https://www.w3.org/TR/trace-context/) to guarantee interop with existing MCP implementations and OpenTelemetry tooling:
- `traceparent`
- `tracestate`
- `baggage`
Implementations **MUST NOT** add any custom fields at the root of a type that's part of the specification. All possible names are reserved for future protocol versions.
## Extension Methods
The protocol reserves any method name starting with an underscore (`_`) for custom extensions. This allows implementations to add new functionality without the risk of conflicting with future protocol versions.
Extension methods follow standard [JSON-RPC 2.0](https://www.jsonrpc.org/specification) semantics:
- **[Requests](https://www.jsonrpc.org/specification#request_object)** - Include an `id` field and expect a response
- **[Notifications](https://www.jsonrpc.org/specification#notification)** - Omit the `id` field and are one-way
### Custom Requests
In addition to the requests specified by the protocol, implementations **MAY** expose and call custom JSON-RPC requests as long as their name starts with an underscore (`_`).
```json theme={null}
{
"jsonrpc": "2.0",
"id": 1,
"method": "_zed.dev/workspace/buffers",
"params": {
"language": "rust"
}
}
```
Upon receiving a custom request, implementations **MUST** respond accordingly with the provided `id`:
```json theme={null}
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"buffers": [
{ "id": 0, "path": "/home/user/project/src/main.rs" },
{ "id": 1, "path": "/home/user/project/src/editor.rs" }
]
}
}
```
If the receiving end doesn't recognize the custom method name, it should respond with the standard "Method not found" error:
```json theme={null}
{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32601,
"message": "Method not found"
}
}
```
To avoid such cases, extensions **SHOULD** advertise their [custom capabilities](#advertising-custom-capabilities) so that callers can check their availability first and adapt their behavior or interface accordingly.
### Custom Notifications
Custom notifications are regular JSON-RPC notifications that start with an underscore (`_`). Like all notifications, they omit the `id` field:
```json theme={null}
{
"jsonrpc": "2.0",
"method": "_zed.dev/file_opened",
"params": {
"path": "/home/user/project/src/editor.rs"
}
}
```
Unlike with custom requests, implementations **SHOULD** ignore unrecognized notifications.
## Advertising Custom Capabilities
Implementations **SHOULD** use the `_meta` field in capability objects to advertise support for extensions and their methods:
```json theme={null}
{
"jsonrpc": "2.0",
"id": 0,
"result": {
"protocolVersion": 1,
"agentCapabilities": {
"loadSession": true,
"_meta": {
"zed.dev": {
"workspace": true,
"fileNotifications": true
}
}
}
}
}
```
This allows implementations to negotiate custom features during initialization without breaking compatibility with standard Clients and Agents.
---
> To find navigation and other pages in this documentation, fetch the llms.txt file at: https://agentclientprotocol.com/llms.txt

View file

@ -0,0 +1,118 @@
# File System
> Client filesystem access methods
The filesystem methods allow Agents to read and write text files within the Client's environment. These methods enable Agents to access unsaved editor state and allow Clients to track file modifications made during agent execution.
## Checking Support
Before attempting to use filesystem methods, Agents **MUST** verify that the Client supports these capabilities by checking the [Client Capabilities](./initialization#client-capabilities) field in the `initialize` response:
```json highlight={8,9} theme={null}
{
"jsonrpc": "2.0",
"id": 0,
"result": {
"protocolVersion": 1,
"clientCapabilities": {
"fs": {
"readTextFile": true,
"writeTextFile": true
}
}
}
}
```
If `readTextFile` or `writeTextFile` is `false` or not present, the Agent **MUST NOT** attempt to call the corresponding filesystem method.
## Reading Files
The `fs/read_text_file` method allows Agents to read text file contents from the Client's filesystem, including unsaved changes in the editor.
```json theme={null}
{
"jsonrpc": "2.0",
"id": 3,
"method": "fs/read_text_file",
"params": {
"sessionId": "sess_abc123def456",
"path": "/home/user/project/src/main.py",
"line": 10,
"limit": 50
}
}
```
<ParamField path="sessionId" type="SessionId" required>
The [Session ID](./session-setup#session-id) for this request
</ParamField>
<ParamField path="path" type="string" required>
Absolute path to the file to read
</ParamField>
<ParamField path="line" type="number">
Optional line number to start reading from (1-based)
</ParamField>
<ParamField path="limit" type="number">
Optional maximum number of lines to read
</ParamField>
The Client responds with the file contents:
```json theme={null}
{
"jsonrpc": "2.0",
"id": 3,
"result": {
"content": "def hello_world():\n print('Hello, world!')\n"
}
}
```
## Writing Files
The `fs/write_text_file` method allows Agents to write or update text files in the Client's filesystem.
```json theme={null}
{
"jsonrpc": "2.0",
"id": 4,
"method": "fs/write_text_file",
"params": {
"sessionId": "sess_abc123def456",
"path": "/home/user/project/config.json",
"content": "{\n \"debug\": true,\n \"version\": \"1.0.0\"\n}"
}
}
```
<ParamField path="sessionId" type="SessionId" required>
The [Session ID](./session-setup#session-id) for this request
</ParamField>
<ParamField path="path" type="string" required>
Absolute path to the file to write.
The Client **MUST** create the file if it doesn't exist.
</ParamField>
<ParamField path="content" type="string" required>
The text content to write to the file
</ParamField>
The Client responds with an empty result on success:
```json theme={null}
{
"jsonrpc": "2.0",
"id": 4,
"result": null
}
```
---
> To find navigation and other pages in this documentation, fetch the llms.txt file at: https://agentclientprotocol.com/llms.txt

View file

@ -0,0 +1,225 @@
# Initialization
> How all Agent Client Protocol connections begin
The Initialization phase allows [Clients](./overview#client) and [Agents](./overview#agent) to negotiate protocol versions, capabilities, and authentication methods.
<br />
```mermaid theme={null}
sequenceDiagram
participant Client
participant Agent
Note over Client, Agent: Connection established
Client->>Agent: initialize
Note right of Agent: Negotiate protocol<br/>version & capabilities
Agent-->>Client: initialize response
Note over Client,Agent: Ready for session setup
```
<br />
Before a Session can be created, Clients **MUST** initialize the connection by calling the `initialize` method with:
- The latest [protocol version](#protocol-version) supported
- The [capabilities](#client-capabilities) supported
They **SHOULD** also provide a name and version to the Agent.
```json theme={null}
{
"jsonrpc": "2.0",
"id": 0,
"method": "initialize",
"params": {
"protocolVersion": 1,
"clientCapabilities": {
"fs": {
"readTextFile": true,
"writeTextFile": true
},
"terminal": true
},
"clientInfo": {
"name": "my-client",
"title": "My Client",
"version": "1.0.0"
}
}
}
```
The Agent **MUST** respond with the chosen [protocol version](#protocol-version) and the [capabilities](#agent-capabilities) it supports. It **SHOULD** also provide a name and version to the Client as well:
```json theme={null}
{
"jsonrpc": "2.0",
"id": 0,
"result": {
"protocolVersion": 1,
"agentCapabilities": {
"loadSession": true,
"promptCapabilities": {
"image": true,
"audio": true,
"embeddedContext": true
},
"mcp": {
"http": true,
"sse": true
}
},
"agentInfo": {
"name": "my-agent",
"title": "My Agent",
"version": "1.0.0"
},
"authMethods": []
}
}
```
## Protocol version
The protocol versions that appear in the `initialize` requests and responses are a single integer that identifies a **MAJOR** protocol version. This version is only incremented when breaking changes are introduced.
Clients and Agents **MUST** agree on a protocol version and act according to its specification.
See [Capabilities](#capabilities) to learn how non-breaking features are introduced.
### Version Negotiation
The `initialize` request **MUST** include the latest protocol version the Client supports.
If the Agent supports the requested version, it **MUST** respond with the same version. Otherwise, the Agent **MUST** respond with the latest version it supports.
If the Client does not support the version specified by the Agent in the `initialize` response, the Client **SHOULD** close the connection and inform the user about it.
## Capabilities
Capabilities describe features supported by the Client and the Agent.
All capabilities included in the `initialize` request are **OPTIONAL**. Clients and Agents **SHOULD** support all possible combinations of their peer's capabilities.
The introduction of new capabilities is not considered a breaking change. Therefore, Clients and Agents **MUST** treat all capabilities omitted in the `initialize` request as **UNSUPPORTED**.
Capabilities are high-level and are not attached to a specific base protocol concept.
Capabilities may specify the availability of protocol methods, notifications, or a subset of their parameters. They may also signal behaviors of the Agent or Client implementation.
Implementations can also [advertise custom capabilities](./extensibility#advertising-custom-capabilities) using the `_meta` field to indicate support for protocol extensions.
### Client Capabilities
The Client **SHOULD** specify whether it supports the following capabilities:
#### File System
<ParamField path="readTextFile" type="boolean">
The `fs/read_text_file` method is available.
</ParamField>
<ParamField path="writeTextFile" type="boolean">
The `fs/write_text_file` method is available.
</ParamField>
<Card icon="file" horizontal href="./file-system">
Learn more about File System methods
</Card>
#### Terminal
<ParamField path="terminal" type="boolean">
All `terminal/*` methods are available, allowing the Agent to execute and
manage shell commands.
</ParamField>
<Card icon="terminal" horizontal href="./terminals">
Learn more about Terminals
</Card>
### Agent Capabilities
The Agent **SHOULD** specify whether it supports the following capabilities:
<ResponseField name="loadSession" type="boolean" post={["default: false"]}>
The [`session/load`](./session-setup#loading-sessions) method is available.
</ResponseField>
<ResponseField name="promptCapabilities" type="PromptCapabilities Object">
Object indicating the different types of [content](./content) that may be
included in `session/prompt` requests.
</ResponseField>
#### Prompt capabilities
As a baseline, all Agents **MUST** support `ContentBlock::Text` and `ContentBlock::ResourceLink` in `session/prompt` requests.
Optionally, they **MAY** support richer types of [content](./content) by specifying the following capabilities:
<ResponseField name="image" type="boolean" post={["default: false"]}>
The prompt may include `ContentBlock::Image`
</ResponseField>
<ResponseField name="audio" type="boolean" post={["default: false"]}>
The prompt may include `ContentBlock::Audio`
</ResponseField>
<ResponseField name="embeddedContext" type="boolean" post={["default: false"]}>
The prompt may include `ContentBlock::Resource`
</ResponseField>
#### MCP capabilities
<ResponseField name="http" type="boolean" post={["default: false"]}>
The Agent supports connecting to MCP servers over HTTP.
</ResponseField>
<ResponseField name="sse" type="boolean" post={["default: false"]}>
The Agent supports connecting to MCP servers over SSE.
Note: This transport has been deprecated by the MCP spec.
</ResponseField>
#### Session Capabilities
As a baseline, all Agents **MUST** support `session/new`, `session/prompt`, `session/cancel`, and `session/update`.
Optionally, they **MAY** support other session methods and notifications by specifying additional capabilities.
<Note>
`session/load` is still handled by the top-level `load_session` capability.
This will be unified in future versions of the protocol.
</Note>
## Implementation Information
Both Clients and Agents **SHOULD** provide information about their implementation in the `clientInfo` and `agentInfo` fields respectively. Both take the following three fields:
<ParamField path="name" type="string">
Intended for programmatic or logical use, but can be used as a display name
fallback if title isnt present.
</ParamField>
<ParamField path="title" type="string">
Intended for UI and end-user contexts — optimized to be human-readable and
easily understood. If not provided, the name should be used for display.
</ParamField>
<ParamField path="version" type="string">
Version of the implementation. Can be displayed to the user or used for
debugging or metrics purposes.
</ParamField>
<Info>
Note: in future versions of the protocol, this information will be required.
</Info>
---
Once the connection is initialized, you're ready to [create a session](./session-setup) and begin the conversation with the Agent.
---
> To find navigation and other pages in this documentation, fetch the llms.txt file at: https://agentclientprotocol.com/llms.txt

View file

@ -0,0 +1,50 @@
# Agent Client Protocol
## Docs
- [Brand](https://agentclientprotocol.com/brand.md): Assets for the Agent Client Protocol brand.
- [Code of Conduct](https://agentclientprotocol.com/community/code-of-conduct.md)
- [Contributor Communication](https://agentclientprotocol.com/community/communication.md): Communication methods for Agent Client Protocol contributors
- [Contributing](https://agentclientprotocol.com/community/contributing.md): How to participate in the development of ACP
- [Governance](https://agentclientprotocol.com/community/governance.md): How the ACP project is governed
- [Working and Interest Groups](https://agentclientprotocol.com/community/working-interest-groups.md): Learn about the two forms of collaborative groups within the Agent Client Protocol's governance structure - Working Groups and Interest Groups.
- [Community](https://agentclientprotocol.com/libraries/community.md): Community managed libraries for the Agent Client Protocol
- [Kotlin](https://agentclientprotocol.com/libraries/kotlin.md): Kotlin library for the Agent Client Protocol
- [Python](https://agentclientprotocol.com/libraries/python.md): Python library for the Agent Client Protocol
- [Rust](https://agentclientprotocol.com/libraries/rust.md): Rust library for the Agent Client Protocol
- [TypeScript](https://agentclientprotocol.com/libraries/typescript.md): TypeScript library for the Agent Client Protocol
- [Agents](https://agentclientprotocol.com/overview/agents.md): Agents implementing the Agent Client Protocol
- [Architecture](https://agentclientprotocol.com/overview/architecture.md): Overview of the Agent Client Protocol architecture
- [Clients](https://agentclientprotocol.com/overview/clients.md): Clients implementing the Agent Client Protocol
- [Introduction](https://agentclientprotocol.com/overview/introduction.md): Get started with the Agent Client Protocol (ACP)
- [Agent Plan](https://agentclientprotocol.com/protocol/agent-plan.md): How Agents communicate their execution plans
- [Content](https://agentclientprotocol.com/protocol/content.md): Understanding content blocks in the Agent Client Protocol
- [Cancellation](https://agentclientprotocol.com/protocol/draft/cancellation.md): Mechanisms for request cancellation
- [Schema](https://agentclientprotocol.com/protocol/draft/schema.md): Schema definitions for the Agent Client Protocol
- [Extensibility](https://agentclientprotocol.com/protocol/extensibility.md): Adding custom data and capabilities
- [File System](https://agentclientprotocol.com/protocol/file-system.md): Client filesystem access methods
- [Initialization](https://agentclientprotocol.com/protocol/initialization.md): How all Agent Client Protocol connections begin
- [Overview](https://agentclientprotocol.com/protocol/overview.md): How the Agent Client Protocol works
- [Prompt Turn](https://agentclientprotocol.com/protocol/prompt-turn.md): Understanding the core conversation flow
- [Schema](https://agentclientprotocol.com/protocol/schema.md): Schema definitions for the Agent Client Protocol
- [Session Modes](https://agentclientprotocol.com/protocol/session-modes.md): Switch between different agent operating modes
- [Session Setup](https://agentclientprotocol.com/protocol/session-setup.md): Creating and loading sessions
- [Slash Commands](https://agentclientprotocol.com/protocol/slash-commands.md): Advertise available slash commands to clients
- [Terminals](https://agentclientprotocol.com/protocol/terminals.md): Executing and managing terminal commands
- [Tool Calls](https://agentclientprotocol.com/protocol/tool-calls.md): How Agents report tool call execution
- [Transports](https://agentclientprotocol.com/protocol/transports.md): Mechanisms for agents and clients to communicate with each other
- [Requests for Dialog (RFDs)](https://agentclientprotocol.com/rfds/about.md): Our process for introducing changes to the protocol
- [ACP Agent Registry](https://agentclientprotocol.com/rfds/acp-agent-registry.md)
- [Agent Telemetry Export](https://agentclientprotocol.com/rfds/agent-telemetry-export.md)
- [Introduce RFD Process](https://agentclientprotocol.com/rfds/introduce-rfd-process.md)
- [MCP-over-ACP: MCP Transport via ACP Channels](https://agentclientprotocol.com/rfds/mcp-over-acp.md)
- [Meta Field Propagation Conventions](https://agentclientprotocol.com/rfds/meta-propagation.md)
- [Agent Extensions via ACP Proxies](https://agentclientprotocol.com/rfds/proxy-chains.md)
- [Request Cancellation Mechanism](https://agentclientprotocol.com/rfds/request-cancellation.md)
- [Session Config Options](https://agentclientprotocol.com/rfds/session-config-options.md)
- [Forking of existing sessions](https://agentclientprotocol.com/rfds/session-fork.md)
- [Session Info Update](https://agentclientprotocol.com/rfds/session-info-update.md)
- [Session List](https://agentclientprotocol.com/rfds/session-list.md)
- [Resuming of existing sessions](https://agentclientprotocol.com/rfds/session-resume.md)
- [Session Usage and Context Status](https://agentclientprotocol.com/rfds/session-usage.md)
- [Updates](https://agentclientprotocol.com/updates.md): Updates and announcements about the Agent Client Protocol

View file

@ -0,0 +1,165 @@
# Overview
> How the Agent Client Protocol works
The Agent Client Protocol allows [Agents](#agent) and [Clients](#client) to communicate by exposing methods that each side can call and sending notifications to inform each other of events.
## Communication Model
The protocol follows the [JSON-RPC 2.0](https://www.jsonrpc.org/specification) specification with two types of messages:
- **Methods**: Request-response pairs that expect a result or error
- **Notifications**: One-way messages that don't expect a response
## Message Flow
A typical flow follows this pattern:
<Steps>
<Step title="Initialization Phase">
* Client → Agent: `initialize` to establish connection
* Client → Agent: `authenticate` if required by the Agent
</Step>
<Step title="Session Setup - either:">
* Client → Agent: `session/new` to create a new session
* Client → Agent: `session/load` to resume an existing session if supported
</Step>
<Step title="Prompt Turn">
* Client → Agent: `session/prompt` to send user message
* Agent → Client: `session/update` notifications for progress updates
* Agent → Client: File operations or permission requests as needed
* Client → Agent: `session/cancel` to interrupt processing if needed
* Turn ends and the Agent sends the `session/prompt` response with a stop reason
</Step>
</Steps>
## Agent
Agents are programs that use generative AI to autonomously modify code. They typically run as subprocesses of the Client.
### Baseline Methods
<ResponseField name="initialize" post={[<a href="./schema#initialize">Schema</a>]}>
[Negotiate versions and exchange capabilities.](./initialization).
</ResponseField>
<ResponseField name="authenticate" post={[<a href="./schema#authenticate">Schema</a>]}>
Authenticate with the Agent (if required).
</ResponseField>
<ResponseField name="session/new" post={[<a href="./schema#session%2Fnew">Schema</a>]}>
[Create a new conversation session](./session-setup#creating-a-session).
</ResponseField>
<ResponseField name="session/prompt" post={[<a href="./schema#session%2Fprompt">Schema</a>]}>
[Send user prompts](./prompt-turn#1-user-message) to the Agent.
</ResponseField>
### Optional Methods
<ResponseField name="session/load" post={[<a href="./schema#session%2Fload">Schema</a>]}>
[Load an existing session](./session-setup#loading-sessions) (requires
`loadSession` capability).
</ResponseField>
<ResponseField name="session/set_mode" post={[<a href="./schema#session%2Fset-mode">Schema</a>]}>
[Switch between agent operating
modes](./session-modes#setting-the-current-mode).
</ResponseField>
### Notifications
<ResponseField name="session/cancel" post={[<a href="./schema#session%2Fcancel">Schema</a>]}>
[Cancel ongoing operations](./prompt-turn#cancellation) (no response
expected).
</ResponseField>
## Client
Clients provide the interface between users and agents. They are typically code editors (IDEs, text editors) but can also be other UIs for interacting with agents. Clients manage the environment, handle user interactions, and control access to resources.
### Baseline Methods
<ResponseField name="session/request_permission" post={[<a href="./schema#session%2Frequest_permission">Schema</a>]}>
[Request user authorization](./tool-calls#requesting-permission) for tool
calls.
</ResponseField>
### Optional Methods
<ResponseField name="fs/read_text_file" post={[<a href="./schema#fs%2Fread_text_file">Schema</a>]}>
[Read file contents](./file-system#reading-files) (requires `fs.readTextFile`
capability).
</ResponseField>
<ResponseField name="fs/write_text_file" post={[<a href="./schema#fs%2Fwrite_text_file">Schema</a>]}>
[Write file contents](./file-system#writing-files) (requires
`fs.writeTextFile` capability).
</ResponseField>
<ResponseField name="terminal/create" post={[<a href="./schema#terminal%2Fcreate">Schema</a>]}>
[Create a new terminal](./terminals) (requires `terminal` capability).
</ResponseField>
<ResponseField name="terminal/output" post={[<a href="./schema#terminal%2Foutput">Schema</a>]}>
Get terminal output and exit status (requires `terminal` capability).
</ResponseField>
<ResponseField name="terminal/release" post={[<a href="./schema#terminal%2Frelease">Schema</a>]}>
Release a terminal (requires `terminal` capability).
</ResponseField>
<ResponseField name="terminal/wait_for_exit" post={[<a href="./schema#terminal%2Fwait_for_exit">Schema</a>]}>
Wait for terminal command to exit (requires `terminal` capability).
</ResponseField>
<ResponseField name="terminal/kill" post={[<a href="./schema#terminal%2Fkill">Schema</a>]}>
Kill terminal command without releasing (requires `terminal` capability).
</ResponseField>
### Notifications
<ResponseField name="session/update" post={[<a href="./schema#session%2Fupdate">Schema</a>]}>
[Send session updates](./prompt-turn#3-agent-reports-output) to inform the
Client of changes (no response expected). This includes: - [Message
chunks](./content) (agent, user, thought) - [Tool calls and
updates](./tool-calls) - [Plans](./agent-plan) - [Available commands
updates](./slash-commands#advertising-commands) - [Mode
changes](./session-modes#from-the-agent)
</ResponseField>
## Argument requirements
- All file paths in the protocol **MUST** be absolute.
- Line numbers are 1-based
## Error Handling
All methods follow standard JSON-RPC 2.0 [error handling](https://www.jsonrpc.org/specification#error_object):
- Successful responses include a `result` field
- Errors include an `error` object with `code` and `message`
- Notifications never receive responses (success or error)
## Extensibility
The protocol provides built-in mechanisms for adding custom functionality while maintaining compatibility:
- Add custom data using `_meta` fields
- Create custom methods by prefixing their name with underscore (`_`)
- Advertise custom capabilities during initialization
Learn about [protocol extensibility](./extensibility) to understand how to use these mechanisms.
## Next Steps
- Learn about [Initialization](./initialization) to understand version and capability negotiation
- Understand [Session Setup](./session-setup) for creating and loading sessions
- Review the [Prompt Turn](./prompt-turn) lifecycle
- Explore [Extensibility](./extensibility) to add custom features
---
> To find navigation and other pages in this documentation, fetch the llms.txt file at: https://agentclientprotocol.com/llms.txt

View file

@ -0,0 +1,321 @@
# Prompt Turn
> Understanding the core conversation flow
A prompt turn represents a complete interaction cycle between the [Client](./overview#client) and [Agent](./overview#agent), starting with a user message and continuing until the Agent completes its response. This may involve multiple exchanges with the language model and tool invocations.
Before sending prompts, Clients **MUST** first complete the [initialization](./initialization) phase and [session setup](./session-setup).
## The Prompt Turn Lifecycle
A prompt turn follows a structured flow that enables rich interactions between the user, Agent, and any connected tools.
<br />
```mermaid theme={null}
sequenceDiagram
participant Client
participant Agent
Note over Agent,Client: Session ready
Note left of Client: User sends message
Client->>Agent: session/prompt (user message)
Note right of Agent: Process with LLM
loop Until completion
Note right of Agent: LLM responds with<br/>content/tool calls
Agent->>Client: session/update (plan)
Agent->>Client: session/update (agent_message_chunk)
opt Tool calls requested
Agent->>Client: session/update (tool_call)
opt Permission required
Agent->>Client: session/request_permission
Note left of Client: User grants/denies
Client-->>Agent: Permission response
end
Agent->>Client: session/update (tool_call status: in_progress)
Note right of Agent: Execute tool
Agent->>Client: session/update (tool_call status: completed)
Note right of Agent: Send tool results<br/>back to LLM
end
opt User cancelled during execution
Note left of Client: User cancels prompt
Client->>Agent: session/cancel
Note right of Agent: Abort operations
Agent-->>Client: session/prompt response (cancelled)
end
end
Agent-->>Client: session/prompt response (stopReason)
```
### 1. User Message
The turn begins when the Client sends a `session/prompt`:
```json theme={null}
{
"jsonrpc": "2.0",
"id": 2,
"method": "session/prompt",
"params": {
"sessionId": "sess_abc123def456",
"prompt": [
{
"type": "text",
"text": "Can you analyze this code for potential issues?"
},
{
"type": "resource",
"resource": {
"uri": "file:///home/user/project/main.py",
"mimeType": "text/x-python",
"text": "def process_data(items):\n for item in items:\n print(item)"
}
}
]
}
}
```
<ParamField path="sessionId" type="SessionId">
The [ID](./session-setup#session-id) of the session to send this message to.
</ParamField>
<ParamField path="prompt" type="ContentBlock[]">
The contents of the user message, e.g. text, images, files, etc.
Clients **MUST** restrict types of content according to the [Prompt Capabilities](./initialization#prompt-capabilities) established during [initialization](./initialization).
<Card icon="comments" horizontal href="./content">
Learn more about Content
</Card>
</ParamField>
### 2. Agent Processing
Upon receiving the prompt request, the Agent processes the user's message and sends it to the language model, which **MAY** respond with text content, tool calls, or both.
### 3. Agent Reports Output
The Agent reports the model's output to the Client via `session/update` notifications. This may include the Agent's plan for accomplishing the task:
```json expandable theme={null}
{
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": "sess_abc123def456",
"update": {
"sessionUpdate": "plan",
"entries": [
{
"content": "Check for syntax errors",
"priority": "high",
"status": "pending"
},
{
"content": "Identify potential type issues",
"priority": "medium",
"status": "pending"
},
{
"content": "Review error handling patterns",
"priority": "medium",
"status": "pending"
},
{
"content": "Suggest improvements",
"priority": "low",
"status": "pending"
}
]
}
}
}
```
<Card icon="lightbulb" horizontal href="./agent-plan">
Learn more about Agent Plans
</Card>
The Agent then reports text responses from the model:
```json theme={null}
{
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": "sess_abc123def456",
"update": {
"sessionUpdate": "agent_message_chunk",
"content": {
"type": "text",
"text": "I'll analyze your code for potential issues. Let me examine it..."
}
}
}
}
```
If the model requested tool calls, these are also reported immediately:
```json theme={null}
{
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": "sess_abc123def456",
"update": {
"sessionUpdate": "tool_call",
"toolCallId": "call_001",
"title": "Analyzing Python code",
"kind": "other",
"status": "pending"
}
}
}
```
### 4. Check for Completion
If there are no pending tool calls, the turn ends and the Agent **MUST** respond to the original `session/prompt` request with a `StopReason`:
```json theme={null}
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"stopReason": "end_turn"
}
}
```
Agents **MAY** stop the turn at any point by returning the corresponding [`StopReason`](#stop-reasons).
### 5. Tool Invocation and Status Reporting
Before proceeding with execution, the Agent **MAY** request permission from the Client via the `session/request_permission` method.
Once permission is granted (if required), the Agent **SHOULD** invoke the tool and report a status update marking the tool as `in_progress`:
```json theme={null}
{
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": "sess_abc123def456",
"update": {
"sessionUpdate": "tool_call_update",
"toolCallId": "call_001",
"status": "in_progress"
}
}
}
```
As the tool runs, the Agent **MAY** send additional updates, providing real-time feedback about tool execution progress.
While tools execute on the Agent, they **MAY** leverage Client capabilities such as the file system (`fs`) methods to access resources within the Client's environment.
When the tool completes, the Agent sends another update with the final status and any content:
```json theme={null}
{
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": "sess_abc123def456",
"update": {
"sessionUpdate": "tool_call_update",
"toolCallId": "call_001",
"status": "completed",
"content": [
{
"type": "content",
"content": {
"type": "text",
"text": "Analysis complete:\n- No syntax errors found\n- Consider adding type hints for better clarity\n- The function could benefit from error handling for empty lists"
}
}
]
}
}
}
```
<Card icon="hammer" horizontal href="./tool-calls">
Learn more about Tool Calls
</Card>
### 6. Continue Conversation
The Agent sends the tool results back to the language model as another request.
The cycle returns to [step 2](#2-agent-processing), continuing until the language model completes its response without requesting additional tool calls or the turn gets stopped by the Agent or cancelled by the Client.
## Stop Reasons
When an Agent stops a turn, it must specify the corresponding `StopReason`:
<ResponseField name="end_turn">
The language model finishes responding without requesting more tools
</ResponseField>
<ResponseField name="max_tokens">
The maximum token limit is reached
</ResponseField>
<ResponseField name="max_turn_requests">
The maximum number of model requests in a single turn is exceeded
</ResponseField>
<ResponseField name="refusal">The Agent refuses to continue</ResponseField>
<ResponseField name="cancelled">The Client cancels the turn</ResponseField>
## Cancellation
Clients **MAY** cancel an ongoing prompt turn at any time by sending a `session/cancel` notification:
```json theme={null}
{
"jsonrpc": "2.0",
"method": "session/cancel",
"params": {
"sessionId": "sess_abc123def456"
}
}
```
The Client **SHOULD** preemptively mark all non-finished tool calls pertaining to the current turn as `cancelled` as soon as it sends the `session/cancel` notification.
The Client **MUST** respond to all pending `session/request_permission` requests with the `cancelled` outcome.
When the Agent receives this notification, it **SHOULD** stop all language model requests and all tool call invocations as soon as possible.
After all ongoing operations have been successfully aborted and pending updates have been sent, the Agent **MUST** respond to the original `session/prompt` request with the `cancelled` [stop reason](#stop-reasons).
<Warning>
API client libraries and tools often throw an exception when their operation is aborted, which may propagate as an error response to `session/prompt`.
Clients often display unrecognized errors from the Agent to the user, which would be undesirable for cancellations as they aren't considered errors.
Agents **MUST** catch these errors and return the semantically meaningful `cancelled` stop reason, so that Clients can reliably confirm the cancellation.
</Warning>
The Agent **MAY** send `session/update` notifications with content or tool call updates after receiving the `session/cancel` notification, but it **MUST** ensure that it does so before responding to the `session/prompt` request.
The Client **SHOULD** still accept tool call updates received after sending `session/cancel`.
---
Once a prompt turn completes, the Client may send another `session/prompt` to continue the conversation, building on the context established in previous turns.
---
> To find navigation and other pages in this documentation, fetch the llms.txt file at: https://agentclientprotocol.com/llms.txt

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,170 @@
# Session Modes
> Switch between different agent operating modes
Agents can provide a set of modes they can operate in. Modes often affect the system prompts used, the availability of tools, and whether they request permission before running.
## Initial state
During [Session Setup](./session-setup) the Agent **MAY** return a list of modes it can operate in and the currently active mode:
```json theme={null}
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"sessionId": "sess_abc123def456",
"modes": {
"currentModeId": "ask",
"availableModes": [
{
"id": "ask",
"name": "Ask",
"description": "Request permission before making any changes"
},
{
"id": "architect",
"name": "Architect",
"description": "Design and plan software systems without implementation"
},
{
"id": "code",
"name": "Code",
"description": "Write and modify code with full tool access"
}
]
}
}
}
```
<ResponseField name="modes" type="SessionModeState">
The current mode state for the session
</ResponseField>
### SessionModeState
<ResponseField name="currentModeId" type="SessionModeId" required>
The ID of the mode that is currently active
</ResponseField>
<ResponseField name="availableModes" type="SessionMode[]" required>
The set of modes that the Agent can operate in
</ResponseField>
### SessionMode
<ResponseField name="id" type="SessionModeId" required>
Unique identifier for this mode
</ResponseField>
<ResponseField name="name" type="string" required>
Human-readable name of the mode
</ResponseField>
<ResponseField name="description" type="string">
Optional description providing more details about what this mode does
</ResponseField>
## Setting the current mode
The current mode can be changed at any point during a session, whether the Agent is idle or generating a response.
### From the Client
Typically, Clients display the available modes to the user and allow them to change the current one, which they can do by calling the [`session/set_mode`](./schema#session%2Fset-mode) method.
```json theme={null}
{
"jsonrpc": "2.0",
"id": 2,
"method": "session/set_mode",
"params": {
"sessionId": "sess_abc123def456",
"modeId": "code"
}
}
```
<ParamField path="sessionId" type="SessionId" required>
The ID of the session to set the mode for
</ParamField>
<ParamField path="modeId" type="SessionModeId" required>
The ID of the mode to switch to. Must be one of the modes listed in
`availableModes`
</ParamField>
### From the Agent
The Agent can also change its own mode and let the Client know by sending the `current_mode_update` session notification:
```json theme={null}
{
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": "sess_abc123def456",
"update": {
"sessionUpdate": "current_mode_update",
"modeId": "code"
}
}
}
```
#### Exiting plan modes
A common case where an Agent might switch modes is from within a special "exit mode" tool that can be provided to the language model during plan/architect modes. The language model can call this tool when it determines it's ready to start implementing a solution.
This "switch mode" tool will usually request permission before running, which it can do just like any other tool:
```json theme={null}
{
"jsonrpc": "2.0",
"id": 3,
"method": "session/request_permission",
"params": {
"sessionId": "sess_abc123def456",
"toolCall": {
"toolCallId": "call_switch_mode_001",
"title": "Ready for implementation",
"kind": "switch_mode",
"status": "pending",
"content": [
{
"type": "text",
"text": "## Implementation Plan..."
}
]
},
"options": [
{
"optionId": "code",
"name": "Yes, and auto-accept all actions",
"kind": "allow_always"
},
{
"optionId": "ask",
"name": "Yes, and manually accept actions",
"kind": "allow_once"
},
{
"optionId": "reject",
"name": "No, stay in architect mode",
"kind": "reject_once"
}
]
}
}
```
When an option is chosen, the tool runs, setting the mode and sending the `current_mode_update` notification mentioned above.
<Card icon="shield-check" horizontal href="./tool-calls#requesting-permission">
Learn more about permission requests
</Card>
---
> To find navigation and other pages in this documentation, fetch the llms.txt file at: https://agentclientprotocol.com/llms.txt

View file

@ -0,0 +1,384 @@
# Session Setup
> Creating and loading sessions
Sessions represent a specific conversation or thread between the [Client](./overview#client) and [Agent](./overview#agent). Each session maintains its own context, conversation history, and state, allowing multiple independent interactions with the same Agent.
Before creating a session, Clients **MUST** first complete the [initialization](./initialization) phase to establish protocol compatibility and capabilities.
<br />
```mermaid theme={null}
sequenceDiagram
participant Client
participant Agent
Note over Agent,Client: Initialized
alt
Client->>Agent: session/new
Note over Agent: Create session context
Note over Agent: Connect to MCP servers
Agent-->>Client: session/new response (sessionId)
else
Client->>Agent: session/load (sessionId)
Note over Agent: Restore session context
Note over Agent: Connect to MCP servers
Note over Agent,Client: Replay conversation history...
Agent->>Client: session/update
Agent->>Client: session/update
Note over Agent,Client: All content streamed
Agent-->>Client: session/load response
end
Note over Client,Agent: Ready for prompts
```
<br />
## Creating a Session
Clients create a new session by calling the `session/new` method with:
- The [working directory](#working-directory) for the session
- A list of [MCP servers](#mcp-servers) the Agent should connect to
```json theme={null}
{
"jsonrpc": "2.0",
"id": 1,
"method": "session/new",
"params": {
"cwd": "/home/user/project",
"mcpServers": [
{
"name": "filesystem",
"command": "/path/to/mcp-server",
"args": ["--stdio"],
"env": []
}
]
}
}
```
The Agent **MUST** respond with a unique [Session ID](#session-id) that identifies this conversation:
```json theme={null}
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"sessionId": "sess_abc123def456"
}
}
```
## Loading Sessions
Agents that support the `loadSession` capability allow Clients to resume previous conversations. This feature enables persistence across restarts and sharing sessions between different Client instances.
### Checking Support
Before attempting to load a session, Clients **MUST** verify that the Agent supports this capability by checking the `loadSession` field in the `initialize` response:
```json highlight={7} theme={null}
{
"jsonrpc": "2.0",
"id": 0,
"result": {
"protocolVersion": 1,
"agentCapabilities": {
"loadSession": true
}
}
}
```
If `loadSession` is `false` or not present, the Agent does not support loading sessions and Clients **MUST NOT** attempt to call `session/load`.
### Loading a Session
To load an existing session, Clients **MUST** call the `session/load` method with:
- The [Session ID](#session-id) to resume
- [MCP servers](#mcp-servers) to connect to
- The [working directory](#working-directory)
```json theme={null}
{
"jsonrpc": "2.0",
"id": 1,
"method": "session/load",
"params": {
"sessionId": "sess_789xyz",
"cwd": "/home/user/project",
"mcpServers": [
{
"name": "filesystem",
"command": "/path/to/mcp-server",
"args": ["--mode", "filesystem"],
"env": []
}
]
}
}
```
The Agent **MUST** replay the entire conversation to the Client in the form of `session/update` notifications (like `session/prompt`).
For example, a user message from the conversation history:
```json theme={null}
{
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": "sess_789xyz",
"update": {
"sessionUpdate": "user_message_chunk",
"content": {
"type": "text",
"text": "What's the capital of France?"
}
}
}
}
```
Followed by the agent's response:
```json theme={null}
{
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": "sess_789xyz",
"update": {
"sessionUpdate": "agent_message_chunk",
"content": {
"type": "text",
"text": "The capital of France is Paris."
}
}
}
}
```
When **all** the conversation entries have been streamed to the Client, the Agent **MUST** respond to the original `session/load` request.
```json theme={null}
{
"jsonrpc": "2.0",
"id": 1,
"result": null
}
```
The Client can then continue sending prompts as if the session was never interrupted.
## Session ID
The session ID returned by `session/new` is a unique identifier for the conversation context.
Clients use this ID to:
- Send prompt requests via `session/prompt`
- Cancel ongoing operations via `session/cancel`
- Load previous sessions via `session/load` (if the Agent supports the `loadSession` capability)
## Working Directory
The `cwd` (current working directory) parameter establishes the file system context for the session. This directory:
- **MUST** be an absolute path
- **MUST** be used for the session regardless of where the Agent subprocess was spawned
- **SHOULD** serve as a boundary for tool operations on the file system
## MCP Servers
The [Model Context Protocol (MCP)](https://modelcontextprotocol.io) allows Agents to access external tools and data sources. When creating a session, Clients **MAY** include connection details for MCP servers that the Agent should connect to.
MCP servers can be connected to using different transports. All Agents **MUST** support the stdio transport, while HTTP and SSE transports are optional capabilities that can be checked during initialization.
While they are not required to by the spec, new Agents **SHOULD** support the HTTP transport to ensure compatibility with modern MCP servers.
### Transport Types
#### Stdio Transport
All Agents **MUST** support connecting to MCP servers via stdio (standard input/output). This is the default transport mechanism.
<ParamField path="name" type="string" required>
A human-readable identifier for the server
</ParamField>
<ParamField path="command" type="string" required>
The absolute path to the MCP server executable
</ParamField>
<ParamField path="args" type="array" required>
Command-line arguments to pass to the server
</ParamField>
<ParamField path="env" type="EnvVariable[]">
Environment variables to set when launching the server
<Expandable title="EnvVariable">
<ParamField path="name" type="string">
The name of the environment variable.
</ParamField>
<ParamField path="value" type="string">
The value of the environment variable.
</ParamField>
</Expandable>
</ParamField>
Example stdio transport configuration:
```json theme={null}
{
"name": "filesystem",
"command": "/path/to/mcp-server",
"args": ["--stdio"],
"env": [
{
"name": "API_KEY",
"value": "secret123"
}
]
}
```
#### HTTP Transport
When the Agent supports `mcpCapabilities.http`, Clients can specify MCP servers configurations using the HTTP transport.
<ParamField path="type" type="string" required>
Must be `"http"` to indicate HTTP transport
</ParamField>
<ParamField path="name" type="string" required>
A human-readable identifier for the server
</ParamField>
<ParamField path="url" type="string" required>
The URL of the MCP server
</ParamField>
<ParamField path="headers" type="HttpHeader[]" required>
HTTP headers to include in requests to the server
<Expandable title="HttpHeader">
<ParamField path="name" type="string">
The name of the HTTP header.
</ParamField>
<ParamField path="value" type="string">
The value to set for the HTTP header.
</ParamField>
</Expandable>
</ParamField>
Example HTTP transport configuration:
```json theme={null}
{
"type": "http",
"name": "api-server",
"url": "https://api.example.com/mcp",
"headers": [
{
"name": "Authorization",
"value": "Bearer token123"
},
{
"name": "Content-Type",
"value": "application/json"
}
]
}
```
#### SSE Transport
When the Agent supports `mcpCapabilities.sse`, Clients can specify MCP servers configurations using the SSE transport.
<Warning>This transport was deprecated by the MCP spec.</Warning>
<ParamField path="type" type="string" required>
Must be `"sse"` to indicate SSE transport
</ParamField>
<ParamField path="name" type="string" required>
A human-readable identifier for the server
</ParamField>
<ParamField path="url" type="string" required>
The URL of the SSE endpoint
</ParamField>
<ParamField path="headers" type="HttpHeader[]" required>
HTTP headers to include when establishing the SSE connection
<Expandable title="HttpHeader">
<ParamField path="name" type="string">
The name of the HTTP header.
</ParamField>
<ParamField path="value" type="string">
The value to set for the HTTP header.
</ParamField>
</Expandable>
</ParamField>
Example SSE transport configuration:
```json theme={null}
{
"type": "sse",
"name": "event-stream",
"url": "https://events.example.com/mcp",
"headers": [
{
"name": "X-API-Key",
"value": "apikey456"
}
]
}
```
### Checking Transport Support
Before using HTTP or SSE transports, Clients **MUST** verify the Agent's capabilities during initialization:
```json highlight={7-10} theme={null}
{
"jsonrpc": "2.0",
"id": 0,
"result": {
"protocolVersion": 1,
"agentCapabilities": {
"mcpCapabilities": {
"http": true,
"sse": true
}
}
}
}
```
If `mcpCapabilities.http` is `false` or not present, the Agent does not support HTTP transport.
If `mcpCapabilities.sse` is `false` or not present, the Agent does not support SSE transport.
Agents **SHOULD** connect to all MCP servers specified by the Client.
Clients **MAY** use this ability to provide tools directly to the underlying language model by including their own MCP server.
---
> To find navigation and other pages in this documentation, fetch the llms.txt file at: https://agentclientprotocol.com/llms.txt

View file

@ -0,0 +1,99 @@
# Slash Commands
> Advertise available slash commands to clients
Agents can advertise a set of slash commands that users can invoke. These commands provide quick access to specific agent capabilities and workflows. Commands are run as part of regular [prompt](./prompt-turn) requests where the Client includes the command text in the prompt.
## Advertising commands
After creating a session, the Agent **MAY** send a list of available commands via the `available_commands_update` session notification:
```json theme={null}
{
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": "sess_abc123def456",
"update": {
"sessionUpdate": "available_commands_update",
"availableCommands": [
{
"name": "web",
"description": "Search the web for information",
"input": {
"hint": "query to search for"
}
},
{
"name": "test",
"description": "Run tests for the current project"
},
{
"name": "plan",
"description": "Create a detailed implementation plan",
"input": {
"hint": "description of what to plan"
}
}
]
}
}
}
```
<ResponseField name="availableCommands" type="AvailableCommand[]">
The list of commands available in this session
</ResponseField>
### AvailableCommand
<ResponseField name="name" type="string" required>
The command name (e.g., "web", "test", "plan")
</ResponseField>
<ResponseField name="description" type="string" required>
Human-readable description of what the command does
</ResponseField>
<ResponseField name="input" type="AvailableCommandInput">
Optional input specification for the command
</ResponseField>
### AvailableCommandInput
Currently supports unstructured text input:
<ResponseField name="hint" type="string" required>
A hint to display when the input hasn't been provided yet
</ResponseField>
## Dynamic updates
The Agent can update the list of available commands at any time during a session by sending another `available_commands_update` notification. This allows commands to be added based on context, removed when no longer relevant, or modified with updated descriptions.
## Running commands
Commands are included as regular user messages in prompt requests:
```json theme={null}
{
"jsonrpc": "2.0",
"id": 3,
"method": "session/prompt",
"params": {
"sessionId": "sess_abc123def456",
"prompt": [
{
"type": "text",
"text": "/web agent client protocol"
}
]
}
}
```
The Agent recognizes the command prefix and processes it accordingly. Commands may be accompanied by any other user message content types (images, audio, etc.) in the same prompt array.
---
> To find navigation and other pages in this documentation, fetch the llms.txt file at: https://agentclientprotocol.com/llms.txt

View file

@ -0,0 +1,281 @@
# Terminals
> Executing and managing terminal commands
The terminal methods allow Agents to execute shell commands within the Client's environment. These methods enable Agents to run build processes, execute scripts, and interact with command-line tools while providing real-time output streaming and process control.
## Checking Support
Before attempting to use terminal methods, Agents **MUST** verify that the Client supports this capability by checking the [Client Capabilities](./initialization#client-capabilities) field in the `initialize` response:
```json highlight={7} theme={null}
{
"jsonrpc": "2.0",
"id": 0,
"result": {
"protocolVersion": 1,
"clientCapabilities": {
"terminal": true
}
}
}
```
If `terminal` is `false` or not present, the Agent **MUST NOT** attempt to call any terminal methods.
## Executing Commands
The `terminal/create` method starts a command in a new terminal:
```json theme={null}
{
"jsonrpc": "2.0",
"id": 5,
"method": "terminal/create",
"params": {
"sessionId": "sess_abc123def456",
"command": "npm",
"args": ["test", "--coverage"],
"env": [
{
"name": "NODE_ENV",
"value": "test"
}
],
"cwd": "/home/user/project",
"outputByteLimit": 1048576
}
}
```
<ParamField path="sessionId" type="SessionId" required>
The [Session ID](./session-setup#session-id) for this request
</ParamField>
<ParamField path="command" type="string" required>
The command to execute
</ParamField>
<ParamField path="args" type="string[]">
Array of command arguments
</ParamField>
<ParamField path="env" type="EnvVariable[]">
Environment variables for the command.
Each variable has:
- `name`: The environment variable name
- `value`: The environment variable value
</ParamField>
<ParamField path="cwd" type="string">
Working directory for the command (absolute path)
</ParamField>
<ParamField path="outputByteLimit" type="number">
Maximum number of output bytes to retain. Once exceeded, earlier output is
truncated to stay within this limit.
When the limit is exceeded, the Client truncates from the beginning of the output
to stay within the limit.
The Client **MUST** ensure truncation happens at a character boundary to maintain valid
string output, even if this means the retained output is slightly less than the
specified limit.
</ParamField>
The Client returns a Terminal ID immediately without waiting for completion:
```json theme={null}
{
"jsonrpc": "2.0",
"id": 5,
"result": {
"terminalId": "term_xyz789"
}
}
```
This allows the command to run in the background while the Agent performs other operations.
After creating the terminal, the Agent can use the `terminal/wait_for_exit` method to wait for the command to complete.
<Note>
The Agent **MUST** release the terminal using `terminal/release` when it's no
longer needed.
</Note>
## Embedding in Tool Calls
Terminals can be embedded directly in [tool calls](./tool-calls) to provide real-time output to users:
```json theme={null}
{
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": "sess_abc123def456",
"update": {
"sessionUpdate": "tool_call",
"toolCallId": "call_002",
"title": "Running tests",
"kind": "execute",
"status": "in_progress",
"content": [
{
"type": "terminal",
"terminalId": "term_xyz789"
}
]
}
}
}
```
When a terminal is embedded in a tool call, the Client displays live output as it's generated and continues to display it even after the terminal is released.
## Getting Output
The `terminal/output` method retrieves the current terminal output without waiting for the command to complete:
```json theme={null}
{
"jsonrpc": "2.0",
"id": 6,
"method": "terminal/output",
"params": {
"sessionId": "sess_abc123def456",
"terminalId": "term_xyz789"
}
}
```
The Client responds with the current output and exit status (if the command has finished):
```json theme={null}
{
"jsonrpc": "2.0",
"id": 6,
"result": {
"output": "Running tests...\n✓ All tests passed (42 total)\n",
"truncated": false,
"exitStatus": {
"exitCode": 0,
"signal": null
}
}
}
```
<ResponseField name="output" type="string" required>
The terminal output captured so far
</ResponseField>
<ResponseField name="truncated" type="boolean" required>
Whether the output was truncated due to byte limits
</ResponseField>
<ResponseField name="exitStatus" type="TerminalExitStatus">
Present only if the command has exited. Contains:
- `exitCode`: The process exit code (may be null)
- `signal`: The signal that terminated the process (may be null)
</ResponseField>
## Waiting for Exit
The `terminal/wait_for_exit` method returns once the command completes:
```json theme={null}
{
"jsonrpc": "2.0",
"id": 7,
"method": "terminal/wait_for_exit",
"params": {
"sessionId": "sess_abc123def456",
"terminalId": "term_xyz789"
}
}
```
The Client responds once the command exits:
```json theme={null}
{
"jsonrpc": "2.0",
"id": 7,
"result": {
"exitCode": 0,
"signal": null
}
}
```
<ResponseField name="exitCode" type="number">
The process exit code (may be null if terminated by signal)
</ResponseField>
<ResponseField name="signal" type="string">
The signal that terminated the process (may be null if exited normally)
</ResponseField>
## Killing Commands
The `terminal/kill` method terminates a command without releasing the terminal:
```json theme={null}
{
"jsonrpc": "2.0",
"id": 8,
"method": "terminal/kill",
"params": {
"sessionId": "sess_abc123def456",
"terminalId": "term_xyz789"
}
}
```
After killing a command, the terminal remains valid and can be used with:
- `terminal/output` to get the final output
- `terminal/wait_for_exit` to get the exit status
The Agent **MUST** still call `terminal/release` when it's done using it.
### Building a Timeout
Agents can implement command timeouts by combining terminal methods:
1. Create a terminal with `terminal/create`
2. Start a timer for the desired timeout duration
3. Concurrently wait for either the timer to expire or `terminal/wait_for_exit` to return
4. If the timer expires first:
- Call `terminal/kill` to terminate the command
- Call `terminal/output` to retrieve any final output
- Include the output in the response to the model
5. Call `terminal/release` when done
## Releasing Terminals
The `terminal/release` kills the command if still running and releases all resources:
```json theme={null}
{
"jsonrpc": "2.0",
"id": 9,
"method": "terminal/release",
"params": {
"sessionId": "sess_abc123def456",
"terminalId": "term_xyz789"
}
}
```
After release the terminal ID becomes invalid for all other `terminal/*` methods.
If the terminal was added to a tool call, the client **SHOULD** continue to display its output after release.
---
> To find navigation and other pages in this documentation, fetch the llms.txt file at: https://agentclientprotocol.com/llms.txt

View file

@ -0,0 +1,311 @@
# Tool Calls
> How Agents report tool call execution
Tool calls represent actions that language models request Agents to perform during a [prompt turn](./prompt-turn). When an LLM determines it needs to interact with external systems—like reading files, running code, or fetching data—it generates tool calls that the Agent executes on its behalf.
Agents report tool calls through [`session/update`](./prompt-turn#3-agent-reports-output) notifications, allowing Clients to display real-time progress and results to users.
While Agents handle the actual execution, they may leverage Client capabilities like [permission requests](#requesting-permission) or [file system access](./file-system) to provide a richer, more integrated experience.
## Creating
When the language model requests a tool invocation, the Agent **SHOULD** report it to the Client:
```json theme={null}
{
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": "sess_abc123def456",
"update": {
"sessionUpdate": "tool_call",
"toolCallId": "call_001",
"title": "Reading configuration file",
"kind": "read",
"status": "pending"
}
}
}
```
<ParamField path="toolCallId" type="ToolCallId" required>
A unique identifier for this tool call within the session
</ParamField>
<ParamField path="title" type="string" required>
A human-readable title describing what the tool is doing
</ParamField>
<ParamField path="kind" type="ToolKind">
The category of tool being invoked.
<Expandable title="kinds">
* `read` - Reading files or data - `edit` - Modifying files or content -
`delete` - Removing files or data - `move` - Moving or renaming files -
`search` - Searching for information - `execute` - Running commands or code -
`think` - Internal reasoning or planning - `fetch` - Retrieving external data
* `other` - Other tool types (default)
</Expandable>
Tool kinds help Clients choose appropriate icons and optimize how they display tool execution progress.
</ParamField>
<ParamField path="status" type="ToolCallStatus">
The current [execution status](#status) (defaults to `pending`)
</ParamField>
<ParamField path="content" type="ToolCallContent[]">
[Content produced](#content) by the tool call
</ParamField>
<ParamField path="locations" type="ToolCallLocation[]">
[File locations](#following-the-agent) affected by this tool call
</ParamField>
<ParamField path="rawInput" type="object">
The raw input parameters sent to the tool
</ParamField>
<ParamField path="rawOutput" type="object">
The raw output returned by the tool
</ParamField>
## Updating
As tools execute, Agents send updates to report progress and results.
Updates use the `session/update` notification with `tool_call_update`:
```json theme={null}
{
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": "sess_abc123def456",
"update": {
"sessionUpdate": "tool_call_update",
"toolCallId": "call_001",
"status": "in_progress",
"content": [
{
"type": "content",
"content": {
"type": "text",
"text": "Found 3 configuration files..."
}
}
]
}
}
}
```
All fields except `toolCallId` are optional in updates. Only the fields being changed need to be included.
## Requesting Permission
The Agent **MAY** request permission from the user before executing a tool call by calling the `session/request_permission` method:
```json theme={null}
{
"jsonrpc": "2.0",
"id": 5,
"method": "session/request_permission",
"params": {
"sessionId": "sess_abc123def456",
"toolCall": {
"toolCallId": "call_001"
},
"options": [
{
"optionId": "allow-once",
"name": "Allow once",
"kind": "allow_once"
},
{
"optionId": "reject-once",
"name": "Reject",
"kind": "reject_once"
}
]
}
}
```
<ParamField path="sessionId" type="SessionId" required>
The session ID for this request
</ParamField>
<ParamField path="toolCall" type="ToolCallUpdate" required>
The tool call update containing details about the operation
</ParamField>
<ParamField path="options" type="PermissionOption[]" required>
Available [permission options](#permission-options) for the user to choose
from
</ParamField>
The Client responds with the user's decision:
```json theme={null}
{
"jsonrpc": "2.0",
"id": 5,
"result": {
"outcome": {
"outcome": "selected",
"optionId": "allow-once"
}
}
}
```
Clients **MAY** automatically allow or reject permission requests according to the user settings.
If the current prompt turn gets [cancelled](./prompt-turn#cancellation), the Client **MUST** respond with the `"cancelled"` outcome:
```json theme={null}
{
"jsonrpc": "2.0",
"id": 5,
"result": {
"outcome": {
"outcome": "cancelled"
}
}
}
```
<ResponseField name="outcome" type="RequestPermissionOutcome" required>
The user's decision, either: - `cancelled` - The [prompt turn was
cancelled](./prompt-turn#cancellation) - `selected` with an `optionId` - The
ID of the selected permission option
</ResponseField>
### Permission Options
Each permission option provided to the Client contains:
<ParamField path="optionId" type="string" required>
Unique identifier for this option
</ParamField>
<ParamField path="name" type="string" required>
Human-readable label to display to the user
</ParamField>
<ParamField path="kind" type="PermissionOptionKind" required>
A hint to help Clients choose appropriate icons and UI treatment for each option.
- `allow_once` - Allow this operation only this time
- `allow_always` - Allow this operation and remember the choice
- `reject_once` - Reject this operation only this time
- `reject_always` - Reject this operation and remember the choice
</ParamField>
## Status
Tool calls progress through different statuses during their lifecycle:
<ResponseField name="pending">
The tool call hasn't started running yet because the input is either streaming
or awaiting approval
</ResponseField>
<ResponseField name="in_progress">
The tool call is currently running
</ResponseField>
<ResponseField name="completed">
The tool call completed successfully
</ResponseField>
<ResponseField name="failed">The tool call failed with an error</ResponseField>
## Content
Tool calls can produce different types of content:
### Regular Content
Standard [content blocks](./content) like text, images, or resources:
```json theme={null}
{
"type": "content",
"content": {
"type": "text",
"text": "Analysis complete. Found 3 issues."
}
}
```
### Diffs
File modifications shown as diffs:
```json theme={null}
{
"type": "diff",
"path": "/home/user/project/src/config.json",
"oldText": "{\n \"debug\": false\n}",
"newText": "{\n \"debug\": true\n}"
}
```
<ParamField path="path" type="string" required>
The absolute file path being modified
</ParamField>
<ParamField path="oldText" type="string">
The original content (null for new files)
</ParamField>
<ParamField path="newText" type="string" required>
The new content after modification
</ParamField>
### Terminals
Live terminal output from command execution:
```json theme={null}
{
"type": "terminal",
"terminalId": "term_xyz789"
}
```
<ParamField path="terminalId" type="string" required>
The ID of a terminal created with `terminal/create`
</ParamField>
When a terminal is embedded in a tool call, the Client displays live output as it's generated and continues to display it even after the terminal is released.
<Card icon="terminal" horizontal href="./terminals">
Learn more about Terminals
</Card>
## Following the Agent
Tool calls can report file locations they're working with, enabling Clients to implement "follow-along" features that track which files the Agent is accessing or modifying in real-time.
```json theme={null}
{
"path": "/home/user/project/src/main.py",
"line": 42
}
```
<ParamField path="path" type="string" required>
The absolute file path being accessed or modified
</ParamField>
<ParamField path="line" type="number">
Optional line number within the file
</ParamField>
---
> To find navigation and other pages in this documentation, fetch the llms.txt file at: https://agentclientprotocol.com/llms.txt

View file

@ -0,0 +1,55 @@
# Transports
> Mechanisms for agents and clients to communicate with each other
ACP uses JSON-RPC to encode messages. JSON-RPC messages **MUST** be UTF-8 encoded.
The protocol currently defines the following transport mechanisms for agent-client communication:
1. [stdio](#stdio), communication over standard in and standard out
2. _[Streamable HTTP](#streamable-http) (draft proposal in progress)_
Agents and clients **SHOULD** support stdio whenever possible.
It is also possible for agents and clients to implement [custom transports](#custom-transports).
## stdio
In the **stdio** transport:
- The client launches the agent as a subprocess.
- The agent reads JSON-RPC messages from its standard input (`stdin`) and sends messages to its standard output (`stdout`).
- Messages are individual JSON-RPC requests, notifications, or responses.
- Messages are delimited by newlines (`\n`), and **MUST NOT** contain embedded newlines.
- The agent **MAY** write UTF-8 strings to its standard error (`stderr`) for logging purposes. Clients **MAY** capture, forward, or ignore this logging.
- The agent **MUST NOT** write anything to its `stdout` that is not a valid ACP message.
- The client **MUST NOT** write anything to the agent's `stdin` that is not a valid ACP message.
```mermaid theme={null}
sequenceDiagram
participant Client
participant Agent Process
Client->>+Agent Process: Launch subprocess
loop Message Exchange
Client->>Agent Process: Write to stdin
Agent Process->>Client: Write to stdout
Agent Process--)Client: Optional logs on stderr
end
Client->>Agent Process: Close stdin, terminate subprocess
deactivate Agent Process
```
## _Streamable HTTP_
_In discussion, draft proposal in progress._
## Custom Transports
Agents and clients **MAY** implement additional custom transport mechanisms to suit their specific needs. The protocol is transport-agnostic and can be implemented over any communication channel that supports bidirectional message exchange.
Implementers who choose to support custom transports **MUST** ensure they preserve the JSON-RPC message format and lifecycle requirements defined by ACP. Custom transports **SHOULD** document their specific connection establishment and message exchange patterns to aid interoperability.
---
> To find navigation and other pages in this documentation, fetch the llms.txt file at: https://agentclientprotocol.com/llms.txt

View file

@ -0,0 +1,148 @@
/**
* ACP File System Service
*
* Delegates file system operations to the ACP client when supported.
* Falls back to direct file system operations when the client doesn't
* support the required capabilities.
*/
import * as acp from "@agentclientprotocol/sdk"
import * as fs from "node:fs/promises"
import * as path from "node:path"
// =============================================================================
// AcpFileSystemService Class
// =============================================================================
/**
* AcpFileSystemService provides file system operations that can be delegated
* to the ACP client or performed locally.
*
* This allows the ACP client (like Zed) to handle file operations within
* its own context, providing proper integration with the editor's file system,
* undo stack, and other features.
*/
export class AcpFileSystemService {
constructor(
private readonly connection: acp.AgentSideConnection,
private readonly sessionId: string,
private readonly capabilities: acp.FileSystemCapability | undefined,
private readonly workspacePath: string,
) {}
// ===========================================================================
// Read Operations
// ===========================================================================
/**
* Read text content from a file.
*
* If the ACP client supports readTextFile, delegates to the client.
* Otherwise, reads directly from the file system.
*/
async readTextFile(filePath: string): Promise<string> {
// Resolve path relative to workspace
const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(this.workspacePath, filePath)
// Use client capability if available
if (this.capabilities?.readTextFile) {
try {
const response = await this.connection.readTextFile({
path: absolutePath,
sessionId: this.sessionId,
})
return response.content
} catch (error) {
// Fall back to direct read on error
console.warn("[AcpFileSystemService] Client read failed, falling back to direct read:", error)
}
}
// Direct file system read
return fs.readFile(absolutePath, "utf-8")
}
// ===========================================================================
// Write Operations
// ===========================================================================
/**
* Write text content to a file.
*
* If the ACP client supports writeTextFile, delegates to the client.
* Otherwise, writes directly to the file system.
*/
async writeTextFile(filePath: string, content: string): Promise<void> {
// Resolve path relative to workspace
const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(this.workspacePath, filePath)
// Use client capability if available
if (this.capabilities?.writeTextFile) {
try {
await this.connection.writeTextFile({
path: absolutePath,
content,
sessionId: this.sessionId,
})
return
} catch (error) {
// Fall back to direct write on error
console.warn("[AcpFileSystemService] Client write failed, falling back to direct write:", error)
}
}
// Ensure directory exists
const dir = path.dirname(absolutePath)
await fs.mkdir(dir, { recursive: true })
// Direct file system write
await fs.writeFile(absolutePath, content, "utf-8")
}
// ===========================================================================
// Capability Checks
// ===========================================================================
/**
* Check if the client supports reading files.
*/
canReadTextFile(): boolean {
return this.capabilities?.readTextFile === true
}
/**
* Check if the client supports writing files.
*/
canWriteTextFile(): boolean {
return this.capabilities?.writeTextFile === true
}
/**
* Check if any client file system capabilities are available.
*/
hasClientCapabilities(): boolean {
return this.canReadTextFile() || this.canWriteTextFile()
}
}
// =============================================================================
// Factory Function
// =============================================================================
/**
* Create an AcpFileSystemService if the client has file system capabilities.
*/
export function createAcpFileSystemService(
connection: acp.AgentSideConnection,
sessionId: string,
clientCapabilities: acp.ClientCapabilities | undefined,
workspacePath: string,
): AcpFileSystemService | null {
const fsCapabilities = clientCapabilities?.fs
if (!fsCapabilities) {
return null
}
return new AcpFileSystemService(connection, sessionId, fsCapabilities, workspacePath)
}

24
apps/cli/src/acp/index.ts Normal file
View file

@ -0,0 +1,24 @@
/**
* ACP (Agent Client Protocol) Integration Module
*
* This module provides ACP support for the Roo Code CLI, allowing ACP-compatible
* clients like Zed to use Roo Code as their AI coding assistant.
*
* Main components:
* - RooCodeAgent: Implements the acp.Agent interface
* - AcpSession: Wraps ExtensionHost for individual sessions
* - Translator: Converts between internal and ACP message formats
* - AcpFileSystemService: Delegates file operations to ACP client
* - UpdateBuffer: Batches session updates to reduce message frequency
* - acpLog: File-based logger for debugging (writes to ~/.roo/acp.log)
*
* Note: Commands are executed internally by the extension (like the reference
* implementations gemini-cli and opencode), not through ACP terminals.
*/
export { RooCodeAgent, type RooCodeAgentOptions } from "./agent.js"
export { AcpSession, type AcpSessionOptions } from "./session.js"
export { AcpFileSystemService, createAcpFileSystemService } from "./file-system-service.js"
export { UpdateBuffer, type UpdateBufferOptions } from "./update-buffer.js"
export { acpLog } from "./logger.js"
export * from "./translator.js"

186
apps/cli/src/acp/logger.ts Normal file
View file

@ -0,0 +1,186 @@
/**
* ACP Logger
*
* Provides file-based logging for ACP debugging.
* Logs are written to ~/.roo/acp.log by default.
*
* Since ACP uses stdin/stdout for protocol communication,
* we cannot use console.log for debugging. This logger writes
* to a file instead.
*/
import * as fs from "node:fs"
import * as path from "node:path"
import * as os from "node:os"
// =============================================================================
// Configuration
// =============================================================================
const DEFAULT_LOG_DIR = path.join(os.homedir(), ".roo")
const DEFAULT_LOG_FILE = "acp.log"
const MAX_LOG_SIZE = 10 * 1024 * 1024 // 10MB
// =============================================================================
// Logger Class
// =============================================================================
class AcpLogger {
private logPath: string
private enabled: boolean = true
private stream: fs.WriteStream | null = null
constructor() {
const logDir = process.env.ROO_ACP_LOG_DIR || DEFAULT_LOG_DIR
const logFile = process.env.ROO_ACP_LOG_FILE || DEFAULT_LOG_FILE
this.logPath = path.join(logDir, logFile)
// Disable logging if explicitly set to false
if (process.env.ROO_ACP_LOG === "false") {
this.enabled = false
}
}
/**
* Initialize the logger.
* Creates the log directory if it doesn't exist.
*/
private ensureLogFile(): void {
if (!this.enabled) return
try {
const logDir = path.dirname(this.logPath)
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir, { recursive: true })
}
// Rotate log if too large
if (fs.existsSync(this.logPath)) {
const stats = fs.statSync(this.logPath)
if (stats.size > MAX_LOG_SIZE) {
const rotatedPath = `${this.logPath}.1`
if (fs.existsSync(rotatedPath)) {
fs.unlinkSync(rotatedPath)
}
fs.renameSync(this.logPath, rotatedPath)
}
}
// Open stream if not already open
if (!this.stream) {
this.stream = fs.createWriteStream(this.logPath, { flags: "a" })
}
} catch (_error) {
// Silently disable logging on error
this.enabled = false
}
}
/**
* Format a log message with timestamp and level.
*/
private formatMessage(level: string, component: string, message: string, data?: unknown): string {
const timestamp = new Date().toISOString()
let formatted = `[${timestamp}] [${level}] [${component}] ${message}`
if (data !== undefined) {
try {
const dataStr = JSON.stringify(data, null, 2)
formatted += `\n${dataStr}`
} catch {
formatted += ` [Data: unserializable]`
}
}
return formatted + "\n"
}
/**
* Write a log entry.
*/
private write(level: string, component: string, message: string, data?: unknown): void {
if (!this.enabled) return
this.ensureLogFile()
if (this.stream) {
const formatted = this.formatMessage(level, component, message, data)
this.stream.write(formatted)
}
}
/**
* Log an info message.
*/
info(component: string, message: string, data?: unknown): void {
this.write("INFO", component, message, data)
}
/**
* Log a debug message.
*/
debug(component: string, message: string, data?: unknown): void {
this.write("DEBUG", component, message, data)
}
/**
* Log a warning message.
*/
warn(component: string, message: string, data?: unknown): void {
this.write("WARN", component, message, data)
}
/**
* Log an error message.
*/
error(component: string, message: string, data?: unknown): void {
this.write("ERROR", component, message, data)
}
/**
* Log an incoming request.
*/
request(method: string, params?: unknown): void {
this.write("REQUEST", "ACP", `${method}`, params)
}
/**
* Log an outgoing response.
*/
response(method: string, result?: unknown): void {
this.write("RESPONSE", "ACP", `${method}`, result)
}
/**
* Log an outgoing notification.
*/
notification(method: string, params?: unknown): void {
this.write("NOTIFY", "ACP", `${method}`, params)
}
/**
* Get the log file path.
*/
getLogPath(): string {
return this.logPath
}
/**
* Close the logger.
*/
close(): void {
if (this.stream) {
this.stream.end()
this.stream = null
}
}
}
// =============================================================================
// Singleton Export
// =============================================================================
export const acpLog = new AcpLogger()
// Log startup
acpLog.info("Logger", `ACP logging initialized. Log file: ${acpLog.getLogPath()}`)

848
apps/cli/src/acp/session.ts Normal file
View file

@ -0,0 +1,848 @@
/**
* ACP Session
*
* Manages a single ACP session, wrapping an ExtensionHost instance.
* Handles message translation, event streaming, and permission requests.
*
* Commands are executed internally by the extension (like the reference
* implementations gemini-cli and opencode), not through ACP terminals.
*/
import * as fs from "node:fs"
import * as path from "node:path"
import * as acp from "@agentclientprotocol/sdk"
import type { ClineMessage, ClineAsk, ClineSay } from "@roo-code/types"
import { type ExtensionHostOptions, ExtensionHost } from "@/agent/extension-host.js"
import type { WaitingForInputEvent, TaskCompletedEvent } from "@/agent/events.js"
import {
translateToAcpUpdate,
isPermissionAsk,
isCompletionAsk,
extractPromptText,
extractPromptImages,
buildToolCallFromMessage,
} from "./translator.js"
import { acpLog } from "./logger.js"
import { DeltaTracker } from "./delta-tracker.js"
import { UpdateBuffer } from "./update-buffer.js"
// =============================================================================
// Streaming Configuration
// =============================================================================
/**
* Configuration for streaming content types.
* Defines which message types should be delta-streamed and how.
*/
interface StreamConfig {
/** ACP update type to use */
updateType: "agent_message_chunk" | "agent_thought_chunk"
/** Optional transform to apply to the text before delta tracking */
textTransform?: (text: string) => string
}
/**
* Declarative configuration for which `say` types should be delta-streamed.
* Any say type not listed here will fall through to the translator for
* non-streaming handling.
*
* To add a new streaming type, simply add it to this map.
*/
const DELTA_STREAM_CONFIG: Partial<Record<ClineSay, StreamConfig>> = {
// Regular text messages from the agent
text: { updateType: "agent_message_chunk" },
// Command output (terminal results, etc.)
command_output: { updateType: "agent_message_chunk" },
// Final completion summary
completion_result: { updateType: "agent_message_chunk" },
// Agent's reasoning/thinking
reasoning: { updateType: "agent_thought_chunk" },
// Error messages (prefixed with "Error: ")
error: {
updateType: "agent_message_chunk",
textTransform: (text) => `Error: ${text}`,
},
}
// =============================================================================
// Types
// =============================================================================
export interface AcpSessionOptions {
/** Path to the extension bundle */
extensionPath: string
/** API provider */
provider: string
/** API key (optional, may come from environment) */
apiKey?: string
/** Model to use */
model: string
/** Initial mode */
mode: string
}
// =============================================================================
// AcpSession Class
// =============================================================================
/**
* AcpSession wraps an ExtensionHost instance and bridges it to the ACP protocol.
*
* Each ACP session creates its own ExtensionHost, which loads the extension
* in a sandboxed environment. The session translates events from the
* ExtensionClient to ACP session updates and handles permission requests.
*/
export class AcpSession {
private pendingPrompt: AbortController | null = null
private promptResolve: ((response: acp.PromptResponse) => void) | null = null
private isProcessingPrompt = false
/** Delta tracker for streaming content - ensures only new text is sent */
private readonly deltaTracker = new DeltaTracker()
/** Update buffer for batching session updates to reduce message frequency */
private readonly updateBuffer: UpdateBuffer
/**
* The current prompt text - used to filter out user message echo.
* When the extension receives a task, it often sends a `text` message
* containing the user's input, which we should NOT echo back to ACP
* since the client already displays the user's message.
*/
private currentPromptText: string | null = null
/**
* Track pending command tool calls to send proper status updates.
* Maps tool call ID to command info for the "Run Command" UI.
*/
private pendingCommandCalls: Map<string, { toolCallId: string; command: string; ts: number }> = new Map()
/** Workspace path for resolving relative file paths */
private readonly workspacePath: string
private constructor(
private readonly sessionId: string,
private readonly extensionHost: ExtensionHost,
private readonly connection: acp.AgentSideConnection,
workspacePath: string,
) {
this.workspacePath = workspacePath
// Initialize update buffer with the actual send function
// Uses defaults: 200 chars min buffer, 500ms delay
this.updateBuffer = new UpdateBuffer((update) => this.sendUpdateDirect(update))
}
// ===========================================================================
// Factory Method
// ===========================================================================
/**
* Create a new AcpSession.
*
* This initializes an ExtensionHost for the given working directory
* and sets up event handlers to stream updates to the ACP client.
*/
static async create(
sessionId: string,
cwd: string,
connection: acp.AgentSideConnection,
_clientCapabilities: acp.ClientCapabilities | undefined,
options: AcpSessionOptions,
): Promise<AcpSession> {
acpLog.info("Session", `Creating session ${sessionId} in ${cwd}`)
// Create ExtensionHost with ACP-specific configuration
const hostOptions: ExtensionHostOptions = {
mode: options.mode,
user: null,
provider: options.provider as ExtensionHostOptions["provider"],
apiKey: options.apiKey,
model: options.model,
workspacePath: cwd,
extensionPath: options.extensionPath,
// ACP mode: disable direct output, we stream through ACP.
disableOutput: true,
// Don't persist state - ACP clients manage their own sessions.
ephemeral: true,
}
acpLog.debug("Session", "Creating ExtensionHost", hostOptions)
const extensionHost = new ExtensionHost(hostOptions)
await extensionHost.activate()
acpLog.info("Session", `ExtensionHost activated for session ${sessionId}`)
const session = new AcpSession(sessionId, extensionHost, connection, cwd)
session.setupEventHandlers()
return session
}
// ===========================================================================
// Event Handlers
// ===========================================================================
/**
* Set up event handlers to translate ExtensionClient events to ACP updates.
*/
private setupEventHandlers(): void {
const client = this.extensionHost.client
// Handle new messages
client.on("message", (msg: ClineMessage) => {
this.handleMessage(msg)
})
// Handle message updates (partial -> complete)
client.on("messageUpdated", (msg: ClineMessage) => {
this.handleMessage(msg)
})
// Handle permission requests (tool calls, commands, etc.)
client.on("waitingForInput", (event: WaitingForInputEvent) => {
void this.handleWaitingForInput(event)
})
// Handle task completion
client.on("taskCompleted", (event: TaskCompletedEvent) => {
this.handleTaskCompleted(event)
})
}
/**
* Handle an incoming message from the extension.
*
* Uses the declarative DELTA_STREAM_CONFIG to automatically determine
* which message types should be delta-streamed and how.
*/
private handleMessage(message: ClineMessage): void {
acpLog.debug(
"Session",
`Message received: type=${message.type}, say=${message.say}, ask=${message.ask}, ts=${message.ts}`,
)
// Check if this is a streaming message type
if (message.type === "say" && message.text && message.say) {
// Handle command_output specially for the "Run Command" UI
if (message.say === "command_output") {
this.handleCommandOutput(message)
return
}
const config = DELTA_STREAM_CONFIG[message.say]
if (config) {
// Filter out user message echo: when the extension starts a task,
// it often sends a `text` message with the user's input. Since the
// ACP client already displays the user's message, we should skip this.
if (message.say === "text" && this.isUserEcho(message.text)) {
acpLog.debug("Session", `Skipping user echo (${message.text.length} chars)`)
return
}
// Apply text transform if configured (e.g., "Error: " prefix)
const textToSend = config.textTransform ? config.textTransform(message.text) : message.text
// Get delta using the tracker (handles all bookkeeping automatically)
const delta = this.deltaTracker.getDelta(message.ts, textToSend)
if (delta) {
acpLog.debug("Session", `Sending ${message.say} delta: ${delta.length} chars (msg ${message.ts})`)
void this.sendUpdate({
sessionUpdate: config.updateType,
content: { type: "text", text: delta },
})
}
return
}
}
// For non-streaming message types, use the translator
const update = translateToAcpUpdate(message)
if (update) {
acpLog.notification("sessionUpdate", {
sessionId: this.sessionId,
updateKind: (update as { sessionUpdate?: string }).sessionUpdate,
})
void this.sendUpdate(update)
}
}
/**
* Handle command_output messages and update the corresponding tool call.
* This provides the "Run Command" UI with live output in Zed.
* Also streams output as agent_message_chunk for visibility in the main chat.
*/
private handleCommandOutput(message: ClineMessage): void {
const output = message.text || ""
const isPartial = message.partial === true
acpLog.info("Session", `handleCommandOutput: partial=${message.partial}, text length=${output.length}`)
acpLog.info("Session", `Pending command calls: ${this.pendingCommandCalls.size}`)
// Always stream command output as agent message for visibility in chat
const delta = this.deltaTracker.getDelta(message.ts, output)
if (delta) {
acpLog.info("Session", `Streaming command output as agent message: ${delta.length} chars`)
void this.sendUpdate({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: delta },
})
}
// Also update the tool call UI if we have a pending command
const pendingCall = this.findMostRecentPendingCommand()
if (pendingCall) {
acpLog.info("Session", `Found pending call: ${pendingCall.toolCallId}, isPartial=${isPartial}`)
if (isPartial) {
// Still running - send update with current output
void this.sendUpdate({
sessionUpdate: "tool_call_update",
toolCallId: pendingCall.toolCallId,
status: "in_progress",
content: [
{
type: "content",
content: { type: "text", text: output },
},
],
})
} else {
// Command completed - send final update and remove from pending
void this.sendUpdate({
sessionUpdate: "tool_call_update",
toolCallId: pendingCall.toolCallId,
status: "completed",
content: [
{
type: "content",
content: { type: "text", text: output },
},
],
rawOutput: { output },
})
this.pendingCommandCalls.delete(pendingCall.toolCallId)
acpLog.info("Session", `Command completed: ${pendingCall.toolCallId}`)
}
}
}
/**
* Find the most recent pending command call.
*/
private findMostRecentPendingCommand(): { toolCallId: string; command: string; ts: number } | undefined {
let pendingCall: { toolCallId: string; command: string; ts: number } | undefined
for (const [, call] of this.pendingCommandCalls) {
if (!pendingCall || call.ts > pendingCall.ts) {
pendingCall = call
}
}
return pendingCall
}
/**
* Reset delta tracking and buffer for a new prompt.
*/
private resetForNewPrompt(): void {
this.deltaTracker.reset()
this.updateBuffer.reset()
}
/**
* Handle waiting for input events (permission requests).
*/
private async handleWaitingForInput(event: WaitingForInputEvent): Promise<void> {
const { ask, message } = event
const askType = ask as ClineAsk
acpLog.debug("Session", `Waiting for input: ask=${askType}`)
// Handle permission-required asks
if (isPermissionAsk(askType)) {
acpLog.info("Session", `Permission request: ${askType}`)
await this.handlePermissionRequest(message, askType)
return
}
// Handle completion asks
if (isCompletionAsk(askType)) {
acpLog.debug("Session", "Completion ask - handled by taskCompleted event")
// Completion is handled by taskCompleted event
return
}
// Handle followup questions - auto-continue for now
// In a more sophisticated implementation, these could be surfaced
// to the ACP client for user input
if (askType === "followup") {
acpLog.debug("Session", "Auto-responding to followup")
this.extensionHost.client.respond("")
return
}
// Handle resume_task - auto-resume
if (askType === "resume_task") {
acpLog.debug("Session", "Auto-approving resume_task")
this.extensionHost.client.approve()
return
}
// Handle API failures - auto-retry for now
if (askType === "api_req_failed") {
acpLog.warn("Session", "API request failed, auto-retrying")
this.extensionHost.client.approve()
return
}
// Default: approve and continue
acpLog.debug("Session", `Auto-approving unknown ask type: ${askType}`)
this.extensionHost.client.approve()
}
/**
* Handle a permission request for a tool call.
*
* Auto-approves all tool calls without prompting the user. This allows
* the agent to work autonomously. Tool calls are still reported to the
* client for visibility via tool_call notifications.
*
* For commands, tracks the call to enable the "Run Command" UI with output.
* For other tools (search, read, etc.), the results are already available
* in the message, so we send both the tool_call and tool_call_update immediately.
*/
private handlePermissionRequest(message: ClineMessage, ask: ClineAsk): void {
const toolCall = buildToolCallFromMessage(message, this.workspacePath)
const isCommand = ask === "command"
// For commands, ensure kind is "execute" for the "Run Command" UI
const kind = isCommand ? "execute" : toolCall.kind
acpLog.info("Session", `Auto-approving tool: ${toolCall.title}, ask=${ask}, isCommand=${isCommand}`)
acpLog.info("Session", `Tool call details: id=${toolCall.toolCallId}, kind=${kind}, title=${toolCall.title}`)
acpLog.info("Session", `Tool call rawInput: ${JSON.stringify(toolCall.rawInput)}`)
// Build the full update with corrected kind for commands
const initialUpdate = {
sessionUpdate: "tool_call" as const,
...toolCall,
kind,
status: "in_progress" as const,
}
acpLog.info("Session", `Sending tool_call update: ${JSON.stringify(initialUpdate)}`)
// Notify client about the tool call with in_progress status
void this.sendUpdate(initialUpdate)
// For commands, track the call for the "Run Command" UI
// (completion will come via handleCommandOutput)
if (isCommand) {
this.pendingCommandCalls.set(toolCall.toolCallId, {
toolCallId: toolCall.toolCallId,
command: message.text || "",
ts: message.ts,
})
acpLog.info("Session", `Tracking command: ${toolCall.toolCallId}`)
} else {
// For non-command tools (search, read, etc.), the results are already
// available in the message. Send completion update immediately.
const rawInput = toolCall.rawInput as Record<string, unknown>
// Build completion update
const completionUpdate: acp.SessionNotification["update"] = {
sessionUpdate: "tool_call_update",
toolCallId: toolCall.toolCallId,
status: "completed",
rawOutput: rawInput,
}
// For edit operations with diff content, use the pre-parsed diff from toolCall
if (kind === "edit" && toolCall.content && toolCall.content.length > 0) {
acpLog.info("Session", `Edit tool with ${toolCall.content.length} content items (diffs)`)
completionUpdate.content = toolCall.content
} else {
// For search, read, etc. - extract and format text content
const rawContent = this.extractContentFromRawInput(rawInput)
acpLog.info("Session", `Non-edit tool content: ${rawContent ? `${rawContent.length} chars` : "none"}`)
if (rawContent) {
const formattedContent = this.formatToolResultContent(kind ?? "other", rawContent)
completionUpdate.content = [
{
type: "content",
content: { type: "text", text: formattedContent },
},
]
}
}
acpLog.info("Session", `Sending tool_call_update (completed): ${toolCall.toolCallId}`)
void this.sendUpdate(completionUpdate)
}
// Auto-approve the tool call
this.extensionHost.client.approve()
}
/**
* Maximum number of lines to show in read operation results.
* Files longer than this will be truncated with a "..." indicator.
*/
private static readonly MAX_READ_LINES = 100
/**
* Format tool result content for cleaner display in the UI.
*
* - For search tools: formats verbose results into a clean file list with summary
* - For read tools: truncates long file contents
* - Both search and read results are wrapped in code blocks for better rendering
* - For other tools: returns the content as-is
*/
private formatToolResultContent(kind: string, content: string): string {
switch (kind) {
case "search":
return this.wrapInCodeBlock(this.formatSearchResults(content))
case "read":
return this.wrapInCodeBlock(this.formatReadResults(content))
default:
return content
}
}
/**
* Extract content from rawInput.
*
* For readFile tools, the "content" field contains the file PATH (not contents),
* so we need to read the file ourselves.
*
* For other tools, try common field names for content.
*/
private extractContentFromRawInput(rawInput: Record<string, unknown>): string | undefined {
const toolName = (rawInput.tool as string | undefined)?.toLowerCase() || ""
// For readFile tools, read the actual file content
if (toolName === "readfile" || toolName === "read_file") {
return this.readFileContent(rawInput)
}
// For other tools, try common field names
const contentFields = ["content", "text", "result", "output", "fileContent", "data"]
for (const field of contentFields) {
const value = rawInput[field]
if (typeof value === "string" && value.length > 0) {
return value
}
}
return undefined
}
/**
* Read file content for readFile tool operations.
* The rawInput.content field contains the absolute path, not the file contents.
*/
private readFileContent(rawInput: Record<string, unknown>): string | undefined {
// The "content" field in readFile contains the absolute path
const filePath = rawInput.content as string | undefined
const relativePath = rawInput.path as string | undefined
// Try absolute path first, then relative path
const pathToRead = filePath || (relativePath ? path.resolve(this.workspacePath, relativePath) : undefined)
if (!pathToRead) {
acpLog.warn("Session", "readFile tool has no path")
return undefined
}
try {
const content = fs.readFileSync(pathToRead, "utf-8")
acpLog.info("Session", `Read file content: ${content.length} chars from ${pathToRead}`)
return content
} catch (error) {
acpLog.error("Session", `Failed to read file ${pathToRead}: ${error}`)
return `Error reading file: ${error}`
}
}
/**
* Wrap content in markdown code block for better rendering.
*/
private wrapInCodeBlock(content: string): string {
return "```\n" + content + "\n```"
}
/**
* Format read results by truncating long file contents.
*/
private formatReadResults(content: string): string {
const lines = content.split("\n")
if (lines.length <= AcpSession.MAX_READ_LINES) {
return content
}
// Truncate and add indicator
const truncated = lines.slice(0, AcpSession.MAX_READ_LINES).join("\n")
const remaining = lines.length - AcpSession.MAX_READ_LINES
return `${truncated}\n\n... (${remaining} more lines)`
}
/**
* Format search results into a clean summary with file list.
*
* Input format:
* ```
* Found 112 results.
*
* # src/acp/__tests__/agent.test.ts
* 9 |
* 10 | // Mock the auth module
* ...
*
* # README.md
* 105 |
* ...
* ```
*
* Output format:
* ```
* Found 112 results in 20 files:
* src/acp/__tests__/agent.test.ts
* README.md
* ...
* ```
*/
private formatSearchResults(content: string): string {
// Extract count from "Found X results" line
const countMatch = content.match(/Found (\d+) results?/)
const resultCount = countMatch?.[1] ? parseInt(countMatch[1], 10) : null
// Extract unique file paths from "# path/to/file" lines
const filePattern = /^# (.+)$/gm
const files = new Set<string>()
let match
while ((match = filePattern.exec(content)) !== null) {
if (match[1]) {
files.add(match[1])
}
}
// Sort files alphabetically
const fileList = Array.from(files).sort((a, b) => a.localeCompare(b))
// Build the formatted output
if (fileList.length === 0) {
// No files found, return original (might be "No results found" or similar)
return content.split("\n")[0] || content
}
const summary =
resultCount !== null
? `Found ${resultCount} result${resultCount !== 1 ? "s" : ""} in ${fileList.length} file${fileList.length !== 1 ? "s" : ""}`
: `Found matches in ${fileList.length} file${fileList.length !== 1 ? "s" : ""}`
// Use markdown list format (renders nicely in code blocks)
const formattedFiles = fileList.map((f) => `- ${f}`).join("\n")
return `${summary}\n\n${formattedFiles}`
}
/**
* Handle task completion.
*/
private handleTaskCompleted(event: TaskCompletedEvent): void {
acpLog.info("Session", `Task completed: success=${event.success}`)
// Flush any buffered updates before completing
void this.updateBuffer.flush().then(() => {
// Resolve the pending prompt
if (this.promptResolve) {
// StopReason only has: "end_turn" | "max_tokens" | "max_turn_requests" | "refusal" | "cancelled"
// Use "refusal" for failed tasks as it's the closest match
const stopReason: acp.StopReason = event.success ? "end_turn" : "refusal"
acpLog.debug("Session", `Resolving prompt with stopReason: ${stopReason}`)
this.promptResolve({ stopReason })
this.promptResolve = null
}
this.isProcessingPrompt = false
this.pendingPrompt = null
})
}
// ===========================================================================
// ACP Methods
// ===========================================================================
/**
* Process a prompt request from the ACP client.
*/
async prompt(params: acp.PromptRequest): Promise<acp.PromptResponse> {
acpLog.info("Session", `Processing prompt for session ${this.sessionId}`)
// Cancel any pending prompt
this.cancel()
// Reset delta tracking and buffer for new prompt
this.resetForNewPrompt()
this.pendingPrompt = new AbortController()
this.isProcessingPrompt = true
// Extract text and images from prompt
const text = extractPromptText(params.prompt)
const images = extractPromptImages(params.prompt)
// Store prompt text to filter out user echo
this.currentPromptText = text
acpLog.debug("Session", `Prompt text (${text.length} chars), images: ${images.length}`)
// Start the task
if (images.length > 0) {
acpLog.debug("Session", "Starting task with images")
this.extensionHost.sendToExtension({
type: "newTask",
text,
images,
})
} else {
acpLog.debug("Session", "Starting task (text only)")
this.extensionHost.sendToExtension({
type: "newTask",
text,
})
}
// Wait for completion
return new Promise((resolve) => {
this.promptResolve = resolve
// Handle abort
this.pendingPrompt?.signal.addEventListener("abort", () => {
acpLog.info("Session", "Prompt aborted")
resolve({ stopReason: "cancelled" })
this.promptResolve = null
})
})
}
/**
* Cancel the current prompt.
*/
cancel(): void {
if (this.pendingPrompt) {
acpLog.info("Session", "Cancelling pending prompt")
this.pendingPrompt.abort()
this.pendingPrompt = null
}
if (this.isProcessingPrompt) {
acpLog.info("Session", "Sending cancelTask to extension")
this.extensionHost.sendToExtension({ type: "cancelTask" })
this.isProcessingPrompt = false
}
}
/**
* Set the session mode.
*/
setMode(mode: string): void {
acpLog.info("Session", `Setting mode to: ${mode}`)
this.extensionHost.sendToExtension({
type: "updateSettings",
updatedSettings: { mode },
})
}
/**
* Dispose of the session and release resources.
*/
async dispose(): Promise<void> {
acpLog.info("Session", `Disposing session ${this.sessionId}`)
this.cancel()
// Flush any remaining buffered updates
await this.updateBuffer.flush()
await this.extensionHost.dispose()
acpLog.info("Session", `Session ${this.sessionId} disposed`)
}
// ===========================================================================
// Helpers
// ===========================================================================
/**
* Send an update to the ACP client through the buffer.
* Text chunks are batched, other updates are sent immediately.
*/
private async sendUpdate(update: acp.SessionNotification["update"]): Promise<void> {
await this.updateBuffer.queueUpdate(update)
}
/**
* Send an update directly to the ACP client (bypasses buffer).
* Used by the UpdateBuffer to actually send batched updates.
*/
private async sendUpdateDirect(update: acp.SessionNotification["update"]): Promise<void> {
try {
await this.connection.sessionUpdate({
sessionId: this.sessionId,
update,
})
} catch (error) {
console.error("[AcpSession] Failed to send update:", error)
}
}
/**
* Get the session ID.
*/
getSessionId(): string {
return this.sessionId
}
/**
* Check if a text message is an echo of the user's prompt.
*
* When the extension starts processing a task, it often sends a `text`
* message containing the user's input. Since the ACP client already
* displays the user's message, we should filter this out to avoid
* showing the message twice.
*
* Uses a fuzzy match to handle minor differences (whitespace, etc.).
*/
private isUserEcho(text: string): boolean {
if (!this.currentPromptText) {
return false
}
// Normalize both strings for comparison
const normalizedPrompt = this.currentPromptText.trim().toLowerCase()
const normalizedText = text.trim().toLowerCase()
// Exact match
if (normalizedText === normalizedPrompt) {
return true
}
// Check if text is contained in prompt (might be truncated)
if (normalizedPrompt.includes(normalizedText) && normalizedText.length > 10) {
return true
}
// Check if prompt is contained in text (might have wrapper)
if (normalizedText.includes(normalizedPrompt) && normalizedPrompt.length > 10) {
return true
}
return false
}
}

View file

@ -0,0 +1,322 @@
/**
* ACP Terminal Manager
*
* Manages ACP terminals for command execution. When the client supports terminals,
* this manager handles creating, tracking, and releasing terminals according to
* the ACP protocol specification.
*/
import * as acp from "@agentclientprotocol/sdk"
import { acpLog } from "./logger.js"
// =============================================================================
// Types
// =============================================================================
/**
* Information about an active terminal.
*/
export interface ActiveTerminal {
/** The terminal handle from ACP SDK */
handle: acp.TerminalHandle
/** The command being executed */
command: string
/** Working directory for the command */
cwd?: string
/** Timestamp when the terminal was created */
createdAt: number
/** Associated tool call ID (for embedding in tool calls) */
toolCallId?: string
}
/**
* Parsed command information extracted from a Roo Code command message.
*/
export interface ParsedCommand {
/** The full command string (may include shell operators) */
fullCommand: string
/** The executable/command name */
executable: string
/** Command arguments */
args: string[]
/** Working directory (if specified) */
cwd?: string
}
// =============================================================================
// Terminal Manager
// =============================================================================
/**
* Manages ACP terminals for command execution.
*
* This class handles the lifecycle of ACP terminals:
* 1. Creating terminals via terminal/create
* 2. Tracking active terminals
* 3. Releasing terminals when done
*
* According to the ACP spec, terminals should be:
* - Created with terminal/create
* - Embedded in tool calls using { type: "terminal", terminalId }
* - Released with terminal/release when done
*/
export class TerminalManager {
/** Map of terminal IDs to active terminal info */
private terminals: Map<string, ActiveTerminal> = new Map()
constructor(
private readonly sessionId: string,
private readonly connection: acp.AgentSideConnection,
) {}
// ===========================================================================
// Terminal Lifecycle
// ===========================================================================
/**
* Create a new terminal and execute a command.
*
* @param command - The command to execute
* @param cwd - Working directory for the command
* @param toolCallId - Optional tool call ID for embedding
* @returns The terminal handle and ID
*/
async createTerminal(
command: string,
cwd: string,
toolCallId?: string,
): Promise<{ handle: acp.TerminalHandle; terminalId: string }> {
acpLog.debug("TerminalManager", `Creating terminal for command: ${command}`)
const parsed = this.parseCommand(command)
try {
const handle = await this.connection.createTerminal({
sessionId: this.sessionId,
command: parsed.executable,
args: parsed.args,
cwd: parsed.cwd || cwd,
})
const terminalId = handle.id
acpLog.info("TerminalManager", `Terminal created: ${terminalId}`)
// Track the terminal
this.terminals.set(terminalId, {
handle,
command,
cwd: parsed.cwd || cwd,
createdAt: Date.now(),
toolCallId,
})
return { handle, terminalId }
} catch (error) {
acpLog.error("TerminalManager", `Failed to create terminal: ${error}`)
throw error
}
}
/**
* Get terminal output without waiting for exit.
*/
async getOutput(terminalId: string): Promise<acp.TerminalOutputResponse | null> {
const terminal = this.terminals.get(terminalId)
if (!terminal) {
acpLog.warn("TerminalManager", `Terminal not found: ${terminalId}`)
return null
}
try {
return await terminal.handle.currentOutput()
} catch (error) {
acpLog.error("TerminalManager", `Failed to get output for ${terminalId}: ${error}`)
return null
}
}
/**
* Wait for a terminal to exit and return the result.
*/
async waitForExit(
terminalId: string,
): Promise<{ exitCode: number | null; signal: string | null; output: string } | null> {
const terminal = this.terminals.get(terminalId)
if (!terminal) {
acpLog.warn("TerminalManager", `Terminal not found: ${terminalId}`)
return null
}
try {
acpLog.debug("TerminalManager", `Waiting for exit: ${terminalId}`)
// Wait for the command to complete
const exitStatus = await terminal.handle.waitForExit()
// Get the final output
const outputResponse = await terminal.handle.currentOutput()
acpLog.info("TerminalManager", `Terminal ${terminalId} exited: code=${exitStatus.exitCode}`)
return {
exitCode: exitStatus.exitCode ?? null,
signal: exitStatus.signal ?? null,
output: outputResponse.output,
}
} catch (error) {
acpLog.error("TerminalManager", `Failed to wait for ${terminalId}: ${error}`)
return null
}
}
/**
* Kill a running terminal command.
*/
async killTerminal(terminalId: string): Promise<boolean> {
const terminal = this.terminals.get(terminalId)
if (!terminal) {
acpLog.warn("TerminalManager", `Terminal not found: ${terminalId}`)
return false
}
try {
await terminal.handle.kill()
acpLog.info("TerminalManager", `Terminal killed: ${terminalId}`)
return true
} catch (error) {
acpLog.error("TerminalManager", `Failed to kill ${terminalId}: ${error}`)
return false
}
}
/**
* Release a terminal and free its resources.
* This MUST be called when done with a terminal.
*/
async releaseTerminal(terminalId: string): Promise<boolean> {
const terminal = this.terminals.get(terminalId)
if (!terminal) {
acpLog.warn("TerminalManager", `Terminal not found: ${terminalId}`)
return false
}
try {
await terminal.handle.release()
this.terminals.delete(terminalId)
acpLog.info("TerminalManager", `Terminal released: ${terminalId}`)
return true
} catch (error) {
acpLog.error("TerminalManager", `Failed to release ${terminalId}: ${error}`)
// Still remove from tracking even if release failed
this.terminals.delete(terminalId)
return false
}
}
/**
* Release all active terminals.
*/
async releaseAll(): Promise<void> {
acpLog.info("TerminalManager", `Releasing ${this.terminals.size} terminals`)
const releasePromises = Array.from(this.terminals.keys()).map((id) => this.releaseTerminal(id))
await Promise.all(releasePromises)
}
// ===========================================================================
// Query Methods
// ===========================================================================
/**
* Check if a terminal exists.
*/
hasTerminal(terminalId: string): boolean {
return this.terminals.has(terminalId)
}
/**
* Get information about a terminal.
*/
getTerminalInfo(terminalId: string): ActiveTerminal | undefined {
return this.terminals.get(terminalId)
}
/**
* Get all active terminal IDs.
*/
getActiveTerminalIds(): string[] {
return Array.from(this.terminals.keys())
}
/**
* Get the count of active terminals.
*/
get activeCount(): number {
return this.terminals.size
}
// ===========================================================================
// Helpers
// ===========================================================================
/**
* Parse a command string into executable and arguments.
*
* This handles common shell command patterns and extracts:
* - The executable (first word or path)
* - Arguments
* - Working directory changes (cd ... &&)
*/
parseCommand(command: string): ParsedCommand {
// Trim and normalize whitespace
const trimmed = command.trim()
// Check for cd command at the start (common pattern: cd /path && command)
const cdMatch = trimmed.match(/^cd\s+([^\s&]+)\s*&&\s*(.+)$/i)
if (cdMatch && cdMatch[1] && cdMatch[2]) {
const cwd = cdMatch[1]
const restCommand = cdMatch[2]
const parsed = this.parseSimpleCommand(restCommand)
return {
...parsed,
cwd,
}
}
return this.parseSimpleCommand(trimmed)
}
/**
* Parse a simple command (no cd prefix) into parts.
*/
private parseSimpleCommand(command: string): ParsedCommand {
// For shell commands with operators, we need to run through a shell
// Check for shell operators
const hasShellOperators = /[|&;<>]/.test(command)
if (hasShellOperators) {
// Run through shell to handle operators
const shell = process.platform === "win32" ? "cmd.exe" : "/bin/sh"
const shellArg = process.platform === "win32" ? "/c" : "-c"
return {
fullCommand: command,
executable: shell,
args: [shellArg, command],
}
}
// Simple command - split on whitespace
const parts = command.split(/\s+/).filter(Boolean)
const executable = parts[0] || command
const args = parts.slice(1)
return {
fullCommand: command,
executable,
args,
}
}
}

View file

@ -0,0 +1,666 @@
/**
* ACP Message Translator
*
* Translates between internal ClineMessage format and ACP protocol format.
* This is the bridge between Roo Code's message system and the ACP protocol.
*/
import * as path from "node:path"
import type * as acp from "@agentclientprotocol/sdk"
import type { ClineMessage, ClineAsk } from "@roo-code/types"
// =============================================================================
// Types
// =============================================================================
export interface ToolCallInfo {
id: string
name: string
title: string
params: Record<string, unknown>
locations: acp.ToolCallLocation[]
content?: acp.ToolCallContent[]
}
// =============================================================================
// Message to ACP Update Translation
// =============================================================================
/**
* Translate an internal ClineMessage to an ACP session update.
* Returns null if the message type should not be sent to ACP.
*/
export function translateToAcpUpdate(message: ClineMessage): acp.SessionNotification["update"] | null {
if (message.type === "say") {
switch (message.say) {
case "text":
// Agent text output
return {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: message.text || "" },
}
case "reasoning":
// Agent reasoning/thinking
return {
sessionUpdate: "agent_thought_chunk",
content: { type: "text", text: message.text || "" },
}
case "shell_integration_warning":
case "mcp_server_request_started":
case "mcp_server_response":
// Tool-related messages
return translateToolSayMessage(message)
case "user_feedback":
// User feedback doesn't need to be sent to ACP client
return null
case "error":
// Error messages
return {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: `Error: ${message.text || ""}` },
}
case "completion_result":
// Completion is handled at prompt level
return null
case "api_req_started":
case "api_req_finished":
case "api_req_retried":
case "api_req_retry_delayed":
case "api_req_deleted":
// API request lifecycle events - not sent to ACP
return null
case "command_output":
// Command execution - handled through tool_call
return null
default:
// Unknown message type
return null
}
}
// Ask messages are handled separately through permission flow
return null
}
/**
* Translate a tool say message to ACP format.
*/
function translateToolSayMessage(message: ClineMessage): acp.SessionNotification["update"] | null {
const toolInfo = parseToolFromMessage(message)
if (!toolInfo) {
return null
}
if (message.partial) {
// Tool in progress
return {
sessionUpdate: "tool_call",
toolCallId: toolInfo.id,
title: toolInfo.title,
kind: mapToolKind(toolInfo.name),
status: "in_progress" as const,
locations: toolInfo.locations,
rawInput: toolInfo.params,
}
} else {
// Tool completed
return {
sessionUpdate: "tool_call_update",
toolCallId: toolInfo.id,
status: "completed" as const,
content: [],
rawOutput: toolInfo.params,
}
}
}
// =============================================================================
// Tool Information Parsing
// =============================================================================
/**
* Parse tool information from a ClineMessage.
* @param message - The ClineMessage to parse
* @param workspacePath - Optional workspace path to resolve relative paths
*/
export function parseToolFromMessage(message: ClineMessage, workspacePath?: string): ToolCallInfo | null {
if (!message.text) {
return null
}
// Tool messages typically have JSON content describing the tool
try {
// Try to parse as JSON first
if (message.text.startsWith("{")) {
const parsed = JSON.parse(message.text) as Record<string, unknown>
const toolName = (parsed.tool as string) || "unknown"
const filePath = (parsed.path as string) || undefined
return {
id: `tool-${message.ts}`,
name: toolName,
title: generateToolTitle(toolName, filePath),
params: parsed,
locations: extractLocations(parsed, workspacePath),
content: extractToolContent(parsed, workspacePath),
}
}
} catch {
// Not JSON, try to extract tool info from text
}
// Extract tool name from text content
const toolMatch = message.text.match(/(?:Using|Executing|Running)\s+(\w+)/i)
const toolName = toolMatch?.[1] || "unknown"
return {
id: `tool-${message.ts}`,
name: toolName,
title: message.text.slice(0, 100),
params: {},
locations: [],
}
}
/**
* Generate a human-readable title for a tool operation.
*/
function generateToolTitle(toolName: string, filePath?: string): string {
const fileName = filePath ? path.basename(filePath) : undefined
// Map tool names to human-readable titles
const toolTitles: Record<string, string> = {
// File creation
newFileCreated: fileName ? `Creating ${fileName}` : "Creating file",
write_to_file: fileName ? `Writing ${fileName}` : "Writing file",
create_file: fileName ? `Creating ${fileName}` : "Creating file",
// File editing
editedExistingFile: fileName ? `Edit ${fileName}` : "Edit file",
apply_diff: fileName ? `Edit ${fileName}` : "Edit file",
appliedDiff: fileName ? `Edit ${fileName}` : "Edit file",
modify_file: fileName ? `Edit ${fileName}` : "Edit file",
// File reading
read_file: fileName ? `Read ${fileName}` : "Read file",
readFile: fileName ? `Read ${fileName}` : "Read file",
// File listing
list_files: filePath ? `Listing files in ${filePath}` : "Listing files",
listFiles: filePath ? `Listing files in ${filePath}` : "Listing files",
// File search
search_files: "Searching files",
searchFiles: "Searching files",
// Command execution
execute_command: "Running command",
executeCommand: "Running command",
// Browser actions
browser_action: "Browser action",
browserAction: "Browser action",
}
return toolTitles[toolName] || (fileName ? `${toolName}: ${fileName}` : toolName)
}
/**
* Extract file locations from tool parameters.
* @param params - Tool parameters
* @param workspacePath - Optional workspace path to resolve relative paths
*/
function extractLocations(params: Record<string, unknown>, workspacePath?: string): acp.ToolCallLocation[] {
const locations: acp.ToolCallLocation[] = []
const toolName = (params.tool as string | undefined)?.toLowerCase() || ""
// For search tools, the 'path' parameter is a search scope directory, not a file being accessed.
// Don't include it in locations. Instead, try to extract file paths from search results.
if (isSearchTool(toolName)) {
// Try to extract file paths from search results content
const content = params.content as string | undefined
if (content) {
const fileLocations = extractFilePathsFromSearchResults(content, workspacePath)
return fileLocations
}
return []
}
// For list_files tools, the 'path' is a directory being listed, which is valid to include
// but we should mark it as a directory operation rather than a file access
if (isListFilesTool(toolName)) {
const dirPath = params.path as string | undefined
if (dirPath) {
const absolutePath = makeAbsolutePath(dirPath, workspacePath)
locations.push({ path: absolutePath })
}
return locations
}
// Check for common path parameters (for file operations)
const pathParams = ["path", "file", "filePath", "file_path"]
for (const param of pathParams) {
if (typeof params[param] === "string") {
const filePath = params[param] as string
const absolutePath = makeAbsolutePath(filePath, workspacePath)
locations.push({ path: absolutePath })
}
}
// Check for directory parameters separately (for directory operations)
const dirParams = ["directory", "dir"]
for (const param of dirParams) {
if (typeof params[param] === "string") {
const dirPath = params[param] as string
const absolutePath = makeAbsolutePath(dirPath, workspacePath)
locations.push({ path: absolutePath })
}
}
// Check for paths array
if (Array.isArray(params.paths)) {
for (const p of params.paths) {
if (typeof p === "string") {
const absolutePath = makeAbsolutePath(p, workspacePath)
locations.push({ path: absolutePath })
}
}
}
return locations
}
/**
* Check if a tool name is a search operation.
*/
function isSearchTool(toolName: string): boolean {
const searchTools = ["search_files", "searchfiles", "codebase_search", "codebasesearch", "grep", "ripgrep"]
return searchTools.includes(toolName) || toolName.includes("search")
}
/**
* Check if a tool name is a list files operation.
*/
function isListFilesTool(toolName: string): boolean {
const listTools = ["list_files", "listfiles", "listfilestoplevel", "listfilesrecursive"]
return listTools.includes(toolName) || toolName.includes("listfiles")
}
/**
* Extract file paths from search results content.
* Search results typically have format: "# path/to/file.ts" for each matched file
*/
function extractFilePathsFromSearchResults(content: string, workspacePath?: string): acp.ToolCallLocation[] {
const locations: acp.ToolCallLocation[] = []
const seenPaths = new Set<string>()
// Match file headers in search results (e.g., "# src/utils.ts" or "## path/to/file.js")
const fileHeaderPattern = /^#+\s+(.+?\.[a-zA-Z0-9]+)\s*$/gm
let match
while ((match = fileHeaderPattern.exec(content)) !== null) {
const filePath = match[1]!.trim()
// Skip if we've already seen this path or if it looks like a markdown header (not a file path)
if (seenPaths.has(filePath) || (!filePath.includes("/") && !filePath.includes("."))) {
continue
}
seenPaths.add(filePath)
const absolutePath = makeAbsolutePath(filePath, workspacePath)
locations.push({ path: absolutePath })
}
return locations
}
/**
* Extract tool content for ACP (diffs, text, etc.)
*/
function extractToolContent(
params: Record<string, unknown>,
workspacePath?: string,
): acp.ToolCallContent[] | undefined {
const content: acp.ToolCallContent[] = []
// Check if this is a file operation with diff content
const filePath = params.path as string | undefined
const diffContent = params.content as string | undefined
const toolName = params.tool as string | undefined
if (filePath && diffContent && isFileEditTool(toolName || "")) {
const absolutePath = makeAbsolutePath(filePath, workspacePath)
const parsedDiff = parseUnifiedDiff(diffContent)
if (parsedDiff) {
// Use ACP diff format
content.push({
type: "diff",
path: absolutePath,
oldText: parsedDiff.oldText,
newText: parsedDiff.newText,
} as acp.ToolCallContent)
}
}
return content.length > 0 ? content : undefined
}
/**
* Parse a unified diff string to extract old and new text.
*/
function parseUnifiedDiff(diffString: string): { oldText: string | null; newText: string } | null {
if (!diffString) {
return null
}
// Check if this is a unified diff format
if (!diffString.includes("@@") && !diffString.includes("---") && !diffString.includes("+++")) {
// Not a diff, treat as raw content
return { oldText: null, newText: diffString }
}
const lines = diffString.split("\n")
const oldLines: string[] = []
const newLines: string[] = []
let inHunk = false
let isNewFile = false
for (const line of lines) {
// Check for new file indicator
if (line.startsWith("--- /dev/null")) {
isNewFile = true
continue
}
// Skip diff headers
if (line.startsWith("===") || line.startsWith("---") || line.startsWith("+++") || line.startsWith("@@")) {
if (line.startsWith("@@")) {
inHunk = true
}
continue
}
if (!inHunk) {
continue
}
if (line.startsWith("-")) {
// Removed line (old content)
oldLines.push(line.slice(1))
} else if (line.startsWith("+")) {
// Added line (new content)
newLines.push(line.slice(1))
} else if (line.startsWith(" ") || line === "") {
// Context line (in both old and new)
const contextLine = line.startsWith(" ") ? line.slice(1) : line
oldLines.push(contextLine)
newLines.push(contextLine)
}
}
return {
oldText: isNewFile ? null : oldLines.join("\n") || null,
newText: newLines.join("\n"),
}
}
/**
* Check if a tool name represents a file edit operation.
*/
function isFileEditTool(toolName: string): boolean {
const editTools = [
"newFileCreated",
"editedExistingFile",
"write_to_file",
"apply_diff",
"create_file",
"modify_file",
]
return editTools.includes(toolName)
}
/**
* Make a file path absolute by resolving it against the workspace path.
*/
function makeAbsolutePath(filePath: string, workspacePath?: string): string {
if (path.isAbsolute(filePath)) {
return filePath
}
if (workspacePath) {
return path.resolve(workspacePath, filePath)
}
// Return as-is if no workspace path available
return filePath
}
// =============================================================================
// Tool Kind Mapping
// =============================================================================
/**
* Map internal tool names to ACP tool kinds.
*
* ACP defines these tool kinds for special UI treatment:
* - read: Reading files or data
* - edit: Modifying files or content
* - delete: Removing files or data
* - move: Moving or renaming files
* - search: Searching for information
* - execute: Running commands or code
* - think: Internal reasoning or planning
* - fetch: Retrieving external data
* - switch_mode: Switching the current session mode
* - other: Other tool types (default)
*/
export function mapToolKind(toolName: string): acp.ToolKind {
const lowerName = toolName.toLowerCase()
// Switch mode operations (check first as it's specific)
if (lowerName.includes("switch_mode") || lowerName.includes("switchmode") || lowerName.includes("set_mode")) {
return "switch_mode"
}
// Think/reasoning operations
if (
lowerName.includes("think") ||
lowerName.includes("reason") ||
lowerName.includes("plan") ||
lowerName.includes("analyze")
) {
return "think"
}
// Search operations (check before read since "search" was previously mapped to read)
if (lowerName.includes("search") || lowerName.includes("find") || lowerName.includes("grep")) {
return "search"
}
// Delete operations (check BEFORE move since "remove" contains "move" substring)
if (lowerName.includes("delete") || lowerName.includes("remove")) {
return "delete"
}
// Move/rename operations
if (lowerName.includes("move") || lowerName.includes("rename")) {
return "move"
}
// Edit operations
if (
lowerName.includes("write") ||
lowerName.includes("edit") ||
lowerName.includes("modify") ||
lowerName.includes("create") ||
lowerName.includes("diff") ||
lowerName.includes("apply")
) {
return "edit"
}
// Fetch operations (check BEFORE read since "http_get" contains "get" substring)
if (
lowerName.includes("browser") ||
lowerName.includes("web") ||
lowerName.includes("fetch") ||
lowerName.includes("http") ||
lowerName.includes("url")
) {
return "fetch"
}
// Read operations
if (
lowerName.includes("read") ||
lowerName.includes("list") ||
lowerName.includes("inspect") ||
lowerName.includes("get")
) {
return "read"
}
// Command/execute operations
if (lowerName.includes("command") || lowerName.includes("execute") || lowerName.includes("run")) {
return "execute"
}
// Default to other
return "other"
}
// =============================================================================
// Ask Type Helpers
// =============================================================================
/**
* Ask types that require permission from the user.
*/
const PERMISSION_ASKS: ClineAsk[] = ["tool", "command", "browser_action_launch", "use_mcp_server"]
/**
* Check if an ask type requires permission.
*/
export function isPermissionAsk(ask: ClineAsk): boolean {
return PERMISSION_ASKS.includes(ask)
}
/**
* Ask types that indicate task completion.
*/
const COMPLETION_ASKS: ClineAsk[] = ["completion_result", "api_req_failed", "mistake_limit_reached"]
/**
* Check if an ask type indicates task completion.
*/
export function isCompletionAsk(ask: ClineAsk): boolean {
return COMPLETION_ASKS.includes(ask)
}
// =============================================================================
// Prompt Content Translation
// =============================================================================
/**
* Extract text content from ACP prompt content blocks.
*/
export function extractPromptText(prompt: acp.ContentBlock[]): string {
const textParts: string[] = []
for (const block of prompt) {
switch (block.type) {
case "text":
textParts.push(block.text)
break
case "resource_link":
// Reference to a file or resource
textParts.push(`@${block.uri}`)
break
case "resource":
// Embedded resource content
if (block.resource && "text" in block.resource) {
textParts.push(`Content from ${block.resource.uri}:\n${block.resource.text}`)
}
break
case "image":
case "audio":
// Binary content - note it but don't include
textParts.push(`[${block.type} content]`)
break
}
}
return textParts.join("\n")
}
/**
* Extract images from ACP prompt content blocks.
*/
export function extractPromptImages(prompt: acp.ContentBlock[]): string[] {
const images: string[] = []
for (const block of prompt) {
if (block.type === "image" && block.data) {
images.push(block.data)
}
}
return images
}
// =============================================================================
// Permission Options
// =============================================================================
/**
* Create standard permission options for a tool call.
*/
export function createPermissionOptions(ask: ClineAsk): acp.PermissionOption[] {
const baseOptions: acp.PermissionOption[] = [
{ optionId: "allow", name: "Allow", kind: "allow_once" },
{ optionId: "reject", name: "Reject", kind: "reject_once" },
]
// Add "allow always" option for certain ask types
if (ask === "tool" || ask === "command") {
return [{ optionId: "allow_always", name: "Always Allow", kind: "allow_always" }, ...baseOptions]
}
return baseOptions
}
// =============================================================================
// Tool Call Building
// =============================================================================
/**
* Build an ACP ToolCall from a ClineMessage.
* @param message - The ClineMessage to parse
* @param workspacePath - Optional workspace path to resolve relative paths
*/
export function buildToolCallFromMessage(message: ClineMessage, workspacePath?: string): acp.ToolCall {
const toolInfo = parseToolFromMessage(message, workspacePath)
const toolCall: acp.ToolCall = {
toolCallId: toolInfo?.id || `tool-${message.ts}`,
title: toolInfo?.title || message.text?.slice(0, 100) || "Tool execution",
kind: toolInfo ? mapToolKind(toolInfo.name) : "other",
status: "pending",
locations: toolInfo?.locations || [],
rawInput: toolInfo?.params || {},
}
// Include content if available (e.g., diffs for file operations)
if (toolInfo?.content && toolInfo.content.length > 0) {
toolCall.content = toolInfo.content
}
return toolCall
}

View file

@ -0,0 +1,212 @@
/**
* ACP Update Buffer
*
* Intelligently buffers session updates to reduce message frequency.
* Text chunks are batched based on size and time thresholds, while
* tool calls and other updates are passed through immediately.
*/
import type * as acp from "@agentclientprotocol/sdk"
import { acpLog } from "./logger.js"
// =============================================================================
// Types (exported)
// =============================================================================
export type { UpdateBufferOptions }
interface UpdateBufferOptions {
/** Minimum characters to buffer before flushing (default: 200) */
minBufferSize?: number
/** Maximum time in ms before flushing (default: 500) */
flushDelayMs?: number
}
type TextChunkUpdate = {
sessionUpdate: "agent_message_chunk" | "agent_thought_chunk"
content: { type: "text"; text: string }
}
type SessionUpdate = acp.SessionNotification["update"]
// Type guard for text chunk updates
function isTextChunkUpdate(update: SessionUpdate): update is TextChunkUpdate {
const u = update as TextChunkUpdate
return (
(u.sessionUpdate === "agent_message_chunk" || u.sessionUpdate === "agent_thought_chunk") &&
u.content?.type === "text"
)
}
// =============================================================================
// UpdateBuffer Class
// =============================================================================
/**
* Buffers session updates to reduce the number of messages sent to the client.
*
* Text chunks (agent_message_chunk, agent_thought_chunk) are batched together
* and flushed when either:
* - The buffer size reaches minBufferSize
* - The flush delay timer expires
* - flush() is called manually
*
* Tool calls and other updates are passed through immediately.
*/
export class UpdateBuffer {
private readonly minBufferSize: number
private readonly flushDelayMs: number
/** Buffered text for agent_message_chunk */
private messageBuffer = ""
/** Buffered text for agent_thought_chunk */
private thoughtBuffer = ""
/** Timer for delayed flush */
private flushTimer: ReturnType<typeof setTimeout> | null = null
/** Callback to send updates */
private readonly sendUpdate: (update: SessionUpdate) => Promise<void>
/** Track if we have pending buffered content */
private hasPendingContent = false
constructor(sendUpdate: (update: SessionUpdate) => Promise<void>, options: UpdateBufferOptions = {}) {
this.minBufferSize = options.minBufferSize ?? 200
this.flushDelayMs = options.flushDelayMs ?? 500
this.sendUpdate = sendUpdate
}
// ===========================================================================
// Public API
// ===========================================================================
/**
* Queue an update for sending.
*
* Text chunks are buffered and batched. Other updates are sent immediately.
*/
async queueUpdate(update: SessionUpdate): Promise<void> {
if (isTextChunkUpdate(update)) {
this.bufferTextChunk(update)
} else {
// Flush any pending text before sending non-text update
// This ensures correct ordering
await this.flush()
await this.sendUpdate(update)
}
}
/**
* Flush all pending buffered content.
*
* Should be called when the session ends or when immediate delivery is needed.
*/
async flush(): Promise<void> {
this.clearFlushTimer()
if (!this.hasPendingContent) {
return
}
acpLog.debug(
"UpdateBuffer",
`Flushing buffers: message=${this.messageBuffer.length}, thought=${this.thoughtBuffer.length}`,
)
// Send buffered message content
if (this.messageBuffer.length > 0) {
await this.sendUpdate({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: this.messageBuffer },
})
this.messageBuffer = ""
}
// Send buffered thought content
if (this.thoughtBuffer.length > 0) {
await this.sendUpdate({
sessionUpdate: "agent_thought_chunk",
content: { type: "text", text: this.thoughtBuffer },
})
this.thoughtBuffer = ""
}
this.hasPendingContent = false
}
/**
* Reset the buffer state.
*
* Should be called when starting a new prompt.
*/
reset(): void {
this.clearFlushTimer()
this.messageBuffer = ""
this.thoughtBuffer = ""
this.hasPendingContent = false
acpLog.debug("UpdateBuffer", "Buffer reset")
}
/**
* Get current buffer sizes for debugging/testing.
*/
getBufferSizes(): { message: number; thought: number } {
return {
message: this.messageBuffer.length,
thought: this.thoughtBuffer.length,
}
}
// ===========================================================================
// Private Methods
// ===========================================================================
/**
* Buffer a text chunk update.
*/
private bufferTextChunk(update: TextChunkUpdate): void {
const text = update.content.text
if (update.sessionUpdate === "agent_message_chunk") {
this.messageBuffer += text
} else {
this.thoughtBuffer += text
}
this.hasPendingContent = true
// Check if we should flush based on size
const totalSize = this.messageBuffer.length + this.thoughtBuffer.length
if (totalSize >= this.minBufferSize) {
acpLog.debug("UpdateBuffer", `Size threshold reached (${totalSize} >= ${this.minBufferSize}), flushing`)
void this.flush()
return
}
// Schedule delayed flush if not already scheduled
this.scheduleFlush()
}
/**
* Schedule a delayed flush.
*/
private scheduleFlush(): void {
if (this.flushTimer !== null) {
return // Already scheduled
}
this.flushTimer = setTimeout(() => {
this.flushTimer = null
acpLog.debug("UpdateBuffer", "Flush timer expired")
void this.flush()
}, this.flushDelayMs)
}
/**
* Clear the flush timer.
*/
private clearFlushTimer(): void {
if (this.flushTimer !== null) {
clearTimeout(this.flushTimer)
this.flushTimer = null
}
}
}

View file

@ -0,0 +1,137 @@
/**
* ACP Command
*
* Starts the Roo Code CLI in ACP server mode, allowing ACP-compatible clients
* like Zed to use Roo Code as their AI coding assistant.
*
* Usage:
* roo acp [options]
*
* The ACP server communicates over stdin/stdout using the ACP protocol
* (JSON-RPC over newline-delimited JSON).
*/
import { Readable, Writable } from "node:stream"
import path from "node:path"
import { fileURLToPath } from "node:url"
import * as acpSdk from "@agentclientprotocol/sdk"
import { type RooCodeAgentOptions, RooCodeAgent, acpLog } from "@/acp/index.js"
import { DEFAULT_FLAGS } from "@/types/constants.js"
import { getDefaultExtensionPath } from "@/lib/utils/extension.js"
// =============================================================================
// Types
// =============================================================================
export interface AcpCommandOptions {
/** Path to the extension bundle directory */
extension?: string
/** API provider (anthropic, openai, openrouter, etc.) */
provider?: string
/** Model to use */
model?: string
/** Initial mode (code, architect, ask, debug) */
mode?: string
/** API key */
apiKey?: string
}
// =============================================================================
// ACP Server
// =============================================================================
/**
* Run the ACP server.
*
* This sets up the ACP connection using stdin/stdout and creates a RooCodeAgent
* to handle incoming requests.
*/
export async function runAcpServer(options: AcpCommandOptions): Promise<void> {
acpLog.info("Command", "Starting ACP server")
acpLog.debug("Command", "Options", options)
// Resolve extension path
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const extensionPath = options.extension || getDefaultExtensionPath(__dirname)
if (!extensionPath) {
acpLog.error("Command", "Extension path not found")
console.error("Error: Extension path not found. Use --extension to specify the path.")
process.exit(1)
}
acpLog.info("Command", `Extension path: ${extensionPath}`)
// Create agent options
const agentOptions: RooCodeAgentOptions = {
extensionPath,
provider: options.provider || DEFAULT_FLAGS.provider,
model: options.model || DEFAULT_FLAGS.model,
mode: options.mode || DEFAULT_FLAGS.mode,
apiKey: options.apiKey || process.env.OPENROUTER_API_KEY,
}
acpLog.debug("Command", "Agent options", {
extensionPath: agentOptions.extensionPath,
provider: agentOptions.provider,
model: agentOptions.model,
mode: agentOptions.mode,
hasApiKey: !!agentOptions.apiKey,
})
// Set up stdio streams for ACP communication
// Note: We write to stdout (agent -> client) and read from stdin (client -> agent)
const stdout = Writable.toWeb(process.stdout) as WritableStream<Uint8Array>
const stdin = Readable.toWeb(process.stdin) as ReadableStream<Uint8Array>
// Create the ACP stream
const stream = acpSdk.ndJsonStream(stdout, stdin)
acpLog.info("Command", "ACP stream created, waiting for connection")
// Create the agent connection
let agent: RooCodeAgent | null = null
const connection = new acpSdk.AgentSideConnection((conn: acpSdk.AgentSideConnection) => {
acpLog.info("Command", "Agent connection established")
agent = new RooCodeAgent(agentOptions, conn)
return agent
}, stream)
// Handle graceful shutdown
const cleanup = async () => {
acpLog.info("Command", "Received shutdown signal, cleaning up")
if (agent) {
await agent.dispose()
}
acpLog.info("Command", "Cleanup complete, exiting")
process.exit(0)
}
process.on("SIGINT", cleanup)
process.on("SIGTERM", cleanup)
// Wait for the connection to close
acpLog.info("Command", "Waiting for connection to close")
await connection.closed
acpLog.info("Command", "Connection closed")
}
// =============================================================================
// Command Action
// =============================================================================
/**
* Action handler for the `roo acp` command.
*/
export async function acp(options: AcpCommandOptions): Promise<void> {
try {
await runAcpServer(options)
} catch (error) {
// Log errors to file and stderr so they don't interfere with ACP protocol
acpLog.error("Command", "Fatal error", error)
console.error("[ACP] Fatal error:", error)
process.exit(1)
}
}

View file

@ -3,6 +3,7 @@ import { Command } from "commander"
import { DEFAULT_FLAGS } from "@/types/constants.js"
import { VERSION } from "@/lib/utils/version.js"
import { run, login, logout, status } from "@/commands/index.js"
import { acp } from "@/commands/acp/index.js"
const program = new Command()
@ -62,4 +63,14 @@ authCommand
process.exit(result.authenticated ? 0 : 1)
})
program
.command("acp")
.description("Start ACP server mode for integration with editors like Zed")
.option("-e, --extension <path>", "Path to the extension bundle directory")
.option("-p, --provider <provider>", "API provider (anthropic, openai, openrouter, etc.)", DEFAULT_FLAGS.provider)
.option("-m, --model <model>", "Model to use", DEFAULT_FLAGS.model)
.option("-M, --mode <mode>", "Initial mode (code, architect, ask, debug)", DEFAULT_FLAGS.mode)
.option("-k, --api-key <key>", "API key for the LLM provider")
.action(acp)
program.parse()

View file

@ -4,6 +4,7 @@ export const DEFAULT_FLAGS = {
mode: "code",
reasoningEffort: "medium" as const,
model: "anthropic/claude-opus-4.5",
provider: "openrouter",
}
export const REASONING_EFFORTS = [...reasoningEffortsExtended, "unspecified", "disabled"]

12
pnpm-lock.yaml generated
View file

@ -82,6 +82,9 @@ importers:
apps/cli:
dependencies:
'@agentclientprotocol/sdk':
specifier: ^0.12.0
version: 0.12.0(zod@3.25.76)
'@inkjs/ui':
specifier: ^2.0.0
version: 2.0.0(ink@6.6.0(@types/react@18.3.23)(react@19.2.3))
@ -1356,6 +1359,11 @@ packages:
'@adobe/css-tools@4.4.2':
resolution: {integrity: sha512-baYZExFpsdkBNuvGKTKWCwKH57HRZLVtycZS05WTQNVOiXVSeAki3nU35zlRbToeMW8aHlJfyS+1C4BOv27q0A==}
'@agentclientprotocol/sdk@0.12.0':
resolution: {integrity: sha512-V8uH/KK1t7utqyJmTA7y7DzKu6+jKFIXM+ZVouz8E55j8Ej2RV42rEvPKn3/PpBJlliI5crcGk1qQhZ7VwaepA==}
peerDependencies:
zod: ^3.25.0 || ^4.0.0
'@alcalzone/ansi-tokenize@0.2.3':
resolution: {integrity: sha512-jsElTJ0sQ4wHRz+C45tfect76BwbTbgkgKByOzpCN9xG61N5V6u/glvg1CsNJhq2xJIFpKHSwG3D2wPPuEYOrQ==}
engines: {node: '>=18'}
@ -10684,6 +10692,10 @@ snapshots:
'@adobe/css-tools@4.4.2': {}
'@agentclientprotocol/sdk@0.12.0(zod@3.25.76)':
dependencies:
zod: 3.25.76
'@alcalzone/ansi-tokenize@0.2.3':
dependencies:
ansi-styles: 6.2.3