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/README.md b/apps/cli/README.md index d4405364405..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` | @@ -171,6 +171,56 @@ 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-opus-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"] + } + } +} +``` + ## 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..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", @@ -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:^", @@ -33,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__/agent.test.ts b/apps/cli/src/acp/__tests__/agent.test.ts new file mode 100644 index 00000000000..64d01f59bac --- /dev/null +++ b/apps/cli/src/acp/__tests__/agent.test.ts @@ -0,0 +1,247 @@ +import type * as acp from "@agentclientprotocol/sdk" + +import { RooCodeAgent } from "../agent.js" +import type { AcpSessionOptions } from "../session.js" + +vi.mock("@/commands/auth/index.js", () => ({ + login: vi.fn().mockResolvedValue({ success: true }), + logout: vi.fn().mockResolvedValue({ success: true }), + status: vi.fn().mockResolvedValue({ authenticated: false }), +})) + +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: AcpSessionOptions = { + extensionPath: "/test/extension", + provider: "openrouter", + apiKey: "test-key", + model: "test-model", + mode: "code", + } + + beforeEach(() => { + 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(1) + + const methods = result.authMethods! + expect(methods[0]!.id).toBe("roo") + }) + + 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 throw for invalid auth method", async () => { + await expect( + agent.authenticate({ + methodId: "invalid-method", + }), + ).rejects.toThrow() + }) + }) + + describe("newSession", () => { + it("should create a new session", async () => { + 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 + 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 + 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 + 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 + 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.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__/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__/delta-tracker.test.ts b/apps/cli/src/acp/__tests__/delta-tracker.test.ts new file mode 100644 index 00000000000..1157c32c69c --- /dev/null +++ b/apps/cli/src/acp/__tests__/delta-tracker.test.ts @@ -0,0 +1,137 @@ +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__/model-service.test.ts b/apps/cli/src/acp/__tests__/model-service.test.ts new file mode 100644 index 00000000000..ad4f83e28a8 --- /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 using name and description fields", async () => { + const service = new ModelService() + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: [ + { + 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 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 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 () => { + 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("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__/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__/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__/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 new file mode 100644 index 00000000000..e5655c00cdd --- /dev/null +++ b/apps/cli/src/acp/__tests__/session.test.ts @@ -0,0 +1,301 @@ +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().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 { + ExtensionHost: vi.fn().mockImplementation(() => ({ + client: mockClient, + 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(), + })), + } +}) + +/** + * 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" + +describe("AcpSession", () => { + let mockConnection: acp.AgentSideConnection + + const defaultOptions: AcpSessionOptions = { + extensionPath: "/test/extension", + provider: "openrouter", + apiKey: "test-api-key", + model: "test-model", + mode: "code", + } + + beforeEach(() => { + // Clear registered event handlers between tests + clientEventHandlers.clear() + + 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() + clientEventHandlers.clear() + }) + + describe("create", () => { + it("should create a session with a unique ID", async () => { + 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({ + sessionId: "test-session-2", + cwd: "/test/workspace", + connection: mockConnection, + options: defaultOptions, + deps: {}, + }) + + 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 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({ + 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() + }) + }) + + describe("prompt", () => { + it("should send a task to the extension host", async () => { + 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 + + // 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 - simulate extension responding to cancel + session.cancel() + simulateExtensionCancelResponse() + const result = await promptPromise + expect(result.stopReason).toBe("cancelled") + }) + + it("should handle image prompts", async () => { + 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 + + 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() + simulateExtensionCancelResponse() + await promptPromise + }) + }) + + describe("cancel", () => { + it("should send cancel message to extension host", async () => { + 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 + + // Start a prompt first + const promptPromise = session.prompt({ + sessionId: "test-session", + prompt: [{ type: "text", text: "Hello" }], + }) + + // Cancel and simulate extension responding + session.cancel() + simulateExtensionCancelResponse() + + expect(mockHostInstance.sendToExtension).toHaveBeenCalledWith({ type: "cancelTask" }) + + await promptPromise + }) + }) + + describe("setMode", () => { + it("should update the session mode", async () => { + 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 + + 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({ + sessionId: "test-session", + cwd: "/test/workspace", + connection: mockConnection, + options: defaultOptions, + deps: {}, + }) + + 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({ + 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/__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 new file mode 100644 index 00000000000..4f22b0c5fba --- /dev/null +++ b/apps/cli/src/acp/__tests__/translator.test.ts @@ -0,0 +1,502 @@ +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", () => { + // 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("listFiles")).toBe("read") + expect(mapToolKind("listFilesTopLevel")).toBe("read") + expect(mapToolKind("listFilesRecursive")).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") + expect(mapToolKind("newFileCreated")).toBe("edit") + expect(mapToolKind("editedExistingFile")).toBe("edit") + }) + + it("should map delete operations", () => { + expect(mapToolKind("delete_file")).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("renameFile")).toBe("move") + }) + + it("should map search operations", () => { + expect(mapToolKind("search_files")).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("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("reason")).toBe("think") + expect(mapToolKind("plan")).toBe("think") + expect(mapToolKind("analyze")).toBe("think") + }) + + it("should map fetch operations", () => { + // 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") + }) +}) + +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) + + // 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") + 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) + + // Tool ID is deterministic based on message timestamp for debugging + 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/agent.ts b/apps/cli/src/acp/agent.ts new file mode 100644 index 00000000000..038a74cba40 --- /dev/null +++ b/apps/cli/src/acp/agent.ts @@ -0,0 +1,243 @@ +/** + * 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 { + 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 { DEFAULT_FLAGS } from "@/types/constants.js" +import { envVarMap } from "@/lib/utils/provider.js" +import { login, status } from "@/commands/auth/index.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" + +/** + * 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 Agent { + private sessions: Map = new Map() + private clientCapabilities: ClientCapabilities | undefined + private isAuthenticated = false + private readonly modelService: ModelService + + constructor( + private readonly options: AcpSessionOptions, + private readonly connection: AgentSideConnection, + ) { + acpLog.info("Agent", `RooCodeAgent constructor: connection=${connection}`) + this.modelService = createModelService({ apiKey: options.apiKey }) + } + + async initialize(params: InitializeRequest): Promise { + acpLog.request("initialize", params) + this.clientCapabilities = params.clientCapabilities + + // Check if already authenticated via environment or existing credentials. + const { authenticated } = await status({ verbose: false }) + acpLog.debug("Agent", `Auth status: ${authenticated ? "authenticated" : "not authenticated"}`) + + return { + protocolVersion: PROTOCOL_VERSION, + authMethods: [ + { + id: "roo", + name: "Sign in with Roo Code Cloud", + description: `Sign in with your Roo Code Cloud account or BYOK by exporting an API key Environment Variable (${Object.values(envVarMap).join(", ")})`, + }, + ], + agentCapabilities: { + loadSession: false, + promptCapabilities: { + image: true, + embeddedContext: true, + }, + }, + } + } + + async newSession(params: NewSessionRequest): Promise { + acpLog.request("newSession", params) + + // @TODO: Detect other env vars for different provider and choose + // the correct provider or throw. + if (!this.isAuthenticated) { + const apiKey = this.options.apiKey || process.env.OPENROUTER_API_KEY + + if (!apiKey) { + acpLog.error("Agent", "newSession failed: not authenticated and no API key") + throw RequestError.authRequired() + } + + this.isAuthenticated = true + } + + const sessionId = randomUUID() + 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({ + sessionId, + cwd: params.cwd, + connection: this.connection, + options: { + extensionPath: this.options.extensionPath, + provider, + apiKey, + model, + mode, + }, + deps: { + logger: acpLog, + }, + }) + + this.sessions.set(sessionId, session) + + const availableModels = await this.modelService.fetchAvailableModels() + const modelExists = availableModels.some((m) => m.modelId === model) + + const response: NewSessionResponse = { + sessionId, + modes: { currentModeId: mode, availableModes: AVAILABLE_MODES }, + models: { + availableModels, + currentModelId: modelExists ? model : DEFAULT_MODELS[0]!.modelId, + }, + } + + acpLog.response("newSession", response) + return response + } + + 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 {} + } + + 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") + } + + this.isAuthenticated = true + + acpLog.response("authenticate", {}) + return {} + } + + async prompt(params: 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 RequestError.invalidParams(undefined, `Session not found: ${params.sessionId}`) + } + + const response = await session.prompt(params) + acpLog.response("prompt", response) + return response + } + + async cancel(params: 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}`) + } + } + + 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/command-stream.ts b/apps/cli/src/acp/command-stream.ts new file mode 100644 index 00000000000..daff9c2c610 --- /dev/null +++ b/apps/cli/src/acp/command-stream.ts @@ -0,0 +1,243 @@ +/** + * CommandStreamManager + * + * Manages streaming of command execution output with code fence wrapping. + * Handles both live command execution events and final command_output messages. + */ + +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 }) + } + + /** + * 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 + + // Skip partial updates - streaming is handled by handleExecutionOutput(). + if (isPartial) { + return + } + + // 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. + 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. + 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 { + // 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) { + return + } + + toolCallId = pendingCall.toolCallId + this.executionToToolCallId.set(executionId, 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.sendUpdate({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "```\n" }, + }) + } + + // Send the delta as agent_message_chunk for Zed visibility. + 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. + this.pendingCommandCalls.clear() + this.commandCodeFencesSent.clear() + this.executionToToolCallId.clear() + } + + /** + * 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 + } + + /** + * 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/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/index.ts b/apps/cli/src/acp/index.ts new file mode 100644 index 00000000000..319b1294c90 --- /dev/null +++ b/apps/cli/src/acp/index.ts @@ -0,0 +1,2 @@ +export { RooCodeAgent } from "./agent.js" +export { acpLog } from "./logger.js" diff --git a/apps/cli/src/acp/interfaces.ts b/apps/cli/src/acp/interfaces.ts new file mode 100644 index 00000000000..33833d8e979 --- /dev/null +++ b/apps/cli/src/acp/interfaces.ts @@ -0,0 +1,375 @@ +/** + * 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 +// ============================================================================= + +/** + * Events emitted by the extension host. + */ +export interface ExtensionHostEvents { + extensionWebviewMessage: (msg: unknown) => void +} + +/** + * Interface for extension host interactions. + */ +export interface IExtensionHost { + /** + * Get the extension client for event handling. + */ + 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. + */ + activate(): Promise + + /** + * Dispose of the extension host. + */ + dispose(): Promise + + /** + * Send a message to the extension. + */ + sendToExtension(message: unknown): 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 + + /** + * 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. + */ + 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 + /** 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 new file mode 100644 index 00000000000..22c393c195a --- /dev/null +++ b/apps/cli/src/acp/logger.ts @@ -0,0 +1,188 @@ +/** + * 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" + +import type { IAcpLogger } from "./interfaces.js" + +// ============================================================================= +// 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 implements IAcpLogger { + 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/model-service.ts b/apps/cli/src/acp/model-service.ts new file mode 100644 index 00000000000..e4c8bfc01bb --- /dev/null +++ b/apps/cli/src/acp/model-service.ts @@ -0,0 +1,130 @@ +/** + * Model Service for ACP + * + * Fetches and caches available models from the Roo Code API. + */ + +import type { ModelInfo } from "@agentclientprotocol/sdk" + +import { DEFAULT_MODELS } from "./types.js" +import { acpLog } from "./logger.js" + +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 DEFAULT_API_URL) */ + apiUrl?: string + /** API key for authentication */ + apiKey?: string + /** Request timeout in milliseconds (defaults to DEFAULT_TIMEOUT) */ + timeout?: number +} + +/** + * Service for fetching and managing available models. + */ +export class ModelService { + private readonly apiUrl: string + private readonly apiKey?: string + private readonly timeout: number + private cachedModels: ModelInfo[] | 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 { + 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() + + if (!data.data || !Array.isArray(data.data)) { + acpLog.warn("ModelService", "Invalid API response format, using default models") + this.cachedModels = DEFAULT_MODELS + return this.cachedModels + } + + this.cachedModels = this.translateModels(data.data) + 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 + } + } + + /** + * Clear the cached models, forcing a refresh on next fetch. + */ + clearCache(): void { + this.cachedModels = null + } + + 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)) + + return models.length === 0 ? DEFAULT_MODELS : models + } +} + +/** + * Create a new ModelService instance. + */ +export function createModelService(options?: ModelServiceOptions): ModelService { + return new ModelService(options) +} diff --git a/apps/cli/src/acp/prompt-state.ts b/apps/cli/src/acp/prompt-state.ts new file mode 100644 index 00000000000..92016a137cb --- /dev/null +++ b/apps/cli/src/acp/prompt-state.ts @@ -0,0 +1,245 @@ +/** + * 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") { + // Cancel existing prompt first + this.cancel() + } + + 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.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") { + return + } + + 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 { + // Clean up any pending resources + if (this.abortController) { + this.abortController.abort() + this.abortController = null + } + + this.state = "idle" + this.resolvePrompt = null + this.currentPromptText = null + } + + // =========================================================================== + // 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). + */ + transitionToComplete(stopReason: acp.StopReason): void { + if (this.state !== "processing") { + return + } + + 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..c10afee2909 --- /dev/null +++ b/apps/cli/src/acp/session-event-handler.ts @@ -0,0 +1,575 @@ +/** + * Session Event Handler + * + * Handles events from the ExtensionClient and ExtensionHost, translating them to ACP updates. + */ + +import type { SessionMode } from "@agentclientprotocol/sdk" +import type { + ClineMessage, + ClineAsk, + ClineSay, + ExtensionMessage, + ExtensionState, + WebviewMessage, + ModeConfig, +} from "@roo-code/types" + +import type { WaitingForInputEvent, TaskCompletedEvent, CommandExecutionOutputEvent } from "@/agent/events.js" + +import { + translateToAcpUpdate, + isPermissionAsk, + isCompletionAsk, + isTodoListMessage, + createPlanUpdateFromMessage, +} from "./translator.js" +import { isUserEcho } from "./utils/index.js" +import type { + IAcpLogger, + IExtensionClient, + IExtensionHost, + 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 + /** Extension host for host-level events (modes, etc.) */ + extensionHost: IExtensionHost + /** 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: WebviewMessage) => void + /** Workspace path */ + workspacePath: string + /** Initial mode ID */ + initialModeId: string + /** Callback to check if cancellation is in progress */ + isCancelling: () => boolean +} + +/** + * Callback for task completion. + */ +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 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 + 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: WebviewMessage) => void + private readonly workspacePath: string + private readonly isCancelling: () => boolean + + 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. + * 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.extensionHost = deps.extensionHost + 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 + this.currentModeId = deps.initialModeId + this.isCancelling = deps.isCancelling + } + + // =========================================================================== + // Public API + // =========================================================================== + + /** + * Set up event handlers to translate ExtensionClient and ExtensionHost 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) + }) + + // Handle extension host messages (modes, state, etc.) + this.extensionMessageListener = (msg: unknown) => { + this.handleExtensionMessage(msg as ExtensionMessage) + } + this.extensionHost.on("extensionWebviewMessage", this.extensionMessageListener) + } + + /** + * Set the callback for task completion. + */ + onTaskCompleted(callback: TaskCompletedCallback): void { + 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. + */ + reset(): void { + this.deltaTracker.reset() + this.commandStreamManager.reset() + this.toolContentStreamManager.reset() + this.processedPermissions.clear() + } + + /** + * Clean up event listeners. + */ + cleanup(): void { + if (this.extensionMessageListener) { + this.extensionHost.off("extensionWebviewMessage", this.extensionMessageListener) + this.extensionMessageListener = null + } + } + + // =========================================================================== + // 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 { + // 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 + 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())) { + 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.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 + + // 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.handlePermissionRequest(message, askType) + return + } + + // Handle completion asks + if (isCompletionAsk(askType)) { + // 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.respondWithText("") + return + } + + // Handle resume_task - auto-resume + if (askType === "resume_task") { + this.approveAction() + return + } + + // Handle API failures - auto-retry for now + if (askType === "api_req_failed") { + this.approveAction() + return + } + + // Default: approve and continue + 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)) { + // 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) + + // 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.sendUpdate(result.completionUpdate) + } + + // Auto-approve the tool call + this.approveAction() + } + + // =========================================================================== + // Task Completion + // =========================================================================== + + /** + * Handle task completion. + */ + private handleTaskCompleted(event: TaskCompletedEvent): void { + if (this.taskCompletedCallback) { + 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.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) { + this.currentModeId = state.mode + + // 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, + })) + } +} + +// ============================================================================= +// 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 new file mode 100644 index 00000000000..efb5145e90b --- /dev/null +++ b/apps/cli/src/acp/session.ts @@ -0,0 +1,403 @@ +/** + * ACP Session + * + * Manages a single ACP session, wrapping an ExtensionHost instance. + * Handles message translation, event streaming, and permission requests. + */ + +import { + 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" + +import { DEFAULT_MODELS } from "./types.js" +import { extractPromptText, extractPromptImages } from "./translator.js" +import { acpLog } from "./logger.js" +import { DeltaTracker } from "./delta-tracker.js" +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, + IPromptStateMachine, + AcpSessionDependencies, +} from "./interfaces.js" +import { type Result, ok, err } from "./utils/index.js" + +export interface AcpSessionOptions { + extensionPath: string + provider: SupportedProvider + apiKey?: string + model: string + mode: string +} + +/** + * 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 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: IDeltaTracker + + /** Tool handler registry for polymorphic tool dispatch */ + private readonly toolHandlerRegistry: ToolHandlerRegistry + + /** Command stream manager for handling command output */ + private readonly commandStreamManager: CommandStreamManager + + /** Tool content stream manager for handling file creates/edits */ + private readonly toolContentStreamManager: ToolContentStreamManager + + /** Session event handler for managing extension events */ + private readonly eventHandler: SessionEventHandler + + /** 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, + private readonly connection: AgentSideConnection, + private readonly workspacePath: string, + private readonly options: AcpSessionOptions, + deps: AcpSessionDependencies = {}, + ) { + this.logger = deps.logger ?? acpLog + this.promptState = deps.createPromptStateMachine?.() ?? new PromptStateMachine({ logger: this.logger }) + this.deltaTracker = deps.createDeltaTracker?.() ?? new DeltaTracker() + + const sendUpdate = (update: SessionUpdate) => connection.sessionUpdate({ sessionId: this.sessionId, update }) + + this.toolHandlerRegistry = new ToolHandlerRegistry() + + this.commandStreamManager = new CommandStreamManager({ + deltaTracker: this.deltaTracker, + sendUpdate, + logger: this.logger, + }) + + this.toolContentStreamManager = new ToolContentStreamManager({ + deltaTracker: this.deltaTracker, + sendUpdate, + 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, + toolContentStreamManager: this.toolContentStreamManager, + toolHandlerRegistry: this.toolHandlerRegistry, + sendUpdate, + approveAction: () => this.extensionHost.client.approve(), + respondWithText: (text: string, images?: string[]) => this.extensionHost.client.respond(text, images), + sendToExtension: (message) => this.extensionHost.sendToExtension(message), + workspacePath, + initialModeId: this.options.mode, + 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() + } + } + }) + } + + /** + * 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, + cwd, + connection, + options, + deps, + }: { + sessionId: string + cwd: string + connection: AgentSideConnection + options: AcpSessionOptions + deps: AcpSessionDependencies + }): Promise { + 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, + 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, deps) + session.setupEventHandlers() + + return session + } + + // =========================================================================== + // Event Handlers + // =========================================================================== + + /** + * 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() + } + + /** + * Reset state for a new prompt. + */ + private resetForNewPrompt(): void { + this.eventHandler.reset() + this.isCancelling = false + } + + /** + * Handle task completion. + */ + private handleTaskCompleted(success: boolean): void { + // 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") + } + + // =========================================================================== + // ACP Methods + // =========================================================================== + + /** + * Process a prompt request from the ACP client. + */ + async prompt(params: PromptRequest): Promise { + // 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() + + // Reset state for new prompt. + this.resetForNewPrompt() + + // Start the prompt using the state machine. + const promise = this.promptState.startPrompt(text) + + if (images.length > 0) { + this.extensionHost.sendToExtension({ type: "newTask", text, images }) + } else { + this.extensionHost.sendToExtension({ type: "newTask", text }) + } + + return promise + } + + /** + * Cancel the current prompt. + */ + cancel(): void { + if (this.promptState.isProcessing()) { + 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") + } + } + + /** + * 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.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.currentModelId = modelId + const updatedSettings = getProviderSettings(this.options.provider, this.options.apiKey, modelId) + this.extensionHost.sendToExtension({ type: "updateSettings", updatedSettings }) + } + + /** + * 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. + */ + async dispose(): Promise { + this.cancel() + this.eventHandler.cleanup() + await this.extensionHost.dispose() + } + + // =========================================================================== + // Helpers + // =========================================================================== + + /** + * Send an update directly to the ACP client. + * + * @returns Result indicating success or failure with error details. + */ + 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)}`) + await this.connection.sessionUpdate({ sessionId: this.sessionId, update }) + return ok(undefined) + } catch (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}`) + } + } + + /** + * Get the session ID. + */ + getSessionId(): string { + return this.sessionId + } +} 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..c572e7beb52 --- /dev/null +++ b/apps/cli/src/acp/tool-content-stream.ts @@ -0,0 +1,204 @@ +/** + * 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)) { + return true // Handled (by skipping) + } + + // 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() + } + + /** + * 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.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.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) + } +} 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 new file mode 100644 index 00000000000..dfe30a451a5 --- /dev/null +++ b/apps/cli/src/acp/translator.ts @@ -0,0 +1,56 @@ +/** + * ACP Message Translator + * + * This file re-exports from the translator/ module for backward compatibility. + * The translator has been split into focused modules for better maintainability: + * + * - 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 + * - translator/plan-translator.ts: TodoItem to ACP PlanEntry 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, + // 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/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..1c82eac5e08 --- /dev/null +++ b/apps/cli/src/acp/translator/index.ts @@ -0,0 +1,59 @@ +/** + * 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 + * - plan-translator: TodoItem to ACP PlanEntry 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" + +// 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/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/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/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..beff18a5fc7 --- /dev/null +++ b/apps/cli/src/acp/translator/tool-parser.ts @@ -0,0 +1,241 @@ +/** + * 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", + + // Plan updates + updateTodoList: "Update plan", + update_todo_list: "Update plan", + } + + 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/types.ts b/apps/cli/src/acp/types.ts new file mode 100644 index 00000000000..414fdd57d5c --- /dev/null +++ b/apps/cli/src/acp/types.ts @@ -0,0 +1,42 @@ +import type { ModelInfo, SessionMode } from "@agentclientprotocol/sdk" + +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/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-client.ts b/apps/cli/src/agent/extension-client.ts index c2d77dfdd91..26185c60ac2 100644 --- a/apps/cli/src/agent/extension-client.ts +++ b/apps/cli/src/agent/extension-client.ts @@ -432,6 +432,7 @@ export class ExtensionClient { const message: WebviewMessage = { type: "cancelTask", } + this.sendMessage(message) } @@ -467,6 +468,7 @@ export class ExtensionClient { type: "terminalOperation", terminalOperation: "continue", } + this.sendMessage(message) } @@ -480,6 +482,7 @@ export class ExtensionClient { type: "terminalOperation", terminalOperation: "abort", } + this.sendMessage(message) } diff --git a/apps/cli/src/agent/extension-host.ts b/apps/cli/src/agent/extension-host.ts index 4a9cf93ccba..c38ffd0a4df 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") @@ -163,6 +164,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. @@ -246,9 +248,30 @@ 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) => { 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() @@ -256,12 +279,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) => { 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() @@ -269,6 +294,7 @@ 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) }) 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/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}`) diff --git a/apps/cli/src/commands/acp/index.ts b/apps/cli/src/commands/acp/index.ts new file mode 100644 index 00000000000..f416a212091 --- /dev/null +++ b/apps/cli/src/commands/acp/index.ts @@ -0,0 +1,81 @@ +import { Readable, Writable } from "node:stream" +import path from "node:path" +import { fileURLToPath } from "node:url" + +import * as acpSdk from "@agentclientprotocol/sdk" + +import { type SupportedProvider, DEFAULT_FLAGS } from "@/types/index.js" +import { getDefaultExtensionPath } from "@/lib/utils/extension.js" +import { RooCodeAgent, acpLog } from "@/acp/index.js" + +export interface AcpCommandOptions { + extension?: string + provider?: SupportedProvider + model?: string + mode?: string + apiKey?: string +} + +export async function runAcpServer(options: AcpCommandOptions): Promise { + const __dirname = path.dirname(fileURLToPath(import.meta.url)) + const extensionPath = options.extension || getDefaultExtensionPath(__dirname) + + if (!extensionPath) { + console.error("Error: Extension path not found. Use --extension to specify the path.") + process.exit(1) + } + + // 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 + + const stream = acpSdk.ndJsonStream(stdout, stdin) + acpLog.info("Command", "ACP stream created, waiting for connection") + + let agent: RooCodeAgent | null = null + + const connection = new acpSdk.AgentSideConnection((conn: acpSdk.AgentSideConnection) => { + acpLog.info("Command", "Agent connection established") + 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) + + 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) + + acpLog.info("Command", "Waiting for connection to close") + await connection.closed + acpLog.info("Command", "Connection closed") +} + +export async function acp(options: AcpCommandOptions): Promise { + try { + await runAcpServer(options) + } catch (error) { + acpLog.error("Command", "Fatal error", error) + console.error(error) + process.exit(1) + } +} 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/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 8d3f5af521e..4139325c739 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -2,7 +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 { run, login, logout, status, acp } from "@/commands/index.js" const program = new Command() @@ -62,4 +62,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/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", diff --git a/apps/cli/src/types/constants.ts b/apps/cli/src/types/constants.ts index 5b3dc577786..891ff87d00b 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" as const, } export const REASONING_EFFORTS = [...reasoningEffortsExtended, "unspecified", "disabled"] 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/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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 177d0b3e5ab..daa03f73792 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@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)) @@ -118,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) @@ -1356,6 +1362,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'} @@ -10659,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'} @@ -10684,6 +10698,10 @@ snapshots: '@adobe/css-tools@4.4.2': {} + '@agentclientprotocol/sdk@0.12.0(zod@4.3.5)': + dependencies: + zod: 4.3.5 + '@alcalzone/ansi-tokenize@0.2.3': dependencies: ansi-styles: 6.2.3 @@ -21715,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) })