From 4ccca5b88f425880332517e6708dabe03ce3885d Mon Sep 17 00:00:00 2001 From: cte Date: Sat, 10 Jan 2026 23:24:54 -0800 Subject: [PATCH 01/17] More progress --- apps/cli/README.md | 97 + apps/cli/package.json | 1 + apps/cli/src/acp/__tests__/agent.test.ts | 272 ++ .../src/acp/__tests__/delta-tracker.test.ts | 138 + apps/cli/src/acp/__tests__/session.test.ts | 265 ++ .../acp/__tests__/terminal-manager.test.ts | 287 ++ apps/cli/src/acp/__tests__/translator.test.ts | 475 +++ .../src/acp/__tests__/update-buffer.test.ts | 381 ++ apps/cli/src/acp/agent.ts | 318 ++ apps/cli/src/acp/delta-tracker.ts | 71 + apps/cli/src/acp/docs/agent-plan.md | 84 + apps/cli/src/acp/docs/content.md | 207 ++ apps/cli/src/acp/docs/extensibility.md | 137 + apps/cli/src/acp/docs/file-system.md | 118 + apps/cli/src/acp/docs/initialization.md | 225 ++ apps/cli/src/acp/docs/llms.txt | 50 + apps/cli/src/acp/docs/overview.md | 165 + apps/cli/src/acp/docs/prompt-turn.md | 321 ++ apps/cli/src/acp/docs/schema.md | 3195 +++++++++++++++++ apps/cli/src/acp/docs/session-modes.md | 170 + apps/cli/src/acp/docs/session-setup.md | 384 ++ apps/cli/src/acp/docs/slash-commands.md | 99 + apps/cli/src/acp/docs/terminals.md | 281 ++ apps/cli/src/acp/docs/tool-calls.md | 311 ++ apps/cli/src/acp/docs/transports.md | 55 + apps/cli/src/acp/file-system-service.ts | 148 + apps/cli/src/acp/index.ts | 24 + apps/cli/src/acp/logger.ts | 186 + apps/cli/src/acp/session.ts | 848 +++++ apps/cli/src/acp/terminal-manager.ts | 322 ++ apps/cli/src/acp/translator.ts | 666 ++++ apps/cli/src/acp/update-buffer.ts | 212 ++ apps/cli/src/commands/acp/index.ts | 137 + apps/cli/src/index.ts | 11 + apps/cli/src/types/constants.ts | 1 + pnpm-lock.yaml | 12 + 36 files changed, 10674 insertions(+) create mode 100644 apps/cli/src/acp/__tests__/agent.test.ts create mode 100644 apps/cli/src/acp/__tests__/delta-tracker.test.ts create mode 100644 apps/cli/src/acp/__tests__/session.test.ts create mode 100644 apps/cli/src/acp/__tests__/terminal-manager.test.ts create mode 100644 apps/cli/src/acp/__tests__/translator.test.ts create mode 100644 apps/cli/src/acp/__tests__/update-buffer.test.ts create mode 100644 apps/cli/src/acp/agent.ts create mode 100644 apps/cli/src/acp/delta-tracker.ts create mode 100644 apps/cli/src/acp/docs/agent-plan.md create mode 100644 apps/cli/src/acp/docs/content.md create mode 100644 apps/cli/src/acp/docs/extensibility.md create mode 100644 apps/cli/src/acp/docs/file-system.md create mode 100644 apps/cli/src/acp/docs/initialization.md create mode 100644 apps/cli/src/acp/docs/llms.txt create mode 100644 apps/cli/src/acp/docs/overview.md create mode 100644 apps/cli/src/acp/docs/prompt-turn.md create mode 100644 apps/cli/src/acp/docs/schema.md create mode 100644 apps/cli/src/acp/docs/session-modes.md create mode 100644 apps/cli/src/acp/docs/session-setup.md create mode 100644 apps/cli/src/acp/docs/slash-commands.md create mode 100644 apps/cli/src/acp/docs/terminals.md create mode 100644 apps/cli/src/acp/docs/tool-calls.md create mode 100644 apps/cli/src/acp/docs/transports.md create mode 100644 apps/cli/src/acp/file-system-service.ts create mode 100644 apps/cli/src/acp/index.ts create mode 100644 apps/cli/src/acp/logger.ts create mode 100644 apps/cli/src/acp/session.ts create mode 100644 apps/cli/src/acp/terminal-manager.ts create mode 100644 apps/cli/src/acp/translator.ts create mode 100644 apps/cli/src/acp/update-buffer.ts create mode 100644 apps/cli/src/commands/acp/index.ts diff --git a/apps/cli/README.md b/apps/cli/README.md index d4405364405..dde72f1a571 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -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 to the extension bundle directory | Auto-detected | +| `-p, --provider ` | API provider (anthropic, openai, openrouter) | `openrouter` | +| `-m, --model ` | Model to use | `anthropic/claude-sonnet-4.5` | +| `-M, --mode ` | Initial mode (code, architect, ask, debug) | `code` | +| `-k, --api-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`: diff --git a/apps/cli/package.json b/apps/cli/package.json index 3939a0aa584..0190dcb5c20 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -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:^", diff --git a/apps/cli/src/acp/__tests__/agent.test.ts b/apps/cli/src/acp/__tests__/agent.test.ts new file mode 100644 index 00000000000..0c61f0e474d --- /dev/null +++ b/apps/cli/src/acp/__tests__/agent.test.ts @@ -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 + }) + }) +}) diff --git a/apps/cli/src/acp/__tests__/delta-tracker.test.ts b/apps/cli/src/acp/__tests__/delta-tracker.test.ts new file mode 100644 index 00000000000..3874a22a452 --- /dev/null +++ b/apps/cli/src/acp/__tests__/delta-tracker.test.ts @@ -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") + }) + }) +}) diff --git a/apps/cli/src/acp/__tests__/session.test.ts b/apps/cli/src/acp/__tests__/session.test.ts new file mode 100644 index 00000000000..2c172e1d3ef --- /dev/null +++ b/apps/cli/src/acp/__tests__/session.test.ts @@ -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") + }) + }) +}) diff --git a/apps/cli/src/acp/__tests__/terminal-manager.test.ts b/apps/cli/src/acp/__tests__/terminal-manager.test.ts new file mode 100644 index 00000000000..e4a08149821 --- /dev/null +++ b/apps/cli/src/acp/__tests__/terminal-manager.test.ts @@ -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 + waitForExit: ReturnType + kill: ReturnType + release: ReturnType +} + +interface MockConnection { + createTerminal: ReturnType + 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() + }) + }) +}) diff --git a/apps/cli/src/acp/__tests__/translator.test.ts b/apps/cli/src/acp/__tests__/translator.test.ts new file mode 100644 index 00000000000..5e3b80c338c --- /dev/null +++ b/apps/cli/src/acp/__tests__/translator.test.ts @@ -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") + }) +}) diff --git a/apps/cli/src/acp/__tests__/update-buffer.test.ts b/apps/cli/src/acp/__tests__/update-buffer.test.ts new file mode 100644 index 00000000000..a1307d14503 --- /dev/null +++ b/apps/cli/src/acp/__tests__/update-buffer.test.ts @@ -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 + + 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) + }) + }) +}) diff --git a/apps/cli/src/acp/agent.ts b/apps/cli/src/acp/agent.ts new file mode 100644 index 00000000000..5094b491f4b --- /dev/null +++ b/apps/cli/src/acp/agent.ts @@ -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 = 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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") + } +} diff --git a/apps/cli/src/acp/delta-tracker.ts b/apps/cli/src/acp/delta-tracker.ts new file mode 100644 index 00000000000..0c1cec71230 --- /dev/null +++ b/apps/cli/src/acp/delta-tracker.ts @@ -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 = 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 + } +} diff --git a/apps/cli/src/acp/docs/agent-plan.md b/apps/cli/src/acp/docs/agent-plan.md new file mode 100644 index 00000000000..c1943520b91 --- /dev/null +++ b/apps/cli/src/acp/docs/agent-plan.md @@ -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" + } + ] + } + } +} +``` + + + An array of [plan entries](#plan-entries) representing the tasks to be + accomplished + + +## Plan Entries + +Each plan entry represents a specific task or goal within the overall execution strategy: + + + A human-readable description of what this task aims to accomplish + + + + The relative importance of this task. + +- `high` +- `medium` +- `low` + + + + The current [execution status](#status) of this task + +- `pending` +- `in_progress` +- `completed` + + +## 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 diff --git a/apps/cli/src/acp/docs/content.md b/apps/cli/src/acp/docs/content.md new file mode 100644 index 00000000000..517fad820a8 --- /dev/null +++ b/apps/cli/src/acp/docs/content.md @@ -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. + + + The text content to display + + + + Optional metadata about how the content should be used or displayed. [Learn + more](https://modelcontextprotocol.io/specification/2025-06-18/server/resources#annotations). + + +### Image Content + +Images can be included for visual context or analysis. + +```json theme={null} +{ + "type": "image", + "mimeType": "image/png", + "data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB..." +} +``` + + Requires the `image` [prompt +capability](./initialization#prompt-capabilities) when included in prompts. + + + Base64-encoded image data + + + + The MIME type of the image (e.g., "image/png", "image/jpeg") + + + + Optional URI reference for the image source + + + + Optional metadata about how the content should be used or displayed. [Learn + more](https://modelcontextprotocol.io/specification/2025-06-18/server/resources#annotations). + + +### Audio Content + +Audio data for transcription or analysis. + +```json theme={null} +{ + "type": "audio", + "mimeType": "audio/wav", + "data": "UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAAB..." +} +``` + + Requires the `audio` [prompt +capability](./initialization#prompt-capabilities) when included in prompts. + + + Base64-encoded audio data + + + + The MIME type of the audio (e.g., "audio/wav", "audio/mp3") + + + + Optional metadata about how the content should be used or displayed. [Learn + more](https://modelcontextprotocol.io/specification/2025-06-18/server/resources#annotations). + + +### Embedded Resource + +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. + + Requires the `embeddedContext` [prompt +capability](./initialization#prompt-capabilities) when included in prompts. + + + The embedded resource contents, which can be either: + + + + The URI identifying the resource + + + + The text content of the resource + + + + Optional MIME type of the text content + + + + + + + The URI identifying the resource + + + + Base64-encoded binary data + + + + Optional MIME type of the blob + + + + + + + Optional metadata about how the content should be used or displayed. [Learn + more](https://modelcontextprotocol.io/specification/2025-06-18/server/resources#annotations). + + +### 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 +} +``` + + + The URI of the resource + + + + A human-readable name for the resource + + + + The MIME type of the resource + + + + Optional display title for the resource + + + + Optional description of the resource contents + + + + Optional size of the resource in bytes + + + + Optional metadata about how the content should be used or displayed. [Learn + more](https://modelcontextprotocol.io/specification/2025-06-18/server/resources#annotations). + + +--- + +> To find navigation and other pages in this documentation, fetch the llms.txt file at: https://agentclientprotocol.com/llms.txt diff --git a/apps/cli/src/acp/docs/extensibility.md b/apps/cli/src/acp/docs/extensibility.md new file mode 100644 index 00000000000..e8ab4f81979 --- /dev/null +++ b/apps/cli/src/acp/docs/extensibility.md @@ -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 diff --git a/apps/cli/src/acp/docs/file-system.md b/apps/cli/src/acp/docs/file-system.md new file mode 100644 index 00000000000..48ce87e0d00 --- /dev/null +++ b/apps/cli/src/acp/docs/file-system.md @@ -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 + } +} +``` + + + The [Session ID](./session-setup#session-id) for this request + + + + Absolute path to the file to read + + + + Optional line number to start reading from (1-based) + + + + Optional maximum number of lines to read + + +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}" + } +} +``` + + + The [Session ID](./session-setup#session-id) for this request + + + + Absolute path to the file to write. + +The Client **MUST** create the file if it doesn't exist. + + + + The text content to write to the file + + +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 diff --git a/apps/cli/src/acp/docs/initialization.md b/apps/cli/src/acp/docs/initialization.md new file mode 100644 index 00000000000..29bd88e37cb --- /dev/null +++ b/apps/cli/src/acp/docs/initialization.md @@ -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. + +
+ +```mermaid theme={null} +sequenceDiagram + participant Client + participant Agent + + Note over Client, Agent: Connection established + Client->>Agent: initialize + Note right of Agent: Negotiate protocol
version & capabilities + Agent-->>Client: initialize response + Note over Client,Agent: Ready for session setup +``` + +
+ +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 + + + The `fs/read_text_file` method is available. + + + + The `fs/write_text_file` method is available. + + + + Learn more about File System methods + + +#### Terminal + + + All `terminal/*` methods are available, allowing the Agent to execute and + manage shell commands. + + + + Learn more about Terminals + + +### Agent Capabilities + +The Agent **SHOULD** specify whether it supports the following capabilities: + + +The [`session/load`](./session-setup#loading-sessions) method is available. + + + + Object indicating the different types of [content](./content) that may be + included in `session/prompt` requests. + + +#### 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: + + +The prompt may include `ContentBlock::Image` + + + +The prompt may include `ContentBlock::Audio` + + + +The prompt may include `ContentBlock::Resource` + + +#### MCP capabilities + + +The Agent supports connecting to MCP servers over HTTP. + + + +The Agent supports connecting to MCP servers over SSE. + +Note: This transport has been deprecated by the MCP spec. + + +#### 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. + + + `session/load` is still handled by the top-level `load_session` capability. + This will be unified in future versions of the protocol. + + +## 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: + + + Intended for programmatic or logical use, but can be used as a display name + fallback if title isn’t present. + + + + 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. + + + + Version of the implementation. Can be displayed to the user or used for + debugging or metrics purposes. + + + + Note: in future versions of the protocol, this information will be required. + + +--- + +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 diff --git a/apps/cli/src/acp/docs/llms.txt b/apps/cli/src/acp/docs/llms.txt new file mode 100644 index 00000000000..62c7e87b82d --- /dev/null +++ b/apps/cli/src/acp/docs/llms.txt @@ -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 diff --git a/apps/cli/src/acp/docs/overview.md b/apps/cli/src/acp/docs/overview.md new file mode 100644 index 00000000000..d9d321f3c4b --- /dev/null +++ b/apps/cli/src/acp/docs/overview.md @@ -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: + + + + * Client → Agent: `initialize` to establish connection + * Client → Agent: `authenticate` if required by the Agent + + + + * Client → Agent: `session/new` to create a new session + * Client → Agent: `session/load` to resume an existing session if supported + + + + * 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 + + + +## Agent + +Agents are programs that use generative AI to autonomously modify code. They typically run as subprocesses of the Client. + +### Baseline Methods + +Schema]}> +[Negotiate versions and exchange capabilities.](./initialization). + + +Schema]}> +Authenticate with the Agent (if required). + + +Schema]}> +[Create a new conversation session](./session-setup#creating-a-session). + + +Schema]}> +[Send user prompts](./prompt-turn#1-user-message) to the Agent. + + +### Optional Methods + +Schema]}> +[Load an existing session](./session-setup#loading-sessions) (requires +`loadSession` capability). + + +Schema]}> +[Switch between agent operating +modes](./session-modes#setting-the-current-mode). + + +### Notifications + +Schema]}> +[Cancel ongoing operations](./prompt-turn#cancellation) (no response +expected). + + +## 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 + +Schema]}> +[Request user authorization](./tool-calls#requesting-permission) for tool +calls. + + +### Optional Methods + +Schema]}> +[Read file contents](./file-system#reading-files) (requires `fs.readTextFile` +capability). + + +Schema]}> +[Write file contents](./file-system#writing-files) (requires +`fs.writeTextFile` capability). + + +Schema]}> +[Create a new terminal](./terminals) (requires `terminal` capability). + + +Schema]}> +Get terminal output and exit status (requires `terminal` capability). + + +Schema]}> +Release a terminal (requires `terminal` capability). + + +Schema]}> +Wait for terminal command to exit (requires `terminal` capability). + + +Schema]}> +Kill terminal command without releasing (requires `terminal` capability). + + +### Notifications + +Schema]}> +[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) + + +## 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 diff --git a/apps/cli/src/acp/docs/prompt-turn.md b/apps/cli/src/acp/docs/prompt-turn.md new file mode 100644 index 00000000000..ad5db75d138 --- /dev/null +++ b/apps/cli/src/acp/docs/prompt-turn.md @@ -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. + +
+ +```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
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
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)" + } + } + ] + } +} +``` + + + The [ID](./session-setup#session-id) of the session to send this message to. + + + + 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). + + + Learn more about Content + + + +### 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" + } + ] + } + } +} +``` + + + Learn more about Agent Plans + + +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" + } + } + ] + } + } +} +``` + + + Learn more about Tool Calls + + +### 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`: + + + The language model finishes responding without requesting more tools + + + + The maximum token limit is reached + + + + The maximum number of model requests in a single turn is exceeded + + +The Agent refuses to continue + +The Client cancels the turn + +## 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). + + + 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. + + +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 diff --git a/apps/cli/src/acp/docs/schema.md b/apps/cli/src/acp/docs/schema.md new file mode 100644 index 00000000000..b18e2590493 --- /dev/null +++ b/apps/cli/src/acp/docs/schema.md @@ -0,0 +1,3195 @@ +# Schema + +> Schema definitions for the Agent Client Protocol + +## Agent + +Defines the interface that all ACP-compliant agents must implement. + +Agents are programs that use generative AI to autonomously modify code. They handle +requests from clients and execute tasks using language models and tools. + +### authenticate + +Authenticates the client using the specified authentication method. + +Called when the agent requires authentication before allowing session creation. +The client provides the authentication method ID that was advertised during initialization. + +After successful authentication, the client can proceed to create sessions with +`new_session` without receiving an `auth_required` error. + +See protocol docs: [Initialization](https://agentclientprotocol.com/protocol/initialization) + +#### AuthenticateRequest + +Request parameters for the authenticate method. + +Specifies which authentication method to use. + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + + +The ID of the authentication method to use. +Must be one of the methods advertised in the initialize response. + + +#### AuthenticateResponse + +Response to the `authenticate` method. + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + +### initialize + +Establishes the connection with a client and negotiates protocol capabilities. + +This method is called once at the beginning of the connection to: + +- Negotiate the protocol version to use +- Exchange capability information between client and agent +- Determine available authentication methods + +The agent should respond with its supported protocol version and capabilities. + +See protocol docs: [Initialization](https://agentclientprotocol.com/protocol/initialization) + +#### InitializeRequest + +Request parameters for the initialize method. + +Sent by the client to establish connection and negotiate capabilities. + +See protocol docs: [Initialization](https://agentclientprotocol.com/protocol/initialization) + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + +ClientCapabilities}> +Capabilities supported by the client. + +- Default: `{"fs":{"readTextFile":false,"writeTextFile":false},"terminal":false}` + + +Implementation | null}> +Information about the Client name and version sent to the Agent. + +Note: in future versions of the protocol, this will be required. + + +ProtocolVersion} required> +The latest protocol version supported by the client. + + +#### InitializeResponse + +Response to the `initialize` method. + +Contains the negotiated protocol version and agent capabilities. + +See protocol docs: [Initialization](https://agentclientprotocol.com/protocol/initialization) + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + +AgentCapabilities}> +Capabilities supported by the agent. + +- Default: `{"loadSession":false,"mcpCapabilities":{"http":false,"sse":false},"promptCapabilities":{"audio":false,"embeddedContext":false,"image":false},"sessionCapabilities":{}}` + + +Implementation | null}> +Information about the Agent name and version sent to the Client. + +Note: in future versions of the protocol, this will be required. + + +AuthMethod[]}> +Authentication methods supported by the agent. + +- Default: `[]` + + +ProtocolVersion} required> +The protocol version the client specified if supported by the agent, +or the latest protocol version supported by the agent. + +The client should disconnect, if it doesn't support this version. + + + + +### session/cancel + +Cancels ongoing operations for a session. + +This is a notification sent by the client to cancel an ongoing prompt turn. + +Upon receiving this notification, the Agent SHOULD: + +- Stop all language model requests as soon as possible +- Abort all tool call invocations in progress +- Send any pending `session/update` notifications +- Respond to the original `session/prompt` request with `StopReason::Cancelled` + +See protocol docs: [Cancellation](https://agentclientprotocol.com/protocol/prompt-turn#cancellation) + +#### CancelNotification + +Notification to cancel ongoing operations for a session. + +See protocol docs: [Cancellation](https://agentclientprotocol.com/protocol/prompt-turn#cancellation) + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + +SessionId} required> +The ID of the session to cancel operations for. + + + + +### session/load + +Loads an existing session to resume a previous conversation. + +This method is only available if the agent advertises the `loadSession` capability. + +The agent should: + +- Restore the session context and conversation history +- Connect to the specified MCP servers +- Stream the entire conversation history back to the client via notifications + +See protocol docs: [Loading Sessions](https://agentclientprotocol.com/protocol/session-setup#loading-sessions) + +#### LoadSessionRequest + +Request parameters for loading an existing session. + +Only available if the Agent supports the `loadSession` capability. + +See protocol docs: [Loading Sessions](https://agentclientprotocol.com/protocol/session-setup#loading-sessions) + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + + +The working directory for this session. + + +McpServer[]} required> +List of MCP servers to connect to for this session. + + +SessionId} required> +The ID of the session to load. + + +#### LoadSessionResponse + +Response from loading an existing session. + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + +SessionModeState | null}> +Initial mode state if supported by the Agent + +See protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes) + + + + +### session/new + +Creates a new conversation session with the agent. + +Sessions represent independent conversation contexts with their own history and state. + +The agent should: + +- Create a new session context +- Connect to any specified MCP servers +- Return a unique session ID for future requests + +May return an `auth_required` error if the agent requires authentication. + +See protocol docs: [Session Setup](https://agentclientprotocol.com/protocol/session-setup) + +#### NewSessionRequest + +Request parameters for creating a new session. + +See protocol docs: [Creating a Session](https://agentclientprotocol.com/protocol/session-setup#creating-a-session) + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + + +The working directory for this session. Must be an absolute path. + + +McpServer[]} required> +List of MCP (Model Context Protocol) servers the agent should connect to. + + +#### NewSessionResponse + +Response from creating a new session. + +See protocol docs: [Creating a Session](https://agentclientprotocol.com/protocol/session-setup#creating-a-session) + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + +SessionModeState | null}> +Initial mode state if supported by the Agent + +See protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes) + + +SessionId} required> +Unique identifier for the created session. + +Used in all subsequent requests for this conversation. + + + + +### session/prompt + +Processes a user prompt within a session. + +This method handles the whole lifecycle of a prompt: + +- Receives user messages with optional context (files, images, etc.) +- Processes the prompt using language models +- Reports language model content and tool calls to the Clients +- Requests permission to run tools +- Executes any requested tool calls +- Returns when the turn is complete with a stop reason + +See protocol docs: [Prompt Turn](https://agentclientprotocol.com/protocol/prompt-turn) + +#### PromptRequest + +Request parameters for sending a user prompt to the agent. + +Contains the user's message and any additional context. + +See protocol docs: [User Message](https://agentclientprotocol.com/protocol/prompt-turn#1-user-message) + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + +ContentBlock[]} required> +The blocks of content that compose the user's message. + +As a baseline, the Agent MUST support `ContentBlock::Text` and `ContentBlock::ResourceLink`, +while other variants are optionally enabled via `PromptCapabilities`. + +The Client MUST adapt its interface according to `PromptCapabilities`. + +The client MAY include referenced pieces of context as either +`ContentBlock::Resource` or `ContentBlock::ResourceLink`. + +When available, `ContentBlock::Resource` is preferred +as it avoids extra round-trips and allows the message to include +pieces of context from sources the agent may not have access to. + + +SessionId} required> +The ID of the session to send this user message to + + +#### PromptResponse + +Response from processing a user prompt. + +See protocol docs: [Check for Completion](https://agentclientprotocol.com/protocol/prompt-turn#4-check-for-completion) + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + +StopReason} required> +Indicates why the agent stopped processing the turn. + + + + +### session/set_mode + +Sets the current mode for a session. + +Allows switching between different agent modes (e.g., "ask", "architect", "code") +that affect system prompts, tool availability, and permission behaviors. + +The mode must be one of the modes advertised in `availableModes` during session +creation or loading. Agents may also change modes autonomously and notify the +client via `current_mode_update` notifications. + +This method can be called at any time during a session, whether the Agent is +idle or actively generating a response. + +See protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes) + +#### SetSessionModeRequest + +Request parameters for setting a session mode. + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + +SessionModeId} required> +The ID of the mode to set. + + +SessionId} required> +The ID of the session to set the mode for. + + +#### SetSessionModeResponse + +Response to `session/set_mode` method. + +**Type:** Object + +**Properties:** + + + +## Client + +Defines the interface that ACP-compliant clients must implement. + +Clients are typically code editors (IDEs, text editors) that provide the interface +between users and AI agents. They manage the environment, handle user interactions, +and control access to resources. + + + +### fs/read_text_file + +Reads content from a text file in the client's file system. + +Only available if the client advertises the `fs.readTextFile` capability. +Allows the agent to access file contents within the client's environment. + +See protocol docs: [Client](https://agentclientprotocol.com/protocol/overview#client) + +#### ReadTextFileRequest + +Request to read content from a text file. + +Only available if the client supports the `fs.readTextFile` capability. + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + + +Maximum number of lines to read. + +- Minimum: `0` + + + +Line number to start reading from (1-based). + +- Minimum: `0` + + + +Absolute path to the file to read. + + +SessionId} required> +The session ID for this request. + + +#### ReadTextFileResponse + +Response containing the contents of a text file. + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + + + + + +### fs/write_text_file + +Writes content to a text file in the client's file system. + +Only available if the client advertises the `fs.writeTextFile` capability. +Allows the agent to create or modify files within the client's environment. + +See protocol docs: [Client](https://agentclientprotocol.com/protocol/overview#client) + +#### WriteTextFileRequest + +Request to write content to a text file. + +Only available if the client supports the `fs.writeTextFile` capability. + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + + +The text content to write to the file. + + + +Absolute path to the file to write. + + +SessionId} required> +The session ID for this request. + + +#### WriteTextFileResponse + +Response to `fs/write_text_file` + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + + + +### session/request_permission + +Requests permission from the user for a tool call operation. + +Called by the agent when it needs user authorization before executing +a potentially sensitive operation. The client should present the options +to the user and return their decision. + +If the client cancels the prompt turn via `session/cancel`, it MUST +respond to this request with `RequestPermissionOutcome::Cancelled`. + +See protocol docs: [Requesting Permission](https://agentclientprotocol.com/protocol/tool-calls#requesting-permission) + +#### RequestPermissionRequest + +Request for user permission to execute a tool call. + +Sent when the agent needs authorization before performing a sensitive operation. + +See protocol docs: [Requesting Permission](https://agentclientprotocol.com/protocol/tool-calls#requesting-permission) + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + +PermissionOption[]} required> +Available permission options for the user to choose from. + + +SessionId} required> +The session ID for this request. + + +ToolCallUpdate} required> +Details about the tool call requiring permission. + + +#### RequestPermissionResponse + +Response to a permission request. + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + +RequestPermissionOutcome} required> +The user's decision on the permission request. + + + + +### session/update + +Handles session update notifications from the agent. + +This is a notification endpoint (no response expected) that receives +real-time updates about session progress, including message chunks, +tool calls, and execution plans. + +Note: Clients SHOULD continue accepting tool call updates even after +sending a `session/cancel` notification, as the agent may send final +updates before responding with the cancelled stop reason. + +See protocol docs: [Agent Reports Output](https://agentclientprotocol.com/protocol/prompt-turn#3-agent-reports-output) + +#### SessionNotification + +Notification containing a session update from the agent. + +Used to stream real-time progress and results during prompt processing. + +See protocol docs: [Agent Reports Output](https://agentclientprotocol.com/protocol/prompt-turn#3-agent-reports-output) + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + +SessionId} required> +The ID of the session this update pertains to. + + +SessionUpdate} required> +The actual update content. + + + + +### terminal/create + +Executes a command in a new terminal + +Only available if the `terminal` Client capability is set to `true`. + +Returns a `TerminalId` that can be used with other terminal methods +to get the current output, wait for exit, and kill the command. + +The `TerminalId` can also be used to embed the terminal in a tool call +by using the `ToolCallContent::Terminal` variant. + +The Agent is responsible for releasing the terminal by using the `terminal/release` +method. + +See protocol docs: [Terminals](https://agentclientprotocol.com/protocol/terminals) + +#### CreateTerminalRequest + +Request to create a new terminal and execute a command. + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + +"string"[]}> +Array of command arguments. + + + +The command to execute. + + + +Working directory for the command (absolute path). + + +EnvVariable[]}> +Environment variables for the command. + + + +Maximum number of output bytes to retain. + +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. + +- Minimum: `0` + + +SessionId} required> +The session ID for this request. + + +#### CreateTerminalResponse + +Response containing the ID of the created terminal. + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + + +The unique identifier for the created terminal. + + + + +### terminal/kill + +Kills the terminal command without releasing the terminal + +While `terminal/release` will also kill the command, this method will keep +the `TerminalId` valid so it can be used with other methods. + +This method can be helpful when implementing command timeouts which terminate +the command as soon as elapsed, and then get the final output so it can be sent +to the model. + +Note: `terminal/release` when `TerminalId` is no longer needed. + +See protocol docs: [Terminals](https://agentclientprotocol.com/protocol/terminals) + +#### KillTerminalCommandRequest + +Request to kill a terminal command without releasing the terminal. + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + +SessionId} required> +The session ID for this request. + + + +The ID of the terminal to kill. + + +#### KillTerminalCommandResponse + +Response to terminal/kill command method + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + + + +### terminal/output + +Gets the terminal output and exit status + +Returns the current content in the terminal without waiting for the command to exit. +If the command has already exited, the exit status is included. + +See protocol docs: [Terminals](https://agentclientprotocol.com/protocol/terminals) + +#### TerminalOutputRequest + +Request to get the current output and status of a terminal. + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + +SessionId} required> +The session ID for this request. + + + +The ID of the terminal to get output from. + + +#### TerminalOutputResponse + +Response containing the terminal output and exit status. + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + +TerminalExitStatus | null}> +Exit status if the command has completed. + + + +The terminal output captured so far. + + + +Whether the output was truncated due to byte limits. + + + + +### terminal/release + +Releases a terminal + +The command is killed if it hasn't exited yet. Use `terminal/wait_for_exit` +to wait for the command to exit before releasing the terminal. + +After release, the `TerminalId` can no longer be used with other `terminal/*` methods, +but tool calls that already contain it, continue to display its output. + +The `terminal/kill` method can be used to terminate the command without releasing +the terminal, allowing the Agent to call `terminal/output` and other methods. + +See protocol docs: [Terminals](https://agentclientprotocol.com/protocol/terminals) + +#### ReleaseTerminalRequest + +Request to release a terminal and free its resources. + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + +SessionId} required> +The session ID for this request. + + + +The ID of the terminal to release. + + +#### ReleaseTerminalResponse + +Response to terminal/release method + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + + + +### terminal/wait_for_exit + +Waits for the terminal command to exit and return its exit status + +See protocol docs: [Terminals](https://agentclientprotocol.com/protocol/terminals) + +#### WaitForTerminalExitRequest + +Request to wait for a terminal command to exit. + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + +SessionId} required> +The session ID for this request. + + + +The ID of the terminal to wait for. + + +#### WaitForTerminalExitResponse + +Response containing the exit status of a terminal command. + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + + +The process exit code (may be null if terminated by signal). + +- Minimum: `0` + + + +The signal that terminated the process (may be null if exited normally). + + +## AgentCapabilities + +Capabilities supported by the agent. + +Advertised during initialization to inform the client about +available features and content types. + +See protocol docs: [Agent Capabilities](https://agentclientprotocol.com/protocol/initialization#agent-capabilities) + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + + +Whether the agent supports `session/load`. + +- Default: `false` + + +McpCapabilities}> +MCP capabilities supported by the agent. + +- Default: `{"http":false,"sse":false}` + + +PromptCapabilities}> +Prompt capabilities supported by the agent. + +- Default: `{"audio":false,"embeddedContext":false,"image":false}` + + +SessionCapabilities}> + +- Default: `{}` + + +## Annotations + +Optional annotations for the client. The client can use annotations to inform how objects are used or displayed + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + +Role[] | null} /> + + + + + +## AudioContent + +Audio provided to or from an LLM. + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + +Annotations | null} /> + + + + + +## AuthMethod + +Describes an available authentication method. + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + + +Optional description providing more details about this authentication method. + + + +Unique identifier for this authentication method. + + + +Human-readable name of the authentication method. + + +## AvailableCommand + +Information about a command. + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + + +Human-readable description of what the command does. + + +AvailableCommandInput | null}> +Input for the command if required + + + +Command name (e.g., `create_plan`, `research_codebase`). + + +## AvailableCommandInput + +The input specification for a command. + +**Type:** Union + + + All text that was typed after the command name is provided as input. + + + + The \_meta property is reserved by ACP to allow clients and agents to attach additional + metadata to their interactions. Implementations MUST NOT make assumptions about values at + these keys. + + See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + + + A hint to display when the input hasn't been provided yet + + + + + +## AvailableCommandsUpdate + +Available commands are ready or have changed + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + +AvailableCommand[]} required> +Commands the agent can execute + + +## BlobResourceContents + +Binary resource contents. + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + + + + + + + +## ClientCapabilities + +Capabilities supported by the client. + +Advertised during initialization to inform the agent about +available features and methods. + +See protocol docs: [Client Capabilities](https://agentclientprotocol.com/protocol/initialization#client-capabilities) + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + +FileSystemCapability}> +File system capabilities supported by the client. +Determines which file operations the agent can request. + +- Default: `{"readTextFile":false,"writeTextFile":false}` + + + +Whether the Client support all `terminal/*` methods. + +- Default: `false` + + +## Content + +Standard content block (text, images, resources). + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + +ContentBlock} required> +The actual content block. + + +## ContentBlock + +Content blocks represent displayable information in 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` +- Language model output streamed through `session/update` notifications +- Progress updates and results from tool calls + +This structure is compatible with the Model Context Protocol (MCP), enabling +agents to seamlessly forward content from MCP tool outputs without transformation. + +See protocol docs: [Content](https://agentclientprotocol.com/protocol/content) + +**Type:** Union + + + Text content. May be plain text or formatted with Markdown. + +All agents MUST support text content blocks in prompts. +Clients SHOULD render this text as Markdown. + + + + The \_meta property is reserved by ACP to allow clients and agents to attach additional + metadata to their interactions. Implementations MUST NOT make assumptions about values at + these keys. + + See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + + Annotations | null} /> + + + + + + + + + + Images for visual context or analysis. + +Requires the `image` prompt capability when included in prompts. + + + + The \_meta property is reserved by ACP to allow clients and agents to attach additional + metadata to their interactions. Implementations MUST NOT make assumptions about values at + these keys. + + See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + + Annotations | null} /> + + + + + + + + + + + + + + Audio data for transcription or analysis. + +Requires the `audio` prompt capability when included in prompts. + + + + The \_meta property is reserved by ACP to allow clients and agents to attach additional + metadata to their interactions. Implementations MUST NOT make assumptions about values at + these keys. + + See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + + Annotations | null} /> + + + + + + + + + + + + References to resources that the agent can access. + +All agents MUST support resource links in prompts. + + + + The \_meta property is reserved by ACP to allow clients and agents to attach additional + metadata to their interactions. Implementations MUST NOT make assumptions about values at + these keys. + + See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + + Annotations | null} /> + + + + + + + + + + + + + + + + + + + + Complete resource contents embedded directly in the message. + +Preferred for including context as it avoids extra round-trips. + +Requires the `embeddedContext` prompt capability when included in prompts. + + + + The \_meta property is reserved by ACP to allow clients and agents to attach additional + metadata to their interactions. Implementations MUST NOT make assumptions about values at + these keys. + + See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + + Annotations | null} /> + + EmbeddedResourceResource} required /> + + + + + + +## ContentChunk + +A streamed item of content + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + +ContentBlock} required> +A single item of content + + +## CurrentModeUpdate + +The current mode of the session has changed + +See protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes) + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + +SessionModeId} required> +The ID of the current mode + + +## Diff + +A diff representing file modifications. + +Shows changes to files in a format suitable for display in the client UI. + +See protocol docs: [Content](https://agentclientprotocol.com/protocol/tool-calls#content) + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + + +The new content after modification. + + + +The original content (None for new files). + + + +The file path being modified. + + +## EmbeddedResource + +The contents of a resource, embedded into a prompt or tool call result. + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + +Annotations | null} /> + +EmbeddedResourceResource} required /> + +## EmbeddedResourceResource + +Resource content that can be embedded in a message. + +**Type:** Union + + + {""} + + + + The \_meta property is reserved by ACP to allow clients and agents to attach additional + metadata to their interactions. Implementations MUST NOT make assumptions about values at + these keys. + + See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + + + + + + + + + + + + {""} + + + + The \_meta property is reserved by ACP to allow clients and agents to attach additional + metadata to their interactions. Implementations MUST NOT make assumptions about values at + these keys. + + See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + + + + + + + + + + +## EnvVariable + +An environment variable to set when launching an MCP server. + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + + +The name of the environment variable. + + + +The value to set for the environment variable. + + +## Error + +JSON-RPC error object. + +Represents an error that occurred during method execution, following the +JSON-RPC 2.0 error object specification with optional additional data. + +See protocol docs: [JSON-RPC Error Object](https://www.jsonrpc.org/specification#error_object) + +**Type:** Object + +**Properties:** + +ErrorCode} required> +A number indicating the error type that occurred. This must be an integer as +defined in the JSON-RPC specification. + + + +Optional primitive or structured value that contains additional information +about the error. This may include debugging information or context-specific +details. + + + +A string providing a short description of the error. The message should be +limited to a concise single sentence. + + +## ErrorCode + +Predefined error codes for common JSON-RPC and ACP-specific errors. + +These codes follow the JSON-RPC 2.0 specification for standard errors +and use the reserved range (-32000 to -32099) for protocol-specific errors. + +**Type:** Union + + + **Parse error**: Invalid JSON was received by the server. An error occurred on + the server while parsing the JSON text. + + + + **Invalid request**: The JSON sent is not a valid Request object. + + + + **Method not found**: The method does not exist or is not available. + + + + **Invalid params**: Invalid method parameter(s). + + + + **Internal error**: Internal JSON-RPC error. Reserved for + implementation-defined server errors. + + + + **Authentication required**: Authentication is required before this operation + can be performed. + + + + **Resource not found**: A given resource, such as a file, was not found. + + + + Other undefined error code. + + +## ExtNotification + +Allows the Agent to send an arbitrary notification that is not part of the ACP spec. +Extension notifications provide a way to send one-way messages for custom functionality +while maintaining protocol compatibility. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + +## ExtRequest + +Allows for sending an arbitrary request that is not part of the ACP spec. +Extension methods provide a way to add custom functionality while maintaining +protocol compatibility. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + +## ExtResponse + +Allows for sending an arbitrary response to an `ExtRequest` that is not part of the ACP spec. +Extension methods provide a way to add custom functionality while maintaining +protocol compatibility. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + +## FileSystemCapability + +Filesystem capabilities supported by the client. +File system capabilities that a client may support. + +See protocol docs: [FileSystem](https://agentclientprotocol.com/protocol/initialization#filesystem) + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + + +Whether the Client supports `fs/read_text_file` requests. + +- Default: `false` + + + +Whether the Client supports `fs/write_text_file` requests. + +- Default: `false` + + +## HttpHeader + +An HTTP header to set when making requests to the MCP server. + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + + +The name of the HTTP header. + + + +The value to set for the HTTP header. + + +## ImageContent + +An image provided to or from an LLM. + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + +Annotations | null} /> + + + + + + + +## Implementation + +Metadata about the implementation of the client or agent. +Describes the name and version of an MCP implementation, with an optional +title for UI representation. + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + + +Intended for programmatic or logical use, but can be used as a display +name fallback if title isn’t present. + + + +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. + + + +Version of the implementation. Can be displayed to the user or used +for debugging or metrics purposes. (e.g. "1.0.0"). + + +## McpCapabilities + +MCP capabilities supported by the agent + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + + +Agent supports `McpServer::Http`. + +- Default: `false` + + + +Agent supports `McpServer::Sse`. + +- Default: `false` + + +## McpServer + +Configuration for connecting to an MCP (Model Context Protocol) server. + +MCP servers provide tools and context that the agent can use when +processing prompts. + +See protocol docs: [MCP Servers](https://agentclientprotocol.com/protocol/session-setup#mcp-servers) + +**Type:** Union + + + HTTP transport configuration + +Only available when the Agent capabilities indicate `mcp_capabilities.http` is `true`. + + + + The \_meta property is reserved by ACP to allow clients and agents to attach additional + metadata to their interactions. Implementations MUST NOT make assumptions about values at + these keys. + + See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + + HttpHeader[]} required> + HTTP headers to set when making requests to the MCP server. + + + + Human-readable name identifying this MCP server. + + + + + + URL to the MCP server. + + + + + + + SSE transport configuration + +Only available when the Agent capabilities indicate `mcp_capabilities.sse` is `true`. + + + + The \_meta property is reserved by ACP to allow clients and agents to attach additional + metadata to their interactions. Implementations MUST NOT make assumptions about values at + these keys. + + See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + + HttpHeader[]} required> + HTTP headers to set when making requests to the MCP server. + + + + Human-readable name identifying this MCP server. + + + + + + URL to the MCP server. + + + + + + + Stdio transport configuration + +All Agents MUST support this transport. + + + + The \_meta property is reserved by ACP to allow clients and agents to attach additional + metadata to their interactions. Implementations MUST NOT make assumptions about values at + these keys. + + See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + + "string"[]} required> + Command-line arguments to pass to the MCP server. + + + + Path to the MCP server executable. + + + EnvVariable[]} required> + Environment variables to set when launching the MCP server. + + + + Human-readable name identifying this MCP server. + + + + + +## McpServerHttp + +HTTP transport configuration for MCP. + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + +HttpHeader[]} required> +HTTP headers to set when making requests to the MCP server. + + + +Human-readable name identifying this MCP server. + + + +URL to the MCP server. + + +## McpServerSse + +SSE transport configuration for MCP. + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + +HttpHeader[]} required> +HTTP headers to set when making requests to the MCP server. + + + +Human-readable name identifying this MCP server. + + + +URL to the MCP server. + + +## McpServerStdio + +Stdio transport configuration for MCP. + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + +"string"[]} required> +Command-line arguments to pass to the MCP server. + + + +Path to the MCP server executable. + + +EnvVariable[]} required> +Environment variables to set when launching the MCP server. + + + +Human-readable name identifying this MCP server. + + +## PermissionOption + +An option presented to the user when requesting permission. + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + +PermissionOptionKind} required> +Hint about the nature of this permission option. + + + +Human-readable label to display to the user. + + +PermissionOptionId} required> +Unique identifier for this permission option. + + +## PermissionOptionId + +Unique identifier for a permission option. + +**Type:** `string` + +## PermissionOptionKind + +The type of permission option being presented to the user. + +Helps clients choose appropriate icons and UI treatment. + +**Type:** Union + + + Allow this operation only this time. + + + + Allow this operation and remember the choice. + + + + Reject this operation only this time. + + + + Reject this operation and remember the choice. + + +## Plan + +An execution plan for accomplishing complex tasks. + +Plans consist of multiple entries representing individual tasks or goals. +Agents report plans to clients to provide visibility into their execution strategy. +Plans can evolve during execution as the agent discovers new requirements or completes tasks. + +See protocol docs: [Agent Plan](https://agentclientprotocol.com/protocol/agent-plan) + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + +PlanEntry[]} required> +The list of tasks to be accomplished. + +When updating a plan, the agent must send a complete list of all entries +with their current status. The client replaces the entire plan with each update. + + +## PlanEntry + +A single entry in the execution plan. + +Represents a task or goal that the assistant intends to accomplish +as part of fulfilling the user's request. +See protocol docs: [Plan Entries](https://agentclientprotocol.com/protocol/agent-plan#plan-entries) + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + + +Human-readable description of what this task aims to accomplish. + + +PlanEntryPriority} required> +The relative importance of this task. +Used to indicate which tasks are most critical to the overall goal. + + +PlanEntryStatus} required> +Current execution status of this task. + + +## PlanEntryPriority + +Priority levels for plan entries. + +Used to indicate the relative importance or urgency of different +tasks in the execution plan. +See protocol docs: [Plan Entries](https://agentclientprotocol.com/protocol/agent-plan#plan-entries) + +**Type:** Union + + + High priority task - critical to the overall goal. + + + + Medium priority task - important but not critical. + + + + Low priority task - nice to have but not essential. + + +## PlanEntryStatus + +Status of a plan entry in the execution flow. + +Tracks the lifecycle of each task from planning through completion. +See protocol docs: [Plan Entries](https://agentclientprotocol.com/protocol/agent-plan#plan-entries) + +**Type:** Union + + + The task has not started yet. + + + + The task is currently being worked on. + + + + The task has been successfully completed. + + +## PromptCapabilities + +Prompt capabilities supported by the agent in `session/prompt` requests. + +Baseline agent functionality requires support for `ContentBlock::Text` +and `ContentBlock::ResourceLink` in prompt requests. + +Other variants must be explicitly opted in to. +Capabilities for different types of content in prompt requests. + +Indicates which content types beyond the baseline (text and resource links) +the agent can process. + +See protocol docs: [Prompt Capabilities](https://agentclientprotocol.com/protocol/initialization#prompt-capabilities) + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + + +Agent supports `ContentBlock::Audio`. + +- Default: `false` + + + +Agent supports embedded context in `session/prompt` requests. + +When enabled, the Client is allowed to include `ContentBlock::Resource` +in prompt requests for pieces of context that are referenced in the message. + +- Default: `false` + + + +Agent supports `ContentBlock::Image`. + +- Default: `false` + + +## ProtocolVersion + +Protocol version identifier. + +This version is only bumped for breaking changes. +Non-breaking changes should be introduced via capabilities. + +**Type:** `integer (uint16)` + +| Constraint | Value | +| ---------- | ------- | +| Minimum | `0` | +| Maximum | `65535` | + +## RequestId + +JSON RPC Request Id + +An identifier established by the Client that MUST contain a String, Number, or NULL value if included. If it is not included it is assumed to be a notification. The value SHOULD normally not be Null \[1] and Numbers SHOULD NOT contain fractional parts \[2] + +The Server MUST reply with the same value in the Response object if included. This member is used to correlate the context between the two objects. + +\[1] The use of Null as a value for the id member in a Request object is discouraged, because this specification uses a value of Null for Responses with an unknown id. Also, because JSON-RPC 1.0 uses an id value of Null for Notifications this could cause confusion in handling. + +\[2] Fractional parts may be problematic, since many decimal fractions cannot be represented exactly as binary fractions. + +**Type:** Union + + + {""} + + + + {""} + + + + {""} + + +## RequestPermissionOutcome + +The outcome of a permission request. + +**Type:** Union + + + The prompt turn was cancelled before the user responded. + +When a client sends a `session/cancel` notification to cancel an ongoing +prompt turn, it MUST respond to all pending `session/request_permission` +requests with this `Cancelled` outcome. + +See protocol docs: [Cancellation](https://agentclientprotocol.com/protocol/prompt-turn#cancellation) + + + + + + + + The user selected one of the provided options. + + + + The \_meta property is reserved by ACP to allow clients and agents to attach additional + metadata to their interactions. Implementations MUST NOT make assumptions about values at + these keys. + + See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + + PermissionOptionId} required> + The ID of the option the user selected. + + + + + + + +## ResourceLink + +A resource that the server is capable of reading, included in a prompt or tool call result. + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + +Annotations | null} /> + + + + + + + + + + + + + +## Role + +The sender or recipient of messages and data in a conversation. + +**Type:** Enumeration + +| Value | +| ------------- | +| `"assistant"` | +| `"user"` | + +## SelectedPermissionOutcome + +The user selected one of the provided options. + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + +PermissionOptionId} required> +The ID of the option the user selected. + + +## SessionCapabilities + +Session capabilities supported by the agent. + +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. + +See protocol docs: [Session Capabilities](https://agentclientprotocol.com/protocol/initialization#session-capabilities) + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + +## SessionId + +A unique identifier for a conversation session between a client and agent. + +Sessions maintain their own context, conversation history, and state, +allowing multiple independent interactions with the same agent. + +See protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id) + +**Type:** `string` + +## SessionMode + +A mode the agent can operate in. + +See protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes) + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + + + +SessionModeId} required /> + + + +## SessionModeId + +Unique identifier for a Session Mode. + +**Type:** `string` + +## SessionModeState + +The set of modes and the one currently active. + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + +SessionMode[]} required> +The set of modes that the Agent can operate in + + +SessionModeId} required> +The current mode the Agent is in. + + +## SessionUpdate + +Different types of updates that can be sent during session processing. + +These updates provide real-time feedback about the agent's progress. + +See protocol docs: [Agent Reports Output](https://agentclientprotocol.com/protocol/prompt-turn#3-agent-reports-output) + +**Type:** Union + + + A chunk of the user's message being streamed. + + + + The \_meta property is reserved by ACP to allow clients and agents to attach additional + metadata to their interactions. Implementations MUST NOT make assumptions about values at + these keys. + + See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + + ContentBlock} required> + A single item of content + + + + + + + + + A chunk of the agent's response being streamed. + + + + The \_meta property is reserved by ACP to allow clients and agents to attach additional + metadata to their interactions. Implementations MUST NOT make assumptions about values at + these keys. + + See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + + ContentBlock} required> + A single item of content + + + + + + + + + A chunk of the agent's internal reasoning being streamed. + + + + The \_meta property is reserved by ACP to allow clients and agents to attach additional + metadata to their interactions. Implementations MUST NOT make assumptions about values at + these keys. + + See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + + ContentBlock} required> + A single item of content + + + + + + + + + Notification that a new tool call has been initiated. + + + + The \_meta property is reserved by ACP to allow clients and agents to attach additional + metadata to their interactions. Implementations MUST NOT make assumptions about values at + these keys. + + See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + + ToolCallContent[]}> + Content produced by the tool call. + + + ToolKind}> + The category of tool being invoked. + Helps clients choose appropriate icons and UI treatment. + + + ToolCallLocation[]}> + File locations affected by this tool call. + Enables "follow-along" features in clients. + + + + Raw input parameters sent to the tool. + + + + Raw output returned by the tool. + + + + + ToolCallStatus}> + Current execution status of the tool call. + + + + Human-readable title describing what the tool is doing. + + + ToolCallId} required> + Unique identifier for this tool call within the session. + + + + + + + Update on the status or results of a tool call. + + + + The \_meta property is reserved by ACP to allow clients and agents to attach additional + metadata to their interactions. Implementations MUST NOT make assumptions about values at + these keys. + + See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + + ToolCallContent[] | null}> + Replace the content collection. + + + ToolKind | null}> + Update the tool kind. + + + ToolCallLocation[] | null}> + Replace the locations collection. + + + + Update the raw input. + + + + Update the raw output. + + + + + ToolCallStatus | null}> + Update the execution status. + + + + Update the human-readable title. + + + ToolCallId} required> + The ID of the tool call being updated. + + + + + + + The agent's execution plan for complex tasks. + See protocol docs: [Agent Plan](https://agentclientprotocol.com/protocol/agent-plan) + + + + The \_meta property is reserved by ACP to allow clients and agents to attach additional + metadata to their interactions. Implementations MUST NOT make assumptions about values at + these keys. + + See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + + PlanEntry[]} required> + The list of tasks to be accomplished. + + When updating a plan, the agent must send a complete list of all entries + with their current status. The client replaces the entire plan with each update. + + + + + + + + + Available commands are ready or have changed + + + + The \_meta property is reserved by ACP to allow clients and agents to attach additional + metadata to their interactions. Implementations MUST NOT make assumptions about values at + these keys. + + See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + + AvailableCommand[]} required> + Commands the agent can execute + + + + + + + + + The current mode of the session has changed + +See protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes) + + + + The \_meta property is reserved by ACP to allow clients and agents to attach additional + metadata to their interactions. Implementations MUST NOT make assumptions about values at + these keys. + + See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + + SessionModeId} required> + The ID of the current mode + + + + + + + +## StopReason + +Reasons why an agent stops processing a prompt turn. + +See protocol docs: [Stop Reasons](https://agentclientprotocol.com/protocol/prompt-turn#stop-reasons) + +**Type:** Union + + + The turn ended successfully. + + + + The turn ended because the agent reached the maximum number of tokens. + + + + The turn ended because the agent reached the maximum number of allowed agent + requests between user turns. + + + + The turn ended because the agent refused to continue. The user prompt and + everything that comes after it won't be included in the next prompt, so this + should be reflected in the UI. + + + + The turn was cancelled by the client via `session/cancel`. + +This stop reason MUST be returned when the client sends a `session/cancel` +notification, even if the cancellation causes exceptions in underlying operations. +Agents should catch these exceptions and return this semantically meaningful +response to confirm successful cancellation. + + +## Terminal + +Embed a terminal created with `terminal/create` by its id. + +The terminal must be added before calling `terminal/release`. + +See protocol docs: [Terminal](https://agentclientprotocol.com/protocol/terminals) + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + + + +## TerminalExitStatus + +Exit status of a terminal command. + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + + +The process exit code (may be null if terminated by signal). + +- Minimum: `0` + + + +The signal that terminated the process (may be null if exited normally). + + +## TextContent + +Text provided to or from an LLM. + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + +Annotations | null} /> + + + +## TextResourceContents + +Text-based resource contents. + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + + + + + + + +## ToolCall + +Represents a tool call that the language model has requested. + +Tool calls are actions that the agent executes on behalf of the language model, +such as reading files, executing code, or fetching data from external sources. + +See protocol docs: [Tool Calls](https://agentclientprotocol.com/protocol/tool-calls) + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + +ToolCallContent[]}> +Content produced by the tool call. + + +ToolKind}> +The category of tool being invoked. +Helps clients choose appropriate icons and UI treatment. + + +ToolCallLocation[]}> +File locations affected by this tool call. +Enables "follow-along" features in clients. + + + +Raw input parameters sent to the tool. + + + +Raw output returned by the tool. + + +ToolCallStatus}> +Current execution status of the tool call. + + + +Human-readable title describing what the tool is doing. + + +ToolCallId} required> +Unique identifier for this tool call within the session. + + +## ToolCallContent + +Content produced by a tool call. + +Tool calls can produce different types of content including +standard content blocks (text, images) or file diffs. + +See protocol docs: [Content](https://agentclientprotocol.com/protocol/tool-calls#content) + +**Type:** Union + + + Standard content block (text, images, resources). + + + + The \_meta property is reserved by ACP to allow clients and agents to attach additional + metadata to their interactions. Implementations MUST NOT make assumptions about values at + these keys. + + See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + + ContentBlock} required> + The actual content block. + + + + + + + + + File modification shown as a diff. + + + + The \_meta property is reserved by ACP to allow clients and agents to attach additional + metadata to their interactions. Implementations MUST NOT make assumptions about values at + these keys. + + See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + + + The new content after modification. + + + + The original content (None for new files). + + + + The file path being modified. + + + + + + + + + Embed a terminal created with `terminal/create` by its id. + +The terminal must be added before calling `terminal/release`. + +See protocol docs: [Terminal](https://agentclientprotocol.com/protocol/terminals) + + + + The \_meta property is reserved by ACP to allow clients and agents to attach additional + metadata to their interactions. Implementations MUST NOT make assumptions about values at + these keys. + + See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + + + + + + + + +## ToolCallId + +Unique identifier for a tool call within a session. + +**Type:** `string` + +## ToolCallLocation + +A file location being accessed or modified by a tool. + +Enables clients to implement "follow-along" features that track +which files the agent is working with in real-time. + +See protocol docs: [Following the Agent](https://agentclientprotocol.com/protocol/tool-calls#following-the-agent) + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + + +Optional line number within the file. + +- Minimum: `0` + + + +The file path being accessed or modified. + + +## ToolCallStatus + +Execution status of a tool call. + +Tool calls progress through different statuses during their lifecycle. + +See protocol docs: [Status](https://agentclientprotocol.com/protocol/tool-calls#status) + +**Type:** Union + + + The tool call hasn't started running yet because the input is either streaming + or we're awaiting approval. + + + + The tool call is currently running. + + + + The tool call completed successfully. + + + + The tool call failed with an error. + + +## ToolCallUpdate + +An update to an existing tool call. + +Used to report progress and results as tools execute. All fields except +the tool call ID are optional - only changed fields need to be included. + +See protocol docs: [Updating](https://agentclientprotocol.com/protocol/tool-calls#updating) + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + +ToolCallContent[] | null}> +Replace the content collection. + + +ToolKind | null}> +Update the tool kind. + + +ToolCallLocation[] | null}> +Replace the locations collection. + + + +Update the raw input. + + + +Update the raw output. + + +ToolCallStatus | null}> +Update the execution status. + + + +Update the human-readable title. + + +ToolCallId} required> +The ID of the tool call being updated. + + +## ToolKind + +Categories of tools that can be invoked. + +Tool kinds help clients choose appropriate icons and optimize how they +display tool execution progress. + +See protocol docs: [Creating](https://agentclientprotocol.com/protocol/tool-calls#creating) + +**Type:** Union + + + Reading files or data. + + + + Modifying files or content. + + + + Removing files or data. + + + + Moving or renaming files. + + + + Searching for information. + + + + Running commands or code. + + + + Internal reasoning or planning. + + + + Retrieving external data. + + + + Switching the current session mode. + + + + Other tool types (default). + + +## UnstructuredCommandInput + +All text that was typed after the command name is provided as input. + +**Type:** Object + +**Properties:** + + +The \_meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + + +A hint to display when the input hasn't been provided yet + + +--- + +> To find navigation and other pages in this documentation, fetch the llms.txt file at: https://agentclientprotocol.com/llms.txt diff --git a/apps/cli/src/acp/docs/session-modes.md b/apps/cli/src/acp/docs/session-modes.md new file mode 100644 index 00000000000..916e44bd96b --- /dev/null +++ b/apps/cli/src/acp/docs/session-modes.md @@ -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" + } + ] + } + } +} +``` + + + The current mode state for the session + + +### SessionModeState + + + The ID of the mode that is currently active + + + + The set of modes that the Agent can operate in + + +### SessionMode + + + Unique identifier for this mode + + + + Human-readable name of the mode + + + + Optional description providing more details about what this mode does + + +## 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" + } +} +``` + + + The ID of the session to set the mode for + + + + The ID of the mode to switch to. Must be one of the modes listed in + `availableModes` + + +### 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. + + + Learn more about permission requests + + +--- + +> To find navigation and other pages in this documentation, fetch the llms.txt file at: https://agentclientprotocol.com/llms.txt diff --git a/apps/cli/src/acp/docs/session-setup.md b/apps/cli/src/acp/docs/session-setup.md new file mode 100644 index 00000000000..535bfce3ffe --- /dev/null +++ b/apps/cli/src/acp/docs/session-setup.md @@ -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. + +
+ +```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 +``` + +
+ +## 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. + + + A human-readable identifier for the server + + + + The absolute path to the MCP server executable + + + + Command-line arguments to pass to the server + + + + Environment variables to set when launching the server + + + + The name of the environment variable. + + + + The value of the environment variable. + + + + + +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. + + + Must be `"http"` to indicate HTTP transport + + + + A human-readable identifier for the server + + + + The URL of the MCP server + + + + HTTP headers to include in requests to the server + + + + The name of the HTTP header. + + + + The value to set for the HTTP header. + + + + + +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. + +This transport was deprecated by the MCP spec. + + + Must be `"sse"` to indicate SSE transport + + + + A human-readable identifier for the server + + + + The URL of the SSE endpoint + + + + HTTP headers to include when establishing the SSE connection + + + + The name of the HTTP header. + + + + The value to set for the HTTP header. + + + + + +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 diff --git a/apps/cli/src/acp/docs/slash-commands.md b/apps/cli/src/acp/docs/slash-commands.md new file mode 100644 index 00000000000..d11c7b20308 --- /dev/null +++ b/apps/cli/src/acp/docs/slash-commands.md @@ -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" + } + } + ] + } + } +} +``` + + + The list of commands available in this session + + +### AvailableCommand + + + The command name (e.g., "web", "test", "plan") + + + + Human-readable description of what the command does + + + + Optional input specification for the command + + +### AvailableCommandInput + +Currently supports unstructured text input: + + + A hint to display when the input hasn't been provided yet + + +## 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 diff --git a/apps/cli/src/acp/docs/terminals.md b/apps/cli/src/acp/docs/terminals.md new file mode 100644 index 00000000000..e4dcb48a0a6 --- /dev/null +++ b/apps/cli/src/acp/docs/terminals.md @@ -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 + } +} +``` + + + The [Session ID](./session-setup#session-id) for this request + + + + The command to execute + + + + Array of command arguments + + + + Environment variables for the command. + +Each variable has: + +- `name`: The environment variable name +- `value`: The environment variable value + + + + Working directory for the command (absolute path) + + + + 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. + + +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. + + + The Agent **MUST** release the terminal using `terminal/release` when it's no + longer needed. + + +## 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 + } + } +} +``` + + + The terminal output captured so far + + + + Whether the output was truncated due to byte limits + + + + 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) + + +## 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 + } +} +``` + + + The process exit code (may be null if terminated by signal) + + + + The signal that terminated the process (may be null if exited normally) + + +## 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 diff --git a/apps/cli/src/acp/docs/tool-calls.md b/apps/cli/src/acp/docs/tool-calls.md new file mode 100644 index 00000000000..ee784ab101d --- /dev/null +++ b/apps/cli/src/acp/docs/tool-calls.md @@ -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" + } + } +} +``` + + + A unique identifier for this tool call within the session + + + + A human-readable title describing what the tool is doing + + + + The category of tool being invoked. + + + * `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) + + +Tool kinds help Clients choose appropriate icons and optimize how they display tool execution progress. + + + + The current [execution status](#status) (defaults to `pending`) + + + + [Content produced](#content) by the tool call + + + + [File locations](#following-the-agent) affected by this tool call + + + + The raw input parameters sent to the tool + + + + The raw output returned by the tool + + +## 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" + } + ] + } +} +``` + + + The session ID for this request + + + + The tool call update containing details about the operation + + + + Available [permission options](#permission-options) for the user to choose + from + + +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" + } + } +} +``` + + + 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 + + +### Permission Options + +Each permission option provided to the Client contains: + + + Unique identifier for this option + + + + Human-readable label to display to the user + + + + 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 + + +## Status + +Tool calls progress through different statuses during their lifecycle: + + + The tool call hasn't started running yet because the input is either streaming + or awaiting approval + + + + The tool call is currently running + + + + The tool call completed successfully + + +The tool call failed with an error + +## 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}" +} +``` + + + The absolute file path being modified + + + + The original content (null for new files) + + + + The new content after modification + + +### Terminals + +Live terminal output from command execution: + +```json theme={null} +{ + "type": "terminal", + "terminalId": "term_xyz789" +} +``` + + + The ID of a terminal created with `terminal/create` + + +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. + + + Learn more about Terminals + + +## 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 +} +``` + + + The absolute file path being accessed or modified + + + + Optional line number within the file + + +--- + +> To find navigation and other pages in this documentation, fetch the llms.txt file at: https://agentclientprotocol.com/llms.txt diff --git a/apps/cli/src/acp/docs/transports.md b/apps/cli/src/acp/docs/transports.md new file mode 100644 index 00000000000..4056d21ccab --- /dev/null +++ b/apps/cli/src/acp/docs/transports.md @@ -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 diff --git a/apps/cli/src/acp/file-system-service.ts b/apps/cli/src/acp/file-system-service.ts new file mode 100644 index 00000000000..71e3502c726 --- /dev/null +++ b/apps/cli/src/acp/file-system-service.ts @@ -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 { + // 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 { + // 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) +} diff --git a/apps/cli/src/acp/index.ts b/apps/cli/src/acp/index.ts new file mode 100644 index 00000000000..1096782a15f --- /dev/null +++ b/apps/cli/src/acp/index.ts @@ -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" diff --git a/apps/cli/src/acp/logger.ts b/apps/cli/src/acp/logger.ts new file mode 100644 index 00000000000..041355f60aa --- /dev/null +++ b/apps/cli/src/acp/logger.ts @@ -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()}`) diff --git a/apps/cli/src/acp/session.ts b/apps/cli/src/acp/session.ts new file mode 100644 index 00000000000..3e14b95921b --- /dev/null +++ b/apps/cli/src/acp/session.ts @@ -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> = { + // 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 = 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 { + 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 { + 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 + + // 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 | 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 | 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() + 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 { + 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 { + 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 { + 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 { + 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 + } +} diff --git a/apps/cli/src/acp/terminal-manager.ts b/apps/cli/src/acp/terminal-manager.ts new file mode 100644 index 00000000000..bca29111c4f --- /dev/null +++ b/apps/cli/src/acp/terminal-manager.ts @@ -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 = 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 { + 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 { + 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 { + 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 { + 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, + } + } +} diff --git a/apps/cli/src/acp/translator.ts b/apps/cli/src/acp/translator.ts new file mode 100644 index 00000000000..eb803581abb --- /dev/null +++ b/apps/cli/src/acp/translator.ts @@ -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 + 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 + 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 = { + // 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, 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() + + // 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, + 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 +} diff --git a/apps/cli/src/acp/update-buffer.ts b/apps/cli/src/acp/update-buffer.ts new file mode 100644 index 00000000000..dc96c7affd3 --- /dev/null +++ b/apps/cli/src/acp/update-buffer.ts @@ -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 | null = null + /** Callback to send updates */ + private readonly sendUpdate: (update: SessionUpdate) => Promise + /** Track if we have pending buffered content */ + private hasPendingContent = false + + constructor(sendUpdate: (update: SessionUpdate) => Promise, 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 { + 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 { + 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 + } + } +} diff --git a/apps/cli/src/commands/acp/index.ts b/apps/cli/src/commands/acp/index.ts new file mode 100644 index 00000000000..bc0b10954b4 --- /dev/null +++ b/apps/cli/src/commands/acp/index.ts @@ -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 { + 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 + const stdin = Readable.toWeb(process.stdin) as ReadableStream + + // 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 { + 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) + } +} diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 8d3f5af521e..4a817109089 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -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 to the extension bundle directory") + .option("-p, --provider ", "API provider (anthropic, openai, openrouter, etc.)", DEFAULT_FLAGS.provider) + .option("-m, --model ", "Model to use", DEFAULT_FLAGS.model) + .option("-M, --mode ", "Initial mode (code, architect, ask, debug)", DEFAULT_FLAGS.mode) + .option("-k, --api-key ", "API key for the LLM provider") + .action(acp) + program.parse() diff --git a/apps/cli/src/types/constants.ts b/apps/cli/src/types/constants.ts index 5b3dc577786..04abb90ad4e 100644 --- a/apps/cli/src/types/constants.ts +++ b/apps/cli/src/types/constants.ts @@ -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"] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 177d0b3e5ab..3dc925f38c6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 From e6b1d20bd6dbf65ed26dcd7466f928729c450a53 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Sun, 11 Jan 2026 07:45:21 +0000 Subject: [PATCH 02/17] fix: use DEFAULT_FLAGS.model for model fallback in agent.ts --- apps/cli/src/acp/agent.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/acp/agent.ts b/apps/cli/src/acp/agent.ts index 5094b491f4b..3b66933f591 100644 --- a/apps/cli/src/acp/agent.ts +++ b/apps/cli/src/acp/agent.ts @@ -9,6 +9,7 @@ import * as acp from "@agentclientprotocol/sdk" import { randomUUID } from "node:crypto" import { login, status } from "@/commands/auth/index.js" +import { DEFAULT_FLAGS } from "@/types/constants.js" import { AcpSession, type AcpSessionOptions } from "./session.js" import { acpLog } from "./logger.js" @@ -207,7 +208,7 @@ export class RooCodeAgent implements acp.Agent { 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", + model: this.options.model || DEFAULT_FLAGS.model, mode: this.options.mode || "code", } From 22fa95f692b89df68370cbe7b93def118e8d0cc2 Mon Sep 17 00:00:00 2001 From: cte Date: Sat, 10 Jan 2026 23:51:23 -0800 Subject: [PATCH 03/17] Remove cruft --- .../acp/__tests__/terminal-manager.test.ts | 287 -- apps/cli/src/acp/docs/agent-plan.md | 84 - apps/cli/src/acp/docs/content.md | 207 -- apps/cli/src/acp/docs/extensibility.md | 137 - apps/cli/src/acp/docs/file-system.md | 118 - apps/cli/src/acp/docs/initialization.md | 225 -- apps/cli/src/acp/docs/llms.txt | 50 - apps/cli/src/acp/docs/overview.md | 165 - apps/cli/src/acp/docs/prompt-turn.md | 321 -- apps/cli/src/acp/docs/schema.md | 3195 ----------------- apps/cli/src/acp/docs/session-modes.md | 170 - apps/cli/src/acp/docs/session-setup.md | 384 -- apps/cli/src/acp/docs/slash-commands.md | 99 - apps/cli/src/acp/docs/terminals.md | 281 -- apps/cli/src/acp/docs/tool-calls.md | 311 -- apps/cli/src/acp/docs/transports.md | 55 - apps/cli/src/acp/file-system-service.ts | 148 - apps/cli/src/acp/index.ts | 2 - apps/cli/src/acp/terminal-manager.ts | 322 -- 19 files changed, 6561 deletions(-) delete mode 100644 apps/cli/src/acp/__tests__/terminal-manager.test.ts delete mode 100644 apps/cli/src/acp/docs/agent-plan.md delete mode 100644 apps/cli/src/acp/docs/content.md delete mode 100644 apps/cli/src/acp/docs/extensibility.md delete mode 100644 apps/cli/src/acp/docs/file-system.md delete mode 100644 apps/cli/src/acp/docs/initialization.md delete mode 100644 apps/cli/src/acp/docs/llms.txt delete mode 100644 apps/cli/src/acp/docs/overview.md delete mode 100644 apps/cli/src/acp/docs/prompt-turn.md delete mode 100644 apps/cli/src/acp/docs/schema.md delete mode 100644 apps/cli/src/acp/docs/session-modes.md delete mode 100644 apps/cli/src/acp/docs/session-setup.md delete mode 100644 apps/cli/src/acp/docs/slash-commands.md delete mode 100644 apps/cli/src/acp/docs/terminals.md delete mode 100644 apps/cli/src/acp/docs/tool-calls.md delete mode 100644 apps/cli/src/acp/docs/transports.md delete mode 100644 apps/cli/src/acp/file-system-service.ts delete mode 100644 apps/cli/src/acp/terminal-manager.ts diff --git a/apps/cli/src/acp/__tests__/terminal-manager.test.ts b/apps/cli/src/acp/__tests__/terminal-manager.test.ts deleted file mode 100644 index e4a08149821..00000000000 --- a/apps/cli/src/acp/__tests__/terminal-manager.test.ts +++ /dev/null @@ -1,287 +0,0 @@ -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 - waitForExit: ReturnType - kill: ReturnType - release: ReturnType -} - -interface MockConnection { - createTerminal: ReturnType - 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() - }) - }) -}) diff --git a/apps/cli/src/acp/docs/agent-plan.md b/apps/cli/src/acp/docs/agent-plan.md deleted file mode 100644 index c1943520b91..00000000000 --- a/apps/cli/src/acp/docs/agent-plan.md +++ /dev/null @@ -1,84 +0,0 @@ -# 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" - } - ] - } - } -} -``` - - - An array of [plan entries](#plan-entries) representing the tasks to be - accomplished - - -## Plan Entries - -Each plan entry represents a specific task or goal within the overall execution strategy: - - - A human-readable description of what this task aims to accomplish - - - - The relative importance of this task. - -- `high` -- `medium` -- `low` - - - - The current [execution status](#status) of this task - -- `pending` -- `in_progress` -- `completed` - - -## 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 diff --git a/apps/cli/src/acp/docs/content.md b/apps/cli/src/acp/docs/content.md deleted file mode 100644 index 517fad820a8..00000000000 --- a/apps/cli/src/acp/docs/content.md +++ /dev/null @@ -1,207 +0,0 @@ -# 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. - - - The text content to display - - - - Optional metadata about how the content should be used or displayed. [Learn - more](https://modelcontextprotocol.io/specification/2025-06-18/server/resources#annotations). - - -### Image Content - -Images can be included for visual context or analysis. - -```json theme={null} -{ - "type": "image", - "mimeType": "image/png", - "data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB..." -} -``` - - Requires the `image` [prompt -capability](./initialization#prompt-capabilities) when included in prompts. - - - Base64-encoded image data - - - - The MIME type of the image (e.g., "image/png", "image/jpeg") - - - - Optional URI reference for the image source - - - - Optional metadata about how the content should be used or displayed. [Learn - more](https://modelcontextprotocol.io/specification/2025-06-18/server/resources#annotations). - - -### Audio Content - -Audio data for transcription or analysis. - -```json theme={null} -{ - "type": "audio", - "mimeType": "audio/wav", - "data": "UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAAB..." -} -``` - - Requires the `audio` [prompt -capability](./initialization#prompt-capabilities) when included in prompts. - - - Base64-encoded audio data - - - - The MIME type of the audio (e.g., "audio/wav", "audio/mp3") - - - - Optional metadata about how the content should be used or displayed. [Learn - more](https://modelcontextprotocol.io/specification/2025-06-18/server/resources#annotations). - - -### Embedded Resource - -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. - - Requires the `embeddedContext` [prompt -capability](./initialization#prompt-capabilities) when included in prompts. - - - The embedded resource contents, which can be either: - - - - The URI identifying the resource - - - - The text content of the resource - - - - Optional MIME type of the text content - - - - - - - The URI identifying the resource - - - - Base64-encoded binary data - - - - Optional MIME type of the blob - - - - - - - Optional metadata about how the content should be used or displayed. [Learn - more](https://modelcontextprotocol.io/specification/2025-06-18/server/resources#annotations). - - -### 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 -} -``` - - - The URI of the resource - - - - A human-readable name for the resource - - - - The MIME type of the resource - - - - Optional display title for the resource - - - - Optional description of the resource contents - - - - Optional size of the resource in bytes - - - - Optional metadata about how the content should be used or displayed. [Learn - more](https://modelcontextprotocol.io/specification/2025-06-18/server/resources#annotations). - - ---- - -> To find navigation and other pages in this documentation, fetch the llms.txt file at: https://agentclientprotocol.com/llms.txt diff --git a/apps/cli/src/acp/docs/extensibility.md b/apps/cli/src/acp/docs/extensibility.md deleted file mode 100644 index e8ab4f81979..00000000000 --- a/apps/cli/src/acp/docs/extensibility.md +++ /dev/null @@ -1,137 +0,0 @@ -# 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 diff --git a/apps/cli/src/acp/docs/file-system.md b/apps/cli/src/acp/docs/file-system.md deleted file mode 100644 index 48ce87e0d00..00000000000 --- a/apps/cli/src/acp/docs/file-system.md +++ /dev/null @@ -1,118 +0,0 @@ -# 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 - } -} -``` - - - The [Session ID](./session-setup#session-id) for this request - - - - Absolute path to the file to read - - - - Optional line number to start reading from (1-based) - - - - Optional maximum number of lines to read - - -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}" - } -} -``` - - - The [Session ID](./session-setup#session-id) for this request - - - - Absolute path to the file to write. - -The Client **MUST** create the file if it doesn't exist. - - - - The text content to write to the file - - -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 diff --git a/apps/cli/src/acp/docs/initialization.md b/apps/cli/src/acp/docs/initialization.md deleted file mode 100644 index 29bd88e37cb..00000000000 --- a/apps/cli/src/acp/docs/initialization.md +++ /dev/null @@ -1,225 +0,0 @@ -# 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. - -
- -```mermaid theme={null} -sequenceDiagram - participant Client - participant Agent - - Note over Client, Agent: Connection established - Client->>Agent: initialize - Note right of Agent: Negotiate protocol
version & capabilities - Agent-->>Client: initialize response - Note over Client,Agent: Ready for session setup -``` - -
- -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 - - - The `fs/read_text_file` method is available. - - - - The `fs/write_text_file` method is available. - - - - Learn more about File System methods - - -#### Terminal - - - All `terminal/*` methods are available, allowing the Agent to execute and - manage shell commands. - - - - Learn more about Terminals - - -### Agent Capabilities - -The Agent **SHOULD** specify whether it supports the following capabilities: - - -The [`session/load`](./session-setup#loading-sessions) method is available. - - - - Object indicating the different types of [content](./content) that may be - included in `session/prompt` requests. - - -#### 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: - - -The prompt may include `ContentBlock::Image` - - - -The prompt may include `ContentBlock::Audio` - - - -The prompt may include `ContentBlock::Resource` - - -#### MCP capabilities - - -The Agent supports connecting to MCP servers over HTTP. - - - -The Agent supports connecting to MCP servers over SSE. - -Note: This transport has been deprecated by the MCP spec. - - -#### 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. - - - `session/load` is still handled by the top-level `load_session` capability. - This will be unified in future versions of the protocol. - - -## 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: - - - Intended for programmatic or logical use, but can be used as a display name - fallback if title isn’t present. - - - - 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. - - - - Version of the implementation. Can be displayed to the user or used for - debugging or metrics purposes. - - - - Note: in future versions of the protocol, this information will be required. - - ---- - -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 diff --git a/apps/cli/src/acp/docs/llms.txt b/apps/cli/src/acp/docs/llms.txt deleted file mode 100644 index 62c7e87b82d..00000000000 --- a/apps/cli/src/acp/docs/llms.txt +++ /dev/null @@ -1,50 +0,0 @@ -# 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 diff --git a/apps/cli/src/acp/docs/overview.md b/apps/cli/src/acp/docs/overview.md deleted file mode 100644 index d9d321f3c4b..00000000000 --- a/apps/cli/src/acp/docs/overview.md +++ /dev/null @@ -1,165 +0,0 @@ -# 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: - - - - * Client → Agent: `initialize` to establish connection - * Client → Agent: `authenticate` if required by the Agent - - - - * Client → Agent: `session/new` to create a new session - * Client → Agent: `session/load` to resume an existing session if supported - - - - * 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 - - - -## Agent - -Agents are programs that use generative AI to autonomously modify code. They typically run as subprocesses of the Client. - -### Baseline Methods - -Schema]}> -[Negotiate versions and exchange capabilities.](./initialization). - - -Schema]}> -Authenticate with the Agent (if required). - - -Schema]}> -[Create a new conversation session](./session-setup#creating-a-session). - - -Schema]}> -[Send user prompts](./prompt-turn#1-user-message) to the Agent. - - -### Optional Methods - -Schema]}> -[Load an existing session](./session-setup#loading-sessions) (requires -`loadSession` capability). - - -Schema]}> -[Switch between agent operating -modes](./session-modes#setting-the-current-mode). - - -### Notifications - -Schema]}> -[Cancel ongoing operations](./prompt-turn#cancellation) (no response -expected). - - -## 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 - -Schema]}> -[Request user authorization](./tool-calls#requesting-permission) for tool -calls. - - -### Optional Methods - -Schema]}> -[Read file contents](./file-system#reading-files) (requires `fs.readTextFile` -capability). - - -Schema]}> -[Write file contents](./file-system#writing-files) (requires -`fs.writeTextFile` capability). - - -Schema]}> -[Create a new terminal](./terminals) (requires `terminal` capability). - - -Schema]}> -Get terminal output and exit status (requires `terminal` capability). - - -Schema]}> -Release a terminal (requires `terminal` capability). - - -Schema]}> -Wait for terminal command to exit (requires `terminal` capability). - - -Schema]}> -Kill terminal command without releasing (requires `terminal` capability). - - -### Notifications - -Schema]}> -[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) - - -## 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 diff --git a/apps/cli/src/acp/docs/prompt-turn.md b/apps/cli/src/acp/docs/prompt-turn.md deleted file mode 100644 index ad5db75d138..00000000000 --- a/apps/cli/src/acp/docs/prompt-turn.md +++ /dev/null @@ -1,321 +0,0 @@ -# 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. - -
- -```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
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
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)" - } - } - ] - } -} -``` - - - The [ID](./session-setup#session-id) of the session to send this message to. - - - - 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). - - - Learn more about Content - - - -### 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" - } - ] - } - } -} -``` - - - Learn more about Agent Plans - - -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" - } - } - ] - } - } -} -``` - - - Learn more about Tool Calls - - -### 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`: - - - The language model finishes responding without requesting more tools - - - - The maximum token limit is reached - - - - The maximum number of model requests in a single turn is exceeded - - -The Agent refuses to continue - -The Client cancels the turn - -## 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). - - - 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. - - -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 diff --git a/apps/cli/src/acp/docs/schema.md b/apps/cli/src/acp/docs/schema.md deleted file mode 100644 index b18e2590493..00000000000 --- a/apps/cli/src/acp/docs/schema.md +++ /dev/null @@ -1,3195 +0,0 @@ -# Schema - -> Schema definitions for the Agent Client Protocol - -## Agent - -Defines the interface that all ACP-compliant agents must implement. - -Agents are programs that use generative AI to autonomously modify code. They handle -requests from clients and execute tasks using language models and tools. - -### authenticate - -Authenticates the client using the specified authentication method. - -Called when the agent requires authentication before allowing session creation. -The client provides the authentication method ID that was advertised during initialization. - -After successful authentication, the client can proceed to create sessions with -`new_session` without receiving an `auth_required` error. - -See protocol docs: [Initialization](https://agentclientprotocol.com/protocol/initialization) - -#### AuthenticateRequest - -Request parameters for the authenticate method. - -Specifies which authentication method to use. - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - - -The ID of the authentication method to use. -Must be one of the methods advertised in the initialize response. - - -#### AuthenticateResponse - -Response to the `authenticate` method. - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - -### initialize - -Establishes the connection with a client and negotiates protocol capabilities. - -This method is called once at the beginning of the connection to: - -- Negotiate the protocol version to use -- Exchange capability information between client and agent -- Determine available authentication methods - -The agent should respond with its supported protocol version and capabilities. - -See protocol docs: [Initialization](https://agentclientprotocol.com/protocol/initialization) - -#### InitializeRequest - -Request parameters for the initialize method. - -Sent by the client to establish connection and negotiate capabilities. - -See protocol docs: [Initialization](https://agentclientprotocol.com/protocol/initialization) - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - -ClientCapabilities}> -Capabilities supported by the client. - -- Default: `{"fs":{"readTextFile":false,"writeTextFile":false},"terminal":false}` - - -Implementation | null}> -Information about the Client name and version sent to the Agent. - -Note: in future versions of the protocol, this will be required. - - -ProtocolVersion} required> -The latest protocol version supported by the client. - - -#### InitializeResponse - -Response to the `initialize` method. - -Contains the negotiated protocol version and agent capabilities. - -See protocol docs: [Initialization](https://agentclientprotocol.com/protocol/initialization) - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - -AgentCapabilities}> -Capabilities supported by the agent. - -- Default: `{"loadSession":false,"mcpCapabilities":{"http":false,"sse":false},"promptCapabilities":{"audio":false,"embeddedContext":false,"image":false},"sessionCapabilities":{}}` - - -Implementation | null}> -Information about the Agent name and version sent to the Client. - -Note: in future versions of the protocol, this will be required. - - -AuthMethod[]}> -Authentication methods supported by the agent. - -- Default: `[]` - - -ProtocolVersion} required> -The protocol version the client specified if supported by the agent, -or the latest protocol version supported by the agent. - -The client should disconnect, if it doesn't support this version. - - - - -### session/cancel - -Cancels ongoing operations for a session. - -This is a notification sent by the client to cancel an ongoing prompt turn. - -Upon receiving this notification, the Agent SHOULD: - -- Stop all language model requests as soon as possible -- Abort all tool call invocations in progress -- Send any pending `session/update` notifications -- Respond to the original `session/prompt` request with `StopReason::Cancelled` - -See protocol docs: [Cancellation](https://agentclientprotocol.com/protocol/prompt-turn#cancellation) - -#### CancelNotification - -Notification to cancel ongoing operations for a session. - -See protocol docs: [Cancellation](https://agentclientprotocol.com/protocol/prompt-turn#cancellation) - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - -SessionId} required> -The ID of the session to cancel operations for. -
- - - -### session/load - -Loads an existing session to resume a previous conversation. - -This method is only available if the agent advertises the `loadSession` capability. - -The agent should: - -- Restore the session context and conversation history -- Connect to the specified MCP servers -- Stream the entire conversation history back to the client via notifications - -See protocol docs: [Loading Sessions](https://agentclientprotocol.com/protocol/session-setup#loading-sessions) - -#### LoadSessionRequest - -Request parameters for loading an existing session. - -Only available if the Agent supports the `loadSession` capability. - -See protocol docs: [Loading Sessions](https://agentclientprotocol.com/protocol/session-setup#loading-sessions) - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - - -The working directory for this session. - - -McpServer[]} required> -List of MCP servers to connect to for this session. -
- -SessionId} required> -The ID of the session to load. - - -#### LoadSessionResponse - -Response from loading an existing session. - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - -SessionModeState | null}> -Initial mode state if supported by the Agent - -See protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes) - - - - -### session/new - -Creates a new conversation session with the agent. - -Sessions represent independent conversation contexts with their own history and state. - -The agent should: - -- Create a new session context -- Connect to any specified MCP servers -- Return a unique session ID for future requests - -May return an `auth_required` error if the agent requires authentication. - -See protocol docs: [Session Setup](https://agentclientprotocol.com/protocol/session-setup) - -#### NewSessionRequest - -Request parameters for creating a new session. - -See protocol docs: [Creating a Session](https://agentclientprotocol.com/protocol/session-setup#creating-a-session) - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - - -The working directory for this session. Must be an absolute path. - - -McpServer[]} required> -List of MCP (Model Context Protocol) servers the agent should connect to. -
- -#### NewSessionResponse - -Response from creating a new session. - -See protocol docs: [Creating a Session](https://agentclientprotocol.com/protocol/session-setup#creating-a-session) - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - -SessionModeState | null}> -Initial mode state if supported by the Agent - -See protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes) - - -SessionId} required> -Unique identifier for the created session. - -Used in all subsequent requests for this conversation. - - - - -### session/prompt - -Processes a user prompt within a session. - -This method handles the whole lifecycle of a prompt: - -- Receives user messages with optional context (files, images, etc.) -- Processes the prompt using language models -- Reports language model content and tool calls to the Clients -- Requests permission to run tools -- Executes any requested tool calls -- Returns when the turn is complete with a stop reason - -See protocol docs: [Prompt Turn](https://agentclientprotocol.com/protocol/prompt-turn) - -#### PromptRequest - -Request parameters for sending a user prompt to the agent. - -Contains the user's message and any additional context. - -See protocol docs: [User Message](https://agentclientprotocol.com/protocol/prompt-turn#1-user-message) - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - -ContentBlock[]} required> -The blocks of content that compose the user's message. - -As a baseline, the Agent MUST support `ContentBlock::Text` and `ContentBlock::ResourceLink`, -while other variants are optionally enabled via `PromptCapabilities`. - -The Client MUST adapt its interface according to `PromptCapabilities`. - -The client MAY include referenced pieces of context as either -`ContentBlock::Resource` or `ContentBlock::ResourceLink`. - -When available, `ContentBlock::Resource` is preferred -as it avoids extra round-trips and allows the message to include -pieces of context from sources the agent may not have access to. -
- -SessionId} required> -The ID of the session to send this user message to - - -#### PromptResponse - -Response from processing a user prompt. - -See protocol docs: [Check for Completion](https://agentclientprotocol.com/protocol/prompt-turn#4-check-for-completion) - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - -StopReason} required> -Indicates why the agent stopped processing the turn. - - - - -### session/set_mode - -Sets the current mode for a session. - -Allows switching between different agent modes (e.g., "ask", "architect", "code") -that affect system prompts, tool availability, and permission behaviors. - -The mode must be one of the modes advertised in `availableModes` during session -creation or loading. Agents may also change modes autonomously and notify the -client via `current_mode_update` notifications. - -This method can be called at any time during a session, whether the Agent is -idle or actively generating a response. - -See protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes) - -#### SetSessionModeRequest - -Request parameters for setting a session mode. - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - -SessionModeId} required> -The ID of the mode to set. -
- -SessionId} required> -The ID of the session to set the mode for. - - -#### SetSessionModeResponse - -Response to `session/set_mode` method. - -**Type:** Object - -**Properties:** - - - -## Client - -Defines the interface that ACP-compliant clients must implement. - -Clients are typically code editors (IDEs, text editors) that provide the interface -between users and AI agents. They manage the environment, handle user interactions, -and control access to resources. - - - -### fs/read_text_file - -Reads content from a text file in the client's file system. - -Only available if the client advertises the `fs.readTextFile` capability. -Allows the agent to access file contents within the client's environment. - -See protocol docs: [Client](https://agentclientprotocol.com/protocol/overview#client) - -#### ReadTextFileRequest - -Request to read content from a text file. - -Only available if the client supports the `fs.readTextFile` capability. - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - - -Maximum number of lines to read. - -- Minimum: `0` - - - -Line number to start reading from (1-based). - -- Minimum: `0` - - - -Absolute path to the file to read. - - -SessionId} required> -The session ID for this request. - - -#### ReadTextFileResponse - -Response containing the contents of a text file. - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - - - - - -### fs/write_text_file - -Writes content to a text file in the client's file system. - -Only available if the client advertises the `fs.writeTextFile` capability. -Allows the agent to create or modify files within the client's environment. - -See protocol docs: [Client](https://agentclientprotocol.com/protocol/overview#client) - -#### WriteTextFileRequest - -Request to write content to a text file. - -Only available if the client supports the `fs.writeTextFile` capability. - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - - -The text content to write to the file. - - - -Absolute path to the file to write. - - -SessionId} required> -The session ID for this request. - - -#### WriteTextFileResponse - -Response to `fs/write_text_file` - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - - - -### session/request_permission - -Requests permission from the user for a tool call operation. - -Called by the agent when it needs user authorization before executing -a potentially sensitive operation. The client should present the options -to the user and return their decision. - -If the client cancels the prompt turn via `session/cancel`, it MUST -respond to this request with `RequestPermissionOutcome::Cancelled`. - -See protocol docs: [Requesting Permission](https://agentclientprotocol.com/protocol/tool-calls#requesting-permission) - -#### RequestPermissionRequest - -Request for user permission to execute a tool call. - -Sent when the agent needs authorization before performing a sensitive operation. - -See protocol docs: [Requesting Permission](https://agentclientprotocol.com/protocol/tool-calls#requesting-permission) - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - -PermissionOption[]} required> -Available permission options for the user to choose from. -
- -SessionId} required> -The session ID for this request. - - -ToolCallUpdate} required> -Details about the tool call requiring permission. - - -#### RequestPermissionResponse - -Response to a permission request. - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - -RequestPermissionOutcome} required> -The user's decision on the permission request. - - - - -### session/update - -Handles session update notifications from the agent. - -This is a notification endpoint (no response expected) that receives -real-time updates about session progress, including message chunks, -tool calls, and execution plans. - -Note: Clients SHOULD continue accepting tool call updates even after -sending a `session/cancel` notification, as the agent may send final -updates before responding with the cancelled stop reason. - -See protocol docs: [Agent Reports Output](https://agentclientprotocol.com/protocol/prompt-turn#3-agent-reports-output) - -#### SessionNotification - -Notification containing a session update from the agent. - -Used to stream real-time progress and results during prompt processing. - -See protocol docs: [Agent Reports Output](https://agentclientprotocol.com/protocol/prompt-turn#3-agent-reports-output) - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - -SessionId} required> -The ID of the session this update pertains to. -
- -SessionUpdate} required> -The actual update content. - - - - -### terminal/create - -Executes a command in a new terminal - -Only available if the `terminal` Client capability is set to `true`. - -Returns a `TerminalId` that can be used with other terminal methods -to get the current output, wait for exit, and kill the command. - -The `TerminalId` can also be used to embed the terminal in a tool call -by using the `ToolCallContent::Terminal` variant. - -The Agent is responsible for releasing the terminal by using the `terminal/release` -method. - -See protocol docs: [Terminals](https://agentclientprotocol.com/protocol/terminals) - -#### CreateTerminalRequest - -Request to create a new terminal and execute a command. - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - -"string"[]}> -Array of command arguments. - - - -The command to execute. - - - -Working directory for the command (absolute path). - - -EnvVariable[]}> -Environment variables for the command. -
- - -Maximum number of output bytes to retain. - -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. - -- Minimum: `0` - - -SessionId} required> -The session ID for this request. - - -#### CreateTerminalResponse - -Response containing the ID of the created terminal. - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - - -The unique identifier for the created terminal. - - - - -### terminal/kill - -Kills the terminal command without releasing the terminal - -While `terminal/release` will also kill the command, this method will keep -the `TerminalId` valid so it can be used with other methods. - -This method can be helpful when implementing command timeouts which terminate -the command as soon as elapsed, and then get the final output so it can be sent -to the model. - -Note: `terminal/release` when `TerminalId` is no longer needed. - -See protocol docs: [Terminals](https://agentclientprotocol.com/protocol/terminals) - -#### KillTerminalCommandRequest - -Request to kill a terminal command without releasing the terminal. - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - -SessionId} required> -The session ID for this request. -
- - -The ID of the terminal to kill. - - -#### KillTerminalCommandResponse - -Response to terminal/kill command method - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - - - -### terminal/output - -Gets the terminal output and exit status - -Returns the current content in the terminal without waiting for the command to exit. -If the command has already exited, the exit status is included. - -See protocol docs: [Terminals](https://agentclientprotocol.com/protocol/terminals) - -#### TerminalOutputRequest - -Request to get the current output and status of a terminal. - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - -SessionId} required> -The session ID for this request. -
- - -The ID of the terminal to get output from. - - -#### TerminalOutputResponse - -Response containing the terminal output and exit status. - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - -TerminalExitStatus | null}> -Exit status if the command has completed. - - - -The terminal output captured so far. - - - -Whether the output was truncated due to byte limits. - - - - -### terminal/release - -Releases a terminal - -The command is killed if it hasn't exited yet. Use `terminal/wait_for_exit` -to wait for the command to exit before releasing the terminal. - -After release, the `TerminalId` can no longer be used with other `terminal/*` methods, -but tool calls that already contain it, continue to display its output. - -The `terminal/kill` method can be used to terminate the command without releasing -the terminal, allowing the Agent to call `terminal/output` and other methods. - -See protocol docs: [Terminals](https://agentclientprotocol.com/protocol/terminals) - -#### ReleaseTerminalRequest - -Request to release a terminal and free its resources. - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - -SessionId} required> -The session ID for this request. -
- - -The ID of the terminal to release. - - -#### ReleaseTerminalResponse - -Response to terminal/release method - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - - - -### terminal/wait_for_exit - -Waits for the terminal command to exit and return its exit status - -See protocol docs: [Terminals](https://agentclientprotocol.com/protocol/terminals) - -#### WaitForTerminalExitRequest - -Request to wait for a terminal command to exit. - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - -SessionId} required> -The session ID for this request. -
- - -The ID of the terminal to wait for. - - -#### WaitForTerminalExitResponse - -Response containing the exit status of a terminal command. - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - - -The process exit code (may be null if terminated by signal). - -- Minimum: `0` - - - -The signal that terminated the process (may be null if exited normally). - - -## AgentCapabilities - -Capabilities supported by the agent. - -Advertised during initialization to inform the client about -available features and content types. - -See protocol docs: [Agent Capabilities](https://agentclientprotocol.com/protocol/initialization#agent-capabilities) - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - - -Whether the agent supports `session/load`. - -- Default: `false` - - -McpCapabilities}> -MCP capabilities supported by the agent. - -- Default: `{"http":false,"sse":false}` - - -PromptCapabilities}> -Prompt capabilities supported by the agent. - -- Default: `{"audio":false,"embeddedContext":false,"image":false}` - - -SessionCapabilities}> - -- Default: `{}` - - -## Annotations - -Optional annotations for the client. The client can use annotations to inform how objects are used or displayed - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - -Role[] | null} /> - - - - - -## AudioContent - -Audio provided to or from an LLM. - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - -Annotations | null} /> - - - - - -## AuthMethod - -Describes an available authentication method. - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - - -Optional description providing more details about this authentication method. - - - -Unique identifier for this authentication method. - - - -Human-readable name of the authentication method. - - -## AvailableCommand - -Information about a command. - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - - -Human-readable description of what the command does. - - -AvailableCommandInput | null}> -Input for the command if required - - - -Command name (e.g., `create_plan`, `research_codebase`). - - -## AvailableCommandInput - -The input specification for a command. - -**Type:** Union - - - All text that was typed after the command name is provided as input. - - - - The \_meta property is reserved by ACP to allow clients and agents to attach additional - metadata to their interactions. Implementations MUST NOT make assumptions about values at - these keys. - - See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - - - A hint to display when the input hasn't been provided yet - - - - - -## AvailableCommandsUpdate - -Available commands are ready or have changed - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - -AvailableCommand[]} required> -Commands the agent can execute - - -## BlobResourceContents - -Binary resource contents. - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - - - - - - - -## ClientCapabilities - -Capabilities supported by the client. - -Advertised during initialization to inform the agent about -available features and methods. - -See protocol docs: [Client Capabilities](https://agentclientprotocol.com/protocol/initialization#client-capabilities) - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - -FileSystemCapability}> -File system capabilities supported by the client. -Determines which file operations the agent can request. - -- Default: `{"readTextFile":false,"writeTextFile":false}` - - - -Whether the Client support all `terminal/*` methods. - -- Default: `false` - - -## Content - -Standard content block (text, images, resources). - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - -ContentBlock} required> -The actual content block. - - -## ContentBlock - -Content blocks represent displayable information in 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` -- Language model output streamed through `session/update` notifications -- Progress updates and results from tool calls - -This structure is compatible with the Model Context Protocol (MCP), enabling -agents to seamlessly forward content from MCP tool outputs without transformation. - -See protocol docs: [Content](https://agentclientprotocol.com/protocol/content) - -**Type:** Union - - - Text content. May be plain text or formatted with Markdown. - -All agents MUST support text content blocks in prompts. -Clients SHOULD render this text as Markdown. - - - - The \_meta property is reserved by ACP to allow clients and agents to attach additional - metadata to their interactions. Implementations MUST NOT make assumptions about values at - these keys. - - See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - - Annotations | null} /> - - - - - - - - - - Images for visual context or analysis. - -Requires the `image` prompt capability when included in prompts. - - - - The \_meta property is reserved by ACP to allow clients and agents to attach additional - metadata to their interactions. Implementations MUST NOT make assumptions about values at - these keys. - - See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - - Annotations | null} /> - - - - - - - - - - - - - - Audio data for transcription or analysis. - -Requires the `audio` prompt capability when included in prompts. - - - - The \_meta property is reserved by ACP to allow clients and agents to attach additional - metadata to their interactions. Implementations MUST NOT make assumptions about values at - these keys. - - See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - - Annotations | null} /> - - - - - - - - - - - - References to resources that the agent can access. - -All agents MUST support resource links in prompts. - - - - The \_meta property is reserved by ACP to allow clients and agents to attach additional - metadata to their interactions. Implementations MUST NOT make assumptions about values at - these keys. - - See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - - Annotations | null} /> - - - - - - - - - - - - - - - - - - - - Complete resource contents embedded directly in the message. - -Preferred for including context as it avoids extra round-trips. - -Requires the `embeddedContext` prompt capability when included in prompts. - - - - The \_meta property is reserved by ACP to allow clients and agents to attach additional - metadata to their interactions. Implementations MUST NOT make assumptions about values at - these keys. - - See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - - Annotations | null} /> - - EmbeddedResourceResource} required /> - - - - - - -## ContentChunk - -A streamed item of content - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - -ContentBlock} required> -A single item of content - - -## CurrentModeUpdate - -The current mode of the session has changed - -See protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes) - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - -SessionModeId} required> -The ID of the current mode - - -## Diff - -A diff representing file modifications. - -Shows changes to files in a format suitable for display in the client UI. - -See protocol docs: [Content](https://agentclientprotocol.com/protocol/tool-calls#content) - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - - -The new content after modification. - - - -The original content (None for new files). - - - -The file path being modified. - - -## EmbeddedResource - -The contents of a resource, embedded into a prompt or tool call result. - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - -Annotations | null} /> - -EmbeddedResourceResource} required /> - -## EmbeddedResourceResource - -Resource content that can be embedded in a message. - -**Type:** Union - - - {""} - - - - The \_meta property is reserved by ACP to allow clients and agents to attach additional - metadata to their interactions. Implementations MUST NOT make assumptions about values at - these keys. - - See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - - - - - - - - - - - - {""} - - - - The \_meta property is reserved by ACP to allow clients and agents to attach additional - metadata to their interactions. Implementations MUST NOT make assumptions about values at - these keys. - - See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - - - - - - - - - - -## EnvVariable - -An environment variable to set when launching an MCP server. - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - - -The name of the environment variable. - - - -The value to set for the environment variable. - - -## Error - -JSON-RPC error object. - -Represents an error that occurred during method execution, following the -JSON-RPC 2.0 error object specification with optional additional data. - -See protocol docs: [JSON-RPC Error Object](https://www.jsonrpc.org/specification#error_object) - -**Type:** Object - -**Properties:** - -ErrorCode} required> -A number indicating the error type that occurred. This must be an integer as -defined in the JSON-RPC specification. - - - -Optional primitive or structured value that contains additional information -about the error. This may include debugging information or context-specific -details. - - - -A string providing a short description of the error. The message should be -limited to a concise single sentence. - - -## ErrorCode - -Predefined error codes for common JSON-RPC and ACP-specific errors. - -These codes follow the JSON-RPC 2.0 specification for standard errors -and use the reserved range (-32000 to -32099) for protocol-specific errors. - -**Type:** Union - - - **Parse error**: Invalid JSON was received by the server. An error occurred on - the server while parsing the JSON text. - - - - **Invalid request**: The JSON sent is not a valid Request object. - - - - **Method not found**: The method does not exist or is not available. - - - - **Invalid params**: Invalid method parameter(s). - - - - **Internal error**: Internal JSON-RPC error. Reserved for - implementation-defined server errors. - - - - **Authentication required**: Authentication is required before this operation - can be performed. - - - - **Resource not found**: A given resource, such as a file, was not found. - - - - Other undefined error code. - - -## ExtNotification - -Allows the Agent to send an arbitrary notification that is not part of the ACP spec. -Extension notifications provide a way to send one-way messages for custom functionality -while maintaining protocol compatibility. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - -## ExtRequest - -Allows for sending an arbitrary request that is not part of the ACP spec. -Extension methods provide a way to add custom functionality while maintaining -protocol compatibility. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - -## ExtResponse - -Allows for sending an arbitrary response to an `ExtRequest` that is not part of the ACP spec. -Extension methods provide a way to add custom functionality while maintaining -protocol compatibility. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - -## FileSystemCapability - -Filesystem capabilities supported by the client. -File system capabilities that a client may support. - -See protocol docs: [FileSystem](https://agentclientprotocol.com/protocol/initialization#filesystem) - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - - -Whether the Client supports `fs/read_text_file` requests. - -- Default: `false` - - - -Whether the Client supports `fs/write_text_file` requests. - -- Default: `false` - - -## HttpHeader - -An HTTP header to set when making requests to the MCP server. - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - - -The name of the HTTP header. - - - -The value to set for the HTTP header. - - -## ImageContent - -An image provided to or from an LLM. - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - -Annotations | null} /> - - - - - - - -## Implementation - -Metadata about the implementation of the client or agent. -Describes the name and version of an MCP implementation, with an optional -title for UI representation. - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - - -Intended for programmatic or logical use, but can be used as a display -name fallback if title isn’t present. - - - -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. - - - -Version of the implementation. Can be displayed to the user or used -for debugging or metrics purposes. (e.g. "1.0.0"). - - -## McpCapabilities - -MCP capabilities supported by the agent - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - - -Agent supports `McpServer::Http`. - -- Default: `false` - - - -Agent supports `McpServer::Sse`. - -- Default: `false` - - -## McpServer - -Configuration for connecting to an MCP (Model Context Protocol) server. - -MCP servers provide tools and context that the agent can use when -processing prompts. - -See protocol docs: [MCP Servers](https://agentclientprotocol.com/protocol/session-setup#mcp-servers) - -**Type:** Union - - - HTTP transport configuration - -Only available when the Agent capabilities indicate `mcp_capabilities.http` is `true`. - - - - The \_meta property is reserved by ACP to allow clients and agents to attach additional - metadata to their interactions. Implementations MUST NOT make assumptions about values at - these keys. - - See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - - HttpHeader[]} required> - HTTP headers to set when making requests to the MCP server. - - - - Human-readable name identifying this MCP server. - - - - - - URL to the MCP server. - - - - - - - SSE transport configuration - -Only available when the Agent capabilities indicate `mcp_capabilities.sse` is `true`. - - - - The \_meta property is reserved by ACP to allow clients and agents to attach additional - metadata to their interactions. Implementations MUST NOT make assumptions about values at - these keys. - - See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - - HttpHeader[]} required> - HTTP headers to set when making requests to the MCP server. - - - - Human-readable name identifying this MCP server. - - - - - - URL to the MCP server. - - - - - - - Stdio transport configuration - -All Agents MUST support this transport. - - - - The \_meta property is reserved by ACP to allow clients and agents to attach additional - metadata to their interactions. Implementations MUST NOT make assumptions about values at - these keys. - - See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - - "string"[]} required> - Command-line arguments to pass to the MCP server. - - - - Path to the MCP server executable. - - - EnvVariable[]} required> - Environment variables to set when launching the MCP server. - - - - Human-readable name identifying this MCP server. - - - - - -## McpServerHttp - -HTTP transport configuration for MCP. - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - -HttpHeader[]} required> -HTTP headers to set when making requests to the MCP server. - - - -Human-readable name identifying this MCP server. - - - -URL to the MCP server. - - -## McpServerSse - -SSE transport configuration for MCP. - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - -HttpHeader[]} required> -HTTP headers to set when making requests to the MCP server. - - - -Human-readable name identifying this MCP server. - - - -URL to the MCP server. - - -## McpServerStdio - -Stdio transport configuration for MCP. - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - -"string"[]} required> -Command-line arguments to pass to the MCP server. - - - -Path to the MCP server executable. - - -EnvVariable[]} required> -Environment variables to set when launching the MCP server. - - - -Human-readable name identifying this MCP server. - - -## PermissionOption - -An option presented to the user when requesting permission. - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - -PermissionOptionKind} required> -Hint about the nature of this permission option. - - - -Human-readable label to display to the user. - - -PermissionOptionId} required> -Unique identifier for this permission option. - - -## PermissionOptionId - -Unique identifier for a permission option. - -**Type:** `string` - -## PermissionOptionKind - -The type of permission option being presented to the user. - -Helps clients choose appropriate icons and UI treatment. - -**Type:** Union - - - Allow this operation only this time. - - - - Allow this operation and remember the choice. - - - - Reject this operation only this time. - - - - Reject this operation and remember the choice. - - -## Plan - -An execution plan for accomplishing complex tasks. - -Plans consist of multiple entries representing individual tasks or goals. -Agents report plans to clients to provide visibility into their execution strategy. -Plans can evolve during execution as the agent discovers new requirements or completes tasks. - -See protocol docs: [Agent Plan](https://agentclientprotocol.com/protocol/agent-plan) - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - -PlanEntry[]} required> -The list of tasks to be accomplished. - -When updating a plan, the agent must send a complete list of all entries -with their current status. The client replaces the entire plan with each update. - - -## PlanEntry - -A single entry in the execution plan. - -Represents a task or goal that the assistant intends to accomplish -as part of fulfilling the user's request. -See protocol docs: [Plan Entries](https://agentclientprotocol.com/protocol/agent-plan#plan-entries) - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - - -Human-readable description of what this task aims to accomplish. - - -PlanEntryPriority} required> -The relative importance of this task. -Used to indicate which tasks are most critical to the overall goal. - - -PlanEntryStatus} required> -Current execution status of this task. - - -## PlanEntryPriority - -Priority levels for plan entries. - -Used to indicate the relative importance or urgency of different -tasks in the execution plan. -See protocol docs: [Plan Entries](https://agentclientprotocol.com/protocol/agent-plan#plan-entries) - -**Type:** Union - - - High priority task - critical to the overall goal. - - - - Medium priority task - important but not critical. - - - - Low priority task - nice to have but not essential. - - -## PlanEntryStatus - -Status of a plan entry in the execution flow. - -Tracks the lifecycle of each task from planning through completion. -See protocol docs: [Plan Entries](https://agentclientprotocol.com/protocol/agent-plan#plan-entries) - -**Type:** Union - - - The task has not started yet. - - - - The task is currently being worked on. - - - - The task has been successfully completed. - - -## PromptCapabilities - -Prompt capabilities supported by the agent in `session/prompt` requests. - -Baseline agent functionality requires support for `ContentBlock::Text` -and `ContentBlock::ResourceLink` in prompt requests. - -Other variants must be explicitly opted in to. -Capabilities for different types of content in prompt requests. - -Indicates which content types beyond the baseline (text and resource links) -the agent can process. - -See protocol docs: [Prompt Capabilities](https://agentclientprotocol.com/protocol/initialization#prompt-capabilities) - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - - -Agent supports `ContentBlock::Audio`. - -- Default: `false` - - - -Agent supports embedded context in `session/prompt` requests. - -When enabled, the Client is allowed to include `ContentBlock::Resource` -in prompt requests for pieces of context that are referenced in the message. - -- Default: `false` - - - -Agent supports `ContentBlock::Image`. - -- Default: `false` - - -## ProtocolVersion - -Protocol version identifier. - -This version is only bumped for breaking changes. -Non-breaking changes should be introduced via capabilities. - -**Type:** `integer (uint16)` - -| Constraint | Value | -| ---------- | ------- | -| Minimum | `0` | -| Maximum | `65535` | - -## RequestId - -JSON RPC Request Id - -An identifier established by the Client that MUST contain a String, Number, or NULL value if included. If it is not included it is assumed to be a notification. The value SHOULD normally not be Null \[1] and Numbers SHOULD NOT contain fractional parts \[2] - -The Server MUST reply with the same value in the Response object if included. This member is used to correlate the context between the two objects. - -\[1] The use of Null as a value for the id member in a Request object is discouraged, because this specification uses a value of Null for Responses with an unknown id. Also, because JSON-RPC 1.0 uses an id value of Null for Notifications this could cause confusion in handling. - -\[2] Fractional parts may be problematic, since many decimal fractions cannot be represented exactly as binary fractions. - -**Type:** Union - - - {""} - - - - {""} - - - - {""} - - -## RequestPermissionOutcome - -The outcome of a permission request. - -**Type:** Union - - - The prompt turn was cancelled before the user responded. - -When a client sends a `session/cancel` notification to cancel an ongoing -prompt turn, it MUST respond to all pending `session/request_permission` -requests with this `Cancelled` outcome. - -See protocol docs: [Cancellation](https://agentclientprotocol.com/protocol/prompt-turn#cancellation) - - - - - - - - The user selected one of the provided options. - - - - The \_meta property is reserved by ACP to allow clients and agents to attach additional - metadata to their interactions. Implementations MUST NOT make assumptions about values at - these keys. - - See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - - PermissionOptionId} required> - The ID of the option the user selected. - - - - - - - -## ResourceLink - -A resource that the server is capable of reading, included in a prompt or tool call result. - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - -Annotations | null} /> - - - - - - - - - - - - - -## Role - -The sender or recipient of messages and data in a conversation. - -**Type:** Enumeration - -| Value | -| ------------- | -| `"assistant"` | -| `"user"` | - -## SelectedPermissionOutcome - -The user selected one of the provided options. - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - -PermissionOptionId} required> -The ID of the option the user selected. - - -## SessionCapabilities - -Session capabilities supported by the agent. - -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. - -See protocol docs: [Session Capabilities](https://agentclientprotocol.com/protocol/initialization#session-capabilities) - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - -## SessionId - -A unique identifier for a conversation session between a client and agent. - -Sessions maintain their own context, conversation history, and state, -allowing multiple independent interactions with the same agent. - -See protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id) - -**Type:** `string` - -## SessionMode - -A mode the agent can operate in. - -See protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes) - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - - - -SessionModeId} required /> - - - -## SessionModeId - -Unique identifier for a Session Mode. - -**Type:** `string` - -## SessionModeState - -The set of modes and the one currently active. - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - -SessionMode[]} required> -The set of modes that the Agent can operate in - - -SessionModeId} required> -The current mode the Agent is in. - - -## SessionUpdate - -Different types of updates that can be sent during session processing. - -These updates provide real-time feedback about the agent's progress. - -See protocol docs: [Agent Reports Output](https://agentclientprotocol.com/protocol/prompt-turn#3-agent-reports-output) - -**Type:** Union - - - A chunk of the user's message being streamed. - - - - The \_meta property is reserved by ACP to allow clients and agents to attach additional - metadata to their interactions. Implementations MUST NOT make assumptions about values at - these keys. - - See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - - ContentBlock} required> - A single item of content - - - - - - - - - A chunk of the agent's response being streamed. - - - - The \_meta property is reserved by ACP to allow clients and agents to attach additional - metadata to their interactions. Implementations MUST NOT make assumptions about values at - these keys. - - See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - - ContentBlock} required> - A single item of content - - - - - - - - - A chunk of the agent's internal reasoning being streamed. - - - - The \_meta property is reserved by ACP to allow clients and agents to attach additional - metadata to their interactions. Implementations MUST NOT make assumptions about values at - these keys. - - See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - - ContentBlock} required> - A single item of content - - - - - - - - - Notification that a new tool call has been initiated. - - - - The \_meta property is reserved by ACP to allow clients and agents to attach additional - metadata to their interactions. Implementations MUST NOT make assumptions about values at - these keys. - - See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - - ToolCallContent[]}> - Content produced by the tool call. - - - ToolKind}> - The category of tool being invoked. - Helps clients choose appropriate icons and UI treatment. - - - ToolCallLocation[]}> - File locations affected by this tool call. - Enables "follow-along" features in clients. - - - - Raw input parameters sent to the tool. - - - - Raw output returned by the tool. - - - - - ToolCallStatus}> - Current execution status of the tool call. - - - - Human-readable title describing what the tool is doing. - - - ToolCallId} required> - Unique identifier for this tool call within the session. - - - - - - - Update on the status or results of a tool call. - - - - The \_meta property is reserved by ACP to allow clients and agents to attach additional - metadata to their interactions. Implementations MUST NOT make assumptions about values at - these keys. - - See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - - ToolCallContent[] | null}> - Replace the content collection. - - - ToolKind | null}> - Update the tool kind. - - - ToolCallLocation[] | null}> - Replace the locations collection. - - - - Update the raw input. - - - - Update the raw output. - - - - - ToolCallStatus | null}> - Update the execution status. - - - - Update the human-readable title. - - - ToolCallId} required> - The ID of the tool call being updated. - - - - - - - The agent's execution plan for complex tasks. - See protocol docs: [Agent Plan](https://agentclientprotocol.com/protocol/agent-plan) - - - - The \_meta property is reserved by ACP to allow clients and agents to attach additional - metadata to their interactions. Implementations MUST NOT make assumptions about values at - these keys. - - See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - - PlanEntry[]} required> - The list of tasks to be accomplished. - - When updating a plan, the agent must send a complete list of all entries - with their current status. The client replaces the entire plan with each update. - - - - - - - - - Available commands are ready or have changed - - - - The \_meta property is reserved by ACP to allow clients and agents to attach additional - metadata to their interactions. Implementations MUST NOT make assumptions about values at - these keys. - - See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - - AvailableCommand[]} required> - Commands the agent can execute - - - - - - - - - The current mode of the session has changed - -See protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes) - - - - The \_meta property is reserved by ACP to allow clients and agents to attach additional - metadata to their interactions. Implementations MUST NOT make assumptions about values at - these keys. - - See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - - SessionModeId} required> - The ID of the current mode - - - - - - - -## StopReason - -Reasons why an agent stops processing a prompt turn. - -See protocol docs: [Stop Reasons](https://agentclientprotocol.com/protocol/prompt-turn#stop-reasons) - -**Type:** Union - - - The turn ended successfully. - - - - The turn ended because the agent reached the maximum number of tokens. - - - - The turn ended because the agent reached the maximum number of allowed agent - requests between user turns. - - - - The turn ended because the agent refused to continue. The user prompt and - everything that comes after it won't be included in the next prompt, so this - should be reflected in the UI. - - - - The turn was cancelled by the client via `session/cancel`. - -This stop reason MUST be returned when the client sends a `session/cancel` -notification, even if the cancellation causes exceptions in underlying operations. -Agents should catch these exceptions and return this semantically meaningful -response to confirm successful cancellation. - - -## Terminal - -Embed a terminal created with `terminal/create` by its id. - -The terminal must be added before calling `terminal/release`. - -See protocol docs: [Terminal](https://agentclientprotocol.com/protocol/terminals) - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - - - -## TerminalExitStatus - -Exit status of a terminal command. - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - - -The process exit code (may be null if terminated by signal). - -- Minimum: `0` - - - -The signal that terminated the process (may be null if exited normally). - - -## TextContent - -Text provided to or from an LLM. - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - -Annotations | null} /> - - - -## TextResourceContents - -Text-based resource contents. - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - - - - - - - -## ToolCall - -Represents a tool call that the language model has requested. - -Tool calls are actions that the agent executes on behalf of the language model, -such as reading files, executing code, or fetching data from external sources. - -See protocol docs: [Tool Calls](https://agentclientprotocol.com/protocol/tool-calls) - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - -ToolCallContent[]}> -Content produced by the tool call. - - -ToolKind}> -The category of tool being invoked. -Helps clients choose appropriate icons and UI treatment. - - -ToolCallLocation[]}> -File locations affected by this tool call. -Enables "follow-along" features in clients. - - - -Raw input parameters sent to the tool. - - - -Raw output returned by the tool. - - -ToolCallStatus}> -Current execution status of the tool call. - - - -Human-readable title describing what the tool is doing. - - -ToolCallId} required> -Unique identifier for this tool call within the session. - - -## ToolCallContent - -Content produced by a tool call. - -Tool calls can produce different types of content including -standard content blocks (text, images) or file diffs. - -See protocol docs: [Content](https://agentclientprotocol.com/protocol/tool-calls#content) - -**Type:** Union - - - Standard content block (text, images, resources). - - - - The \_meta property is reserved by ACP to allow clients and agents to attach additional - metadata to their interactions. Implementations MUST NOT make assumptions about values at - these keys. - - See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - - ContentBlock} required> - The actual content block. - - - - - - - - - File modification shown as a diff. - - - - The \_meta property is reserved by ACP to allow clients and agents to attach additional - metadata to their interactions. Implementations MUST NOT make assumptions about values at - these keys. - - See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - - - The new content after modification. - - - - The original content (None for new files). - - - - The file path being modified. - - - - - - - - - Embed a terminal created with `terminal/create` by its id. - -The terminal must be added before calling `terminal/release`. - -See protocol docs: [Terminal](https://agentclientprotocol.com/protocol/terminals) - - - - The \_meta property is reserved by ACP to allow clients and agents to attach additional - metadata to their interactions. Implementations MUST NOT make assumptions about values at - these keys. - - See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - - - - - - - - -## ToolCallId - -Unique identifier for a tool call within a session. - -**Type:** `string` - -## ToolCallLocation - -A file location being accessed or modified by a tool. - -Enables clients to implement "follow-along" features that track -which files the agent is working with in real-time. - -See protocol docs: [Following the Agent](https://agentclientprotocol.com/protocol/tool-calls#following-the-agent) - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - - -Optional line number within the file. - -- Minimum: `0` - - - -The file path being accessed or modified. - - -## ToolCallStatus - -Execution status of a tool call. - -Tool calls progress through different statuses during their lifecycle. - -See protocol docs: [Status](https://agentclientprotocol.com/protocol/tool-calls#status) - -**Type:** Union - - - The tool call hasn't started running yet because the input is either streaming - or we're awaiting approval. - - - - The tool call is currently running. - - - - The tool call completed successfully. - - - - The tool call failed with an error. - - -## ToolCallUpdate - -An update to an existing tool call. - -Used to report progress and results as tools execute. All fields except -the tool call ID are optional - only changed fields need to be included. - -See protocol docs: [Updating](https://agentclientprotocol.com/protocol/tool-calls#updating) - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - -ToolCallContent[] | null}> -Replace the content collection. - - -ToolKind | null}> -Update the tool kind. - - -ToolCallLocation[] | null}> -Replace the locations collection. - - - -Update the raw input. - - - -Update the raw output. - - -ToolCallStatus | null}> -Update the execution status. - - - -Update the human-readable title. - - -ToolCallId} required> -The ID of the tool call being updated. - - -## ToolKind - -Categories of tools that can be invoked. - -Tool kinds help clients choose appropriate icons and optimize how they -display tool execution progress. - -See protocol docs: [Creating](https://agentclientprotocol.com/protocol/tool-calls#creating) - -**Type:** Union - - - Reading files or data. - - - - Modifying files or content. - - - - Removing files or data. - - - - Moving or renaming files. - - - - Searching for information. - - - - Running commands or code. - - - - Internal reasoning or planning. - - - - Retrieving external data. - - - - Switching the current session mode. - - - - Other tool types (default). - - -## UnstructuredCommandInput - -All text that was typed after the command name is provided as input. - -**Type:** Object - -**Properties:** - - -The \_meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - - -A hint to display when the input hasn't been provided yet - - ---- - -> To find navigation and other pages in this documentation, fetch the llms.txt file at: https://agentclientprotocol.com/llms.txt diff --git a/apps/cli/src/acp/docs/session-modes.md b/apps/cli/src/acp/docs/session-modes.md deleted file mode 100644 index 916e44bd96b..00000000000 --- a/apps/cli/src/acp/docs/session-modes.md +++ /dev/null @@ -1,170 +0,0 @@ -# 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" - } - ] - } - } -} -``` - - - The current mode state for the session - - -### SessionModeState - - - The ID of the mode that is currently active - - - - The set of modes that the Agent can operate in - - -### SessionMode - - - Unique identifier for this mode - - - - Human-readable name of the mode - - - - Optional description providing more details about what this mode does - - -## 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" - } -} -``` - - - The ID of the session to set the mode for - - - - The ID of the mode to switch to. Must be one of the modes listed in - `availableModes` - - -### 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. - - - Learn more about permission requests - - ---- - -> To find navigation and other pages in this documentation, fetch the llms.txt file at: https://agentclientprotocol.com/llms.txt diff --git a/apps/cli/src/acp/docs/session-setup.md b/apps/cli/src/acp/docs/session-setup.md deleted file mode 100644 index 535bfce3ffe..00000000000 --- a/apps/cli/src/acp/docs/session-setup.md +++ /dev/null @@ -1,384 +0,0 @@ -# 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. - -
- -```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 -``` - -
- -## 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. - - - A human-readable identifier for the server - - - - The absolute path to the MCP server executable - - - - Command-line arguments to pass to the server - - - - Environment variables to set when launching the server - - - - The name of the environment variable. - - - - The value of the environment variable. - - - - - -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. - - - Must be `"http"` to indicate HTTP transport - - - - A human-readable identifier for the server - - - - The URL of the MCP server - - - - HTTP headers to include in requests to the server - - - - The name of the HTTP header. - - - - The value to set for the HTTP header. - - - - - -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. - -This transport was deprecated by the MCP spec. - - - Must be `"sse"` to indicate SSE transport - - - - A human-readable identifier for the server - - - - The URL of the SSE endpoint - - - - HTTP headers to include when establishing the SSE connection - - - - The name of the HTTP header. - - - - The value to set for the HTTP header. - - - - - -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 diff --git a/apps/cli/src/acp/docs/slash-commands.md b/apps/cli/src/acp/docs/slash-commands.md deleted file mode 100644 index d11c7b20308..00000000000 --- a/apps/cli/src/acp/docs/slash-commands.md +++ /dev/null @@ -1,99 +0,0 @@ -# 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" - } - } - ] - } - } -} -``` - - - The list of commands available in this session - - -### AvailableCommand - - - The command name (e.g., "web", "test", "plan") - - - - Human-readable description of what the command does - - - - Optional input specification for the command - - -### AvailableCommandInput - -Currently supports unstructured text input: - - - A hint to display when the input hasn't been provided yet - - -## 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 diff --git a/apps/cli/src/acp/docs/terminals.md b/apps/cli/src/acp/docs/terminals.md deleted file mode 100644 index e4dcb48a0a6..00000000000 --- a/apps/cli/src/acp/docs/terminals.md +++ /dev/null @@ -1,281 +0,0 @@ -# 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 - } -} -``` - - - The [Session ID](./session-setup#session-id) for this request - - - - The command to execute - - - - Array of command arguments - - - - Environment variables for the command. - -Each variable has: - -- `name`: The environment variable name -- `value`: The environment variable value - - - - Working directory for the command (absolute path) - - - - 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. - - -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. - - - The Agent **MUST** release the terminal using `terminal/release` when it's no - longer needed. - - -## 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 - } - } -} -``` - - - The terminal output captured so far - - - - Whether the output was truncated due to byte limits - - - - 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) - - -## 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 - } -} -``` - - - The process exit code (may be null if terminated by signal) - - - - The signal that terminated the process (may be null if exited normally) - - -## 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 diff --git a/apps/cli/src/acp/docs/tool-calls.md b/apps/cli/src/acp/docs/tool-calls.md deleted file mode 100644 index ee784ab101d..00000000000 --- a/apps/cli/src/acp/docs/tool-calls.md +++ /dev/null @@ -1,311 +0,0 @@ -# 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" - } - } -} -``` - - - A unique identifier for this tool call within the session - - - - A human-readable title describing what the tool is doing - - - - The category of tool being invoked. - - - * `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) - - -Tool kinds help Clients choose appropriate icons and optimize how they display tool execution progress. - - - - The current [execution status](#status) (defaults to `pending`) - - - - [Content produced](#content) by the tool call - - - - [File locations](#following-the-agent) affected by this tool call - - - - The raw input parameters sent to the tool - - - - The raw output returned by the tool - - -## 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" - } - ] - } -} -``` - - - The session ID for this request - - - - The tool call update containing details about the operation - - - - Available [permission options](#permission-options) for the user to choose - from - - -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" - } - } -} -``` - - - 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 - - -### Permission Options - -Each permission option provided to the Client contains: - - - Unique identifier for this option - - - - Human-readable label to display to the user - - - - 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 - - -## Status - -Tool calls progress through different statuses during their lifecycle: - - - The tool call hasn't started running yet because the input is either streaming - or awaiting approval - - - - The tool call is currently running - - - - The tool call completed successfully - - -The tool call failed with an error - -## 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}" -} -``` - - - The absolute file path being modified - - - - The original content (null for new files) - - - - The new content after modification - - -### Terminals - -Live terminal output from command execution: - -```json theme={null} -{ - "type": "terminal", - "terminalId": "term_xyz789" -} -``` - - - The ID of a terminal created with `terminal/create` - - -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. - - - Learn more about Terminals - - -## 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 -} -``` - - - The absolute file path being accessed or modified - - - - Optional line number within the file - - ---- - -> To find navigation and other pages in this documentation, fetch the llms.txt file at: https://agentclientprotocol.com/llms.txt diff --git a/apps/cli/src/acp/docs/transports.md b/apps/cli/src/acp/docs/transports.md deleted file mode 100644 index 4056d21ccab..00000000000 --- a/apps/cli/src/acp/docs/transports.md +++ /dev/null @@ -1,55 +0,0 @@ -# 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 diff --git a/apps/cli/src/acp/file-system-service.ts b/apps/cli/src/acp/file-system-service.ts deleted file mode 100644 index 71e3502c726..00000000000 --- a/apps/cli/src/acp/file-system-service.ts +++ /dev/null @@ -1,148 +0,0 @@ -/** - * 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 { - // 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 { - // 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) -} diff --git a/apps/cli/src/acp/index.ts b/apps/cli/src/acp/index.ts index 1096782a15f..96b896bc2e3 100644 --- a/apps/cli/src/acp/index.ts +++ b/apps/cli/src/acp/index.ts @@ -8,7 +8,6 @@ * - 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) * @@ -18,7 +17,6 @@ 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" diff --git a/apps/cli/src/acp/terminal-manager.ts b/apps/cli/src/acp/terminal-manager.ts deleted file mode 100644 index bca29111c4f..00000000000 --- a/apps/cli/src/acp/terminal-manager.ts +++ /dev/null @@ -1,322 +0,0 @@ -/** - * 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 = 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 { - 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 { - 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 { - 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 { - 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, - } - } -} From 844de641530809c00102924fc52193a76764b91c Mon Sep 17 00:00:00 2001 From: cte Date: Sun, 11 Jan 2026 00:00:44 -0800 Subject: [PATCH 04/17] Some cleanup --- apps/cli/src/acp/__tests__/agent.test.ts | 8 -- .../src/acp/__tests__/delta-tracker.test.ts | 1 - apps/cli/src/acp/__tests__/session.test.ts | 8 -- apps/cli/src/acp/__tests__/translator.test.ts | 5 -- .../src/acp/__tests__/update-buffer.test.ts | 7 -- apps/cli/src/acp/index.ts | 22 +----- apps/cli/src/commands/acp/index.ts | 74 ++----------------- apps/cli/src/commands/index.ts | 1 + apps/cli/src/index.ts | 3 +- 9 files changed, 11 insertions(+), 118 deletions(-) diff --git a/apps/cli/src/acp/__tests__/agent.test.ts b/apps/cli/src/acp/__tests__/agent.test.ts index 0c61f0e474d..3fb2dc4736d 100644 --- a/apps/cli/src/acp/__tests__/agent.test.ts +++ b/apps/cli/src/acp/__tests__/agent.test.ts @@ -1,20 +1,13 @@ -/** - * 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({ @@ -40,7 +33,6 @@ describe("RooCodeAgent", () => { } beforeEach(() => { - // Create a mock connection mockConnection = { sessionUpdate: vi.fn().mockResolvedValue(undefined), requestPermission: vi.fn().mockResolvedValue({ diff --git a/apps/cli/src/acp/__tests__/delta-tracker.test.ts b/apps/cli/src/acp/__tests__/delta-tracker.test.ts index 3874a22a452..1157c32c69c 100644 --- a/apps/cli/src/acp/__tests__/delta-tracker.test.ts +++ b/apps/cli/src/acp/__tests__/delta-tracker.test.ts @@ -1,4 +1,3 @@ -import { describe, it, expect, beforeEach } from "vitest" import { DeltaTracker } from "../delta-tracker.js" describe("DeltaTracker", () => { diff --git a/apps/cli/src/acp/__tests__/session.test.ts b/apps/cli/src/acp/__tests__/session.test.ts index 2c172e1d3ef..895872f9aca 100644 --- a/apps/cli/src/acp/__tests__/session.test.ts +++ b/apps/cli/src/acp/__tests__/session.test.ts @@ -1,11 +1,5 @@ -/** - * 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(), @@ -25,7 +19,6 @@ vi.mock("@/agent/extension-host.js", () => { } }) -// Import after mocking import { AcpSession, type AcpSessionOptions } from "../session.js" import { ExtensionHost } from "@/agent/extension-host.js" @@ -41,7 +34,6 @@ describe("AcpSession", () => { } beforeEach(() => { - // Create a mock connection mockConnection = { sessionUpdate: vi.fn().mockResolvedValue(undefined), requestPermission: vi.fn().mockResolvedValue({ diff --git a/apps/cli/src/acp/__tests__/translator.test.ts b/apps/cli/src/acp/__tests__/translator.test.ts index 5e3b80c338c..26795e72d64 100644 --- a/apps/cli/src/acp/__tests__/translator.test.ts +++ b/apps/cli/src/acp/__tests__/translator.test.ts @@ -1,8 +1,3 @@ -/** - * Tests for ACP Message Translator - */ - -import { describe, it, expect } from "vitest" import type { ClineMessage } from "@roo-code/types" import { diff --git a/apps/cli/src/acp/__tests__/update-buffer.test.ts b/apps/cli/src/acp/__tests__/update-buffer.test.ts index a1307d14503..60e3c7a9344 100644 --- a/apps/cli/src/acp/__tests__/update-buffer.test.ts +++ b/apps/cli/src/acp/__tests__/update-buffer.test.ts @@ -1,10 +1,3 @@ -/** - * 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" diff --git a/apps/cli/src/acp/index.ts b/apps/cli/src/acp/index.ts index 96b896bc2e3..12a6e239471 100644 --- a/apps/cli/src/acp/index.ts +++ b/apps/cli/src/acp/index.ts @@ -1,22 +1,2 @@ -/** - * 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 - * - 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 { UpdateBuffer, type UpdateBufferOptions } from "./update-buffer.js" +export { type RooCodeAgentOptions, RooCodeAgent } from "./agent.js" export { acpLog } from "./logger.js" -export * from "./translator.js" diff --git a/apps/cli/src/commands/acp/index.ts b/apps/cli/src/commands/acp/index.ts index bc0b10954b4..9be7b8b529a 100644 --- a/apps/cli/src/commands/acp/index.ts +++ b/apps/cli/src/commands/acp/index.ts @@ -1,70 +1,30 @@ -/** - * 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 { type SupportedProvider, DEFAULT_FLAGS } from "@/types/index.js" import { getDefaultExtensionPath } from "@/lib/utils/extension.js" - -// ============================================================================= -// Types -// ============================================================================= +import { type RooCodeAgentOptions, RooCodeAgent, acpLog } from "@/acp/index.js" export interface AcpCommandOptions { - /** Path to the extension bundle directory */ extension?: string - /** API provider (anthropic, openai, openrouter, etc.) */ - provider?: string - /** Model to use */ + provider?: SupportedProvider 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 { - 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, @@ -73,24 +33,14 @@ export async function runAcpServer(options: AcpCommandOptions): Promise { 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) + // 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 const stdin = Readable.toWeb(process.stdin) as ReadableStream - // 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) => { @@ -99,12 +49,13 @@ export async function runAcpServer(options: AcpCommandOptions): Promise { 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) } @@ -112,26 +63,17 @@ export async function runAcpServer(options: AcpCommandOptions): Promise { 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 { 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) + console.error(error) process.exit(1) } } diff --git a/apps/cli/src/commands/index.ts b/apps/cli/src/commands/index.ts index 717a7040ef6..566cf1a330b 100644 --- a/apps/cli/src/commands/index.ts +++ b/apps/cli/src/commands/index.ts @@ -1,2 +1,3 @@ export * from "./auth/index.js" export * from "./cli/index.js" +export * from "./acp/index.js" diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 4a817109089..4139325c739 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -2,8 +2,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" +import { run, login, logout, status, acp } from "@/commands/index.js" const program = new Command() From 89f26ac053672a40a758cd07240445275989cab4 Mon Sep 17 00:00:00 2001 From: cte Date: Sun, 11 Jan 2026 00:02:19 -0800 Subject: [PATCH 05/17] Update README --- apps/cli/README.md | 47 ---------------------------------------------- 1 file changed, 47 deletions(-) diff --git a/apps/cli/README.md b/apps/cli/README.md index dde72f1a571..76e66573eb1 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -221,53 +221,6 @@ If you need to specify options: } ``` -### 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`: From 74881a4f4fb4b54d5876b9a30bde8a719cd11c75 Mon Sep 17 00:00:00 2001 From: cte Date: Sun, 11 Jan 2026 00:20:56 -0800 Subject: [PATCH 06/17] Fix command output streaming --- apps/cli/src/acp/session.ts | 129 +++++++++++++++++++++++---------- apps/cli/src/acp/translator.ts | 6 +- 2 files changed, 93 insertions(+), 42 deletions(-) diff --git a/apps/cli/src/acp/session.ts b/apps/cli/src/acp/session.ts index 3e14b95921b..ea57dcc89b3 100644 --- a/apps/cli/src/acp/session.ts +++ b/apps/cli/src/acp/session.ts @@ -14,7 +14,7 @@ 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 type { WaitingForInputEvent, TaskCompletedEvent, CommandExecutionOutputEvent } from "@/agent/events.js" import { translateToAcpUpdate, @@ -123,6 +123,12 @@ export class AcpSession { */ private pendingCommandCalls: Map = new Map() + /** + * Track which command executions have sent the opening code fence. + * Used to wrap command output in markdown code blocks. + */ + private commandCodeFencesSent: Set = new Set() + /** Workspace path for resolving relative file paths */ private readonly workspacePath: string @@ -208,6 +214,11 @@ export class AcpSession { void this.handleWaitingForInput(event) }) + // Handle streaming command execution output (live terminal output) + client.on("commandExecutionOutput", (event: CommandExecutionOutputEvent) => { + this.handleCommandExecutionOutput(event) + }) + // Handle task completion client.on("taskCompleted", (event: TaskCompletedEvent) => { this.handleTaskCompleted(event) @@ -252,7 +263,7 @@ export class AcpSession { const delta = this.deltaTracker.getDelta(message.ts, textToSend) if (delta) { - acpLog.debug("Session", `Sending ${message.say} delta: ${delta.length} chars (msg ${message.ts})`) + // acpLog.debug("Session", `Queueing ${message.say} delta: ${delta.length} chars (msg ${message.ts})`) void this.sendUpdate({ sessionUpdate: config.updateType, content: { type: "text", text: delta }, @@ -275,62 +286,102 @@ export class AcpSession { /** * 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. + * This provides the "Run Command" UI with completion status in Zed. + * + * NOTE: Streaming output is handled by handleCommandExecutionOutput(). + * This method only handles the final tool_call_update for completion. */ 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`) + // Skip partial updates - streaming is handled by handleCommandExecutionOutput() + if (isPartial) { + return + } + + // Send closing code fence if any was opened + if (this.commandCodeFencesSent.size > 0) { + acpLog.info("Session", "Sending closing code fence") void this.sendUpdate({ sessionUpdate: "agent_message_chunk", - content: { type: "text", text: delta }, + content: { type: "text", text: "\n```" }, }) + this.commandCodeFencesSent.clear() } - // Also update the tool call UI if we have a pending command + // Handle completion - update the tool call UI const pendingCall = this.findMostRecentPendingCommand() if (pendingCall) { - acpLog.info("Session", `Found pending call: ${pendingCall.toolCallId}, isPartial=${isPartial}`) + acpLog.info("Session", `Command completed: ${pendingCall.toolCallId}`) - 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 + // 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) + } + } + + /** + * Handle streaming command execution output (live terminal output). + * This provides real-time output during command execution. + * Output is wrapped in markdown code blocks for proper rendering. + */ + private handleCommandExecutionOutput(event: CommandExecutionOutputEvent): void { + const { executionId, output } = event + + acpLog.info("Session", `commandExecutionOutput: executionId=${executionId}, output length=${output.length}`) + + // Stream output as agent message for visibility in chat + // Use executionId as the message key for delta tracking + const delta = this.deltaTracker.getDelta(executionId, output) + if (delta) { + // Send opening code fence on first output for this execution + const isFirstChunk = !this.commandCodeFencesSent.has(executionId) + if (isFirstChunk) { + this.commandCodeFencesSent.add(executionId) + acpLog.info("Session", `Sending opening code fence for execution ${executionId}`) void this.sendUpdate({ - sessionUpdate: "tool_call_update", - toolCallId: pendingCall.toolCallId, - status: "completed", - content: [ - { - type: "content", - content: { type: "text", text: output }, - }, - ], - rawOutput: { output }, + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "```\n" }, }) - this.pendingCommandCalls.delete(pendingCall.toolCallId) - acpLog.info("Session", `Command completed: ${pendingCall.toolCallId}`) } + + acpLog.info("Session", `Streaming command execution output: ${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", `Updating tool call ${pendingCall.toolCallId} with streaming output`) + void this.sendUpdate({ + sessionUpdate: "tool_call_update", + toolCallId: pendingCall.toolCallId, + status: "in_progress", + content: [ + { + type: "content", + content: { type: "text", text: output }, + }, + ], + }) } } diff --git a/apps/cli/src/acp/translator.ts b/apps/cli/src/acp/translator.ts index eb803581abb..56b91cec132 100644 --- a/apps/cli/src/acp/translator.ts +++ b/apps/cli/src/acp/translator.ts @@ -507,12 +507,12 @@ export function mapToolKind(toolName: string): acp.ToolKind { } // Fetch operations (check BEFORE read since "http_get" contains "get" substring) + // Note: "browser" is NOT included here since browser tools are disabled in CLI if ( - lowerName.includes("browser") || - lowerName.includes("web") || lowerName.includes("fetch") || lowerName.includes("http") || - lowerName.includes("url") + lowerName.includes("url") || + lowerName.includes("web_request") ) { return "fetch" } From 4b43a0d865fb3f9dce3750d5003a8d0954a46d7e Mon Sep 17 00:00:00 2001 From: cte Date: Sun, 11 Jan 2026 14:32:22 -0800 Subject: [PATCH 07/17] Clean refactor --- apps/cli/package.json | 1 + .../src/acp/__tests__/command-stream.test.ts | 314 +++++++ .../acp/__tests__/content-formatter.test.ts | 298 ++++++ .../src/acp/__tests__/prompt-state.test.ts | 373 ++++++++ .../acp/__tests__/tool-content-stream.test.ts | 495 ++++++++++ .../src/acp/__tests__/tool-handler.test.ts | 495 ++++++++++ apps/cli/src/acp/__tests__/translator.test.ts | 56 +- apps/cli/src/acp/command-stream.ts | 265 ++++++ apps/cli/src/acp/content-formatter.ts | 221 +++++ apps/cli/src/acp/index.ts | 179 ++++ apps/cli/src/acp/interfaces.ts | 378 ++++++++ apps/cli/src/acp/logger.ts | 4 +- apps/cli/src/acp/prompt-state.ts | 254 +++++ apps/cli/src/acp/session-event-handler.ts | 431 +++++++++ apps/cli/src/acp/session.ts | 873 ++++-------------- apps/cli/src/acp/tool-content-stream.ts | 217 +++++ apps/cli/src/acp/tool-handler.ts | 480 ++++++++++ apps/cli/src/acp/tool-registry.ts | 530 +++++++++++ apps/cli/src/acp/translator.ts | 701 +------------- apps/cli/src/acp/translator/diff-parser.ts | 106 +++ apps/cli/src/acp/translator/index.ts | 43 + .../src/acp/translator/location-extractor.ts | 136 +++ .../src/acp/translator/message-translator.ts | 179 ++++ .../src/acp/translator/prompt-extractor.ts | 101 ++ apps/cli/src/acp/translator/tool-parser.ts | 237 +++++ apps/cli/src/acp/update-buffer.ts | 18 +- apps/cli/src/acp/utils/format-utils.ts | 379 ++++++++ apps/cli/src/acp/utils/index.ts | 29 + apps/cli/src/agent/extension-host.ts | 1 + apps/cli/src/agent/message-processor.ts | 15 + apps/cli/src/agent/output-manager.ts | 128 ++- apps/cli/src/types/constants.ts | 2 +- apps/cli/src/ui/hooks/useClientEvents.ts | 10 +- pnpm-lock.yaml | 14 +- .../tools/__tests__/writeToFileTool.spec.ts | 6 +- 35 files changed, 6556 insertions(+), 1413 deletions(-) create mode 100644 apps/cli/src/acp/__tests__/command-stream.test.ts create mode 100644 apps/cli/src/acp/__tests__/content-formatter.test.ts create mode 100644 apps/cli/src/acp/__tests__/prompt-state.test.ts create mode 100644 apps/cli/src/acp/__tests__/tool-content-stream.test.ts create mode 100644 apps/cli/src/acp/__tests__/tool-handler.test.ts create mode 100644 apps/cli/src/acp/command-stream.ts create mode 100644 apps/cli/src/acp/content-formatter.ts create mode 100644 apps/cli/src/acp/interfaces.ts create mode 100644 apps/cli/src/acp/prompt-state.ts create mode 100644 apps/cli/src/acp/session-event-handler.ts create mode 100644 apps/cli/src/acp/tool-content-stream.ts create mode 100644 apps/cli/src/acp/tool-handler.ts create mode 100644 apps/cli/src/acp/tool-registry.ts create mode 100644 apps/cli/src/acp/translator/diff-parser.ts create mode 100644 apps/cli/src/acp/translator/index.ts create mode 100644 apps/cli/src/acp/translator/location-extractor.ts create mode 100644 apps/cli/src/acp/translator/message-translator.ts create mode 100644 apps/cli/src/acp/translator/prompt-extractor.ts create mode 100644 apps/cli/src/acp/translator/tool-parser.ts create mode 100644 apps/cli/src/acp/utils/format-utils.ts create mode 100644 apps/cli/src/acp/utils/index.ts diff --git a/apps/cli/package.json b/apps/cli/package.json index 0190dcb5c20..01f2e4595ab 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -34,6 +34,7 @@ "p-wait-for": "^5.0.2", "react": "^19.1.0", "superjson": "^2.2.6", + "zod": "^4.3.5", "zustand": "^5.0.0" }, "devDependencies": { diff --git a/apps/cli/src/acp/__tests__/command-stream.test.ts b/apps/cli/src/acp/__tests__/command-stream.test.ts new file mode 100644 index 00000000000..be1e1ea13d5 --- /dev/null +++ b/apps/cli/src/acp/__tests__/command-stream.test.ts @@ -0,0 +1,314 @@ +/** + * Tests for CommandStreamManager + * + * Tests the command output streaming functionality extracted from session.ts. + */ + +import type { ClineMessage } from "@roo-code/types" + +import { DeltaTracker } from "../delta-tracker.js" +import { CommandStreamManager } from "../command-stream.js" +import { NullLogger } from "../interfaces.js" +import type { SendUpdateFn } from "../interfaces.js" + +describe("CommandStreamManager", () => { + let deltaTracker: DeltaTracker + let sendUpdate: SendUpdateFn + let sentUpdates: Array> + let manager: CommandStreamManager + + beforeEach(() => { + deltaTracker = new DeltaTracker() + sentUpdates = [] + sendUpdate = (update) => { + sentUpdates.push(update as Record) + } + manager = new CommandStreamManager({ + deltaTracker, + sendUpdate, + logger: new NullLogger(), + }) + }) + + describe("isCommandOutputMessage", () => { + it("returns true for command_output say messages", () => { + const message: ClineMessage = { + type: "say", + say: "command_output", + ts: Date.now(), + text: "output", + } + expect(manager.isCommandOutputMessage(message)).toBe(true) + }) + + it("returns false for other say types", () => { + const message: ClineMessage = { + type: "say", + say: "text", + ts: Date.now(), + text: "hello", + } + expect(manager.isCommandOutputMessage(message)).toBe(false) + }) + + it("returns false for ask messages", () => { + const message: ClineMessage = { + type: "ask", + ask: "command", + ts: Date.now(), + text: "run command", + } + expect(manager.isCommandOutputMessage(message)).toBe(false) + }) + }) + + describe("trackCommand", () => { + it("tracks a pending command", () => { + manager.trackCommand("call-1", "npm test", 12345) + expect(manager.getPendingCommandCount()).toBe(1) + }) + + it("tracks multiple commands", () => { + manager.trackCommand("call-1", "npm test", 12345) + manager.trackCommand("call-2", "npm build", 12346) + expect(manager.getPendingCommandCount()).toBe(2) + }) + + it("overwrites command with same ID", () => { + manager.trackCommand("call-1", "npm test", 12345) + manager.trackCommand("call-1", "npm build", 12346) + expect(manager.getPendingCommandCount()).toBe(1) + }) + }) + + describe("handleExecutionOutput", () => { + it("does nothing without a pending command", () => { + manager.handleExecutionOutput("exec-1", "Hello") + + expect(sentUpdates.length).toBe(0) + }) + + it("sends opening code fence as agent_message_chunk on first output", () => { + manager.trackCommand("call-1", "npm test", 12345) + manager.handleExecutionOutput("exec-1", "Hello") + + // First message is opening fence, second is the content + expect(sentUpdates.length).toBe(2) + expect(sentUpdates[0]).toEqual({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "```\n" }, + }) + expect(sentUpdates[1]).toEqual({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "Hello" }, + }) + }) + + it("sends only delta content on subsequent calls (no fence)", () => { + manager.trackCommand("call-1", "npm test", 12345) + manager.handleExecutionOutput("exec-1", "Hello") + sentUpdates.length = 0 // Clear previous updates + + manager.handleExecutionOutput("exec-1", "Hello World") + + // Only the delta " World" is sent, no fence + expect(sentUpdates.length).toBe(1) + expect(sentUpdates[0]).toEqual({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: " World" }, + }) + }) + + it("tracks code fence by toolCallId not executionId", () => { + manager.trackCommand("call-1", "npm test", 12345) + + // First execution stream + manager.handleExecutionOutput("exec-1", "First") + expect(manager.hasOpenCodeFences()).toBe(true) + + // Second execution stream for same command - no new opening fence since toolCallId already has one + manager.handleExecutionOutput("exec-2", "Second") + + // Should still only have one open fence (tracked by toolCallId) + expect(manager.hasOpenCodeFences()).toBe(true) + + // Second call should NOT have opening fence since toolCallId already has one + // sentUpdates[0] = opening fence, sentUpdates[1] = "First", sentUpdates[2] = "Second" + expect(sentUpdates[2]).toEqual({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "Second" }, + }) + }) + + it("sends streaming output as agent_message_chunk", () => { + manager.trackCommand("call-1", "npm test", 12345) + manager.handleExecutionOutput("exec-1", "Running...") + + // Opening fence + content + const contentUpdate = sentUpdates.find( + (u) => + u.sessionUpdate === "agent_message_chunk" && (u.content as { text: string }).text === "Running...", + ) + expect(contentUpdate).toEqual({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "Running..." }, + }) + }) + }) + + describe("handleCommandOutput", () => { + it("ignores partial messages", () => { + const message: ClineMessage = { + type: "say", + say: "command_output", + ts: Date.now(), + text: "partial output", + partial: true, + } + + manager.handleCommandOutput(message) + expect(sentUpdates.length).toBe(0) + }) + + it("sends closing fence and completion when streaming was used", () => { + // Track command and open a code fence via execution output + manager.trackCommand("call-1", "npm test", 12345) + manager.handleExecutionOutput("exec-1", "output") + expect(manager.hasOpenCodeFences()).toBe(true) + + sentUpdates.length = 0 // Clear + + const message: ClineMessage = { + type: "say", + say: "command_output", + ts: Date.now(), + text: "final output", + partial: false, + } + + manager.handleCommandOutput(message) + + // First: closing fence as agent_message_chunk + expect(sentUpdates[0]).toEqual({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "```\n" }, + }) + // Second: tool_call_update with completed status (no content, just rawOutput) + expect(sentUpdates[1]).toEqual({ + sessionUpdate: "tool_call_update", + toolCallId: "call-1", + status: "completed", + rawOutput: { output: "final output" }, + }) + expect(manager.hasOpenCodeFences()).toBe(false) + }) + + it("sends completion update for pending command without streaming", () => { + manager.trackCommand("call-1", "npm test", 12345) + + const message: ClineMessage = { + type: "say", + say: "command_output", + ts: Date.now(), + text: "Test passed!", + partial: false, + } + + manager.handleCommandOutput(message) + + // No streaming, so no closing fence - just the completion update + const completionUpdate = sentUpdates.find( + (u) => u.sessionUpdate === "tool_call_update" && u.status === "completed", + ) + expect(completionUpdate).toEqual({ + sessionUpdate: "tool_call_update", + toolCallId: "call-1", + status: "completed", + rawOutput: { output: "Test passed!" }, + }) + }) + + it("removes pending command after completion", () => { + manager.trackCommand("call-1", "npm test", 12345) + expect(manager.getPendingCommandCount()).toBe(1) + + const message: ClineMessage = { + type: "say", + say: "command_output", + ts: Date.now(), + text: "done", + partial: false, + } + + manager.handleCommandOutput(message) + expect(manager.getPendingCommandCount()).toBe(0) + }) + + it("picks most recent pending command when multiple exist", () => { + manager.trackCommand("call-1", "npm test", 12345) + manager.trackCommand("call-2", "npm build", 12346) // More recent + + const message: ClineMessage = { + type: "say", + say: "command_output", + ts: Date.now(), + text: "done", + partial: false, + } + + manager.handleCommandOutput(message) + + const completionUpdate = sentUpdates.find((u) => u.sessionUpdate === "tool_call_update") + expect((completionUpdate as Record).toolCallId).toBe("call-2") + }) + }) + + describe("reset", () => { + it("clears code fence tracking", () => { + manager.trackCommand("call-1", "npm test", 12345) + manager.handleExecutionOutput("exec-1", "output") + expect(manager.hasOpenCodeFences()).toBe(true) + + manager.reset() + expect(manager.hasOpenCodeFences()).toBe(false) + }) + + it("clears pending commands to avoid stale entries", () => { + // Pending commands from previous prompts would cause duplicate completion messages + manager.trackCommand("call-1", "npm test", 12345) + manager.reset() + expect(manager.getPendingCommandCount()).toBe(0) + }) + }) + + describe("getPendingCommandCount", () => { + it("returns 0 when no commands tracked", () => { + expect(manager.getPendingCommandCount()).toBe(0) + }) + + it("returns correct count", () => { + manager.trackCommand("call-1", "cmd1", 1) + manager.trackCommand("call-2", "cmd2", 2) + expect(manager.getPendingCommandCount()).toBe(2) + }) + }) + + describe("hasOpenCodeFences", () => { + it("returns false initially", () => { + expect(manager.hasOpenCodeFences()).toBe(false) + }) + + it("returns true after execution output with pending command", () => { + manager.trackCommand("call-1", "npm test", 12345) + manager.handleExecutionOutput("exec-1", "output") + expect(manager.hasOpenCodeFences()).toBe(true) + }) + + it("returns false after reset", () => { + manager.trackCommand("call-1", "npm test", 12345) + manager.handleExecutionOutput("exec-1", "output") + manager.reset() + expect(manager.hasOpenCodeFences()).toBe(false) + }) + }) +}) diff --git a/apps/cli/src/acp/__tests__/content-formatter.test.ts b/apps/cli/src/acp/__tests__/content-formatter.test.ts new file mode 100644 index 00000000000..f4587e9a19f --- /dev/null +++ b/apps/cli/src/acp/__tests__/content-formatter.test.ts @@ -0,0 +1,298 @@ +/** + * Content Formatter Unit Tests + * + * Tests for the ContentFormatter class. + */ + +import { ContentFormatter, createContentFormatter } from "../content-formatter.js" + +describe("ContentFormatter", () => { + describe("formatToolResult", () => { + const formatter = new ContentFormatter() + + it("should format search results", () => { + const content = "Found 5 results.\n\n# src/file.ts\n 1 | match" + const result = formatter.formatToolResult("search", content) + + expect(result).toContain("Found 5 results in 1 file") + expect(result).toContain("- src/file.ts") + expect(result).toMatch(/^```/) + expect(result).toMatch(/```$/) + }) + + it("should format read results", () => { + const content = "line1\nline2\nline3" + const result = formatter.formatToolResult("read", content) + + expect(result).toContain("line1") + expect(result).toContain("line2") + expect(result).toContain("line3") + expect(result).toMatch(/^```/) + expect(result).toMatch(/```$/) + }) + + it("should return content unchanged for unknown kinds", () => { + const content = "some content" + const result = formatter.formatToolResult("unknown", content) + + expect(result).toBe(content) + }) + }) + + describe("formatSearchResults", () => { + const formatter = new ContentFormatter() + + it("should extract file count and result count", () => { + const content = "Found 10 results.\n\n# src/a.ts\n 1 | code\n\n# src/b.ts\n 5 | code" + const result = formatter.formatSearchResults(content) + + expect(result).toContain("Found 10 results in 2 files") + }) + + it("should list unique files alphabetically", () => { + const content = "Found 3 results.\n\n# src/z.ts\n 1 | a\n\n# src/a.ts\n 2 | b\n\n# src/m.ts\n 3 | c" + const result = formatter.formatSearchResults(content) + + const lines = result.split("\n") + const fileLines = lines.filter((l) => l.startsWith("- ")) + + expect(fileLines[0]).toBe("- src/a.ts") + expect(fileLines[1]).toBe("- src/m.ts") + expect(fileLines[2]).toBe("- src/z.ts") + }) + + it("should deduplicate repeated file paths", () => { + const content = + "Found 5 results.\n\n# src/file.ts\n 1 | a\n\n# src/file.ts\n 5 | b\n\n# src/other.ts\n 10 | c" + const result = formatter.formatSearchResults(content) + + expect(result).toContain("in 2 files") + expect((result.match(/- src\/file\.ts/g) || []).length).toBe(1) + }) + + it("should handle no files found", () => { + const content = "No results found" + const result = formatter.formatSearchResults(content) + + expect(result).toBe("No results found") + }) + + it("should handle singular result", () => { + const content = "Found 1 result.\n\n# src/file.ts\n 1 | match" + const result = formatter.formatSearchResults(content) + + expect(result).toContain("Found 1 result in 1 file") + }) + + it("should handle missing result count", () => { + const content = "# src/file.ts\n 1 | match" + const result = formatter.formatSearchResults(content) + + expect(result).toContain("Found matches in 1 file") + }) + }) + + describe("formatReadResults", () => { + it("should return short content unchanged", () => { + const formatter = new ContentFormatter({ maxReadLines: 100 }) + const content = "line1\nline2\nline3" + const result = formatter.formatReadResults(content) + + expect(result).toBe(content) + }) + + it("should truncate long content", () => { + const formatter = new ContentFormatter({ maxReadLines: 5 }) + const lines = Array.from({ length: 10 }, (_, i) => `line${i + 1}`) + const content = lines.join("\n") + const result = formatter.formatReadResults(content) + + expect(result).toContain("line1") + expect(result).toContain("line5") + expect(result).not.toContain("line6") + expect(result).toContain("... (5 more lines)") + }) + + it("should handle exactly maxReadLines", () => { + const formatter = new ContentFormatter({ maxReadLines: 5 }) + const lines = Array.from({ length: 5 }, (_, i) => `line${i + 1}`) + const content = lines.join("\n") + const result = formatter.formatReadResults(content) + + expect(result).toBe(content) + }) + + it("should use default maxReadLines of 100", () => { + const formatter = new ContentFormatter() + const lines = Array.from({ length: 105 }, (_, i) => `line${i + 1}`) + const content = lines.join("\n") + const result = formatter.formatReadResults(content) + + expect(result).toContain("... (5 more lines)") + }) + }) + + describe("wrapInCodeBlock", () => { + const formatter = new ContentFormatter() + + it("should wrap content in code block", () => { + const result = formatter.wrapInCodeBlock("some code") + + expect(result).toBe("```\nsome code\n```") + }) + + it("should support language specification", () => { + const result = formatter.wrapInCodeBlock("const x = 1", "typescript") + + expect(result).toBe("```typescript\nconst x = 1\n```") + }) + + it("should handle empty content", () => { + const result = formatter.wrapInCodeBlock("") + + expect(result).toBe("```\n\n```") + }) + + it("should handle multiline content", () => { + const result = formatter.wrapInCodeBlock("line1\nline2\nline3") + + expect(result).toBe("```\nline1\nline2\nline3\n```") + }) + }) + + describe("extractContentFromRawInput", () => { + const formatter = new ContentFormatter() + + it("should extract content field", () => { + const result = formatter.extractContentFromRawInput({ content: "my content" }) + expect(result).toBe("my content") + }) + + it("should extract text field", () => { + const result = formatter.extractContentFromRawInput({ text: "my text" }) + expect(result).toBe("my text") + }) + + it("should extract result field", () => { + const result = formatter.extractContentFromRawInput({ result: "my result" }) + expect(result).toBe("my result") + }) + + it("should extract output field", () => { + const result = formatter.extractContentFromRawInput({ output: "my output" }) + expect(result).toBe("my output") + }) + + it("should extract fileContent field", () => { + const result = formatter.extractContentFromRawInput({ fileContent: "my file content" }) + expect(result).toBe("my file content") + }) + + it("should extract data field", () => { + const result = formatter.extractContentFromRawInput({ data: "my data" }) + expect(result).toBe("my data") + }) + + it("should prioritize content over other fields", () => { + const result = formatter.extractContentFromRawInput({ + content: "content value", + text: "text value", + result: "result value", + }) + expect(result).toBe("content value") + }) + + it("should return undefined for empty object", () => { + const result = formatter.extractContentFromRawInput({}) + expect(result).toBeUndefined() + }) + + it("should return undefined for empty string values", () => { + const result = formatter.extractContentFromRawInput({ content: "", text: "" }) + expect(result).toBeUndefined() + }) + + it("should skip non-string values", () => { + const result = formatter.extractContentFromRawInput({ + content: 123 as unknown as string, + text: "valid text", + }) + expect(result).toBe("valid text") + }) + }) + + describe("extractFileContent", () => { + const formatter = new ContentFormatter() + + it("should use extractContentFromRawInput for non-readFile tools", () => { + const result = formatter.extractFileContent({ tool: "list_files", content: "file list" }, "/workspace") + expect(result).toBe("file list") + }) + + it("should return undefined for readFile with no path", () => { + const result = formatter.extractFileContent({ tool: "readFile" }, "/workspace") + expect(result).toBeUndefined() + }) + + // Note: actual file reading is tested in integration tests + }) + + describe("isUserEcho", () => { + const formatter = new ContentFormatter() + + it("should return false for null prompt", () => { + expect(formatter.isUserEcho("any text", null)).toBe(false) + }) + + it("should detect exact match", () => { + expect(formatter.isUserEcho("hello world", "hello world")).toBe(true) + }) + + it("should be case insensitive", () => { + expect(formatter.isUserEcho("Hello World", "hello world")).toBe(true) + }) + + it("should handle whitespace differences", () => { + expect(formatter.isUserEcho(" hello world ", "hello world")).toBe(true) + }) + + it("should detect text contained in prompt (truncated)", () => { + expect(formatter.isUserEcho("write a function", "write a function that adds numbers")).toBe(true) + }) + + it("should detect prompt contained in text (wrapped)", () => { + expect(formatter.isUserEcho("User said: write a function here", "write a function")).toBe(true) + }) + + it("should not match short strings", () => { + expect(formatter.isUserEcho("test", "this is a test prompt")).toBe(false) + }) + + it("should not match completely different text", () => { + expect(formatter.isUserEcho("completely different", "original prompt text")).toBe(false) + }) + + it("should handle empty strings", () => { + expect(formatter.isUserEcho("", "prompt")).toBe(false) + expect(formatter.isUserEcho("text", "")).toBe(false) + }) + }) +}) + +describe("createContentFormatter", () => { + it("should create a formatter with default config", () => { + const formatter = createContentFormatter() + expect(formatter).toBeInstanceOf(ContentFormatter) + }) + + it("should accept custom config", () => { + const formatter = createContentFormatter({ maxReadLines: 50 }) + + // Test that custom config is used + const lines = Array.from({ length: 55 }, (_, i) => `line${i + 1}`) + const content = lines.join("\n") + const result = formatter.formatReadResults(content) + + expect(result).toContain("... (5 more lines)") + }) +}) diff --git a/apps/cli/src/acp/__tests__/prompt-state.test.ts b/apps/cli/src/acp/__tests__/prompt-state.test.ts new file mode 100644 index 00000000000..96a16945607 --- /dev/null +++ b/apps/cli/src/acp/__tests__/prompt-state.test.ts @@ -0,0 +1,373 @@ +/** + * Prompt State Machine Unit Tests + * + * Tests for the PromptStateMachine class. + */ + +import { PromptStateMachine, createPromptStateMachine } from "../prompt-state.js" + +describe("PromptStateMachine", () => { + describe("initial state", () => { + it("should start in idle state", () => { + const sm = new PromptStateMachine() + expect(sm.getState()).toBe("idle") + }) + + it("should have null abort signal initially", () => { + const sm = new PromptStateMachine() + expect(sm.getAbortSignal()).toBeNull() + }) + + it("should have null prompt text initially", () => { + const sm = new PromptStateMachine() + expect(sm.getCurrentPromptText()).toBeNull() + }) + }) + + describe("canStartPrompt", () => { + it("should return true when idle", () => { + const sm = new PromptStateMachine() + expect(sm.canStartPrompt()).toBe(true) + }) + + it("should return false when processing", async () => { + const sm = new PromptStateMachine() + sm.startPrompt("test") + + expect(sm.canStartPrompt()).toBe(false) + + // Clean up + sm.complete(true) + }) + }) + + describe("isProcessing", () => { + it("should return false when idle", () => { + const sm = new PromptStateMachine() + expect(sm.isProcessing()).toBe(false) + }) + + it("should return true when processing", () => { + const sm = new PromptStateMachine() + sm.startPrompt("test") + + expect(sm.isProcessing()).toBe(true) + + // Clean up + sm.complete(true) + }) + + it("should return false after completion", () => { + const sm = new PromptStateMachine() + sm.startPrompt("test") + sm.complete(true) + + expect(sm.isProcessing()).toBe(false) + }) + }) + + describe("startPrompt", () => { + it("should transition to processing state", () => { + const sm = new PromptStateMachine() + sm.startPrompt("test prompt") + + expect(sm.getState()).toBe("processing") + + // Clean up + sm.complete(true) + }) + + it("should store the prompt text", () => { + const sm = new PromptStateMachine() + sm.startPrompt("my test prompt") + + expect(sm.getCurrentPromptText()).toBe("my test prompt") + + // Clean up + sm.complete(true) + }) + + it("should create abort signal", () => { + const sm = new PromptStateMachine() + sm.startPrompt("test") + + expect(sm.getAbortSignal()).not.toBeNull() + expect(sm.getAbortSignal()?.aborted).toBe(false) + + // Clean up + sm.complete(true) + }) + + it("should return a promise", () => { + const sm = new PromptStateMachine() + const promise = sm.startPrompt("test") + + expect(promise).toBeInstanceOf(Promise) + + // Clean up + sm.complete(true) + }) + + it("should resolve with end_turn on successful completion", async () => { + const sm = new PromptStateMachine() + const promise = sm.startPrompt("test") + + sm.complete(true) + const result = await promise + + expect(result.stopReason).toBe("end_turn") + }) + + it("should resolve with refusal on failed completion", async () => { + const sm = new PromptStateMachine() + const promise = sm.startPrompt("test") + + sm.complete(false) + const result = await promise + + expect(result.stopReason).toBe("refusal") + }) + + it("should resolve with cancelled on cancel", async () => { + const sm = new PromptStateMachine() + const promise = sm.startPrompt("test") + + sm.cancel() + const result = await promise + + expect(result.stopReason).toBe("cancelled") + }) + + it("should cancel existing prompt if called while processing", async () => { + const sm = new PromptStateMachine() + const promise1 = sm.startPrompt("first prompt") + + // Start a second prompt (should cancel first) + sm.startPrompt("second prompt") + + // First promise should resolve with cancelled + const result1 = await promise1 + expect(result1.stopReason).toBe("cancelled") + + // Clean up + sm.complete(true) + }) + }) + + describe("complete", () => { + it("should transition to idle state", () => { + const sm = new PromptStateMachine() + sm.startPrompt("test") + sm.complete(true) + + expect(sm.getState()).toBe("idle") + }) + + it("should return end_turn for success", () => { + const sm = new PromptStateMachine() + sm.startPrompt("test") + + const stopReason = sm.complete(true) + expect(stopReason).toBe("end_turn") + }) + + it("should return refusal for failure", () => { + const sm = new PromptStateMachine() + sm.startPrompt("test") + + const stopReason = sm.complete(false) + expect(stopReason).toBe("refusal") + }) + + it("should clear prompt text", () => { + const sm = new PromptStateMachine() + sm.startPrompt("test") + sm.complete(true) + + expect(sm.getCurrentPromptText()).toBeNull() + }) + + it("should clear abort controller", () => { + const sm = new PromptStateMachine() + sm.startPrompt("test") + sm.complete(true) + + expect(sm.getAbortSignal()).toBeNull() + }) + + it("should be idempotent (multiple calls ignored)", () => { + const sm = new PromptStateMachine() + sm.startPrompt("test") + + const result1 = sm.complete(true) + const result2 = sm.complete(false) // Should be ignored + + expect(result1).toBe("end_turn") + expect(result2).toBe("refusal") // Returns mapped value but doesn't change state + expect(sm.getState()).toBe("idle") + }) + }) + + describe("cancel", () => { + it("should abort the signal", () => { + const sm = new PromptStateMachine() + sm.startPrompt("test") + const signal = sm.getAbortSignal() + + sm.cancel() + + expect(signal?.aborted).toBe(true) + }) + + it("should transition to idle state", async () => { + const sm = new PromptStateMachine() + const promise = sm.startPrompt("test") + + sm.cancel() + await promise + + expect(sm.getState()).toBe("idle") + }) + + it("should be safe to call when idle", () => { + const sm = new PromptStateMachine() + + // Should not throw + expect(() => sm.cancel()).not.toThrow() + expect(sm.getState()).toBe("idle") + }) + + it("should be idempotent (multiple calls safe)", () => { + const sm = new PromptStateMachine() + sm.startPrompt("test") + + sm.cancel() + sm.cancel() // Should not throw + + expect(sm.getState()).toBe("idle") + }) + }) + + describe("reset", () => { + it("should transition to idle state", () => { + const sm = new PromptStateMachine() + sm.startPrompt("test") + sm.reset() + + expect(sm.getState()).toBe("idle") + }) + + it("should clear prompt text", () => { + const sm = new PromptStateMachine() + sm.startPrompt("test") + sm.reset() + + expect(sm.getCurrentPromptText()).toBeNull() + }) + + it("should clear abort controller", () => { + const sm = new PromptStateMachine() + sm.startPrompt("test") + sm.reset() + + expect(sm.getAbortSignal()).toBeNull() + }) + + it("should abort any pending operation", () => { + const sm = new PromptStateMachine() + sm.startPrompt("test") + const signal = sm.getAbortSignal() + + sm.reset() + + expect(signal?.aborted).toBe(true) + }) + + it("should be safe to call when idle", () => { + const sm = new PromptStateMachine() + + expect(() => sm.reset()).not.toThrow() + expect(sm.getState()).toBe("idle") + }) + }) + + describe("abort signal integration", () => { + it("should trigger abort handler on cancel", async () => { + const sm = new PromptStateMachine() + const promise = sm.startPrompt("test") + + let abortHandlerCalled = false + sm.getAbortSignal()?.addEventListener("abort", () => { + abortHandlerCalled = true + }) + + sm.cancel() + await promise + + expect(abortHandlerCalled).toBe(true) + }) + + it("should resolve promise via abort handler", async () => { + const sm = new PromptStateMachine() + const promise = sm.startPrompt("test") + + sm.cancel() + const result = await promise + + expect(result.stopReason).toBe("cancelled") + }) + }) + + describe("lifecycle scenarios", () => { + it("should handle multiple prompt cycles", async () => { + const sm = new PromptStateMachine() + + // First cycle + const promise1 = sm.startPrompt("prompt 1") + expect(sm.isProcessing()).toBe(true) + sm.complete(true) + const result1 = await promise1 + expect(result1.stopReason).toBe("end_turn") + expect(sm.isProcessing()).toBe(false) + + // Second cycle + const promise2 = sm.startPrompt("prompt 2") + expect(sm.isProcessing()).toBe(true) + expect(sm.getCurrentPromptText()).toBe("prompt 2") + sm.complete(false) + const result2 = await promise2 + expect(result2.stopReason).toBe("refusal") + + // Third cycle with cancellation + const promise3 = sm.startPrompt("prompt 3") + sm.cancel() + const result3 = await promise3 + expect(result3.stopReason).toBe("cancelled") + }) + + it("should handle rapid start/cancel cycles", async () => { + const sm = new PromptStateMachine() + + const promises: Promise<{ stopReason: string }>[] = [] + + for (let i = 0; i < 5; i++) { + const promise = sm.startPrompt(`prompt ${i}`) + promises.push(promise) + sm.cancel() + } + + // All should resolve with cancelled + const results = await Promise.all(promises) + expect(results.every((r) => r.stopReason === "cancelled")).toBe(true) + }) + }) +}) + +describe("createPromptStateMachine", () => { + it("should create a new state machine", () => { + const sm = createPromptStateMachine() + + expect(sm).toBeInstanceOf(PromptStateMachine) + expect(sm.getState()).toBe("idle") + }) +}) diff --git a/apps/cli/src/acp/__tests__/tool-content-stream.test.ts b/apps/cli/src/acp/__tests__/tool-content-stream.test.ts new file mode 100644 index 00000000000..d145f2034e5 --- /dev/null +++ b/apps/cli/src/acp/__tests__/tool-content-stream.test.ts @@ -0,0 +1,495 @@ +/** + * Tests for ToolContentStreamManager + * + * Tests the tool content (file creates/edits) streaming functionality + * extracted from session.ts. + */ + +import type { ClineMessage } from "@roo-code/types" + +import { DeltaTracker } from "../delta-tracker.js" +import { ToolContentStreamManager } from "../tool-content-stream.js" +import { NullLogger } from "../interfaces.js" +import type { SendUpdateFn } from "../interfaces.js" + +describe("ToolContentStreamManager", () => { + let deltaTracker: DeltaTracker + let sendUpdate: SendUpdateFn + let sentUpdates: Array> + let manager: ToolContentStreamManager + + beforeEach(() => { + deltaTracker = new DeltaTracker() + sentUpdates = [] + sendUpdate = (update) => { + sentUpdates.push(update as Record) + } + manager = new ToolContentStreamManager({ + deltaTracker, + sendUpdate, + logger: new NullLogger(), + }) + }) + + describe("isToolAskMessage", () => { + it("returns true for tool ask messages", () => { + const message: ClineMessage = { + type: "ask", + ask: "tool", + ts: Date.now(), + text: "{}", + } + expect(manager.isToolAskMessage(message)).toBe(true) + }) + + it("returns false for other ask types", () => { + const message: ClineMessage = { + type: "ask", + ask: "command", + ts: Date.now(), + text: "npm test", + } + expect(manager.isToolAskMessage(message)).toBe(false) + }) + + it("returns false for say messages", () => { + const message: ClineMessage = { + type: "say", + say: "text", + ts: Date.now(), + text: "hello", + } + expect(manager.isToolAskMessage(message)).toBe(false) + }) + }) + + describe("handleToolContentStreaming", () => { + describe("file write tool detection", () => { + const fileWriteTools = [ + "newFileCreated", + "write_to_file", + "create_file", + "editedExistingFile", + "apply_diff", + "modify_file", + ] + + fileWriteTools.forEach((toolName) => { + it(`handles ${toolName} as a file write tool`, () => { + const message: ClineMessage = { + type: "ask", + ask: "tool", + ts: 12345, + text: JSON.stringify({ + tool: toolName, + path: "test.ts", + content: "content", + }), + partial: true, + } + + const result = manager.handleToolContentStreaming(message) + expect(result).toBe(true) + // Should send header since it's a file write tool + expect(sentUpdates.length).toBeGreaterThan(0) + }) + }) + + it("skips non-file tools", () => { + const message: ClineMessage = { + type: "ask", + ask: "tool", + ts: 12345, + text: JSON.stringify({ + tool: "read_file", + path: "test.ts", + }), + partial: true, + } + + const result = manager.handleToolContentStreaming(message) + expect(result).toBe(true) // Handled by skipping + expect(sentUpdates.length).toBe(0) // Nothing sent + }) + }) + + describe("header management", () => { + it("sends header on first valid path", () => { + const message: ClineMessage = { + type: "ask", + ask: "tool", + ts: 12345, + text: JSON.stringify({ + tool: "write_to_file", + path: "src/index.ts", + content: "", + }), + partial: true, + } + + manager.handleToolContentStreaming(message) + + expect(sentUpdates.length).toBe(1) + expect(sentUpdates[0]).toEqual({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "\n**Creating src/index.ts**\n```\n" }, + }) + }) + + it("only sends header once per message", () => { + const ts = 12345 + + // First call + manager.handleToolContentStreaming({ + type: "ask", + ask: "tool", + ts, + text: JSON.stringify({ + tool: "write_to_file", + path: "test.ts", + content: "line 1", + }), + partial: true, + }) + + const headerCount1 = sentUpdates.filter((u) => + ((u.content as { text: string }).text || "").includes("**Creating"), + ).length + + // Second call with same ts + manager.handleToolContentStreaming({ + type: "ask", + ask: "tool", + ts, + text: JSON.stringify({ + tool: "write_to_file", + path: "test.ts", + content: "line 1\nline 2", + }), + partial: true, + }) + + const headerCount2 = sentUpdates.filter((u) => + ((u.content as { text: string }).text || "").includes("**Creating"), + ).length + + expect(headerCount1).toBe(1) + expect(headerCount2).toBe(1) // Still 1, no duplicate + }) + + it("waits for valid path before sending header", () => { + // Path without extension is not valid + const message: ClineMessage = { + type: "ask", + ask: "tool", + ts: 12345, + text: JSON.stringify({ + tool: "write_to_file", + path: "incomplete", + content: "content", + }), + partial: true, + } + + manager.handleToolContentStreaming(message) + expect(sentUpdates.length).toBe(0) // No header yet + }) + + it("validates path has file extension", () => { + const validPaths = ["test.ts", "README.md", "config.json", "src/utils.js"] + const invalidPaths = ["test", "src/folder/", "noextension"] + + validPaths.forEach((path) => { + sentUpdates.length = 0 + manager = new ToolContentStreamManager({ + deltaTracker: new DeltaTracker(), + sendUpdate, + logger: new NullLogger(), + }) + + manager.handleToolContentStreaming({ + type: "ask", + ask: "tool", + ts: Date.now(), + text: JSON.stringify({ tool: "write_to_file", path, content: "" }), + partial: true, + }) + + expect(sentUpdates.length).toBeGreaterThan(0) + }) + + invalidPaths.forEach((path) => { + sentUpdates.length = 0 + manager = new ToolContentStreamManager({ + deltaTracker: new DeltaTracker(), + sendUpdate, + logger: new NullLogger(), + }) + + manager.handleToolContentStreaming({ + type: "ask", + ask: "tool", + ts: Date.now(), + text: JSON.stringify({ tool: "write_to_file", path, content: "x" }), + partial: true, + }) + + expect(sentUpdates.length).toBe(0) + }) + }) + }) + + describe("content streaming", () => { + it("streams content deltas", () => { + const ts = 12345 + + // First chunk + manager.handleToolContentStreaming({ + type: "ask", + ask: "tool", + ts, + text: JSON.stringify({ + tool: "write_to_file", + path: "test.ts", + content: "const x = 1;", + }), + partial: true, + }) + + // Header + content + expect(sentUpdates.length).toBe(2) + expect(sentUpdates[1]).toEqual({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "const x = 1;" }, + }) + + // Second chunk with more content + manager.handleToolContentStreaming({ + type: "ask", + ask: "tool", + ts, + text: JSON.stringify({ + tool: "write_to_file", + path: "test.ts", + content: "const x = 1;\nconst y = 2;", + }), + partial: true, + }) + + // Should only send the delta + expect(sentUpdates.length).toBe(3) + expect(sentUpdates[2]).toEqual({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "\nconst y = 2;" }, + }) + }) + + it("handles multiple tool streams independently", () => { + // First tool + manager.handleToolContentStreaming({ + type: "ask", + ask: "tool", + ts: 1000, + text: JSON.stringify({ + tool: "write_to_file", + path: "file1.ts", + content: "content1", + }), + partial: true, + }) + + // Second tool + manager.handleToolContentStreaming({ + type: "ask", + ask: "tool", + ts: 2000, + text: JSON.stringify({ + tool: "write_to_file", + path: "file2.ts", + content: "content2", + }), + partial: true, + }) + + // Both should get headers + const headers = sentUpdates.filter((u) => + ((u.content as { text: string }).text || "").includes("**Creating"), + ) + expect(headers.length).toBe(2) + }) + }) + + describe("completion", () => { + it("sends closing code fence on complete", () => { + const ts = 12345 + + // Partial message + manager.handleToolContentStreaming({ + type: "ask", + ask: "tool", + ts, + text: JSON.stringify({ + tool: "write_to_file", + path: "test.ts", + content: "content", + }), + partial: true, + }) + + sentUpdates.length = 0 // Clear + + // Complete message + manager.handleToolContentStreaming({ + type: "ask", + ask: "tool", + ts, + text: JSON.stringify({ + tool: "write_to_file", + path: "test.ts", + content: "content", + }), + partial: false, + }) + + expect(sentUpdates[0]).toEqual({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "\n```\n" }, + }) + }) + + it("cleans up header tracking on complete", () => { + const ts = 12345 + + // Partial + manager.handleToolContentStreaming({ + type: "ask", + ask: "tool", + ts, + text: JSON.stringify({ + tool: "write_to_file", + path: "test.ts", + content: "content", + }), + partial: true, + }) + + expect(manager.getActiveHeaderCount()).toBe(1) + + // Complete + manager.handleToolContentStreaming({ + type: "ask", + ask: "tool", + ts, + text: JSON.stringify({ + tool: "write_to_file", + path: "test.ts", + content: "content", + }), + partial: false, + }) + + expect(manager.getActiveHeaderCount()).toBe(0) + }) + + it("does not send code fence if no header was sent", () => { + const ts = 12345 + + // Complete message without prior partial (no header sent) + manager.handleToolContentStreaming({ + type: "ask", + ask: "tool", + ts, + text: JSON.stringify({ + tool: "write_to_file", + path: "test.ts", + content: "content", + }), + partial: false, + }) + + // Should not send closing fence + const closingFences = sentUpdates.filter((u) => + ((u.content as { text: string }).text || "").includes("```"), + ) + expect(closingFences.length).toBe(0) + }) + }) + + describe("JSON parsing", () => { + it("handles invalid JSON gracefully", () => { + const message: ClineMessage = { + type: "ask", + ask: "tool", + ts: 12345, + text: "{incomplete json", + partial: true, + } + + const result = manager.handleToolContentStreaming(message) + expect(result).toBe(true) // Handled by returning early + expect(sentUpdates.length).toBe(0) + }) + + it("handles empty text", () => { + const message: ClineMessage = { + type: "ask", + ask: "tool", + ts: 12345, + text: "", + partial: true, + } + + const result = manager.handleToolContentStreaming(message) + expect(result).toBe(true) + expect(sentUpdates.length).toBe(0) + }) + }) + }) + + describe("reset", () => { + it("clears header tracking", () => { + manager.handleToolContentStreaming({ + type: "ask", + ask: "tool", + ts: 12345, + text: JSON.stringify({ + tool: "write_to_file", + path: "test.ts", + content: "content", + }), + partial: true, + }) + + expect(manager.getActiveHeaderCount()).toBe(1) + + manager.reset() + expect(manager.getActiveHeaderCount()).toBe(0) + }) + }) + + describe("getActiveHeaderCount", () => { + it("returns 0 initially", () => { + expect(manager.getActiveHeaderCount()).toBe(0) + }) + + it("returns correct count after streaming", () => { + manager.handleToolContentStreaming({ + type: "ask", + ask: "tool", + ts: 1000, + text: JSON.stringify({ tool: "write_to_file", path: "a.ts", content: "" }), + partial: true, + }) + + manager.handleToolContentStreaming({ + type: "ask", + ask: "tool", + ts: 2000, + text: JSON.stringify({ tool: "write_to_file", path: "b.ts", content: "" }), + partial: true, + }) + + expect(manager.getActiveHeaderCount()).toBe(2) + }) + }) +}) diff --git a/apps/cli/src/acp/__tests__/tool-handler.test.ts b/apps/cli/src/acp/__tests__/tool-handler.test.ts new file mode 100644 index 00000000000..6edd26071ee --- /dev/null +++ b/apps/cli/src/acp/__tests__/tool-handler.test.ts @@ -0,0 +1,495 @@ +/** + * Tool Handler Unit Tests + * + * Tests for the ToolHandler abstraction and ToolHandlerRegistry. + */ + +import type { ClineMessage, ClineAsk } from "@roo-code/types" + +import { + ToolHandlerRegistry, + CommandToolHandler, + FileEditToolHandler, + FileReadToolHandler, + SearchToolHandler, + ListFilesToolHandler, + DefaultToolHandler, + type ToolHandlerContext, +} from "../tool-handler.js" +import { parseToolFromMessage } from "../translator.js" +import { NullLogger } from "../interfaces.js" + +// ============================================================================= +// Test Utilities +// ============================================================================= + +const testLogger = new NullLogger() + +function createContext(message: ClineMessage, ask: ClineAsk, workspacePath = "/workspace"): ToolHandlerContext { + return { + message, + ask, + workspacePath, + toolInfo: parseToolFromMessage(message, workspacePath), + logger: testLogger, + } +} + +function createToolMessage(tool: string, params: Record = {}): ClineMessage { + return { + ts: Date.now(), + type: "say", + say: "text", + text: JSON.stringify({ tool, ...params }), + } +} + +// ============================================================================= +// CommandToolHandler Tests +// ============================================================================= + +describe("CommandToolHandler", () => { + const handler = new CommandToolHandler() + + describe("canHandle", () => { + it("should handle command asks", () => { + const context = createContext(createToolMessage("execute_command", { command: "ls" }), "command") + expect(handler.canHandle(context)).toBe(true) + }) + + it("should not handle tool asks", () => { + const context = createContext(createToolMessage("read_file", { path: "test.ts" }), "tool") + expect(handler.canHandle(context)).toBe(false) + }) + + it("should not handle browser_action_launch asks", () => { + const context = createContext(createToolMessage("browser_action", {}), "browser_action_launch") + expect(handler.canHandle(context)).toBe(false) + }) + }) + + describe("handle", () => { + it("should return execute kind for commands", () => { + const context = createContext(createToolMessage("execute_command", { command: "npm test" }), "command") + const result = handler.handle(context) + + expect(result.initialUpdate).toMatchObject({ + sessionUpdate: "tool_call", + kind: "execute", + status: "in_progress", + }) + }) + + it("should track as pending command", () => { + const message = createToolMessage("execute_command", { command: "npm test" }) + const context = createContext(message, "command") + const result = handler.handle(context) + + expect(result.trackAsPendingCommand).toBeDefined() + expect(result.trackAsPendingCommand?.command).toBe(message.text) + expect(result.trackAsPendingCommand?.ts).toBe(message.ts) + }) + + it("should not include completion update", () => { + const context = createContext(createToolMessage("execute_command", { command: "ls" }), "command") + const result = handler.handle(context) + + expect(result.completionUpdate).toBeUndefined() + }) + }) +}) + +// ============================================================================= +// FileEditToolHandler Tests +// ============================================================================= + +describe("FileEditToolHandler", () => { + const handler = new FileEditToolHandler() + + describe("canHandle", () => { + it("should handle write_to_file tool", () => { + const context = createContext(createToolMessage("write_to_file", { path: "test.ts" }), "tool") + expect(handler.canHandle(context)).toBe(true) + }) + + it("should handle apply_diff tool", () => { + const context = createContext(createToolMessage("apply_diff", { path: "test.ts" }), "tool") + expect(handler.canHandle(context)).toBe(true) + }) + + it("should handle create_file tool", () => { + const context = createContext(createToolMessage("create_file", { path: "new.ts" }), "tool") + expect(handler.canHandle(context)).toBe(true) + }) + + it("should handle newFileCreated tool", () => { + const context = createContext(createToolMessage("newFileCreated", { path: "new.ts" }), "tool") + expect(handler.canHandle(context)).toBe(true) + }) + + it("should handle editedExistingFile tool", () => { + const context = createContext(createToolMessage("editedExistingFile", { path: "test.ts" }), "tool") + expect(handler.canHandle(context)).toBe(true) + }) + + it("should not handle read_file tool", () => { + const context = createContext(createToolMessage("read_file", { path: "test.ts" }), "tool") + expect(handler.canHandle(context)).toBe(false) + }) + + it("should not handle command asks", () => { + const context = createContext(createToolMessage("write_to_file", { path: "test.ts" }), "command") + expect(handler.canHandle(context)).toBe(false) + }) + }) + + describe("handle", () => { + it("should return edit kind", () => { + const context = createContext(createToolMessage("write_to_file", { path: "test.ts" }), "tool") + const result = handler.handle(context) + + expect(result.initialUpdate).toMatchObject({ + sessionUpdate: "tool_call", + kind: "edit", + status: "in_progress", + }) + }) + + it("should include completion update", () => { + const context = createContext(createToolMessage("write_to_file", { path: "test.ts" }), "tool") + const result = handler.handle(context) + + expect(result.completionUpdate).toMatchObject({ + sessionUpdate: "tool_call_update", + status: "completed", + }) + }) + + it("should not track as pending command", () => { + const context = createContext(createToolMessage("write_to_file", { path: "test.ts" }), "tool") + const result = handler.handle(context) + + expect(result.trackAsPendingCommand).toBeUndefined() + }) + }) +}) + +// ============================================================================= +// FileReadToolHandler Tests +// ============================================================================= + +describe("FileReadToolHandler", () => { + const handler = new FileReadToolHandler() + + describe("canHandle", () => { + it("should handle read_file tool", () => { + const context = createContext(createToolMessage("read_file", { path: "test.ts" }), "tool") + expect(handler.canHandle(context)).toBe(true) + }) + + it("should handle readFile tool", () => { + const context = createContext(createToolMessage("readFile", { path: "test.ts" }), "tool") + expect(handler.canHandle(context)).toBe(true) + }) + + it("should not handle write_to_file tool", () => { + const context = createContext(createToolMessage("write_to_file", { path: "test.ts" }), "tool") + expect(handler.canHandle(context)).toBe(false) + }) + + it("should not handle command asks", () => { + const context = createContext(createToolMessage("read_file", { path: "test.ts" }), "command") + expect(handler.canHandle(context)).toBe(false) + }) + }) + + describe("handle", () => { + it("should return read kind", () => { + const context = createContext(createToolMessage("read_file", { path: "test.ts" }), "tool") + const result = handler.handle(context) + + expect(result.initialUpdate).toMatchObject({ + sessionUpdate: "tool_call", + kind: "read", + status: "in_progress", + }) + }) + + it("should include completion update", () => { + const context = createContext(createToolMessage("read_file", { path: "test.ts" }), "tool") + const result = handler.handle(context) + + expect(result.completionUpdate).toMatchObject({ + sessionUpdate: "tool_call_update", + status: "completed", + }) + }) + }) +}) + +// ============================================================================= +// SearchToolHandler Tests +// ============================================================================= + +describe("SearchToolHandler", () => { + const handler = new SearchToolHandler() + + describe("canHandle", () => { + it("should handle search_files tool", () => { + const context = createContext(createToolMessage("search_files", { regex: "test" }), "tool") + expect(handler.canHandle(context)).toBe(true) + }) + + it("should handle searchFiles tool", () => { + const context = createContext(createToolMessage("searchFiles", { regex: "test" }), "tool") + expect(handler.canHandle(context)).toBe(true) + }) + + it("should handle codebase_search tool", () => { + const context = createContext(createToolMessage("codebase_search", { query: "test" }), "tool") + expect(handler.canHandle(context)).toBe(true) + }) + + it("should handle grep tool", () => { + const context = createContext(createToolMessage("grep", { pattern: "test" }), "tool") + expect(handler.canHandle(context)).toBe(true) + }) + + it("should not handle custom tool with search in name (exact matching)", () => { + const context = createContext(createToolMessage("custom_search_tool", {}), "tool") + // With exact matching, "custom_search_tool" won't match the search category + expect(handler.canHandle(context)).toBe(false) + }) + + it("should not handle read_file tool", () => { + const context = createContext(createToolMessage("read_file", { path: "test.ts" }), "tool") + expect(handler.canHandle(context)).toBe(false) + }) + }) + + describe("handle", () => { + it("should return search kind", () => { + const context = createContext(createToolMessage("search_files", { regex: "test" }), "tool") + const result = handler.handle(context) + + expect(result.initialUpdate).toMatchObject({ + sessionUpdate: "tool_call", + kind: "search", + status: "in_progress", + }) + }) + + it("should format search results in completion", () => { + const searchResults = "Found 5 results.\n\n# src/file1.ts\n 1 | match\n\n# src/file2.ts\n 2 | match" + const context = createContext(createToolMessage("search_files", { content: searchResults }), "tool") + const result = handler.handle(context) + + expect(result.completionUpdate).toMatchObject({ + sessionUpdate: "tool_call_update", + status: "completed", + }) + + // Content should be formatted - cast to access content property + const completionUpdate = result.completionUpdate as Record + expect(completionUpdate?.content).toBeDefined() + }) + }) +}) + +// ============================================================================= +// ListFilesToolHandler Tests +// ============================================================================= + +describe("ListFilesToolHandler", () => { + const handler = new ListFilesToolHandler() + + describe("canHandle", () => { + it("should handle list_files tool", () => { + const context = createContext(createToolMessage("list_files", { path: "src" }), "tool") + expect(handler.canHandle(context)).toBe(true) + }) + + it("should handle listFiles tool", () => { + const context = createContext(createToolMessage("listFiles", { path: "src" }), "tool") + expect(handler.canHandle(context)).toBe(true) + }) + + it("should handle listFilesTopLevel tool", () => { + const context = createContext(createToolMessage("listFilesTopLevel", { path: "src" }), "tool") + expect(handler.canHandle(context)).toBe(true) + }) + + it("should handle listFilesRecursive tool", () => { + const context = createContext(createToolMessage("listFilesRecursive", { path: "src" }), "tool") + expect(handler.canHandle(context)).toBe(true) + }) + + it("should not handle read_file tool", () => { + const context = createContext(createToolMessage("read_file", { path: "test.ts" }), "tool") + expect(handler.canHandle(context)).toBe(false) + }) + }) + + describe("handle", () => { + it("should return read kind", () => { + const context = createContext(createToolMessage("list_files", { path: "src" }), "tool") + const result = handler.handle(context) + + expect(result.initialUpdate).toMatchObject({ + sessionUpdate: "tool_call", + kind: "read", + status: "in_progress", + }) + }) + }) +}) + +// ============================================================================= +// DefaultToolHandler Tests +// ============================================================================= + +describe("DefaultToolHandler", () => { + const handler = new DefaultToolHandler() + + describe("canHandle", () => { + it("should always return true", () => { + const context1 = createContext(createToolMessage("unknown_tool", {}), "tool") + const context2 = createContext(createToolMessage("custom_operation", {}), "tool") + const context3 = createContext(createToolMessage("any_tool", {}), "browser_action_launch") + + expect(handler.canHandle(context1)).toBe(true) + expect(handler.canHandle(context2)).toBe(true) + expect(handler.canHandle(context3)).toBe(true) + }) + }) + + describe("handle", () => { + it("should map tool kind from tool name (exact matching)", () => { + // Use exact tool name from TOOL_CATEGORIES.think + const context = createContext(createToolMessage("think", {}), "tool") + const result = handler.handle(context) + + expect(result.initialUpdate).toMatchObject({ + sessionUpdate: "tool_call", + kind: "think", + status: "in_progress", + }) + }) + + it("should return other kind for unknown tools (exact matching)", () => { + // Tool names that don't exactly match categories return "other" + const context = createContext(createToolMessage("think_about_it", {}), "tool") + const result = handler.handle(context) + + expect(result.initialUpdate).toMatchObject({ + sessionUpdate: "tool_call", + kind: "other", + status: "in_progress", + }) + }) + + it("should include completion update", () => { + const context = createContext(createToolMessage("custom_tool", {}), "tool") + const result = handler.handle(context) + + expect(result.completionUpdate).toMatchObject({ + sessionUpdate: "tool_call_update", + status: "completed", + }) + }) + }) +}) + +// ============================================================================= +// ToolHandlerRegistry Tests +// ============================================================================= + +describe("ToolHandlerRegistry", () => { + describe("getHandler", () => { + const registry = new ToolHandlerRegistry() + + it("should return CommandToolHandler for command asks", () => { + const context = createContext(createToolMessage("execute_command", {}), "command") + const handler = registry.getHandler(context) + + expect(handler).toBeInstanceOf(CommandToolHandler) + }) + + it("should return FileEditToolHandler for edit tools", () => { + const context = createContext(createToolMessage("write_to_file", { path: "test.ts" }), "tool") + const handler = registry.getHandler(context) + + expect(handler).toBeInstanceOf(FileEditToolHandler) + }) + + it("should return FileReadToolHandler for read tools", () => { + const context = createContext(createToolMessage("read_file", { path: "test.ts" }), "tool") + const handler = registry.getHandler(context) + + expect(handler).toBeInstanceOf(FileReadToolHandler) + }) + + it("should return SearchToolHandler for search tools", () => { + const context = createContext(createToolMessage("search_files", {}), "tool") + const handler = registry.getHandler(context) + + expect(handler).toBeInstanceOf(SearchToolHandler) + }) + + it("should return ListFilesToolHandler for list tools", () => { + const context = createContext(createToolMessage("list_files", {}), "tool") + const handler = registry.getHandler(context) + + expect(handler).toBeInstanceOf(ListFilesToolHandler) + }) + + it("should return DefaultToolHandler for unknown tools", () => { + const context = createContext(createToolMessage("unknown_tool", {}), "tool") + const handler = registry.getHandler(context) + + expect(handler).toBeInstanceOf(DefaultToolHandler) + }) + }) + + describe("handle", () => { + const registry = new ToolHandlerRegistry() + + it("should dispatch to correct handler and return result", () => { + const context = createContext(createToolMessage("execute_command", {}), "command") + const result = registry.handle(context) + + expect(result.initialUpdate).toMatchObject({ + sessionUpdate: "tool_call", + kind: "execute", + status: "in_progress", + }) + expect(result.trackAsPendingCommand).toBeDefined() + }) + }) + + describe("createContext", () => { + it("should create a valid context", () => { + const message = createToolMessage("read_file", { path: "test.ts" }) + const context = ToolHandlerRegistry.createContext(message, "tool", "/workspace", testLogger) + + expect(context.message).toBe(message) + expect(context.ask).toBe("tool") + expect(context.workspacePath).toBe("/workspace") + expect(context.toolInfo).toBeDefined() + expect(context.toolInfo?.name).toBe("read_file") + expect(context.logger).toBe(testLogger) + }) + }) + + describe("custom handlers", () => { + it("should accept custom handler list", () => { + const customHandler = new DefaultToolHandler() + const registry = new ToolHandlerRegistry([customHandler]) + + const context = createContext(createToolMessage("any_tool", {}), "command") + const handler = registry.getHandler(context) + + expect(handler).toBe(customHandler) + }) + }) +}) diff --git a/apps/cli/src/acp/__tests__/translator.test.ts b/apps/cli/src/acp/__tests__/translator.test.ts index 26795e72d64..4f22b0c5fba 100644 --- a/apps/cli/src/acp/__tests__/translator.test.ts +++ b/apps/cli/src/acp/__tests__/translator.test.ts @@ -140,10 +140,17 @@ describe("parseToolFromMessage", () => { describe("mapToolKind", () => { it("should map read operations", () => { + // Uses exact matching with normalized tool names from TOOL_CATEGORIES expect(mapToolKind("read_file")).toBe("read") + expect(mapToolKind("readFile")).toBe("read") + }) + + it("should map list_files to read kind", () => { + // list operations are read-like in the ACP protocol expect(mapToolKind("list_files")).toBe("read") - expect(mapToolKind("inspect_code")).toBe("read") - expect(mapToolKind("get_info")).toBe("read") + expect(mapToolKind("listFiles")).toBe("read") + expect(mapToolKind("listFilesTopLevel")).toBe("read") + expect(mapToolKind("listFilesRecursive")).toBe("read") }) it("should map edit operations", () => { @@ -151,53 +158,76 @@ describe("mapToolKind", () => { expect(mapToolKind("apply_diff")).toBe("edit") expect(mapToolKind("modify_file")).toBe("edit") expect(mapToolKind("create_file")).toBe("edit") + expect(mapToolKind("newFileCreated")).toBe("edit") + expect(mapToolKind("editedExistingFile")).toBe("edit") }) it("should map delete operations", () => { expect(mapToolKind("delete_file")).toBe("delete") - expect(mapToolKind("remove_directory")).toBe("delete") + expect(mapToolKind("deleteFile")).toBe("delete") + expect(mapToolKind("remove_file")).toBe("delete") + expect(mapToolKind("removeFile")).toBe("delete") }) it("should map move operations", () => { expect(mapToolKind("move_file")).toBe("move") + expect(mapToolKind("moveFile")).toBe("move") expect(mapToolKind("rename_file")).toBe("move") - expect(mapToolKind("move_directory")).toBe("move") + expect(mapToolKind("renameFile")).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") + expect(mapToolKind("searchFiles")).toBe("search") + expect(mapToolKind("codebase_search")).toBe("search") + expect(mapToolKind("codebaseSearch")).toBe("search") + expect(mapToolKind("grep")).toBe("search") + expect(mapToolKind("ripgrep")).toBe("search") }) it("should map execute operations", () => { expect(mapToolKind("execute_command")).toBe("execute") - expect(mapToolKind("run_script")).toBe("execute") + expect(mapToolKind("executeCommand")).toBe("execute") + expect(mapToolKind("run_command")).toBe("execute") + expect(mapToolKind("runCommand")).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") + expect(mapToolKind("reason")).toBe("think") + expect(mapToolKind("plan")).toBe("think") + expect(mapToolKind("analyze")).toBe("think") }) it("should map fetch operations", () => { - expect(mapToolKind("browser_action")).toBe("fetch") - expect(mapToolKind("fetch_url")).toBe("fetch") + // Note: browser_action is NOT mapped to fetch because browser tools are disabled in CLI + expect(mapToolKind("fetch")).toBe("fetch") expect(mapToolKind("web_request")).toBe("fetch") + expect(mapToolKind("webRequest")).toBe("fetch") expect(mapToolKind("http_get")).toBe("fetch") + expect(mapToolKind("httpGet")).toBe("fetch") + expect(mapToolKind("http_post")).toBe("fetch") + expect(mapToolKind("url_fetch")).toBe("fetch") + }) + + it("should map browser_action to other (browser tools disabled in CLI)", () => { + // browser_action intentionally maps to "other" because browser tools are disabled in CLI mode + expect(mapToolKind("browser_action")).toBe("other") }) 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") + expect(mapToolKind("setMode")).toBe("switch_mode") }) it("should return other for unknown operations", () => { expect(mapToolKind("unknown_tool")).toBe("other") expect(mapToolKind("custom_operation")).toBe("other") + // Tool names that don't exactly match categories also return other + expect(mapToolKind("inspect_code")).toBe("other") + expect(mapToolKind("get_info")).toBe("other") }) }) @@ -345,6 +375,7 @@ describe("buildToolCallFromMessage", () => { const result = buildToolCallFromMessage(message) + // Tool ID is deterministic based on message timestamp for debugging expect(result.toolCallId).toBe("tool-12345") // Title is now human-readable based on tool name and filename expect(result.title).toBe("Read file.txt") @@ -362,6 +393,7 @@ describe("buildToolCallFromMessage", () => { const result = buildToolCallFromMessage(message) + // Tool ID is deterministic based on message timestamp for debugging expect(result.toolCallId).toBe("tool-12345") expect(result.kind).toBe("other") }) diff --git a/apps/cli/src/acp/command-stream.ts b/apps/cli/src/acp/command-stream.ts new file mode 100644 index 00000000000..37b4e2e9d21 --- /dev/null +++ b/apps/cli/src/acp/command-stream.ts @@ -0,0 +1,265 @@ +/** + * CommandStreamManager + * + * Manages streaming of command execution output with code fence wrapping. + * Handles both live command execution events and final command_output messages. + * + * Extracted from session.ts to separate the command output streaming concern. + */ + +import type { ClineMessage } from "@roo-code/types" + +import type { IDeltaTracker, IAcpLogger, SendUpdateFn } from "./interfaces.js" + +// ============================================================================= +// Types +// ============================================================================= + +/** + * Information about a pending command execution. + */ +export interface PendingCommand { + toolCallId: string + command: string + ts: number +} + +/** + * Options for creating a CommandStreamManager. + */ +export interface CommandStreamManagerOptions { + /** Delta tracker for tracking already-sent content */ + deltaTracker: IDeltaTracker + /** Callback to send session updates */ + sendUpdate: SendUpdateFn + /** Logger instance */ + logger: IAcpLogger +} + +// ============================================================================= +// CommandStreamManager Class +// ============================================================================= + +/** + * Manages command output streaming with proper code fence wrapping. + * + * Responsibilities: + * - Track pending command tool calls + * - Handle live command execution output (with code fences) + * - Handle final command_output messages + * - Send tool_call_update notifications + */ +export class CommandStreamManager { + /** + * Track pending command tool calls for the "Run Command" UI. + * Maps tool call ID to command info. + */ + private pendingCommandCalls: Map = new Map() + + /** + * Track which command executions have sent the opening code fence. + * Used to wrap command output in markdown code blocks. + */ + private commandCodeFencesSent: Set = new Set() + + /** + * Map executionId → toolCallId for robust command output routing. + * The executionId is generated by the extension when the command starts, + * so we establish this mapping when we first see output for an executionId. + * This ensures streaming output goes to the correct tool call, even with + * concurrent commands. + */ + private executionToToolCallId: Map = new Map() + + private readonly deltaTracker: IDeltaTracker + private readonly sendUpdate: SendUpdateFn + private readonly logger: IAcpLogger + + constructor(options: CommandStreamManagerOptions) { + this.deltaTracker = options.deltaTracker + this.sendUpdate = options.sendUpdate + this.logger = options.logger + } + + // =========================================================================== + // Public API + // =========================================================================== + + /** + * Track a new pending command. + * Called when a command tool call is approved. + */ + trackCommand(toolCallId: string, command: string, ts: number): void { + this.pendingCommandCalls.set(toolCallId, { toolCallId, command, ts }) + this.logger.debug("CommandStream", `Tracking command: ${toolCallId}`) + } + + /** + * Handle a command_output message from the extension. + * This handles the final tool_call_update for completion, plus the closing fence. + * + * NOTE: Streaming output is handled by handleExecutionOutput(). + * This method handles: + * 1. Sending the closing code fence as agent_message_chunk (if streaming occurred) + * 2. Sending the final tool_call_update with status "completed" + */ + handleCommandOutput(message: ClineMessage): void { + const output = message.text || "" + const isPartial = message.partial === true + + this.logger.debug( + "CommandStream", + `handleCommandOutput: partial=${message.partial}, text length=${output.length}`, + ) + + // Skip partial updates - streaming is handled by handleExecutionOutput() + if (isPartial) { + return + } + + // Handle completion - update the tool call UI + const pendingCall = this.findMostRecentPendingCommand() + + if (pendingCall) { + this.logger.debug("CommandStream", `Command completed: ${pendingCall.toolCallId}`) + + // Send closing code fence as agent_message_chunk if we had streaming output + const hadStreamingOutput = this.commandCodeFencesSent.has(pendingCall.toolCallId) + if (hadStreamingOutput) { + this.logger.debug("CommandStream", "Sending closing code fence via agent_message_chunk") + this.sendUpdate({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "```\n" }, + }) + this.commandCodeFencesSent.delete(pendingCall.toolCallId) + } + + // Command completed - send final tool_call_update with completed status + // Note: Zed doesn't display tool_call_update content, so we just mark it complete + this.sendUpdate({ + sessionUpdate: "tool_call_update", + toolCallId: pendingCall.toolCallId, + status: "completed", + rawOutput: { output }, + }) + this.pendingCommandCalls.delete(pendingCall.toolCallId) + } + } + + /** + * Handle streaming command execution output (live terminal output). + * This provides real-time output during command execution. + * + * Sends output as agent_message_chunk messages for Zed visibility. + * The tool_call UI is updated separately in session-event-handler. + * + * Output is wrapped in markdown code blocks: + * - Opening fence ``` sent on first chunk + * - Subsequent chunks sent as-is (deltas only) + * - Closing fence ``` sent in handleCommandOutput() + * + * Uses executionId → toolCallId mapping for robust routing. + */ + handleExecutionOutput(executionId: string, output: string): void { + this.logger.debug( + "CommandStream", + `handleExecutionOutput: executionId=${executionId}, output length=${output.length}`, + ) + + // Find or establish the toolCallId for this executionId + let toolCallId = this.executionToToolCallId.get(executionId) + + if (!toolCallId) { + // First output for this executionId - establish the mapping + const pendingCall = this.findMostRecentPendingCommand() + if (!pendingCall) { + this.logger.debug("CommandStream", "No pending command, skipping execution output") + return + } + toolCallId = pendingCall.toolCallId + this.executionToToolCallId.set(executionId, toolCallId) + this.logger.debug("CommandStream", `Mapped executionId ${executionId} → toolCallId ${toolCallId}`) + } + + // Use executionId as the message key for delta tracking + const delta = this.deltaTracker.getDelta(executionId, output) + if (!delta) { + return + } + + // Send opening code fence on first chunk + const isFirstChunk = !this.commandCodeFencesSent.has(toolCallId) + if (isFirstChunk) { + this.commandCodeFencesSent.add(toolCallId) + this.logger.debug("CommandStream", `Sending opening code fence for toolCallId ${toolCallId}`) + this.sendUpdate({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "```\n" }, + }) + } + + // Send the delta as agent_message_chunk for Zed visibility + this.logger.debug("CommandStream", `Streaming command output via agent_message_chunk: ${delta.length} chars`) + this.sendUpdate({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: delta }, + }) + } + + /** + * Check if a message is a command_output message that this manager handles. + */ + isCommandOutputMessage(message: ClineMessage): boolean { + return message.type === "say" && message.say === "command_output" + } + + /** + * Reset state for a new prompt. + * Call when starting a new prompt to clear all pending state. + */ + reset(): void { + // Clear all pending commands - any from previous prompts are now stale + // and would cause duplicate completion messages if not cleaned up + const staleCount = this.pendingCommandCalls.size + if (staleCount > 0) { + this.logger.debug("CommandStream", `Clearing ${staleCount} stale pending commands`) + } + this.pendingCommandCalls.clear() + this.commandCodeFencesSent.clear() + this.executionToToolCallId.clear() + this.logger.debug("CommandStream", "Reset command stream state") + } + + /** + * Get the number of pending commands (for testing/debugging). + */ + getPendingCommandCount(): number { + return this.pendingCommandCalls.size + } + + /** + * Check if there are any open code fences (for testing/debugging). + */ + hasOpenCodeFences(): boolean { + return this.commandCodeFencesSent.size > 0 + } + + // =========================================================================== + // Private Methods + // =========================================================================== + + /** + * Find the most recent pending command call. + */ + private findMostRecentPendingCommand(): PendingCommand | undefined { + let pendingCall: PendingCommand | undefined + + for (const [, call] of this.pendingCommandCalls) { + if (!pendingCall || call.ts > pendingCall.ts) { + pendingCall = call + } + } + + return pendingCall + } +} diff --git a/apps/cli/src/acp/content-formatter.ts b/apps/cli/src/acp/content-formatter.ts new file mode 100644 index 00000000000..fd099187224 --- /dev/null +++ b/apps/cli/src/acp/content-formatter.ts @@ -0,0 +1,221 @@ +/** + * Content Formatter + * + * Provides content formatting for ACP UI display. + * + * This module offers two usage patterns: + * + * 1. **Direct function imports** (preferred for simple use cases): + * ```ts + * import { formatSearchResults, wrapInCodeBlock } from './content-formatter.js' + * const formatted = wrapInCodeBlock(formatSearchResults(content)) + * ``` + * + * 2. **Class-based DI** (for dependency injection in tests): + * ```ts + * import { ContentFormatter, type IContentFormatter } from './content-formatter.js' + * const formatter: IContentFormatter = new ContentFormatter() + * ``` + */ + +import type { IContentFormatter } from "./interfaces.js" +import { + formatSearchResults, + formatReadContent, + wrapInCodeBlock, + isUserEcho, + readFileContent, + readFileContentAsync, + extractContentFromParams, + type FormatConfig, + DEFAULT_FORMAT_CONFIG, +} from "./utils/index.js" +import { acpLog } from "./logger.js" + +// ============================================================================= +// Direct Exports (Preferred) +// ============================================================================= + +// Re-export utility functions for direct use +export { formatSearchResults, formatReadContent, wrapInCodeBlock, isUserEcho } + +// ============================================================================= +// Tool Result Formatting +// ============================================================================= + +/** + * Format tool result content based on the tool kind. + * + * Applies appropriate formatting (search summary, truncation, code blocks) + * based on the tool type. + * + * @param kind - The tool kind (search, read, etc.) + * @param content - The raw content to format + * @param config - Optional formatting configuration + * @returns Formatted content + */ +export function formatToolResult(kind: string, content: string, config: FormatConfig = DEFAULT_FORMAT_CONFIG): string { + switch (kind) { + case "search": + return wrapInCodeBlock(formatSearchResults(content)) + case "read": + return wrapInCodeBlock(formatReadContent(content, config)) + default: + return content + } +} + +/** + * Extract file content for readFile operations. + * + * For readFile tools, the rawInput.content field contains the file PATH + * (not the contents), so we need to read the actual file. + * + * @param rawInput - Tool parameters + * @param workspacePath - Workspace path for resolving relative paths + * @returns File content or error message, or undefined if no path + */ +export function extractFileContent(rawInput: Record, workspacePath: string): string | undefined { + const toolName = (rawInput.tool as string | undefined)?.toLowerCase() || "" + + // Only read file content for readFile tools + if (toolName !== "readfile" && toolName !== "read_file") { + return extractContentFromParams(rawInput) + } + + // Check if we have a path before attempting to read + const filePath = rawInput.content as string | undefined + const relativePath = rawInput.path as string | undefined + if (!filePath && !relativePath) { + acpLog.warn("ContentFormatter", "readFile tool has no path") + return undefined + } + + const result = readFileContent(rawInput, workspacePath) + if (result.ok) { + acpLog.debug("ContentFormatter", `Read file content: ${result.value.length} chars`) + return result.value + } else { + acpLog.error("ContentFormatter", result.error) + return `Error reading file: ${result.error}` + } +} + +/** + * Extract file content asynchronously for readFile operations. + * + * @param rawInput - Tool parameters + * @param workspacePath - Workspace path for resolving relative paths + * @returns Promise with file content or error message + */ +export async function extractFileContentAsync( + rawInput: Record, + workspacePath: string, +): Promise { + const toolName = (rawInput.tool as string | undefined)?.toLowerCase() || "" + + // Only read file content for readFile tools + if (toolName !== "readfile" && toolName !== "read_file") { + return extractContentFromParams(rawInput) + } + + // Check if we have a path before attempting to read + const filePath = rawInput.content as string | undefined + const relativePath = rawInput.path as string | undefined + if (!filePath && !relativePath) { + acpLog.warn("ContentFormatter", "readFile tool has no path") + return undefined + } + + const result = await readFileContentAsync(rawInput, workspacePath) + if (result.ok) { + acpLog.debug("ContentFormatter", `Read file content: ${result.value.length} chars`) + return result.value + } else { + acpLog.error("ContentFormatter", result.error) + return `Error reading file: ${result.error}` + } +} + +// ============================================================================= +// ContentFormatter Class (for DI) +// ============================================================================= + +/** + * Formats content for display in the ACP client UI. + * + * Implements IContentFormatter interface for dependency injection. + * For simple use cases, prefer the direct function exports above. + * + * @example + * ```ts + * // In production code + * const formatter = new ContentFormatter() + * + * // In tests with mock + * const mockFormatter: IContentFormatter = { + * formatToolResult: vi.fn(), + * // ... + * } + * ``` + */ +export class ContentFormatter implements IContentFormatter { + private readonly config: FormatConfig + + constructor(config?: Partial) { + this.config = { ...DEFAULT_FORMAT_CONFIG, ...config } + } + + formatToolResult(kind: string, content: string): string { + return formatToolResult(kind, content, this.config) + } + + formatSearchResults(content: string): string { + return formatSearchResults(content) + } + + formatReadResults(content: string): string { + return formatReadContent(content, this.config) + } + + wrapInCodeBlock(content: string, language?: string): string { + return wrapInCodeBlock(content, language) + } + + isUserEcho(text: string, promptText: string | null): boolean { + return isUserEcho(text, promptText) + } + + /** + * Extract content from rawInput parameters. + * Tries common field names for content. + */ + extractContentFromRawInput(rawInput: Record): string | undefined { + return extractContentFromParams(rawInput) + } + + /** + * Extract file content for readFile operations. + * Delegates to the standalone extractFileContent function. + */ + extractFileContent(rawInput: Record, workspacePath: string): string | undefined { + return extractFileContent(rawInput, workspacePath) + } +} + +// ============================================================================= +// Factory Function +// ============================================================================= + +/** + * Create a new content formatter with optional configuration. + */ +export function createContentFormatter(config?: Partial): ContentFormatter { + return new ContentFormatter(config) +} + +// ============================================================================= +// Type Exports +// ============================================================================= + +export type { FormatConfig as ContentFormatterConfig } diff --git a/apps/cli/src/acp/index.ts b/apps/cli/src/acp/index.ts index 12a6e239471..1e06292d196 100644 --- a/apps/cli/src/acp/index.ts +++ b/apps/cli/src/acp/index.ts @@ -1,2 +1,181 @@ +// Main agent exports export { type RooCodeAgentOptions, RooCodeAgent } from "./agent.js" +export { type AcpSessionOptions, AcpSession } from "./session.js" + +// Interfaces for dependency injection +export type { + IAcpLogger, + IAcpSession, + IContentFormatter, + IExtensionClient, + IExtensionHost, + IUpdateBuffer, + IDeltaTracker, + IPromptStateMachine, + ICommandStreamManager, + IToolContentStreamManager, + AcpSessionDependencies, + SendUpdateFn, + PromptStateType, + PromptCompletionResult, + StreamManagerOptions, +} from "./interfaces.js" +export { NullLogger } from "./interfaces.js" + +// Logger export { acpLog } from "./logger.js" + +// Utilities +export { DeltaTracker } from "./delta-tracker.js" +export { UpdateBuffer, type UpdateBufferOptions } from "./update-buffer.js" + +// Shared utility functions +export { + // Result type + type Result, + ok, + err, + // Formatting functions + formatSearchResults, + formatReadContent, + wrapInCodeBlock, + // Content extraction + extractContentFromParams, + // File operations + readFileContent, + readFileContentAsync, + resolveFilePath, + resolveFilePathUnsafe, + // Validation + isUserEcho, + hasValidFilePath, + // Config + type FormatConfig, + DEFAULT_FORMAT_CONFIG, +} from "./utils/index.js" + +// Tool Registry +export { + // Categories + TOOL_CATEGORIES, + type ToolCategory, + type KnownToolName, + // Detection functions + isEditTool, + isReadTool, + isSearchTool, + isListFilesTool, + isExecuteTool, + isDeleteTool, + isMoveTool, + isThinkTool, + isFetchTool, + isSwitchModeTool, + isFileWriteTool, + // Kind mapping + mapToolToKind, + // Validation schemas + FilePathParamsSchema, + FileWriteParamsSchema, + FileMoveParamsSchema, + SearchParamsSchema, + ListFilesParamsSchema, + CommandParamsSchema, + ThinkParamsSchema, + SwitchModeParamsSchema, + GenericToolParamsSchema, + ToolMessageSchema, + // Parameter types + type FilePathParams, + type FileWriteParams, + type FileMoveParams, + type SearchParams, + type ListFilesParams, + type CommandParams, + type ThinkParams, + type SwitchModeParams, + type GenericToolParams, + type ToolParams, + type ToolMessage, + // Validation functions + type ValidationResult, + validateToolParams, + parseToolParams, + parseToolMessage, +} from "./tool-registry.js" + +// State management +export { PromptStateMachine, createPromptStateMachine, type PromptStateMachineOptions } from "./prompt-state.js" + +// Content formatting +export { + // Direct function exports (preferred for simple use) + formatToolResult, + extractFileContent, + extractFileContentAsync, + // Re-exported utilities + formatSearchResults as formatSearch, + formatReadContent as formatRead, + wrapInCodeBlock as wrapCode, + isUserEcho as checkUserEcho, + // Class-based DI + ContentFormatter, + createContentFormatter, + type ContentFormatterConfig, +} from "./content-formatter.js" + +// Tool handlers +export { + type ToolHandler, + type ToolHandlerContext, + type ToolHandleResult, + ToolHandlerRegistry, + // Individual handlers for extension + CommandToolHandler, + FileEditToolHandler, + FileReadToolHandler, + SearchToolHandler, + ListFilesToolHandler, + DefaultToolHandler, +} from "./tool-handler.js" + +// Stream managers +export { CommandStreamManager, type PendingCommand, type CommandStreamManagerOptions } from "./command-stream.js" +export { ToolContentStreamManager, type ToolContentStreamManagerOptions } from "./tool-content-stream.js" + +// Session event handler +export { + SessionEventHandler, + createSessionEventHandler, + type SessionEventHandlerDeps, + type TaskCompletedCallback, +} from "./session-event-handler.js" + +// Translation utilities +export { + // Message translation + translateToAcpUpdate, + isPermissionAsk, + isCompletionAsk, + createPermissionOptions, + // Tool parsing + parseToolFromMessage, + generateToolTitle, + extractToolContent, + buildToolCallFromMessage, + type ToolCallInfo, + // Prompt extraction + extractPromptText, + extractPromptImages, + extractPromptResources, + // Location extraction + extractLocations, + extractFilePathsFromSearchResults, + type LocationParams, + // Diff parsing + parseUnifiedDiff, + isUnifiedDiff, + type ParsedDiff, + // Backward compatibility + mapToolKind, +} from "./translator.js" diff --git a/apps/cli/src/acp/interfaces.ts b/apps/cli/src/acp/interfaces.ts new file mode 100644 index 00000000000..3bbd872d917 --- /dev/null +++ b/apps/cli/src/acp/interfaces.ts @@ -0,0 +1,378 @@ +/** + * ACP Interfaces + * + * Defines interfaces for dependency injection and testability. + * These interfaces allow for mocking in tests and swapping implementations. + */ + +import type * as acp from "@agentclientprotocol/sdk" + +// ============================================================================= +// Logger Interface +// ============================================================================= + +/** + * Interface for ACP logging. + * Allows for different logging implementations (file, console, mock for tests). + */ +export interface IAcpLogger { + /** + * Log an info message. + */ + info(component: string, message: string, data?: unknown): void + + /** + * Log a debug message. + */ + debug(component: string, message: string, data?: unknown): void + + /** + * Log a warning message. + */ + warn(component: string, message: string, data?: unknown): void + + /** + * Log an error message. + */ + error(component: string, message: string, data?: unknown): void + + /** + * Log an incoming request. + */ + request(method: string, params?: unknown): void + + /** + * Log an outgoing response. + */ + response(method: string, result?: unknown): void + + /** + * Log an outgoing notification. + */ + notification(method: string, params?: unknown): void +} + +// ============================================================================= +// Content Formatter Interface +// ============================================================================= + +/** + * Interface for content formatting operations. + */ +export interface IContentFormatter { + /** + * Format tool result content based on the tool kind. + */ + formatToolResult(kind: string, content: string): string + + /** + * Format search results into a clean summary with file list. + */ + formatSearchResults(content: string): string + + /** + * Format read results by truncating long file contents. + */ + formatReadResults(content: string): string + + /** + * Wrap content in markdown code block for better rendering. + */ + wrapInCodeBlock(content: string, language?: string): string + + /** + * Check if a text message is an echo of the user's prompt. + */ + isUserEcho(text: string, promptText: string | null): boolean +} + +// ============================================================================= +// Session Interface +// ============================================================================= + +/** + * Interface for ACP Session. + * Enables mocking for tests. + */ +export interface IAcpSession { + /** + * Process a prompt request from the ACP client. + */ + prompt(params: acp.PromptRequest): Promise + + /** + * Cancel the current prompt. + */ + cancel(): void + + /** + * Set the session mode. + */ + setMode(mode: string): void + + /** + * Dispose of the session and release resources. + */ + dispose(): Promise + + /** + * Get the session ID. + */ + getSessionId(): string +} + +// ============================================================================= +// Extension Client Interface +// ============================================================================= + +/** + * Events emitted by the extension client. + */ +export interface ExtensionClientEvents { + message: (msg: unknown) => void + messageUpdated: (msg: unknown) => void + waitingForInput: (event: unknown) => void + commandExecutionOutput: (event: unknown) => void + taskCompleted: (event: unknown) => void +} + +/** + * Interface for extension client interactions. + */ +export interface IExtensionClient { + on(event: K, handler: ExtensionClientEvents[K]): void + off(event: K, handler: ExtensionClientEvents[K]): void + respond(text: string): void + approve(): void + reject(message?: string): void +} + +// ============================================================================= +// Extension Host Interface +// ============================================================================= + +/** + * Interface for extension host interactions. + */ +export interface IExtensionHost { + /** + * Get the extension client for event handling. + */ + readonly client: IExtensionClient + + /** + * Activate the extension host. + */ + activate(): Promise + + /** + * Dispose of the extension host. + */ + dispose(): Promise + + /** + * Send a message to the extension. + */ + sendToExtension(message: unknown): void +} + +// ============================================================================= +// Update Buffer Interface +// ============================================================================= + +/** + * Interface for update buffering. + */ +export interface IUpdateBuffer { + /** + * Queue an update for sending. + */ + queueUpdate(update: acp.SessionNotification["update"]): Promise + + /** + * Flush all pending buffered content. + */ + flush(): Promise + + /** + * Reset the buffer state. + */ + reset(): void +} + +// ============================================================================= +// Delta Tracker Interface +// ============================================================================= + +/** + * Interface for delta tracking. + */ +export interface IDeltaTracker { + /** + * Get the delta (new portion) of text that hasn't been sent yet. + */ + getDelta(id: string | number, fullText: string): string + + /** + * Check if there would be a delta without updating tracking. + */ + peekDelta(id: string | number, fullText: string): string + + /** + * Reset all tracking. + */ + reset(): void + + /** + * Reset tracking for a specific ID only. + */ + resetId(id: string | number): void +} + +// ============================================================================= +// Prompt State Interface +// ============================================================================= + +/** + * Valid states for a prompt turn. + * + * - idle: No prompt is being processed, ready for new prompts + * - processing: A prompt is actively being processed + */ +export type PromptStateType = "idle" | "processing" + +/** + * Result of completing a prompt. + */ +export interface PromptCompletionResult { + stopReason: acp.StopReason +} + +/** + * Interface for prompt state management. + */ +export interface IPromptStateMachine { + /** + * Get the current state. + */ + getState(): PromptStateType + + /** + * Get the abort signal for the current prompt. + */ + getAbortSignal(): AbortSignal | null + + /** + * Get the current prompt text. + */ + getPromptText(): string | null + + /** + * Check if a prompt can be started. + */ + canStartPrompt(): boolean + + /** + * Check if currently processing a prompt. + */ + isProcessing(): boolean + + /** + * Start a new prompt. + */ + startPrompt(promptText: string): Promise + + /** + * Complete the prompt with success or failure. + */ + complete(success: boolean): acp.StopReason + + /** + * Cancel the current prompt. + */ + cancel(): void + + /** + * Reset to idle state. + */ + reset(): void +} + +// ============================================================================= +// Stream Manager Interfaces +// ============================================================================= + +/** + * Callback to send an ACP session update. + */ +export type SendUpdateFn = (update: acp.SessionNotification["update"]) => void + +/** + * Options for creating stream managers. + */ +export interface StreamManagerOptions { + /** Delta tracker for tracking already-sent content */ + deltaTracker: IDeltaTracker + /** Callback to send session updates */ + sendUpdate: SendUpdateFn + /** Logger instance */ + logger: IAcpLogger +} + +/** + * Interface for command output streaming. + */ +export interface ICommandStreamManager { + trackCommand(toolCallId: string, command: string, ts: number): void + handleCommandOutput(message: unknown): void + handleExecutionOutput(executionId: string, output: string): void + isCommandOutputMessage(message: unknown): boolean + reset(): void +} + +/** + * Interface for tool content streaming. + */ +export interface IToolContentStreamManager { + isToolAskMessage(message: unknown): boolean + handleToolContentStreaming(message: unknown): boolean + reset(): void +} + +// ============================================================================= +// Session Dependencies +// ============================================================================= + +/** + * Dependencies required for creating an AcpSession. + * Enables dependency injection for testing. + */ +export interface AcpSessionDependencies { + /** Logger instance */ + logger?: IAcpLogger + /** Content formatter instance */ + contentFormatter?: IContentFormatter + /** Delta tracker factory */ + createDeltaTracker?: () => IDeltaTracker + /** Update buffer factory */ + createUpdateBuffer?: (sendUpdate: (update: acp.SessionNotification["update"]) => Promise) => IUpdateBuffer + /** Prompt state machine factory */ + createPromptStateMachine?: () => IPromptStateMachine +} + +// ============================================================================= +// Null/Mock Implementations for Testing +// ============================================================================= + +/** + * No-op logger implementation for testing. + */ +export class NullLogger implements IAcpLogger { + info(_component: string, _message: string, _data?: unknown): void {} + debug(_component: string, _message: string, _data?: unknown): void {} + warn(_component: string, _message: string, _data?: unknown): void {} + error(_component: string, _message: string, _data?: unknown): void {} + request(_method: string, _params?: unknown): void {} + response(_method: string, _result?: unknown): void {} + notification(_method: string, _params?: unknown): void {} +} diff --git a/apps/cli/src/acp/logger.ts b/apps/cli/src/acp/logger.ts index 041355f60aa..22c393c195a 100644 --- a/apps/cli/src/acp/logger.ts +++ b/apps/cli/src/acp/logger.ts @@ -13,6 +13,8 @@ import * as fs from "node:fs" import * as path from "node:path" import * as os from "node:os" +import type { IAcpLogger } from "./interfaces.js" + // ============================================================================= // Configuration // ============================================================================= @@ -25,7 +27,7 @@ const MAX_LOG_SIZE = 10 * 1024 * 1024 // 10MB // Logger Class // ============================================================================= -class AcpLogger { +class AcpLogger implements IAcpLogger { private logPath: string private enabled: boolean = true private stream: fs.WriteStream | null = null diff --git a/apps/cli/src/acp/prompt-state.ts b/apps/cli/src/acp/prompt-state.ts new file mode 100644 index 00000000000..0f2b9a4c093 --- /dev/null +++ b/apps/cli/src/acp/prompt-state.ts @@ -0,0 +1,254 @@ +/** + * Prompt State Machine + * + * Manages the lifecycle state of a prompt turn in a type-safe way. + * Replaces boolean flags with explicit state transitions and guards. + * + * State transitions: + * idle -> processing (on startPrompt) + * processing -> idle (on complete/cancel) + * idle -> idle (reset) + * + * This state machine ensures: + * - Only one prompt can be active at a time + * - State transitions are valid + * - Stop reasons are correctly mapped + */ + +import type * as acp from "@agentclientprotocol/sdk" +import type { IAcpLogger } from "./interfaces.js" +import { NullLogger } from "./interfaces.js" + +// ============================================================================= +// Types +// ============================================================================= + +/** + * Valid states for a prompt turn. + * + * - idle: No prompt is being processed, ready for new prompts + * - processing: A prompt is actively being processed + */ +export type PromptStateType = "idle" | "processing" + +/** + * Result of completing a prompt. + */ +export interface PromptCompletionResult { + stopReason: acp.StopReason +} + +/** + * Events that can occur during prompt lifecycle. + */ +export type PromptEvent = + | { type: "START_PROMPT" } + | { type: "COMPLETE"; success: boolean } + | { type: "CANCEL" } + | { type: "RESET" } + +/** + * Options for creating a PromptStateMachine. + */ +export interface PromptStateMachineOptions { + /** Logger instance (optional, defaults to NullLogger) */ + logger?: IAcpLogger +} + +// ============================================================================= +// PromptStateMachine Class +// ============================================================================= + +/** + * State machine for managing prompt lifecycle. + * + * Provides explicit state transitions with validation, + * replacing ad-hoc boolean flag management. + */ +export class PromptStateMachine { + private state: PromptStateType = "idle" + private abortController: AbortController | null = null + private resolvePrompt: ((result: PromptCompletionResult) => void) | null = null + private currentPromptText: string | null = null + private readonly logger: IAcpLogger + + constructor(options: PromptStateMachineOptions = {}) { + this.logger = options.logger ?? new NullLogger() + } + + /** + * Get the current state. + */ + getState(): PromptStateType { + return this.state + } + + /** + * Get the abort signal for the current prompt. + */ + getAbortSignal(): AbortSignal | null { + return this.abortController?.signal ?? null + } + + /** + * Get the current prompt text (for echo detection). + */ + getCurrentPromptText(): string | null { + return this.currentPromptText + } + + /** + * Alias for getCurrentPromptText for compatibility. + */ + getPromptText(): string | null { + return this.currentPromptText + } + + /** + * Check if a prompt can be started. + */ + canStartPrompt(): boolean { + return this.state === "idle" + } + + /** + * Check if currently processing a prompt. + */ + isProcessing(): boolean { + return this.state === "processing" + } + + /** + * Start a new prompt. + * + * @param promptText - The user's prompt text (for echo detection) + * @returns A promise that resolves when the prompt completes + * @throws If a prompt is already in progress + */ + startPrompt(promptText: string): Promise { + if (this.state !== "idle") { + this.logger.warn("PromptStateMachine", `Cannot start prompt in state: ${this.state}`) + // Cancel existing prompt first + this.cancel() + } + + this.logger.debug("PromptStateMachine", "Transitioning: idle -> processing") + this.state = "processing" + this.abortController = new AbortController() + this.currentPromptText = promptText + + return new Promise((resolve) => { + this.resolvePrompt = resolve + + // Handle abort signal + this.abortController?.signal.addEventListener("abort", () => { + if (this.state === "processing") { + this.logger.debug("PromptStateMachine", "Abort signal received") + this.transitionToComplete("cancelled") + } + }) + }) + } + + /** + * Complete the prompt with success or failure. + * + * @param success - Whether the task completed successfully + * @returns The stop reason that was used + */ + complete(success: boolean): acp.StopReason { + const stopReason = this.mapSuccessToStopReason(success) + this.transitionToComplete(stopReason) + return stopReason + } + + /** + * Cancel the current prompt. + * + * Safe to call even if no prompt is active. + */ + cancel(): void { + if (this.state !== "processing") { + this.logger.debug("PromptStateMachine", `Cancel ignored in state: ${this.state}`) + return + } + + this.logger.debug("PromptStateMachine", "Cancelling prompt") + this.abortController?.abort() + // Note: The abort handler will call transitionToComplete + } + + /** + * Reset to idle state. + * + * Should be called when starting a new prompt to ensure clean state. + */ + reset(): void { + this.logger.debug("PromptStateMachine", `Resetting from state: ${this.state}`) + + // Clean up any pending resources + if (this.abortController) { + this.abortController.abort() + this.abortController = null + } + + this.state = "idle" + this.resolvePrompt = null + this.currentPromptText = null + } + + // =========================================================================== + // Private Methods + // =========================================================================== + + /** + * Transition to completion and resolve the promise. + */ + private transitionToComplete(stopReason: acp.StopReason): void { + if (this.state !== "processing") { + this.logger.debug("PromptStateMachine", `Already completed, ignoring transition with reason: ${stopReason}`) + return + } + + this.logger.debug("PromptStateMachine", `Transitioning: processing -> idle (reason: ${stopReason})`) + + this.state = "idle" + + // Resolve the promise + if (this.resolvePrompt) { + this.resolvePrompt({ stopReason }) + this.resolvePrompt = null + } + + // Clean up + this.abortController = null + this.currentPromptText = null + } + + /** + * Map task success to ACP stop reason. + * + * ACP defines these stop reasons: + * - end_turn: Normal completion + * - max_tokens: Token limit reached + * - max_turn_requests: Request limit reached + * - refusal: Agent refused to continue + * - cancelled: User cancelled + */ + private mapSuccessToStopReason(success: boolean): acp.StopReason { + // Use "refusal" for failed tasks as it's the closest match + // (indicates the task couldn't continue normally) + return success ? "end_turn" : "refusal" + } +} + +// ============================================================================= +// Factory Function +// ============================================================================= + +/** + * Create a new prompt state machine. + */ +export function createPromptStateMachine(options?: PromptStateMachineOptions): PromptStateMachine { + return new PromptStateMachine(options) +} diff --git a/apps/cli/src/acp/session-event-handler.ts b/apps/cli/src/acp/session-event-handler.ts new file mode 100644 index 00000000000..431a665bd3f --- /dev/null +++ b/apps/cli/src/acp/session-event-handler.ts @@ -0,0 +1,431 @@ +/** + * Session Event Handler + * + * Handles events from the ExtensionClient and translates them to ACP updates. + * Extracted from session.ts for better separation of concerns. + */ + +import type { ClineMessage, ClineAsk, ClineSay } from "@roo-code/types" + +import type { WaitingForInputEvent, TaskCompletedEvent, CommandExecutionOutputEvent } from "@/agent/events.js" + +import { translateToAcpUpdate, isPermissionAsk, isCompletionAsk } from "./translator.js" +import { isUserEcho } from "./utils/index.js" +import type { + IAcpLogger, + IExtensionClient, + IPromptStateMachine, + ICommandStreamManager, + IToolContentStreamManager, + IDeltaTracker, + SendUpdateFn, +} from "./interfaces.js" +import { ToolHandlerRegistry } from "./tool-handler.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 */ + readonly updateType: "agent_message_chunk" | "agent_thought_chunk" + /** Optional transform to apply to the text before delta tracking */ + readonly textTransform?: (text: string) => string +} + +/** + * Type for the delta stream configuration map. + * Uses Partial> for type safety. + */ +type DeltaStreamConfigMap = Partial> + +/** + * 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. + * + * Type safety is enforced by: + * - DELTA_STREAM_KEYS constrained to ClineSay values + * - DeltaStreamConfigMap type annotation + * + * To add a new streaming type: + * 1. Add the key to DELTA_STREAM_KEYS + * 2. Add the configuration below + */ +const DELTA_STREAM_CONFIG: DeltaStreamConfigMap = { + // 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: string) => `Error: ${text}`, + }, +} + +/** + * Get stream configuration for a say type. + * Returns undefined if the say type is not configured for streaming. + */ +function getStreamConfig(sayType: ClineSay): StreamConfig | undefined { + return DELTA_STREAM_CONFIG[sayType] +} + +// ============================================================================= +// Types +// ============================================================================= + +/** + * Dependencies for the SessionEventHandler. + */ +export interface SessionEventHandlerDeps { + /** Logger instance */ + logger: IAcpLogger + /** Extension client for event subscription */ + client: IExtensionClient + /** Prompt state machine */ + promptState: IPromptStateMachine + /** Delta tracker for streaming */ + deltaTracker: IDeltaTracker + /** Command stream manager */ + commandStreamManager: ICommandStreamManager + /** Tool content stream manager */ + toolContentStreamManager: IToolContentStreamManager + /** Tool handler registry */ + toolHandlerRegistry: ToolHandlerRegistry + /** Callback to send updates */ + sendUpdate: SendUpdateFn + /** Callback to approve extension actions */ + approveAction: () => void + /** Callback to respond with text */ + respondWithText: (text: string) => void + /** Callback to send message to extension */ + sendToExtension: (message: unknown) => void + /** Workspace path */ + workspacePath: string +} + +/** + * Callback for task completion. + */ +export type TaskCompletedCallback = (success: boolean) => void + +// ============================================================================= +// SessionEventHandler Class +// ============================================================================= + +/** + * Handles events from the ExtensionClient and translates them to ACP updates. + * + * Responsibilities: + * - Subscribe to extension client events + * - Handle streaming for text/reasoning messages + * - Handle tool permission requests + * - Handle task completion + */ +export class SessionEventHandler { + private readonly logger: IAcpLogger + private readonly client: IExtensionClient + private readonly promptState: IPromptStateMachine + private readonly deltaTracker: IDeltaTracker + private readonly commandStreamManager: ICommandStreamManager + private readonly toolContentStreamManager: IToolContentStreamManager + private readonly toolHandlerRegistry: ToolHandlerRegistry + private readonly sendUpdate: SendUpdateFn + private readonly approveAction: () => void + private readonly respondWithText: (text: string) => void + private readonly sendToExtension: (message: unknown) => void + private readonly workspacePath: string + + private taskCompletedCallback: TaskCompletedCallback | null = null + + /** + * Track processed permission requests to prevent duplicates. + * The extension may fire multiple waitingForInput events for the same tool call + * as the message is updated. We deduplicate by generating a stable key from + * the ask type and relevant content. + */ + private processedPermissions: Set = new Set() + + constructor(deps: SessionEventHandlerDeps) { + this.logger = deps.logger + this.client = deps.client + this.promptState = deps.promptState + this.deltaTracker = deps.deltaTracker + this.commandStreamManager = deps.commandStreamManager + this.toolContentStreamManager = deps.toolContentStreamManager + this.toolHandlerRegistry = deps.toolHandlerRegistry + this.sendUpdate = deps.sendUpdate + this.approveAction = deps.approveAction + this.respondWithText = deps.respondWithText + this.sendToExtension = deps.sendToExtension + this.workspacePath = deps.workspacePath + } + + // =========================================================================== + // Public API + // =========================================================================== + + /** + * Set up event handlers to translate ExtensionClient events to ACP updates. + */ + setupEventHandlers(): void { + // Handle new messages + this.client.on("message", (msg: unknown) => { + this.handleMessage(msg as ClineMessage) + }) + + // Handle message updates (partial -> complete) + this.client.on("messageUpdated", (msg: unknown) => { + this.handleMessage(msg as ClineMessage) + }) + + // Handle permission requests (tool calls, commands, etc.) + this.client.on("waitingForInput", (event: unknown) => { + void this.handleWaitingForInput(event as WaitingForInputEvent) + }) + + // Handle streaming command execution output (live terminal output) + this.client.on("commandExecutionOutput", (event: unknown) => { + const cmdEvent = event as CommandExecutionOutputEvent + this.commandStreamManager.handleExecutionOutput(cmdEvent.executionId, cmdEvent.output) + }) + + // Handle task completion + this.client.on("taskCompleted", (event: unknown) => { + this.handleTaskCompleted(event as TaskCompletedEvent) + }) + } + + /** + * Set the callback for task completion. + */ + onTaskCompleted(callback: TaskCompletedCallback): void { + this.taskCompletedCallback = callback + } + + /** + * Reset state for a new prompt. + */ + reset(): void { + this.deltaTracker.reset() + this.commandStreamManager.reset() + this.toolContentStreamManager.reset() + this.processedPermissions.clear() + } + + // =========================================================================== + // Message Handling + // =========================================================================== + + /** + * 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 { + this.logger.debug( + "SessionEventHandler", + `Message received: type=${message.type}, say=${message.say}, ask=${message.ask}, ts=${message.ts}, partial=${message.partial}`, + ) + + // Handle streaming for tool ask messages (file creates/edits) + // These contain content that grows as the LLM generates it + if (this.toolContentStreamManager.isToolAskMessage(message)) { + this.toolContentStreamManager.handleToolContentStreaming(message) + return + } + + // 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 (this.commandStreamManager.isCommandOutputMessage(message)) { + this.commandStreamManager.handleCommandOutput(message) + return + } + + const config = getStreamConfig(message.say) + + if (config) { + // Filter out user message echo + if (message.say === "text" && isUserEcho(message.text, this.promptState.getPromptText())) { + this.logger.debug("SessionEventHandler", `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) { + 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) { + this.logger.notification("sessionUpdate", { + updateKind: (update as { sessionUpdate?: string }).sessionUpdate, + }) + this.sendUpdate(update) + } + } + + // =========================================================================== + // Permission Handling + // =========================================================================== + + /** + * Handle waiting for input events (permission requests). + */ + private async handleWaitingForInput(event: WaitingForInputEvent): Promise { + const { ask, message } = event + const askType = ask as ClineAsk + this.logger.debug("SessionEventHandler", `Waiting for input: ask=${askType}`) + + // Handle permission-required asks + if (isPermissionAsk(askType)) { + this.logger.info("SessionEventHandler", `Permission request: ${askType}`) + this.handlePermissionRequest(message, askType) + return + } + + // Handle completion asks + if (isCompletionAsk(askType)) { + this.logger.debug("SessionEventHandler", "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") { + this.logger.debug("SessionEventHandler", "Auto-responding to followup") + this.respondWithText("") + return + } + + // Handle resume_task - auto-resume + if (askType === "resume_task") { + this.logger.debug("SessionEventHandler", "Auto-approving resume_task") + this.approveAction() + return + } + + // Handle API failures - auto-retry for now + if (askType === "api_req_failed") { + this.logger.warn("SessionEventHandler", "API request failed, auto-retrying") + this.approveAction() + return + } + + // Default: approve and continue + this.logger.debug("SessionEventHandler", `Auto-approving unknown ask type: ${askType}`) + this.approveAction() + } + + /** + * Handle a permission request for a tool call. + * + * Uses the ToolHandlerRegistry for polymorphic dispatch to the appropriate + * handler based on tool type. Auto-approves all tool calls without prompting + * the user, allowing autonomous operation. + * + * For commands, tracks the call to enable the "Run Command" UI with output. + * For other tools (search, read, etc.), both initial and completion updates + * are sent immediately as the results are already available. + */ + private handlePermissionRequest(message: ClineMessage, ask: ClineAsk): void { + // Generate a stable key for deduplication based on ask type and content + // The extension may fire multiple waitingForInput events for the same tool + // as the message is updated. We use the message text as a stable identifier. + const permissionKey = `${ask}:${message.text || ""}` + + // Check if we've already processed this permission request + if (this.processedPermissions.has(permissionKey)) { + this.logger.debug("SessionEventHandler", `Skipping duplicate permission request: ${ask}`) + // Still need to approve the action to unblock the extension + this.approveAction() + return + } + + // Mark this permission as processed + this.processedPermissions.add(permissionKey) + + // Create context for the tool handler + const context = ToolHandlerRegistry.createContext(message, ask, this.workspacePath, this.logger) + + // Dispatch to the appropriate handler via the registry + const result = this.toolHandlerRegistry.handle(context) + + this.logger.debug("SessionEventHandler", `Auto-approving tool: ask=${ask}`) + this.logger.debug("SessionEventHandler", `Sending tool_call update`) + + // Send the initial in_progress update + this.sendUpdate(result.initialUpdate) + + // Track pending commands for the "Run Command" UI + if (result.trackAsPendingCommand) { + const { toolCallId, command, ts } = result.trackAsPendingCommand + this.commandStreamManager.trackCommand(toolCallId, command, ts) + } + + // Send completion update if available (non-command tools) + if (result.completionUpdate) { + this.logger.debug("SessionEventHandler", `Sending tool_call_update (completed)`) + this.sendUpdate(result.completionUpdate) + } + + // Auto-approve the tool call + this.approveAction() + } + + // =========================================================================== + // Task Completion + // =========================================================================== + + /** + * Handle task completion. + */ + private handleTaskCompleted(event: TaskCompletedEvent): void { + this.logger.info("SessionEventHandler", `Task completed: success=${event.success}`) + + if (this.taskCompletedCallback) { + this.taskCompletedCallback(event.success) + } + } +} + +// ============================================================================= +// Factory Function +// ============================================================================= + +/** + * Create a new SessionEventHandler instance. + */ +export function createSessionEventHandler(deps: SessionEventHandlerDeps): SessionEventHandler { + return new SessionEventHandler(deps) +} diff --git a/apps/cli/src/acp/session.ts b/apps/cli/src/acp/session.ts index ea57dcc89b3..f10e7588728 100644 --- a/apps/cli/src/acp/session.ts +++ b/apps/cli/src/acp/session.ts @@ -3,72 +3,36 @@ * * 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 SessionNotification, + type ClientCapabilities, + type PromptRequest, + type PromptResponse, + AgentSideConnection, +} from "@agentclientprotocol/sdk" import { type ExtensionHostOptions, ExtensionHost } from "@/agent/extension-host.js" -import type { WaitingForInputEvent, TaskCompletedEvent, CommandExecutionOutputEvent } from "@/agent/events.js" -import { - translateToAcpUpdate, - isPermissionAsk, - isCompletionAsk, - extractPromptText, - extractPromptImages, - buildToolCallFromMessage, -} from "./translator.js" +import { extractPromptText, extractPromptImages } 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> = { - // 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}`, - }, -} +import { PromptStateMachine } from "./prompt-state.js" +import { ToolHandlerRegistry } from "./tool-handler.js" +import { CommandStreamManager } from "./command-stream.js" +import { ToolContentStreamManager } from "./tool-content-stream.js" +import { SessionEventHandler, createSessionEventHandler } from "./session-event-handler.js" +import type { + IAcpSession, + IAcpLogger, + IDeltaTracker, + IUpdateBuffer, + IPromptStateMachine, + AcpSessionDependencies, +} from "./interfaces.js" +import { type Result, ok, err } from "./utils/index.js" // ============================================================================= // Types @@ -87,10 +51,6 @@ export interface AcpSessionOptions { mode: string } -// ============================================================================= -// AcpSession Class -// ============================================================================= - /** * AcpSession wraps an ExtensionHost instance and bridges it to the ACP protocol. * @@ -98,36 +58,30 @@ export interface AcpSessionOptions { * 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 +export class AcpSession implements IAcpSession { + /** Logger instance (injected) */ + private readonly logger: IAcpLogger + + /** State machine for prompt lifecycle management */ + private readonly promptState: IPromptStateMachine /** Delta tracker for streaming content - ensures only new text is sent */ - private readonly deltaTracker = new DeltaTracker() + private readonly deltaTracker: IDeltaTracker /** Update buffer for batching session updates to reduce message frequency */ - private readonly updateBuffer: UpdateBuffer + private readonly updateBuffer: IUpdateBuffer - /** - * 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 + /** Tool handler registry for polymorphic tool dispatch */ + private readonly toolHandlerRegistry: ToolHandlerRegistry - /** - * 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 = new Map() + /** Command stream manager for handling command output */ + private readonly commandStreamManager: CommandStreamManager - /** - * Track which command executions have sent the opening code fence. - * Used to wrap command output in markdown code blocks. - */ - private commandCodeFencesSent: Set = new Set() + /** Tool content stream manager for handling file creates/edits */ + private readonly toolContentStreamManager: ToolContentStreamManager + + /** Session event handler for managing extension events */ + private readonly eventHandler: SessionEventHandler /** Workspace path for resolving relative file paths */ private readonly workspacePath: string @@ -135,13 +89,66 @@ export class AcpSession { private constructor( private readonly sessionId: string, private readonly extensionHost: ExtensionHost, - private readonly connection: acp.AgentSideConnection, + private readonly connection: AgentSideConnection, workspacePath: string, + deps: AcpSessionDependencies = {}, ) { 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)) + + // Initialize dependencies with defaults or injected instances. + this.logger = deps.logger ?? acpLog + this.promptState = deps.createPromptStateMachine?.() ?? new PromptStateMachine({ logger: this.logger }) + this.deltaTracker = deps.createDeltaTracker?.() ?? new DeltaTracker() + + // Initialize update buffer with the actual send function. + // Uses defaults: 200 chars min buffer, 500ms delay. + // Wrap sendUpdateDirect to match the expected Promise signature. + const sendDirectAdapter = async (update: SessionNotification["update"]): Promise => { + await this.sendUpdateDirect(update) + // Result is logged internally; adapter converts to void for interface compatibility. + } + + this.updateBuffer = + deps.createUpdateBuffer?.(sendDirectAdapter) ?? new UpdateBuffer(sendDirectAdapter, { logger: this.logger }) + + // Initialize tool handler registry. + this.toolHandlerRegistry = new ToolHandlerRegistry() + + // Create send update callback for stream managers. + const sendUpdate = (update: SessionNotification["update"]) => { + void this.sendUpdate(update) + } + + // Initialize stream managers with injected logger. + this.commandStreamManager = new CommandStreamManager({ + deltaTracker: this.deltaTracker, + sendUpdate, + logger: this.logger, + }) + + this.toolContentStreamManager = new ToolContentStreamManager({ + deltaTracker: this.deltaTracker, + sendUpdate, + logger: this.logger, + }) + + this.eventHandler = createSessionEventHandler({ + logger: this.logger, + client: extensionHost.client, + promptState: this.promptState, + deltaTracker: this.deltaTracker, + commandStreamManager: this.commandStreamManager, + toolContentStreamManager: this.toolContentStreamManager, + toolHandlerRegistry: this.toolHandlerRegistry, + sendUpdate, + approveAction: () => this.extensionHost.client.approve(), + respondWithText: (text: string) => this.extensionHost.client.respond(text), + sendToExtension: (message) => + this.extensionHost.sendToExtension(message as Parameters[0]), + workspacePath, + }) + + this.eventHandler.onTaskCompleted((success) => this.handleTaskCompleted(success)) } // =========================================================================== @@ -153,17 +160,26 @@ export class AcpSession { * * This initializes an ExtensionHost for the given working directory * and sets up event handlers to stream updates to the ACP client. + * + * @param sessionId - Unique session identifier + * @param cwd - Working directory for the session + * @param connection - ACP connection for sending updates + * @param _clientCapabilities - Client capabilities (currently unused) + * @param options - Session configuration options + * @param deps - Optional dependencies for testing */ static async create( sessionId: string, cwd: string, - connection: acp.AgentSideConnection, - _clientCapabilities: acp.ClientCapabilities | undefined, + connection: AgentSideConnection, + _clientCapabilities: ClientCapabilities | undefined, options: AcpSessionOptions, + deps: AcpSessionDependencies = {}, ): Promise { - acpLog.info("Session", `Creating session ${sessionId} in ${cwd}`) + const logger = deps.logger ?? acpLog + logger.info("Session", `Creating session ${sessionId} in ${cwd}`) - // Create ExtensionHost with ACP-specific configuration + // Create ExtensionHost with ACP-specific configuration. const hostOptions: ExtensionHostOptions = { mode: options.mode, user: null, @@ -178,12 +194,12 @@ export class AcpSession { ephemeral: true, } - acpLog.debug("Session", "Creating ExtensionHost", hostOptions) + logger.debug("Session", "Creating ExtensionHost", hostOptions) const extensionHost = new ExtensionHost(hostOptions) await extensionHost.activate() - acpLog.info("Session", `ExtensionHost activated for session ${sessionId}`) + logger.info("Session", `ExtensionHost activated for session ${sessionId}`) - const session = new AcpSession(sessionId, extensionHost, connection, cwd) + const session = new AcpSession(sessionId, extensionHost, connection, cwd, deps) session.setupEventHandlers() return session @@ -197,535 +213,26 @@ export class AcpSession { * 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 streaming command execution output (live terminal output) - client.on("commandExecutionOutput", (event: CommandExecutionOutputEvent) => { - this.handleCommandExecutionOutput(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", `Queueing ${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 completion status in Zed. - * - * NOTE: Streaming output is handled by handleCommandExecutionOutput(). - * This method only handles the final tool_call_update for completion. - */ - 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}`) - - // Skip partial updates - streaming is handled by handleCommandExecutionOutput() - if (isPartial) { - return - } - - // Send closing code fence if any was opened - if (this.commandCodeFencesSent.size > 0) { - acpLog.info("Session", "Sending closing code fence") - void this.sendUpdate({ - sessionUpdate: "agent_message_chunk", - content: { type: "text", text: "\n```" }, - }) - this.commandCodeFencesSent.clear() - } - - // Handle completion - update the tool call UI - const pendingCall = this.findMostRecentPendingCommand() - - if (pendingCall) { - acpLog.info("Session", `Command completed: ${pendingCall.toolCallId}`) - - // 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) - } + this.eventHandler.setupEventHandlers() } /** - * Handle streaming command execution output (live terminal output). - * This provides real-time output during command execution. - * Output is wrapped in markdown code blocks for proper rendering. - */ - private handleCommandExecutionOutput(event: CommandExecutionOutputEvent): void { - const { executionId, output } = event - - acpLog.info("Session", `commandExecutionOutput: executionId=${executionId}, output length=${output.length}`) - - // Stream output as agent message for visibility in chat - // Use executionId as the message key for delta tracking - const delta = this.deltaTracker.getDelta(executionId, output) - if (delta) { - // Send opening code fence on first output for this execution - const isFirstChunk = !this.commandCodeFencesSent.has(executionId) - if (isFirstChunk) { - this.commandCodeFencesSent.add(executionId) - acpLog.info("Session", `Sending opening code fence for execution ${executionId}`) - void this.sendUpdate({ - sessionUpdate: "agent_message_chunk", - content: { type: "text", text: "```\n" }, - }) - } - - acpLog.info("Session", `Streaming command execution output: ${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", `Updating tool call ${pendingCall.toolCallId} with streaming output`) - void this.sendUpdate({ - sessionUpdate: "tool_call_update", - toolCallId: pendingCall.toolCallId, - status: "in_progress", - content: [ - { - type: "content", - content: { type: "text", text: output }, - }, - ], - }) - } - } - - /** - * 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. + * Reset state for a new prompt. */ private resetForNewPrompt(): void { - this.deltaTracker.reset() + this.eventHandler.reset() this.updateBuffer.reset() } - /** - * Handle waiting for input events (permission requests). - */ - private async handleWaitingForInput(event: WaitingForInputEvent): Promise { - 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 - - // 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 | 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 | 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() - 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 + private handleTaskCompleted(success: boolean): void { + // 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 + // Complete the prompt using the state machine. + const stopReason = this.promptState.complete(success) + this.logger.debug("Session", `Resolving prompt with stopReason: ${stopReason}`) }) } @@ -736,70 +243,44 @@ export class AcpSession { /** * Process a prompt request from the ACP client. */ - async prompt(params: acp.PromptRequest): Promise { - acpLog.info("Session", `Processing prompt for session ${this.sessionId}`) + async prompt(params: PromptRequest): Promise { + this.logger.info("Session", `Processing prompt for session ${this.sessionId}`) - // Cancel any pending prompt + // Cancel any pending prompt. this.cancel() - // Reset delta tracking and buffer for new prompt + // Reset state for new prompt. this.resetForNewPrompt() - this.pendingPrompt = new AbortController() - this.isProcessingPrompt = true - - // Extract text and images from prompt + // 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 + this.logger.debug("Session", `Prompt text (${text.length} chars), images: ${images.length}`) - acpLog.debug("Session", `Prompt text (${text.length} chars), images: ${images.length}`) + // Start the prompt using the state machine. + const promise = this.promptState.startPrompt(text) - // Start the task if (images.length > 0) { - acpLog.debug("Session", "Starting task with images") - this.extensionHost.sendToExtension({ - type: "newTask", - text, - images, - }) + this.logger.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, - }) + this.logger.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 - }) - }) + return promise } /** * 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") + if (this.promptState.isProcessing()) { + this.logger.info("Session", "Cancelling pending prompt") + this.promptState.cancel() + this.logger.info("Session", "Sending cancelTask to extension") this.extensionHost.sendToExtension({ type: "cancelTask" }) - this.isProcessingPrompt = false } } @@ -807,23 +288,21 @@ export class AcpSession { * Set the session mode. */ setMode(mode: string): void { - acpLog.info("Session", `Setting mode to: ${mode}`) - this.extensionHost.sendToExtension({ - type: "updateSettings", - updatedSettings: { mode }, - }) + this.logger.info("Session", `Setting mode to: ${mode}`) + this.extensionHost.sendToExtension({ type: "updateSettings", updatedSettings: { mode } }) } /** * Dispose of the session and release resources. */ async dispose(): Promise { - acpLog.info("Session", `Disposing session ${this.sessionId}`) + this.logger.info("Session", `Disposing session ${this.sessionId}`) this.cancel() - // Flush any remaining buffered updates + + // Flush any remaining buffered updates. await this.updateBuffer.flush() await this.extensionHost.dispose() - acpLog.info("Session", `Session ${this.sessionId} disposed`) + this.logger.info("Session", `Session ${this.sessionId} disposed`) } // =========================================================================== @@ -833,23 +312,36 @@ export class AcpSession { /** * Send an update to the ACP client through the buffer. * Text chunks are batched, other updates are sent immediately. + * + * @returns Result indicating success or failure. */ - private async sendUpdate(update: acp.SessionNotification["update"]): Promise { - await this.updateBuffer.queueUpdate(update) + private async sendUpdate(update: SessionNotification["update"]): Promise> { + try { + await this.updateBuffer.queueUpdate(update) + return ok(undefined) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + this.logger.error("Session", `Failed to queue update: ${errorMessage}`) + return err(`Failed to queue update: ${errorMessage}`) + } } /** * Send an update directly to the ACP client (bypasses buffer). * Used by the UpdateBuffer to actually send batched updates. + * + * @returns Result indicating success or failure with error details. */ - private async sendUpdateDirect(update: acp.SessionNotification["update"]): Promise { + private async sendUpdateDirect(update: SessionNotification["update"]): Promise> { try { - await this.connection.sessionUpdate({ - sessionId: this.sessionId, - update, - }) + // Log the full update being sent to ACP connection + this.logger.debug("Session", `ACP OUT: ${JSON.stringify({ sessionId: this.sessionId, update })}`) + await this.connection.sessionUpdate({ sessionId: this.sessionId, update }) + return ok(undefined) } catch (error) { - console.error("[AcpSession] Failed to send update:", error) + const errorMessage = error instanceof Error ? error.message : String(error) + this.logger.error("Session", `Failed to send update: ${errorMessage}`, error) + return err(`Failed to send update to ACP client: ${errorMessage}`) } } @@ -859,41 +351,4 @@ export class AcpSession { 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 - } } diff --git a/apps/cli/src/acp/tool-content-stream.ts b/apps/cli/src/acp/tool-content-stream.ts new file mode 100644 index 00000000000..b07df91a5b3 --- /dev/null +++ b/apps/cli/src/acp/tool-content-stream.ts @@ -0,0 +1,217 @@ +/** + * ToolContentStreamManager + * + * Manages streaming of tool content (file creates/edits) with headers and code fences. + * Provides live feedback as files are being written by the LLM. + * + * Extracted from session.ts to separate the tool content streaming concern. + */ + +import type { ClineMessage } from "@roo-code/types" + +import type { IDeltaTracker, IAcpLogger, SendUpdateFn } from "./interfaces.js" +import { isFileWriteTool } from "./tool-registry.js" +import { hasValidFilePath } from "./utils/index.js" + +// ============================================================================= +// Types +// ============================================================================= + +/** + * Options for creating a ToolContentStreamManager. + */ +export interface ToolContentStreamManagerOptions { + /** Delta tracker for tracking already-sent content */ + deltaTracker: IDeltaTracker + /** Callback to send session updates */ + sendUpdate: SendUpdateFn + /** Logger instance */ + logger: IAcpLogger +} + +// ============================================================================= +// ToolContentStreamManager Class +// ============================================================================= + +/** + * Manages streaming of tool content for file creates/edits. + * + * Responsibilities: + * - Track which tools have sent their header + * - Stream file content as it's being generated + * - Wrap content in proper markdown code blocks + * - Clean up tracking state + */ +export class ToolContentStreamManager { + /** + * Track which tool content streams have sent their header. + * Used to show file path before streaming content. + */ + private toolContentHeadersSent: Set = new Set() + + private readonly deltaTracker: IDeltaTracker + private readonly sendUpdate: SendUpdateFn + private readonly logger: IAcpLogger + + constructor(options: ToolContentStreamManagerOptions) { + this.deltaTracker = options.deltaTracker + this.sendUpdate = options.sendUpdate + this.logger = options.logger + } + + // =========================================================================== + // Public API + // =========================================================================== + + /** + * Check if a message is a tool ask message that this manager handles. + */ + isToolAskMessage(message: ClineMessage): boolean { + return message.type === "ask" && message.ask === "tool" + } + + /** + * Handle streaming content for tool ask messages (file creates/edits). + * + * This streams the content field from tool JSON as agent_message_chunk updates, + * providing live feedback as files are being written. + * + * @returns true if the message was handled, false if it should fall through + */ + handleToolContentStreaming(message: ClineMessage): boolean { + const isPartial = message.partial === true + const ts = message.ts + const text = message.text || "" + + // Parse tool info to get the tool name, path, and content + const parsed = this.parseToolMessage(text) + + // If we couldn't parse yet (early streaming), skip until we can identify the tool + if (!parsed) { + return true // Handled (by skipping) + } + + const { toolName, toolPath, content } = parsed + + // Only stream content for file write operations (uses tool registry) + if (!isFileWriteTool(toolName)) { + this.logger.debug("ToolContentStream", `Skipping content streaming for non-file tool: ${toolName}`) + return true // Handled (by skipping) + } + + this.logger.debug( + "ToolContentStream", + `handleToolContentStreaming: tool=${toolName}, path=${toolPath}, partial=${isPartial}, contentLen=${content.length}`, + ) + + // Check if we have valid path and content to start streaming + // Path must have a file extension to be considered valid (uses shared utility) + const validPath = hasValidFilePath(toolPath) + const hasContent = content.length > 0 + + if (isPartial) { + this.handlePartialMessage(ts, toolPath, content, validPath, hasContent) + } else { + this.handleCompleteMessage(ts, toolPath, content) + } + + return true // Handled + } + + /** + * Reset state for a new prompt. + */ + reset(): void { + this.toolContentHeadersSent.clear() + this.logger.debug("ToolContentStream", "Reset tool content stream state") + } + + /** + * Get the number of active headers (for testing/debugging). + */ + getActiveHeaderCount(): number { + return this.toolContentHeadersSent.size + } + + // =========================================================================== + // Private Methods + // =========================================================================== + + /** + * Parse a tool message to extract tool info. + * Returns null if JSON is incomplete (expected early in streaming). + */ + private parseToolMessage(text: string): { toolName: string; toolPath: string; content: string } | null { + try { + const toolInfo = JSON.parse(text || "{}") as Record + return { + toolName: (toolInfo.tool as string) || "tool", + toolPath: (toolInfo.path as string) || "", + content: (toolInfo.content as string) || "", + } + } catch { + // Early in streaming, JSON may be incomplete - this is expected + return null + } + } + + /** + * Handle a partial (streaming) tool message. + */ + private handlePartialMessage( + ts: number, + toolPath: string, + content: string, + hasValidPath: boolean, + hasContent: boolean, + ): void { + // Send header as soon as we have a valid path (even without content yet) + // This provides immediate feedback that a file is being created, reducing + // perceived latency during the gap while LLM generates file content. + if (hasValidPath && !this.toolContentHeadersSent.has(ts)) { + this.toolContentHeadersSent.add(ts) + this.logger.debug("ToolContentStream", `Sending tool content header for ${toolPath}`) + this.sendUpdate({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: `\n**Creating ${toolPath}**\n\`\`\`\n` }, + }) + } + + // Stream content deltas when content becomes available + if (hasValidPath && hasContent) { + // Use a unique key for delta tracking: "tool-content-{ts}" + const deltaKey = `tool-content-${ts}` + const delta = this.deltaTracker.getDelta(deltaKey, content) + + if (delta) { + this.logger.debug("ToolContentStream", `Streaming tool content delta: ${delta.length} chars`) + this.sendUpdate({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: delta }, + }) + } + } + } + + /** + * Handle a complete (non-partial) tool message. + */ + private handleCompleteMessage(ts: number, toolPath: string, content: string): void { + // Message complete - finish streaming and clean up + if (this.toolContentHeadersSent.has(ts)) { + // Send closing code fence + this.sendUpdate({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "\n```\n" }, + }) + this.toolContentHeadersSent.delete(ts) + } + + // Note: The actual tool_call notification will be sent via handleWaitingForInput + // when the waitingForInput event fires (which happens when partial becomes false) + this.logger.debug( + "ToolContentStream", + `Tool content streaming complete for ${toolPath}: ${content.length} chars`, + ) + } +} diff --git a/apps/cli/src/acp/tool-handler.ts b/apps/cli/src/acp/tool-handler.ts new file mode 100644 index 00000000000..c24c97a9c07 --- /dev/null +++ b/apps/cli/src/acp/tool-handler.ts @@ -0,0 +1,480 @@ +/** + * Tool Handler Abstraction + * + * Provides a polymorphic interface for handling different tool types. + * Each handler knows how to process a specific category of tool operations, + * enabling cleaner separation of concerns and easier testing. + */ + +import type * as acp from "@agentclientprotocol/sdk" +import type { ClineMessage, ClineAsk } from "@roo-code/types" + +import { parseToolFromMessage, type ToolCallInfo } from "./translator.js" +import type { IAcpLogger } from "./interfaces.js" +import { isEditTool, isReadTool, isSearchTool, isListFilesTool, mapToolToKind } from "./tool-registry.js" +import { + formatSearchResults, + formatReadContent, + wrapInCodeBlock, + readFileContent, + extractContentFromParams, + DEFAULT_FORMAT_CONFIG, +} from "./utils/index.js" + +// ============================================================================= +// Types +// ============================================================================= + +/** + * Context passed to tool handlers for processing. + */ +export interface ToolHandlerContext { + /** The original message from the extension */ + message: ClineMessage + /** The ask type if this is a permission request */ + ask: ClineAsk + /** Workspace path for resolving relative file paths */ + workspacePath: string + /** Parsed tool information from the message */ + toolInfo: ToolCallInfo | null + /** Logger instance */ + logger: IAcpLogger +} + +/** + * Result of handling a tool call. + */ +export interface ToolHandleResult { + /** Initial tool_call update to send */ + initialUpdate: acp.SessionNotification["update"] + /** Completion update to send (for non-command tools) */ + completionUpdate?: acp.SessionNotification["update"] + /** Whether to track this as a pending command */ + trackAsPendingCommand?: { + toolCallId: string + command: string + ts: number + } +} + +/** + * Interface for tool handlers. + * + * Each implementation handles a specific category of tools (commands, files, search, etc.) + * and knows how to create the appropriate ACP updates. + */ +export interface ToolHandler { + /** + * Check if this handler can process the given tool. + */ + canHandle(context: ToolHandlerContext): boolean + + /** + * Handle the tool call and return the appropriate updates. + */ + handle(context: ToolHandlerContext): ToolHandleResult +} + +// ============================================================================= +// Base Handler +// ============================================================================= + +/** + * Base class providing common functionality for tool handlers. + */ +abstract class BaseToolHandler implements ToolHandler { + abstract canHandle(context: ToolHandlerContext): boolean + abstract handle(context: ToolHandlerContext): ToolHandleResult + + /** + * Build the basic tool call structure from context. + */ + protected buildBaseToolCall(context: ToolHandlerContext, kindOverride?: acp.ToolKind): acp.ToolCall { + const { message, toolInfo } = context + + return { + toolCallId: toolInfo?.id || `tool-${message.ts}`, + title: toolInfo?.title || message.text?.slice(0, 100) || "Tool execution", + kind: kindOverride ?? (toolInfo ? mapToolToKind(toolInfo.name) : "other"), + status: "pending", + locations: toolInfo?.locations || [], + rawInput: toolInfo?.params || {}, + } + } + + /** + * Create the initial in_progress update. + */ + protected createInitialUpdate( + toolCall: acp.ToolCall, + kindOverride?: acp.ToolKind, + ): acp.SessionNotification["update"] { + return { + sessionUpdate: "tool_call", + ...toolCall, + kind: kindOverride ?? toolCall.kind, + status: "in_progress", + } + } +} + +// ============================================================================= +// Command Tool Handler +// ============================================================================= + +/** + * Handles command execution tools. + * + * Commands are special because: + * - They use "execute" kind for the "Run Command" UI + * - They track pending calls for output correlation + * - Completion comes via command_output messages, not immediately + */ +export class CommandToolHandler extends BaseToolHandler { + canHandle(context: ToolHandlerContext): boolean { + return context.ask === "command" + } + + handle(context: ToolHandlerContext): ToolHandleResult { + const { message, logger } = context + + const toolCall = this.buildBaseToolCall(context, "execute") + + logger.info("CommandToolHandler", `Handling command: ${toolCall.toolCallId}`) + + return { + initialUpdate: this.createInitialUpdate(toolCall, "execute"), + trackAsPendingCommand: { + toolCallId: toolCall.toolCallId, + command: message.text || "", + ts: message.ts, + }, + } + } +} + +// ============================================================================= +// File Edit Tool Handler +// ============================================================================= + +/** + * Handles file editing operations (write, apply_diff, create, modify). + * + * File edits include diff content in the completion update for UI display. + */ +export class FileEditToolHandler extends BaseToolHandler { + canHandle(context: ToolHandlerContext): boolean { + if (context.ask !== "tool") return false + + const toolName = context.toolInfo?.name || "" + return isEditTool(toolName) + } + + handle(context: ToolHandlerContext): ToolHandleResult { + const { toolInfo, logger } = context + + const toolCall = this.buildBaseToolCall(context, "edit") + + // Include diff content if available + if (toolInfo?.content && toolInfo.content.length > 0) { + toolCall.content = toolInfo.content + } + + logger.info("FileEditToolHandler", `Handling file edit: ${toolCall.toolCallId}`) + + const completionUpdate: acp.SessionNotification["update"] = { + sessionUpdate: "tool_call_update", + toolCallId: toolCall.toolCallId, + status: "completed", + rawOutput: toolInfo?.params || {}, + } + + // Include diff content in completion + if (toolInfo?.content && toolInfo.content.length > 0) { + completionUpdate.content = toolInfo.content + } + + return { + initialUpdate: this.createInitialUpdate(toolCall, "edit"), + completionUpdate, + } + } +} + +// ============================================================================= +// File Read Tool Handler +// ============================================================================= + +/** + * Handles file reading operations. + * + * For readFile tools, the rawInput.content contains the file PATH (not contents), + * so we need to read the actual file content. + */ +export class FileReadToolHandler extends BaseToolHandler { + canHandle(context: ToolHandlerContext): boolean { + if (context.ask !== "tool") return false + + const toolName = context.toolInfo?.name || "" + return isReadTool(toolName) + } + + handle(context: ToolHandlerContext): ToolHandleResult { + const { toolInfo, workspacePath, logger } = context + + const toolCall = this.buildBaseToolCall(context, "read") + const rawInput = (toolInfo?.params as Record) || {} + + logger.info("FileReadToolHandler", `Handling file read: ${toolCall.toolCallId}`) + + // Read actual file content using shared utility + const result = readFileContent(rawInput, workspacePath) + const fileContent = result.ok ? result.value : result.error + + // Format the content (truncate if needed, wrap in code block) + const formattedContent = fileContent + ? wrapInCodeBlock(formatReadContent(fileContent, DEFAULT_FORMAT_CONFIG)) + : undefined + + const completionUpdate: acp.SessionNotification["update"] = { + sessionUpdate: "tool_call_update", + toolCallId: toolCall.toolCallId, + status: "completed", + rawOutput: rawInput, + } + + if (formattedContent) { + completionUpdate.content = [ + { + type: "content", + content: { type: "text", text: formattedContent }, + }, + ] + } + + return { + initialUpdate: this.createInitialUpdate(toolCall, "read"), + completionUpdate, + } + } +} + +// ============================================================================= +// Search Tool Handler +// ============================================================================= + +/** + * Handles search operations (search_files, codebase_search, grep, etc.). + * + * Search results are formatted into a clean file list with summary. + */ +export class SearchToolHandler extends BaseToolHandler { + canHandle(context: ToolHandlerContext): boolean { + if (context.ask !== "tool") return false + + const toolName = context.toolInfo?.name || "" + return isSearchTool(toolName) + } + + handle(context: ToolHandlerContext): ToolHandleResult { + const { toolInfo, logger } = context + + const toolCall = this.buildBaseToolCall(context, "search") + const rawInput = (toolInfo?.params as Record) || {} + + logger.info("SearchToolHandler", `Handling search: ${toolCall.toolCallId}`) + + // Format search results using shared utility + const rawContent = rawInput.content as string | undefined + const formattedContent = rawContent ? wrapInCodeBlock(formatSearchResults(rawContent)) : undefined + + const completionUpdate: acp.SessionNotification["update"] = { + sessionUpdate: "tool_call_update", + toolCallId: toolCall.toolCallId, + status: "completed", + rawOutput: rawInput, + } + + if (formattedContent) { + completionUpdate.content = [ + { + type: "content", + content: { type: "text", text: formattedContent }, + }, + ] + } + + return { + initialUpdate: this.createInitialUpdate(toolCall, "search"), + completionUpdate, + } + } +} + +// ============================================================================= +// List Files Tool Handler +// ============================================================================= + +/** + * Handles list_files operations. + */ +export class ListFilesToolHandler extends BaseToolHandler { + canHandle(context: ToolHandlerContext): boolean { + if (context.ask !== "tool") return false + + const toolName = context.toolInfo?.name || "" + return isListFilesTool(toolName) + } + + handle(context: ToolHandlerContext): ToolHandleResult { + const { toolInfo, logger } = context + + const toolCall = this.buildBaseToolCall(context, "read") + const rawInput = (toolInfo?.params as Record) || {} + + logger.info("ListFilesToolHandler", `Handling list files: ${toolCall.toolCallId}`) + + // Extract content using shared utility + const rawContent = extractContentFromParams(rawInput) + + const completionUpdate: acp.SessionNotification["update"] = { + sessionUpdate: "tool_call_update", + toolCallId: toolCall.toolCallId, + status: "completed", + rawOutput: rawInput, + } + + if (rawContent) { + completionUpdate.content = [ + { + type: "content", + content: { type: "text", text: rawContent }, + }, + ] + } + + return { + initialUpdate: this.createInitialUpdate(toolCall, "read"), + completionUpdate, + } + } +} + +// ============================================================================= +// Default Tool Handler +// ============================================================================= + +/** + * Fallback handler for tools not matched by other handlers. + */ +export class DefaultToolHandler extends BaseToolHandler { + canHandle(_context: ToolHandlerContext): boolean { + // Default handler always matches as fallback + return true + } + + handle(context: ToolHandlerContext): ToolHandleResult { + const { toolInfo, logger } = context + + const toolCall = this.buildBaseToolCall(context) + const rawInput = (toolInfo?.params as Record) || {} + + logger.info("DefaultToolHandler", `Handling tool: ${toolCall.toolCallId}, kind: ${toolCall.kind}`) + + // Extract content using shared utility + const rawContent = extractContentFromParams(rawInput) + + const completionUpdate: acp.SessionNotification["update"] = { + sessionUpdate: "tool_call_update", + toolCallId: toolCall.toolCallId, + status: "completed", + rawOutput: rawInput, + } + + if (rawContent) { + completionUpdate.content = [ + { + type: "content", + content: { type: "text", text: rawContent }, + }, + ] + } + + return { + initialUpdate: this.createInitialUpdate(toolCall), + completionUpdate, + } + } +} + +// ============================================================================= +// Tool Handler Registry +// ============================================================================= + +/** + * Registry that manages tool handlers and dispatches to the appropriate one. + * + * Handlers are checked in order - the first one that canHandle() returns true wins. + * DefaultToolHandler should always be last as it accepts everything. + */ +export class ToolHandlerRegistry { + private readonly handlers: ToolHandler[] + + constructor(handlers?: ToolHandler[]) { + // Default handler order - more specific handlers first + this.handlers = handlers || [ + new CommandToolHandler(), + new FileEditToolHandler(), + new FileReadToolHandler(), + new SearchToolHandler(), + new ListFilesToolHandler(), + new DefaultToolHandler(), + ] + } + + /** + * Find the appropriate handler for the given context. + */ + getHandler(context: ToolHandlerContext): ToolHandler { + for (const handler of this.handlers) { + if (handler.canHandle(context)) { + return handler + } + } + + // Should never happen if DefaultToolHandler is last + throw new Error("No handler found for tool - DefaultToolHandler should always match") + } + + /** + * Handle a tool call by finding the appropriate handler and dispatching. + */ + handle(context: ToolHandlerContext): ToolHandleResult { + const handler = this.getHandler(context) + return handler.handle(context) + } + + /** + * Create a context object from message and ask. + */ + static createContext( + message: ClineMessage, + ask: ClineAsk, + workspacePath: string, + logger: IAcpLogger, + ): ToolHandlerContext { + return { + message, + ask, + workspacePath, + toolInfo: parseToolFromMessage(message, workspacePath), + logger, + } + } +} + +// ============================================================================= +// Exports +// ============================================================================= + +export { BaseToolHandler } diff --git a/apps/cli/src/acp/tool-registry.ts b/apps/cli/src/acp/tool-registry.ts new file mode 100644 index 00000000000..a510f241a2c --- /dev/null +++ b/apps/cli/src/acp/tool-registry.ts @@ -0,0 +1,530 @@ +/** + * Tool Registry + * + * Centralized registry for tool type definitions, categories, and validation schemas. + * Provides type-safe tool identification and parameter validation. + * + * Uses exact matching with normalized tool names to avoid fragile substring matching. + */ + +import { z } from "zod" +import type * as acp from "@agentclientprotocol/sdk" + +// ============================================================================= +// Tool Category Registry Class +// ============================================================================= + +/** + * Tool category names. + */ +export type ToolCategory = + | "edit" + | "read" + | "search" + | "list" + | "execute" + | "delete" + | "move" + | "think" + | "fetch" + | "switchMode" + | "fileWrite" + +/** + * Registry for tool categories with automatic Set generation. + * + * This class ensures that TOOL_CATEGORIES and lookup Sets are always in sync + * by generating Sets automatically from the category definitions. + */ +class ToolCategoryRegistry { + private readonly categories: Map> = new Map() + private readonly toolDefinitions: Record + + constructor() { + // Define tool categories with their associated tool names + // All tool names are stored in normalized form (lowercase, no separators) + this.toolDefinitions = { + /** File edit operations (create, write, modify) */ + edit: [ + "newfilecreated", + "editedexistingfile", + "writetofile", + "applydiff", + "applieddiff", + "createfile", + "modifyfile", + ], + + /** File read operations */ + read: ["readfile"], + + /** File/codebase search operations */ + search: ["searchfiles", "codebasesearch", "grep", "ripgrep"], + + /** Directory listing operations */ + list: ["listfiles", "listfilestoplevel", "listfilesrecursive"], + + /** Command/shell execution */ + execute: ["executecommand", "runcommand"], + + /** File deletion */ + delete: ["deletefile", "removefile"], + + /** File move/rename */ + move: ["movefile", "renamefile"], + + /** Reasoning/thinking operations */ + think: ["think", "reason", "plan", "analyze"], + + /** External fetch/HTTP operations */ + fetch: ["fetch", "httpget", "httppost", "urlfetch", "webrequest"], + + /** Mode switching operations */ + switchMode: ["switchmode", "setmode"], + + /** File write operations (for streaming detection) */ + fileWrite: ["newfilecreated", "writetofile", "createfile", "editedexistingfile", "applydiff", "modifyfile"], + } + + // Build Sets automatically from definitions + for (const [category, tools] of Object.entries(this.toolDefinitions)) { + this.categories.set(category as ToolCategory, new Set(tools)) + } + } + + /** + * Check if a tool name belongs to a specific category. + * Uses O(1) Set lookup. + */ + isInCategory(toolName: string, category: ToolCategory): boolean { + const normalized = this.normalizeToolName(toolName) + return this.categories.get(category)?.has(normalized) ?? false + } + + /** + * Get all tools in a category. + */ + getToolsInCategory(category: ToolCategory): readonly string[] { + return this.toolDefinitions[category] + } + + /** + * Get all category names. + */ + getCategoryNames(): ToolCategory[] { + return Object.keys(this.toolDefinitions) as ToolCategory[] + } + + /** + * Normalize a tool name for comparison. + * Converts to lowercase and removes all separators (-, _). + */ + private normalizeToolName(name: string): string { + return name.toLowerCase().replace(/[-_]/g, "") + } +} + +// ============================================================================= +// Singleton Registry Instance +// ============================================================================= + +/** + * Global tool category registry instance. + */ +const toolCategoryRegistry = new ToolCategoryRegistry() + +// ============================================================================= +// Legacy Exports for Backward Compatibility +// ============================================================================= + +/** + * Tool categories with their associated tool names. + * @deprecated Use toolCategoryRegistry methods instead + */ +export const TOOL_CATEGORIES = { + edit: toolCategoryRegistry.getToolsInCategory("edit"), + read: toolCategoryRegistry.getToolsInCategory("read"), + search: toolCategoryRegistry.getToolsInCategory("search"), + list: toolCategoryRegistry.getToolsInCategory("list"), + execute: toolCategoryRegistry.getToolsInCategory("execute"), + delete: toolCategoryRegistry.getToolsInCategory("delete"), + move: toolCategoryRegistry.getToolsInCategory("move"), + think: toolCategoryRegistry.getToolsInCategory("think"), + fetch: toolCategoryRegistry.getToolsInCategory("fetch"), + switchMode: toolCategoryRegistry.getToolsInCategory("switchMode"), + fileWrite: toolCategoryRegistry.getToolsInCategory("fileWrite"), +} as const + +// ============================================================================= +// Type Definitions +// ============================================================================= + +/** + * All known tool names (union of all categories) + */ +export type KnownToolName = (typeof TOOL_CATEGORIES)[ToolCategory][number] + +// ============================================================================= +// Tool Category Detection Functions +// ============================================================================= + +/** + * Check if a tool name belongs to a specific category using exact matching. + * Uses the centralized registry for O(1) lookup. + */ +export function isToolInCategory(toolName: string, category: ToolCategory): boolean { + return toolCategoryRegistry.isInCategory(toolName, category) +} + +/** + * Check if tool is an edit operation. + */ +export function isEditTool(toolName: string): boolean { + return isToolInCategory(toolName, "edit") +} + +/** + * Check if tool is a read operation. + */ +export function isReadTool(toolName: string): boolean { + return isToolInCategory(toolName, "read") +} + +/** + * Check if tool is a search operation. + */ +export function isSearchTool(toolName: string): boolean { + return isToolInCategory(toolName, "search") +} + +/** + * Check if tool is a list files operation. + */ +export function isListFilesTool(toolName: string): boolean { + return isToolInCategory(toolName, "list") +} + +/** + * Check if tool is a command execution operation. + */ +export function isExecuteTool(toolName: string): boolean { + return isToolInCategory(toolName, "execute") +} + +/** + * Check if tool is a delete operation. + */ +export function isDeleteTool(toolName: string): boolean { + return isToolInCategory(toolName, "delete") +} + +/** + * Check if tool is a move/rename operation. + */ +export function isMoveTool(toolName: string): boolean { + return isToolInCategory(toolName, "move") +} + +/** + * Check if tool is a think/reasoning operation. + */ +export function isThinkTool(toolName: string): boolean { + return isToolInCategory(toolName, "think") +} + +/** + * Check if tool is an external fetch operation. + */ +export function isFetchTool(toolName: string): boolean { + return isToolInCategory(toolName, "fetch") +} + +/** + * Check if tool is a mode switching operation. + */ +export function isSwitchModeTool(toolName: string): boolean { + return isToolInCategory(toolName, "switchMode") +} + +/** + * Check if tool is a file write operation (for streaming). + */ +export function isFileWriteTool(toolName: string): boolean { + return isToolInCategory(toolName, "fileWrite") +} + +// ============================================================================= +// Tool Kind Mapping +// ============================================================================= + +/** + * Map a tool name to an ACP ToolKind. + * + * 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) + * + * Uses exact category matching for reliability. Falls back to "other" for unknown tools. + */ +export function mapToolToKind(toolName: string): acp.ToolKind { + // Check exact category matches in priority order + // Order matters only for overlapping categories (like fileWrite and edit) + if (isToolInCategory(toolName, "switchMode")) { + return "switch_mode" + } + if (isToolInCategory(toolName, "think")) { + return "think" + } + if (isToolInCategory(toolName, "search")) { + return "search" + } + if (isToolInCategory(toolName, "delete")) { + return "delete" + } + if (isToolInCategory(toolName, "move")) { + return "move" + } + if (isToolInCategory(toolName, "edit")) { + return "edit" + } + if (isToolInCategory(toolName, "fetch")) { + return "fetch" + } + if (isToolInCategory(toolName, "read")) { + return "read" + } + if (isToolInCategory(toolName, "list")) { + return "read" // list operations are read-like + } + if (isToolInCategory(toolName, "execute")) { + return "execute" + } + + // Default to other for unknown tools + return "other" +} + +// ============================================================================= +// Zod Schemas for Tool Parameters +// ============================================================================= + +/** + * Base schema for all tool parameters. + */ +const BaseToolParamsSchema = z.object({ + tool: z.string(), +}) + +/** + * Schema for file path tools (read, delete, etc.) + */ +export const FilePathParamsSchema = BaseToolParamsSchema.extend({ + path: z.string(), + content: z.string().optional(), +}) + +/** + * Schema for file write/create tools. + */ +export const FileWriteParamsSchema = BaseToolParamsSchema.extend({ + path: z.string(), + content: z.string(), +}) + +/** + * Schema for file move/rename tools. + */ +export const FileMoveParamsSchema = BaseToolParamsSchema.extend({ + path: z.string(), + newPath: z.string().optional(), + destination: z.string().optional(), +}) + +/** + * Schema for search tools. + */ +export const SearchParamsSchema = BaseToolParamsSchema.extend({ + path: z.string().optional(), + regex: z.string().optional(), + query: z.string().optional(), + pattern: z.string().optional(), + filePattern: z.string().optional(), + content: z.string().optional(), +}) + +/** + * Schema for list files tools. + */ +export const ListFilesParamsSchema = BaseToolParamsSchema.extend({ + path: z.string(), + recursive: z.boolean().optional(), + content: z.string().optional(), +}) + +/** + * Schema for command execution tools. + */ +export const CommandParamsSchema = BaseToolParamsSchema.extend({ + command: z.string().optional(), + cwd: z.string().optional(), +}) + +/** + * Schema for think/reasoning tools. + */ +export const ThinkParamsSchema = BaseToolParamsSchema.extend({ + thought: z.string().optional(), + reasoning: z.string().optional(), + analysis: z.string().optional(), +}) + +/** + * Schema for mode switching tools. + */ +export const SwitchModeParamsSchema = BaseToolParamsSchema.extend({ + mode: z.string().optional(), + modeId: z.string().optional(), +}) + +/** + * Generic tool params schema (for unknown tools). + */ +export const GenericToolParamsSchema = BaseToolParamsSchema.passthrough() + +// ============================================================================= +// Parameter Types +// ============================================================================= + +export type FilePathParams = z.infer +export type FileWriteParams = z.infer +export type FileMoveParams = z.infer +export type SearchParams = z.infer +export type ListFilesParams = z.infer +export type CommandParams = z.infer +export type ThinkParams = z.infer +export type SwitchModeParams = z.infer +export type GenericToolParams = z.infer + +/** + * Union of all tool parameter types. + */ +export type ToolParams = + | FilePathParams + | FileWriteParams + | FileMoveParams + | SearchParams + | ListFilesParams + | CommandParams + | ThinkParams + | SwitchModeParams + | GenericToolParams + +// ============================================================================= +// Parameter Validation +// ============================================================================= + +/** + * Result of parameter validation. + */ +export type ValidationResult = { success: true; data: T } | { success: false; error: z.ZodError } + +/** + * Validate tool parameters against the appropriate schema. + * + * @param toolName - Name of the tool + * @param params - Raw parameters to validate + * @returns Validation result with typed params or error + */ +export function validateToolParams(toolName: string, params: unknown): ValidationResult { + // Select schema based on tool category + let schema: z.ZodSchema + + if (isEditTool(toolName)) { + schema = FileWriteParamsSchema + } else if (isReadTool(toolName)) { + schema = FilePathParamsSchema + } else if (isSearchTool(toolName)) { + schema = SearchParamsSchema + } else if (isListFilesTool(toolName)) { + schema = ListFilesParamsSchema + } else if (isExecuteTool(toolName)) { + schema = CommandParamsSchema + } else if (isDeleteTool(toolName)) { + schema = FilePathParamsSchema + } else if (isMoveTool(toolName)) { + schema = FileMoveParamsSchema + } else if (isThinkTool(toolName)) { + schema = ThinkParamsSchema + } else if (isSwitchModeTool(toolName)) { + schema = SwitchModeParamsSchema + } else { + // Use generic schema for unknown tools + schema = GenericToolParamsSchema + } + + const result = schema.safeParse(params) + + if (result.success) { + return { success: true, data: result.data as ToolParams } + } + + return { success: false, error: result.error } +} + +/** + * Parse and validate tool parameters, returning undefined on failure. + * Use when validation failure should be handled gracefully. + * + * @param toolName - Name of the tool + * @param params - Raw parameters to validate + * @returns Validated params or undefined + */ +export function parseToolParams(toolName: string, params: unknown): ToolParams | undefined { + const result = validateToolParams(toolName, params) + return result.success ? result.data : undefined +} + +// ============================================================================= +// Tool Message Parsing +// ============================================================================= + +/** + * Schema for parsing tool JSON from message text. + */ +export const ToolMessageSchema = z + .object({ + tool: z.string(), + path: z.string().optional(), + content: z.string().optional(), + }) + .passthrough() + +export type ToolMessage = z.infer + +/** + * Parse tool information from a JSON message. + * + * @param text - JSON text to parse + * @returns Parsed tool message or undefined if invalid + */ +export function parseToolMessage(text: string): ToolMessage | undefined { + if (!text.startsWith("{")) { + return undefined + } + + try { + const parsed = JSON.parse(text) + const result = ToolMessageSchema.safeParse(parsed) + return result.success ? result.data : undefined + } catch { + return undefined + } +} diff --git a/apps/cli/src/acp/translator.ts b/apps/cli/src/acp/translator.ts index 56b91cec132..aa8d9696b82 100644 --- a/apps/cli/src/acp/translator.ts +++ b/apps/cli/src/acp/translator.ts @@ -1,666 +1,43 @@ /** * 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 - 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 - 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 = { - // 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, 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() - - // 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, - 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. + * This file re-exports from the translator/ module for backward compatibility. + * The translator has been split into focused modules for better maintainability: * - * 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) - // Note: "browser" is NOT included here since browser tools are disabled in CLI - if ( - lowerName.includes("fetch") || - lowerName.includes("http") || - lowerName.includes("url") || - lowerName.includes("web_request") - ) { - 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 -} + * - translator/diff-parser.ts: Unified diff parsing + * - translator/location-extractor.ts: File location extraction + * - translator/prompt-extractor.ts: Prompt content extraction + * - translator/tool-parser.ts: Tool information parsing + * - translator/message-translator.ts: Main message translation + * + * Import from this file or directly from translator/index.ts + */ + +// Re-export everything from the translator module +export { + // Diff parsing + parseUnifiedDiff, + isUnifiedDiff, + type ParsedDiff, + // Location extraction + extractLocations, + extractFilePathsFromSearchResults, + type LocationParams, + // Prompt extraction + extractPromptText, + extractPromptImages, + extractPromptResources, + // Tool parsing + parseToolFromMessage, + generateToolTitle, + extractToolContent, + buildToolCallFromMessage, + type ToolCallInfo, + // Message translation + translateToAcpUpdate, + isPermissionAsk, + isCompletionAsk, + createPermissionOptions, + // Backward compatibility + mapToolKind, +} from "./translator/index.js" diff --git a/apps/cli/src/acp/translator/diff-parser.ts b/apps/cli/src/acp/translator/diff-parser.ts new file mode 100644 index 00000000000..ec66b22dc96 --- /dev/null +++ b/apps/cli/src/acp/translator/diff-parser.ts @@ -0,0 +1,106 @@ +/** + * Diff Parser + * + * Parses unified diff format to extract old and new text. + * Used for displaying file changes in ACP tool calls. + */ + +// ============================================================================= +// Types +// ============================================================================= + +/** + * Result of parsing a unified diff. + */ +export interface ParsedDiff { + /** Original text (null for new files) */ + oldText: string | null + /** New text content */ + newText: string +} + +// ============================================================================= +// Diff Parsing +// ============================================================================= + +/** + * Parse a unified diff string to extract old and new text. + * + * Handles standard unified diff format: + * ``` + * --- a/file.txt + * +++ b/file.txt + * @@ -1,3 +1,4 @@ + * context line + * -removed line + * +added line + * more context + * ``` + * + * For non-diff content (raw file content), returns { oldText: null, newText: content }. + * + * @param diffString - The diff string to parse + * @returns Parsed diff with old and new text, or null if invalid + */ +export function parseUnifiedDiff(diffString: string): ParsedDiff | 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 string appears to be a unified diff. + */ +export function isUnifiedDiff(content: string): boolean { + return content.includes("@@") || (content.includes("---") && content.includes("+++")) +} diff --git a/apps/cli/src/acp/translator/index.ts b/apps/cli/src/acp/translator/index.ts new file mode 100644 index 00000000000..011f83e620e --- /dev/null +++ b/apps/cli/src/acp/translator/index.ts @@ -0,0 +1,43 @@ +/** + * Translator Module + * + * Re-exports all translator functionality for backward compatibility. + * Import from this module to use the translator features. + * + * The translator is split into focused modules: + * - diff-parser: Unified diff parsing + * - location-extractor: File location extraction + * - prompt-extractor: Prompt content extraction + * - tool-parser: Tool information parsing + * - message-translator: Main message translation + */ + +// Diff parsing +export { parseUnifiedDiff, isUnifiedDiff, type ParsedDiff } from "./diff-parser.js" + +// Location extraction +export { extractLocations, extractFilePathsFromSearchResults, type LocationParams } from "./location-extractor.js" + +// Prompt extraction +export { extractPromptText, extractPromptImages, extractPromptResources } from "./prompt-extractor.js" + +// Tool parsing +export { + parseToolFromMessage, + generateToolTitle, + extractToolContent, + buildToolCallFromMessage, + type ToolCallInfo, +} from "./tool-parser.js" + +// Message translation +export { + translateToAcpUpdate, + isPermissionAsk, + isCompletionAsk, + createPermissionOptions, +} from "./message-translator.js" + +// Re-export mapToolKind for backward compatibility +// (now uses mapToolToKind from tool-registry internally) +export { mapToolToKind as mapToolKind } from "../tool-registry.js" diff --git a/apps/cli/src/acp/translator/location-extractor.ts b/apps/cli/src/acp/translator/location-extractor.ts new file mode 100644 index 00000000000..2d53c7d7cf2 --- /dev/null +++ b/apps/cli/src/acp/translator/location-extractor.ts @@ -0,0 +1,136 @@ +/** + * Location Extractor + * + * Extracts file locations from tool parameters for ACP tool calls. + * Handles various parameter formats and tool-specific behaviors. + */ + +import type * as acp from "@agentclientprotocol/sdk" + +import { isSearchTool, isListFilesTool } from "../tool-registry.js" +import { resolveFilePathUnsafe } from "../utils/index.js" + +// ============================================================================= +// Types +// ============================================================================= + +/** + * Parameters that may contain file locations. + */ +export interface LocationParams { + tool?: string + path?: string + file?: string + filePath?: string + file_path?: string + directory?: string + dir?: string + paths?: string[] + content?: string +} + +// ============================================================================= +// Location Extraction +// ============================================================================= + +/** + * Extract file locations from tool parameters. + * + * Handles different tool types: + * - Search tools: Extract file paths from search results + * - List files: Include the directory being listed + * - File operations: Extract path from standard parameters + * + * @param params - Tool parameters + * @param workspacePath - Optional workspace path to resolve relative paths + * @returns Array of tool call locations + */ +export function extractLocations(params: Record, 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) { + return extractFilePathsFromSearchResults(content, workspacePath) + } + 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 = resolveFilePathUnsafe(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 = resolveFilePathUnsafe(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 = resolveFilePathUnsafe(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 = resolveFilePathUnsafe(p, workspacePath) + locations.push({ path: absolutePath }) + } + } + } + + return locations +} + +/** + * Extract file paths from search results content. + * + * Search results typically have format: "# path/to/file.ts" for each matched file. + * + * @param content - Search results content + * @param workspacePath - Optional workspace path + * @returns Array of locations from search results + */ +export function extractFilePathsFromSearchResults(content: string, workspacePath?: string): acp.ToolCallLocation[] { + const locations: acp.ToolCallLocation[] = [] + const seenPaths = new Set() + + // 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 = resolveFilePathUnsafe(filePath, workspacePath) + locations.push({ path: absolutePath }) + } + + return locations +} diff --git a/apps/cli/src/acp/translator/message-translator.ts b/apps/cli/src/acp/translator/message-translator.ts new file mode 100644 index 00000000000..938b098e1e6 --- /dev/null +++ b/apps/cli/src/acp/translator/message-translator.ts @@ -0,0 +1,179 @@ +/** + * Message Translator + * + * Translates between internal ClineMessage format and ACP protocol format. + * This is the main bridge between Roo Code's message system and the ACP protocol. + */ + +import type * as acp from "@agentclientprotocol/sdk" +import type { ClineMessage, ClineAsk } from "@roo-code/types" + +import { mapToolToKind } from "../tool-registry.js" +import { parseToolFromMessage } from "./tool-parser.js" + +// ============================================================================= +// 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. + * + * @param message - Internal ClineMessage + * @returns ACP session update or null + */ +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. + * + * @param message - Tool-related ClineMessage + * @returns ACP session update or null + */ +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: mapToolToKind(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, + } + } +} + +// ============================================================================= +// Ask Type Helpers +// ============================================================================= + +/** + * Ask types that require permission from the user. + */ +const PERMISSION_ASKS: readonly ClineAsk[] = ["tool", "command", "browser_action_launch", "use_mcp_server"] + +/** + * Check if an ask type requires permission. + * + * @param ask - The ask type to check + * @returns true if permission is required + */ +export function isPermissionAsk(ask: ClineAsk): boolean { + return PERMISSION_ASKS.includes(ask) +} + +/** + * Ask types that indicate task completion. + */ +const COMPLETION_ASKS: readonly ClineAsk[] = ["completion_result", "api_req_failed", "mistake_limit_reached"] + +/** + * Check if an ask type indicates task completion. + * + * @param ask - The ask type to check + * @returns true if this indicates completion + */ +export function isCompletionAsk(ask: ClineAsk): boolean { + return COMPLETION_ASKS.includes(ask) +} + +// ============================================================================= +// Permission Options +// ============================================================================= + +/** + * Create standard permission options for a tool call. + * + * Returns options like "Allow", "Reject", and optionally "Always Allow" + * for certain tool types. + * + * @param ask - The ask type + * @returns Array of permission options + */ +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 +} diff --git a/apps/cli/src/acp/translator/prompt-extractor.ts b/apps/cli/src/acp/translator/prompt-extractor.ts new file mode 100644 index 00000000000..3d135869d27 --- /dev/null +++ b/apps/cli/src/acp/translator/prompt-extractor.ts @@ -0,0 +1,101 @@ +/** + * Prompt Extractor + * + * Extracts text and images from ACP prompt content blocks. + * Handles various content block types including text, resources, and media. + */ + +import type * as acp from "@agentclientprotocol/sdk" + +// ============================================================================= +// Text Extraction +// ============================================================================= + +/** + * Extract text content from ACP prompt content blocks. + * + * Handles these content block types: + * - text: Direct text content + * - resource_link: Reference to a file or resource (converted to @uri format) + * - resource: Embedded resource with text content + * - image/audio: Noted as placeholders + * + * @param prompt - Array of ACP content blocks + * @returns Combined text from all 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") +} + +// ============================================================================= +// Image Extraction +// ============================================================================= + +/** + * Extract images from ACP prompt content blocks. + * + * Extracts base64-encoded image data from image content blocks. + * + * @param prompt - Array of ACP content blocks + * @returns Array of base64-encoded image data strings + */ +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 +} + +// ============================================================================= +// Resource Extraction +// ============================================================================= + +/** + * Extract resource URIs from ACP prompt content blocks. + * + * @param prompt - Array of ACP content blocks + * @returns Array of resource URIs + */ +export function extractPromptResources(prompt: acp.ContentBlock[]): string[] { + const resources: string[] = [] + + for (const block of prompt) { + if (block.type === "resource_link") { + resources.push(block.uri) + } else if (block.type === "resource" && block.resource) { + resources.push(block.resource.uri) + } + } + + return resources +} diff --git a/apps/cli/src/acp/translator/tool-parser.ts b/apps/cli/src/acp/translator/tool-parser.ts new file mode 100644 index 00000000000..aac517bff98 --- /dev/null +++ b/apps/cli/src/acp/translator/tool-parser.ts @@ -0,0 +1,237 @@ +/** + * Tool Parser + * + * Parses tool information from ClineMessage format. + * Extracts tool name, parameters, and generates titles. + */ + +import * as path from "node:path" +import type * as acp from "@agentclientprotocol/sdk" +import type { ClineMessage } from "@roo-code/types" + +import { mapToolToKind, isEditTool as isFileEditTool } from "../tool-registry.js" +import { extractLocations } from "./location-extractor.js" +import { parseUnifiedDiff } from "./diff-parser.js" +import { resolveFilePathUnsafe } from "../utils/index.js" + +// ============================================================================= +// Types +// ============================================================================= + +/** + * Parsed tool call information. + */ +export interface ToolCallInfo { + /** Unique identifier for the tool call */ + id: string + /** Tool name */ + name: string + /** Human-readable title */ + title: string + /** Tool parameters */ + params: Record + /** File locations involved */ + locations: acp.ToolCallLocation[] + /** Tool content (diffs, etc.) */ + content?: acp.ToolCallContent[] +} + +// ============================================================================= +// Tool Call ID Generation +// ============================================================================= + +/** + * Generate a tool call ID from a ClineMessage timestamp. + * + * Uses the message timestamp directly, which provides: + * - Deterministic IDs - same message always produces same ID + * - Natural deduplication - duplicate waitingForInput events use same ID + * - Easy debugging - can correlate ACP tool calls to ClineMessages + * - Sortable by creation time + * + * @param timestamp - ClineMessage timestamp (message.ts) + * @returns Tool call ID + */ +function generateToolCallId(timestamp: number): string { + return `tool-${timestamp}` +} + +// ============================================================================= +// Tool Parsing +// ============================================================================= + +/** + * Parse tool information from a ClineMessage. + * + * Handles two formats: + * 1. JSON format: Message text is JSON with tool name and parameters + * 2. Text format: Tool name extracted from text like "Using/Executing/Running X" + * + * @param message - The ClineMessage to parse + * @param workspacePath - Optional workspace path to resolve relative paths + * @returns Parsed tool info or null if parsing fails + */ +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 + const toolName = (parsed.tool as string) || "unknown" + const filePath = (parsed.path as string) || undefined + + return { + id: generateToolCallId(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: generateToolCallId(message.ts), + name: toolName, + title: message.text.slice(0, 100), + params: {}, + locations: [], + } +} + +// ============================================================================= +// Tool Title Generation +// ============================================================================= + +/** + * Generate a human-readable title for a tool operation. + * + * Maps tool names to descriptive titles, optionally including file names. + * + * @param toolName - The tool name + * @param filePath - Optional file path for context + * @returns Human-readable title + */ +export function generateToolTitle(toolName: string, filePath?: string): string { + const fileName = filePath ? path.basename(filePath) : undefined + + // Map tool names to human-readable titles + const toolTitles: Record = { + // 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) +} + +// ============================================================================= +// Tool Content Extraction +// ============================================================================= + +/** + * Extract tool content for ACP (diffs, text, etc.) + * + * For file edit tools, parses the content as a unified diff. + * + * @param params - Tool parameters + * @param workspacePath - Optional workspace path + * @returns Array of tool content or undefined + */ +export function extractToolContent( + params: Record, + 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 = resolveFilePathUnsafe(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 +} + +// ============================================================================= +// 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 + * @returns ACP ToolCall object + */ +export function buildToolCallFromMessage(message: ClineMessage, workspacePath?: string): acp.ToolCall { + const toolInfo = parseToolFromMessage(message, workspacePath) + + const toolCall: acp.ToolCall = { + toolCallId: toolInfo?.id || generateToolCallId(message.ts), + title: toolInfo?.title || message.text?.slice(0, 100) || "Tool execution", + kind: toolInfo ? mapToolToKind(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 +} diff --git a/apps/cli/src/acp/update-buffer.ts b/apps/cli/src/acp/update-buffer.ts index dc96c7affd3..362ccb3e80b 100644 --- a/apps/cli/src/acp/update-buffer.ts +++ b/apps/cli/src/acp/update-buffer.ts @@ -7,7 +7,8 @@ */ import type * as acp from "@agentclientprotocol/sdk" -import { acpLog } from "./logger.js" +import type { IAcpLogger } from "./interfaces.js" +import { NullLogger } from "./interfaces.js" // ============================================================================= // Types (exported) @@ -20,6 +21,8 @@ interface UpdateBufferOptions { minBufferSize?: number /** Maximum time in ms before flushing (default: 500) */ flushDelayMs?: number + /** Logger instance (optional, defaults to NullLogger) */ + logger?: IAcpLogger } type TextChunkUpdate = { @@ -56,6 +59,7 @@ function isTextChunkUpdate(update: SessionUpdate): update is TextChunkUpdate { export class UpdateBuffer { private readonly minBufferSize: number private readonly flushDelayMs: number + private readonly logger: IAcpLogger /** Buffered text for agent_message_chunk */ private messageBuffer = "" @@ -71,6 +75,7 @@ export class UpdateBuffer { constructor(sendUpdate: (update: SessionUpdate) => Promise, options: UpdateBufferOptions = {}) { this.minBufferSize = options.minBufferSize ?? 200 this.flushDelayMs = options.flushDelayMs ?? 500 + this.logger = options.logger ?? new NullLogger() this.sendUpdate = sendUpdate } @@ -106,7 +111,7 @@ export class UpdateBuffer { return } - acpLog.debug( + this.logger.debug( "UpdateBuffer", `Flushing buffers: message=${this.messageBuffer.length}, thought=${this.thoughtBuffer.length}`, ) @@ -142,7 +147,7 @@ export class UpdateBuffer { this.messageBuffer = "" this.thoughtBuffer = "" this.hasPendingContent = false - acpLog.debug("UpdateBuffer", "Buffer reset") + this.logger.debug("UpdateBuffer", "Buffer reset") } /** @@ -176,7 +181,10 @@ export class UpdateBuffer { // 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`) + this.logger.debug( + "UpdateBuffer", + `Size threshold reached (${totalSize} >= ${this.minBufferSize}), flushing`, + ) void this.flush() return } @@ -195,7 +203,7 @@ export class UpdateBuffer { this.flushTimer = setTimeout(() => { this.flushTimer = null - acpLog.debug("UpdateBuffer", "Flush timer expired") + this.logger.debug("UpdateBuffer", "Flush timer expired") void this.flush() }, this.flushDelayMs) } diff --git a/apps/cli/src/acp/utils/format-utils.ts b/apps/cli/src/acp/utils/format-utils.ts new file mode 100644 index 00000000000..73632bdfb3f --- /dev/null +++ b/apps/cli/src/acp/utils/format-utils.ts @@ -0,0 +1,379 @@ +/** + * Format Utilities + * + * Shared formatting and content extraction utilities for ACP. + * Extracted to eliminate code duplication across modules. + */ + +import * as fs from "node:fs" +import * as fsPromises from "node:fs/promises" +import * as path from "node:path" + +// ============================================================================= +// Configuration +// ============================================================================= + +/** + * Default configuration for content formatting. + */ +export interface FormatConfig { + /** Maximum number of lines to show for read results */ + maxReadLines: number +} + +export const DEFAULT_FORMAT_CONFIG: FormatConfig = { + maxReadLines: 100, +} + +// ============================================================================= +// Result Type for Error Handling +// ============================================================================= + +/** + * Result type for operations that can fail. + * Provides explicit success/failure indication instead of returning error strings. + */ +export type Result = { ok: true; value: T } | { ok: false; error: string } + +/** + * Create a successful result. + */ +export function ok(value: T): Result { + return { ok: true, value } +} + +/** + * Create a failed result. + */ +export function err(error: string): Result { + return { ok: false, error } +} + +// ============================================================================= +// Search Result Formatting +// ============================================================================= + +/** + * Format search results into a clean summary with file list. + * + * Input format (verbose): + * ``` + * Found 112 results. + * + * # src/acp/__tests__/agent.test.ts + * 9 | + * 10 | // Mock the auth module + * ... + * + * # README.md + * 105 | + * ... + * ``` + * + * Output format (clean): + * ``` + * Found 112 results in 20 files + * + * - src/acp/__tests__/agent.test.ts + * - README.md + * ... + * ``` + */ +export function 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() + 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 first line (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 + const formattedFiles = fileList.map((f) => `- ${f}`).join("\n") + + return `${summary}\n\n${formattedFiles}` +} + +// ============================================================================= +// Read Content Formatting +// ============================================================================= + +/** + * Format read results by truncating long file contents. + * + * @param content - The raw file content + * @param config - Optional configuration overrides + * @returns Truncated content with indicator if truncated + */ +export function formatReadContent(content: string, config: FormatConfig = DEFAULT_FORMAT_CONFIG): string { + const lines = content.split("\n") + + if (lines.length <= config.maxReadLines) { + return content + } + + // Truncate and add indicator + const truncated = lines.slice(0, config.maxReadLines).join("\n") + const remaining = lines.length - config.maxReadLines + return `${truncated}\n\n... (${remaining} more lines)` +} + +// ============================================================================= +// Code Block Wrapping +// ============================================================================= + +/** + * Wrap content in markdown code block for better rendering. + * + * @param content - Content to wrap + * @param language - Optional language for syntax highlighting + * @returns Content wrapped in markdown code fences + */ +export function wrapInCodeBlock(content: string, language?: string): string { + const fence = language ? `\`\`\`${language}` : "```" + return `${fence}\n${content}\n\`\`\`` +} + +// ============================================================================= +// Content Extraction from Raw Input +// ============================================================================= + +/** + * Common field names to check when extracting content from tool parameters. + */ +const CONTENT_FIELDS = ["content", "text", "result", "output", "fileContent", "data"] as const + +/** + * Extract content from raw input parameters. + * + * Tries common field names for content. Returns the first non-empty string found. + * + * @param rawInput - Tool parameters object + * @returns Extracted content or undefined if not found + */ +export function extractContentFromParams(rawInput: Record): string | undefined { + for (const field of CONTENT_FIELDS) { + const value = rawInput[field] + if (typeof value === "string" && value.length > 0) { + return value + } + } + + return undefined +} + +// ============================================================================= +// File Reading +// ============================================================================= + +/** + * Resolve a file path to absolute, using workspace path if relative. + * Includes path traversal protection when workspace path is provided. + * + * @param filePath - File path (may be relative or absolute) + * @param workspacePath - Workspace path for resolving relative paths + * @returns Result with absolute path, or error if path traversal detected + */ +export function resolveFilePath(filePath: string, workspacePath?: string): Result { + // Normalize the path to resolve any . or .. segments + const normalizedPath = path.normalize(filePath) + + if (path.isAbsolute(normalizedPath)) { + // For absolute paths with workspace, verify it's within workspace + if (workspacePath) { + const normalizedWorkspace = path.normalize(workspacePath) + if (!normalizedPath.startsWith(normalizedWorkspace + path.sep) && normalizedPath !== normalizedWorkspace) { + return err(`Path traversal detected: ${filePath} is outside workspace ${workspacePath}`) + } + } + return ok(normalizedPath) + } + + if (workspacePath) { + const resolved = path.resolve(workspacePath, normalizedPath) + const normalizedWorkspace = path.normalize(workspacePath) + + // Verify resolved path is within workspace (prevents ../../../etc/passwd attacks) + if (!resolved.startsWith(normalizedWorkspace + path.sep) && resolved !== normalizedWorkspace) { + return err(`Path traversal detected: ${filePath} resolves outside workspace ${workspacePath}`) + } + + return ok(resolved) + } + + // Return as-is if no workspace path available + return ok(normalizedPath) +} + +/** + * Resolve a file path to absolute (legacy version without Result wrapper). + * + * @deprecated Use resolveFilePath() with Result type for better error handling + * @param filePath - File path (may be relative or absolute) + * @param workspacePath - Workspace path for resolving relative paths + * @returns Absolute path (returns original path on error) + */ +export function resolveFilePathUnsafe(filePath: string, workspacePath?: string): string { + const result = resolveFilePath(filePath, workspacePath) + return result.ok ? result.value : filePath +} + +/** + * Read file content from the filesystem (synchronous version). + * + * For readFile tools, the rawInput.content field contains the file PATH + * (not the contents), so we need to read the actual file. + * + * @deprecated Use readFileContentAsync() for non-blocking I/O + * @param rawInput - Tool parameters (must contain path or content with file path) + * @param workspacePath - Workspace path for resolving relative paths + * @returns Result with file content or error message + */ +export function readFileContent(rawInput: Record, workspacePath: string): Result { + // 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 + let pathToRead: string | undefined + if (filePath) { + const resolved = resolveFilePath(filePath, workspacePath) + if (!resolved.ok) return resolved + pathToRead = resolved.value + } else if (relativePath) { + const resolved = resolveFilePath(relativePath, workspacePath) + if (!resolved.ok) return resolved + pathToRead = resolved.value + } + + if (!pathToRead) { + return err("readFile tool has no path") + } + + try { + const content = fs.readFileSync(pathToRead, "utf-8") + return ok(content) + } catch (error) { + return err(`Failed to read file ${pathToRead}: ${error}`) + } +} + +/** + * Read file content from the filesystem (asynchronous version). + * + * For readFile tools, the rawInput.content field contains the file PATH + * (not the contents), so we need to read the actual file. + * + * @param rawInput - Tool parameters (must contain path or content with file path) + * @param workspacePath - Workspace path for resolving relative paths + * @returns Promise resolving to Result with file content or error message + */ +export async function readFileContentAsync( + rawInput: Record, + workspacePath: string, +): Promise> { + // 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 + let pathToRead: string | undefined + if (filePath) { + const resolved = resolveFilePath(filePath, workspacePath) + if (!resolved.ok) return resolved + pathToRead = resolved.value + } else if (relativePath) { + const resolved = resolveFilePath(relativePath, workspacePath) + if (!resolved.ok) return resolved + pathToRead = resolved.value + } + + if (!pathToRead) { + return err("readFile tool has no path") + } + + try { + const content = await fsPromises.readFile(pathToRead, "utf-8") + return ok(content) + } catch (error) { + return err(`Failed to read file ${pathToRead}: ${error}`) + } +} + +// ============================================================================= +// User Echo Detection +// ============================================================================= + +/** + * 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. + * + * Uses fuzzy matching to handle minor differences (whitespace, etc.). + * + * @param text - The text to check + * @param promptText - The original prompt text to compare against + * @returns true if the text appears to be an echo of the prompt + */ +export function isUserEcho(text: string, promptText: string | null): boolean { + if (!promptText) { + return false + } + + // Normalize both strings for comparison + const normalizedPrompt = promptText.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 +} + +// ============================================================================= +// Validation Helpers +// ============================================================================= + +/** + * Check if a path looks like a valid file path (has extension). + * + * @param filePath - Path to check + * @returns true if the path has a file extension + */ +export function hasValidFilePath(filePath: string): boolean { + return /\.[a-zA-Z0-9]+$/.test(filePath) +} diff --git a/apps/cli/src/acp/utils/index.ts b/apps/cli/src/acp/utils/index.ts new file mode 100644 index 00000000000..37b7bd12808 --- /dev/null +++ b/apps/cli/src/acp/utils/index.ts @@ -0,0 +1,29 @@ +/** + * ACP Utilities Module + * + * Shared utilities for the ACP implementation. + */ + +export { + // Configuration + type FormatConfig, + DEFAULT_FORMAT_CONFIG, + // Result type + type Result, + ok, + err, + // Formatting functions + formatSearchResults, + formatReadContent, + wrapInCodeBlock, + // Content extraction + extractContentFromParams, + // File operations + readFileContent, + readFileContentAsync, + resolveFilePath, + resolveFilePathUnsafe, + // Validation + isUserEcho, + hasValidFilePath, +} from "./format-utils.js" diff --git a/apps/cli/src/agent/extension-host.ts b/apps/cli/src/agent/extension-host.ts index 968de48e9fc..07ac2e07859 100644 --- a/apps/cli/src/agent/extension-host.ts +++ b/apps/cli/src/agent/extension-host.ts @@ -163,6 +163,7 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac // Initialize output manager. this.outputManager = new OutputManager({ disabled: options.disableOutput, + debug: options.debug, }) // Initialize prompt manager with console mode callbacks. diff --git a/apps/cli/src/agent/message-processor.ts b/apps/cli/src/agent/message-processor.ts index dce32a93e73..d8d7842806e 100644 --- a/apps/cli/src/agent/message-processor.ts +++ b/apps/cli/src/agent/message-processor.ts @@ -105,6 +105,9 @@ export class MessageProcessor { * @param message - The raw message from the extension */ processMessage(message: ExtensionMessage): void { + // Debug logging for ALL messages to trace flow (always enabled for debugging) + console.error(`[MessageProcessor-DEBUG] processMessage: type=${message.type}`) + if (this.options.debug) { debugLog("[MessageProcessor] Received message", { type: message.type }) } @@ -248,6 +251,12 @@ export class MessageProcessor { const clineMessage = message.clineMessage + // Debug logging for messageUpdated + const msgType = clineMessage.type === "ask" ? `ask:${clineMessage.ask}` : `say:${clineMessage.say}` + console.error( + `[MessageProcessor-DEBUG] handleMessageUpdated: ${msgType}, ts=${clineMessage.ts}, partial=${clineMessage.partial}, textLen=${clineMessage.text?.length || 0}`, + ) + const previousState = this.store.getAgentState() // Update the message in the store @@ -422,6 +431,12 @@ export class MessageProcessor { // A more sophisticated implementation would track seen message timestamps const lastMessage = messages[messages.length - 1] if (lastMessage) { + // Debug logging for emitted messages + const msgType = lastMessage.type === "ask" ? `ask:${lastMessage.ask}` : `say:${lastMessage.say}` + console.error( + `[MessageProcessor-DEBUG] emitNewMessageEvents (last of ${messages.length}): ${msgType}, ts=${lastMessage.ts}, partial=${lastMessage.partial}, textLen=${lastMessage.text?.length || 0}`, + ) + // DEBUG: Log all emitted ask messages to trace partial handling if (this.options.debug && lastMessage.type === "ask") { debugLog("[MessageProcessor] EMIT message", { diff --git a/apps/cli/src/agent/output-manager.ts b/apps/cli/src/agent/output-manager.ts index f657b2802e2..f6b74d28b23 100644 --- a/apps/cli/src/agent/output-manager.ts +++ b/apps/cli/src/agent/output-manager.ts @@ -13,8 +13,17 @@ * - Can be disabled for TUI mode where Ink controls the terminal */ +import fs from "fs" import { ClineMessage, ClineSay } from "@roo-code/types" +// Debug logging to file (for CLI debugging without breaking TUI) +const DEBUG_LOG = "/tmp/roo-cli-debug.log" +function debugLog(message: string, data?: unknown) { + const timestamp = new Date().toISOString() + const entry = data ? `[${timestamp}] ${message}: ${JSON.stringify(data, null, 2)}\n` : `[${timestamp}] ${message}\n` + fs.appendFileSync(DEBUG_LOG, entry) +} + import { Observable } from "./events.js" // ============================================================================= @@ -58,6 +67,12 @@ export interface OutputManagerOptions { * Stream for error output (default: process.stderr). */ stderr?: NodeJS.WriteStream + + /** + * When true, outputs verbose debug info for tool requests. + * Enabled by -d flag in CLI. + */ + debug?: boolean } // ============================================================================= @@ -68,6 +83,7 @@ export class OutputManager { private disabled: boolean private stdout: NodeJS.WriteStream private stderr: NodeJS.WriteStream + private debug: boolean /** * Track displayed messages by ts to avoid duplicate output. @@ -113,6 +129,7 @@ export class OutputManager { this.disabled = options.disabled ?? false this.stdout = options.stdout ?? process.stdout this.stderr = options.stderr ?? process.stderr + this.debug = options.debug ?? false } // =========================================================================== @@ -158,12 +175,26 @@ export class OutputManager { } } + /** + * Get a timestamp for debug output. + */ + private getTimestamp(): string { + const now = new Date() + return `[${now.toISOString().slice(11, 23)}]` + } + + /** + * Whether to include timestamps in output (for debugging). + */ + private showTimestamps = !!process.env.DEBUG_TIMESTAMPS + /** * Output a simple text line with a label. */ output(label: string, text?: string): void { if (this.disabled) return - const message = text ? `${label} ${text}\n` : `${label}\n` + const ts = this.showTimestamps ? `${this.getTimestamp()} ` : "" + const message = text ? `${ts}${label} ${text}\n` : `${ts}${label}\n` this.stdout.write(message) } @@ -172,7 +203,8 @@ export class OutputManager { */ outputError(label: string, text?: string): void { if (this.disabled) return - const message = text ? `${label} ${text}\n` : `${label}\n` + const ts = this.showTimestamps ? `${this.getTimestamp()} ` : "" + const message = text ? `${ts}${label} ${text}\n` : `${ts}${label}\n` this.stderr.write(message) } @@ -181,7 +213,8 @@ export class OutputManager { */ writeRaw(text: string): void { if (this.disabled) return - this.stdout.write(text) + const ts = this.showTimestamps ? `${this.getTimestamp()} ` : "" + this.stdout.write(ts + text) } /** @@ -233,6 +266,7 @@ export class OutputManager { this.hasStreamedTerminalOutput = false this.toolContentStreamed.clear() this.toolContentTruncated.clear() + this.toolLastDisplayedCharCount.clear() this.streamingState.next({ ts: null, isStreaming: false }) } @@ -420,11 +454,25 @@ export class OutputManager { */ private toolContentTruncated = new Set() + /** + * Track the last displayed character count for streaming updates. + */ + private toolLastDisplayedCharCount = new Map() + /** * Maximum lines to show when streaming file content. */ private static readonly MAX_PREVIEW_LINES = 5 + /** + * Helper to write debug output to stderr with timestamp. + */ + private debugOutput(message: string): void { + if (!this.debug) return + const ts = this.getTimestamp() + this.stderr.write(`${ts} [DEBUG] ${message}\n`) + } + /** * Output tool request (file create/edit/delete) with streaming content preview. * Shows the file content being written (up to 20 lines), then final state when complete. @@ -448,17 +496,56 @@ export class OutputManager { // Use default if not JSON } + // Debug output: show every tool request message + this.debugOutput( + `outputToolRequest: ts=${ts} partial=${isPartial} tool=${toolName} path="${toolPath}" contentLen=${content.length}`, + ) + + debugLog("[outputToolRequest] called", { + ts, + isPartial, + toolName, + toolPath, + contentLen: content.length, + }) + if (isPartial && text) { const previousContent = this.toolContentStreamed.get(ts) || "" const previous = this.streamedContent.get(ts) + const currentLineCount = content === "" ? 0 : content.split("\n").length - if (!previous) { - // First partial - show header with path (if has valid extension) - // Check for valid extension: must have a dot followed by 1+ characters - const hasValidExtension = /\.[a-zA-Z0-9]+$/.test(toolPath) - const pathInfo = hasValidExtension ? ` ${toolPath}` : "" - this.writeRaw(`\n[${toolName}]${pathInfo}\n`) + // Check for valid extension: must have a dot followed by 1+ characters + const hasValidExtension = /\.[a-zA-Z0-9]+$/.test(toolPath) + + // Don't show header until we have BOTH a valid path AND some content. + // This prevents showing "[newFileCreated] (0 chars)" followed by a long + // pause while the LLM generates the content. + const shouldShowHeader = hasValidExtension && content.length > 0 + + if (!previous && shouldShowHeader) { + // First partial with valid path and content - show header + const pathInfo = ` ${toolPath}` + debugLog("[outputToolRequest] FIRST PARTIAL - header", { + toolName, + toolPath, + contentLen: content.length, + }) + this.writeRaw(`\n[${toolName}]${pathInfo} (${content.length} chars)\n`) + this.streamedContent.set(ts, { ts, text, headerShown: true }) + this.toolLastDisplayedCharCount.set(ts, content.length) + this.currentlyStreamingTs = ts + this.streamingState.next({ ts, isStreaming: true }) + } else if (!previous && !shouldShowHeader) { + // Early partial without valid path/content - track but don't show yet + // Just set headerShown: false to track we've seen this ts + this.streamedContent.set(ts, { ts, text, headerShown: false }) + } else if (previous && !previous.headerShown && shouldShowHeader) { + // Path and content now valid - show the header now + const pathInfo = ` ${toolPath}` + debugLog("[outputToolRequest] DEFERRED HEADER", { toolName, toolPath, contentLen: content.length }) + this.writeRaw(`\n[${toolName}]${pathInfo} (${content.length} chars)\n`) this.streamedContent.set(ts, { ts, text, headerShown: true }) + this.toolLastDisplayedCharCount.set(ts, content.length) this.currentlyStreamingTs = ts this.streamingState.next({ ts, isStreaming: true }) } @@ -468,7 +555,6 @@ export class OutputManager { const delta = content.slice(previousContent.length) // Check if we're still within the preview limit const previousLineCount = previousContent === "" ? 0 : previousContent.split("\n").length - const currentLineCount = content === "" ? 0 : content.split("\n").length const previouslyTruncated = this.toolContentTruncated.has(ts) if (!previouslyTruncated) { @@ -477,7 +563,6 @@ export class OutputManager { this.writeRaw(delta) } else if (previousLineCount < OutputManager.MAX_PREVIEW_LINES) { // Just crossed the limit - output remaining lines up to limit, mark as truncated - // (truncation message will be shown at completion with final count) const linesToShow = OutputManager.MAX_PREVIEW_LINES - previousLineCount const deltaLines = delta.split("\n") const truncatedDelta = deltaLines.slice(0, linesToShow).join("\n") @@ -485,24 +570,34 @@ export class OutputManager { this.writeRaw(truncatedDelta) } this.toolContentTruncated.add(ts) + // Show streaming indicator with char count + this.writeRaw(`\n... streaming (${content.length} chars)`) + this.toolLastDisplayedCharCount.set(ts, content.length) } else { // Already at/past limit but not yet marked - just mark as truncated this.toolContentTruncated.add(ts) } + } else { + // Already truncated - update streaming char count on each update + // Output on new lines so updates are visible in captured output + const lastDisplayed = this.toolLastDisplayedCharCount.get(ts) || 0 + if (content.length !== lastDisplayed) { + this.writeRaw(`\n... streaming (${content.length} chars)`) + this.toolLastDisplayedCharCount.set(ts, content.length) + } } - // If already truncated, don't output more content this.toolContentStreamed.set(ts, content) } this.displayedMessages.set(ts, { ts, text, partial: true }) } else if (!isPartial && !alreadyDisplayedComplete) { - // Tool request complete - check if we need to show truncation message + // Tool request complete const previousContent = this.toolContentStreamed.get(ts) || "" const currentLineCount = content === "" ? 0 : content.split("\n").length + const wasTruncated = this.toolContentTruncated.has(ts) - // Show truncation message if content exceeds preview limit - // (We only mark as truncated during partials, the actual message is shown here with final count) - if (currentLineCount > OutputManager.MAX_PREVIEW_LINES && previousContent) { + // Show final truncation message + if (wasTruncated && previousContent) { const remainingLines = currentLineCount - OutputManager.MAX_PREVIEW_LINES this.writeRaw(`\n... (${remainingLines} more lines)\n`) } @@ -517,6 +612,7 @@ export class OutputManager { // Clean up tool content tracking this.toolContentStreamed.delete(ts) this.toolContentTruncated.delete(ts) + this.toolLastDisplayedCharCount.delete(ts) } } diff --git a/apps/cli/src/types/constants.ts b/apps/cli/src/types/constants.ts index 04abb90ad4e..f11973bf45c 100644 --- a/apps/cli/src/types/constants.ts +++ b/apps/cli/src/types/constants.ts @@ -3,7 +3,7 @@ import { reasoningEffortsExtended } from "@roo-code/types" export const DEFAULT_FLAGS = { mode: "code", reasoningEffort: "medium" as const, - model: "anthropic/claude-opus-4.5", + model: "anthropic/claude-4.5-sonnet", provider: "openrouter", } diff --git a/apps/cli/src/ui/hooks/useClientEvents.ts b/apps/cli/src/ui/hooks/useClientEvents.ts index 30758858bec..110d14ce4a0 100644 --- a/apps/cli/src/ui/hooks/useClientEvents.ts +++ b/apps/cli/src/ui/hooks/useClientEvents.ts @@ -200,9 +200,15 @@ export function useClientEvents({ client, nonInteractive }: UseClientEventsOptio toolDisplayName = toolInfo.tool as string toolDisplayOutput = formatToolOutput(toolInfo) toolData = extractToolData(toolInfo) - } catch { + } catch (err) { // Use raw text if not valid JSON - may happen during early streaming parseError = true + tuiLogger.debug("ask:partial-tool:parse-error", { + id: messageId, + textLen: text.length, + textPreview: text.substring(0, 100), + error: String(err), + }) } tuiLogger.debug("ask:partial-tool", { @@ -210,6 +216,8 @@ export function useClientEvents({ client, nonInteractive }: UseClientEventsOptio textLen: text.length, toolName: toolName || "none", hasToolData: !!toolData, + toolDataPath: toolData?.path, + toolDataContentLen: toolData?.content?.length || 0, parseError, }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3dc925f38c6..daa03f73792 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,7 +84,7 @@ importers: dependencies: '@agentclientprotocol/sdk': specifier: ^0.12.0 - version: 0.12.0(zod@3.25.76) + version: 0.12.0(zod@4.3.5) '@inkjs/ui': specifier: ^2.0.0 version: 2.0.0(ink@6.6.0(@types/react@18.3.23)(react@19.2.3)) @@ -121,6 +121,9 @@ importers: superjson: specifier: ^2.2.6 version: 2.2.6 + zod: + specifier: ^4.3.5 + version: 4.3.5 zustand: specifier: ^5.0.0 version: 5.0.9(@types/react@18.3.23)(react@19.2.3) @@ -10667,6 +10670,9 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.3.5: + resolution: {integrity: sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==} + zustand@5.0.9: resolution: {integrity: sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==} engines: {node: '>=12.20.0'} @@ -10692,9 +10698,9 @@ snapshots: '@adobe/css-tools@4.4.2': {} - '@agentclientprotocol/sdk@0.12.0(zod@3.25.76)': + '@agentclientprotocol/sdk@0.12.0(zod@4.3.5)': dependencies: - zod: 3.25.76 + zod: 4.3.5 '@alcalzone/ansi-tokenize@0.2.3': dependencies: @@ -21727,6 +21733,8 @@ snapshots: zod@3.25.76: {} + zod@4.3.5: {} + zustand@5.0.9(@types/react@18.3.23)(react@19.2.3): optionalDependencies: '@types/react': 18.3.23 diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index fd791729b4d..e33e567cb3e 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -400,14 +400,14 @@ describe("writeToFileTool", () => { }) it("streams content updates during partial execution after path stabilizes", async () => { - // First call - path not yet stabilized, early return (no file operations) + // First call - sends early "tool starting" notification, but no file operations yet await executeWriteFileTool({}, { isPartial: true }) - expect(mockCline.ask).not.toHaveBeenCalled() + expect(mockCline.ask).toHaveBeenCalledTimes(1) // Early notification sent expect(mockCline.diffViewProvider.open).not.toHaveBeenCalled() // Second call with same path - path is now stabilized, file operations proceed await executeWriteFileTool({}, { isPartial: true }) - expect(mockCline.ask).toHaveBeenCalled() + expect(mockCline.ask).toHaveBeenCalledTimes(2) // Additional call after path stabilizes expect(mockCline.diffViewProvider.open).toHaveBeenCalledWith(testFilePath) expect(mockCline.diffViewProvider.update).toHaveBeenCalledWith(testContent, false) }) From e92a0fca063ecbe50ac730f28942b45e3f84be03 Mon Sep 17 00:00:00 2001 From: cte Date: Sun, 11 Jan 2026 16:19:16 -0800 Subject: [PATCH 08/17] Add mode and model pickers --- .../src/acp/__tests__/model-service.test.ts | 233 ++++++++++++++++++ apps/cli/src/acp/__tests__/session.test.ts | 3 + apps/cli/src/acp/agent.ts | 98 ++++---- apps/cli/src/acp/index.ts | 7 + apps/cli/src/acp/interfaces.ts | 17 ++ apps/cli/src/acp/model-service.ts | 219 ++++++++++++++++ apps/cli/src/acp/session-event-handler.ts | 128 +++++++++- apps/cli/src/acp/session.ts | 58 ++++- apps/cli/src/acp/types.ts | 73 ++++++ apps/cli/src/commands/cli/run.ts | 2 +- apps/cli/src/lib/utils/provider.ts | 2 +- 11 files changed, 777 insertions(+), 63 deletions(-) create mode 100644 apps/cli/src/acp/__tests__/model-service.test.ts create mode 100644 apps/cli/src/acp/model-service.ts create mode 100644 apps/cli/src/acp/types.ts diff --git a/apps/cli/src/acp/__tests__/model-service.test.ts b/apps/cli/src/acp/__tests__/model-service.test.ts new file mode 100644 index 00000000000..d2faf110ce1 --- /dev/null +++ b/apps/cli/src/acp/__tests__/model-service.test.ts @@ -0,0 +1,233 @@ +/** + * Tests for ModelService + */ + +import { ModelService, createModelService } from "../model-service.js" +import { DEFAULT_MODELS } from "../types.js" + +// Mock fetch globally +const mockFetch = vi.fn() +global.fetch = mockFetch + +describe("ModelService", () => { + beforeEach(() => { + vi.clearAllMocks() + mockFetch.mockReset() + }) + + describe("constructor", () => { + it("should create a ModelService with default options", () => { + const service = new ModelService() + expect(service).toBeInstanceOf(ModelService) + }) + + it("should create a ModelService with custom options", () => { + const service = new ModelService({ + apiUrl: "https://custom.api.com", + apiKey: "test-key", + timeout: 10000, + }) + expect(service).toBeInstanceOf(ModelService) + }) + }) + + describe("createModelService factory", () => { + it("should create a ModelService instance", () => { + const service = createModelService() + expect(service).toBeInstanceOf(ModelService) + }) + + it("should pass options to ModelService", () => { + const service = createModelService({ + apiKey: "test-api-key", + }) + expect(service).toBeInstanceOf(ModelService) + }) + }) + + describe("fetchAvailableModels", () => { + it("should return cached models on subsequent calls", async () => { + const service = new ModelService() + + // First call - should fetch from API + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + object: "list", + data: [ + { id: "model-1", owned_by: "test" }, + { id: "model-2", owned_by: "test" }, + ], + }), + }) + + const firstResult = await service.fetchAvailableModels() + expect(mockFetch).toHaveBeenCalledTimes(1) + + // Second call - should use cache + const secondResult = await service.fetchAvailableModels() + expect(mockFetch).toHaveBeenCalledTimes(1) // No additional fetch + expect(secondResult).toEqual(firstResult) + }) + + it("should return DEFAULT_MODELS when API fails", async () => { + const service = new ModelService() + + mockFetch.mockRejectedValueOnce(new Error("Network error")) + + const result = await service.fetchAvailableModels() + expect(result).toEqual(DEFAULT_MODELS) + }) + + it("should return DEFAULT_MODELS when API returns non-OK status", async () => { + const service = new ModelService() + + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + }) + + const result = await service.fetchAvailableModels() + expect(result).toEqual(DEFAULT_MODELS) + }) + + it("should return DEFAULT_MODELS when API returns invalid response", async () => { + const service = new ModelService() + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ invalid: "response" }), + }) + + const result = await service.fetchAvailableModels() + expect(result).toEqual(DEFAULT_MODELS) + }) + + it("should return DEFAULT_MODELS on timeout", async () => { + const service = new ModelService({ timeout: 100 }) + + // Mock a fetch that never resolves + mockFetch.mockImplementationOnce( + () => + new Promise((_, reject) => { + setTimeout(() => reject(new DOMException("Aborted", "AbortError")), 50) + }), + ) + + const result = await service.fetchAvailableModels() + expect(result).toEqual(DEFAULT_MODELS) + }) + + it("should transform API response to AcpModel format", async () => { + const service = new ModelService() + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: [ + { id: "anthropic/claude-3-sonnet", owned_by: "anthropic" }, + { id: "openai/gpt-4", owned_by: "openai" }, + ], + }), + }) + + const result = await service.fetchAvailableModels() + + // Should include default model first with actual model name + expect(result[0]).toEqual({ + modelId: "anthropic/claude-sonnet-4.5", + name: "Claude Sonnet 4.5", + description: "Best balance of speed and capability", + }) + + // Should include transformed models + expect(result.length).toBeGreaterThan(1) + }) + + it("should include Authorization header when apiKey is provided", async () => { + const service = new ModelService({ apiKey: "test-api-key" }) + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: [] }), + }) + + await service.fetchAvailableModels() + + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer test-api-key", + }), + }), + ) + }) + }) + + describe("getModelState", () => { + it("should return model state with current model ID", async () => { + const service = new ModelService() + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: [{ id: "anthropic/claude-sonnet-4.5" }], + }), + }) + + const state = await service.getModelState("anthropic/claude-sonnet-4.5") + + expect(state).toEqual({ + availableModels: expect.any(Array), + currentModelId: "anthropic/claude-sonnet-4.5", + }) + }) + + it("should fall back to 'default' if current model ID is not in available models", async () => { + const service = new ModelService() + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: [{ id: "model-1" }], + }), + }) + + const state = await service.getModelState("non-existent-model") + + expect(state.currentModelId).toBe(DEFAULT_MODELS[0]!.modelId) + }) + }) + + describe("clearCache", () => { + it("should clear the cached models", async () => { + const service = new ModelService() + + // First fetch + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: [{ id: "model-1" }], + }), + }) + + await service.fetchAvailableModels() + expect(mockFetch).toHaveBeenCalledTimes(1) + + // Clear cache + service.clearCache() + + // Second fetch - should call API again + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: [{ id: "model-2" }], + }), + }) + + await service.fetchAvailableModels() + expect(mockFetch).toHaveBeenCalledTimes(2) + }) + }) +}) diff --git a/apps/cli/src/acp/__tests__/session.test.ts b/apps/cli/src/acp/__tests__/session.test.ts index 895872f9aca..2d5d2802a07 100644 --- a/apps/cli/src/acp/__tests__/session.test.ts +++ b/apps/cli/src/acp/__tests__/session.test.ts @@ -15,6 +15,9 @@ vi.mock("@/agent/extension-host.js", () => { activate: vi.fn().mockResolvedValue(undefined), dispose: vi.fn().mockResolvedValue(undefined), sendToExtension: vi.fn(), + // Add on/off methods for extension host events (e.g., extensionWebviewMessage) + on: vi.fn().mockReturnThis(), + off: vi.fn().mockReturnThis(), })), } }) diff --git a/apps/cli/src/acp/agent.ts b/apps/cli/src/acp/agent.ts index 3b66933f591..72860e95bec 100644 --- a/apps/cli/src/acp/agent.ts +++ b/apps/cli/src/acp/agent.ts @@ -13,6 +13,9 @@ import { DEFAULT_FLAGS } from "@/types/constants.js" import { AcpSession, type AcpSessionOptions } from "./session.js" import { acpLog } from "./logger.js" +import { ModelService, createModelService } from "./model-service.js" +import { type ExtendedNewSessionResponse, type AcpModelState, DEFAULT_MODELS } from "./types.js" +import { envVarMap } from "@/lib/utils/provider.js" // ============================================================================= // Types @@ -31,15 +34,6 @@ export interface RooCodeAgentOptions { mode?: string } -// ============================================================================= -// Auth Method IDs -// ============================================================================= - -const AUTH_METHODS = { - ROO_CLOUD: "roo-cloud", - API_KEY: "api-key", -} as const - // ============================================================================= // Available Modes // ============================================================================= @@ -81,11 +75,17 @@ export class RooCodeAgent implements acp.Agent { private sessions: Map = new Map() private clientCapabilities: acp.ClientCapabilities | undefined private isAuthenticated = false + private readonly modelService: ModelService constructor( private readonly options: RooCodeAgentOptions, private readonly connection: acp.AgentSideConnection, - ) {} + ) { + // Initialize model service with optional API key + this.modelService = createModelService({ + apiKey: options.apiKey, + }) + } // =========================================================================== // Initialization @@ -109,14 +109,9 @@ export class RooCodeAgent implements acp.Agent { protocolVersion: acp.PROTOCOL_VERSION, authMethods: [ { - id: AUTH_METHODS.ROO_CLOUD, + id: "roo", 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)", + description: `Sign in with your Roo Code Cloud account or BYOK by exporting an API key Environment Variable (${Object.values(envVarMap).join(", ")})`, }, ], agentCapabilities: { @@ -137,45 +132,17 @@ export class RooCodeAgent implements acp.Agent { // =========================================================================== /** - * Authenticate with the specified method. + * Authenticate with Roo Code Cloud. */ - async authenticate(params: acp.AuthenticateRequest): Promise { - 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 - } + async authenticate(_params: acp.AuthenticateRequest): Promise { + const result = await login({ verbose: false }) - default: - acpLog.error("Agent", `Unknown auth method: ${params.methodId}`) - throw acp.RequestError.invalidParams(undefined, `Unknown auth method: ${params.methodId}`) + if (!result.success) { + throw acp.RequestError.authRequired(undefined, "Failed to authenticate with Roo Code Cloud") } + this.isAuthenticated = true + acpLog.response("authenticate", {}) return {} } @@ -187,7 +154,7 @@ export class RooCodeAgent implements acp.Agent { /** * Create a new session. */ - async newSession(params: acp.NewSessionRequest): Promise { + async newSession(params: acp.NewSessionRequest): Promise { acpLog.request("newSession", { cwd: params.cwd }) // Require authentication @@ -202,6 +169,7 @@ export class RooCodeAgent implements acp.Agent { } const sessionId = randomUUID() + const initialMode = this.options.mode || "code" acpLog.info("Agent", `Creating new session: ${sessionId}`) const sessionOptions: AcpSessionOptions = { @@ -209,7 +177,7 @@ export class RooCodeAgent implements acp.Agent { provider: this.options.provider || "openrouter", apiKey: this.options.apiKey || process.env.OPENROUTER_API_KEY, model: this.options.model || DEFAULT_FLAGS.model, - mode: this.options.mode || "code", + mode: initialMode, } acpLog.debug("Agent", "Session options", { @@ -230,11 +198,31 @@ export class RooCodeAgent implements acp.Agent { this.sessions.set(sessionId, session) acpLog.info("Agent", `Session created successfully: ${sessionId}`) - const response = { sessionId } + // Fetch model state asynchronously (don't block session creation) + const modelState = await this.getModelState() + + // Build response with modes and models + const response: ExtendedNewSessionResponse = { + sessionId, + modes: { + currentModeId: initialMode, + availableModes: AVAILABLE_MODES, + }, + models: modelState, + } + acpLog.response("newSession", response) return response } + /** + * Get the current model state. + */ + private async getModelState(): Promise { + const currentModelId = this.options.model || DEFAULT_MODELS[0]!.modelId + return this.modelService.getModelState(currentModelId) + } + // =========================================================================== // Prompt Handling // =========================================================================== diff --git a/apps/cli/src/acp/index.ts b/apps/cli/src/acp/index.ts index 1e06292d196..ab0594fcd9e 100644 --- a/apps/cli/src/acp/index.ts +++ b/apps/cli/src/acp/index.ts @@ -2,6 +2,13 @@ export { type RooCodeAgentOptions, RooCodeAgent } from "./agent.js" export { type AcpSessionOptions, AcpSession } from "./session.js" +// Types for mode and model pickers +export type { AcpModel, AcpModelState, ExtendedNewSessionResponse } from "./types.js" +export { DEFAULT_MODELS } from "./types.js" + +// Model service +export { ModelService, createModelService, type ModelServiceOptions } from "./model-service.js" + // Interfaces for dependency injection export type { IAcpLogger, diff --git a/apps/cli/src/acp/interfaces.ts b/apps/cli/src/acp/interfaces.ts index 3bbd872d917..d05795b4e8f 100644 --- a/apps/cli/src/acp/interfaces.ts +++ b/apps/cli/src/acp/interfaces.ts @@ -151,6 +151,13 @@ export interface IExtensionClient { // Extension Host Interface // ============================================================================= +/** + * Events emitted by the extension host. + */ +export interface ExtensionHostEvents { + extensionWebviewMessage: (msg: unknown) => void +} + /** * Interface for extension host interactions. */ @@ -160,6 +167,16 @@ export interface IExtensionHost { */ readonly client: IExtensionClient + /** + * Subscribe to extension host events. + */ + on(event: K, handler: ExtensionHostEvents[K]): void + + /** + * Unsubscribe from extension host events. + */ + off(event: K, handler: ExtensionHostEvents[K]): void + /** * Activate the extension host. */ diff --git a/apps/cli/src/acp/model-service.ts b/apps/cli/src/acp/model-service.ts new file mode 100644 index 00000000000..17da48d84df --- /dev/null +++ b/apps/cli/src/acp/model-service.ts @@ -0,0 +1,219 @@ +/** + * Model Service for ACP + * + * Fetches and caches available models from the Roo Code API. + */ + +import type { AcpModel, AcpModelState } from "./types.js" +import { DEFAULT_MODELS } from "./types.js" +import { acpLog } from "./logger.js" + +// ============================================================================= +// Types +// ============================================================================= + +export interface ModelServiceOptions { + /** Base URL for the API (defaults to https://api.roocode.com) */ + apiUrl?: string + /** API key for authentication */ + apiKey?: string + /** Request timeout in milliseconds (defaults to 5000) */ + timeout?: number +} + +/** + * Response structure from /proxy/v1/models endpoint. + * Based on OpenAI-compatible model listing format. + */ +interface ModelsApiResponse { + object?: string + data?: Array<{ + id: string + object?: string + created?: number + owned_by?: string + // Additional fields may be present + }> +} + +// ============================================================================= +// Constants +// ============================================================================= + +const DEFAULT_API_URL = "https://api.roocode.com" +const DEFAULT_TIMEOUT = 5000 + +// ============================================================================= +// ModelService Class +// ============================================================================= + +/** + * Service for fetching and managing available models. + */ +export class ModelService { + private readonly apiUrl: string + private readonly apiKey?: string + private readonly timeout: number + private cachedModels: AcpModel[] | null = null + + constructor(options: ModelServiceOptions = {}) { + this.apiUrl = options.apiUrl || DEFAULT_API_URL + this.apiKey = options.apiKey + this.timeout = options.timeout || DEFAULT_TIMEOUT + } + + /** + * Fetch available models from the API. + * Returns cached models if available, otherwise fetches from API. + * Falls back to default models on error. + */ + async fetchAvailableModels(): Promise { + // Return cached models if available + if (this.cachedModels) { + return this.cachedModels + } + + try { + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), this.timeout) + + const headers: Record = { + "Content-Type": "application/json", + } + + if (this.apiKey) { + headers["Authorization"] = `Bearer ${this.apiKey}` + } + + const response = await fetch(`${this.apiUrl}/proxy/v1/models`, { + method: "GET", + headers, + signal: controller.signal, + }) + + clearTimeout(timeoutId) + + if (!response.ok) { + acpLog.warn("ModelService", `API returned ${response.status}, using default models`) + this.cachedModels = DEFAULT_MODELS + return this.cachedModels + } + + const data = (await response.json()) as ModelsApiResponse + + if (!data.data || !Array.isArray(data.data)) { + acpLog.warn("ModelService", "Invalid API response format, using default models") + this.cachedModels = DEFAULT_MODELS + return this.cachedModels + } + + // Transform API response to AcpModel format + this.cachedModels = this.transformApiResponse(data.data) + acpLog.debug("ModelService", `Fetched ${this.cachedModels.length} models from API`) + + return this.cachedModels + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + acpLog.warn("ModelService", "Request timed out, using default models") + } else { + acpLog.warn( + "ModelService", + `Failed to fetch models: ${error instanceof Error ? error.message : String(error)}`, + ) + } + this.cachedModels = DEFAULT_MODELS + return this.cachedModels + } + } + + /** + * Get the current model state including available models and current selection. + */ + async getModelState(currentModelId: string): Promise { + const availableModels = await this.fetchAvailableModels() + + // Validate that currentModelId exists in available models + const modelExists = availableModels.some((m) => m.modelId === currentModelId) + const effectiveModelId = modelExists ? currentModelId : DEFAULT_MODELS[0]!.modelId + + return { + availableModels, + currentModelId: effectiveModelId, + } + } + + /** + * Clear the cached models, forcing a refresh on next fetch. + */ + clearCache(): void { + this.cachedModels = null + } + + /** + * Transform API response to AcpModel format. + */ + private transformApiResponse( + data: Array<{ + id: string + object?: string + created?: number + owned_by?: string + }>, + ): AcpModel[] { + // If API returns models, transform them + // For now, we'll create a simple mapping + // In practice, the API should return model metadata including pricing + const models: AcpModel[] = [] + + const defaultModel = DEFAULT_MODELS[0]! + + // Always include the default model first (shows actual model name) + models.push(defaultModel) + + // Add models from API response + for (const model of data) { + // Skip if it's already in our list or if it's a system model + if (model.id === defaultModel.modelId || model.id.startsWith("_")) { + continue + } + + models.push({ + modelId: model.id, + name: this.formatModelName(model.id), + description: model.owned_by ? `Provided by ${model.owned_by}` : undefined, + }) + } + + // If no models from API, return defaults + if (models.length === 1) { + return DEFAULT_MODELS + } + + return models + } + + /** + * Format a model ID into a human-readable name. + */ + private formatModelName(modelId: string): string { + // Convert model IDs like "anthropic/claude-3-sonnet" to "Claude 3 Sonnet" + const parts = modelId.split("/") + const name = parts[parts.length - 1] || modelId + + return name + .split("-") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" ") + } +} + +// ============================================================================= +// Factory Function +// ============================================================================= + +/** + * Create a new ModelService instance. + */ +export function createModelService(options?: ModelServiceOptions): ModelService { + return new ModelService(options) +} diff --git a/apps/cli/src/acp/session-event-handler.ts b/apps/cli/src/acp/session-event-handler.ts index 431a665bd3f..a838203777f 100644 --- a/apps/cli/src/acp/session-event-handler.ts +++ b/apps/cli/src/acp/session-event-handler.ts @@ -1,11 +1,12 @@ /** * Session Event Handler * - * Handles events from the ExtensionClient and translates them to ACP updates. + * Handles events from the ExtensionClient and ExtensionHost, translating them to ACP updates. * Extracted from session.ts for better separation of concerns. */ -import type { ClineMessage, ClineAsk, ClineSay } from "@roo-code/types" +import type { SessionMode } from "@agentclientprotocol/sdk" +import type { ClineMessage, ClineAsk, ClineSay, ExtensionMessage, ExtensionState, ModeConfig } from "@roo-code/types" import type { WaitingForInputEvent, TaskCompletedEvent, CommandExecutionOutputEvent } from "@/agent/events.js" @@ -14,6 +15,7 @@ import { isUserEcho } from "./utils/index.js" import type { IAcpLogger, IExtensionClient, + IExtensionHost, IPromptStateMachine, ICommandStreamManager, IToolContentStreamManager, @@ -96,6 +98,8 @@ export interface SessionEventHandlerDeps { logger: IAcpLogger /** Extension client for event subscription */ client: IExtensionClient + /** Extension host for host-level events (modes, etc.) */ + extensionHost: IExtensionHost /** Prompt state machine */ promptState: IPromptStateMachine /** Delta tracker for streaming */ @@ -116,6 +120,8 @@ export interface SessionEventHandlerDeps { sendToExtension: (message: unknown) => void /** Workspace path */ workspacePath: string + /** Initial mode ID */ + initialModeId: string } /** @@ -123,22 +129,30 @@ export interface SessionEventHandlerDeps { */ export type TaskCompletedCallback = (success: boolean) => void +/** + * Callback for mode changes. + */ +export type ModeChangedCallback = (modeId: string, availableModes: SessionMode[]) => void + // ============================================================================= // SessionEventHandler Class // ============================================================================= /** - * Handles events from the ExtensionClient and translates them to ACP updates. + * Handles events from the ExtensionClient and ExtensionHost, translating them to ACP updates. * * Responsibilities: * - Subscribe to extension client events + * - Subscribe to extension host events (mode changes, etc.) * - Handle streaming for text/reasoning messages * - Handle tool permission requests * - Handle task completion + * - Track mode state changes */ export class SessionEventHandler { private readonly logger: IAcpLogger private readonly client: IExtensionClient + private readonly extensionHost: IExtensionHost private readonly promptState: IPromptStateMachine private readonly deltaTracker: IDeltaTracker private readonly commandStreamManager: ICommandStreamManager @@ -151,6 +165,16 @@ export class SessionEventHandler { private readonly workspacePath: string private taskCompletedCallback: TaskCompletedCallback | null = null + private modeChangedCallback: ModeChangedCallback | null = null + + /** Current mode ID (Roo Code mode like 'code', 'architect', etc.) */ + private currentModeId: string + + /** Available modes from extension state */ + private availableModes: SessionMode[] = [] + + /** Listener for extension host messages */ + private extensionMessageListener: ((msg: unknown) => void) | null = null /** * Track processed permission requests to prevent duplicates. @@ -163,6 +187,7 @@ export class SessionEventHandler { constructor(deps: SessionEventHandlerDeps) { this.logger = deps.logger this.client = deps.client + this.extensionHost = deps.extensionHost this.promptState = deps.promptState this.deltaTracker = deps.deltaTracker this.commandStreamManager = deps.commandStreamManager @@ -173,6 +198,7 @@ export class SessionEventHandler { this.respondWithText = deps.respondWithText this.sendToExtension = deps.sendToExtension this.workspacePath = deps.workspacePath + this.currentModeId = deps.initialModeId } // =========================================================================== @@ -180,7 +206,7 @@ export class SessionEventHandler { // =========================================================================== /** - * Set up event handlers to translate ExtensionClient events to ACP updates. + * Set up event handlers to translate ExtensionClient and ExtensionHost events to ACP updates. */ setupEventHandlers(): void { // Handle new messages @@ -208,6 +234,12 @@ export class SessionEventHandler { this.client.on("taskCompleted", (event: unknown) => { this.handleTaskCompleted(event as TaskCompletedEvent) }) + + // Handle extension host messages (modes, state, etc.) + this.extensionMessageListener = (msg: unknown) => { + this.handleExtensionMessage(msg as ExtensionMessage) + } + this.extensionHost.on("extensionWebviewMessage", this.extensionMessageListener) } /** @@ -217,6 +249,27 @@ export class SessionEventHandler { this.taskCompletedCallback = callback } + /** + * Set the callback for mode changes. + */ + onModeChanged(callback: ModeChangedCallback): void { + this.modeChangedCallback = callback + } + + /** + * Get the current mode ID. + */ + getCurrentModeId(): string { + return this.currentModeId + } + + /** + * Get the available modes. + */ + getAvailableModes(): SessionMode[] { + return this.availableModes + } + /** * Reset state for a new prompt. */ @@ -227,6 +280,16 @@ export class SessionEventHandler { this.processedPermissions.clear() } + /** + * Clean up event listeners. + */ + cleanup(): void { + if (this.extensionMessageListener) { + this.extensionHost.off("extensionWebviewMessage", this.extensionMessageListener) + this.extensionMessageListener = null + } + } + // =========================================================================== // Message Handling // =========================================================================== @@ -417,6 +480,63 @@ export class SessionEventHandler { this.taskCompletedCallback(event.success) } } + + // =========================================================================== + // Extension Message Handling (Modes, State) + // =========================================================================== + + /** + * Handle extension messages for mode and state updates. + */ + private handleExtensionMessage(msg: ExtensionMessage): void { + // Handle "modes" message - list of available modes + if (msg.type === "modes" && msg.modes) { + this.logger.debug("SessionEventHandler", `Received modes: ${msg.modes.length} modes`) + this.availableModes = msg.modes.map((m) => ({ + id: m.slug, + name: m.name, + description: undefined, + })) + } + + // Handle "state" message - includes current mode + if (msg.type === "state" && msg.state) { + const state = msg.state as ExtensionState + if (state.mode && state.mode !== this.currentModeId) { + const previousMode = this.currentModeId + this.currentModeId = state.mode + this.logger.info("SessionEventHandler", `Mode changed: ${previousMode} -> ${this.currentModeId}`) + + // Send mode update notification + this.sendUpdate({ + sessionUpdate: "current_mode_update", + currentModeId: this.currentModeId, + }) + + // Notify callback + if (this.modeChangedCallback) { + this.modeChangedCallback(this.currentModeId, this.availableModes) + } + } + + // Update available modes from customModes + if (state.customModes && Array.isArray(state.customModes)) { + this.updateAvailableModesFromConfig(state.customModes as ModeConfig[]) + } + } + } + + /** + * Update available modes from ModeConfig array. + */ + private updateAvailableModesFromConfig(modes: ModeConfig[]): void { + this.availableModes = modes.map((m) => ({ + id: m.slug, + name: m.name, + description: undefined, + })) + this.logger.debug("SessionEventHandler", `Updated available modes: ${this.availableModes.length} modes`) + } } // ============================================================================= diff --git a/apps/cli/src/acp/session.ts b/apps/cli/src/acp/session.ts index f10e7588728..1d4cf787e59 100644 --- a/apps/cli/src/acp/session.ts +++ b/apps/cli/src/acp/session.ts @@ -10,11 +10,13 @@ import { type ClientCapabilities, type PromptRequest, type PromptResponse, + type SessionModeState, AgentSideConnection, } from "@agentclientprotocol/sdk" import { type ExtensionHostOptions, ExtensionHost } from "@/agent/extension-host.js" +import { DEFAULT_MODELS } from "./types.js" import { extractPromptText, extractPromptImages } from "./translator.js" import { acpLog } from "./logger.js" import { DeltaTracker } from "./delta-tracker.js" @@ -86,11 +88,15 @@ export class AcpSession implements IAcpSession { /** Workspace path for resolving relative file paths */ private readonly workspacePath: string + /** Current model ID */ + private currentModelId: string = DEFAULT_MODELS[0]!.modelId + private constructor( private readonly sessionId: string, private readonly extensionHost: ExtensionHost, private readonly connection: AgentSideConnection, workspacePath: string, + initialMode: string, deps: AcpSessionDependencies = {}, ) { this.workspacePath = workspacePath @@ -132,9 +138,11 @@ export class AcpSession implements IAcpSession { logger: this.logger, }) + // Create event handler with extension host for mode tracking this.eventHandler = createSessionEventHandler({ logger: this.logger, client: extensionHost.client, + extensionHost, promptState: this.promptState, deltaTracker: this.deltaTracker, commandStreamManager: this.commandStreamManager, @@ -146,6 +154,7 @@ export class AcpSession implements IAcpSession { sendToExtension: (message) => this.extensionHost.sendToExtension(message as Parameters[0]), workspacePath, + initialModeId: initialMode, }) this.eventHandler.onTaskCompleted((success) => this.handleTaskCompleted(success)) @@ -199,7 +208,7 @@ export class AcpSession implements IAcpSession { await extensionHost.activate() logger.info("Session", `ExtensionHost activated for session ${sessionId}`) - const session = new AcpSession(sessionId, extensionHost, connection, cwd, deps) + const session = new AcpSession(sessionId, extensionHost, connection, cwd, options.mode, deps) session.setupEventHandlers() return session @@ -211,6 +220,7 @@ export class AcpSession implements IAcpSession { /** * Set up event handlers to translate ExtensionClient events to ACP updates. + * This includes both ExtensionClient events and ExtensionHost events (modes, state). */ private setupEventHandlers(): void { this.eventHandler.setupEventHandlers() @@ -285,13 +295,54 @@ export class AcpSession implements IAcpSession { } /** - * Set the session mode. + * Set the session mode (Roo Code operational mode like 'code', 'architect'). + * The mode change is tracked by the event handler which listens to extension state updates. */ setMode(mode: string): void { this.logger.info("Session", `Setting mode to: ${mode}`) this.extensionHost.sendToExtension({ type: "updateSettings", updatedSettings: { mode } }) } + /** + * Set the current model. + * This updates the provider settings to use the specified model. + */ + setModel(modelId: string): void { + this.logger.info("Session", `Setting model to: ${modelId}`) + this.currentModelId = modelId + + // Map model ID to extension settings + // The property is apiModelId for most providers + this.extensionHost.sendToExtension({ + type: "updateSettings", + updatedSettings: { apiModelId: modelId }, + }) + } + + /** + * Get the current mode state (delegated to event handler). + */ + getModeState(): SessionModeState { + return { + currentModeId: this.eventHandler.getCurrentModeId(), + availableModes: this.eventHandler.getAvailableModes(), + } + } + + /** + * Get the current mode ID (delegated to event handler). + */ + getCurrentModeId(): string { + return this.eventHandler.getCurrentModeId() + } + + /** + * Get the current model ID. + */ + getCurrentModelId(): string { + return this.currentModelId + } + /** * Dispose of the session and release resources. */ @@ -299,6 +350,9 @@ export class AcpSession implements IAcpSession { this.logger.info("Session", `Disposing session ${this.sessionId}`) this.cancel() + // Clean up event handler listeners + this.eventHandler.cleanup() + // Flush any remaining buffered updates. await this.updateBuffer.flush() await this.extensionHost.dispose() diff --git a/apps/cli/src/acp/types.ts b/apps/cli/src/acp/types.ts new file mode 100644 index 00000000000..cc0789726e3 --- /dev/null +++ b/apps/cli/src/acp/types.ts @@ -0,0 +1,73 @@ +/** + * ACP Types for Mode and Model Pickers + * + * Extends the standard ACP types with model support for the Roo Code agent. + */ + +import type * as acp from "@agentclientprotocol/sdk" + +// ============================================================================= +// Model Types +// ============================================================================= + +/** + * Represents an available model in the ACP interface. + */ +export interface AcpModel { + /** Unique identifier for the model */ + modelId: string + /** Human-readable name */ + name: string + /** Optional description with details like pricing */ + description?: string +} + +/** + * State of available models and current selection. + */ +export interface AcpModelState { + /** List of available models */ + availableModels: AcpModel[] + /** Currently selected model ID */ + currentModelId: string +} + +// ============================================================================= +// Extended Response Types +// ============================================================================= + +/** + * Extended NewSessionResponse that includes model state. + * The standard ACP NewSessionResponse only includes sessionId and optional modes. + * We extend it with models for our implementation. + */ +export interface ExtendedNewSessionResponse extends acp.NewSessionResponse { + /** Model state for the session */ + models?: AcpModelState +} + +// ============================================================================= +// Default Constants +// ============================================================================= + +/** + * Default models available when API is not accessible. + * These map to Roo Code Cloud model tiers. + */ +export const DEFAULT_MODELS: AcpModel[] = [ + { + modelId: "anthropic/claude-sonnet-4.5", + name: "Claude Sonnet 4.5", + description: "Best balance of speed and capability", + }, + { + modelId: "anthropic/claude-opus-4.5", + name: "Claude Opus 4.5", + description: "Most capable for complex work", + }, + { + modelId: "anthropic/claude-haiku-4.5", + name: "Claude Haiku 4.5", + description: "Fastest for quick answers", + }, +] diff --git a/apps/cli/src/commands/cli/run.ts b/apps/cli/src/commands/cli/run.ts index 5b305ce2751..04fe5d00454 100644 --- a/apps/cli/src/commands/cli/run.ts +++ b/apps/cli/src/commands/cli/run.ts @@ -168,7 +168,7 @@ export async function run(workspaceArg: string, options: FlagOptions) { console.log(ASCII_ROO) console.log() console.log( - `[roo] Running ${options.model || "default"} (${options.reasoningEffort || "default"}) on ${provider} in ${options.mode || "default"} mode in ${workspacePath}`, + `[roo] Running ${options.model || DEFAULT_FLAGS.model} (${options.reasoningEffort || "default"}) on ${provider} in ${options.mode || "default"} mode in ${workspacePath}`, ) const host = new ExtensionHost({ diff --git a/apps/cli/src/lib/utils/provider.ts b/apps/cli/src/lib/utils/provider.ts index 64aec430c1b..3edef87d947 100644 --- a/apps/cli/src/lib/utils/provider.ts +++ b/apps/cli/src/lib/utils/provider.ts @@ -2,7 +2,7 @@ import { RooCodeSettings } from "@roo-code/types" import type { SupportedProvider } from "@/types/index.js" -const envVarMap: Record = { +export const envVarMap: Record = { anthropic: "ANTHROPIC_API_KEY", "openai-native": "OPENAI_API_KEY", gemini: "GOOGLE_API_KEY", From 7492abb9d05e2807b5abbef6e77b73bb7c6e57b5 Mon Sep 17 00:00:00 2001 From: cte Date: Sun, 11 Jan 2026 16:29:13 -0800 Subject: [PATCH 09/17] Fix tests --- apps/cli/src/acp/__tests__/agent.test.ts | 22 ++-------------------- apps/cli/src/acp/agent.ts | 6 +++++- 2 files changed, 7 insertions(+), 21 deletions(-) diff --git a/apps/cli/src/acp/__tests__/agent.test.ts b/apps/cli/src/acp/__tests__/agent.test.ts index 3fb2dc4736d..a682a261b4e 100644 --- a/apps/cli/src/acp/__tests__/agent.test.ts +++ b/apps/cli/src/acp/__tests__/agent.test.ts @@ -72,11 +72,10 @@ describe("RooCodeAgent", () => { }) expect(result.authMethods).toBeDefined() - expect(result.authMethods).toHaveLength(2) + expect(result.authMethods).toHaveLength(1) const methods = result.authMethods! - expect(methods[0]!.id).toBe("roo-cloud") - expect(methods[1]!.id).toBe("api-key") + expect(methods[0]!.id).toBe("roo") }) it("should store client capabilities", async () => { @@ -98,15 +97,6 @@ describe("RooCodeAgent", () => { }) 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({ @@ -118,9 +108,6 @@ describe("RooCodeAgent", () => { 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: [], @@ -156,7 +143,6 @@ describe("RooCodeAgent", () => { 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: [], @@ -185,7 +171,6 @@ describe("RooCodeAgent", () => { describe("cancel", () => { it("should cancel session prompt", async () => { // Setup - await agent.authenticate({ methodId: "api-key" }) const { sessionId } = await agent.newSession({ cwd: "/test/workspace", mcpServers: [], @@ -204,7 +189,6 @@ describe("RooCodeAgent", () => { describe("setSessionMode", () => { it("should set session mode", async () => { // Setup - await agent.authenticate({ methodId: "api-key" }) const { sessionId } = await agent.newSession({ cwd: "/test/workspace", mcpServers: [], @@ -222,7 +206,6 @@ describe("RooCodeAgent", () => { it("should throw for invalid mode", async () => { // Setup - await agent.authenticate({ methodId: "api-key" }) const { sessionId } = await agent.newSession({ cwd: "/test/workspace", mcpServers: [], @@ -250,7 +233,6 @@ describe("RooCodeAgent", () => { 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: [] }) diff --git a/apps/cli/src/acp/agent.ts b/apps/cli/src/acp/agent.ts index 72860e95bec..a0154cb3400 100644 --- a/apps/cli/src/acp/agent.ts +++ b/apps/cli/src/acp/agent.ts @@ -134,7 +134,11 @@ export class RooCodeAgent implements acp.Agent { /** * Authenticate with Roo Code Cloud. */ - async authenticate(_params: acp.AuthenticateRequest): Promise { + async authenticate(params: acp.AuthenticateRequest): Promise { + if (params.methodId !== "roo") { + throw acp.RequestError.invalidParams(undefined, `Invalid auth method: ${params.methodId}`) + } + const result = await login({ verbose: false }) if (!result.success) { From 4ac8a460dbcd32bfc811ed7b2a141b29d6664d90 Mon Sep 17 00:00:00 2001 From: cte Date: Sun, 11 Jan 2026 18:40:45 -0800 Subject: [PATCH 10/17] Fix ACP task cancellation --- .../src/acp/__tests__/plan-translator.test.ts | 397 ++++++++++++++++++ .../session-plan-integration.test.ts | 397 ++++++++++++++++++ apps/cli/src/acp/__tests__/session.test.ts | 42 +- .../src/acp/__tests__/update-buffer.test.ts | 374 ----------------- apps/cli/src/acp/command-stream.ts | 23 - apps/cli/src/acp/index.ts | 2 - apps/cli/src/acp/interfaces.ts | 32 +- apps/cli/src/acp/prompt-state.ts | 15 +- apps/cli/src/acp/session-event-handler.ts | 71 ++-- apps/cli/src/acp/session.ts | 176 +++++--- apps/cli/src/acp/tool-content-stream.ts | 15 +- apps/cli/src/acp/translator.ts | 13 + apps/cli/src/acp/translator/index.ts | 16 + .../cli/src/acp/translator/plan-translator.ts | 260 ++++++++++++ apps/cli/src/acp/translator/tool-parser.ts | 4 + apps/cli/src/acp/update-buffer.ts | 220 ---------- apps/cli/src/agent/extension-client.ts | 8 + apps/cli/src/agent/extension-host.ts | 47 +++ apps/cli/src/agent/message-processor.ts | 15 - apps/cli/src/agent/test-logger.ts | 115 +++++ 20 files changed, 1458 insertions(+), 784 deletions(-) create mode 100644 apps/cli/src/acp/__tests__/plan-translator.test.ts create mode 100644 apps/cli/src/acp/__tests__/session-plan-integration.test.ts delete mode 100644 apps/cli/src/acp/__tests__/update-buffer.test.ts create mode 100644 apps/cli/src/acp/translator/plan-translator.ts delete mode 100644 apps/cli/src/acp/update-buffer.ts create mode 100644 apps/cli/src/agent/test-logger.ts diff --git a/apps/cli/src/acp/__tests__/plan-translator.test.ts b/apps/cli/src/acp/__tests__/plan-translator.test.ts new file mode 100644 index 00000000000..c5e177347c1 --- /dev/null +++ b/apps/cli/src/acp/__tests__/plan-translator.test.ts @@ -0,0 +1,397 @@ +import type { TodoItem } from "@roo-code/types" + +import { + todoItemToPlanEntry, + todoListToPlanUpdate, + parseTodoListFromMessage, + isTodoListMessage, + extractTodoListFromMessage, + createPlanUpdateFromMessage, + type PriorityConfig, +} from "../translator/plan-translator.js" + +describe("Plan Translator", () => { + // =========================================================================== + // Test Data + // =========================================================================== + + const createTodoItem = ( + content: string, + status: "pending" | "in_progress" | "completed", + id?: string, + ): TodoItem => ({ + id: id ?? `todo-${Date.now()}`, + content, + status, + }) + + // =========================================================================== + // todoItemToPlanEntry + // =========================================================================== + + describe("todoItemToPlanEntry", () => { + it("converts a todo item to a plan entry with default config", () => { + const todo = createTodoItem("Implement feature X", "pending") + const entry = todoItemToPlanEntry(todo) + + expect(entry).toEqual({ + content: "Implement feature X", + priority: "medium", + status: "pending", + }) + }) + + it("assigns high priority to in_progress items by default", () => { + const todo = createTodoItem("Working on feature", "in_progress") + const entry = todoItemToPlanEntry(todo) + + expect(entry.priority).toBe("high") + expect(entry.status).toBe("in_progress") + }) + + it("preserves completed status", () => { + const todo = createTodoItem("Done task", "completed") + const entry = todoItemToPlanEntry(todo) + + expect(entry.status).toBe("completed") + }) + + it("respects custom priority config", () => { + const todo = createTodoItem("Low priority task", "pending") + const config: PriorityConfig = { + defaultPriority: "low", + prioritizeInProgress: false, + prioritizeByOrder: false, + highPriorityCount: 3, + } + const entry = todoItemToPlanEntry(todo, 0, 1, config) + + expect(entry.priority).toBe("low") + }) + + it("uses order-based priority when enabled", () => { + const config: PriorityConfig = { + defaultPriority: "medium", + prioritizeInProgress: false, + prioritizeByOrder: true, + highPriorityCount: 2, + } + + // First 2 items should be high priority + const first = todoItemToPlanEntry(createTodoItem("First", "pending"), 0, 6, config) + const second = todoItemToPlanEntry(createTodoItem("Second", "pending"), 1, 6, config) + expect(first.priority).toBe("high") + expect(second.priority).toBe("high") + + // Items 3-4 (first half) should be medium + const third = todoItemToPlanEntry(createTodoItem("Third", "pending"), 2, 6, config) + expect(third.priority).toBe("medium") + + // Items past the halfway point should be low + const fifth = todoItemToPlanEntry(createTodoItem("Fifth", "pending"), 4, 6, config) + expect(fifth.priority).toBe("low") + }) + + it("prioritizes in_progress over order when both enabled", () => { + const config: PriorityConfig = { + defaultPriority: "low", + prioritizeInProgress: true, + prioritizeByOrder: true, + highPriorityCount: 1, + } + + // Even at the end of the list, in_progress should be high + const inProgress = todoItemToPlanEntry(createTodoItem("In progress", "in_progress"), 5, 6, config) + expect(inProgress.priority).toBe("high") + }) + }) + + // =========================================================================== + // todoListToPlanUpdate + // =========================================================================== + + describe("todoListToPlanUpdate", () => { + it("converts an empty array to a plan with no entries", () => { + const update = todoListToPlanUpdate([]) + + expect(update).toEqual({ + sessionUpdate: "plan", + entries: [], + }) + }) + + it("converts a list of todos to a plan update", () => { + const todos: TodoItem[] = [ + createTodoItem("Task 1", "completed"), + createTodoItem("Task 2", "in_progress"), + createTodoItem("Task 3", "pending"), + ] + const update = todoListToPlanUpdate(todos) + + expect(update.sessionUpdate).toBe("plan") + expect(update.entries).toHaveLength(3) + expect(update.entries[0]).toEqual({ + content: "Task 1", + priority: "medium", + status: "completed", + }) + expect(update.entries[1]).toEqual({ + content: "Task 2", + priority: "high", // in_progress gets high priority + status: "in_progress", + }) + expect(update.entries[2]).toEqual({ + content: "Task 3", + priority: "medium", + status: "pending", + }) + }) + + it("accepts partial config overrides", () => { + const todos = [createTodoItem("Task", "pending")] + const update = todoListToPlanUpdate(todos, { defaultPriority: "high" }) + + expect(update.entries[0]?.priority).toBe("high") + }) + }) + + // =========================================================================== + // parseTodoListFromMessage + // =========================================================================== + + describe("parseTodoListFromMessage", () => { + it("parses valid todo list JSON", () => { + const text = JSON.stringify({ + tool: "updateTodoList", + todos: [ + { id: "1", content: "Task 1", status: "pending" }, + { id: "2", content: "Task 2", status: "completed" }, + ], + }) + + const result = parseTodoListFromMessage(text) + + expect(result).toEqual([ + { id: "1", content: "Task 1", status: "pending" }, + { id: "2", content: "Task 2", status: "completed" }, + ]) + }) + + it("returns null for invalid JSON", () => { + expect(parseTodoListFromMessage("not json")).toBeNull() + expect(parseTodoListFromMessage("{invalid}")).toBeNull() + }) + + it("returns null for JSON without updateTodoList tool", () => { + expect(parseTodoListFromMessage(JSON.stringify({ tool: "other" }))).toBeNull() + expect(parseTodoListFromMessage(JSON.stringify({ todos: [] }))).toBeNull() + }) + + it("returns null for JSON with non-array todos", () => { + expect(parseTodoListFromMessage(JSON.stringify({ tool: "updateTodoList", todos: "not array" }))).toBeNull() + }) + }) + + // =========================================================================== + // isTodoListMessage + // =========================================================================== + + describe("isTodoListMessage", () => { + it("detects tool ask messages with updateTodoList", () => { + const message = { + type: "ask", + ask: "tool", + text: JSON.stringify({ tool: "updateTodoList", todos: [] }), + } + + expect(isTodoListMessage(message)).toBe(true) + }) + + it("detects user_edit_todos say messages", () => { + const message = { + type: "say", + say: "user_edit_todos", + text: JSON.stringify({ tool: "updateTodoList", todos: [] }), + } + + expect(isTodoListMessage(message)).toBe(true) + }) + + it("returns false for other ask types", () => { + const message = { + type: "ask", + ask: "command", + text: JSON.stringify({ tool: "updateTodoList", todos: [] }), + } + + expect(isTodoListMessage(message)).toBe(false) + }) + + it("returns false for other say types", () => { + const message = { + type: "say", + say: "text", + text: JSON.stringify({ tool: "updateTodoList", todos: [] }), + } + + expect(isTodoListMessage(message)).toBe(false) + }) + + it("returns false for messages without text", () => { + const message = { + type: "ask", + ask: "tool", + } + + expect(isTodoListMessage(message)).toBe(false) + }) + + it("returns false for tool messages with other tools", () => { + const message = { + type: "ask", + ask: "tool", + text: JSON.stringify({ tool: "read_file", path: "/some/path" }), + } + + expect(isTodoListMessage(message)).toBe(false) + }) + }) + + // =========================================================================== + // extractTodoListFromMessage + // =========================================================================== + + describe("extractTodoListFromMessage", () => { + it("extracts todos from tool ask message", () => { + const todos = [{ id: "1", content: "Task", status: "pending" }] + const message = { + type: "ask", + ask: "tool", + text: JSON.stringify({ tool: "updateTodoList", todos }), + } + + expect(extractTodoListFromMessage(message)).toEqual(todos) + }) + + it("extracts todos from user_edit_todos say message", () => { + const todos = [{ id: "1", content: "Task", status: "completed" }] + const message = { + type: "say", + say: "user_edit_todos", + text: JSON.stringify({ tool: "updateTodoList", todos }), + } + + expect(extractTodoListFromMessage(message)).toEqual(todos) + }) + + it("returns null for non-todo messages", () => { + expect(extractTodoListFromMessage({ type: "say", say: "text", text: "Hello" })).toBeNull() + }) + + it("returns null for messages without text", () => { + expect(extractTodoListFromMessage({ type: "ask", ask: "tool" })).toBeNull() + }) + }) + + // =========================================================================== + // createPlanUpdateFromMessage + // =========================================================================== + + describe("createPlanUpdateFromMessage", () => { + it("creates plan update from valid todo message", () => { + const todos = [ + { id: "1", content: "First task", status: "in_progress" as const }, + { id: "2", content: "Second task", status: "pending" as const }, + ] + const message = { + type: "ask", + ask: "tool", + text: JSON.stringify({ tool: "updateTodoList", todos }), + } + + const update = createPlanUpdateFromMessage(message) + + expect(update).not.toBeNull() + expect(update?.sessionUpdate).toBe("plan") + expect(update?.entries).toHaveLength(2) + expect(update?.entries[0]).toEqual({ + content: "First task", + priority: "high", // in_progress + status: "in_progress", + }) + }) + + it("returns null for non-todo messages", () => { + const message = { + type: "say", + say: "text", + text: "Just some text", + } + + expect(createPlanUpdateFromMessage(message)).toBeNull() + }) + + it("returns null for empty todo list", () => { + const message = { + type: "ask", + ask: "tool", + text: JSON.stringify({ tool: "updateTodoList", todos: [] }), + } + + expect(createPlanUpdateFromMessage(message)).toBeNull() + }) + + it("accepts custom priority config", () => { + const todos = [{ id: "1", content: "Task", status: "pending" as const }] + const message = { + type: "ask", + ask: "tool", + text: JSON.stringify({ tool: "updateTodoList", todos }), + } + + const update = createPlanUpdateFromMessage(message, { defaultPriority: "low" }) + + expect(update?.entries[0]?.priority).toBe("low") + }) + }) + + // =========================================================================== + // Edge Cases + // =========================================================================== + + describe("edge cases", () => { + it("handles todos with special characters in content", () => { + const todo = createTodoItem('Task with "quotes" and ', "pending") + const entry = todoItemToPlanEntry(todo) + + expect(entry.content).toBe('Task with "quotes" and ') + }) + + it("handles todos with unicode content", () => { + const todo = createTodoItem("Task with emoji 🚀 and unicode ñ", "pending") + const entry = todoItemToPlanEntry(todo) + + expect(entry.content).toBe("Task with emoji 🚀 and unicode ñ") + }) + + it("handles very long content", () => { + const longContent = "A".repeat(10000) + const todo = createTodoItem(longContent, "pending") + const entry = todoItemToPlanEntry(todo) + + expect(entry.content).toBe(longContent) + }) + + it("handles malformed JSON gracefully", () => { + const message = { + type: "ask", + ask: "tool", + text: '{"tool": "updateTodoList", "todos": [{"broken', + } + + expect(isTodoListMessage(message)).toBe(false) + expect(extractTodoListFromMessage(message)).toBeNull() + expect(createPlanUpdateFromMessage(message)).toBeNull() + }) + }) +}) diff --git a/apps/cli/src/acp/__tests__/session-plan-integration.test.ts b/apps/cli/src/acp/__tests__/session-plan-integration.test.ts new file mode 100644 index 00000000000..c9e1f814c2e --- /dev/null +++ b/apps/cli/src/acp/__tests__/session-plan-integration.test.ts @@ -0,0 +1,397 @@ +/** + * Integration tests for ACP Plan updates via session-event-handler. + * + * Tests the end-to-end flow of: + * 1. Extension sending todo list update messages + * 2. Session-event-handler detecting and translating them + * 3. ACP plan updates being sent to the connection + */ + +import type { ClineMessage } from "@roo-code/types" + +import { + SessionEventHandler, + createSessionEventHandler, + type SessionEventHandlerDeps, +} from "../session-event-handler.js" +import type { IAcpLogger, IDeltaTracker, IPromptStateMachine } from "../interfaces.js" +import { ToolHandlerRegistry } from "../tool-handler.js" + +// ============================================================================= +// Mock Setup +// ============================================================================= + +const createMockLogger = (): IAcpLogger => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + request: vi.fn(), + response: vi.fn(), + notification: vi.fn(), +}) + +const createMockDeltaTracker = (): IDeltaTracker => ({ + getDelta: vi.fn().mockReturnValue(null), + peekDelta: vi.fn().mockReturnValue(null), + reset: vi.fn(), + resetId: vi.fn(), +}) + +const createMockPromptState = (): IPromptStateMachine => ({ + getState: vi.fn().mockReturnValue("processing"), + getAbortSignal: vi.fn().mockReturnValue(null), + getPromptText: vi.fn().mockReturnValue(""), + canStartPrompt: vi.fn().mockReturnValue(false), + isProcessing: vi.fn().mockReturnValue(true), // Return true so messages are processed + startPrompt: vi.fn().mockReturnValue(Promise.resolve({ stopReason: "end_turn" })), + complete: vi.fn().mockReturnValue("end_turn"), + transitionToComplete: vi.fn(), + cancel: vi.fn(), + reset: vi.fn(), +}) + +const createMockCommandStreamManager = () => ({ + handleExecutionOutput: vi.fn(), + handleCommandOutput: vi.fn(), + isCommandOutputMessage: vi.fn().mockReturnValue(false), + trackCommand: vi.fn(), + reset: vi.fn(), +}) + +const createMockToolContentStreamManager = () => ({ + handleToolContentStreaming: vi.fn(), + isToolAskMessage: vi.fn().mockReturnValue(false), + reset: vi.fn(), +}) + +const createMockExtensionClient = () => { + const handlers: Record void)[]> = {} + return { + on: vi.fn((event: string, handler: (data: unknown) => void) => { + handlers[event] = handlers[event] || [] + handlers[event]!.push(handler) + return { on: vi.fn(), off: vi.fn() } + }), + off: vi.fn(), + emit: (event: string, data: unknown) => { + handlers[event]?.forEach((h) => h(data)) + }, + respond: vi.fn(), + approve: vi.fn(), + reject: vi.fn(), + } +} + +const createMockExtensionHost = () => ({ + on: vi.fn(), + off: vi.fn(), + client: createMockExtensionClient(), + activate: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn().mockResolvedValue(undefined), + sendToExtension: vi.fn(), +}) + +// ============================================================================= +// Tests +// ============================================================================= + +describe("Session Plan Integration", () => { + let eventHandler: SessionEventHandler + let mockSendUpdate: ReturnType + let mockClient: ReturnType + let deps: SessionEventHandlerDeps + + beforeEach(() => { + mockSendUpdate = vi.fn() + mockClient = createMockExtensionClient() + + deps = { + logger: createMockLogger(), + client: mockClient, + extensionHost: createMockExtensionHost(), + promptState: createMockPromptState(), + deltaTracker: createMockDeltaTracker(), + commandStreamManager: createMockCommandStreamManager(), + toolContentStreamManager: createMockToolContentStreamManager(), + toolHandlerRegistry: new ToolHandlerRegistry(), + sendUpdate: mockSendUpdate, + approveAction: vi.fn(), + respondWithText: vi.fn(), + sendToExtension: vi.fn(), + workspacePath: "/test/workspace", + initialModeId: "code", + isCancelling: vi.fn().mockReturnValue(false), + } + + eventHandler = createSessionEventHandler(deps) + eventHandler.setupEventHandlers() + }) + + describe("todo list message detection", () => { + it("detects and sends plan update for updateTodoList tool ask message", () => { + const todoMessage: ClineMessage = { + type: "ask", + ask: "tool", + ts: Date.now(), + text: JSON.stringify({ + tool: "updateTodoList", + todos: [ + { id: "1", content: "First task", status: "completed" }, + { id: "2", content: "Second task", status: "in_progress" }, + { id: "3", content: "Third task", status: "pending" }, + ], + }), + } + + // Emit the message through the mock client + mockClient.emit("message", todoMessage) + + // Verify plan update was sent + expect(mockSendUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + sessionUpdate: "plan", + entries: expect.arrayContaining([ + expect.objectContaining({ + content: "First task", + status: "completed", + priority: expect.any(String), + }), + expect.objectContaining({ + content: "Second task", + status: "in_progress", + priority: "high", // in_progress gets high priority + }), + expect.objectContaining({ + content: "Third task", + status: "pending", + priority: expect.any(String), + }), + ]), + }), + ) + }) + + it("detects and sends plan update for user_edit_todos say message", () => { + const editMessage: ClineMessage = { + type: "say", + say: "user_edit_todos", + ts: Date.now(), + text: JSON.stringify({ + tool: "updateTodoList", + todos: [{ id: "1", content: "Edited task", status: "completed" }], + }), + } + + mockClient.emit("message", editMessage) + + expect(mockSendUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + sessionUpdate: "plan", + entries: [ + expect.objectContaining({ + content: "Edited task", + status: "completed", + }), + ], + }), + ) + }) + + it("does not send plan update for other tool ask messages", () => { + const otherToolMessage: ClineMessage = { + type: "ask", + ask: "tool", + ts: Date.now(), + text: JSON.stringify({ + tool: "read_file", + path: "/some/file.txt", + }), + } + + mockClient.emit("message", otherToolMessage) + + // Should not have sent a plan update (but may send other updates) + const planUpdateCalls = mockSendUpdate.mock.calls.filter( + (call) => (call[0] as { sessionUpdate?: string })?.sessionUpdate === "plan", + ) + expect(planUpdateCalls).toHaveLength(0) + }) + + it("does not send plan update for empty todo list", () => { + const emptyTodoMessage: ClineMessage = { + type: "ask", + ask: "tool", + ts: Date.now(), + text: JSON.stringify({ + tool: "updateTodoList", + todos: [], + }), + } + + mockClient.emit("message", emptyTodoMessage) + + // Should not have sent a plan update for empty list + const planUpdateCalls = mockSendUpdate.mock.calls.filter( + (call) => (call[0] as { sessionUpdate?: string })?.sessionUpdate === "plan", + ) + expect(planUpdateCalls).toHaveLength(0) + }) + }) + + describe("priority assignment", () => { + it("assigns high priority to in_progress items", () => { + const todoMessage: ClineMessage = { + type: "ask", + ask: "tool", + ts: Date.now(), + text: JSON.stringify({ + tool: "updateTodoList", + todos: [ + { id: "1", content: "Pending task", status: "pending" }, + { id: "2", content: "In progress task", status: "in_progress" }, + { id: "3", content: "Completed task", status: "completed" }, + ], + }), + } + + mockClient.emit("message", todoMessage) + + const planUpdateCall = mockSendUpdate.mock.calls.find( + (call) => (call[0] as { sessionUpdate?: string })?.sessionUpdate === "plan", + ) + expect(planUpdateCall).toBeDefined() + + const entries = (planUpdateCall![0] as { entries: Array<{ content: string; priority: string }> }).entries + const inProgressEntry = entries.find((e) => e.content === "In progress task") + + expect(inProgressEntry?.priority).toBe("high") + }) + + it("assigns medium priority to pending and completed items by default", () => { + const todoMessage: ClineMessage = { + type: "ask", + ask: "tool", + ts: Date.now(), + text: JSON.stringify({ + tool: "updateTodoList", + todos: [ + { id: "1", content: "Pending task", status: "pending" }, + { id: "2", content: "Completed task", status: "completed" }, + ], + }), + } + + mockClient.emit("message", todoMessage) + + const planUpdateCall = mockSendUpdate.mock.calls.find( + (call) => (call[0] as { sessionUpdate?: string })?.sessionUpdate === "plan", + ) + expect(planUpdateCall).toBeDefined() + + const entries = (planUpdateCall![0] as { entries: Array<{ content: string; priority: string }> }).entries + const pendingEntry = entries.find((e) => e.content === "Pending task") + const completedEntry = entries.find((e) => e.content === "Completed task") + + expect(pendingEntry?.priority).toBe("medium") + expect(completedEntry?.priority).toBe("medium") + }) + }) + + describe("message updates (streaming)", () => { + it("sends plan update when message is updated", () => { + const todoMessage: ClineMessage = { + type: "ask", + ask: "tool", + ts: Date.now(), + text: JSON.stringify({ + tool: "updateTodoList", + todos: [{ id: "1", content: "Initial task", status: "pending" }], + }), + } + + // First message + mockClient.emit("message", todoMessage) + + // Updated message with more todos + const updatedMessage: ClineMessage = { + ...todoMessage, + text: JSON.stringify({ + tool: "updateTodoList", + todos: [ + { id: "1", content: "Initial task", status: "completed" }, + { id: "2", content: "New task", status: "pending" }, + ], + }), + } + + mockClient.emit("messageUpdated", updatedMessage) + + // Should have sent 2 plan updates (one for each message) + const planUpdateCalls = mockSendUpdate.mock.calls.filter( + (call) => (call[0] as { sessionUpdate?: string })?.sessionUpdate === "plan", + ) + expect(planUpdateCalls.length).toBeGreaterThanOrEqual(2) + }) + }) + + describe("logging", () => { + it("sends plan updates without verbose logging", () => { + const todoMessage: ClineMessage = { + type: "ask", + ask: "tool", + ts: Date.now(), + text: JSON.stringify({ + tool: "updateTodoList", + todos: [{ id: "1", content: "Test task", status: "pending" }], + }), + } + + mockClient.emit("message", todoMessage) + + // Plan update should be sent without verbose logging + const planUpdateCalls = mockSendUpdate.mock.calls.filter( + (call) => (call[0] as { sessionUpdate?: string })?.sessionUpdate === "plan", + ) + expect(planUpdateCalls).toHaveLength(1) + }) + }) + + describe("reset behavior", () => { + it("continues to detect plan updates after reset", () => { + const todoMessage: ClineMessage = { + type: "ask", + ask: "tool", + ts: Date.now(), + text: JSON.stringify({ + tool: "updateTodoList", + todos: [{ id: "1", content: "Task 1", status: "pending" }], + }), + } + + mockClient.emit("message", todoMessage) + mockSendUpdate.mockClear() + + // Reset the event handler + eventHandler.reset() + + // Send another todo message + const anotherMessage: ClineMessage = { + ...todoMessage, + ts: Date.now() + 1, + text: JSON.stringify({ + tool: "updateTodoList", + todos: [{ id: "2", content: "Task 2", status: "pending" }], + }), + } + + mockClient.emit("message", anotherMessage) + + // Should still detect and send plan update + const planUpdateCalls = mockSendUpdate.mock.calls.filter( + (call) => (call[0] as { sessionUpdate?: string })?.sessionUpdate === "plan", + ) + expect(planUpdateCalls).toHaveLength(1) + }) + }) +}) diff --git a/apps/cli/src/acp/__tests__/session.test.ts b/apps/cli/src/acp/__tests__/session.test.ts index 2d5d2802a07..21586853a07 100644 --- a/apps/cli/src/acp/__tests__/session.test.ts +++ b/apps/cli/src/acp/__tests__/session.test.ts @@ -1,12 +1,28 @@ import type * as acp from "@agentclientprotocol/sdk" +import { AgentLoopState } from "@/agent/agent-state.js" + +// Track registered event handlers for simulation +type EventHandler = (data: unknown) => void +const clientEventHandlers: Map = new Map() vi.mock("@/agent/extension-host.js", () => { const mockClient = { - on: vi.fn().mockReturnThis(), + on: vi.fn().mockImplementation((event: string, handler: EventHandler) => { + const handlers = clientEventHandlers.get(event) || [] + handlers.push(handler) + clientEventHandlers.set(event, handlers) + return mockClient + }), off: vi.fn().mockReturnThis(), respond: vi.fn(), approve: vi.fn(), reject: vi.fn(), + getAgentState: vi.fn().mockReturnValue({ + state: AgentLoopState.RUNNING, + isRunning: true, + isStreaming: false, + currentAsk: null, + }), } return { @@ -22,6 +38,19 @@ vi.mock("@/agent/extension-host.js", () => { } }) +/** + * Simulate the extension responding to a cancel by emitting a state change to a terminal state. + */ +function simulateExtensionCancelResponse(): void { + const handlers = clientEventHandlers.get("stateChange") || [] + handlers.forEach((handler) => { + handler({ + previousState: { state: AgentLoopState.RUNNING, isRunning: true, isStreaming: false }, + currentState: { state: AgentLoopState.IDLE, isRunning: false, isStreaming: false }, + }) + }) +} + import { AcpSession, type AcpSessionOptions } from "../session.js" import { ExtensionHost } from "@/agent/extension-host.js" @@ -37,6 +66,9 @@ describe("AcpSession", () => { } beforeEach(() => { + // Clear registered event handlers between tests + clientEventHandlers.clear() + mockConnection = { sessionUpdate: vi.fn().mockResolvedValue(undefined), requestPermission: vi.fn().mockResolvedValue({ @@ -56,6 +88,7 @@ describe("AcpSession", () => { afterEach(() => { vi.clearAllMocks() + clientEventHandlers.clear() }) describe("create", () => { @@ -140,8 +173,9 @@ describe("AcpSession", () => { }), ) - // Cancel to resolve the promise + // Cancel to resolve the promise - simulate extension responding to cancel session.cancel() + simulateExtensionCancelResponse() const result = await promptPromise expect(result.stopReason).toBe("cancelled") }) @@ -174,6 +208,7 @@ describe("AcpSession", () => { ) session.cancel() + simulateExtensionCancelResponse() await promptPromise }) }) @@ -196,8 +231,9 @@ describe("AcpSession", () => { prompt: [{ type: "text", text: "Hello" }], }) - // Cancel + // Cancel and simulate extension responding session.cancel() + simulateExtensionCancelResponse() expect(mockHostInstance.sendToExtension).toHaveBeenCalledWith({ type: "cancelTask" }) diff --git a/apps/cli/src/acp/__tests__/update-buffer.test.ts b/apps/cli/src/acp/__tests__/update-buffer.test.ts deleted file mode 100644 index 60e3c7a9344..00000000000 --- a/apps/cli/src/acp/__tests__/update-buffer.test.ts +++ /dev/null @@ -1,374 +0,0 @@ -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 - - 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) - }) - }) -}) diff --git a/apps/cli/src/acp/command-stream.ts b/apps/cli/src/acp/command-stream.ts index 37b4e2e9d21..4bc09f12353 100644 --- a/apps/cli/src/acp/command-stream.ts +++ b/apps/cli/src/acp/command-stream.ts @@ -91,7 +91,6 @@ export class CommandStreamManager { */ trackCommand(toolCallId: string, command: string, ts: number): void { this.pendingCommandCalls.set(toolCallId, { toolCallId, command, ts }) - this.logger.debug("CommandStream", `Tracking command: ${toolCallId}`) } /** @@ -107,11 +106,6 @@ export class CommandStreamManager { const output = message.text || "" const isPartial = message.partial === true - this.logger.debug( - "CommandStream", - `handleCommandOutput: partial=${message.partial}, text length=${output.length}`, - ) - // Skip partial updates - streaming is handled by handleExecutionOutput() if (isPartial) { return @@ -121,12 +115,9 @@ export class CommandStreamManager { const pendingCall = this.findMostRecentPendingCommand() if (pendingCall) { - this.logger.debug("CommandStream", `Command completed: ${pendingCall.toolCallId}`) - // Send closing code fence as agent_message_chunk if we had streaming output const hadStreamingOutput = this.commandCodeFencesSent.has(pendingCall.toolCallId) if (hadStreamingOutput) { - this.logger.debug("CommandStream", "Sending closing code fence via agent_message_chunk") this.sendUpdate({ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "```\n" }, @@ -161,11 +152,6 @@ export class CommandStreamManager { * Uses executionId → toolCallId mapping for robust routing. */ handleExecutionOutput(executionId: string, output: string): void { - this.logger.debug( - "CommandStream", - `handleExecutionOutput: executionId=${executionId}, output length=${output.length}`, - ) - // Find or establish the toolCallId for this executionId let toolCallId = this.executionToToolCallId.get(executionId) @@ -173,12 +159,10 @@ export class CommandStreamManager { // First output for this executionId - establish the mapping const pendingCall = this.findMostRecentPendingCommand() if (!pendingCall) { - this.logger.debug("CommandStream", "No pending command, skipping execution output") return } toolCallId = pendingCall.toolCallId this.executionToToolCallId.set(executionId, toolCallId) - this.logger.debug("CommandStream", `Mapped executionId ${executionId} → toolCallId ${toolCallId}`) } // Use executionId as the message key for delta tracking @@ -191,7 +175,6 @@ export class CommandStreamManager { const isFirstChunk = !this.commandCodeFencesSent.has(toolCallId) if (isFirstChunk) { this.commandCodeFencesSent.add(toolCallId) - this.logger.debug("CommandStream", `Sending opening code fence for toolCallId ${toolCallId}`) this.sendUpdate({ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "```\n" }, @@ -199,7 +182,6 @@ export class CommandStreamManager { } // Send the delta as agent_message_chunk for Zed visibility - this.logger.debug("CommandStream", `Streaming command output via agent_message_chunk: ${delta.length} chars`) this.sendUpdate({ sessionUpdate: "agent_message_chunk", content: { type: "text", text: delta }, @@ -220,14 +202,9 @@ export class CommandStreamManager { reset(): void { // Clear all pending commands - any from previous prompts are now stale // and would cause duplicate completion messages if not cleaned up - const staleCount = this.pendingCommandCalls.size - if (staleCount > 0) { - this.logger.debug("CommandStream", `Clearing ${staleCount} stale pending commands`) - } this.pendingCommandCalls.clear() this.commandCodeFencesSent.clear() this.executionToToolCallId.clear() - this.logger.debug("CommandStream", "Reset command stream state") } /** diff --git a/apps/cli/src/acp/index.ts b/apps/cli/src/acp/index.ts index ab0594fcd9e..01055866901 100644 --- a/apps/cli/src/acp/index.ts +++ b/apps/cli/src/acp/index.ts @@ -16,7 +16,6 @@ export type { IContentFormatter, IExtensionClient, IExtensionHost, - IUpdateBuffer, IDeltaTracker, IPromptStateMachine, ICommandStreamManager, @@ -34,7 +33,6 @@ export { acpLog } from "./logger.js" // Utilities export { DeltaTracker } from "./delta-tracker.js" -export { UpdateBuffer, type UpdateBufferOptions } from "./update-buffer.js" // Shared utility functions export { diff --git a/apps/cli/src/acp/interfaces.ts b/apps/cli/src/acp/interfaces.ts index d05795b4e8f..33833d8e979 100644 --- a/apps/cli/src/acp/interfaces.ts +++ b/apps/cli/src/acp/interfaces.ts @@ -193,30 +193,6 @@ export interface IExtensionHost { sendToExtension(message: unknown): void } -// ============================================================================= -// Update Buffer Interface -// ============================================================================= - -/** - * Interface for update buffering. - */ -export interface IUpdateBuffer { - /** - * Queue an update for sending. - */ - queueUpdate(update: acp.SessionNotification["update"]): Promise - - /** - * Flush all pending buffered content. - */ - flush(): Promise - - /** - * Reset the buffer state. - */ - reset(): void -} - // ============================================================================= // Delta Tracker Interface // ============================================================================= @@ -304,6 +280,12 @@ export interface IPromptStateMachine { */ complete(success: boolean): acp.StopReason + /** + * Transition to completion with a specific stop reason. + * This allows direct control over the stop reason (e.g., for cancellation). + */ + transitionToComplete(stopReason: acp.StopReason): void + /** * Cancel the current prompt. */ @@ -371,8 +353,6 @@ export interface AcpSessionDependencies { contentFormatter?: IContentFormatter /** Delta tracker factory */ createDeltaTracker?: () => IDeltaTracker - /** Update buffer factory */ - createUpdateBuffer?: (sendUpdate: (update: acp.SessionNotification["update"]) => Promise) => IUpdateBuffer /** Prompt state machine factory */ createPromptStateMachine?: () => IPromptStateMachine } diff --git a/apps/cli/src/acp/prompt-state.ts b/apps/cli/src/acp/prompt-state.ts index 0f2b9a4c093..92016a137cb 100644 --- a/apps/cli/src/acp/prompt-state.ts +++ b/apps/cli/src/acp/prompt-state.ts @@ -127,12 +127,10 @@ export class PromptStateMachine { */ startPrompt(promptText: string): Promise { if (this.state !== "idle") { - this.logger.warn("PromptStateMachine", `Cannot start prompt in state: ${this.state}`) // Cancel existing prompt first this.cancel() } - this.logger.debug("PromptStateMachine", "Transitioning: idle -> processing") this.state = "processing" this.abortController = new AbortController() this.currentPromptText = promptText @@ -143,7 +141,6 @@ export class PromptStateMachine { // Handle abort signal this.abortController?.signal.addEventListener("abort", () => { if (this.state === "processing") { - this.logger.debug("PromptStateMachine", "Abort signal received") this.transitionToComplete("cancelled") } }) @@ -169,11 +166,9 @@ export class PromptStateMachine { */ cancel(): void { if (this.state !== "processing") { - this.logger.debug("PromptStateMachine", `Cancel ignored in state: ${this.state}`) return } - this.logger.debug("PromptStateMachine", "Cancelling prompt") this.abortController?.abort() // Note: The abort handler will call transitionToComplete } @@ -184,8 +179,6 @@ export class PromptStateMachine { * Should be called when starting a new prompt to ensure clean state. */ reset(): void { - this.logger.debug("PromptStateMachine", `Resetting from state: ${this.state}`) - // Clean up any pending resources if (this.abortController) { this.abortController.abort() @@ -198,20 +191,18 @@ export class PromptStateMachine { } // =========================================================================== - // Private Methods + // Public Methods (for direct control) // =========================================================================== /** * Transition to completion and resolve the promise. + * This is public to allow direct control of the stop reason (e.g., for cancellation). */ - private transitionToComplete(stopReason: acp.StopReason): void { + transitionToComplete(stopReason: acp.StopReason): void { if (this.state !== "processing") { - this.logger.debug("PromptStateMachine", `Already completed, ignoring transition with reason: ${stopReason}`) return } - this.logger.debug("PromptStateMachine", `Transitioning: processing -> idle (reason: ${stopReason})`) - this.state = "idle" // Resolve the promise diff --git a/apps/cli/src/acp/session-event-handler.ts b/apps/cli/src/acp/session-event-handler.ts index a838203777f..07f0f9e1e00 100644 --- a/apps/cli/src/acp/session-event-handler.ts +++ b/apps/cli/src/acp/session-event-handler.ts @@ -10,7 +10,13 @@ import type { ClineMessage, ClineAsk, ClineSay, ExtensionMessage, ExtensionState import type { WaitingForInputEvent, TaskCompletedEvent, CommandExecutionOutputEvent } from "@/agent/events.js" -import { translateToAcpUpdate, isPermissionAsk, isCompletionAsk } from "./translator.js" +import { + translateToAcpUpdate, + isPermissionAsk, + isCompletionAsk, + isTodoListMessage, + createPlanUpdateFromMessage, +} from "./translator.js" import { isUserEcho } from "./utils/index.js" import type { IAcpLogger, @@ -122,6 +128,8 @@ export interface SessionEventHandlerDeps { workspacePath: string /** Initial mode ID */ initialModeId: string + /** Callback to check if cancellation is in progress */ + isCancelling: () => boolean } /** @@ -163,6 +171,7 @@ export class SessionEventHandler { private readonly respondWithText: (text: string) => void private readonly sendToExtension: (message: unknown) => void private readonly workspacePath: string + private readonly isCancelling: () => boolean private taskCompletedCallback: TaskCompletedCallback | null = null private modeChangedCallback: ModeChangedCallback | null = null @@ -199,6 +208,7 @@ export class SessionEventHandler { this.sendToExtension = deps.sendToExtension this.workspacePath = deps.workspacePath this.currentModeId = deps.initialModeId + this.isCancelling = deps.isCancelling } // =========================================================================== @@ -301,10 +311,30 @@ export class SessionEventHandler { * which message types should be delta-streamed and how. */ private handleMessage(message: ClineMessage): void { - this.logger.debug( - "SessionEventHandler", - `Message received: type=${message.type}, say=${message.say}, ask=${message.ask}, ts=${message.ts}, partial=${message.partial}`, - ) + // Don't process messages if there's no active prompt + // NOTE: isCancelling guard REMOVED - we now show all content even during cancellation + // so the user can see exactly what was produced before the task paused + if (!this.promptState.isProcessing()) { + return + } + + // === TEST LOGGING: Log messages that arrive during cancellation === + if (this.isCancelling()) { + const msgType = message.type === "say" ? `say:${message.say}` : `ask:${message.ask}` + const partial = message.partial ? "PARTIAL" : "COMPLETE" + this.logger.info("EventHandler", `MSG DURING CANCEL (processing): ${msgType} ${partial} ts=${message.ts}`) + } + + // Handle todo list updates - translate to ACP plan updates + // Detects both tool asks for updateTodoList and user_edit_todos say messages + if (isTodoListMessage(message)) { + const planUpdate = createPlanUpdateFromMessage(message) + if (planUpdate) { + this.sendUpdate(planUpdate) + } + // Don't return - let the message also be processed by other handlers + // (e.g., for permission requests that may follow) + } // Handle streaming for tool ask messages (file creates/edits) // These contain content that grows as the LLM generates it @@ -326,7 +356,6 @@ export class SessionEventHandler { if (config) { // Filter out user message echo if (message.say === "text" && isUserEcho(message.text, this.promptState.getPromptText())) { - this.logger.debug("SessionEventHandler", `Skipping user echo (${message.text.length} chars)`) return } @@ -349,9 +378,6 @@ export class SessionEventHandler { // For non-streaming message types, use the translator const update = translateToAcpUpdate(message) if (update) { - this.logger.notification("sessionUpdate", { - updateKind: (update as { sessionUpdate?: string }).sessionUpdate, - }) this.sendUpdate(update) } } @@ -366,18 +392,24 @@ export class SessionEventHandler { private async handleWaitingForInput(event: WaitingForInputEvent): Promise { const { ask, message } = event const askType = ask as ClineAsk - this.logger.debug("SessionEventHandler", `Waiting for input: ask=${askType}`) + + // Don't auto-approve asks if there's no active prompt or if cancellation is in progress + if (!this.promptState.isProcessing() || this.isCancelling()) { + // === TEST LOGGING: Skipped ask due to cancellation === + if (this.isCancelling()) { + this.logger.info("EventHandler", `ASK SKIPPED (cancelling): ask=${askType} ts=${message.ts}`) + } + return + } // Handle permission-required asks if (isPermissionAsk(askType)) { - this.logger.info("SessionEventHandler", `Permission request: ${askType}`) this.handlePermissionRequest(message, askType) return } // Handle completion asks if (isCompletionAsk(askType)) { - this.logger.debug("SessionEventHandler", "Completion ask - handled by taskCompleted event") // Completion is handled by taskCompleted event return } @@ -386,27 +418,23 @@ export class SessionEventHandler { // In a more sophisticated implementation, these could be surfaced // to the ACP client for user input if (askType === "followup") { - this.logger.debug("SessionEventHandler", "Auto-responding to followup") this.respondWithText("") return } // Handle resume_task - auto-resume if (askType === "resume_task") { - this.logger.debug("SessionEventHandler", "Auto-approving resume_task") this.approveAction() return } // Handle API failures - auto-retry for now if (askType === "api_req_failed") { - this.logger.warn("SessionEventHandler", "API request failed, auto-retrying") this.approveAction() return } // Default: approve and continue - this.logger.debug("SessionEventHandler", `Auto-approving unknown ask type: ${askType}`) this.approveAction() } @@ -429,7 +457,6 @@ export class SessionEventHandler { // Check if we've already processed this permission request if (this.processedPermissions.has(permissionKey)) { - this.logger.debug("SessionEventHandler", `Skipping duplicate permission request: ${ask}`) // Still need to approve the action to unblock the extension this.approveAction() return @@ -444,9 +471,6 @@ export class SessionEventHandler { // Dispatch to the appropriate handler via the registry const result = this.toolHandlerRegistry.handle(context) - this.logger.debug("SessionEventHandler", `Auto-approving tool: ask=${ask}`) - this.logger.debug("SessionEventHandler", `Sending tool_call update`) - // Send the initial in_progress update this.sendUpdate(result.initialUpdate) @@ -458,7 +482,6 @@ export class SessionEventHandler { // Send completion update if available (non-command tools) if (result.completionUpdate) { - this.logger.debug("SessionEventHandler", `Sending tool_call_update (completed)`) this.sendUpdate(result.completionUpdate) } @@ -474,8 +497,6 @@ export class SessionEventHandler { * Handle task completion. */ private handleTaskCompleted(event: TaskCompletedEvent): void { - this.logger.info("SessionEventHandler", `Task completed: success=${event.success}`) - if (this.taskCompletedCallback) { this.taskCompletedCallback(event.success) } @@ -491,7 +512,6 @@ export class SessionEventHandler { private handleExtensionMessage(msg: ExtensionMessage): void { // Handle "modes" message - list of available modes if (msg.type === "modes" && msg.modes) { - this.logger.debug("SessionEventHandler", `Received modes: ${msg.modes.length} modes`) this.availableModes = msg.modes.map((m) => ({ id: m.slug, name: m.name, @@ -503,9 +523,7 @@ export class SessionEventHandler { if (msg.type === "state" && msg.state) { const state = msg.state as ExtensionState if (state.mode && state.mode !== this.currentModeId) { - const previousMode = this.currentModeId this.currentModeId = state.mode - this.logger.info("SessionEventHandler", `Mode changed: ${previousMode} -> ${this.currentModeId}`) // Send mode update notification this.sendUpdate({ @@ -535,7 +553,6 @@ export class SessionEventHandler { name: m.name, description: undefined, })) - this.logger.debug("SessionEventHandler", `Updated available modes: ${this.availableModes.length} modes`) } } diff --git a/apps/cli/src/acp/session.ts b/apps/cli/src/acp/session.ts index 1d4cf787e59..5a30fb19c9f 100644 --- a/apps/cli/src/acp/session.ts +++ b/apps/cli/src/acp/session.ts @@ -15,12 +15,12 @@ import { } from "@agentclientprotocol/sdk" import { type ExtensionHostOptions, ExtensionHost } from "@/agent/extension-host.js" +import { AgentLoopState } from "@/agent/agent-state.js" import { DEFAULT_MODELS } from "./types.js" import { extractPromptText, extractPromptImages } from "./translator.js" import { acpLog } from "./logger.js" import { DeltaTracker } from "./delta-tracker.js" -import { UpdateBuffer } from "./update-buffer.js" import { PromptStateMachine } from "./prompt-state.js" import { ToolHandlerRegistry } from "./tool-handler.js" import { CommandStreamManager } from "./command-stream.js" @@ -30,7 +30,6 @@ import type { IAcpSession, IAcpLogger, IDeltaTracker, - IUpdateBuffer, IPromptStateMachine, AcpSessionDependencies, } from "./interfaces.js" @@ -70,9 +69,6 @@ export class AcpSession implements IAcpSession { /** Delta tracker for streaming content - ensures only new text is sent */ private readonly deltaTracker: IDeltaTracker - /** Update buffer for batching session updates to reduce message frequency */ - private readonly updateBuffer: IUpdateBuffer - /** Tool handler registry for polymorphic tool dispatch */ private readonly toolHandlerRegistry: ToolHandlerRegistry @@ -91,6 +87,9 @@ export class AcpSession implements IAcpSession { /** Current model ID */ private currentModelId: string = DEFAULT_MODELS[0]!.modelId + /** Track if we're in the process of cancelling a task */ + private isCancelling: boolean = false + private constructor( private readonly sessionId: string, private readonly extensionHost: ExtensionHost, @@ -106,23 +105,13 @@ export class AcpSession implements IAcpSession { this.promptState = deps.createPromptStateMachine?.() ?? new PromptStateMachine({ logger: this.logger }) this.deltaTracker = deps.createDeltaTracker?.() ?? new DeltaTracker() - // Initialize update buffer with the actual send function. - // Uses defaults: 200 chars min buffer, 500ms delay. - // Wrap sendUpdateDirect to match the expected Promise signature. - const sendDirectAdapter = async (update: SessionNotification["update"]): Promise => { - await this.sendUpdateDirect(update) - // Result is logged internally; adapter converts to void for interface compatibility. - } - - this.updateBuffer = - deps.createUpdateBuffer?.(sendDirectAdapter) ?? new UpdateBuffer(sendDirectAdapter, { logger: this.logger }) - // Initialize tool handler registry. this.toolHandlerRegistry = new ToolHandlerRegistry() // Create send update callback for stream managers. + // Updates are sent directly to preserve chunk ordering. const sendUpdate = (update: SessionNotification["update"]) => { - void this.sendUpdate(update) + void this.sendUpdateDirect(update) } // Initialize stream managers with injected logger. @@ -155,9 +144,48 @@ export class AcpSession implements IAcpSession { this.extensionHost.sendToExtension(message as Parameters[0]), workspacePath, initialModeId: initialMode, + isCancelling: () => this.isCancelling, }) this.eventHandler.onTaskCompleted((success) => this.handleTaskCompleted(success)) + + // Listen for state changes to log and detect cancellation completion + this.extensionHost.client.on("stateChange", (event) => { + const prev = event.previousState + const curr = event.currentState + + // Only log if something actually changed + const stateChanged = + prev.state !== curr.state || + prev.isRunning !== curr.isRunning || + prev.isStreaming !== curr.isStreaming || + prev.currentAsk !== curr.currentAsk + + if (stateChanged) { + this.logger.info( + "ExtensionClient", + `STATE: ${prev.state} → ${curr.state} (running=${curr.isRunning}, streaming=${curr.isStreaming}, ask=${curr.currentAsk || "none"})`, + ) + } + + // If we're cancelling and the extension transitions to NO_TASK or IDLE, complete the cancellation + // NO_TASK: messages were cleared + // IDLE: task stopped (e.g., completion_result, api_req_failed, or just stopped) + if (this.isCancelling) { + const newState = curr.state + const isTerminalState = + newState === AgentLoopState.NO_TASK || + newState === AgentLoopState.IDLE || + newState === AgentLoopState.RESUMABLE + + // Also check if the agent is no longer running/streaming (it has stopped processing) + const hasStopped = !curr.isRunning && !curr.isStreaming + + if (isTerminalState || hasStopped) { + this.handleCancellationComplete() + } + } + }) } // =========================================================================== @@ -185,9 +213,6 @@ export class AcpSession implements IAcpSession { options: AcpSessionOptions, deps: AcpSessionDependencies = {}, ): Promise { - const logger = deps.logger ?? acpLog - logger.info("Session", `Creating session ${sessionId} in ${cwd}`) - // Create ExtensionHost with ACP-specific configuration. const hostOptions: ExtensionHostOptions = { mode: options.mode, @@ -203,10 +228,8 @@ export class AcpSession implements IAcpSession { ephemeral: true, } - logger.debug("Session", "Creating ExtensionHost", hostOptions) const extensionHost = new ExtensionHost(hostOptions) await extensionHost.activate() - logger.info("Session", `ExtensionHost activated for session ${sessionId}`) const session = new AcpSession(sessionId, extensionHost, connection, cwd, options.mode, deps) session.setupEventHandlers() @@ -231,19 +254,35 @@ export class AcpSession implements IAcpSession { */ private resetForNewPrompt(): void { this.eventHandler.reset() - this.updateBuffer.reset() + this.isCancelling = false } /** * Handle task completion. */ private handleTaskCompleted(success: boolean): void { - // Flush any buffered updates before completing. - void this.updateBuffer.flush().then(() => { - // Complete the prompt using the state machine. - const stopReason = this.promptState.complete(success) - this.logger.debug("Session", `Resolving prompt with stopReason: ${stopReason}`) - }) + // If we're cancelling, override the stop reason to "cancelled" + if (this.isCancelling) { + this.handleCancellationComplete() + } else { + // Normal completion + this.promptState.complete(success) + } + } + + /** + * Handle cancellation completion. + * Called when the extension has finished cancelling (either via taskCompleted or NO_TASK transition). + */ + private handleCancellationComplete(): void { + if (!this.isCancelling) { + return // Already handled + } + + this.isCancelling = false + + // Directly transition to complete with "cancelled" stop reason + this.promptState.transitionToComplete("cancelled") } // =========================================================================== @@ -254,7 +293,31 @@ export class AcpSession implements IAcpSession { * Process a prompt request from the ACP client. */ async prompt(params: PromptRequest): Promise { - this.logger.info("Session", `Processing prompt for session ${this.sessionId}`) + // Extract text and images from prompt. + const text = extractPromptText(params.prompt) + const images = extractPromptImages(params.prompt) + + // Check if we're in a resumable state (paused after cancel). + // If so, resume the existing conversation instead of starting fresh. + const currentState = this.extensionHost.client.getAgentState() + if (currentState.state === AgentLoopState.RESUMABLE && currentState.currentAsk === "resume_task") { + this.logger.info( + "Session", + `RESUME TASK: resuming paused task with user input (was ask=${currentState.currentAsk})`, + ) + + // Reset state for the resumed prompt (but don't cancel - task is already paused) + this.eventHandler.reset() + this.isCancelling = false + + // Start tracking the prompt + const promise = this.promptState.startPrompt(text) + + // Resume the task with the user's message as follow-up + this.extensionHost.client.respond(text, images.length > 0 ? images : undefined) + + return promise + } // Cancel any pending prompt. this.cancel() @@ -262,20 +325,12 @@ export class AcpSession implements IAcpSession { // Reset state for new prompt. this.resetForNewPrompt() - // Extract text and images from prompt. - const text = extractPromptText(params.prompt) - const images = extractPromptImages(params.prompt) - - this.logger.debug("Session", `Prompt text (${text.length} chars), images: ${images.length}`) - // Start the prompt using the state machine. const promise = this.promptState.startPrompt(text) if (images.length > 0) { - this.logger.debug("Session", "Starting task with images") this.extensionHost.sendToExtension({ type: "newTask", text, images }) } else { - this.logger.debug("Session", "Starting task (text only)") this.extensionHost.sendToExtension({ type: "newTask", text }) } @@ -287,10 +342,19 @@ export class AcpSession implements IAcpSession { */ cancel(): void { if (this.promptState.isProcessing()) { - this.logger.info("Session", "Cancelling pending prompt") - this.promptState.cancel() - this.logger.info("Session", "Sending cancelTask to extension") + // === TEST LOGGING: Cancel triggered === + const currentState = this.extensionHost.client.getAgentState() + this.logger.info( + "Session", + `CANCEL TASK: sending cancelTask (state=${currentState.state}, running=${currentState.isRunning}, streaming=${currentState.isStreaming}, ask=${currentState.currentAsk || "none"})`, + ) + + this.isCancelling = true + // Content continues flowing to the client during cancellation so users + // see what the LLM was generating when cancel was triggered. this.extensionHost.sendToExtension({ type: "cancelTask" }) + // We wait for the extension to send a taskCompleted event or transition to NO_TASK + // which will trigger handleTaskCompleted -> promptState.transitionToComplete("cancelled") } } @@ -299,7 +363,6 @@ export class AcpSession implements IAcpSession { * The mode change is tracked by the event handler which listens to extension state updates. */ setMode(mode: string): void { - this.logger.info("Session", `Setting mode to: ${mode}`) this.extensionHost.sendToExtension({ type: "updateSettings", updatedSettings: { mode } }) } @@ -308,7 +371,6 @@ export class AcpSession implements IAcpSession { * This updates the provider settings to use the specified model. */ setModel(modelId: string): void { - this.logger.info("Session", `Setting model to: ${modelId}`) this.currentModelId = modelId // Map model ID to extension settings @@ -347,16 +409,12 @@ export class AcpSession implements IAcpSession { * Dispose of the session and release resources. */ async dispose(): Promise { - this.logger.info("Session", `Disposing session ${this.sessionId}`) this.cancel() // Clean up event handler listeners this.eventHandler.cleanup() - // Flush any remaining buffered updates. - await this.updateBuffer.flush() await this.extensionHost.dispose() - this.logger.info("Session", `Session ${this.sessionId} disposed`) } // =========================================================================== @@ -364,32 +422,14 @@ export class AcpSession implements IAcpSession { // =========================================================================== /** - * Send an update to the ACP client through the buffer. - * Text chunks are batched, other updates are sent immediately. - * - * @returns Result indicating success or failure. - */ - private async sendUpdate(update: SessionNotification["update"]): Promise> { - try { - await this.updateBuffer.queueUpdate(update) - return ok(undefined) - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - this.logger.error("Session", `Failed to queue update: ${errorMessage}`) - return err(`Failed to queue update: ${errorMessage}`) - } - } - - /** - * Send an update directly to the ACP client (bypasses buffer). - * Used by the UpdateBuffer to actually send batched updates. + * Send an update directly to the ACP client. * * @returns Result indicating success or failure with error details. */ private async sendUpdateDirect(update: SessionNotification["update"]): Promise> { try { - // Log the full update being sent to ACP connection - this.logger.debug("Session", `ACP OUT: ${JSON.stringify({ sessionId: this.sessionId, update })}`) + // Log the update being sent to ACP connection (commented out - too noisy) + // this.logger.info("Session", `OUT: ${JSON.stringify(update)}`) await this.connection.sessionUpdate({ sessionId: this.sessionId, update }) return ok(undefined) } catch (error) { diff --git a/apps/cli/src/acp/tool-content-stream.ts b/apps/cli/src/acp/tool-content-stream.ts index b07df91a5b3..c572e7beb52 100644 --- a/apps/cli/src/acp/tool-content-stream.ts +++ b/apps/cli/src/acp/tool-content-stream.ts @@ -95,15 +95,9 @@ export class ToolContentStreamManager { // Only stream content for file write operations (uses tool registry) if (!isFileWriteTool(toolName)) { - this.logger.debug("ToolContentStream", `Skipping content streaming for non-file tool: ${toolName}`) return true // Handled (by skipping) } - this.logger.debug( - "ToolContentStream", - `handleToolContentStreaming: tool=${toolName}, path=${toolPath}, partial=${isPartial}, contentLen=${content.length}`, - ) - // Check if we have valid path and content to start streaming // Path must have a file extension to be considered valid (uses shared utility) const validPath = hasValidFilePath(toolPath) @@ -123,7 +117,6 @@ export class ToolContentStreamManager { */ reset(): void { this.toolContentHeadersSent.clear() - this.logger.debug("ToolContentStream", "Reset tool content stream state") } /** @@ -170,7 +163,6 @@ export class ToolContentStreamManager { // perceived latency during the gap while LLM generates file content. if (hasValidPath && !this.toolContentHeadersSent.has(ts)) { this.toolContentHeadersSent.add(ts) - this.logger.debug("ToolContentStream", `Sending tool content header for ${toolPath}`) this.sendUpdate({ sessionUpdate: "agent_message_chunk", content: { type: "text", text: `\n**Creating ${toolPath}**\n\`\`\`\n` }, @@ -184,7 +176,6 @@ export class ToolContentStreamManager { const delta = this.deltaTracker.getDelta(deltaKey, content) if (delta) { - this.logger.debug("ToolContentStream", `Streaming tool content delta: ${delta.length} chars`) this.sendUpdate({ sessionUpdate: "agent_message_chunk", content: { type: "text", text: delta }, @@ -196,7 +187,7 @@ export class ToolContentStreamManager { /** * Handle a complete (non-partial) tool message. */ - private handleCompleteMessage(ts: number, toolPath: string, content: string): void { + private handleCompleteMessage(ts: number, _toolPath: string, _content: string): void { // Message complete - finish streaming and clean up if (this.toolContentHeadersSent.has(ts)) { // Send closing code fence @@ -209,9 +200,5 @@ export class ToolContentStreamManager { // Note: The actual tool_call notification will be sent via handleWaitingForInput // when the waitingForInput event fires (which happens when partial becomes false) - this.logger.debug( - "ToolContentStream", - `Tool content streaming complete for ${toolPath}: ${content.length} chars`, - ) } } diff --git a/apps/cli/src/acp/translator.ts b/apps/cli/src/acp/translator.ts index aa8d9696b82..dfe30a451a5 100644 --- a/apps/cli/src/acp/translator.ts +++ b/apps/cli/src/acp/translator.ts @@ -9,6 +9,7 @@ * - translator/prompt-extractor.ts: Prompt content extraction * - translator/tool-parser.ts: Tool information parsing * - translator/message-translator.ts: Main message translation + * - translator/plan-translator.ts: TodoItem to ACP PlanEntry translation * * Import from this file or directly from translator/index.ts */ @@ -40,4 +41,16 @@ export { createPermissionOptions, // Backward compatibility mapToolKind, + // Plan translation (TodoItem to ACP PlanEntry) + todoItemToPlanEntry, + todoListToPlanUpdate, + parseTodoListFromMessage, + isTodoListMessage, + extractTodoListFromMessage, + createPlanUpdateFromMessage, + type PlanEntry, + type PlanEntryPriority, + type PlanEntryStatus, + type PlanUpdate, + type PriorityConfig, } from "./translator/index.js" diff --git a/apps/cli/src/acp/translator/index.ts b/apps/cli/src/acp/translator/index.ts index 011f83e620e..1c82eac5e08 100644 --- a/apps/cli/src/acp/translator/index.ts +++ b/apps/cli/src/acp/translator/index.ts @@ -10,6 +10,7 @@ * - prompt-extractor: Prompt content extraction * - tool-parser: Tool information parsing * - message-translator: Main message translation + * - plan-translator: TodoItem to ACP PlanEntry translation */ // Diff parsing @@ -41,3 +42,18 @@ export { // Re-export mapToolKind for backward compatibility // (now uses mapToolToKind from tool-registry internally) export { mapToolToKind as mapToolKind } from "../tool-registry.js" + +// Plan translation (TodoItem to ACP PlanEntry) +export { + todoItemToPlanEntry, + todoListToPlanUpdate, + parseTodoListFromMessage, + isTodoListMessage, + extractTodoListFromMessage, + createPlanUpdateFromMessage, + type PlanEntry, + type PlanEntryPriority, + type PlanEntryStatus, + type PlanUpdate, + type PriorityConfig, +} from "./plan-translator.js" diff --git a/apps/cli/src/acp/translator/plan-translator.ts b/apps/cli/src/acp/translator/plan-translator.ts new file mode 100644 index 00000000000..05e46205910 --- /dev/null +++ b/apps/cli/src/acp/translator/plan-translator.ts @@ -0,0 +1,260 @@ +/** + * Plan Translator + * + * Translates between Roo CLI TodoItem format and ACP PlanEntry format. + * This enables the agent to communicate execution plans to ACP clients + * when using the update_todo_list tool. + * + * @see https://agentclientprotocol.com/protocol/agent-plan + */ + +import type { TodoItem } from "@roo-code/types" + +// ============================================================================= +// Types +// ============================================================================= + +/** + * Priority levels for plan entries. + * Maps to ACP PlanEntryPriority. + */ +export type PlanEntryPriority = "high" | "medium" | "low" + +/** + * Status levels for plan entries. + * Maps to ACP PlanEntryStatus (same as TodoStatus). + */ +export type PlanEntryStatus = "pending" | "in_progress" | "completed" + +/** + * A single entry in the execution plan. + * Represents a task or goal that the agent intends to accomplish. + */ +export interface PlanEntry { + /** Human-readable description of what this task aims to accomplish */ + content: string + /** The relative importance of this task */ + priority: PlanEntryPriority + /** Current execution status of this task */ + status: PlanEntryStatus +} + +/** + * ACP Plan session update payload. + */ +export interface PlanUpdate { + sessionUpdate: "plan" + entries: PlanEntry[] +} + +/** + * Configuration for priority assignment when converting todos to plan entries. + */ +export interface PriorityConfig { + /** Default priority for all items (default: "medium") */ + defaultPriority: PlanEntryPriority + /** Assign high priority to in_progress items (default: true) */ + prioritizeInProgress: boolean + /** Assign higher priority to earlier items in the list (default: false) */ + prioritizeByOrder: boolean + /** Number of top items to mark as high priority when prioritizeByOrder is true */ + highPriorityCount: number +} + +/** + * Default priority configuration. + */ +const DEFAULT_PRIORITY_CONFIG: PriorityConfig = { + defaultPriority: "medium", + prioritizeInProgress: true, + prioritizeByOrder: false, + highPriorityCount: 3, +} + +// ============================================================================= +// Priority Determination +// ============================================================================= + +/** + * Determine the priority of a todo item based on configuration. + * + * @param item - The todo item + * @param index - Position in the list (0-based) + * @param total - Total number of items + * @param config - Priority configuration + * @returns The determined priority + */ +function determinePriority(item: TodoItem, index: number, total: number, config: PriorityConfig): PlanEntryPriority { + // In-progress items get high priority + if (config.prioritizeInProgress && item.status === "in_progress") { + return "high" + } + + // Order-based priority + if (config.prioritizeByOrder && total > 0) { + if (index < config.highPriorityCount) { + return "high" + } + if (index < Math.floor(total / 2)) { + return "medium" + } + return "low" + } + + return config.defaultPriority +} + +// ============================================================================= +// Translation Functions +// ============================================================================= + +/** + * Translate a single TodoItem to a PlanEntry. + * + * @param item - The todo item to translate + * @param index - Position in the list (0-based) + * @param total - Total number of items + * @param config - Priority configuration + * @returns The translated plan entry + */ +export function todoItemToPlanEntry( + item: TodoItem, + index: number = 0, + total: number = 1, + config: PriorityConfig = DEFAULT_PRIORITY_CONFIG, +): PlanEntry { + return { + content: item.content, + priority: determinePriority(item, index, total, config), + status: item.status, + } +} + +/** + * Translate an array of TodoItems to a PlanUpdate. + * + * @param todos - Array of todo items + * @param config - Optional partial priority configuration + * @returns The plan update payload + */ +export function todoListToPlanUpdate(todos: TodoItem[], config?: Partial): PlanUpdate { + const mergedConfig: PriorityConfig = { ...DEFAULT_PRIORITY_CONFIG, ...config } + const total = todos.length + + return { + sessionUpdate: "plan", + entries: todos.map((item, index) => todoItemToPlanEntry(item, index, total, mergedConfig)), + } +} + +// ============================================================================= +// Message Detection and Parsing +// ============================================================================= + +/** + * Parsed todo list message structure. + */ +interface ParsedTodoMessage { + tool: "updateTodoList" + todos: TodoItem[] +} + +/** + * Type guard to check if parsed JSON is a valid todo list message. + */ +function isParsedTodoMessage(obj: unknown): obj is ParsedTodoMessage { + if (!obj || typeof obj !== "object") return false + const record = obj as Record + return record.tool === "updateTodoList" && Array.isArray(record.todos) +} + +/** + * Parse todo list from a tool message text. + * + * @param text - The message text (JSON string) + * @returns Array of TodoItems or null if not a valid todo message + */ +export function parseTodoListFromMessage(text: string): TodoItem[] | null { + try { + const parsed: unknown = JSON.parse(text) + if (isParsedTodoMessage(parsed)) { + return parsed.todos + } + } catch { + // Not valid JSON - ignore + } + return null +} + +/** + * Minimal message interface for detection. + */ +interface MessageLike { + type: string + ask?: string + say?: string + text?: string +} + +/** + * Check if a message contains a todo list update. + * + * Detects two types of messages: + * 1. Tool ask messages with updateTodoList + * 2. user_edit_todos say messages (when user edits the todo list) + * + * @param message - The message to check + * @returns true if message contains a todo list update + */ +export function isTodoListMessage(message: MessageLike): boolean { + // Check for tool ask message with updateTodoList + if (message.type === "ask" && message.ask === "tool" && message.text) { + const todos = parseTodoListFromMessage(message.text) + return todos !== null + } + + // Check for user_edit_todos say message + if (message.type === "say" && message.say === "user_edit_todos" && message.text) { + const todos = parseTodoListFromMessage(message.text) + return todos !== null + } + + return false +} + +/** + * Extract todo list from a message if present. + * + * @param message - The message to extract from + * @returns Array of TodoItems or null if not a todo message + */ +export function extractTodoListFromMessage(message: MessageLike): TodoItem[] | null { + if (!message.text) return null + + if (message.type === "ask" && message.ask === "tool") { + return parseTodoListFromMessage(message.text) + } + + if (message.type === "say" && message.say === "user_edit_todos") { + return parseTodoListFromMessage(message.text) + } + + return null +} + +/** + * Create a plan update from a message if it contains a todo list. + * + * Convenience function that combines detection, extraction, and translation. + * + * @param message - The message to process + * @param config - Optional priority configuration + * @returns PlanUpdate or null if message doesn't contain todos + */ +export function createPlanUpdateFromMessage(message: MessageLike, config?: Partial): PlanUpdate | null { + const todos = extractTodoListFromMessage(message) + if (!todos || todos.length === 0) { + return null + } + return todoListToPlanUpdate(todos, config) +} diff --git a/apps/cli/src/acp/translator/tool-parser.ts b/apps/cli/src/acp/translator/tool-parser.ts index aac517bff98..beff18a5fc7 100644 --- a/apps/cli/src/acp/translator/tool-parser.ts +++ b/apps/cli/src/acp/translator/tool-parser.ts @@ -158,6 +158,10 @@ export function generateToolTitle(toolName: string, filePath?: string): string { // Browser actions browser_action: "Browser action", browserAction: "Browser action", + + // Plan updates + updateTodoList: "Update plan", + update_todo_list: "Update plan", } return toolTitles[toolName] || (fileName ? `${toolName}: ${fileName}` : toolName) diff --git a/apps/cli/src/acp/update-buffer.ts b/apps/cli/src/acp/update-buffer.ts deleted file mode 100644 index 362ccb3e80b..00000000000 --- a/apps/cli/src/acp/update-buffer.ts +++ /dev/null @@ -1,220 +0,0 @@ -/** - * 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 type { IAcpLogger } from "./interfaces.js" -import { NullLogger } from "./interfaces.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 - /** Logger instance (optional, defaults to NullLogger) */ - logger?: IAcpLogger -} - -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 - private readonly logger: IAcpLogger - - /** Buffered text for agent_message_chunk */ - private messageBuffer = "" - /** Buffered text for agent_thought_chunk */ - private thoughtBuffer = "" - /** Timer for delayed flush */ - private flushTimer: ReturnType | null = null - /** Callback to send updates */ - private readonly sendUpdate: (update: SessionUpdate) => Promise - /** Track if we have pending buffered content */ - private hasPendingContent = false - - constructor(sendUpdate: (update: SessionUpdate) => Promise, options: UpdateBufferOptions = {}) { - this.minBufferSize = options.minBufferSize ?? 200 - this.flushDelayMs = options.flushDelayMs ?? 500 - this.logger = options.logger ?? new NullLogger() - 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 { - 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 { - this.clearFlushTimer() - - if (!this.hasPendingContent) { - return - } - - this.logger.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 - this.logger.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) { - this.logger.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 - this.logger.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 - } - } -} diff --git a/apps/cli/src/agent/extension-client.ts b/apps/cli/src/agent/extension-client.ts index c2d77dfdd91..299543aa8b5 100644 --- a/apps/cli/src/agent/extension-client.ts +++ b/apps/cli/src/agent/extension-client.ts @@ -39,6 +39,7 @@ import { type ModeChangedEvent, } from "./events.js" import { AgentLoopState, type AgentStateInfo } from "./agent-state.js" +import { testLog } from "./test-logger.js" // ============================================================================= // Extension Client Configuration @@ -429,6 +430,13 @@ export class ExtensionClient { * Use this to interrupt a task that is currently processing. */ cancelTask(): void { + // === TEST LOGGING: Cancel triggered === + const currentState = this.store.getAgentState() + testLog.info( + "ExtensionClient", + `CANCEL TASK: sending cancelTask (state=${currentState.state}, running=${currentState.isRunning}, streaming=${currentState.isStreaming}, ask=${currentState.currentAsk || "none"})`, + ) + const message: WebviewMessage = { type: "cancelTask", } diff --git a/apps/cli/src/agent/extension-host.ts b/apps/cli/src/agent/extension-host.ts index 07ac2e07859..b85d8f7e765 100644 --- a/apps/cli/src/agent/extension-host.ts +++ b/apps/cli/src/agent/extension-host.ts @@ -37,6 +37,7 @@ import { ExtensionClient } from "./extension-client.js" import { OutputManager } from "./output-manager.js" import { PromptManager } from "./prompt-manager.js" import { AskDispatcher } from "./ask-dispatcher.js" +import { testLog } from "./test-logger.js" // Pre-configured logger for CLI message activity debugging. const cliLogger = new DebugLogger("CLI") @@ -246,8 +247,33 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac * The client emits events, managers handle them. */ private setupClientEventHandlers(): void { + // === TEST LOGGING: State changes (matches ACP session.ts logging) === + this.client.on("stateChange", (event) => { + const prev = event.previousState + const curr = event.currentState + + // Only log if something actually changed + const stateChanged = + prev.state !== curr.state || + prev.isRunning !== curr.isRunning || + prev.isStreaming !== curr.isStreaming || + prev.currentAsk !== curr.currentAsk + + if (stateChanged) { + testLog.info( + "ExtensionClient", + `STATE: ${prev.state} → ${curr.state} (running=${curr.isRunning}, streaming=${curr.isStreaming}, ask=${curr.currentAsk || "none"})`, + ) + } + }) + // Handle new messages - delegate to OutputManager. this.client.on("message", (msg: ClineMessage) => { + // === TEST LOGGING: New messages === + const msgType = msg.type === "say" ? `say:${msg.say}` : `ask:${msg.ask}` + const partial = msg.partial ? "PARTIAL" : "COMPLETE" + testLog.info("ExtensionClient", `MSG NEW: ${msgType} ${partial} ts=${msg.ts}`) + this.logMessageDebug(msg, "new") // DEBUG: Log all incoming messages with timestamp (only when -d flag is set) if (this.options.debug) { @@ -261,6 +287,11 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac // Handle message updates - delegate to OutputManager. this.client.on("messageUpdated", (msg: ClineMessage) => { + // === TEST LOGGING: Message updates === + const msgType = msg.type === "say" ? `say:${msg.say}` : `ask:${msg.ask}` + const partial = msg.partial ? "PARTIAL" : "COMPLETE" + testLog.info("ExtensionClient", `MSG UPDATE: ${msgType} ${partial} ts=${msg.ts}`) + this.logMessageDebug(msg, "updated") // DEBUG: Log all message updates with timestamp (only when -d flag is set) if (this.options.debug) { @@ -274,11 +305,16 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac // Handle waiting for input - delegate to AskDispatcher. this.client.on("waitingForInput", (event: WaitingForInputEvent) => { + // === TEST LOGGING: Waiting for input === + testLog.info("ExtensionClient", `WAITING FOR INPUT: ask=${event.ask}`) this.askDispatcher.handleAsk(event.message) }) // Handle task completion. this.client.on("taskCompleted", (event: TaskCompletedEvent) => { + // === TEST LOGGING: Task completed === + testLog.info("ExtensionClient", `TASK COMPLETED: success=${event.success}`) + // Output completion message via OutputManager. // Note: completion_result is an "ask" type, not a "say" type. if (event.message && event.message.type === "ask" && event.message.ask === "completion_result") { @@ -455,6 +491,17 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac throw new Error("You cannot send messages to the extension before it is ready") } + // === TEST LOGGING: Track outgoing messages to extension (especially cancelTask) === + if (message.type === "cancelTask") { + const currentState = this.client.getAgentState() + testLog.info( + "ExtensionHost", + `SEND TO EXT: cancelTask (state=${currentState.state}, running=${currentState.isRunning}, streaming=${currentState.isStreaming}, ask=${currentState.currentAsk || "none"})`, + ) + } else if (message.type === "askResponse") { + testLog.info("ExtensionHost", `SEND TO EXT: askResponse (response=${message.askResponse})`) + } + this.emit("webviewMessage", message) } diff --git a/apps/cli/src/agent/message-processor.ts b/apps/cli/src/agent/message-processor.ts index d8d7842806e..dce32a93e73 100644 --- a/apps/cli/src/agent/message-processor.ts +++ b/apps/cli/src/agent/message-processor.ts @@ -105,9 +105,6 @@ export class MessageProcessor { * @param message - The raw message from the extension */ processMessage(message: ExtensionMessage): void { - // Debug logging for ALL messages to trace flow (always enabled for debugging) - console.error(`[MessageProcessor-DEBUG] processMessage: type=${message.type}`) - if (this.options.debug) { debugLog("[MessageProcessor] Received message", { type: message.type }) } @@ -251,12 +248,6 @@ export class MessageProcessor { const clineMessage = message.clineMessage - // Debug logging for messageUpdated - const msgType = clineMessage.type === "ask" ? `ask:${clineMessage.ask}` : `say:${clineMessage.say}` - console.error( - `[MessageProcessor-DEBUG] handleMessageUpdated: ${msgType}, ts=${clineMessage.ts}, partial=${clineMessage.partial}, textLen=${clineMessage.text?.length || 0}`, - ) - const previousState = this.store.getAgentState() // Update the message in the store @@ -431,12 +422,6 @@ export class MessageProcessor { // A more sophisticated implementation would track seen message timestamps const lastMessage = messages[messages.length - 1] if (lastMessage) { - // Debug logging for emitted messages - const msgType = lastMessage.type === "ask" ? `ask:${lastMessage.ask}` : `say:${lastMessage.say}` - console.error( - `[MessageProcessor-DEBUG] emitNewMessageEvents (last of ${messages.length}): ${msgType}, ts=${lastMessage.ts}, partial=${lastMessage.partial}, textLen=${lastMessage.text?.length || 0}`, - ) - // DEBUG: Log all emitted ask messages to trace partial handling if (this.options.debug && lastMessage.type === "ask") { debugLog("[MessageProcessor] EMIT message", { diff --git a/apps/cli/src/agent/test-logger.ts b/apps/cli/src/agent/test-logger.ts new file mode 100644 index 00000000000..50a16e4c68a --- /dev/null +++ b/apps/cli/src/agent/test-logger.ts @@ -0,0 +1,115 @@ +/** + * Test Logger for CLI/ACP Cancellation Debugging + * + * This writes logs to ~/.roo/cli-acp-test.log for comparing CLI + * behavior with ACP during cancellation testing. + * + * Format matches ACP logger for easy side-by-side comparison. + */ + +import * as fs from "node:fs" +import * as path from "node:path" +import * as os from "node:os" + +const LOG_DIR = path.join(os.homedir(), ".roo") +const LOG_FILE = path.join(LOG_DIR, "cli-acp-test.log") + +let stream: fs.WriteStream | null = null + +/** + * Ensure log file and directory exist. + */ +function ensureLogFile(): void { + try { + if (!fs.existsSync(LOG_DIR)) { + fs.mkdirSync(LOG_DIR, { recursive: true }) + } + if (!stream) { + stream = fs.createWriteStream(LOG_FILE, { flags: "a" }) + } + } catch { + // Silently fail + } +} + +/** + * Format and write a log entry. + */ +function write(level: string, component: string, message: string, data?: unknown): void { + ensureLogFile() + if (!stream) return + + 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]` + } + } + + stream.write(formatted + "\n") +} + +/** + * Test logger for CLI cancellation debugging. + * + * Usage: + * testLog.info("ExtensionClient", "STATE: idle → running (running=true, streaming=true, ask=none)") + * testLog.info("Session", "CANCEL: triggered") + */ +export const testLog = { + info(component: string, message: string, data?: unknown): void { + write("INFO", component, message, data) + }, + + debug(component: string, message: string, data?: unknown): void { + write("DEBUG", component, message, data) + }, + + warn(component: string, message: string, data?: unknown): void { + write("WARN", component, message, data) + }, + + error(component: string, message: string, data?: unknown): void { + write("ERROR", component, message, data) + }, + + /** + * Clear the log file (call at start of test session). + */ + clear(): void { + try { + if (stream) { + stream.end() + stream = null + } + fs.writeFileSync(LOG_FILE, "") + } catch { + // Silently fail + } + }, + + /** + * Get the log file path. + */ + getLogPath(): string { + return LOG_FILE + }, + + /** + * Close the logger. + */ + close(): void { + if (stream) { + stream.end() + stream = null + } + }, +} + +// Log startup +testLog.info("TestLogger", `CLI test logging initialized. Log file: ${LOG_FILE}`) From 4b4afceeb69c3f749d4da2b402515f355f0f3602 Mon Sep 17 00:00:00 2001 From: cte Date: Sun, 11 Jan 2026 18:45:14 -0800 Subject: [PATCH 11/17] Logging cleanup --- apps/cli/src/acp/session.ts | 7 ------- apps/cli/src/agent/extension-client.ts | 11 +++-------- 2 files changed, 3 insertions(+), 15 deletions(-) diff --git a/apps/cli/src/acp/session.ts b/apps/cli/src/acp/session.ts index 5a30fb19c9f..b5843dd80bc 100644 --- a/apps/cli/src/acp/session.ts +++ b/apps/cli/src/acp/session.ts @@ -342,13 +342,6 @@ export class AcpSession implements IAcpSession { */ cancel(): void { if (this.promptState.isProcessing()) { - // === TEST LOGGING: Cancel triggered === - const currentState = this.extensionHost.client.getAgentState() - this.logger.info( - "Session", - `CANCEL TASK: sending cancelTask (state=${currentState.state}, running=${currentState.isRunning}, streaming=${currentState.isStreaming}, ask=${currentState.currentAsk || "none"})`, - ) - this.isCancelling = true // Content continues flowing to the client during cancellation so users // see what the LLM was generating when cancel was triggered. diff --git a/apps/cli/src/agent/extension-client.ts b/apps/cli/src/agent/extension-client.ts index 299543aa8b5..26185c60ac2 100644 --- a/apps/cli/src/agent/extension-client.ts +++ b/apps/cli/src/agent/extension-client.ts @@ -39,7 +39,6 @@ import { type ModeChangedEvent, } from "./events.js" import { AgentLoopState, type AgentStateInfo } from "./agent-state.js" -import { testLog } from "./test-logger.js" // ============================================================================= // Extension Client Configuration @@ -430,16 +429,10 @@ export class ExtensionClient { * Use this to interrupt a task that is currently processing. */ cancelTask(): void { - // === TEST LOGGING: Cancel triggered === - const currentState = this.store.getAgentState() - testLog.info( - "ExtensionClient", - `CANCEL TASK: sending cancelTask (state=${currentState.state}, running=${currentState.isRunning}, streaming=${currentState.isStreaming}, ask=${currentState.currentAsk || "none"})`, - ) - const message: WebviewMessage = { type: "cancelTask", } + this.sendMessage(message) } @@ -475,6 +468,7 @@ export class ExtensionClient { type: "terminalOperation", terminalOperation: "continue", } + this.sendMessage(message) } @@ -488,6 +482,7 @@ export class ExtensionClient { type: "terminalOperation", terminalOperation: "abort", } + this.sendMessage(message) } From 29e7045b81948f49d8f3a3534d95d9795b381f15 Mon Sep 17 00:00:00 2001 From: cte Date: Sun, 11 Jan 2026 18:47:38 -0800 Subject: [PATCH 12/17] Logging cleanup --- apps/cli/src/agent/extension-host.ts | 30 ++++------------------------ 1 file changed, 4 insertions(+), 26 deletions(-) diff --git a/apps/cli/src/agent/extension-host.ts b/apps/cli/src/agent/extension-host.ts index b85d8f7e765..487314e1364 100644 --- a/apps/cli/src/agent/extension-host.ts +++ b/apps/cli/src/agent/extension-host.ts @@ -269,12 +269,8 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac // Handle new messages - delegate to OutputManager. this.client.on("message", (msg: ClineMessage) => { - // === TEST LOGGING: New messages === - const msgType = msg.type === "say" ? `say:${msg.say}` : `ask:${msg.ask}` - const partial = msg.partial ? "PARTIAL" : "COMPLETE" - testLog.info("ExtensionClient", `MSG NEW: ${msgType} ${partial} ts=${msg.ts}`) - this.logMessageDebug(msg, "new") + // DEBUG: Log all incoming messages with timestamp (only when -d flag is set) if (this.options.debug) { const ts = new Date().toISOString() @@ -282,17 +278,14 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac const partial = msg.partial ? "PARTIAL" : "COMPLETE" process.stdout.write(`\n[DEBUG ${ts}] NEW ${msgType} ${partial} ts=${msg.ts}\n`) } + this.outputManager.outputMessage(msg) }) // Handle message updates - delegate to OutputManager. this.client.on("messageUpdated", (msg: ClineMessage) => { - // === TEST LOGGING: Message updates === - const msgType = msg.type === "say" ? `say:${msg.say}` : `ask:${msg.ask}` - const partial = msg.partial ? "PARTIAL" : "COMPLETE" - testLog.info("ExtensionClient", `MSG UPDATE: ${msgType} ${partial} ts=${msg.ts}`) - this.logMessageDebug(msg, "updated") + // DEBUG: Log all message updates with timestamp (only when -d flag is set) if (this.options.debug) { const ts = new Date().toISOString() @@ -300,21 +293,17 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac const partial = msg.partial ? "PARTIAL" : "COMPLETE" process.stdout.write(`\n[DEBUG ${ts}] UPDATED ${msgType} ${partial} ts=${msg.ts}\n`) } + this.outputManager.outputMessage(msg) }) // Handle waiting for input - delegate to AskDispatcher. this.client.on("waitingForInput", (event: WaitingForInputEvent) => { - // === TEST LOGGING: Waiting for input === - testLog.info("ExtensionClient", `WAITING FOR INPUT: ask=${event.ask}`) this.askDispatcher.handleAsk(event.message) }) // Handle task completion. this.client.on("taskCompleted", (event: TaskCompletedEvent) => { - // === TEST LOGGING: Task completed === - testLog.info("ExtensionClient", `TASK COMPLETED: success=${event.success}`) - // Output completion message via OutputManager. // Note: completion_result is an "ask" type, not a "say" type. if (event.message && event.message.type === "ask" && event.message.ask === "completion_result") { @@ -491,17 +480,6 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac throw new Error("You cannot send messages to the extension before it is ready") } - // === TEST LOGGING: Track outgoing messages to extension (especially cancelTask) === - if (message.type === "cancelTask") { - const currentState = this.client.getAgentState() - testLog.info( - "ExtensionHost", - `SEND TO EXT: cancelTask (state=${currentState.state}, running=${currentState.isRunning}, streaming=${currentState.isStreaming}, ask=${currentState.currentAsk || "none"})`, - ) - } else if (message.type === "askResponse") { - testLog.info("ExtensionHost", `SEND TO EXT: askResponse (response=${message.askResponse})`) - } - this.emit("webviewMessage", message) } From 510cf8fc8ac6e5ae0354ae2f074e6f7ff2912278 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Mon, 12 Jan 2026 02:49:06 +0000 Subject: [PATCH 13/17] fix: change default model to opus and fix model ID mismatch --- apps/cli/README.md | 4 ++-- apps/cli/src/acp/agent.ts | 4 ++-- apps/cli/src/acp/types.ts | 10 +++++----- apps/cli/src/types/constants.ts | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/cli/README.md b/apps/cli/README.md index 76e66573eb1..740fb79ea81 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -157,7 +157,7 @@ Tokens are valid for 90 days. The CLI will prompt you to re-authenticate when yo | `-y, --yes` | Non-interactive mode: auto-approve all actions | `false` | | `-k, --api-key ` | API key for the LLM provider | From env var | | `-p, --provider ` | API provider (anthropic, openai, openrouter, etc.) | `openrouter` | -| `-m, --model ` | Model to use | `anthropic/claude-sonnet-4.5` | +| `-m, --model ` | Model to use | `anthropic/claude-opus-4.5` | | `-M, --mode ` | Mode to start in (code, architect, ask, debug, etc.) | `code` | | `-r, --reasoning-effort ` | Reasoning effort level (unspecified, disabled, none, minimal, low, medium, high, xhigh) | `medium` | | `--ephemeral` | Run without persisting state (uses temporary storage) | `false` | @@ -189,7 +189,7 @@ roo acp [options] | --------------------------- | -------------------------------------------- | ----------------------------- | | `-e, --extension ` | Path to the extension bundle directory | Auto-detected | | `-p, --provider ` | API provider (anthropic, openai, openrouter) | `openrouter` | -| `-m, --model ` | Model to use | `anthropic/claude-sonnet-4.5` | +| `-m, --model ` | Model to use | `anthropic/claude-opus-4.5` | | `-M, --mode ` | Initial mode (code, architect, ask, debug) | `code` | | `-k, --api-key ` | API key for the LLM provider | From env var | diff --git a/apps/cli/src/acp/agent.ts b/apps/cli/src/acp/agent.ts index a0154cb3400..75574199118 100644 --- a/apps/cli/src/acp/agent.ts +++ b/apps/cli/src/acp/agent.ts @@ -14,7 +14,7 @@ import { DEFAULT_FLAGS } from "@/types/constants.js" import { AcpSession, type AcpSessionOptions } from "./session.js" import { acpLog } from "./logger.js" import { ModelService, createModelService } from "./model-service.js" -import { type ExtendedNewSessionResponse, type AcpModelState, DEFAULT_MODELS } from "./types.js" +import { type ExtendedNewSessionResponse, type AcpModelState } from "./types.js" import { envVarMap } from "@/lib/utils/provider.js" // ============================================================================= @@ -223,7 +223,7 @@ export class RooCodeAgent implements acp.Agent { * Get the current model state. */ private async getModelState(): Promise { - const currentModelId = this.options.model || DEFAULT_MODELS[0]!.modelId + const currentModelId = this.options.model || DEFAULT_FLAGS.model return this.modelService.getModelState(currentModelId) } diff --git a/apps/cli/src/acp/types.ts b/apps/cli/src/acp/types.ts index cc0789726e3..a6f03fcdea3 100644 --- a/apps/cli/src/acp/types.ts +++ b/apps/cli/src/acp/types.ts @@ -55,16 +55,16 @@ export interface ExtendedNewSessionResponse extends acp.NewSessionResponse { * These map to Roo Code Cloud model tiers. */ export const DEFAULT_MODELS: AcpModel[] = [ - { - modelId: "anthropic/claude-sonnet-4.5", - name: "Claude Sonnet 4.5", - description: "Best balance of speed and capability", - }, { modelId: "anthropic/claude-opus-4.5", name: "Claude Opus 4.5", description: "Most capable for complex work", }, + { + modelId: "anthropic/claude-sonnet-4.5", + name: "Claude Sonnet 4.5", + description: "Best balance of speed and capability", + }, { modelId: "anthropic/claude-haiku-4.5", name: "Claude Haiku 4.5", diff --git a/apps/cli/src/types/constants.ts b/apps/cli/src/types/constants.ts index f11973bf45c..04abb90ad4e 100644 --- a/apps/cli/src/types/constants.ts +++ b/apps/cli/src/types/constants.ts @@ -3,7 +3,7 @@ import { reasoningEffortsExtended } from "@roo-code/types" export const DEFAULT_FLAGS = { mode: "code", reasoningEffort: "medium" as const, - model: "anthropic/claude-4.5-sonnet", + model: "anthropic/claude-opus-4.5", provider: "openrouter", } From 1991eb65174d8dfafae6bd253b9a4a3498c5cc44 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Mon, 12 Jan 2026 02:48:59 +0000 Subject: [PATCH 14/17] fix: use DEFAULT_FLAGS.model as single source of truth for default model ID --- apps/cli/src/acp/__tests__/model-service.test.ts | 6 +----- apps/cli/src/acp/types.ts | 13 ++++++++----- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/apps/cli/src/acp/__tests__/model-service.test.ts b/apps/cli/src/acp/__tests__/model-service.test.ts index d2faf110ce1..7931b8b5b1a 100644 --- a/apps/cli/src/acp/__tests__/model-service.test.ts +++ b/apps/cli/src/acp/__tests__/model-service.test.ts @@ -134,11 +134,7 @@ describe("ModelService", () => { const result = await service.fetchAvailableModels() // Should include default model first with actual model name - expect(result[0]).toEqual({ - modelId: "anthropic/claude-sonnet-4.5", - name: "Claude Sonnet 4.5", - description: "Best balance of speed and capability", - }) + expect(result[0]).toEqual(DEFAULT_MODELS[0]) // Should include transformed models expect(result.length).toBeGreaterThan(1) diff --git a/apps/cli/src/acp/types.ts b/apps/cli/src/acp/types.ts index a6f03fcdea3..3a79fb64b37 100644 --- a/apps/cli/src/acp/types.ts +++ b/apps/cli/src/acp/types.ts @@ -6,6 +6,8 @@ import type * as acp from "@agentclientprotocol/sdk" +import { DEFAULT_FLAGS } from "@/types/constants.js" + // ============================================================================= // Model Types // ============================================================================= @@ -53,18 +55,19 @@ export interface ExtendedNewSessionResponse extends acp.NewSessionResponse { /** * Default models available when API is not accessible. * These map to Roo Code Cloud model tiers. + * The first model uses DEFAULT_FLAGS.model as the source of truth. */ export const DEFAULT_MODELS: AcpModel[] = [ + { + modelId: DEFAULT_FLAGS.model, + name: "Claude Sonnet 4.5", + description: "Best balance of speed and capability", + }, { modelId: "anthropic/claude-opus-4.5", name: "Claude Opus 4.5", description: "Most capable for complex work", }, - { - modelId: "anthropic/claude-sonnet-4.5", - name: "Claude Sonnet 4.5", - description: "Best balance of speed and capability", - }, { modelId: "anthropic/claude-haiku-4.5", name: "Claude Haiku 4.5", From 1150158d557512d0c36f6b9f919ac5f9fbe230df Mon Sep 17 00:00:00 2001 From: cte Date: Mon, 12 Jan 2026 16:06:14 -0800 Subject: [PATCH 15/17] Fix model switching --- apps/cli/src/acp/__tests__/agent.test.ts | 5 +- .../src/acp/__tests__/model-service.test.ts | 88 ++--- apps/cli/src/acp/__tests__/session.test.ts | 135 +++---- apps/cli/src/acp/agent.ts | 336 +++++++----------- apps/cli/src/acp/command-stream.ts | 33 +- apps/cli/src/acp/index.ts | 186 +--------- apps/cli/src/acp/model-service.ts | 139 ++------ apps/cli/src/acp/session-event-handler.ts | 15 +- apps/cli/src/acp/session.ts | 106 ++---- apps/cli/src/acp/types.ts | 94 ++--- apps/cli/src/commands/acp/index.ts | 22 +- apps/cli/src/types/constants.ts | 2 +- 12 files changed, 384 insertions(+), 777 deletions(-) diff --git a/apps/cli/src/acp/__tests__/agent.test.ts b/apps/cli/src/acp/__tests__/agent.test.ts index a682a261b4e..64d01f59bac 100644 --- a/apps/cli/src/acp/__tests__/agent.test.ts +++ b/apps/cli/src/acp/__tests__/agent.test.ts @@ -1,6 +1,7 @@ import type * as acp from "@agentclientprotocol/sdk" -import { RooCodeAgent, type RooCodeAgentOptions } from "../agent.js" +import { RooCodeAgent } from "../agent.js" +import type { AcpSessionOptions } from "../session.js" vi.mock("@/commands/auth/index.js", () => ({ login: vi.fn().mockResolvedValue({ success: true }), @@ -24,7 +25,7 @@ describe("RooCodeAgent", () => { let agent: RooCodeAgent let mockConnection: acp.AgentSideConnection - const defaultOptions: RooCodeAgentOptions = { + const defaultOptions: AcpSessionOptions = { extensionPath: "/test/extension", provider: "openrouter", apiKey: "test-key", diff --git a/apps/cli/src/acp/__tests__/model-service.test.ts b/apps/cli/src/acp/__tests__/model-service.test.ts index 7931b8b5b1a..ad4f83e28a8 100644 --- a/apps/cli/src/acp/__tests__/model-service.test.ts +++ b/apps/cli/src/acp/__tests__/model-service.test.ts @@ -118,26 +118,65 @@ describe("ModelService", () => { expect(result).toEqual(DEFAULT_MODELS) }) - it("should transform API response to AcpModel format", async () => { + it("should transform API response to AcpModel format using name and description fields", async () => { const service = new ModelService() mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ data: [ - { id: "anthropic/claude-3-sonnet", owned_by: "anthropic" }, - { id: "openai/gpt-4", owned_by: "openai" }, + { + id: "anthropic/claude-3-sonnet", + name: "Claude 3 Sonnet", + description: "A balanced model for most tasks", + owned_by: "anthropic", + }, + { + id: "openai/gpt-4", + name: "GPT-4", + description: "OpenAI's flagship model", + owned_by: "openai", + }, ], }), }) const result = await service.fetchAvailableModels() - // Should include default model first with actual model name - expect(result[0]).toEqual(DEFAULT_MODELS[0]) + // Should include transformed models with name and description from API + expect(result).toHaveLength(2) + expect(result).toContainEqual({ + modelId: "anthropic/claude-3-sonnet", + name: "Claude 3 Sonnet", + description: "A balanced model for most tasks", + }) + expect(result).toContainEqual({ + modelId: "openai/gpt-4", + name: "GPT-4", + description: "OpenAI's flagship model", + }) + }) + + it("should sort models by model ID", async () => { + const service = new ModelService() + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: [ + { id: "openai/gpt-4", name: "GPT-4" }, + { id: "anthropic/claude-3-sonnet", name: "Claude 3 Sonnet" }, + { id: "google/gemini-pro", name: "Gemini Pro" }, + ], + }), + }) + + const result = await service.fetchAvailableModels() - // Should include transformed models - expect(result.length).toBeGreaterThan(1) + // Should be sorted by model ID + expect(result[0]!.modelId).toBe("anthropic/claude-3-sonnet") + expect(result[1]!.modelId).toBe("google/gemini-pro") + expect(result[2]!.modelId).toBe("openai/gpt-4") }) it("should include Authorization header when apiKey is provided", async () => { @@ -161,41 +200,6 @@ describe("ModelService", () => { }) }) - describe("getModelState", () => { - it("should return model state with current model ID", async () => { - const service = new ModelService() - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - data: [{ id: "anthropic/claude-sonnet-4.5" }], - }), - }) - - const state = await service.getModelState("anthropic/claude-sonnet-4.5") - - expect(state).toEqual({ - availableModels: expect.any(Array), - currentModelId: "anthropic/claude-sonnet-4.5", - }) - }) - - it("should fall back to 'default' if current model ID is not in available models", async () => { - const service = new ModelService() - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - data: [{ id: "model-1" }], - }), - }) - - const state = await service.getModelState("non-existent-model") - - expect(state.currentModelId).toBe(DEFAULT_MODELS[0]!.modelId) - }) - }) - describe("clearCache", () => { it("should clear the cached models", async () => { const service = new ModelService() diff --git a/apps/cli/src/acp/__tests__/session.test.ts b/apps/cli/src/acp/__tests__/session.test.ts index 21586853a07..e5655c00cdd 100644 --- a/apps/cli/src/acp/__tests__/session.test.ts +++ b/apps/cli/src/acp/__tests__/session.test.ts @@ -93,20 +93,26 @@ describe("AcpSession", () => { 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, - ) + const session = await AcpSession.create({ + sessionId: "test-session-1", + cwd: "/test/workspace", + connection: mockConnection, + options: defaultOptions, + deps: {}, + }) 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) + await AcpSession.create({ + sessionId: "test-session-2", + cwd: "/test/workspace", + connection: mockConnection, + options: defaultOptions, + deps: {}, + }) expect(ExtensionHost).toHaveBeenCalledWith( expect.objectContaining({ @@ -121,26 +127,25 @@ describe("AcpSession", () => { }) 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, - ) + const session = await AcpSession.create({ + sessionId: "test-session-3", + cwd: "/test/workspace", + connection: mockConnection, + options: defaultOptions, + deps: {}, + }) expect(session).toBeDefined() }) it("should activate the extension host", async () => { - await AcpSession.create("test-session-4", "/test/workspace", mockConnection, undefined, defaultOptions) + await AcpSession.create({ + sessionId: "test-session-4", + cwd: "/test/workspace", + connection: mockConnection, + options: defaultOptions, + deps: {}, + }) const mockHostInstance = vi.mocked(ExtensionHost).mock.results[0]!.value expect(mockHostInstance.activate).toHaveBeenCalled() @@ -149,13 +154,13 @@ describe("AcpSession", () => { 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 session = await AcpSession.create({ + sessionId: "test-session", + cwd: "/test/workspace", + connection: mockConnection, + options: defaultOptions, + deps: {}, + }) const mockHostInstance = vi.mocked(ExtensionHost).mock.results[0]!.value @@ -181,13 +186,13 @@ describe("AcpSession", () => { }) it("should handle image prompts", async () => { - const session = await AcpSession.create( - "test-session", - "/test/workspace", - mockConnection, - undefined, - defaultOptions, - ) + const session = await AcpSession.create({ + sessionId: "test-session", + cwd: "/test/workspace", + connection: mockConnection, + options: defaultOptions, + deps: {}, + }) const mockHostInstance = vi.mocked(ExtensionHost).mock.results[0]!.value @@ -215,13 +220,13 @@ describe("AcpSession", () => { describe("cancel", () => { it("should send cancel message to extension host", async () => { - const session = await AcpSession.create( - "test-session", - "/test/workspace", - mockConnection, - undefined, - defaultOptions, - ) + const session = await AcpSession.create({ + sessionId: "test-session", + cwd: "/test/workspace", + connection: mockConnection, + options: defaultOptions, + deps: {}, + }) const mockHostInstance = vi.mocked(ExtensionHost).mock.results[0]!.value @@ -243,13 +248,13 @@ describe("AcpSession", () => { describe("setMode", () => { it("should update the session mode", async () => { - const session = await AcpSession.create( - "test-session", - "/test/workspace", - mockConnection, - undefined, - defaultOptions, - ) + const session = await AcpSession.create({ + sessionId: "test-session", + cwd: "/test/workspace", + connection: mockConnection, + options: defaultOptions, + deps: {}, + }) const mockHostInstance = vi.mocked(ExtensionHost).mock.results[0]!.value @@ -264,13 +269,13 @@ describe("AcpSession", () => { describe("dispose", () => { it("should dispose the extension host", async () => { - const session = await AcpSession.create( - "test-session", - "/test/workspace", - mockConnection, - undefined, - defaultOptions, - ) + const session = await AcpSession.create({ + sessionId: "test-session", + cwd: "/test/workspace", + connection: mockConnection, + options: defaultOptions, + deps: {}, + }) const mockHostInstance = vi.mocked(ExtensionHost).mock.results[0]!.value @@ -282,13 +287,13 @@ describe("AcpSession", () => { describe("getSessionId", () => { it("should return the session ID", async () => { - const session = await AcpSession.create( - "my-unique-session-id", - "/test/workspace", - mockConnection, - undefined, - defaultOptions, - ) + const session = await AcpSession.create({ + sessionId: "my-unique-session-id", + cwd: "/test/workspace", + connection: mockConnection, + options: defaultOptions, + deps: {}, + }) expect(session.getSessionId()).toBe("my-unique-session-id") }) diff --git a/apps/cli/src/acp/agent.ts b/apps/cli/src/acp/agent.ts index 75574199118..038a74cba40 100644 --- a/apps/cli/src/acp/agent.ts +++ b/apps/cli/src/acp/agent.ts @@ -5,65 +5,39 @@ * This allows ACP clients like Zed to use Roo Code as their AI coding assistant. */ -import * as acp from "@agentclientprotocol/sdk" +import { + type Agent, + type ClientCapabilities, + type CancelNotification, + // Requests + Responses + type InitializeRequest, + type InitializeResponse, + type NewSessionRequest, + type NewSessionResponse, + type SetSessionModeRequest, + type SetSessionModeResponse, + type SetSessionModelRequest, + type SetSessionModelResponse, + type AuthenticateRequest, + type AuthenticateResponse, + type PromptRequest, + type PromptResponse, + // Classes + AgentSideConnection, + RequestError, + // Constants + PROTOCOL_VERSION, +} from "@agentclientprotocol/sdk" import { randomUUID } from "node:crypto" -import { login, status } from "@/commands/auth/index.js" import { DEFAULT_FLAGS } from "@/types/constants.js" +import { envVarMap } from "@/lib/utils/provider.js" +import { login, status } from "@/commands/auth/index.js" -import { AcpSession, type AcpSessionOptions } from "./session.js" +import { AVAILABLE_MODES, DEFAULT_MODELS } from "./types.js" +import { type AcpSessionOptions, AcpSession } from "./session.js" import { acpLog } from "./logger.js" import { ModelService, createModelService } from "./model-service.js" -import { type ExtendedNewSessionResponse, type AcpModelState } from "./types.js" -import { envVarMap } from "@/lib/utils/provider.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 -} - -// ============================================================================= -// 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. @@ -71,42 +45,30 @@ const AVAILABLE_MODES: acp.SessionMode[] = [ * 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 { +export class RooCodeAgent implements Agent { private sessions: Map = new Map() - private clientCapabilities: acp.ClientCapabilities | undefined + private clientCapabilities: ClientCapabilities | undefined private isAuthenticated = false private readonly modelService: ModelService constructor( - private readonly options: RooCodeAgentOptions, - private readonly connection: acp.AgentSideConnection, + private readonly options: AcpSessionOptions, + private readonly connection: AgentSideConnection, ) { - // Initialize model service with optional API key - this.modelService = createModelService({ - apiKey: options.apiKey, - }) + acpLog.info("Agent", `RooCodeAgent constructor: connection=${connection}`) + this.modelService = createModelService({ apiKey: options.apiKey }) } - // =========================================================================== - // Initialization - // =========================================================================== - - /** - * Initialize the agent and exchange capabilities with the client. - */ - async initialize(params: acp.InitializeRequest): Promise { - acpLog.request("initialize", { protocolVersion: params.protocolVersion }) - + async initialize(params: InitializeRequest): Promise { + acpLog.request("initialize", params) 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"}`) + // Check if already authenticated via environment or existing credentials. + const { authenticated } = await status({ verbose: false }) + acpLog.debug("Agent", `Auth status: ${authenticated ? "authenticated" : "not authenticated"}`) - const response: acp.InitializeResponse = { - protocolVersion: acp.PROTOCOL_VERSION, + return { + protocolVersion: PROTOCOL_VERSION, authMethods: [ { id: "roo", @@ -122,119 +84,127 @@ export class RooCodeAgent implements acp.Agent { }, }, } - - acpLog.response("initialize", response) - return response } - // =========================================================================== - // Authentication - // =========================================================================== + async newSession(params: NewSessionRequest): Promise { + acpLog.request("newSession", params) - /** - * Authenticate with Roo Code Cloud. - */ - async authenticate(params: acp.AuthenticateRequest): Promise { - if (params.methodId !== "roo") { - throw acp.RequestError.invalidParams(undefined, `Invalid auth method: ${params.methodId}`) - } - - const result = await login({ verbose: false }) - - if (!result.success) { - throw acp.RequestError.authRequired(undefined, "Failed to authenticate with Roo Code Cloud") - } - - this.isAuthenticated = true - - acpLog.response("authenticate", {}) - return {} - } - - // =========================================================================== - // Session Management - // =========================================================================== - - /** - * Create a new session. - */ - async newSession(params: acp.NewSessionRequest): Promise { - acpLog.request("newSession", { cwd: params.cwd }) - - // Require authentication + // @TODO: Detect other env vars for different provider and choose + // the correct provider or throw. 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() + throw RequestError.authRequired() } + this.isAuthenticated = true } const sessionId = randomUUID() - const initialMode = this.options.mode || "code" - 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 || DEFAULT_FLAGS.model, - mode: initialMode, - } - - acpLog.debug("Agent", "Session options", { - extensionPath: sessionOptions.extensionPath, - provider: sessionOptions.provider, - model: sessionOptions.model, - mode: sessionOptions.mode, - }) + const provider = this.options.provider || "openrouter" + const apiKey = this.options.apiKey || process.env.OPENROUTER_API_KEY + const mode = this.options.mode || AVAILABLE_MODES[0]!.id + const model = this.options.model || DEFAULT_FLAGS.model - const session = await AcpSession.create( + const session = await AcpSession.create({ sessionId, - params.cwd, - this.connection, - this.clientCapabilities, - sessionOptions, - ) + cwd: params.cwd, + connection: this.connection, + options: { + extensionPath: this.options.extensionPath, + provider, + apiKey, + model, + mode, + }, + deps: { + logger: acpLog, + }, + }) this.sessions.set(sessionId, session) - acpLog.info("Agent", `Session created successfully: ${sessionId}`) - // Fetch model state asynchronously (don't block session creation) - const modelState = await this.getModelState() + const availableModels = await this.modelService.fetchAvailableModels() + const modelExists = availableModels.some((m) => m.modelId === model) - // Build response with modes and models - const response: ExtendedNewSessionResponse = { + const response: NewSessionResponse = { sessionId, - modes: { - currentModeId: initialMode, - availableModes: AVAILABLE_MODES, + modes: { currentModeId: mode, availableModes: AVAILABLE_MODES }, + models: { + availableModels, + currentModelId: modelExists ? model : DEFAULT_MODELS[0]!.modelId, }, - models: modelState, } acpLog.response("newSession", response) return response } - /** - * Get the current model state. - */ - private async getModelState(): Promise { - const currentModelId = this.options.model || DEFAULT_FLAGS.model - return this.modelService.getModelState(currentModelId) + async setSessionMode(params: SetSessionModeRequest): Promise { + acpLog.request("setSessionMode", params) + const session = this.sessions.get(params.sessionId) + + if (!session) { + acpLog.error("Agent", `setSessionMode failed: session not found: ${params.sessionId}`) + throw 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 RequestError.invalidParams(undefined, `Unknown mode: ${params.modeId}`) + } + + session.setMode(params.modeId) + acpLog.response("setSessionMode", {}) + return {} + } + + async unstable_setSessionModel?(params: SetSessionModelRequest): Promise { + acpLog.request("setSessionMode", params) + const session = this.sessions.get(params.sessionId) + + if (!session) { + acpLog.error("Agent", `unstable_setSessionModel failed: session not found: ${params.sessionId}`) + throw RequestError.invalidParams(undefined, `Session not found: ${params.sessionId}`) + } + + const availableModels = await this.modelService.fetchAvailableModels() + const modelExists = availableModels.some((m) => m.modelId === params.modelId) + + if (!modelExists) { + acpLog.error("Agent", `unstable_setSessionModel failed: model not found: ${params.modelId}`) + throw RequestError.invalidParams(undefined, `Model not found: ${params.modelId}`) + } + + session.setModel(params.modelId) + acpLog.response("unstable_setSessionModel", {}) + return {} } - // =========================================================================== - // Prompt Handling - // =========================================================================== + async authenticate(params: AuthenticateRequest): Promise { + acpLog.request("authenticate", params) + + if (params.methodId !== "roo") { + throw RequestError.invalidParams(undefined, `Invalid auth method: ${params.methodId}`) + } + + const result = await login({ verbose: false }) + + if (!result.success) { + throw RequestError.authRequired(undefined, "Failed to authenticate with Roo Code Cloud") + } - /** - * Process a prompt request. - */ - async prompt(params: acp.PromptRequest): Promise { + this.isAuthenticated = true + + acpLog.response("authenticate", {}) + return {} + } + + async prompt(params: PromptRequest): Promise { acpLog.request("prompt", { sessionId: params.sessionId, promptLength: params.prompt?.length ?? 0, @@ -243,7 +213,7 @@ export class RooCodeAgent implements acp.Agent { 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}`) + throw RequestError.invalidParams(undefined, `Session not found: ${params.sessionId}`) } const response = await session.prompt(params) @@ -251,14 +221,7 @@ export class RooCodeAgent implements acp.Agent { return response } - // =========================================================================== - // Session Control - // =========================================================================== - - /** - * Cancel an ongoing prompt. - */ - async cancel(params: acp.CancelNotification): Promise { + async cancel(params: CancelNotification): Promise { acpLog.request("cancel", { sessionId: params.sessionId }) const session = this.sessions.get(params.sessionId) @@ -270,37 +233,6 @@ export class RooCodeAgent implements acp.Agent { } } - /** - * Set the session mode. - */ - async setSessionMode(params: acp.SetSessionModeRequest): Promise { - 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 { acpLog.info("Agent", `Disposing ${this.sessions.size} sessions`) const disposals = Array.from(this.sessions.values()).map((session) => session.dispose()) diff --git a/apps/cli/src/acp/command-stream.ts b/apps/cli/src/acp/command-stream.ts index 4bc09f12353..daff9c2c610 100644 --- a/apps/cli/src/acp/command-stream.ts +++ b/apps/cli/src/acp/command-stream.ts @@ -3,8 +3,6 @@ * * Manages streaming of command execution output with code fence wrapping. * Handles both live command execution events and final command_output messages. - * - * Extracted from session.ts to separate the command output streaming concern. */ import type { ClineMessage } from "@roo-code/types" @@ -106,33 +104,36 @@ export class CommandStreamManager { const output = message.text || "" const isPartial = message.partial === true - // Skip partial updates - streaming is handled by handleExecutionOutput() + // Skip partial updates - streaming is handled by handleExecutionOutput(). if (isPartial) { return } - // Handle completion - update the tool call UI + // Handle completion - update the tool call UI. const pendingCall = this.findMostRecentPendingCommand() if (pendingCall) { - // Send closing code fence as agent_message_chunk if we had streaming output + // Send closing code fence as agent_message_chunk if we had streaming output. const hadStreamingOutput = this.commandCodeFencesSent.has(pendingCall.toolCallId) + if (hadStreamingOutput) { this.sendUpdate({ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "```\n" }, }) + this.commandCodeFencesSent.delete(pendingCall.toolCallId) } - // Command completed - send final tool_call_update with completed status - // Note: Zed doesn't display tool_call_update content, so we just mark it complete + // Command completed - send final tool_call_update with completed status. + // Note: Zed doesn't display tool_call_update content, so we just mark it complete. this.sendUpdate({ sessionUpdate: "tool_call_update", toolCallId: pendingCall.toolCallId, status: "completed", rawOutput: { output }, }) + this.pendingCommandCalls.delete(pendingCall.toolCallId) } } @@ -152,21 +153,24 @@ export class CommandStreamManager { * Uses executionId → toolCallId mapping for robust routing. */ handleExecutionOutput(executionId: string, output: string): void { - // Find or establish the toolCallId for this executionId + // Find or establish the toolCallId for this executionId. let toolCallId = this.executionToToolCallId.get(executionId) if (!toolCallId) { - // First output for this executionId - establish the mapping + // First output for this executionId - establish the mapping. const pendingCall = this.findMostRecentPendingCommand() + if (!pendingCall) { return } + toolCallId = pendingCall.toolCallId this.executionToToolCallId.set(executionId, toolCallId) } - // Use executionId as the message key for delta tracking + // Use executionId as the message key for delta tracking. const delta = this.deltaTracker.getDelta(executionId, output) + if (!delta) { return } @@ -175,13 +179,14 @@ export class CommandStreamManager { const isFirstChunk = !this.commandCodeFencesSent.has(toolCallId) if (isFirstChunk) { this.commandCodeFencesSent.add(toolCallId) + this.sendUpdate({ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "```\n" }, }) } - // Send the delta as agent_message_chunk for Zed visibility + // Send the delta as agent_message_chunk for Zed visibility. this.sendUpdate({ sessionUpdate: "agent_message_chunk", content: { type: "text", text: delta }, @@ -201,7 +206,7 @@ export class CommandStreamManager { */ reset(): void { // Clear all pending commands - any from previous prompts are now stale - // and would cause duplicate completion messages if not cleaned up + // and would cause duplicate completion messages if not cleaned up. this.pendingCommandCalls.clear() this.commandCodeFencesSent.clear() this.executionToToolCallId.clear() @@ -221,10 +226,6 @@ export class CommandStreamManager { return this.commandCodeFencesSent.size > 0 } - // =========================================================================== - // Private Methods - // =========================================================================== - /** * Find the most recent pending command call. */ diff --git a/apps/cli/src/acp/index.ts b/apps/cli/src/acp/index.ts index 01055866901..319b1294c90 100644 --- a/apps/cli/src/acp/index.ts +++ b/apps/cli/src/acp/index.ts @@ -1,186 +1,2 @@ -// Main agent exports -export { type RooCodeAgentOptions, RooCodeAgent } from "./agent.js" -export { type AcpSessionOptions, AcpSession } from "./session.js" - -// Types for mode and model pickers -export type { AcpModel, AcpModelState, ExtendedNewSessionResponse } from "./types.js" -export { DEFAULT_MODELS } from "./types.js" - -// Model service -export { ModelService, createModelService, type ModelServiceOptions } from "./model-service.js" - -// Interfaces for dependency injection -export type { - IAcpLogger, - IAcpSession, - IContentFormatter, - IExtensionClient, - IExtensionHost, - IDeltaTracker, - IPromptStateMachine, - ICommandStreamManager, - IToolContentStreamManager, - AcpSessionDependencies, - SendUpdateFn, - PromptStateType, - PromptCompletionResult, - StreamManagerOptions, -} from "./interfaces.js" -export { NullLogger } from "./interfaces.js" - -// Logger +export { RooCodeAgent } from "./agent.js" export { acpLog } from "./logger.js" - -// Utilities -export { DeltaTracker } from "./delta-tracker.js" - -// Shared utility functions -export { - // Result type - type Result, - ok, - err, - // Formatting functions - formatSearchResults, - formatReadContent, - wrapInCodeBlock, - // Content extraction - extractContentFromParams, - // File operations - readFileContent, - readFileContentAsync, - resolveFilePath, - resolveFilePathUnsafe, - // Validation - isUserEcho, - hasValidFilePath, - // Config - type FormatConfig, - DEFAULT_FORMAT_CONFIG, -} from "./utils/index.js" - -// Tool Registry -export { - // Categories - TOOL_CATEGORIES, - type ToolCategory, - type KnownToolName, - // Detection functions - isEditTool, - isReadTool, - isSearchTool, - isListFilesTool, - isExecuteTool, - isDeleteTool, - isMoveTool, - isThinkTool, - isFetchTool, - isSwitchModeTool, - isFileWriteTool, - // Kind mapping - mapToolToKind, - // Validation schemas - FilePathParamsSchema, - FileWriteParamsSchema, - FileMoveParamsSchema, - SearchParamsSchema, - ListFilesParamsSchema, - CommandParamsSchema, - ThinkParamsSchema, - SwitchModeParamsSchema, - GenericToolParamsSchema, - ToolMessageSchema, - // Parameter types - type FilePathParams, - type FileWriteParams, - type FileMoveParams, - type SearchParams, - type ListFilesParams, - type CommandParams, - type ThinkParams, - type SwitchModeParams, - type GenericToolParams, - type ToolParams, - type ToolMessage, - // Validation functions - type ValidationResult, - validateToolParams, - parseToolParams, - parseToolMessage, -} from "./tool-registry.js" - -// State management -export { PromptStateMachine, createPromptStateMachine, type PromptStateMachineOptions } from "./prompt-state.js" - -// Content formatting -export { - // Direct function exports (preferred for simple use) - formatToolResult, - extractFileContent, - extractFileContentAsync, - // Re-exported utilities - formatSearchResults as formatSearch, - formatReadContent as formatRead, - wrapInCodeBlock as wrapCode, - isUserEcho as checkUserEcho, - // Class-based DI - ContentFormatter, - createContentFormatter, - type ContentFormatterConfig, -} from "./content-formatter.js" - -// Tool handlers -export { - type ToolHandler, - type ToolHandlerContext, - type ToolHandleResult, - ToolHandlerRegistry, - // Individual handlers for extension - CommandToolHandler, - FileEditToolHandler, - FileReadToolHandler, - SearchToolHandler, - ListFilesToolHandler, - DefaultToolHandler, -} from "./tool-handler.js" - -// Stream managers -export { CommandStreamManager, type PendingCommand, type CommandStreamManagerOptions } from "./command-stream.js" -export { ToolContentStreamManager, type ToolContentStreamManagerOptions } from "./tool-content-stream.js" - -// Session event handler -export { - SessionEventHandler, - createSessionEventHandler, - type SessionEventHandlerDeps, - type TaskCompletedCallback, -} from "./session-event-handler.js" - -// Translation utilities -export { - // Message translation - translateToAcpUpdate, - isPermissionAsk, - isCompletionAsk, - createPermissionOptions, - // Tool parsing - parseToolFromMessage, - generateToolTitle, - extractToolContent, - buildToolCallFromMessage, - type ToolCallInfo, - // Prompt extraction - extractPromptText, - extractPromptImages, - extractPromptResources, - // Location extraction - extractLocations, - extractFilePathsFromSearchResults, - type LocationParams, - // Diff parsing - parseUnifiedDiff, - isUnifiedDiff, - type ParsedDiff, - // Backward compatibility - mapToolKind, -} from "./translator.js" diff --git a/apps/cli/src/acp/model-service.ts b/apps/cli/src/acp/model-service.ts index 17da48d84df..e4c8bfc01bb 100644 --- a/apps/cli/src/acp/model-service.ts +++ b/apps/cli/src/acp/model-service.ts @@ -4,49 +4,32 @@ * Fetches and caches available models from the Roo Code API. */ -import type { AcpModel, AcpModelState } from "./types.js" +import type { ModelInfo } from "@agentclientprotocol/sdk" + import { DEFAULT_MODELS } from "./types.js" import { acpLog } from "./logger.js" -// ============================================================================= -// Types -// ============================================================================= +const DEFAULT_API_URL = "https://api.roocode.com" +const DEFAULT_TIMEOUT = 5_000 + +interface RooModel { + id: string + name: string + description?: string + object?: string + created?: number + owned_by?: string +} export interface ModelServiceOptions { - /** Base URL for the API (defaults to https://api.roocode.com) */ + /** Base URL for the API (defaults to DEFAULT_API_URL) */ apiUrl?: string /** API key for authentication */ apiKey?: string - /** Request timeout in milliseconds (defaults to 5000) */ + /** Request timeout in milliseconds (defaults to DEFAULT_TIMEOUT) */ timeout?: number } -/** - * Response structure from /proxy/v1/models endpoint. - * Based on OpenAI-compatible model listing format. - */ -interface ModelsApiResponse { - object?: string - data?: Array<{ - id: string - object?: string - created?: number - owned_by?: string - // Additional fields may be present - }> -} - -// ============================================================================= -// Constants -// ============================================================================= - -const DEFAULT_API_URL = "https://api.roocode.com" -const DEFAULT_TIMEOUT = 5000 - -// ============================================================================= -// ModelService Class -// ============================================================================= - /** * Service for fetching and managing available models. */ @@ -54,7 +37,7 @@ export class ModelService { private readonly apiUrl: string private readonly apiKey?: string private readonly timeout: number - private cachedModels: AcpModel[] | null = null + private cachedModels: ModelInfo[] | null = null constructor(options: ModelServiceOptions = {}) { this.apiUrl = options.apiUrl || DEFAULT_API_URL @@ -67,8 +50,7 @@ export class ModelService { * Returns cached models if available, otherwise fetches from API. * Falls back to default models on error. */ - async fetchAvailableModels(): Promise { - // Return cached models if available + async fetchAvailableModels(): Promise { if (this.cachedModels) { return this.cachedModels } @@ -99,7 +81,7 @@ export class ModelService { return this.cachedModels } - const data = (await response.json()) as ModelsApiResponse + const data = await response.json() if (!data.data || !Array.isArray(data.data)) { acpLog.warn("ModelService", "Invalid API response format, using default models") @@ -107,10 +89,7 @@ export class ModelService { return this.cachedModels } - // Transform API response to AcpModel format - this.cachedModels = this.transformApiResponse(data.data) - acpLog.debug("ModelService", `Fetched ${this.cachedModels.length} models from API`) - + this.cachedModels = this.translateModels(data.data) return this.cachedModels } catch (error) { if (error instanceof Error && error.name === "AbortError") { @@ -121,27 +100,12 @@ export class ModelService { `Failed to fetch models: ${error instanceof Error ? error.message : String(error)}`, ) } + this.cachedModels = DEFAULT_MODELS return this.cachedModels } } - /** - * Get the current model state including available models and current selection. - */ - async getModelState(currentModelId: string): Promise { - const availableModels = await this.fetchAvailableModels() - - // Validate that currentModelId exists in available models - const modelExists = availableModels.some((m) => m.modelId === currentModelId) - const effectiveModelId = modelExists ? currentModelId : DEFAULT_MODELS[0]!.modelId - - return { - availableModels, - currentModelId: effectiveModelId, - } - } - /** * Clear the cached models, forcing a refresh on next fetch. */ @@ -149,68 +113,15 @@ export class ModelService { this.cachedModels = null } - /** - * Transform API response to AcpModel format. - */ - private transformApiResponse( - data: Array<{ - id: string - object?: string - created?: number - owned_by?: string - }>, - ): AcpModel[] { - // If API returns models, transform them - // For now, we'll create a simple mapping - // In practice, the API should return model metadata including pricing - const models: AcpModel[] = [] - - const defaultModel = DEFAULT_MODELS[0]! - - // Always include the default model first (shows actual model name) - models.push(defaultModel) - - // Add models from API response - for (const model of data) { - // Skip if it's already in our list or if it's a system model - if (model.id === defaultModel.modelId || model.id.startsWith("_")) { - continue - } - - models.push({ - modelId: model.id, - name: this.formatModelName(model.id), - description: model.owned_by ? `Provided by ${model.owned_by}` : undefined, - }) - } + private translateModels(data: RooModel[]): ModelInfo[] { + const models: ModelInfo[] = data + .map(({ id, name, description }) => ({ modelId: id, name, description })) + .sort((a, b) => a.modelId.localeCompare(b.modelId)) - // If no models from API, return defaults - if (models.length === 1) { - return DEFAULT_MODELS - } - - return models - } - - /** - * Format a model ID into a human-readable name. - */ - private formatModelName(modelId: string): string { - // Convert model IDs like "anthropic/claude-3-sonnet" to "Claude 3 Sonnet" - const parts = modelId.split("/") - const name = parts[parts.length - 1] || modelId - - return name - .split("-") - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(" ") + return models.length === 0 ? DEFAULT_MODELS : models } } -// ============================================================================= -// Factory Function -// ============================================================================= - /** * Create a new ModelService instance. */ diff --git a/apps/cli/src/acp/session-event-handler.ts b/apps/cli/src/acp/session-event-handler.ts index 07f0f9e1e00..c10afee2909 100644 --- a/apps/cli/src/acp/session-event-handler.ts +++ b/apps/cli/src/acp/session-event-handler.ts @@ -2,11 +2,18 @@ * Session Event Handler * * Handles events from the ExtensionClient and ExtensionHost, translating them to ACP updates. - * Extracted from session.ts for better separation of concerns. */ import type { SessionMode } from "@agentclientprotocol/sdk" -import type { ClineMessage, ClineAsk, ClineSay, ExtensionMessage, ExtensionState, ModeConfig } from "@roo-code/types" +import type { + ClineMessage, + ClineAsk, + ClineSay, + ExtensionMessage, + ExtensionState, + WebviewMessage, + ModeConfig, +} from "@roo-code/types" import type { WaitingForInputEvent, TaskCompletedEvent, CommandExecutionOutputEvent } from "@/agent/events.js" @@ -123,7 +130,7 @@ export interface SessionEventHandlerDeps { /** Callback to respond with text */ respondWithText: (text: string) => void /** Callback to send message to extension */ - sendToExtension: (message: unknown) => void + sendToExtension: (message: WebviewMessage) => void /** Workspace path */ workspacePath: string /** Initial mode ID */ @@ -169,7 +176,7 @@ export class SessionEventHandler { private readonly sendUpdate: SendUpdateFn private readonly approveAction: () => void private readonly respondWithText: (text: string) => void - private readonly sendToExtension: (message: unknown) => void + private readonly sendToExtension: (message: WebviewMessage) => void private readonly workspacePath: string private readonly isCancelling: () => boolean diff --git a/apps/cli/src/acp/session.ts b/apps/cli/src/acp/session.ts index b5843dd80bc..efb5145e90b 100644 --- a/apps/cli/src/acp/session.ts +++ b/apps/cli/src/acp/session.ts @@ -6,14 +6,15 @@ */ import { - type SessionNotification, - type ClientCapabilities, + type SessionUpdate, type PromptRequest, type PromptResponse, type SessionModeState, AgentSideConnection, } from "@agentclientprotocol/sdk" +import type { SupportedProvider } from "@/types/types.js" +import { getProviderSettings } from "@/lib/utils/provider.js" import { type ExtensionHostOptions, ExtensionHost } from "@/agent/extension-host.js" import { AgentLoopState } from "@/agent/agent-state.js" @@ -35,20 +36,11 @@ import type { } from "./interfaces.js" import { type Result, ok, err } from "./utils/index.js" -// ============================================================================= -// Types -// ============================================================================= - export interface AcpSessionOptions { - /** Path to the extension bundle */ extensionPath: string - /** API provider */ - provider: string - /** API key (optional, may come from environment) */ + provider: SupportedProvider apiKey?: string - /** Model to use */ model: string - /** Initial mode */ mode: string } @@ -81,9 +73,6 @@ export class AcpSession implements IAcpSession { /** Session event handler for managing extension events */ private readonly eventHandler: SessionEventHandler - /** Workspace path for resolving relative file paths */ - private readonly workspacePath: string - /** Current model ID */ private currentModelId: string = DEFAULT_MODELS[0]!.modelId @@ -94,27 +83,18 @@ export class AcpSession implements IAcpSession { private readonly sessionId: string, private readonly extensionHost: ExtensionHost, private readonly connection: AgentSideConnection, - workspacePath: string, - initialMode: string, + private readonly workspacePath: string, + private readonly options: AcpSessionOptions, deps: AcpSessionDependencies = {}, ) { - this.workspacePath = workspacePath - - // Initialize dependencies with defaults or injected instances. this.logger = deps.logger ?? acpLog this.promptState = deps.createPromptStateMachine?.() ?? new PromptStateMachine({ logger: this.logger }) this.deltaTracker = deps.createDeltaTracker?.() ?? new DeltaTracker() - // Initialize tool handler registry. - this.toolHandlerRegistry = new ToolHandlerRegistry() + const sendUpdate = (update: SessionUpdate) => connection.sessionUpdate({ sessionId: this.sessionId, update }) - // Create send update callback for stream managers. - // Updates are sent directly to preserve chunk ordering. - const sendUpdate = (update: SessionNotification["update"]) => { - void this.sendUpdateDirect(update) - } + this.toolHandlerRegistry = new ToolHandlerRegistry() - // Initialize stream managers with injected logger. this.commandStreamManager = new CommandStreamManager({ deltaTracker: this.deltaTracker, sendUpdate, @@ -127,7 +107,7 @@ export class AcpSession implements IAcpSession { logger: this.logger, }) - // Create event handler with extension host for mode tracking + // Create event handler with extension host for mode tracking. this.eventHandler = createSessionEventHandler({ logger: this.logger, client: extensionHost.client, @@ -139,22 +119,21 @@ export class AcpSession implements IAcpSession { toolHandlerRegistry: this.toolHandlerRegistry, sendUpdate, approveAction: () => this.extensionHost.client.approve(), - respondWithText: (text: string) => this.extensionHost.client.respond(text), - sendToExtension: (message) => - this.extensionHost.sendToExtension(message as Parameters[0]), + respondWithText: (text: string, images?: string[]) => this.extensionHost.client.respond(text, images), + sendToExtension: (message) => this.extensionHost.sendToExtension(message), workspacePath, - initialModeId: initialMode, + initialModeId: this.options.mode, isCancelling: () => this.isCancelling, }) this.eventHandler.onTaskCompleted((success) => this.handleTaskCompleted(success)) - // Listen for state changes to log and detect cancellation completion + // Listen for state changes to log and detect cancellation completion. this.extensionHost.client.on("stateChange", (event) => { const prev = event.previousState const curr = event.currentState - // Only log if something actually changed + // Only log if something actually changed. const stateChanged = prev.state !== curr.state || prev.isRunning !== curr.isRunning || @@ -188,32 +167,25 @@ export class AcpSession implements IAcpSession { }) } - // =========================================================================== - // 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. - * - * @param sessionId - Unique session identifier - * @param cwd - Working directory for the session - * @param connection - ACP connection for sending updates - * @param _clientCapabilities - Client capabilities (currently unused) - * @param options - Session configuration options - * @param deps - Optional dependencies for testing */ - static async create( - sessionId: string, - cwd: string, - connection: AgentSideConnection, - _clientCapabilities: ClientCapabilities | undefined, - options: AcpSessionOptions, - deps: AcpSessionDependencies = {}, - ): Promise { - // Create ExtensionHost with ACP-specific configuration. + static async create({ + sessionId, + cwd, + connection, + options, + deps, + }: { + sessionId: string + cwd: string + connection: AgentSideConnection + options: AcpSessionOptions + deps: AcpSessionDependencies + }): Promise { const hostOptions: ExtensionHostOptions = { mode: options.mode, user: null, @@ -222,16 +194,14 @@ export class AcpSession implements IAcpSession { 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, + disableOutput: true, // ACP mode: disable direct output, we stream through ACP. + ephemeral: true, // Don't persist state - ACP clients manage their own sessions. } const extensionHost = new ExtensionHost(hostOptions) await extensionHost.activate() - const session = new AcpSession(sessionId, extensionHost, connection, cwd, options.mode, deps) + const session = new AcpSession(sessionId, extensionHost, connection, cwd, options, deps) session.setupEventHandlers() return session @@ -365,13 +335,8 @@ export class AcpSession implements IAcpSession { */ setModel(modelId: string): void { this.currentModelId = modelId - - // Map model ID to extension settings - // The property is apiModelId for most providers - this.extensionHost.sendToExtension({ - type: "updateSettings", - updatedSettings: { apiModelId: modelId }, - }) + const updatedSettings = getProviderSettings(this.options.provider, this.options.apiKey, modelId) + this.extensionHost.sendToExtension({ type: "updateSettings", updatedSettings }) } /** @@ -403,10 +368,7 @@ export class AcpSession implements IAcpSession { */ async dispose(): Promise { this.cancel() - - // Clean up event handler listeners this.eventHandler.cleanup() - await this.extensionHost.dispose() } @@ -419,10 +381,10 @@ export class AcpSession implements IAcpSession { * * @returns Result indicating success or failure with error details. */ - private async sendUpdateDirect(update: SessionNotification["update"]): Promise> { + private async sendUpdate(update: SessionUpdate): Promise> { try { // Log the update being sent to ACP connection (commented out - too noisy) - // this.logger.info("Session", `OUT: ${JSON.stringify(update)}`) + this.logger.info("Session", `OUT: ${JSON.stringify(update)}`) await this.connection.sessionUpdate({ sessionId: this.sessionId, update }) return ok(undefined) } catch (error) { diff --git a/apps/cli/src/acp/types.ts b/apps/cli/src/acp/types.ts index 3a79fb64b37..414fdd57d5c 100644 --- a/apps/cli/src/acp/types.ts +++ b/apps/cli/src/acp/types.ts @@ -1,76 +1,42 @@ -/** - * ACP Types for Mode and Model Pickers - * - * Extends the standard ACP types with model support for the Roo Code agent. - */ +import type { ModelInfo, SessionMode } from "@agentclientprotocol/sdk" -import type * as acp from "@agentclientprotocol/sdk" - -import { DEFAULT_FLAGS } from "@/types/constants.js" - -// ============================================================================= -// Model Types -// ============================================================================= - -/** - * Represents an available model in the ACP interface. - */ -export interface AcpModel { - /** Unique identifier for the model */ - modelId: string - /** Human-readable name */ - name: string - /** Optional description with details like pricing */ - description?: string -} - -/** - * State of available models and current selection. - */ -export interface AcpModelState { - /** List of available models */ - availableModels: AcpModel[] - /** Currently selected model ID */ - currentModelId: string -} - -// ============================================================================= -// Extended Response Types -// ============================================================================= - -/** - * Extended NewSessionResponse that includes model state. - * The standard ACP NewSessionResponse only includes sessionId and optional modes. - * We extend it with models for our implementation. - */ -export interface ExtendedNewSessionResponse extends acp.NewSessionResponse { - /** Model state for the session */ - models?: AcpModelState -} - -// ============================================================================= -// Default Constants -// ============================================================================= - -/** - * Default models available when API is not accessible. - * These map to Roo Code Cloud model tiers. - * The first model uses DEFAULT_FLAGS.model as the source of truth. - */ -export const DEFAULT_MODELS: AcpModel[] = [ - { - modelId: DEFAULT_FLAGS.model, - name: "Claude Sonnet 4.5", - description: "Best balance of speed and capability", - }, +export const DEFAULT_MODELS: ModelInfo[] = [ { modelId: "anthropic/claude-opus-4.5", name: "Claude Opus 4.5", description: "Most capable for complex work", }, + { + modelId: "anthropic/claude-sonnet-4.5", + name: "Claude Sonnet 4.5", + description: "Best balance of speed and capability", + }, { modelId: "anthropic/claude-haiku-4.5", name: "Claude Haiku 4.5", description: "Fastest for quick answers", }, ] + +export const AVAILABLE_MODES: 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", + }, +] diff --git a/apps/cli/src/commands/acp/index.ts b/apps/cli/src/commands/acp/index.ts index 9be7b8b529a..f416a212091 100644 --- a/apps/cli/src/commands/acp/index.ts +++ b/apps/cli/src/commands/acp/index.ts @@ -6,7 +6,7 @@ import * as acpSdk from "@agentclientprotocol/sdk" import { type SupportedProvider, DEFAULT_FLAGS } from "@/types/index.js" import { getDefaultExtensionPath } from "@/lib/utils/extension.js" -import { type RooCodeAgentOptions, RooCodeAgent, acpLog } from "@/acp/index.js" +import { RooCodeAgent, acpLog } from "@/acp/index.js" export interface AcpCommandOptions { extension?: string @@ -25,14 +25,6 @@ export async function runAcpServer(options: AcpCommandOptions): Promise { process.exit(1) } - 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, - } - // 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 @@ -45,7 +37,17 @@ export async function runAcpServer(options: AcpCommandOptions): Promise { const connection = new acpSdk.AgentSideConnection((conn: acpSdk.AgentSideConnection) => { acpLog.info("Command", "Agent connection established") - agent = new RooCodeAgent(agentOptions, conn) + agent = new RooCodeAgent( + { + 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, + }, + conn, + ) + return agent }, stream) diff --git a/apps/cli/src/types/constants.ts b/apps/cli/src/types/constants.ts index 04abb90ad4e..891ff87d00b 100644 --- a/apps/cli/src/types/constants.ts +++ b/apps/cli/src/types/constants.ts @@ -4,7 +4,7 @@ export const DEFAULT_FLAGS = { mode: "code", reasoningEffort: "medium" as const, model: "anthropic/claude-opus-4.5", - provider: "openrouter", + provider: "openrouter" as const, } export const REASONING_EFFORTS = [...reasoningEffortsExtended, "unspecified", "disabled"] From 22288a2b1ef381e1168b7d9a87334cf4469c9535 Mon Sep 17 00:00:00 2001 From: cte Date: Mon, 12 Jan 2026 16:20:29 -0800 Subject: [PATCH 16/17] Fix the build --- apps/cli/tsup.config.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/cli/tsup.config.ts b/apps/cli/tsup.config.ts index eff2c14e2c9..8cbec03aa5d 100644 --- a/apps/cli/tsup.config.ts +++ b/apps/cli/tsup.config.ts @@ -11,8 +11,18 @@ export default defineConfig({ banner: { js: "#!/usr/bin/env node", }, - // Bundle workspace packages that export TypeScript - noExternal: ["@roo-code/core", "@roo-code/core/cli", "@roo-code/types", "@roo-code/vscode-shim"], + // Bundle workspace packages and ESM-only npm dependencies to create a self-contained CLI + noExternal: [ + // Workspace packages + "@roo-code/core", + "@roo-code/core/cli", + "@roo-code/types", + "@roo-code/vscode-shim", + // ESM-only npm dependencies that need to be bundled + "@agentclientprotocol/sdk", + "p-wait-for", + "zod", + ], external: [ // Keep native modules external "@anthropic-ai/sdk", From 9c9220ad8de62533ce1506699315d26d95ddf219 Mon Sep 17 00:00:00 2001 From: cte Date: Mon, 12 Jan 2026 16:21:17 -0800 Subject: [PATCH 17/17] chore(cli): prepare release v0.0.46 --- apps/cli/CHANGELOG.md | 28 ++++++++++++++++++++++++++++ apps/cli/package.json | 2 +- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/apps/cli/CHANGELOG.md b/apps/cli/CHANGELOG.md index c2682a591f0..fb8675c802c 100644 --- a/apps/cli/CHANGELOG.md +++ b/apps/cli/CHANGELOG.md @@ -5,6 +5,34 @@ All notable changes to the `@roo-code/cli` package will be documented in this fi The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.0.46] - 2026-01-12 + +### Added + +- **Text User Interface (TUI)**: Major new interactive terminal UI with React/Ink for enhanced user experience ([#10480](https://github.com/RooCodeInc/Roo-Code/pull/10480)) + - Interactive mode and model pickers for easy selection + - Improved task management and navigation +- CLI release script now supports local installation for testing ([#10597](https://github.com/RooCodeInc/Roo-Code/pull/10597)) + +### Changed + +- Default model changed to `anthropic/claude-opus-4.5` ([#10544](https://github.com/RooCodeInc/Roo-Code/pull/10544)) +- File organization improvements for better maintainability ([#10599](https://github.com/RooCodeInc/Roo-Code/pull/10599)) +- Cleanup in ExtensionHost for better code organization ([#10600](https://github.com/RooCodeInc/Roo-Code/pull/10600)) +- Updated README documentation +- Logging cleanup and improvements + +### Fixed + +- Model switching issues (model ID mismatch) +- ACP task cancellation handling +- Command output streaming +- Use `DEFAULT_FLAGS.model` as single source of truth for default model ID + +### Tests + +- Updated tests for model changes + ## [0.0.45] - 2026-01-08 ### Changed diff --git a/apps/cli/package.json b/apps/cli/package.json index 01f2e4595ab..5fdb2c42340 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@roo-code/cli", - "version": "0.0.45", + "version": "0.0.46", "description": "Roo Code CLI - Run the Roo Code agent from the command line", "private": true, "type": "module",