diff --git a/apps/cli/package.json b/apps/cli/package.json index 0190dcb5c2..01f2e4595a 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -34,6 +34,7 @@ "p-wait-for": "^5.0.2", "react": "^19.1.0", "superjson": "^2.2.6", + "zod": "^4.3.5", "zustand": "^5.0.0" }, "devDependencies": { diff --git a/apps/cli/src/acp/__tests__/command-stream.test.ts b/apps/cli/src/acp/__tests__/command-stream.test.ts new file mode 100644 index 0000000000..be1e1ea13d --- /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 0000000000..f4587e9a19 --- /dev/null +++ b/apps/cli/src/acp/__tests__/content-formatter.test.ts @@ -0,0 +1,298 @@ +/** + * Content Formatter Unit Tests + * + * Tests for the ContentFormatter class. + */ + +import { ContentFormatter, createContentFormatter } from "../content-formatter.js" + +describe("ContentFormatter", () => { + describe("formatToolResult", () => { + const formatter = new ContentFormatter() + + it("should format search results", () => { + const content = "Found 5 results.\n\n# src/file.ts\n 1 | match" + const result = formatter.formatToolResult("search", content) + + expect(result).toContain("Found 5 results in 1 file") + expect(result).toContain("- src/file.ts") + expect(result).toMatch(/^```/) + expect(result).toMatch(/```$/) + }) + + it("should format read results", () => { + const content = "line1\nline2\nline3" + const result = formatter.formatToolResult("read", content) + + expect(result).toContain("line1") + expect(result).toContain("line2") + expect(result).toContain("line3") + expect(result).toMatch(/^```/) + expect(result).toMatch(/```$/) + }) + + it("should return content unchanged for unknown kinds", () => { + const content = "some content" + const result = formatter.formatToolResult("unknown", content) + + expect(result).toBe(content) + }) + }) + + describe("formatSearchResults", () => { + const formatter = new ContentFormatter() + + it("should extract file count and result count", () => { + const content = "Found 10 results.\n\n# src/a.ts\n 1 | code\n\n# src/b.ts\n 5 | code" + const result = formatter.formatSearchResults(content) + + expect(result).toContain("Found 10 results in 2 files") + }) + + it("should list unique files alphabetically", () => { + const content = "Found 3 results.\n\n# src/z.ts\n 1 | a\n\n# src/a.ts\n 2 | b\n\n# src/m.ts\n 3 | c" + const result = formatter.formatSearchResults(content) + + const lines = result.split("\n") + const fileLines = lines.filter((l) => l.startsWith("- ")) + + expect(fileLines[0]).toBe("- src/a.ts") + expect(fileLines[1]).toBe("- src/m.ts") + expect(fileLines[2]).toBe("- src/z.ts") + }) + + it("should deduplicate repeated file paths", () => { + const content = + "Found 5 results.\n\n# src/file.ts\n 1 | a\n\n# src/file.ts\n 5 | b\n\n# src/other.ts\n 10 | c" + const result = formatter.formatSearchResults(content) + + expect(result).toContain("in 2 files") + expect((result.match(/- src\/file\.ts/g) || []).length).toBe(1) + }) + + it("should handle no files found", () => { + const content = "No results found" + const result = formatter.formatSearchResults(content) + + expect(result).toBe("No results found") + }) + + it("should handle singular result", () => { + const content = "Found 1 result.\n\n# src/file.ts\n 1 | match" + const result = formatter.formatSearchResults(content) + + expect(result).toContain("Found 1 result in 1 file") + }) + + it("should handle missing result count", () => { + const content = "# src/file.ts\n 1 | match" + const result = formatter.formatSearchResults(content) + + expect(result).toContain("Found matches in 1 file") + }) + }) + + describe("formatReadResults", () => { + it("should return short content unchanged", () => { + const formatter = new ContentFormatter({ maxReadLines: 100 }) + const content = "line1\nline2\nline3" + const result = formatter.formatReadResults(content) + + expect(result).toBe(content) + }) + + it("should truncate long content", () => { + const formatter = new ContentFormatter({ maxReadLines: 5 }) + const lines = Array.from({ length: 10 }, (_, i) => `line${i + 1}`) + const content = lines.join("\n") + const result = formatter.formatReadResults(content) + + expect(result).toContain("line1") + expect(result).toContain("line5") + expect(result).not.toContain("line6") + expect(result).toContain("... (5 more lines)") + }) + + it("should handle exactly maxReadLines", () => { + const formatter = new ContentFormatter({ maxReadLines: 5 }) + const lines = Array.from({ length: 5 }, (_, i) => `line${i + 1}`) + const content = lines.join("\n") + const result = formatter.formatReadResults(content) + + expect(result).toBe(content) + }) + + it("should use default maxReadLines of 100", () => { + const formatter = new ContentFormatter() + const lines = Array.from({ length: 105 }, (_, i) => `line${i + 1}`) + const content = lines.join("\n") + const result = formatter.formatReadResults(content) + + expect(result).toContain("... (5 more lines)") + }) + }) + + describe("wrapInCodeBlock", () => { + const formatter = new ContentFormatter() + + it("should wrap content in code block", () => { + const result = formatter.wrapInCodeBlock("some code") + + expect(result).toBe("```\nsome code\n```") + }) + + it("should support language specification", () => { + const result = formatter.wrapInCodeBlock("const x = 1", "typescript") + + expect(result).toBe("```typescript\nconst x = 1\n```") + }) + + it("should handle empty content", () => { + const result = formatter.wrapInCodeBlock("") + + expect(result).toBe("```\n\n```") + }) + + it("should handle multiline content", () => { + const result = formatter.wrapInCodeBlock("line1\nline2\nline3") + + expect(result).toBe("```\nline1\nline2\nline3\n```") + }) + }) + + describe("extractContentFromRawInput", () => { + const formatter = new ContentFormatter() + + it("should extract content field", () => { + const result = formatter.extractContentFromRawInput({ content: "my content" }) + expect(result).toBe("my content") + }) + + it("should extract text field", () => { + const result = formatter.extractContentFromRawInput({ text: "my text" }) + expect(result).toBe("my text") + }) + + it("should extract result field", () => { + const result = formatter.extractContentFromRawInput({ result: "my result" }) + expect(result).toBe("my result") + }) + + it("should extract output field", () => { + const result = formatter.extractContentFromRawInput({ output: "my output" }) + expect(result).toBe("my output") + }) + + it("should extract fileContent field", () => { + const result = formatter.extractContentFromRawInput({ fileContent: "my file content" }) + expect(result).toBe("my file content") + }) + + it("should extract data field", () => { + const result = formatter.extractContentFromRawInput({ data: "my data" }) + expect(result).toBe("my data") + }) + + it("should prioritize content over other fields", () => { + const result = formatter.extractContentFromRawInput({ + content: "content value", + text: "text value", + result: "result value", + }) + expect(result).toBe("content value") + }) + + it("should return undefined for empty object", () => { + const result = formatter.extractContentFromRawInput({}) + expect(result).toBeUndefined() + }) + + it("should return undefined for empty string values", () => { + const result = formatter.extractContentFromRawInput({ content: "", text: "" }) + expect(result).toBeUndefined() + }) + + it("should skip non-string values", () => { + const result = formatter.extractContentFromRawInput({ + content: 123 as unknown as string, + text: "valid text", + }) + expect(result).toBe("valid text") + }) + }) + + describe("extractFileContent", () => { + const formatter = new ContentFormatter() + + it("should use extractContentFromRawInput for non-readFile tools", () => { + const result = formatter.extractFileContent({ tool: "list_files", content: "file list" }, "/workspace") + expect(result).toBe("file list") + }) + + it("should return undefined for readFile with no path", () => { + const result = formatter.extractFileContent({ tool: "readFile" }, "/workspace") + expect(result).toBeUndefined() + }) + + // Note: actual file reading is tested in integration tests + }) + + describe("isUserEcho", () => { + const formatter = new ContentFormatter() + + it("should return false for null prompt", () => { + expect(formatter.isUserEcho("any text", null)).toBe(false) + }) + + it("should detect exact match", () => { + expect(formatter.isUserEcho("hello world", "hello world")).toBe(true) + }) + + it("should be case insensitive", () => { + expect(formatter.isUserEcho("Hello World", "hello world")).toBe(true) + }) + + it("should handle whitespace differences", () => { + expect(formatter.isUserEcho(" hello world ", "hello world")).toBe(true) + }) + + it("should detect text contained in prompt (truncated)", () => { + expect(formatter.isUserEcho("write a function", "write a function that adds numbers")).toBe(true) + }) + + it("should detect prompt contained in text (wrapped)", () => { + expect(formatter.isUserEcho("User said: write a function here", "write a function")).toBe(true) + }) + + it("should not match short strings", () => { + expect(formatter.isUserEcho("test", "this is a test prompt")).toBe(false) + }) + + it("should not match completely different text", () => { + expect(formatter.isUserEcho("completely different", "original prompt text")).toBe(false) + }) + + it("should handle empty strings", () => { + expect(formatter.isUserEcho("", "prompt")).toBe(false) + expect(formatter.isUserEcho("text", "")).toBe(false) + }) + }) +}) + +describe("createContentFormatter", () => { + it("should create a formatter with default config", () => { + const formatter = createContentFormatter() + expect(formatter).toBeInstanceOf(ContentFormatter) + }) + + it("should accept custom config", () => { + const formatter = createContentFormatter({ maxReadLines: 50 }) + + // Test that custom config is used + const lines = Array.from({ length: 55 }, (_, i) => `line${i + 1}`) + const content = lines.join("\n") + const result = formatter.formatReadResults(content) + + expect(result).toContain("... (5 more lines)") + }) +}) diff --git a/apps/cli/src/acp/__tests__/prompt-state.test.ts b/apps/cli/src/acp/__tests__/prompt-state.test.ts new file mode 100644 index 0000000000..96a1694560 --- /dev/null +++ b/apps/cli/src/acp/__tests__/prompt-state.test.ts @@ -0,0 +1,373 @@ +/** + * Prompt State Machine Unit Tests + * + * Tests for the PromptStateMachine class. + */ + +import { PromptStateMachine, createPromptStateMachine } from "../prompt-state.js" + +describe("PromptStateMachine", () => { + describe("initial state", () => { + it("should start in idle state", () => { + const sm = new PromptStateMachine() + expect(sm.getState()).toBe("idle") + }) + + it("should have null abort signal initially", () => { + const sm = new PromptStateMachine() + expect(sm.getAbortSignal()).toBeNull() + }) + + it("should have null prompt text initially", () => { + const sm = new PromptStateMachine() + expect(sm.getCurrentPromptText()).toBeNull() + }) + }) + + describe("canStartPrompt", () => { + it("should return true when idle", () => { + const sm = new PromptStateMachine() + expect(sm.canStartPrompt()).toBe(true) + }) + + it("should return false when processing", async () => { + const sm = new PromptStateMachine() + sm.startPrompt("test") + + expect(sm.canStartPrompt()).toBe(false) + + // Clean up + sm.complete(true) + }) + }) + + describe("isProcessing", () => { + it("should return false when idle", () => { + const sm = new PromptStateMachine() + expect(sm.isProcessing()).toBe(false) + }) + + it("should return true when processing", () => { + const sm = new PromptStateMachine() + sm.startPrompt("test") + + expect(sm.isProcessing()).toBe(true) + + // Clean up + sm.complete(true) + }) + + it("should return false after completion", () => { + const sm = new PromptStateMachine() + sm.startPrompt("test") + sm.complete(true) + + expect(sm.isProcessing()).toBe(false) + }) + }) + + describe("startPrompt", () => { + it("should transition to processing state", () => { + const sm = new PromptStateMachine() + sm.startPrompt("test prompt") + + expect(sm.getState()).toBe("processing") + + // Clean up + sm.complete(true) + }) + + it("should store the prompt text", () => { + const sm = new PromptStateMachine() + sm.startPrompt("my test prompt") + + expect(sm.getCurrentPromptText()).toBe("my test prompt") + + // Clean up + sm.complete(true) + }) + + it("should create abort signal", () => { + const sm = new PromptStateMachine() + sm.startPrompt("test") + + expect(sm.getAbortSignal()).not.toBeNull() + expect(sm.getAbortSignal()?.aborted).toBe(false) + + // Clean up + sm.complete(true) + }) + + it("should return a promise", () => { + const sm = new PromptStateMachine() + const promise = sm.startPrompt("test") + + expect(promise).toBeInstanceOf(Promise) + + // Clean up + sm.complete(true) + }) + + it("should resolve with end_turn on successful completion", async () => { + const sm = new PromptStateMachine() + const promise = sm.startPrompt("test") + + sm.complete(true) + const result = await promise + + expect(result.stopReason).toBe("end_turn") + }) + + it("should resolve with refusal on failed completion", async () => { + const sm = new PromptStateMachine() + const promise = sm.startPrompt("test") + + sm.complete(false) + const result = await promise + + expect(result.stopReason).toBe("refusal") + }) + + it("should resolve with cancelled on cancel", async () => { + const sm = new PromptStateMachine() + const promise = sm.startPrompt("test") + + sm.cancel() + const result = await promise + + expect(result.stopReason).toBe("cancelled") + }) + + it("should cancel existing prompt if called while processing", async () => { + const sm = new PromptStateMachine() + const promise1 = sm.startPrompt("first prompt") + + // Start a second prompt (should cancel first) + sm.startPrompt("second prompt") + + // First promise should resolve with cancelled + const result1 = await promise1 + expect(result1.stopReason).toBe("cancelled") + + // Clean up + sm.complete(true) + }) + }) + + describe("complete", () => { + it("should transition to idle state", () => { + const sm = new PromptStateMachine() + sm.startPrompt("test") + sm.complete(true) + + expect(sm.getState()).toBe("idle") + }) + + it("should return end_turn for success", () => { + const sm = new PromptStateMachine() + sm.startPrompt("test") + + const stopReason = sm.complete(true) + expect(stopReason).toBe("end_turn") + }) + + it("should return refusal for failure", () => { + const sm = new PromptStateMachine() + sm.startPrompt("test") + + const stopReason = sm.complete(false) + expect(stopReason).toBe("refusal") + }) + + it("should clear prompt text", () => { + const sm = new PromptStateMachine() + sm.startPrompt("test") + sm.complete(true) + + expect(sm.getCurrentPromptText()).toBeNull() + }) + + it("should clear abort controller", () => { + const sm = new PromptStateMachine() + sm.startPrompt("test") + sm.complete(true) + + expect(sm.getAbortSignal()).toBeNull() + }) + + it("should be idempotent (multiple calls ignored)", () => { + const sm = new PromptStateMachine() + sm.startPrompt("test") + + const result1 = sm.complete(true) + const result2 = sm.complete(false) // Should be ignored + + expect(result1).toBe("end_turn") + expect(result2).toBe("refusal") // Returns mapped value but doesn't change state + expect(sm.getState()).toBe("idle") + }) + }) + + describe("cancel", () => { + it("should abort the signal", () => { + const sm = new PromptStateMachine() + sm.startPrompt("test") + const signal = sm.getAbortSignal() + + sm.cancel() + + expect(signal?.aborted).toBe(true) + }) + + it("should transition to idle state", async () => { + const sm = new PromptStateMachine() + const promise = sm.startPrompt("test") + + sm.cancel() + await promise + + expect(sm.getState()).toBe("idle") + }) + + it("should be safe to call when idle", () => { + const sm = new PromptStateMachine() + + // Should not throw + expect(() => sm.cancel()).not.toThrow() + expect(sm.getState()).toBe("idle") + }) + + it("should be idempotent (multiple calls safe)", () => { + const sm = new PromptStateMachine() + sm.startPrompt("test") + + sm.cancel() + sm.cancel() // Should not throw + + expect(sm.getState()).toBe("idle") + }) + }) + + describe("reset", () => { + it("should transition to idle state", () => { + const sm = new PromptStateMachine() + sm.startPrompt("test") + sm.reset() + + expect(sm.getState()).toBe("idle") + }) + + it("should clear prompt text", () => { + const sm = new PromptStateMachine() + sm.startPrompt("test") + sm.reset() + + expect(sm.getCurrentPromptText()).toBeNull() + }) + + it("should clear abort controller", () => { + const sm = new PromptStateMachine() + sm.startPrompt("test") + sm.reset() + + expect(sm.getAbortSignal()).toBeNull() + }) + + it("should abort any pending operation", () => { + const sm = new PromptStateMachine() + sm.startPrompt("test") + const signal = sm.getAbortSignal() + + sm.reset() + + expect(signal?.aborted).toBe(true) + }) + + it("should be safe to call when idle", () => { + const sm = new PromptStateMachine() + + expect(() => sm.reset()).not.toThrow() + expect(sm.getState()).toBe("idle") + }) + }) + + describe("abort signal integration", () => { + it("should trigger abort handler on cancel", async () => { + const sm = new PromptStateMachine() + const promise = sm.startPrompt("test") + + let abortHandlerCalled = false + sm.getAbortSignal()?.addEventListener("abort", () => { + abortHandlerCalled = true + }) + + sm.cancel() + await promise + + expect(abortHandlerCalled).toBe(true) + }) + + it("should resolve promise via abort handler", async () => { + const sm = new PromptStateMachine() + const promise = sm.startPrompt("test") + + sm.cancel() + const result = await promise + + expect(result.stopReason).toBe("cancelled") + }) + }) + + describe("lifecycle scenarios", () => { + it("should handle multiple prompt cycles", async () => { + const sm = new PromptStateMachine() + + // First cycle + const promise1 = sm.startPrompt("prompt 1") + expect(sm.isProcessing()).toBe(true) + sm.complete(true) + const result1 = await promise1 + expect(result1.stopReason).toBe("end_turn") + expect(sm.isProcessing()).toBe(false) + + // Second cycle + const promise2 = sm.startPrompt("prompt 2") + expect(sm.isProcessing()).toBe(true) + expect(sm.getCurrentPromptText()).toBe("prompt 2") + sm.complete(false) + const result2 = await promise2 + expect(result2.stopReason).toBe("refusal") + + // Third cycle with cancellation + const promise3 = sm.startPrompt("prompt 3") + sm.cancel() + const result3 = await promise3 + expect(result3.stopReason).toBe("cancelled") + }) + + it("should handle rapid start/cancel cycles", async () => { + const sm = new PromptStateMachine() + + const promises: Promise<{ stopReason: string }>[] = [] + + for (let i = 0; i < 5; i++) { + const promise = sm.startPrompt(`prompt ${i}`) + promises.push(promise) + sm.cancel() + } + + // All should resolve with cancelled + const results = await Promise.all(promises) + expect(results.every((r) => r.stopReason === "cancelled")).toBe(true) + }) + }) +}) + +describe("createPromptStateMachine", () => { + it("should create a new state machine", () => { + const sm = createPromptStateMachine() + + expect(sm).toBeInstanceOf(PromptStateMachine) + expect(sm.getState()).toBe("idle") + }) +}) diff --git a/apps/cli/src/acp/__tests__/tool-content-stream.test.ts b/apps/cli/src/acp/__tests__/tool-content-stream.test.ts new file mode 100644 index 0000000000..d145f2034e --- /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 0000000000..6edd26071e --- /dev/null +++ b/apps/cli/src/acp/__tests__/tool-handler.test.ts @@ -0,0 +1,495 @@ +/** + * Tool Handler Unit Tests + * + * Tests for the ToolHandler abstraction and ToolHandlerRegistry. + */ + +import type { ClineMessage, ClineAsk } from "@roo-code/types" + +import { + ToolHandlerRegistry, + CommandToolHandler, + FileEditToolHandler, + FileReadToolHandler, + SearchToolHandler, + ListFilesToolHandler, + DefaultToolHandler, + type ToolHandlerContext, +} from "../tool-handler.js" +import { parseToolFromMessage } from "../translator.js" +import { NullLogger } from "../interfaces.js" + +// ============================================================================= +// Test Utilities +// ============================================================================= + +const testLogger = new NullLogger() + +function createContext(message: ClineMessage, ask: ClineAsk, workspacePath = "/workspace"): ToolHandlerContext { + return { + message, + ask, + workspacePath, + toolInfo: parseToolFromMessage(message, workspacePath), + logger: testLogger, + } +} + +function createToolMessage(tool: string, params: Record = {}): ClineMessage { + return { + ts: Date.now(), + type: "say", + say: "text", + text: JSON.stringify({ tool, ...params }), + } +} + +// ============================================================================= +// CommandToolHandler Tests +// ============================================================================= + +describe("CommandToolHandler", () => { + const handler = new CommandToolHandler() + + describe("canHandle", () => { + it("should handle command asks", () => { + const context = createContext(createToolMessage("execute_command", { command: "ls" }), "command") + expect(handler.canHandle(context)).toBe(true) + }) + + it("should not handle tool asks", () => { + const context = createContext(createToolMessage("read_file", { path: "test.ts" }), "tool") + expect(handler.canHandle(context)).toBe(false) + }) + + it("should not handle browser_action_launch asks", () => { + const context = createContext(createToolMessage("browser_action", {}), "browser_action_launch") + expect(handler.canHandle(context)).toBe(false) + }) + }) + + describe("handle", () => { + it("should return execute kind for commands", () => { + const context = createContext(createToolMessage("execute_command", { command: "npm test" }), "command") + const result = handler.handle(context) + + expect(result.initialUpdate).toMatchObject({ + sessionUpdate: "tool_call", + kind: "execute", + status: "in_progress", + }) + }) + + it("should track as pending command", () => { + const message = createToolMessage("execute_command", { command: "npm test" }) + const context = createContext(message, "command") + const result = handler.handle(context) + + expect(result.trackAsPendingCommand).toBeDefined() + expect(result.trackAsPendingCommand?.command).toBe(message.text) + expect(result.trackAsPendingCommand?.ts).toBe(message.ts) + }) + + it("should not include completion update", () => { + const context = createContext(createToolMessage("execute_command", { command: "ls" }), "command") + const result = handler.handle(context) + + expect(result.completionUpdate).toBeUndefined() + }) + }) +}) + +// ============================================================================= +// FileEditToolHandler Tests +// ============================================================================= + +describe("FileEditToolHandler", () => { + const handler = new FileEditToolHandler() + + describe("canHandle", () => { + it("should handle write_to_file tool", () => { + const context = createContext(createToolMessage("write_to_file", { path: "test.ts" }), "tool") + expect(handler.canHandle(context)).toBe(true) + }) + + it("should handle apply_diff tool", () => { + const context = createContext(createToolMessage("apply_diff", { path: "test.ts" }), "tool") + expect(handler.canHandle(context)).toBe(true) + }) + + it("should handle create_file tool", () => { + const context = createContext(createToolMessage("create_file", { path: "new.ts" }), "tool") + expect(handler.canHandle(context)).toBe(true) + }) + + it("should handle newFileCreated tool", () => { + const context = createContext(createToolMessage("newFileCreated", { path: "new.ts" }), "tool") + expect(handler.canHandle(context)).toBe(true) + }) + + it("should handle editedExistingFile tool", () => { + const context = createContext(createToolMessage("editedExistingFile", { path: "test.ts" }), "tool") + expect(handler.canHandle(context)).toBe(true) + }) + + it("should not handle read_file tool", () => { + const context = createContext(createToolMessage("read_file", { path: "test.ts" }), "tool") + expect(handler.canHandle(context)).toBe(false) + }) + + it("should not handle command asks", () => { + const context = createContext(createToolMessage("write_to_file", { path: "test.ts" }), "command") + expect(handler.canHandle(context)).toBe(false) + }) + }) + + describe("handle", () => { + it("should return edit kind", () => { + const context = createContext(createToolMessage("write_to_file", { path: "test.ts" }), "tool") + const result = handler.handle(context) + + expect(result.initialUpdate).toMatchObject({ + sessionUpdate: "tool_call", + kind: "edit", + status: "in_progress", + }) + }) + + it("should include completion update", () => { + const context = createContext(createToolMessage("write_to_file", { path: "test.ts" }), "tool") + const result = handler.handle(context) + + expect(result.completionUpdate).toMatchObject({ + sessionUpdate: "tool_call_update", + status: "completed", + }) + }) + + it("should not track as pending command", () => { + const context = createContext(createToolMessage("write_to_file", { path: "test.ts" }), "tool") + const result = handler.handle(context) + + expect(result.trackAsPendingCommand).toBeUndefined() + }) + }) +}) + +// ============================================================================= +// FileReadToolHandler Tests +// ============================================================================= + +describe("FileReadToolHandler", () => { + const handler = new FileReadToolHandler() + + describe("canHandle", () => { + it("should handle read_file tool", () => { + const context = createContext(createToolMessage("read_file", { path: "test.ts" }), "tool") + expect(handler.canHandle(context)).toBe(true) + }) + + it("should handle readFile tool", () => { + const context = createContext(createToolMessage("readFile", { path: "test.ts" }), "tool") + expect(handler.canHandle(context)).toBe(true) + }) + + it("should not handle write_to_file tool", () => { + const context = createContext(createToolMessage("write_to_file", { path: "test.ts" }), "tool") + expect(handler.canHandle(context)).toBe(false) + }) + + it("should not handle command asks", () => { + const context = createContext(createToolMessage("read_file", { path: "test.ts" }), "command") + expect(handler.canHandle(context)).toBe(false) + }) + }) + + describe("handle", () => { + it("should return read kind", () => { + const context = createContext(createToolMessage("read_file", { path: "test.ts" }), "tool") + const result = handler.handle(context) + + expect(result.initialUpdate).toMatchObject({ + sessionUpdate: "tool_call", + kind: "read", + status: "in_progress", + }) + }) + + it("should include completion update", () => { + const context = createContext(createToolMessage("read_file", { path: "test.ts" }), "tool") + const result = handler.handle(context) + + expect(result.completionUpdate).toMatchObject({ + sessionUpdate: "tool_call_update", + status: "completed", + }) + }) + }) +}) + +// ============================================================================= +// SearchToolHandler Tests +// ============================================================================= + +describe("SearchToolHandler", () => { + const handler = new SearchToolHandler() + + describe("canHandle", () => { + it("should handle search_files tool", () => { + const context = createContext(createToolMessage("search_files", { regex: "test" }), "tool") + expect(handler.canHandle(context)).toBe(true) + }) + + it("should handle searchFiles tool", () => { + const context = createContext(createToolMessage("searchFiles", { regex: "test" }), "tool") + expect(handler.canHandle(context)).toBe(true) + }) + + it("should handle codebase_search tool", () => { + const context = createContext(createToolMessage("codebase_search", { query: "test" }), "tool") + expect(handler.canHandle(context)).toBe(true) + }) + + it("should handle grep tool", () => { + const context = createContext(createToolMessage("grep", { pattern: "test" }), "tool") + expect(handler.canHandle(context)).toBe(true) + }) + + it("should not handle custom tool with search in name (exact matching)", () => { + const context = createContext(createToolMessage("custom_search_tool", {}), "tool") + // With exact matching, "custom_search_tool" won't match the search category + expect(handler.canHandle(context)).toBe(false) + }) + + it("should not handle read_file tool", () => { + const context = createContext(createToolMessage("read_file", { path: "test.ts" }), "tool") + expect(handler.canHandle(context)).toBe(false) + }) + }) + + describe("handle", () => { + it("should return search kind", () => { + const context = createContext(createToolMessage("search_files", { regex: "test" }), "tool") + const result = handler.handle(context) + + expect(result.initialUpdate).toMatchObject({ + sessionUpdate: "tool_call", + kind: "search", + status: "in_progress", + }) + }) + + it("should format search results in completion", () => { + const searchResults = "Found 5 results.\n\n# src/file1.ts\n 1 | match\n\n# src/file2.ts\n 2 | match" + const context = createContext(createToolMessage("search_files", { content: searchResults }), "tool") + const result = handler.handle(context) + + expect(result.completionUpdate).toMatchObject({ + sessionUpdate: "tool_call_update", + status: "completed", + }) + + // Content should be formatted - cast to access content property + const completionUpdate = result.completionUpdate as Record + expect(completionUpdate?.content).toBeDefined() + }) + }) +}) + +// ============================================================================= +// ListFilesToolHandler Tests +// ============================================================================= + +describe("ListFilesToolHandler", () => { + const handler = new ListFilesToolHandler() + + describe("canHandle", () => { + it("should handle list_files tool", () => { + const context = createContext(createToolMessage("list_files", { path: "src" }), "tool") + expect(handler.canHandle(context)).toBe(true) + }) + + it("should handle listFiles tool", () => { + const context = createContext(createToolMessage("listFiles", { path: "src" }), "tool") + expect(handler.canHandle(context)).toBe(true) + }) + + it("should handle listFilesTopLevel tool", () => { + const context = createContext(createToolMessage("listFilesTopLevel", { path: "src" }), "tool") + expect(handler.canHandle(context)).toBe(true) + }) + + it("should handle listFilesRecursive tool", () => { + const context = createContext(createToolMessage("listFilesRecursive", { path: "src" }), "tool") + expect(handler.canHandle(context)).toBe(true) + }) + + it("should not handle read_file tool", () => { + const context = createContext(createToolMessage("read_file", { path: "test.ts" }), "tool") + expect(handler.canHandle(context)).toBe(false) + }) + }) + + describe("handle", () => { + it("should return read kind", () => { + const context = createContext(createToolMessage("list_files", { path: "src" }), "tool") + const result = handler.handle(context) + + expect(result.initialUpdate).toMatchObject({ + sessionUpdate: "tool_call", + kind: "read", + status: "in_progress", + }) + }) + }) +}) + +// ============================================================================= +// DefaultToolHandler Tests +// ============================================================================= + +describe("DefaultToolHandler", () => { + const handler = new DefaultToolHandler() + + describe("canHandle", () => { + it("should always return true", () => { + const context1 = createContext(createToolMessage("unknown_tool", {}), "tool") + const context2 = createContext(createToolMessage("custom_operation", {}), "tool") + const context3 = createContext(createToolMessage("any_tool", {}), "browser_action_launch") + + expect(handler.canHandle(context1)).toBe(true) + expect(handler.canHandle(context2)).toBe(true) + expect(handler.canHandle(context3)).toBe(true) + }) + }) + + describe("handle", () => { + it("should map tool kind from tool name (exact matching)", () => { + // Use exact tool name from TOOL_CATEGORIES.think + const context = createContext(createToolMessage("think", {}), "tool") + const result = handler.handle(context) + + expect(result.initialUpdate).toMatchObject({ + sessionUpdate: "tool_call", + kind: "think", + status: "in_progress", + }) + }) + + it("should return other kind for unknown tools (exact matching)", () => { + // Tool names that don't exactly match categories return "other" + const context = createContext(createToolMessage("think_about_it", {}), "tool") + const result = handler.handle(context) + + expect(result.initialUpdate).toMatchObject({ + sessionUpdate: "tool_call", + kind: "other", + status: "in_progress", + }) + }) + + it("should include completion update", () => { + const context = createContext(createToolMessage("custom_tool", {}), "tool") + const result = handler.handle(context) + + expect(result.completionUpdate).toMatchObject({ + sessionUpdate: "tool_call_update", + status: "completed", + }) + }) + }) +}) + +// ============================================================================= +// ToolHandlerRegistry Tests +// ============================================================================= + +describe("ToolHandlerRegistry", () => { + describe("getHandler", () => { + const registry = new ToolHandlerRegistry() + + it("should return CommandToolHandler for command asks", () => { + const context = createContext(createToolMessage("execute_command", {}), "command") + const handler = registry.getHandler(context) + + expect(handler).toBeInstanceOf(CommandToolHandler) + }) + + it("should return FileEditToolHandler for edit tools", () => { + const context = createContext(createToolMessage("write_to_file", { path: "test.ts" }), "tool") + const handler = registry.getHandler(context) + + expect(handler).toBeInstanceOf(FileEditToolHandler) + }) + + it("should return FileReadToolHandler for read tools", () => { + const context = createContext(createToolMessage("read_file", { path: "test.ts" }), "tool") + const handler = registry.getHandler(context) + + expect(handler).toBeInstanceOf(FileReadToolHandler) + }) + + it("should return SearchToolHandler for search tools", () => { + const context = createContext(createToolMessage("search_files", {}), "tool") + const handler = registry.getHandler(context) + + expect(handler).toBeInstanceOf(SearchToolHandler) + }) + + it("should return ListFilesToolHandler for list tools", () => { + const context = createContext(createToolMessage("list_files", {}), "tool") + const handler = registry.getHandler(context) + + expect(handler).toBeInstanceOf(ListFilesToolHandler) + }) + + it("should return DefaultToolHandler for unknown tools", () => { + const context = createContext(createToolMessage("unknown_tool", {}), "tool") + const handler = registry.getHandler(context) + + expect(handler).toBeInstanceOf(DefaultToolHandler) + }) + }) + + describe("handle", () => { + const registry = new ToolHandlerRegistry() + + it("should dispatch to correct handler and return result", () => { + const context = createContext(createToolMessage("execute_command", {}), "command") + const result = registry.handle(context) + + expect(result.initialUpdate).toMatchObject({ + sessionUpdate: "tool_call", + kind: "execute", + status: "in_progress", + }) + expect(result.trackAsPendingCommand).toBeDefined() + }) + }) + + describe("createContext", () => { + it("should create a valid context", () => { + const message = createToolMessage("read_file", { path: "test.ts" }) + const context = ToolHandlerRegistry.createContext(message, "tool", "/workspace", testLogger) + + expect(context.message).toBe(message) + expect(context.ask).toBe("tool") + expect(context.workspacePath).toBe("/workspace") + expect(context.toolInfo).toBeDefined() + expect(context.toolInfo?.name).toBe("read_file") + expect(context.logger).toBe(testLogger) + }) + }) + + describe("custom handlers", () => { + it("should accept custom handler list", () => { + const customHandler = new DefaultToolHandler() + const registry = new ToolHandlerRegistry([customHandler]) + + const context = createContext(createToolMessage("any_tool", {}), "command") + const handler = registry.getHandler(context) + + expect(handler).toBe(customHandler) + }) + }) +}) diff --git a/apps/cli/src/acp/__tests__/translator.test.ts b/apps/cli/src/acp/__tests__/translator.test.ts index 26795e72d6..4f22b0c5fb 100644 --- a/apps/cli/src/acp/__tests__/translator.test.ts +++ b/apps/cli/src/acp/__tests__/translator.test.ts @@ -140,10 +140,17 @@ describe("parseToolFromMessage", () => { describe("mapToolKind", () => { it("should map read operations", () => { + // Uses exact matching with normalized tool names from TOOL_CATEGORIES expect(mapToolKind("read_file")).toBe("read") + expect(mapToolKind("readFile")).toBe("read") + }) + + it("should map list_files to read kind", () => { + // list operations are read-like in the ACP protocol expect(mapToolKind("list_files")).toBe("read") - expect(mapToolKind("inspect_code")).toBe("read") - expect(mapToolKind("get_info")).toBe("read") + expect(mapToolKind("listFiles")).toBe("read") + expect(mapToolKind("listFilesTopLevel")).toBe("read") + expect(mapToolKind("listFilesRecursive")).toBe("read") }) it("should map edit operations", () => { @@ -151,53 +158,76 @@ describe("mapToolKind", () => { expect(mapToolKind("apply_diff")).toBe("edit") expect(mapToolKind("modify_file")).toBe("edit") expect(mapToolKind("create_file")).toBe("edit") + expect(mapToolKind("newFileCreated")).toBe("edit") + expect(mapToolKind("editedExistingFile")).toBe("edit") }) it("should map delete operations", () => { expect(mapToolKind("delete_file")).toBe("delete") - expect(mapToolKind("remove_directory")).toBe("delete") + expect(mapToolKind("deleteFile")).toBe("delete") + expect(mapToolKind("remove_file")).toBe("delete") + expect(mapToolKind("removeFile")).toBe("delete") }) it("should map move operations", () => { expect(mapToolKind("move_file")).toBe("move") + expect(mapToolKind("moveFile")).toBe("move") expect(mapToolKind("rename_file")).toBe("move") - expect(mapToolKind("move_directory")).toBe("move") + expect(mapToolKind("renameFile")).toBe("move") }) it("should map search operations", () => { expect(mapToolKind("search_files")).toBe("search") - expect(mapToolKind("find_references")).toBe("search") - expect(mapToolKind("grep_code")).toBe("search") + expect(mapToolKind("searchFiles")).toBe("search") + expect(mapToolKind("codebase_search")).toBe("search") + expect(mapToolKind("codebaseSearch")).toBe("search") + expect(mapToolKind("grep")).toBe("search") + expect(mapToolKind("ripgrep")).toBe("search") }) it("should map execute operations", () => { expect(mapToolKind("execute_command")).toBe("execute") - expect(mapToolKind("run_script")).toBe("execute") + expect(mapToolKind("executeCommand")).toBe("execute") + expect(mapToolKind("run_command")).toBe("execute") + expect(mapToolKind("runCommand")).toBe("execute") }) it("should map think operations", () => { expect(mapToolKind("think")).toBe("think") - expect(mapToolKind("reasoning_step")).toBe("think") - expect(mapToolKind("plan_execution")).toBe("think") - expect(mapToolKind("analyze_code")).toBe("think") + expect(mapToolKind("reason")).toBe("think") + expect(mapToolKind("plan")).toBe("think") + expect(mapToolKind("analyze")).toBe("think") }) it("should map fetch operations", () => { - expect(mapToolKind("browser_action")).toBe("fetch") - expect(mapToolKind("fetch_url")).toBe("fetch") + // Note: browser_action is NOT mapped to fetch because browser tools are disabled in CLI + expect(mapToolKind("fetch")).toBe("fetch") expect(mapToolKind("web_request")).toBe("fetch") + expect(mapToolKind("webRequest")).toBe("fetch") expect(mapToolKind("http_get")).toBe("fetch") + expect(mapToolKind("httpGet")).toBe("fetch") + expect(mapToolKind("http_post")).toBe("fetch") + expect(mapToolKind("url_fetch")).toBe("fetch") + }) + + it("should map browser_action to other (browser tools disabled in CLI)", () => { + // browser_action intentionally maps to "other" because browser tools are disabled in CLI mode + expect(mapToolKind("browser_action")).toBe("other") }) it("should map switch_mode operations", () => { expect(mapToolKind("switch_mode")).toBe("switch_mode") expect(mapToolKind("switchMode")).toBe("switch_mode") expect(mapToolKind("set_mode")).toBe("switch_mode") + expect(mapToolKind("setMode")).toBe("switch_mode") }) it("should return other for unknown operations", () => { expect(mapToolKind("unknown_tool")).toBe("other") expect(mapToolKind("custom_operation")).toBe("other") + // Tool names that don't exactly match categories also return other + expect(mapToolKind("inspect_code")).toBe("other") + expect(mapToolKind("get_info")).toBe("other") }) }) @@ -345,6 +375,7 @@ describe("buildToolCallFromMessage", () => { const result = buildToolCallFromMessage(message) + // Tool ID is deterministic based on message timestamp for debugging expect(result.toolCallId).toBe("tool-12345") // Title is now human-readable based on tool name and filename expect(result.title).toBe("Read file.txt") @@ -362,6 +393,7 @@ describe("buildToolCallFromMessage", () => { const result = buildToolCallFromMessage(message) + // Tool ID is deterministic based on message timestamp for debugging expect(result.toolCallId).toBe("tool-12345") expect(result.kind).toBe("other") }) diff --git a/apps/cli/src/acp/command-stream.ts b/apps/cli/src/acp/command-stream.ts new file mode 100644 index 0000000000..37b4e2e9d2 --- /dev/null +++ b/apps/cli/src/acp/command-stream.ts @@ -0,0 +1,265 @@ +/** + * CommandStreamManager + * + * Manages streaming of command execution output with code fence wrapping. + * Handles both live command execution events and final command_output messages. + * + * Extracted from session.ts to separate the command output streaming concern. + */ + +import type { ClineMessage } from "@roo-code/types" + +import type { IDeltaTracker, IAcpLogger, SendUpdateFn } from "./interfaces.js" + +// ============================================================================= +// Types +// ============================================================================= + +/** + * Information about a pending command execution. + */ +export interface PendingCommand { + toolCallId: string + command: string + ts: number +} + +/** + * Options for creating a CommandStreamManager. + */ +export interface CommandStreamManagerOptions { + /** Delta tracker for tracking already-sent content */ + deltaTracker: IDeltaTracker + /** Callback to send session updates */ + sendUpdate: SendUpdateFn + /** Logger instance */ + logger: IAcpLogger +} + +// ============================================================================= +// CommandStreamManager Class +// ============================================================================= + +/** + * Manages command output streaming with proper code fence wrapping. + * + * Responsibilities: + * - Track pending command tool calls + * - Handle live command execution output (with code fences) + * - Handle final command_output messages + * - Send tool_call_update notifications + */ +export class CommandStreamManager { + /** + * Track pending command tool calls for the "Run Command" UI. + * Maps tool call ID to command info. + */ + private pendingCommandCalls: Map = new Map() + + /** + * Track which command executions have sent the opening code fence. + * Used to wrap command output in markdown code blocks. + */ + private commandCodeFencesSent: Set = new Set() + + /** + * Map executionId → toolCallId for robust command output routing. + * The executionId is generated by the extension when the command starts, + * so we establish this mapping when we first see output for an executionId. + * This ensures streaming output goes to the correct tool call, even with + * concurrent commands. + */ + private executionToToolCallId: Map = new Map() + + private readonly deltaTracker: IDeltaTracker + private readonly sendUpdate: SendUpdateFn + private readonly logger: IAcpLogger + + constructor(options: CommandStreamManagerOptions) { + this.deltaTracker = options.deltaTracker + this.sendUpdate = options.sendUpdate + this.logger = options.logger + } + + // =========================================================================== + // Public API + // =========================================================================== + + /** + * Track a new pending command. + * Called when a command tool call is approved. + */ + trackCommand(toolCallId: string, command: string, ts: number): void { + this.pendingCommandCalls.set(toolCallId, { toolCallId, command, ts }) + this.logger.debug("CommandStream", `Tracking command: ${toolCallId}`) + } + + /** + * Handle a command_output message from the extension. + * This handles the final tool_call_update for completion, plus the closing fence. + * + * NOTE: Streaming output is handled by handleExecutionOutput(). + * This method handles: + * 1. Sending the closing code fence as agent_message_chunk (if streaming occurred) + * 2. Sending the final tool_call_update with status "completed" + */ + handleCommandOutput(message: ClineMessage): void { + const output = message.text || "" + const isPartial = message.partial === true + + this.logger.debug( + "CommandStream", + `handleCommandOutput: partial=${message.partial}, text length=${output.length}`, + ) + + // Skip partial updates - streaming is handled by handleExecutionOutput() + if (isPartial) { + return + } + + // Handle completion - update the tool call UI + const pendingCall = this.findMostRecentPendingCommand() + + if (pendingCall) { + this.logger.debug("CommandStream", `Command completed: ${pendingCall.toolCallId}`) + + // Send closing code fence as agent_message_chunk if we had streaming output + const hadStreamingOutput = this.commandCodeFencesSent.has(pendingCall.toolCallId) + if (hadStreamingOutput) { + this.logger.debug("CommandStream", "Sending closing code fence via agent_message_chunk") + this.sendUpdate({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "```\n" }, + }) + this.commandCodeFencesSent.delete(pendingCall.toolCallId) + } + + // Command completed - send final tool_call_update with completed status + // Note: Zed doesn't display tool_call_update content, so we just mark it complete + this.sendUpdate({ + sessionUpdate: "tool_call_update", + toolCallId: pendingCall.toolCallId, + status: "completed", + rawOutput: { output }, + }) + this.pendingCommandCalls.delete(pendingCall.toolCallId) + } + } + + /** + * Handle streaming command execution output (live terminal output). + * This provides real-time output during command execution. + * + * Sends output as agent_message_chunk messages for Zed visibility. + * The tool_call UI is updated separately in session-event-handler. + * + * Output is wrapped in markdown code blocks: + * - Opening fence ``` sent on first chunk + * - Subsequent chunks sent as-is (deltas only) + * - Closing fence ``` sent in handleCommandOutput() + * + * Uses executionId → toolCallId mapping for robust routing. + */ + handleExecutionOutput(executionId: string, output: string): void { + this.logger.debug( + "CommandStream", + `handleExecutionOutput: executionId=${executionId}, output length=${output.length}`, + ) + + // Find or establish the toolCallId for this executionId + let toolCallId = this.executionToToolCallId.get(executionId) + + if (!toolCallId) { + // First output for this executionId - establish the mapping + const pendingCall = this.findMostRecentPendingCommand() + if (!pendingCall) { + this.logger.debug("CommandStream", "No pending command, skipping execution output") + return + } + toolCallId = pendingCall.toolCallId + this.executionToToolCallId.set(executionId, toolCallId) + this.logger.debug("CommandStream", `Mapped executionId ${executionId} → toolCallId ${toolCallId}`) + } + + // Use executionId as the message key for delta tracking + const delta = this.deltaTracker.getDelta(executionId, output) + if (!delta) { + return + } + + // Send opening code fence on first chunk + const isFirstChunk = !this.commandCodeFencesSent.has(toolCallId) + if (isFirstChunk) { + this.commandCodeFencesSent.add(toolCallId) + this.logger.debug("CommandStream", `Sending opening code fence for toolCallId ${toolCallId}`) + this.sendUpdate({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "```\n" }, + }) + } + + // Send the delta as agent_message_chunk for Zed visibility + this.logger.debug("CommandStream", `Streaming command output via agent_message_chunk: ${delta.length} chars`) + this.sendUpdate({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: delta }, + }) + } + + /** + * Check if a message is a command_output message that this manager handles. + */ + isCommandOutputMessage(message: ClineMessage): boolean { + return message.type === "say" && message.say === "command_output" + } + + /** + * Reset state for a new prompt. + * Call when starting a new prompt to clear all pending state. + */ + reset(): void { + // Clear all pending commands - any from previous prompts are now stale + // and would cause duplicate completion messages if not cleaned up + const staleCount = this.pendingCommandCalls.size + if (staleCount > 0) { + this.logger.debug("CommandStream", `Clearing ${staleCount} stale pending commands`) + } + this.pendingCommandCalls.clear() + this.commandCodeFencesSent.clear() + this.executionToToolCallId.clear() + this.logger.debug("CommandStream", "Reset command stream state") + } + + /** + * Get the number of pending commands (for testing/debugging). + */ + getPendingCommandCount(): number { + return this.pendingCommandCalls.size + } + + /** + * Check if there are any open code fences (for testing/debugging). + */ + hasOpenCodeFences(): boolean { + return this.commandCodeFencesSent.size > 0 + } + + // =========================================================================== + // Private Methods + // =========================================================================== + + /** + * Find the most recent pending command call. + */ + private findMostRecentPendingCommand(): PendingCommand | undefined { + let pendingCall: PendingCommand | undefined + + for (const [, call] of this.pendingCommandCalls) { + if (!pendingCall || call.ts > pendingCall.ts) { + pendingCall = call + } + } + + return pendingCall + } +} diff --git a/apps/cli/src/acp/content-formatter.ts b/apps/cli/src/acp/content-formatter.ts new file mode 100644 index 0000000000..fd09918722 --- /dev/null +++ b/apps/cli/src/acp/content-formatter.ts @@ -0,0 +1,221 @@ +/** + * Content Formatter + * + * Provides content formatting for ACP UI display. + * + * This module offers two usage patterns: + * + * 1. **Direct function imports** (preferred for simple use cases): + * ```ts + * import { formatSearchResults, wrapInCodeBlock } from './content-formatter.js' + * const formatted = wrapInCodeBlock(formatSearchResults(content)) + * ``` + * + * 2. **Class-based DI** (for dependency injection in tests): + * ```ts + * import { ContentFormatter, type IContentFormatter } from './content-formatter.js' + * const formatter: IContentFormatter = new ContentFormatter() + * ``` + */ + +import type { IContentFormatter } from "./interfaces.js" +import { + formatSearchResults, + formatReadContent, + wrapInCodeBlock, + isUserEcho, + readFileContent, + readFileContentAsync, + extractContentFromParams, + type FormatConfig, + DEFAULT_FORMAT_CONFIG, +} from "./utils/index.js" +import { acpLog } from "./logger.js" + +// ============================================================================= +// Direct Exports (Preferred) +// ============================================================================= + +// Re-export utility functions for direct use +export { formatSearchResults, formatReadContent, wrapInCodeBlock, isUserEcho } + +// ============================================================================= +// Tool Result Formatting +// ============================================================================= + +/** + * Format tool result content based on the tool kind. + * + * Applies appropriate formatting (search summary, truncation, code blocks) + * based on the tool type. + * + * @param kind - The tool kind (search, read, etc.) + * @param content - The raw content to format + * @param config - Optional formatting configuration + * @returns Formatted content + */ +export function formatToolResult(kind: string, content: string, config: FormatConfig = DEFAULT_FORMAT_CONFIG): string { + switch (kind) { + case "search": + return wrapInCodeBlock(formatSearchResults(content)) + case "read": + return wrapInCodeBlock(formatReadContent(content, config)) + default: + return content + } +} + +/** + * Extract file content for readFile operations. + * + * For readFile tools, the rawInput.content field contains the file PATH + * (not the contents), so we need to read the actual file. + * + * @param rawInput - Tool parameters + * @param workspacePath - Workspace path for resolving relative paths + * @returns File content or error message, or undefined if no path + */ +export function extractFileContent(rawInput: Record, workspacePath: string): string | undefined { + const toolName = (rawInput.tool as string | undefined)?.toLowerCase() || "" + + // Only read file content for readFile tools + if (toolName !== "readfile" && toolName !== "read_file") { + return extractContentFromParams(rawInput) + } + + // Check if we have a path before attempting to read + const filePath = rawInput.content as string | undefined + const relativePath = rawInput.path as string | undefined + if (!filePath && !relativePath) { + acpLog.warn("ContentFormatter", "readFile tool has no path") + return undefined + } + + const result = readFileContent(rawInput, workspacePath) + if (result.ok) { + acpLog.debug("ContentFormatter", `Read file content: ${result.value.length} chars`) + return result.value + } else { + acpLog.error("ContentFormatter", result.error) + return `Error reading file: ${result.error}` + } +} + +/** + * Extract file content asynchronously for readFile operations. + * + * @param rawInput - Tool parameters + * @param workspacePath - Workspace path for resolving relative paths + * @returns Promise with file content or error message + */ +export async function extractFileContentAsync( + rawInput: Record, + workspacePath: string, +): Promise { + const toolName = (rawInput.tool as string | undefined)?.toLowerCase() || "" + + // Only read file content for readFile tools + if (toolName !== "readfile" && toolName !== "read_file") { + return extractContentFromParams(rawInput) + } + + // Check if we have a path before attempting to read + const filePath = rawInput.content as string | undefined + const relativePath = rawInput.path as string | undefined + if (!filePath && !relativePath) { + acpLog.warn("ContentFormatter", "readFile tool has no path") + return undefined + } + + const result = await readFileContentAsync(rawInput, workspacePath) + if (result.ok) { + acpLog.debug("ContentFormatter", `Read file content: ${result.value.length} chars`) + return result.value + } else { + acpLog.error("ContentFormatter", result.error) + return `Error reading file: ${result.error}` + } +} + +// ============================================================================= +// ContentFormatter Class (for DI) +// ============================================================================= + +/** + * Formats content for display in the ACP client UI. + * + * Implements IContentFormatter interface for dependency injection. + * For simple use cases, prefer the direct function exports above. + * + * @example + * ```ts + * // In production code + * const formatter = new ContentFormatter() + * + * // In tests with mock + * const mockFormatter: IContentFormatter = { + * formatToolResult: vi.fn(), + * // ... + * } + * ``` + */ +export class ContentFormatter implements IContentFormatter { + private readonly config: FormatConfig + + constructor(config?: Partial) { + this.config = { ...DEFAULT_FORMAT_CONFIG, ...config } + } + + formatToolResult(kind: string, content: string): string { + return formatToolResult(kind, content, this.config) + } + + formatSearchResults(content: string): string { + return formatSearchResults(content) + } + + formatReadResults(content: string): string { + return formatReadContent(content, this.config) + } + + wrapInCodeBlock(content: string, language?: string): string { + return wrapInCodeBlock(content, language) + } + + isUserEcho(text: string, promptText: string | null): boolean { + return isUserEcho(text, promptText) + } + + /** + * Extract content from rawInput parameters. + * Tries common field names for content. + */ + extractContentFromRawInput(rawInput: Record): string | undefined { + return extractContentFromParams(rawInput) + } + + /** + * Extract file content for readFile operations. + * Delegates to the standalone extractFileContent function. + */ + extractFileContent(rawInput: Record, workspacePath: string): string | undefined { + return extractFileContent(rawInput, workspacePath) + } +} + +// ============================================================================= +// Factory Function +// ============================================================================= + +/** + * Create a new content formatter with optional configuration. + */ +export function createContentFormatter(config?: Partial): ContentFormatter { + return new ContentFormatter(config) +} + +// ============================================================================= +// Type Exports +// ============================================================================= + +export type { FormatConfig as ContentFormatterConfig } diff --git a/apps/cli/src/acp/index.ts b/apps/cli/src/acp/index.ts index 12a6e23947..1e06292d19 100644 --- a/apps/cli/src/acp/index.ts +++ b/apps/cli/src/acp/index.ts @@ -1,2 +1,181 @@ +// Main agent exports export { type RooCodeAgentOptions, RooCodeAgent } from "./agent.js" +export { type AcpSessionOptions, AcpSession } from "./session.js" + +// Interfaces for dependency injection +export type { + IAcpLogger, + IAcpSession, + IContentFormatter, + IExtensionClient, + IExtensionHost, + IUpdateBuffer, + IDeltaTracker, + IPromptStateMachine, + ICommandStreamManager, + IToolContentStreamManager, + AcpSessionDependencies, + SendUpdateFn, + PromptStateType, + PromptCompletionResult, + StreamManagerOptions, +} from "./interfaces.js" +export { NullLogger } from "./interfaces.js" + +// Logger export { acpLog } from "./logger.js" + +// Utilities +export { DeltaTracker } from "./delta-tracker.js" +export { UpdateBuffer, type UpdateBufferOptions } from "./update-buffer.js" + +// Shared utility functions +export { + // Result type + type Result, + ok, + err, + // Formatting functions + formatSearchResults, + formatReadContent, + wrapInCodeBlock, + // Content extraction + extractContentFromParams, + // File operations + readFileContent, + readFileContentAsync, + resolveFilePath, + resolveFilePathUnsafe, + // Validation + isUserEcho, + hasValidFilePath, + // Config + type FormatConfig, + DEFAULT_FORMAT_CONFIG, +} from "./utils/index.js" + +// Tool Registry +export { + // Categories + TOOL_CATEGORIES, + type ToolCategory, + type KnownToolName, + // Detection functions + isEditTool, + isReadTool, + isSearchTool, + isListFilesTool, + isExecuteTool, + isDeleteTool, + isMoveTool, + isThinkTool, + isFetchTool, + isSwitchModeTool, + isFileWriteTool, + // Kind mapping + mapToolToKind, + // Validation schemas + FilePathParamsSchema, + FileWriteParamsSchema, + FileMoveParamsSchema, + SearchParamsSchema, + ListFilesParamsSchema, + CommandParamsSchema, + ThinkParamsSchema, + SwitchModeParamsSchema, + GenericToolParamsSchema, + ToolMessageSchema, + // Parameter types + type FilePathParams, + type FileWriteParams, + type FileMoveParams, + type SearchParams, + type ListFilesParams, + type CommandParams, + type ThinkParams, + type SwitchModeParams, + type GenericToolParams, + type ToolParams, + type ToolMessage, + // Validation functions + type ValidationResult, + validateToolParams, + parseToolParams, + parseToolMessage, +} from "./tool-registry.js" + +// State management +export { PromptStateMachine, createPromptStateMachine, type PromptStateMachineOptions } from "./prompt-state.js" + +// Content formatting +export { + // Direct function exports (preferred for simple use) + formatToolResult, + extractFileContent, + extractFileContentAsync, + // Re-exported utilities + formatSearchResults as formatSearch, + formatReadContent as formatRead, + wrapInCodeBlock as wrapCode, + isUserEcho as checkUserEcho, + // Class-based DI + ContentFormatter, + createContentFormatter, + type ContentFormatterConfig, +} from "./content-formatter.js" + +// Tool handlers +export { + type ToolHandler, + type ToolHandlerContext, + type ToolHandleResult, + ToolHandlerRegistry, + // Individual handlers for extension + CommandToolHandler, + FileEditToolHandler, + FileReadToolHandler, + SearchToolHandler, + ListFilesToolHandler, + DefaultToolHandler, +} from "./tool-handler.js" + +// Stream managers +export { CommandStreamManager, type PendingCommand, type CommandStreamManagerOptions } from "./command-stream.js" +export { ToolContentStreamManager, type ToolContentStreamManagerOptions } from "./tool-content-stream.js" + +// Session event handler +export { + SessionEventHandler, + createSessionEventHandler, + type SessionEventHandlerDeps, + type TaskCompletedCallback, +} from "./session-event-handler.js" + +// Translation utilities +export { + // Message translation + translateToAcpUpdate, + isPermissionAsk, + isCompletionAsk, + createPermissionOptions, + // Tool parsing + parseToolFromMessage, + generateToolTitle, + extractToolContent, + buildToolCallFromMessage, + type ToolCallInfo, + // Prompt extraction + extractPromptText, + extractPromptImages, + extractPromptResources, + // Location extraction + extractLocations, + extractFilePathsFromSearchResults, + type LocationParams, + // Diff parsing + parseUnifiedDiff, + isUnifiedDiff, + type ParsedDiff, + // Backward compatibility + mapToolKind, +} from "./translator.js" diff --git a/apps/cli/src/acp/interfaces.ts b/apps/cli/src/acp/interfaces.ts new file mode 100644 index 0000000000..3bbd872d91 --- /dev/null +++ b/apps/cli/src/acp/interfaces.ts @@ -0,0 +1,378 @@ +/** + * ACP Interfaces + * + * Defines interfaces for dependency injection and testability. + * These interfaces allow for mocking in tests and swapping implementations. + */ + +import type * as acp from "@agentclientprotocol/sdk" + +// ============================================================================= +// Logger Interface +// ============================================================================= + +/** + * Interface for ACP logging. + * Allows for different logging implementations (file, console, mock for tests). + */ +export interface IAcpLogger { + /** + * Log an info message. + */ + info(component: string, message: string, data?: unknown): void + + /** + * Log a debug message. + */ + debug(component: string, message: string, data?: unknown): void + + /** + * Log a warning message. + */ + warn(component: string, message: string, data?: unknown): void + + /** + * Log an error message. + */ + error(component: string, message: string, data?: unknown): void + + /** + * Log an incoming request. + */ + request(method: string, params?: unknown): void + + /** + * Log an outgoing response. + */ + response(method: string, result?: unknown): void + + /** + * Log an outgoing notification. + */ + notification(method: string, params?: unknown): void +} + +// ============================================================================= +// Content Formatter Interface +// ============================================================================= + +/** + * Interface for content formatting operations. + */ +export interface IContentFormatter { + /** + * Format tool result content based on the tool kind. + */ + formatToolResult(kind: string, content: string): string + + /** + * Format search results into a clean summary with file list. + */ + formatSearchResults(content: string): string + + /** + * Format read results by truncating long file contents. + */ + formatReadResults(content: string): string + + /** + * Wrap content in markdown code block for better rendering. + */ + wrapInCodeBlock(content: string, language?: string): string + + /** + * Check if a text message is an echo of the user's prompt. + */ + isUserEcho(text: string, promptText: string | null): boolean +} + +// ============================================================================= +// Session Interface +// ============================================================================= + +/** + * Interface for ACP Session. + * Enables mocking for tests. + */ +export interface IAcpSession { + /** + * Process a prompt request from the ACP client. + */ + prompt(params: acp.PromptRequest): Promise + + /** + * Cancel the current prompt. + */ + cancel(): void + + /** + * Set the session mode. + */ + setMode(mode: string): void + + /** + * Dispose of the session and release resources. + */ + dispose(): Promise + + /** + * Get the session ID. + */ + getSessionId(): string +} + +// ============================================================================= +// Extension Client Interface +// ============================================================================= + +/** + * Events emitted by the extension client. + */ +export interface ExtensionClientEvents { + message: (msg: unknown) => void + messageUpdated: (msg: unknown) => void + waitingForInput: (event: unknown) => void + commandExecutionOutput: (event: unknown) => void + taskCompleted: (event: unknown) => void +} + +/** + * Interface for extension client interactions. + */ +export interface IExtensionClient { + on(event: K, handler: ExtensionClientEvents[K]): void + off(event: K, handler: ExtensionClientEvents[K]): void + respond(text: string): void + approve(): void + reject(message?: string): void +} + +// ============================================================================= +// Extension Host Interface +// ============================================================================= + +/** + * Interface for extension host interactions. + */ +export interface IExtensionHost { + /** + * Get the extension client for event handling. + */ + readonly client: IExtensionClient + + /** + * Activate the extension host. + */ + activate(): Promise + + /** + * Dispose of the extension host. + */ + dispose(): Promise + + /** + * Send a message to the extension. + */ + sendToExtension(message: unknown): void +} + +// ============================================================================= +// Update Buffer Interface +// ============================================================================= + +/** + * Interface for update buffering. + */ +export interface IUpdateBuffer { + /** + * Queue an update for sending. + */ + queueUpdate(update: acp.SessionNotification["update"]): Promise + + /** + * Flush all pending buffered content. + */ + flush(): Promise + + /** + * Reset the buffer state. + */ + reset(): void +} + +// ============================================================================= +// Delta Tracker Interface +// ============================================================================= + +/** + * Interface for delta tracking. + */ +export interface IDeltaTracker { + /** + * Get the delta (new portion) of text that hasn't been sent yet. + */ + getDelta(id: string | number, fullText: string): string + + /** + * Check if there would be a delta without updating tracking. + */ + peekDelta(id: string | number, fullText: string): string + + /** + * Reset all tracking. + */ + reset(): void + + /** + * Reset tracking for a specific ID only. + */ + resetId(id: string | number): void +} + +// ============================================================================= +// Prompt State Interface +// ============================================================================= + +/** + * Valid states for a prompt turn. + * + * - idle: No prompt is being processed, ready for new prompts + * - processing: A prompt is actively being processed + */ +export type PromptStateType = "idle" | "processing" + +/** + * Result of completing a prompt. + */ +export interface PromptCompletionResult { + stopReason: acp.StopReason +} + +/** + * Interface for prompt state management. + */ +export interface IPromptStateMachine { + /** + * Get the current state. + */ + getState(): PromptStateType + + /** + * Get the abort signal for the current prompt. + */ + getAbortSignal(): AbortSignal | null + + /** + * Get the current prompt text. + */ + getPromptText(): string | null + + /** + * Check if a prompt can be started. + */ + canStartPrompt(): boolean + + /** + * Check if currently processing a prompt. + */ + isProcessing(): boolean + + /** + * Start a new prompt. + */ + startPrompt(promptText: string): Promise + + /** + * Complete the prompt with success or failure. + */ + complete(success: boolean): acp.StopReason + + /** + * Cancel the current prompt. + */ + cancel(): void + + /** + * Reset to idle state. + */ + reset(): void +} + +// ============================================================================= +// Stream Manager Interfaces +// ============================================================================= + +/** + * Callback to send an ACP session update. + */ +export type SendUpdateFn = (update: acp.SessionNotification["update"]) => void + +/** + * Options for creating stream managers. + */ +export interface StreamManagerOptions { + /** Delta tracker for tracking already-sent content */ + deltaTracker: IDeltaTracker + /** Callback to send session updates */ + sendUpdate: SendUpdateFn + /** Logger instance */ + logger: IAcpLogger +} + +/** + * Interface for command output streaming. + */ +export interface ICommandStreamManager { + trackCommand(toolCallId: string, command: string, ts: number): void + handleCommandOutput(message: unknown): void + handleExecutionOutput(executionId: string, output: string): void + isCommandOutputMessage(message: unknown): boolean + reset(): void +} + +/** + * Interface for tool content streaming. + */ +export interface IToolContentStreamManager { + isToolAskMessage(message: unknown): boolean + handleToolContentStreaming(message: unknown): boolean + reset(): void +} + +// ============================================================================= +// Session Dependencies +// ============================================================================= + +/** + * Dependencies required for creating an AcpSession. + * Enables dependency injection for testing. + */ +export interface AcpSessionDependencies { + /** Logger instance */ + logger?: IAcpLogger + /** Content formatter instance */ + contentFormatter?: IContentFormatter + /** Delta tracker factory */ + createDeltaTracker?: () => IDeltaTracker + /** Update buffer factory */ + createUpdateBuffer?: (sendUpdate: (update: acp.SessionNotification["update"]) => Promise) => IUpdateBuffer + /** Prompt state machine factory */ + createPromptStateMachine?: () => IPromptStateMachine +} + +// ============================================================================= +// Null/Mock Implementations for Testing +// ============================================================================= + +/** + * No-op logger implementation for testing. + */ +export class NullLogger implements IAcpLogger { + info(_component: string, _message: string, _data?: unknown): void {} + debug(_component: string, _message: string, _data?: unknown): void {} + warn(_component: string, _message: string, _data?: unknown): void {} + error(_component: string, _message: string, _data?: unknown): void {} + request(_method: string, _params?: unknown): void {} + response(_method: string, _result?: unknown): void {} + notification(_method: string, _params?: unknown): void {} +} diff --git a/apps/cli/src/acp/logger.ts b/apps/cli/src/acp/logger.ts index 041355f60a..22c393c195 100644 --- a/apps/cli/src/acp/logger.ts +++ b/apps/cli/src/acp/logger.ts @@ -13,6 +13,8 @@ import * as fs from "node:fs" import * as path from "node:path" import * as os from "node:os" +import type { IAcpLogger } from "./interfaces.js" + // ============================================================================= // Configuration // ============================================================================= @@ -25,7 +27,7 @@ const MAX_LOG_SIZE = 10 * 1024 * 1024 // 10MB // Logger Class // ============================================================================= -class AcpLogger { +class AcpLogger implements IAcpLogger { private logPath: string private enabled: boolean = true private stream: fs.WriteStream | null = null diff --git a/apps/cli/src/acp/prompt-state.ts b/apps/cli/src/acp/prompt-state.ts new file mode 100644 index 0000000000..0f2b9a4c09 --- /dev/null +++ b/apps/cli/src/acp/prompt-state.ts @@ -0,0 +1,254 @@ +/** + * Prompt State Machine + * + * Manages the lifecycle state of a prompt turn in a type-safe way. + * Replaces boolean flags with explicit state transitions and guards. + * + * State transitions: + * idle -> processing (on startPrompt) + * processing -> idle (on complete/cancel) + * idle -> idle (reset) + * + * This state machine ensures: + * - Only one prompt can be active at a time + * - State transitions are valid + * - Stop reasons are correctly mapped + */ + +import type * as acp from "@agentclientprotocol/sdk" +import type { IAcpLogger } from "./interfaces.js" +import { NullLogger } from "./interfaces.js" + +// ============================================================================= +// Types +// ============================================================================= + +/** + * Valid states for a prompt turn. + * + * - idle: No prompt is being processed, ready for new prompts + * - processing: A prompt is actively being processed + */ +export type PromptStateType = "idle" | "processing" + +/** + * Result of completing a prompt. + */ +export interface PromptCompletionResult { + stopReason: acp.StopReason +} + +/** + * Events that can occur during prompt lifecycle. + */ +export type PromptEvent = + | { type: "START_PROMPT" } + | { type: "COMPLETE"; success: boolean } + | { type: "CANCEL" } + | { type: "RESET" } + +/** + * Options for creating a PromptStateMachine. + */ +export interface PromptStateMachineOptions { + /** Logger instance (optional, defaults to NullLogger) */ + logger?: IAcpLogger +} + +// ============================================================================= +// PromptStateMachine Class +// ============================================================================= + +/** + * State machine for managing prompt lifecycle. + * + * Provides explicit state transitions with validation, + * replacing ad-hoc boolean flag management. + */ +export class PromptStateMachine { + private state: PromptStateType = "idle" + private abortController: AbortController | null = null + private resolvePrompt: ((result: PromptCompletionResult) => void) | null = null + private currentPromptText: string | null = null + private readonly logger: IAcpLogger + + constructor(options: PromptStateMachineOptions = {}) { + this.logger = options.logger ?? new NullLogger() + } + + /** + * Get the current state. + */ + getState(): PromptStateType { + return this.state + } + + /** + * Get the abort signal for the current prompt. + */ + getAbortSignal(): AbortSignal | null { + return this.abortController?.signal ?? null + } + + /** + * Get the current prompt text (for echo detection). + */ + getCurrentPromptText(): string | null { + return this.currentPromptText + } + + /** + * Alias for getCurrentPromptText for compatibility. + */ + getPromptText(): string | null { + return this.currentPromptText + } + + /** + * Check if a prompt can be started. + */ + canStartPrompt(): boolean { + return this.state === "idle" + } + + /** + * Check if currently processing a prompt. + */ + isProcessing(): boolean { + return this.state === "processing" + } + + /** + * Start a new prompt. + * + * @param promptText - The user's prompt text (for echo detection) + * @returns A promise that resolves when the prompt completes + * @throws If a prompt is already in progress + */ + startPrompt(promptText: string): Promise { + if (this.state !== "idle") { + this.logger.warn("PromptStateMachine", `Cannot start prompt in state: ${this.state}`) + // Cancel existing prompt first + this.cancel() + } + + this.logger.debug("PromptStateMachine", "Transitioning: idle -> processing") + this.state = "processing" + this.abortController = new AbortController() + this.currentPromptText = promptText + + return new Promise((resolve) => { + this.resolvePrompt = resolve + + // Handle abort signal + this.abortController?.signal.addEventListener("abort", () => { + if (this.state === "processing") { + this.logger.debug("PromptStateMachine", "Abort signal received") + this.transitionToComplete("cancelled") + } + }) + }) + } + + /** + * Complete the prompt with success or failure. + * + * @param success - Whether the task completed successfully + * @returns The stop reason that was used + */ + complete(success: boolean): acp.StopReason { + const stopReason = this.mapSuccessToStopReason(success) + this.transitionToComplete(stopReason) + return stopReason + } + + /** + * Cancel the current prompt. + * + * Safe to call even if no prompt is active. + */ + cancel(): void { + if (this.state !== "processing") { + this.logger.debug("PromptStateMachine", `Cancel ignored in state: ${this.state}`) + return + } + + this.logger.debug("PromptStateMachine", "Cancelling prompt") + this.abortController?.abort() + // Note: The abort handler will call transitionToComplete + } + + /** + * Reset to idle state. + * + * Should be called when starting a new prompt to ensure clean state. + */ + reset(): void { + this.logger.debug("PromptStateMachine", `Resetting from state: ${this.state}`) + + // Clean up any pending resources + if (this.abortController) { + this.abortController.abort() + this.abortController = null + } + + this.state = "idle" + this.resolvePrompt = null + this.currentPromptText = null + } + + // =========================================================================== + // Private Methods + // =========================================================================== + + /** + * Transition to completion and resolve the promise. + */ + private transitionToComplete(stopReason: acp.StopReason): void { + if (this.state !== "processing") { + this.logger.debug("PromptStateMachine", `Already completed, ignoring transition with reason: ${stopReason}`) + return + } + + this.logger.debug("PromptStateMachine", `Transitioning: processing -> idle (reason: ${stopReason})`) + + this.state = "idle" + + // Resolve the promise + if (this.resolvePrompt) { + this.resolvePrompt({ stopReason }) + this.resolvePrompt = null + } + + // Clean up + this.abortController = null + this.currentPromptText = null + } + + /** + * Map task success to ACP stop reason. + * + * ACP defines these stop reasons: + * - end_turn: Normal completion + * - max_tokens: Token limit reached + * - max_turn_requests: Request limit reached + * - refusal: Agent refused to continue + * - cancelled: User cancelled + */ + private mapSuccessToStopReason(success: boolean): acp.StopReason { + // Use "refusal" for failed tasks as it's the closest match + // (indicates the task couldn't continue normally) + return success ? "end_turn" : "refusal" + } +} + +// ============================================================================= +// Factory Function +// ============================================================================= + +/** + * Create a new prompt state machine. + */ +export function createPromptStateMachine(options?: PromptStateMachineOptions): PromptStateMachine { + return new PromptStateMachine(options) +} diff --git a/apps/cli/src/acp/session-event-handler.ts b/apps/cli/src/acp/session-event-handler.ts new file mode 100644 index 0000000000..431a665bd3 --- /dev/null +++ b/apps/cli/src/acp/session-event-handler.ts @@ -0,0 +1,431 @@ +/** + * Session Event Handler + * + * Handles events from the ExtensionClient and translates them to ACP updates. + * Extracted from session.ts for better separation of concerns. + */ + +import type { ClineMessage, ClineAsk, ClineSay } from "@roo-code/types" + +import type { WaitingForInputEvent, TaskCompletedEvent, CommandExecutionOutputEvent } from "@/agent/events.js" + +import { translateToAcpUpdate, isPermissionAsk, isCompletionAsk } from "./translator.js" +import { isUserEcho } from "./utils/index.js" +import type { + IAcpLogger, + IExtensionClient, + IPromptStateMachine, + ICommandStreamManager, + IToolContentStreamManager, + IDeltaTracker, + SendUpdateFn, +} from "./interfaces.js" +import { ToolHandlerRegistry } from "./tool-handler.js" + +// ============================================================================= +// Streaming Configuration +// ============================================================================= + +/** + * Configuration for streaming content types. + * Defines which message types should be delta-streamed and how. + */ +interface StreamConfig { + /** ACP update type to use */ + readonly updateType: "agent_message_chunk" | "agent_thought_chunk" + /** Optional transform to apply to the text before delta tracking */ + readonly textTransform?: (text: string) => string +} + +/** + * Type for the delta stream configuration map. + * Uses Partial> for type safety. + */ +type DeltaStreamConfigMap = Partial> + +/** + * Declarative configuration for which `say` types should be delta-streamed. + * Any say type not listed here will fall through to the translator for + * non-streaming handling. + * + * Type safety is enforced by: + * - DELTA_STREAM_KEYS constrained to ClineSay values + * - DeltaStreamConfigMap type annotation + * + * To add a new streaming type: + * 1. Add the key to DELTA_STREAM_KEYS + * 2. Add the configuration below + */ +const DELTA_STREAM_CONFIG: DeltaStreamConfigMap = { + // Regular text messages from the agent + text: { updateType: "agent_message_chunk" }, + + // Command output (terminal results, etc.) + command_output: { updateType: "agent_message_chunk" }, + + // Final completion summary + completion_result: { updateType: "agent_message_chunk" }, + + // Agent's reasoning/thinking + reasoning: { updateType: "agent_thought_chunk" }, + + // Error messages (prefixed with "Error: ") + error: { + updateType: "agent_message_chunk", + textTransform: (text: string) => `Error: ${text}`, + }, +} + +/** + * Get stream configuration for a say type. + * Returns undefined if the say type is not configured for streaming. + */ +function getStreamConfig(sayType: ClineSay): StreamConfig | undefined { + return DELTA_STREAM_CONFIG[sayType] +} + +// ============================================================================= +// Types +// ============================================================================= + +/** + * Dependencies for the SessionEventHandler. + */ +export interface SessionEventHandlerDeps { + /** Logger instance */ + logger: IAcpLogger + /** Extension client for event subscription */ + client: IExtensionClient + /** Prompt state machine */ + promptState: IPromptStateMachine + /** Delta tracker for streaming */ + deltaTracker: IDeltaTracker + /** Command stream manager */ + commandStreamManager: ICommandStreamManager + /** Tool content stream manager */ + toolContentStreamManager: IToolContentStreamManager + /** Tool handler registry */ + toolHandlerRegistry: ToolHandlerRegistry + /** Callback to send updates */ + sendUpdate: SendUpdateFn + /** Callback to approve extension actions */ + approveAction: () => void + /** Callback to respond with text */ + respondWithText: (text: string) => void + /** Callback to send message to extension */ + sendToExtension: (message: unknown) => void + /** Workspace path */ + workspacePath: string +} + +/** + * Callback for task completion. + */ +export type TaskCompletedCallback = (success: boolean) => void + +// ============================================================================= +// SessionEventHandler Class +// ============================================================================= + +/** + * Handles events from the ExtensionClient and translates them to ACP updates. + * + * Responsibilities: + * - Subscribe to extension client events + * - Handle streaming for text/reasoning messages + * - Handle tool permission requests + * - Handle task completion + */ +export class SessionEventHandler { + private readonly logger: IAcpLogger + private readonly client: IExtensionClient + private readonly promptState: IPromptStateMachine + private readonly deltaTracker: IDeltaTracker + private readonly commandStreamManager: ICommandStreamManager + private readonly toolContentStreamManager: IToolContentStreamManager + private readonly toolHandlerRegistry: ToolHandlerRegistry + private readonly sendUpdate: SendUpdateFn + private readonly approveAction: () => void + private readonly respondWithText: (text: string) => void + private readonly sendToExtension: (message: unknown) => void + private readonly workspacePath: string + + private taskCompletedCallback: TaskCompletedCallback | null = null + + /** + * Track processed permission requests to prevent duplicates. + * The extension may fire multiple waitingForInput events for the same tool call + * as the message is updated. We deduplicate by generating a stable key from + * the ask type and relevant content. + */ + private processedPermissions: Set = new Set() + + constructor(deps: SessionEventHandlerDeps) { + this.logger = deps.logger + this.client = deps.client + this.promptState = deps.promptState + this.deltaTracker = deps.deltaTracker + this.commandStreamManager = deps.commandStreamManager + this.toolContentStreamManager = deps.toolContentStreamManager + this.toolHandlerRegistry = deps.toolHandlerRegistry + this.sendUpdate = deps.sendUpdate + this.approveAction = deps.approveAction + this.respondWithText = deps.respondWithText + this.sendToExtension = deps.sendToExtension + this.workspacePath = deps.workspacePath + } + + // =========================================================================== + // Public API + // =========================================================================== + + /** + * Set up event handlers to translate ExtensionClient events to ACP updates. + */ + setupEventHandlers(): void { + // Handle new messages + this.client.on("message", (msg: unknown) => { + this.handleMessage(msg as ClineMessage) + }) + + // Handle message updates (partial -> complete) + this.client.on("messageUpdated", (msg: unknown) => { + this.handleMessage(msg as ClineMessage) + }) + + // Handle permission requests (tool calls, commands, etc.) + this.client.on("waitingForInput", (event: unknown) => { + void this.handleWaitingForInput(event as WaitingForInputEvent) + }) + + // Handle streaming command execution output (live terminal output) + this.client.on("commandExecutionOutput", (event: unknown) => { + const cmdEvent = event as CommandExecutionOutputEvent + this.commandStreamManager.handleExecutionOutput(cmdEvent.executionId, cmdEvent.output) + }) + + // Handle task completion + this.client.on("taskCompleted", (event: unknown) => { + this.handleTaskCompleted(event as TaskCompletedEvent) + }) + } + + /** + * Set the callback for task completion. + */ + onTaskCompleted(callback: TaskCompletedCallback): void { + this.taskCompletedCallback = callback + } + + /** + * Reset state for a new prompt. + */ + reset(): void { + this.deltaTracker.reset() + this.commandStreamManager.reset() + this.toolContentStreamManager.reset() + this.processedPermissions.clear() + } + + // =========================================================================== + // Message Handling + // =========================================================================== + + /** + * Handle an incoming message from the extension. + * + * Uses the declarative DELTA_STREAM_CONFIG to automatically determine + * which message types should be delta-streamed and how. + */ + private handleMessage(message: ClineMessage): void { + this.logger.debug( + "SessionEventHandler", + `Message received: type=${message.type}, say=${message.say}, ask=${message.ask}, ts=${message.ts}, partial=${message.partial}`, + ) + + // Handle streaming for tool ask messages (file creates/edits) + // These contain content that grows as the LLM generates it + if (this.toolContentStreamManager.isToolAskMessage(message)) { + this.toolContentStreamManager.handleToolContentStreaming(message) + return + } + + // Check if this is a streaming message type + if (message.type === "say" && message.text && message.say) { + // Handle command_output specially for the "Run Command" UI + if (this.commandStreamManager.isCommandOutputMessage(message)) { + this.commandStreamManager.handleCommandOutput(message) + return + } + + const config = getStreamConfig(message.say) + + if (config) { + // Filter out user message echo + if (message.say === "text" && isUserEcho(message.text, this.promptState.getPromptText())) { + this.logger.debug("SessionEventHandler", `Skipping user echo (${message.text.length} chars)`) + return + } + + // Apply text transform if configured (e.g., "Error: " prefix) + const textToSend = config.textTransform ? config.textTransform(message.text) : message.text + + // Get delta using the tracker (handles all bookkeeping automatically) + const delta = this.deltaTracker.getDelta(message.ts, textToSend) + + if (delta) { + this.sendUpdate({ + sessionUpdate: config.updateType, + content: { type: "text", text: delta }, + }) + } + return + } + } + + // For non-streaming message types, use the translator + const update = translateToAcpUpdate(message) + if (update) { + this.logger.notification("sessionUpdate", { + updateKind: (update as { sessionUpdate?: string }).sessionUpdate, + }) + this.sendUpdate(update) + } + } + + // =========================================================================== + // Permission Handling + // =========================================================================== + + /** + * Handle waiting for input events (permission requests). + */ + private async handleWaitingForInput(event: WaitingForInputEvent): Promise { + const { ask, message } = event + const askType = ask as ClineAsk + this.logger.debug("SessionEventHandler", `Waiting for input: ask=${askType}`) + + // Handle permission-required asks + if (isPermissionAsk(askType)) { + this.logger.info("SessionEventHandler", `Permission request: ${askType}`) + this.handlePermissionRequest(message, askType) + return + } + + // Handle completion asks + if (isCompletionAsk(askType)) { + this.logger.debug("SessionEventHandler", "Completion ask - handled by taskCompleted event") + // Completion is handled by taskCompleted event + return + } + + // Handle followup questions - auto-continue for now + // In a more sophisticated implementation, these could be surfaced + // to the ACP client for user input + if (askType === "followup") { + this.logger.debug("SessionEventHandler", "Auto-responding to followup") + this.respondWithText("") + return + } + + // Handle resume_task - auto-resume + if (askType === "resume_task") { + this.logger.debug("SessionEventHandler", "Auto-approving resume_task") + this.approveAction() + return + } + + // Handle API failures - auto-retry for now + if (askType === "api_req_failed") { + this.logger.warn("SessionEventHandler", "API request failed, auto-retrying") + this.approveAction() + return + } + + // Default: approve and continue + this.logger.debug("SessionEventHandler", `Auto-approving unknown ask type: ${askType}`) + this.approveAction() + } + + /** + * Handle a permission request for a tool call. + * + * Uses the ToolHandlerRegistry for polymorphic dispatch to the appropriate + * handler based on tool type. Auto-approves all tool calls without prompting + * the user, allowing autonomous operation. + * + * For commands, tracks the call to enable the "Run Command" UI with output. + * For other tools (search, read, etc.), both initial and completion updates + * are sent immediately as the results are already available. + */ + private handlePermissionRequest(message: ClineMessage, ask: ClineAsk): void { + // Generate a stable key for deduplication based on ask type and content + // The extension may fire multiple waitingForInput events for the same tool + // as the message is updated. We use the message text as a stable identifier. + const permissionKey = `${ask}:${message.text || ""}` + + // Check if we've already processed this permission request + if (this.processedPermissions.has(permissionKey)) { + this.logger.debug("SessionEventHandler", `Skipping duplicate permission request: ${ask}`) + // Still need to approve the action to unblock the extension + this.approveAction() + return + } + + // Mark this permission as processed + this.processedPermissions.add(permissionKey) + + // Create context for the tool handler + const context = ToolHandlerRegistry.createContext(message, ask, this.workspacePath, this.logger) + + // Dispatch to the appropriate handler via the registry + const result = this.toolHandlerRegistry.handle(context) + + this.logger.debug("SessionEventHandler", `Auto-approving tool: ask=${ask}`) + this.logger.debug("SessionEventHandler", `Sending tool_call update`) + + // Send the initial in_progress update + this.sendUpdate(result.initialUpdate) + + // Track pending commands for the "Run Command" UI + if (result.trackAsPendingCommand) { + const { toolCallId, command, ts } = result.trackAsPendingCommand + this.commandStreamManager.trackCommand(toolCallId, command, ts) + } + + // Send completion update if available (non-command tools) + if (result.completionUpdate) { + this.logger.debug("SessionEventHandler", `Sending tool_call_update (completed)`) + this.sendUpdate(result.completionUpdate) + } + + // Auto-approve the tool call + this.approveAction() + } + + // =========================================================================== + // Task Completion + // =========================================================================== + + /** + * Handle task completion. + */ + private handleTaskCompleted(event: TaskCompletedEvent): void { + this.logger.info("SessionEventHandler", `Task completed: success=${event.success}`) + + if (this.taskCompletedCallback) { + this.taskCompletedCallback(event.success) + } + } +} + +// ============================================================================= +// Factory Function +// ============================================================================= + +/** + * Create a new SessionEventHandler instance. + */ +export function createSessionEventHandler(deps: SessionEventHandlerDeps): SessionEventHandler { + return new SessionEventHandler(deps) +} diff --git a/apps/cli/src/acp/session.ts b/apps/cli/src/acp/session.ts index ea57dcc89b..f10e758872 100644 --- a/apps/cli/src/acp/session.ts +++ b/apps/cli/src/acp/session.ts @@ -3,72 +3,36 @@ * * Manages a single ACP session, wrapping an ExtensionHost instance. * Handles message translation, event streaming, and permission requests. - * - * Commands are executed internally by the extension (like the reference - * implementations gemini-cli and opencode), not through ACP terminals. */ -import * as fs from "node:fs" -import * as path from "node:path" -import * as acp from "@agentclientprotocol/sdk" -import type { ClineMessage, ClineAsk, ClineSay } from "@roo-code/types" +import { + type SessionNotification, + type ClientCapabilities, + type PromptRequest, + type PromptResponse, + AgentSideConnection, +} from "@agentclientprotocol/sdk" import { type ExtensionHostOptions, ExtensionHost } from "@/agent/extension-host.js" -import type { WaitingForInputEvent, TaskCompletedEvent, CommandExecutionOutputEvent } from "@/agent/events.js" -import { - translateToAcpUpdate, - isPermissionAsk, - isCompletionAsk, - extractPromptText, - extractPromptImages, - buildToolCallFromMessage, -} from "./translator.js" +import { extractPromptText, extractPromptImages } from "./translator.js" import { acpLog } from "./logger.js" import { DeltaTracker } from "./delta-tracker.js" import { UpdateBuffer } from "./update-buffer.js" - -// ============================================================================= -// Streaming Configuration -// ============================================================================= - -/** - * Configuration for streaming content types. - * Defines which message types should be delta-streamed and how. - */ -interface StreamConfig { - /** ACP update type to use */ - updateType: "agent_message_chunk" | "agent_thought_chunk" - /** Optional transform to apply to the text before delta tracking */ - textTransform?: (text: string) => string -} - -/** - * Declarative configuration for which `say` types should be delta-streamed. - * Any say type not listed here will fall through to the translator for - * non-streaming handling. - * - * To add a new streaming type, simply add it to this map. - */ -const DELTA_STREAM_CONFIG: Partial> = { - // Regular text messages from the agent - text: { updateType: "agent_message_chunk" }, - - // Command output (terminal results, etc.) - command_output: { updateType: "agent_message_chunk" }, - - // Final completion summary - completion_result: { updateType: "agent_message_chunk" }, - - // Agent's reasoning/thinking - reasoning: { updateType: "agent_thought_chunk" }, - - // Error messages (prefixed with "Error: ") - error: { - updateType: "agent_message_chunk", - textTransform: (text) => `Error: ${text}`, - }, -} +import { PromptStateMachine } from "./prompt-state.js" +import { ToolHandlerRegistry } from "./tool-handler.js" +import { CommandStreamManager } from "./command-stream.js" +import { ToolContentStreamManager } from "./tool-content-stream.js" +import { SessionEventHandler, createSessionEventHandler } from "./session-event-handler.js" +import type { + IAcpSession, + IAcpLogger, + IDeltaTracker, + IUpdateBuffer, + IPromptStateMachine, + AcpSessionDependencies, +} from "./interfaces.js" +import { type Result, ok, err } from "./utils/index.js" // ============================================================================= // Types @@ -87,10 +51,6 @@ export interface AcpSessionOptions { mode: string } -// ============================================================================= -// AcpSession Class -// ============================================================================= - /** * AcpSession wraps an ExtensionHost instance and bridges it to the ACP protocol. * @@ -98,36 +58,30 @@ export interface AcpSessionOptions { * in a sandboxed environment. The session translates events from the * ExtensionClient to ACP session updates and handles permission requests. */ -export class AcpSession { - private pendingPrompt: AbortController | null = null - private promptResolve: ((response: acp.PromptResponse) => void) | null = null - private isProcessingPrompt = false +export class AcpSession implements IAcpSession { + /** Logger instance (injected) */ + private readonly logger: IAcpLogger + + /** State machine for prompt lifecycle management */ + private readonly promptState: IPromptStateMachine /** Delta tracker for streaming content - ensures only new text is sent */ - private readonly deltaTracker = new DeltaTracker() + private readonly deltaTracker: IDeltaTracker /** Update buffer for batching session updates to reduce message frequency */ - private readonly updateBuffer: UpdateBuffer + private readonly updateBuffer: IUpdateBuffer - /** - * The current prompt text - used to filter out user message echo. - * When the extension receives a task, it often sends a `text` message - * containing the user's input, which we should NOT echo back to ACP - * since the client already displays the user's message. - */ - private currentPromptText: string | null = null + /** Tool handler registry for polymorphic tool dispatch */ + private readonly toolHandlerRegistry: ToolHandlerRegistry - /** - * Track pending command tool calls to send proper status updates. - * Maps tool call ID to command info for the "Run Command" UI. - */ - private pendingCommandCalls: Map = new Map() + /** Command stream manager for handling command output */ + private readonly commandStreamManager: CommandStreamManager - /** - * Track which command executions have sent the opening code fence. - * Used to wrap command output in markdown code blocks. - */ - private commandCodeFencesSent: Set = new Set() + /** Tool content stream manager for handling file creates/edits */ + private readonly toolContentStreamManager: ToolContentStreamManager + + /** Session event handler for managing extension events */ + private readonly eventHandler: SessionEventHandler /** Workspace path for resolving relative file paths */ private readonly workspacePath: string @@ -135,13 +89,66 @@ export class AcpSession { private constructor( private readonly sessionId: string, private readonly extensionHost: ExtensionHost, - private readonly connection: acp.AgentSideConnection, + private readonly connection: AgentSideConnection, workspacePath: string, + deps: AcpSessionDependencies = {}, ) { this.workspacePath = workspacePath - // Initialize update buffer with the actual send function - // Uses defaults: 200 chars min buffer, 500ms delay - this.updateBuffer = new UpdateBuffer((update) => this.sendUpdateDirect(update)) + + // Initialize dependencies with defaults or injected instances. + this.logger = deps.logger ?? acpLog + this.promptState = deps.createPromptStateMachine?.() ?? new PromptStateMachine({ logger: this.logger }) + this.deltaTracker = deps.createDeltaTracker?.() ?? new DeltaTracker() + + // Initialize update buffer with the actual send function. + // Uses defaults: 200 chars min buffer, 500ms delay. + // Wrap sendUpdateDirect to match the expected Promise signature. + const sendDirectAdapter = async (update: SessionNotification["update"]): Promise => { + await this.sendUpdateDirect(update) + // Result is logged internally; adapter converts to void for interface compatibility. + } + + this.updateBuffer = + deps.createUpdateBuffer?.(sendDirectAdapter) ?? new UpdateBuffer(sendDirectAdapter, { logger: this.logger }) + + // Initialize tool handler registry. + this.toolHandlerRegistry = new ToolHandlerRegistry() + + // Create send update callback for stream managers. + const sendUpdate = (update: SessionNotification["update"]) => { + void this.sendUpdate(update) + } + + // Initialize stream managers with injected logger. + this.commandStreamManager = new CommandStreamManager({ + deltaTracker: this.deltaTracker, + sendUpdate, + logger: this.logger, + }) + + this.toolContentStreamManager = new ToolContentStreamManager({ + deltaTracker: this.deltaTracker, + sendUpdate, + logger: this.logger, + }) + + this.eventHandler = createSessionEventHandler({ + logger: this.logger, + client: extensionHost.client, + promptState: this.promptState, + deltaTracker: this.deltaTracker, + commandStreamManager: this.commandStreamManager, + toolContentStreamManager: this.toolContentStreamManager, + toolHandlerRegistry: this.toolHandlerRegistry, + sendUpdate, + approveAction: () => this.extensionHost.client.approve(), + respondWithText: (text: string) => this.extensionHost.client.respond(text), + sendToExtension: (message) => + this.extensionHost.sendToExtension(message as Parameters[0]), + workspacePath, + }) + + this.eventHandler.onTaskCompleted((success) => this.handleTaskCompleted(success)) } // =========================================================================== @@ -153,17 +160,26 @@ export class AcpSession { * * This initializes an ExtensionHost for the given working directory * and sets up event handlers to stream updates to the ACP client. + * + * @param sessionId - Unique session identifier + * @param cwd - Working directory for the session + * @param connection - ACP connection for sending updates + * @param _clientCapabilities - Client capabilities (currently unused) + * @param options - Session configuration options + * @param deps - Optional dependencies for testing */ static async create( sessionId: string, cwd: string, - connection: acp.AgentSideConnection, - _clientCapabilities: acp.ClientCapabilities | undefined, + connection: AgentSideConnection, + _clientCapabilities: ClientCapabilities | undefined, options: AcpSessionOptions, + deps: AcpSessionDependencies = {}, ): Promise { - acpLog.info("Session", `Creating session ${sessionId} in ${cwd}`) + const logger = deps.logger ?? acpLog + logger.info("Session", `Creating session ${sessionId} in ${cwd}`) - // Create ExtensionHost with ACP-specific configuration + // Create ExtensionHost with ACP-specific configuration. const hostOptions: ExtensionHostOptions = { mode: options.mode, user: null, @@ -178,12 +194,12 @@ export class AcpSession { ephemeral: true, } - acpLog.debug("Session", "Creating ExtensionHost", hostOptions) + logger.debug("Session", "Creating ExtensionHost", hostOptions) const extensionHost = new ExtensionHost(hostOptions) await extensionHost.activate() - acpLog.info("Session", `ExtensionHost activated for session ${sessionId}`) + logger.info("Session", `ExtensionHost activated for session ${sessionId}`) - const session = new AcpSession(sessionId, extensionHost, connection, cwd) + const session = new AcpSession(sessionId, extensionHost, connection, cwd, deps) session.setupEventHandlers() return session @@ -197,535 +213,26 @@ export class AcpSession { * Set up event handlers to translate ExtensionClient events to ACP updates. */ private setupEventHandlers(): void { - const client = this.extensionHost.client - - // Handle new messages - client.on("message", (msg: ClineMessage) => { - this.handleMessage(msg) - }) - - // Handle message updates (partial -> complete) - client.on("messageUpdated", (msg: ClineMessage) => { - this.handleMessage(msg) - }) - - // Handle permission requests (tool calls, commands, etc.) - client.on("waitingForInput", (event: WaitingForInputEvent) => { - void this.handleWaitingForInput(event) - }) - - // Handle streaming command execution output (live terminal output) - client.on("commandExecutionOutput", (event: CommandExecutionOutputEvent) => { - this.handleCommandExecutionOutput(event) - }) - - // Handle task completion - client.on("taskCompleted", (event: TaskCompletedEvent) => { - this.handleTaskCompleted(event) - }) + this.eventHandler.setupEventHandlers() } /** - * Handle an incoming message from the extension. - * - * Uses the declarative DELTA_STREAM_CONFIG to automatically determine - * which message types should be delta-streamed and how. - */ - private handleMessage(message: ClineMessage): void { - acpLog.debug( - "Session", - `Message received: type=${message.type}, say=${message.say}, ask=${message.ask}, ts=${message.ts}`, - ) - - // Check if this is a streaming message type - if (message.type === "say" && message.text && message.say) { - // Handle command_output specially for the "Run Command" UI - if (message.say === "command_output") { - this.handleCommandOutput(message) - return - } - - const config = DELTA_STREAM_CONFIG[message.say] - - if (config) { - // Filter out user message echo: when the extension starts a task, - // it often sends a `text` message with the user's input. Since the - // ACP client already displays the user's message, we should skip this. - if (message.say === "text" && this.isUserEcho(message.text)) { - acpLog.debug("Session", `Skipping user echo (${message.text.length} chars)`) - return - } - - // Apply text transform if configured (e.g., "Error: " prefix) - const textToSend = config.textTransform ? config.textTransform(message.text) : message.text - - // Get delta using the tracker (handles all bookkeeping automatically) - const delta = this.deltaTracker.getDelta(message.ts, textToSend) - - if (delta) { - // acpLog.debug("Session", `Queueing ${message.say} delta: ${delta.length} chars (msg ${message.ts})`) - void this.sendUpdate({ - sessionUpdate: config.updateType, - content: { type: "text", text: delta }, - }) - } - return - } - } - - // For non-streaming message types, use the translator - const update = translateToAcpUpdate(message) - if (update) { - acpLog.notification("sessionUpdate", { - sessionId: this.sessionId, - updateKind: (update as { sessionUpdate?: string }).sessionUpdate, - }) - void this.sendUpdate(update) - } - } - - /** - * Handle command_output messages and update the corresponding tool call. - * This provides the "Run Command" UI with completion status in Zed. - * - * NOTE: Streaming output is handled by handleCommandExecutionOutput(). - * This method only handles the final tool_call_update for completion. - */ - private handleCommandOutput(message: ClineMessage): void { - const output = message.text || "" - const isPartial = message.partial === true - - acpLog.info("Session", `handleCommandOutput: partial=${message.partial}, text length=${output.length}`) - - // Skip partial updates - streaming is handled by handleCommandExecutionOutput() - if (isPartial) { - return - } - - // Send closing code fence if any was opened - if (this.commandCodeFencesSent.size > 0) { - acpLog.info("Session", "Sending closing code fence") - void this.sendUpdate({ - sessionUpdate: "agent_message_chunk", - content: { type: "text", text: "\n```" }, - }) - this.commandCodeFencesSent.clear() - } - - // Handle completion - update the tool call UI - const pendingCall = this.findMostRecentPendingCommand() - - if (pendingCall) { - acpLog.info("Session", `Command completed: ${pendingCall.toolCallId}`) - - // Command completed - send final update and remove from pending - void this.sendUpdate({ - sessionUpdate: "tool_call_update", - toolCallId: pendingCall.toolCallId, - status: "completed", - content: [ - { - type: "content", - content: { type: "text", text: output }, - }, - ], - rawOutput: { output }, - }) - this.pendingCommandCalls.delete(pendingCall.toolCallId) - } - } - - /** - * Handle streaming command execution output (live terminal output). - * This provides real-time output during command execution. - * Output is wrapped in markdown code blocks for proper rendering. - */ - private handleCommandExecutionOutput(event: CommandExecutionOutputEvent): void { - const { executionId, output } = event - - acpLog.info("Session", `commandExecutionOutput: executionId=${executionId}, output length=${output.length}`) - - // Stream output as agent message for visibility in chat - // Use executionId as the message key for delta tracking - const delta = this.deltaTracker.getDelta(executionId, output) - if (delta) { - // Send opening code fence on first output for this execution - const isFirstChunk = !this.commandCodeFencesSent.has(executionId) - if (isFirstChunk) { - this.commandCodeFencesSent.add(executionId) - acpLog.info("Session", `Sending opening code fence for execution ${executionId}`) - void this.sendUpdate({ - sessionUpdate: "agent_message_chunk", - content: { type: "text", text: "```\n" }, - }) - } - - acpLog.info("Session", `Streaming command execution output: ${delta.length} chars`) - void this.sendUpdate({ - sessionUpdate: "agent_message_chunk", - content: { type: "text", text: delta }, - }) - } - - // Also update the tool call UI if we have a pending command - const pendingCall = this.findMostRecentPendingCommand() - if (pendingCall) { - acpLog.info("Session", `Updating tool call ${pendingCall.toolCallId} with streaming output`) - void this.sendUpdate({ - sessionUpdate: "tool_call_update", - toolCallId: pendingCall.toolCallId, - status: "in_progress", - content: [ - { - type: "content", - content: { type: "text", text: output }, - }, - ], - }) - } - } - - /** - * Find the most recent pending command call. - */ - private findMostRecentPendingCommand(): { toolCallId: string; command: string; ts: number } | undefined { - let pendingCall: { toolCallId: string; command: string; ts: number } | undefined - - for (const [, call] of this.pendingCommandCalls) { - if (!pendingCall || call.ts > pendingCall.ts) { - pendingCall = call - } - } - - return pendingCall - } - - /** - * Reset delta tracking and buffer for a new prompt. + * Reset state for a new prompt. */ private resetForNewPrompt(): void { - this.deltaTracker.reset() + this.eventHandler.reset() this.updateBuffer.reset() } - /** - * Handle waiting for input events (permission requests). - */ - private async handleWaitingForInput(event: WaitingForInputEvent): Promise { - const { ask, message } = event - const askType = ask as ClineAsk - acpLog.debug("Session", `Waiting for input: ask=${askType}`) - - // Handle permission-required asks - if (isPermissionAsk(askType)) { - acpLog.info("Session", `Permission request: ${askType}`) - await this.handlePermissionRequest(message, askType) - return - } - - // Handle completion asks - if (isCompletionAsk(askType)) { - acpLog.debug("Session", "Completion ask - handled by taskCompleted event") - // Completion is handled by taskCompleted event - return - } - - // Handle followup questions - auto-continue for now - // In a more sophisticated implementation, these could be surfaced - // to the ACP client for user input - if (askType === "followup") { - acpLog.debug("Session", "Auto-responding to followup") - this.extensionHost.client.respond("") - return - } - - // Handle resume_task - auto-resume - if (askType === "resume_task") { - acpLog.debug("Session", "Auto-approving resume_task") - this.extensionHost.client.approve() - return - } - - // Handle API failures - auto-retry for now - if (askType === "api_req_failed") { - acpLog.warn("Session", "API request failed, auto-retrying") - this.extensionHost.client.approve() - return - } - - // Default: approve and continue - acpLog.debug("Session", `Auto-approving unknown ask type: ${askType}`) - this.extensionHost.client.approve() - } - - /** - * Handle a permission request for a tool call. - * - * Auto-approves all tool calls without prompting the user. This allows - * the agent to work autonomously. Tool calls are still reported to the - * client for visibility via tool_call notifications. - * - * For commands, tracks the call to enable the "Run Command" UI with output. - * For other tools (search, read, etc.), the results are already available - * in the message, so we send both the tool_call and tool_call_update immediately. - */ - private handlePermissionRequest(message: ClineMessage, ask: ClineAsk): void { - const toolCall = buildToolCallFromMessage(message, this.workspacePath) - const isCommand = ask === "command" - - // For commands, ensure kind is "execute" for the "Run Command" UI - const kind = isCommand ? "execute" : toolCall.kind - - acpLog.info("Session", `Auto-approving tool: ${toolCall.title}, ask=${ask}, isCommand=${isCommand}`) - acpLog.info("Session", `Tool call details: id=${toolCall.toolCallId}, kind=${kind}, title=${toolCall.title}`) - acpLog.info("Session", `Tool call rawInput: ${JSON.stringify(toolCall.rawInput)}`) - - // Build the full update with corrected kind for commands - const initialUpdate = { - sessionUpdate: "tool_call" as const, - ...toolCall, - kind, - status: "in_progress" as const, - } - acpLog.info("Session", `Sending tool_call update: ${JSON.stringify(initialUpdate)}`) - - // Notify client about the tool call with in_progress status - void this.sendUpdate(initialUpdate) - - // For commands, track the call for the "Run Command" UI - // (completion will come via handleCommandOutput) - if (isCommand) { - this.pendingCommandCalls.set(toolCall.toolCallId, { - toolCallId: toolCall.toolCallId, - command: message.text || "", - ts: message.ts, - }) - acpLog.info("Session", `Tracking command: ${toolCall.toolCallId}`) - } else { - // For non-command tools (search, read, etc.), the results are already - // available in the message. Send completion update immediately. - const rawInput = toolCall.rawInput as Record - - // Build completion update - const completionUpdate: acp.SessionNotification["update"] = { - sessionUpdate: "tool_call_update", - toolCallId: toolCall.toolCallId, - status: "completed", - rawOutput: rawInput, - } - - // For edit operations with diff content, use the pre-parsed diff from toolCall - if (kind === "edit" && toolCall.content && toolCall.content.length > 0) { - acpLog.info("Session", `Edit tool with ${toolCall.content.length} content items (diffs)`) - completionUpdate.content = toolCall.content - } else { - // For search, read, etc. - extract and format text content - const rawContent = this.extractContentFromRawInput(rawInput) - acpLog.info("Session", `Non-edit tool content: ${rawContent ? `${rawContent.length} chars` : "none"}`) - - if (rawContent) { - const formattedContent = this.formatToolResultContent(kind ?? "other", rawContent) - completionUpdate.content = [ - { - type: "content", - content: { type: "text", text: formattedContent }, - }, - ] - } - } - - acpLog.info("Session", `Sending tool_call_update (completed): ${toolCall.toolCallId}`) - void this.sendUpdate(completionUpdate) - } - - // Auto-approve the tool call - this.extensionHost.client.approve() - } - - /** - * Maximum number of lines to show in read operation results. - * Files longer than this will be truncated with a "..." indicator. - */ - private static readonly MAX_READ_LINES = 100 - - /** - * Format tool result content for cleaner display in the UI. - * - * - For search tools: formats verbose results into a clean file list with summary - * - For read tools: truncates long file contents - * - Both search and read results are wrapped in code blocks for better rendering - * - For other tools: returns the content as-is - */ - private formatToolResultContent(kind: string, content: string): string { - switch (kind) { - case "search": - return this.wrapInCodeBlock(this.formatSearchResults(content)) - case "read": - return this.wrapInCodeBlock(this.formatReadResults(content)) - default: - return content - } - } - - /** - * Extract content from rawInput. - * - * For readFile tools, the "content" field contains the file PATH (not contents), - * so we need to read the file ourselves. - * - * For other tools, try common field names for content. - */ - private extractContentFromRawInput(rawInput: Record): string | undefined { - const toolName = (rawInput.tool as string | undefined)?.toLowerCase() || "" - - // For readFile tools, read the actual file content - if (toolName === "readfile" || toolName === "read_file") { - return this.readFileContent(rawInput) - } - - // For other tools, try common field names - const contentFields = ["content", "text", "result", "output", "fileContent", "data"] - - for (const field of contentFields) { - const value = rawInput[field] - if (typeof value === "string" && value.length > 0) { - return value - } - } - - return undefined - } - - /** - * Read file content for readFile tool operations. - * The rawInput.content field contains the absolute path, not the file contents. - */ - private readFileContent(rawInput: Record): string | undefined { - // The "content" field in readFile contains the absolute path - const filePath = rawInput.content as string | undefined - const relativePath = rawInput.path as string | undefined - - // Try absolute path first, then relative path - const pathToRead = filePath || (relativePath ? path.resolve(this.workspacePath, relativePath) : undefined) - - if (!pathToRead) { - acpLog.warn("Session", "readFile tool has no path") - return undefined - } - - try { - const content = fs.readFileSync(pathToRead, "utf-8") - acpLog.info("Session", `Read file content: ${content.length} chars from ${pathToRead}`) - return content - } catch (error) { - acpLog.error("Session", `Failed to read file ${pathToRead}: ${error}`) - return `Error reading file: ${error}` - } - } - - /** - * Wrap content in markdown code block for better rendering. - */ - private wrapInCodeBlock(content: string): string { - return "```\n" + content + "\n```" - } - - /** - * Format read results by truncating long file contents. - */ - private formatReadResults(content: string): string { - const lines = content.split("\n") - - if (lines.length <= AcpSession.MAX_READ_LINES) { - return content - } - - // Truncate and add indicator - const truncated = lines.slice(0, AcpSession.MAX_READ_LINES).join("\n") - const remaining = lines.length - AcpSession.MAX_READ_LINES - return `${truncated}\n\n... (${remaining} more lines)` - } - - /** - * Format search results into a clean summary with file list. - * - * Input format: - * ``` - * Found 112 results. - * - * # src/acp/__tests__/agent.test.ts - * 9 | - * 10 | // Mock the auth module - * ... - * - * # README.md - * 105 | - * ... - * ``` - * - * Output format: - * ``` - * Found 112 results in 20 files: - * • src/acp/__tests__/agent.test.ts - * • README.md - * ... - * ``` - */ - private formatSearchResults(content: string): string { - // Extract count from "Found X results" line - const countMatch = content.match(/Found (\d+) results?/) - const resultCount = countMatch?.[1] ? parseInt(countMatch[1], 10) : null - - // Extract unique file paths from "# path/to/file" lines - const filePattern = /^# (.+)$/gm - const files = new Set() - let match - while ((match = filePattern.exec(content)) !== null) { - if (match[1]) { - files.add(match[1]) - } - } - - // Sort files alphabetically - const fileList = Array.from(files).sort((a, b) => a.localeCompare(b)) - - // Build the formatted output - if (fileList.length === 0) { - // No files found, return original (might be "No results found" or similar) - return content.split("\n")[0] || content - } - - const summary = - resultCount !== null - ? `Found ${resultCount} result${resultCount !== 1 ? "s" : ""} in ${fileList.length} file${fileList.length !== 1 ? "s" : ""}` - : `Found matches in ${fileList.length} file${fileList.length !== 1 ? "s" : ""}` - - // Use markdown list format (renders nicely in code blocks) - const formattedFiles = fileList.map((f) => `- ${f}`).join("\n") - - return `${summary}\n\n${formattedFiles}` - } - /** * Handle task completion. */ - private handleTaskCompleted(event: TaskCompletedEvent): void { - acpLog.info("Session", `Task completed: success=${event.success}`) - - // Flush any buffered updates before completing + private handleTaskCompleted(success: boolean): void { + // Flush any buffered updates before completing. void this.updateBuffer.flush().then(() => { - // Resolve the pending prompt - if (this.promptResolve) { - // StopReason only has: "end_turn" | "max_tokens" | "max_turn_requests" | "refusal" | "cancelled" - // Use "refusal" for failed tasks as it's the closest match - const stopReason: acp.StopReason = event.success ? "end_turn" : "refusal" - acpLog.debug("Session", `Resolving prompt with stopReason: ${stopReason}`) - this.promptResolve({ stopReason }) - this.promptResolve = null - } - - this.isProcessingPrompt = false - this.pendingPrompt = null + // Complete the prompt using the state machine. + const stopReason = this.promptState.complete(success) + this.logger.debug("Session", `Resolving prompt with stopReason: ${stopReason}`) }) } @@ -736,70 +243,44 @@ export class AcpSession { /** * Process a prompt request from the ACP client. */ - async prompt(params: acp.PromptRequest): Promise { - acpLog.info("Session", `Processing prompt for session ${this.sessionId}`) + async prompt(params: PromptRequest): Promise { + this.logger.info("Session", `Processing prompt for session ${this.sessionId}`) - // Cancel any pending prompt + // Cancel any pending prompt. this.cancel() - // Reset delta tracking and buffer for new prompt + // Reset state for new prompt. this.resetForNewPrompt() - this.pendingPrompt = new AbortController() - this.isProcessingPrompt = true - - // Extract text and images from prompt + // Extract text and images from prompt. const text = extractPromptText(params.prompt) const images = extractPromptImages(params.prompt) - // Store prompt text to filter out user echo - this.currentPromptText = text + this.logger.debug("Session", `Prompt text (${text.length} chars), images: ${images.length}`) - acpLog.debug("Session", `Prompt text (${text.length} chars), images: ${images.length}`) + // Start the prompt using the state machine. + const promise = this.promptState.startPrompt(text) - // Start the task if (images.length > 0) { - acpLog.debug("Session", "Starting task with images") - this.extensionHost.sendToExtension({ - type: "newTask", - text, - images, - }) + this.logger.debug("Session", "Starting task with images") + this.extensionHost.sendToExtension({ type: "newTask", text, images }) } else { - acpLog.debug("Session", "Starting task (text only)") - this.extensionHost.sendToExtension({ - type: "newTask", - text, - }) + this.logger.debug("Session", "Starting task (text only)") + this.extensionHost.sendToExtension({ type: "newTask", text }) } - // Wait for completion - return new Promise((resolve) => { - this.promptResolve = resolve - - // Handle abort - this.pendingPrompt?.signal.addEventListener("abort", () => { - acpLog.info("Session", "Prompt aborted") - resolve({ stopReason: "cancelled" }) - this.promptResolve = null - }) - }) + return promise } /** * Cancel the current prompt. */ cancel(): void { - if (this.pendingPrompt) { - acpLog.info("Session", "Cancelling pending prompt") - this.pendingPrompt.abort() - this.pendingPrompt = null - } - - if (this.isProcessingPrompt) { - acpLog.info("Session", "Sending cancelTask to extension") + if (this.promptState.isProcessing()) { + this.logger.info("Session", "Cancelling pending prompt") + this.promptState.cancel() + this.logger.info("Session", "Sending cancelTask to extension") this.extensionHost.sendToExtension({ type: "cancelTask" }) - this.isProcessingPrompt = false } } @@ -807,23 +288,21 @@ export class AcpSession { * Set the session mode. */ setMode(mode: string): void { - acpLog.info("Session", `Setting mode to: ${mode}`) - this.extensionHost.sendToExtension({ - type: "updateSettings", - updatedSettings: { mode }, - }) + this.logger.info("Session", `Setting mode to: ${mode}`) + this.extensionHost.sendToExtension({ type: "updateSettings", updatedSettings: { mode } }) } /** * Dispose of the session and release resources. */ async dispose(): Promise { - acpLog.info("Session", `Disposing session ${this.sessionId}`) + this.logger.info("Session", `Disposing session ${this.sessionId}`) this.cancel() - // Flush any remaining buffered updates + + // Flush any remaining buffered updates. await this.updateBuffer.flush() await this.extensionHost.dispose() - acpLog.info("Session", `Session ${this.sessionId} disposed`) + this.logger.info("Session", `Session ${this.sessionId} disposed`) } // =========================================================================== @@ -833,23 +312,36 @@ export class AcpSession { /** * Send an update to the ACP client through the buffer. * Text chunks are batched, other updates are sent immediately. + * + * @returns Result indicating success or failure. */ - private async sendUpdate(update: acp.SessionNotification["update"]): Promise { - await this.updateBuffer.queueUpdate(update) + private async sendUpdate(update: SessionNotification["update"]): Promise> { + try { + await this.updateBuffer.queueUpdate(update) + return ok(undefined) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + this.logger.error("Session", `Failed to queue update: ${errorMessage}`) + return err(`Failed to queue update: ${errorMessage}`) + } } /** * Send an update directly to the ACP client (bypasses buffer). * Used by the UpdateBuffer to actually send batched updates. + * + * @returns Result indicating success or failure with error details. */ - private async sendUpdateDirect(update: acp.SessionNotification["update"]): Promise { + private async sendUpdateDirect(update: SessionNotification["update"]): Promise> { try { - await this.connection.sessionUpdate({ - sessionId: this.sessionId, - update, - }) + // Log the full update being sent to ACP connection + this.logger.debug("Session", `ACP OUT: ${JSON.stringify({ sessionId: this.sessionId, update })}`) + await this.connection.sessionUpdate({ sessionId: this.sessionId, update }) + return ok(undefined) } catch (error) { - console.error("[AcpSession] Failed to send update:", error) + const errorMessage = error instanceof Error ? error.message : String(error) + this.logger.error("Session", `Failed to send update: ${errorMessage}`, error) + return err(`Failed to send update to ACP client: ${errorMessage}`) } } @@ -859,41 +351,4 @@ export class AcpSession { getSessionId(): string { return this.sessionId } - - /** - * Check if a text message is an echo of the user's prompt. - * - * When the extension starts processing a task, it often sends a `text` - * message containing the user's input. Since the ACP client already - * displays the user's message, we should filter this out to avoid - * showing the message twice. - * - * Uses a fuzzy match to handle minor differences (whitespace, etc.). - */ - private isUserEcho(text: string): boolean { - if (!this.currentPromptText) { - return false - } - - // Normalize both strings for comparison - const normalizedPrompt = this.currentPromptText.trim().toLowerCase() - const normalizedText = text.trim().toLowerCase() - - // Exact match - if (normalizedText === normalizedPrompt) { - return true - } - - // Check if text is contained in prompt (might be truncated) - if (normalizedPrompt.includes(normalizedText) && normalizedText.length > 10) { - return true - } - - // Check if prompt is contained in text (might have wrapper) - if (normalizedText.includes(normalizedPrompt) && normalizedPrompt.length > 10) { - return true - } - - return false - } } diff --git a/apps/cli/src/acp/tool-content-stream.ts b/apps/cli/src/acp/tool-content-stream.ts new file mode 100644 index 0000000000..b07df91a5b --- /dev/null +++ b/apps/cli/src/acp/tool-content-stream.ts @@ -0,0 +1,217 @@ +/** + * ToolContentStreamManager + * + * Manages streaming of tool content (file creates/edits) with headers and code fences. + * Provides live feedback as files are being written by the LLM. + * + * Extracted from session.ts to separate the tool content streaming concern. + */ + +import type { ClineMessage } from "@roo-code/types" + +import type { IDeltaTracker, IAcpLogger, SendUpdateFn } from "./interfaces.js" +import { isFileWriteTool } from "./tool-registry.js" +import { hasValidFilePath } from "./utils/index.js" + +// ============================================================================= +// Types +// ============================================================================= + +/** + * Options for creating a ToolContentStreamManager. + */ +export interface ToolContentStreamManagerOptions { + /** Delta tracker for tracking already-sent content */ + deltaTracker: IDeltaTracker + /** Callback to send session updates */ + sendUpdate: SendUpdateFn + /** Logger instance */ + logger: IAcpLogger +} + +// ============================================================================= +// ToolContentStreamManager Class +// ============================================================================= + +/** + * Manages streaming of tool content for file creates/edits. + * + * Responsibilities: + * - Track which tools have sent their header + * - Stream file content as it's being generated + * - Wrap content in proper markdown code blocks + * - Clean up tracking state + */ +export class ToolContentStreamManager { + /** + * Track which tool content streams have sent their header. + * Used to show file path before streaming content. + */ + private toolContentHeadersSent: Set = new Set() + + private readonly deltaTracker: IDeltaTracker + private readonly sendUpdate: SendUpdateFn + private readonly logger: IAcpLogger + + constructor(options: ToolContentStreamManagerOptions) { + this.deltaTracker = options.deltaTracker + this.sendUpdate = options.sendUpdate + this.logger = options.logger + } + + // =========================================================================== + // Public API + // =========================================================================== + + /** + * Check if a message is a tool ask message that this manager handles. + */ + isToolAskMessage(message: ClineMessage): boolean { + return message.type === "ask" && message.ask === "tool" + } + + /** + * Handle streaming content for tool ask messages (file creates/edits). + * + * This streams the content field from tool JSON as agent_message_chunk updates, + * providing live feedback as files are being written. + * + * @returns true if the message was handled, false if it should fall through + */ + handleToolContentStreaming(message: ClineMessage): boolean { + const isPartial = message.partial === true + const ts = message.ts + const text = message.text || "" + + // Parse tool info to get the tool name, path, and content + const parsed = this.parseToolMessage(text) + + // If we couldn't parse yet (early streaming), skip until we can identify the tool + if (!parsed) { + return true // Handled (by skipping) + } + + const { toolName, toolPath, content } = parsed + + // Only stream content for file write operations (uses tool registry) + if (!isFileWriteTool(toolName)) { + this.logger.debug("ToolContentStream", `Skipping content streaming for non-file tool: ${toolName}`) + return true // Handled (by skipping) + } + + this.logger.debug( + "ToolContentStream", + `handleToolContentStreaming: tool=${toolName}, path=${toolPath}, partial=${isPartial}, contentLen=${content.length}`, + ) + + // Check if we have valid path and content to start streaming + // Path must have a file extension to be considered valid (uses shared utility) + const validPath = hasValidFilePath(toolPath) + const hasContent = content.length > 0 + + if (isPartial) { + this.handlePartialMessage(ts, toolPath, content, validPath, hasContent) + } else { + this.handleCompleteMessage(ts, toolPath, content) + } + + return true // Handled + } + + /** + * Reset state for a new prompt. + */ + reset(): void { + this.toolContentHeadersSent.clear() + this.logger.debug("ToolContentStream", "Reset tool content stream state") + } + + /** + * Get the number of active headers (for testing/debugging). + */ + getActiveHeaderCount(): number { + return this.toolContentHeadersSent.size + } + + // =========================================================================== + // Private Methods + // =========================================================================== + + /** + * Parse a tool message to extract tool info. + * Returns null if JSON is incomplete (expected early in streaming). + */ + private parseToolMessage(text: string): { toolName: string; toolPath: string; content: string } | null { + try { + const toolInfo = JSON.parse(text || "{}") as Record + return { + toolName: (toolInfo.tool as string) || "tool", + toolPath: (toolInfo.path as string) || "", + content: (toolInfo.content as string) || "", + } + } catch { + // Early in streaming, JSON may be incomplete - this is expected + return null + } + } + + /** + * Handle a partial (streaming) tool message. + */ + private handlePartialMessage( + ts: number, + toolPath: string, + content: string, + hasValidPath: boolean, + hasContent: boolean, + ): void { + // Send header as soon as we have a valid path (even without content yet) + // This provides immediate feedback that a file is being created, reducing + // perceived latency during the gap while LLM generates file content. + if (hasValidPath && !this.toolContentHeadersSent.has(ts)) { + this.toolContentHeadersSent.add(ts) + this.logger.debug("ToolContentStream", `Sending tool content header for ${toolPath}`) + this.sendUpdate({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: `\n**Creating ${toolPath}**\n\`\`\`\n` }, + }) + } + + // Stream content deltas when content becomes available + if (hasValidPath && hasContent) { + // Use a unique key for delta tracking: "tool-content-{ts}" + const deltaKey = `tool-content-${ts}` + const delta = this.deltaTracker.getDelta(deltaKey, content) + + if (delta) { + this.logger.debug("ToolContentStream", `Streaming tool content delta: ${delta.length} chars`) + this.sendUpdate({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: delta }, + }) + } + } + } + + /** + * Handle a complete (non-partial) tool message. + */ + private handleCompleteMessage(ts: number, toolPath: string, content: string): void { + // Message complete - finish streaming and clean up + if (this.toolContentHeadersSent.has(ts)) { + // Send closing code fence + this.sendUpdate({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "\n```\n" }, + }) + this.toolContentHeadersSent.delete(ts) + } + + // Note: The actual tool_call notification will be sent via handleWaitingForInput + // when the waitingForInput event fires (which happens when partial becomes false) + this.logger.debug( + "ToolContentStream", + `Tool content streaming complete for ${toolPath}: ${content.length} chars`, + ) + } +} diff --git a/apps/cli/src/acp/tool-handler.ts b/apps/cli/src/acp/tool-handler.ts new file mode 100644 index 0000000000..c24c97a9c0 --- /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 0000000000..a510f241a2 --- /dev/null +++ b/apps/cli/src/acp/tool-registry.ts @@ -0,0 +1,530 @@ +/** + * Tool Registry + * + * Centralized registry for tool type definitions, categories, and validation schemas. + * Provides type-safe tool identification and parameter validation. + * + * Uses exact matching with normalized tool names to avoid fragile substring matching. + */ + +import { z } from "zod" +import type * as acp from "@agentclientprotocol/sdk" + +// ============================================================================= +// Tool Category Registry Class +// ============================================================================= + +/** + * Tool category names. + */ +export type ToolCategory = + | "edit" + | "read" + | "search" + | "list" + | "execute" + | "delete" + | "move" + | "think" + | "fetch" + | "switchMode" + | "fileWrite" + +/** + * Registry for tool categories with automatic Set generation. + * + * This class ensures that TOOL_CATEGORIES and lookup Sets are always in sync + * by generating Sets automatically from the category definitions. + */ +class ToolCategoryRegistry { + private readonly categories: Map> = new Map() + private readonly toolDefinitions: Record + + constructor() { + // Define tool categories with their associated tool names + // All tool names are stored in normalized form (lowercase, no separators) + this.toolDefinitions = { + /** File edit operations (create, write, modify) */ + edit: [ + "newfilecreated", + "editedexistingfile", + "writetofile", + "applydiff", + "applieddiff", + "createfile", + "modifyfile", + ], + + /** File read operations */ + read: ["readfile"], + + /** File/codebase search operations */ + search: ["searchfiles", "codebasesearch", "grep", "ripgrep"], + + /** Directory listing operations */ + list: ["listfiles", "listfilestoplevel", "listfilesrecursive"], + + /** Command/shell execution */ + execute: ["executecommand", "runcommand"], + + /** File deletion */ + delete: ["deletefile", "removefile"], + + /** File move/rename */ + move: ["movefile", "renamefile"], + + /** Reasoning/thinking operations */ + think: ["think", "reason", "plan", "analyze"], + + /** External fetch/HTTP operations */ + fetch: ["fetch", "httpget", "httppost", "urlfetch", "webrequest"], + + /** Mode switching operations */ + switchMode: ["switchmode", "setmode"], + + /** File write operations (for streaming detection) */ + fileWrite: ["newfilecreated", "writetofile", "createfile", "editedexistingfile", "applydiff", "modifyfile"], + } + + // Build Sets automatically from definitions + for (const [category, tools] of Object.entries(this.toolDefinitions)) { + this.categories.set(category as ToolCategory, new Set(tools)) + } + } + + /** + * Check if a tool name belongs to a specific category. + * Uses O(1) Set lookup. + */ + isInCategory(toolName: string, category: ToolCategory): boolean { + const normalized = this.normalizeToolName(toolName) + return this.categories.get(category)?.has(normalized) ?? false + } + + /** + * Get all tools in a category. + */ + getToolsInCategory(category: ToolCategory): readonly string[] { + return this.toolDefinitions[category] + } + + /** + * Get all category names. + */ + getCategoryNames(): ToolCategory[] { + return Object.keys(this.toolDefinitions) as ToolCategory[] + } + + /** + * Normalize a tool name for comparison. + * Converts to lowercase and removes all separators (-, _). + */ + private normalizeToolName(name: string): string { + return name.toLowerCase().replace(/[-_]/g, "") + } +} + +// ============================================================================= +// Singleton Registry Instance +// ============================================================================= + +/** + * Global tool category registry instance. + */ +const toolCategoryRegistry = new ToolCategoryRegistry() + +// ============================================================================= +// Legacy Exports for Backward Compatibility +// ============================================================================= + +/** + * Tool categories with their associated tool names. + * @deprecated Use toolCategoryRegistry methods instead + */ +export const TOOL_CATEGORIES = { + edit: toolCategoryRegistry.getToolsInCategory("edit"), + read: toolCategoryRegistry.getToolsInCategory("read"), + search: toolCategoryRegistry.getToolsInCategory("search"), + list: toolCategoryRegistry.getToolsInCategory("list"), + execute: toolCategoryRegistry.getToolsInCategory("execute"), + delete: toolCategoryRegistry.getToolsInCategory("delete"), + move: toolCategoryRegistry.getToolsInCategory("move"), + think: toolCategoryRegistry.getToolsInCategory("think"), + fetch: toolCategoryRegistry.getToolsInCategory("fetch"), + switchMode: toolCategoryRegistry.getToolsInCategory("switchMode"), + fileWrite: toolCategoryRegistry.getToolsInCategory("fileWrite"), +} as const + +// ============================================================================= +// Type Definitions +// ============================================================================= + +/** + * All known tool names (union of all categories) + */ +export type KnownToolName = (typeof TOOL_CATEGORIES)[ToolCategory][number] + +// ============================================================================= +// Tool Category Detection Functions +// ============================================================================= + +/** + * Check if a tool name belongs to a specific category using exact matching. + * Uses the centralized registry for O(1) lookup. + */ +export function isToolInCategory(toolName: string, category: ToolCategory): boolean { + return toolCategoryRegistry.isInCategory(toolName, category) +} + +/** + * Check if tool is an edit operation. + */ +export function isEditTool(toolName: string): boolean { + return isToolInCategory(toolName, "edit") +} + +/** + * Check if tool is a read operation. + */ +export function isReadTool(toolName: string): boolean { + return isToolInCategory(toolName, "read") +} + +/** + * Check if tool is a search operation. + */ +export function isSearchTool(toolName: string): boolean { + return isToolInCategory(toolName, "search") +} + +/** + * Check if tool is a list files operation. + */ +export function isListFilesTool(toolName: string): boolean { + return isToolInCategory(toolName, "list") +} + +/** + * Check if tool is a command execution operation. + */ +export function isExecuteTool(toolName: string): boolean { + return isToolInCategory(toolName, "execute") +} + +/** + * Check if tool is a delete operation. + */ +export function isDeleteTool(toolName: string): boolean { + return isToolInCategory(toolName, "delete") +} + +/** + * Check if tool is a move/rename operation. + */ +export function isMoveTool(toolName: string): boolean { + return isToolInCategory(toolName, "move") +} + +/** + * Check if tool is a think/reasoning operation. + */ +export function isThinkTool(toolName: string): boolean { + return isToolInCategory(toolName, "think") +} + +/** + * Check if tool is an external fetch operation. + */ +export function isFetchTool(toolName: string): boolean { + return isToolInCategory(toolName, "fetch") +} + +/** + * Check if tool is a mode switching operation. + */ +export function isSwitchModeTool(toolName: string): boolean { + return isToolInCategory(toolName, "switchMode") +} + +/** + * Check if tool is a file write operation (for streaming). + */ +export function isFileWriteTool(toolName: string): boolean { + return isToolInCategory(toolName, "fileWrite") +} + +// ============================================================================= +// Tool Kind Mapping +// ============================================================================= + +/** + * Map a tool name to an ACP ToolKind. + * + * ACP defines these tool kinds for special UI treatment: + * - read: Reading files or data + * - edit: Modifying files or content + * - delete: Removing files or data + * - move: Moving or renaming files + * - search: Searching for information + * - execute: Running commands or code + * - think: Internal reasoning or planning + * - fetch: Retrieving external data + * - switch_mode: Switching the current session mode + * - other: Other tool types (default) + * + * Uses exact category matching for reliability. Falls back to "other" for unknown tools. + */ +export function mapToolToKind(toolName: string): acp.ToolKind { + // Check exact category matches in priority order + // Order matters only for overlapping categories (like fileWrite and edit) + if (isToolInCategory(toolName, "switchMode")) { + return "switch_mode" + } + if (isToolInCategory(toolName, "think")) { + return "think" + } + if (isToolInCategory(toolName, "search")) { + return "search" + } + if (isToolInCategory(toolName, "delete")) { + return "delete" + } + if (isToolInCategory(toolName, "move")) { + return "move" + } + if (isToolInCategory(toolName, "edit")) { + return "edit" + } + if (isToolInCategory(toolName, "fetch")) { + return "fetch" + } + if (isToolInCategory(toolName, "read")) { + return "read" + } + if (isToolInCategory(toolName, "list")) { + return "read" // list operations are read-like + } + if (isToolInCategory(toolName, "execute")) { + return "execute" + } + + // Default to other for unknown tools + return "other" +} + +// ============================================================================= +// Zod Schemas for Tool Parameters +// ============================================================================= + +/** + * Base schema for all tool parameters. + */ +const BaseToolParamsSchema = z.object({ + tool: z.string(), +}) + +/** + * Schema for file path tools (read, delete, etc.) + */ +export const FilePathParamsSchema = BaseToolParamsSchema.extend({ + path: z.string(), + content: z.string().optional(), +}) + +/** + * Schema for file write/create tools. + */ +export const FileWriteParamsSchema = BaseToolParamsSchema.extend({ + path: z.string(), + content: z.string(), +}) + +/** + * Schema for file move/rename tools. + */ +export const FileMoveParamsSchema = BaseToolParamsSchema.extend({ + path: z.string(), + newPath: z.string().optional(), + destination: z.string().optional(), +}) + +/** + * Schema for search tools. + */ +export const SearchParamsSchema = BaseToolParamsSchema.extend({ + path: z.string().optional(), + regex: z.string().optional(), + query: z.string().optional(), + pattern: z.string().optional(), + filePattern: z.string().optional(), + content: z.string().optional(), +}) + +/** + * Schema for list files tools. + */ +export const ListFilesParamsSchema = BaseToolParamsSchema.extend({ + path: z.string(), + recursive: z.boolean().optional(), + content: z.string().optional(), +}) + +/** + * Schema for command execution tools. + */ +export const CommandParamsSchema = BaseToolParamsSchema.extend({ + command: z.string().optional(), + cwd: z.string().optional(), +}) + +/** + * Schema for think/reasoning tools. + */ +export const ThinkParamsSchema = BaseToolParamsSchema.extend({ + thought: z.string().optional(), + reasoning: z.string().optional(), + analysis: z.string().optional(), +}) + +/** + * Schema for mode switching tools. + */ +export const SwitchModeParamsSchema = BaseToolParamsSchema.extend({ + mode: z.string().optional(), + modeId: z.string().optional(), +}) + +/** + * Generic tool params schema (for unknown tools). + */ +export const GenericToolParamsSchema = BaseToolParamsSchema.passthrough() + +// ============================================================================= +// Parameter Types +// ============================================================================= + +export type FilePathParams = z.infer +export type FileWriteParams = z.infer +export type FileMoveParams = z.infer +export type SearchParams = z.infer +export type ListFilesParams = z.infer +export type CommandParams = z.infer +export type ThinkParams = z.infer +export type SwitchModeParams = z.infer +export type GenericToolParams = z.infer + +/** + * Union of all tool parameter types. + */ +export type ToolParams = + | FilePathParams + | FileWriteParams + | FileMoveParams + | SearchParams + | ListFilesParams + | CommandParams + | ThinkParams + | SwitchModeParams + | GenericToolParams + +// ============================================================================= +// Parameter Validation +// ============================================================================= + +/** + * Result of parameter validation. + */ +export type ValidationResult = { success: true; data: T } | { success: false; error: z.ZodError } + +/** + * Validate tool parameters against the appropriate schema. + * + * @param toolName - Name of the tool + * @param params - Raw parameters to validate + * @returns Validation result with typed params or error + */ +export function validateToolParams(toolName: string, params: unknown): ValidationResult { + // Select schema based on tool category + let schema: z.ZodSchema + + if (isEditTool(toolName)) { + schema = FileWriteParamsSchema + } else if (isReadTool(toolName)) { + schema = FilePathParamsSchema + } else if (isSearchTool(toolName)) { + schema = SearchParamsSchema + } else if (isListFilesTool(toolName)) { + schema = ListFilesParamsSchema + } else if (isExecuteTool(toolName)) { + schema = CommandParamsSchema + } else if (isDeleteTool(toolName)) { + schema = FilePathParamsSchema + } else if (isMoveTool(toolName)) { + schema = FileMoveParamsSchema + } else if (isThinkTool(toolName)) { + schema = ThinkParamsSchema + } else if (isSwitchModeTool(toolName)) { + schema = SwitchModeParamsSchema + } else { + // Use generic schema for unknown tools + schema = GenericToolParamsSchema + } + + const result = schema.safeParse(params) + + if (result.success) { + return { success: true, data: result.data as ToolParams } + } + + return { success: false, error: result.error } +} + +/** + * Parse and validate tool parameters, returning undefined on failure. + * Use when validation failure should be handled gracefully. + * + * @param toolName - Name of the tool + * @param params - Raw parameters to validate + * @returns Validated params or undefined + */ +export function parseToolParams(toolName: string, params: unknown): ToolParams | undefined { + const result = validateToolParams(toolName, params) + return result.success ? result.data : undefined +} + +// ============================================================================= +// Tool Message Parsing +// ============================================================================= + +/** + * Schema for parsing tool JSON from message text. + */ +export const ToolMessageSchema = z + .object({ + tool: z.string(), + path: z.string().optional(), + content: z.string().optional(), + }) + .passthrough() + +export type ToolMessage = z.infer + +/** + * Parse tool information from a JSON message. + * + * @param text - JSON text to parse + * @returns Parsed tool message or undefined if invalid + */ +export function parseToolMessage(text: string): ToolMessage | undefined { + if (!text.startsWith("{")) { + return undefined + } + + try { + const parsed = JSON.parse(text) + const result = ToolMessageSchema.safeParse(parsed) + return result.success ? result.data : undefined + } catch { + return undefined + } +} diff --git a/apps/cli/src/acp/translator.ts b/apps/cli/src/acp/translator.ts index 56b91cec13..aa8d9696b8 100644 --- a/apps/cli/src/acp/translator.ts +++ b/apps/cli/src/acp/translator.ts @@ -1,666 +1,43 @@ /** * ACP Message Translator * - * Translates between internal ClineMessage format and ACP protocol format. - * This is the bridge between Roo Code's message system and the ACP protocol. - */ - -import * as path from "node:path" -import type * as acp from "@agentclientprotocol/sdk" -import type { ClineMessage, ClineAsk } from "@roo-code/types" - -// ============================================================================= -// Types -// ============================================================================= - -export interface ToolCallInfo { - id: string - name: string - title: string - params: Record - locations: acp.ToolCallLocation[] - content?: acp.ToolCallContent[] -} - -// ============================================================================= -// Message to ACP Update Translation -// ============================================================================= - -/** - * Translate an internal ClineMessage to an ACP session update. - * Returns null if the message type should not be sent to ACP. - */ -export function translateToAcpUpdate(message: ClineMessage): acp.SessionNotification["update"] | null { - if (message.type === "say") { - switch (message.say) { - case "text": - // Agent text output - return { - sessionUpdate: "agent_message_chunk", - content: { type: "text", text: message.text || "" }, - } - - case "reasoning": - // Agent reasoning/thinking - return { - sessionUpdate: "agent_thought_chunk", - content: { type: "text", text: message.text || "" }, - } - - case "shell_integration_warning": - case "mcp_server_request_started": - case "mcp_server_response": - // Tool-related messages - return translateToolSayMessage(message) - - case "user_feedback": - // User feedback doesn't need to be sent to ACP client - return null - - case "error": - // Error messages - return { - sessionUpdate: "agent_message_chunk", - content: { type: "text", text: `Error: ${message.text || ""}` }, - } - - case "completion_result": - // Completion is handled at prompt level - return null - - case "api_req_started": - case "api_req_finished": - case "api_req_retried": - case "api_req_retry_delayed": - case "api_req_deleted": - // API request lifecycle events - not sent to ACP - return null - - case "command_output": - // Command execution - handled through tool_call - return null - - default: - // Unknown message type - return null - } - } - - // Ask messages are handled separately through permission flow - return null -} - -/** - * Translate a tool say message to ACP format. - */ -function translateToolSayMessage(message: ClineMessage): acp.SessionNotification["update"] | null { - const toolInfo = parseToolFromMessage(message) - if (!toolInfo) { - return null - } - - if (message.partial) { - // Tool in progress - return { - sessionUpdate: "tool_call", - toolCallId: toolInfo.id, - title: toolInfo.title, - kind: mapToolKind(toolInfo.name), - status: "in_progress" as const, - locations: toolInfo.locations, - rawInput: toolInfo.params, - } - } else { - // Tool completed - return { - sessionUpdate: "tool_call_update", - toolCallId: toolInfo.id, - status: "completed" as const, - content: [], - rawOutput: toolInfo.params, - } - } -} - -// ============================================================================= -// Tool Information Parsing -// ============================================================================= - -/** - * Parse tool information from a ClineMessage. - * @param message - The ClineMessage to parse - * @param workspacePath - Optional workspace path to resolve relative paths - */ -export function parseToolFromMessage(message: ClineMessage, workspacePath?: string): ToolCallInfo | null { - if (!message.text) { - return null - } - - // Tool messages typically have JSON content describing the tool - try { - // Try to parse as JSON first - if (message.text.startsWith("{")) { - const parsed = JSON.parse(message.text) as Record - const toolName = (parsed.tool as string) || "unknown" - const filePath = (parsed.path as string) || undefined - - return { - id: `tool-${message.ts}`, - name: toolName, - title: generateToolTitle(toolName, filePath), - params: parsed, - locations: extractLocations(parsed, workspacePath), - content: extractToolContent(parsed, workspacePath), - } - } - } catch { - // Not JSON, try to extract tool info from text - } - - // Extract tool name from text content - const toolMatch = message.text.match(/(?:Using|Executing|Running)\s+(\w+)/i) - const toolName = toolMatch?.[1] || "unknown" - - return { - id: `tool-${message.ts}`, - name: toolName, - title: message.text.slice(0, 100), - params: {}, - locations: [], - } -} - -/** - * Generate a human-readable title for a tool operation. - */ -function generateToolTitle(toolName: string, filePath?: string): string { - const fileName = filePath ? path.basename(filePath) : undefined - - // Map tool names to human-readable titles - const toolTitles: Record = { - // File creation - newFileCreated: fileName ? `Creating ${fileName}` : "Creating file", - write_to_file: fileName ? `Writing ${fileName}` : "Writing file", - create_file: fileName ? `Creating ${fileName}` : "Creating file", - - // File editing - editedExistingFile: fileName ? `Edit ${fileName}` : "Edit file", - apply_diff: fileName ? `Edit ${fileName}` : "Edit file", - appliedDiff: fileName ? `Edit ${fileName}` : "Edit file", - modify_file: fileName ? `Edit ${fileName}` : "Edit file", - - // File reading - read_file: fileName ? `Read ${fileName}` : "Read file", - readFile: fileName ? `Read ${fileName}` : "Read file", - - // File listing - list_files: filePath ? `Listing files in ${filePath}` : "Listing files", - listFiles: filePath ? `Listing files in ${filePath}` : "Listing files", - - // File search - search_files: "Searching files", - searchFiles: "Searching files", - - // Command execution - execute_command: "Running command", - executeCommand: "Running command", - - // Browser actions - browser_action: "Browser action", - browserAction: "Browser action", - } - - return toolTitles[toolName] || (fileName ? `${toolName}: ${fileName}` : toolName) -} - -/** - * Extract file locations from tool parameters. - * @param params - Tool parameters - * @param workspacePath - Optional workspace path to resolve relative paths - */ -function extractLocations(params: Record, workspacePath?: string): acp.ToolCallLocation[] { - const locations: acp.ToolCallLocation[] = [] - const toolName = (params.tool as string | undefined)?.toLowerCase() || "" - - // For search tools, the 'path' parameter is a search scope directory, not a file being accessed. - // Don't include it in locations. Instead, try to extract file paths from search results. - if (isSearchTool(toolName)) { - // Try to extract file paths from search results content - const content = params.content as string | undefined - if (content) { - const fileLocations = extractFilePathsFromSearchResults(content, workspacePath) - return fileLocations - } - return [] - } - - // For list_files tools, the 'path' is a directory being listed, which is valid to include - // but we should mark it as a directory operation rather than a file access - if (isListFilesTool(toolName)) { - const dirPath = params.path as string | undefined - if (dirPath) { - const absolutePath = makeAbsolutePath(dirPath, workspacePath) - locations.push({ path: absolutePath }) - } - return locations - } - - // Check for common path parameters (for file operations) - const pathParams = ["path", "file", "filePath", "file_path"] - for (const param of pathParams) { - if (typeof params[param] === "string") { - const filePath = params[param] as string - const absolutePath = makeAbsolutePath(filePath, workspacePath) - locations.push({ path: absolutePath }) - } - } - - // Check for directory parameters separately (for directory operations) - const dirParams = ["directory", "dir"] - for (const param of dirParams) { - if (typeof params[param] === "string") { - const dirPath = params[param] as string - const absolutePath = makeAbsolutePath(dirPath, workspacePath) - locations.push({ path: absolutePath }) - } - } - - // Check for paths array - if (Array.isArray(params.paths)) { - for (const p of params.paths) { - if (typeof p === "string") { - const absolutePath = makeAbsolutePath(p, workspacePath) - locations.push({ path: absolutePath }) - } - } - } - - return locations -} - -/** - * Check if a tool name is a search operation. - */ -function isSearchTool(toolName: string): boolean { - const searchTools = ["search_files", "searchfiles", "codebase_search", "codebasesearch", "grep", "ripgrep"] - return searchTools.includes(toolName) || toolName.includes("search") -} - -/** - * Check if a tool name is a list files operation. - */ -function isListFilesTool(toolName: string): boolean { - const listTools = ["list_files", "listfiles", "listfilestoplevel", "listfilesrecursive"] - return listTools.includes(toolName) || toolName.includes("listfiles") -} - -/** - * Extract file paths from search results content. - * Search results typically have format: "# path/to/file.ts" for each matched file - */ -function extractFilePathsFromSearchResults(content: string, workspacePath?: string): acp.ToolCallLocation[] { - const locations: acp.ToolCallLocation[] = [] - const seenPaths = new Set() - - // Match file headers in search results (e.g., "# src/utils.ts" or "## path/to/file.js") - const fileHeaderPattern = /^#+\s+(.+?\.[a-zA-Z0-9]+)\s*$/gm - let match - - while ((match = fileHeaderPattern.exec(content)) !== null) { - const filePath = match[1]!.trim() - // Skip if we've already seen this path or if it looks like a markdown header (not a file path) - if (seenPaths.has(filePath) || (!filePath.includes("/") && !filePath.includes("."))) { - continue - } - seenPaths.add(filePath) - const absolutePath = makeAbsolutePath(filePath, workspacePath) - locations.push({ path: absolutePath }) - } - - return locations -} - -/** - * Extract tool content for ACP (diffs, text, etc.) - */ -function extractToolContent( - params: Record, - workspacePath?: string, -): acp.ToolCallContent[] | undefined { - const content: acp.ToolCallContent[] = [] - - // Check if this is a file operation with diff content - const filePath = params.path as string | undefined - const diffContent = params.content as string | undefined - const toolName = params.tool as string | undefined - - if (filePath && diffContent && isFileEditTool(toolName || "")) { - const absolutePath = makeAbsolutePath(filePath, workspacePath) - const parsedDiff = parseUnifiedDiff(diffContent) - - if (parsedDiff) { - // Use ACP diff format - content.push({ - type: "diff", - path: absolutePath, - oldText: parsedDiff.oldText, - newText: parsedDiff.newText, - } as acp.ToolCallContent) - } - } - - return content.length > 0 ? content : undefined -} - -/** - * Parse a unified diff string to extract old and new text. - */ -function parseUnifiedDiff(diffString: string): { oldText: string | null; newText: string } | null { - if (!diffString) { - return null - } - - // Check if this is a unified diff format - if (!diffString.includes("@@") && !diffString.includes("---") && !diffString.includes("+++")) { - // Not a diff, treat as raw content - return { oldText: null, newText: diffString } - } - - const lines = diffString.split("\n") - const oldLines: string[] = [] - const newLines: string[] = [] - let inHunk = false - let isNewFile = false - - for (const line of lines) { - // Check for new file indicator - if (line.startsWith("--- /dev/null")) { - isNewFile = true - continue - } - - // Skip diff headers - if (line.startsWith("===") || line.startsWith("---") || line.startsWith("+++") || line.startsWith("@@")) { - if (line.startsWith("@@")) { - inHunk = true - } - continue - } - - if (!inHunk) { - continue - } - - if (line.startsWith("-")) { - // Removed line (old content) - oldLines.push(line.slice(1)) - } else if (line.startsWith("+")) { - // Added line (new content) - newLines.push(line.slice(1)) - } else if (line.startsWith(" ") || line === "") { - // Context line (in both old and new) - const contextLine = line.startsWith(" ") ? line.slice(1) : line - oldLines.push(contextLine) - newLines.push(contextLine) - } - } - - return { - oldText: isNewFile ? null : oldLines.join("\n") || null, - newText: newLines.join("\n"), - } -} - -/** - * Check if a tool name represents a file edit operation. - */ -function isFileEditTool(toolName: string): boolean { - const editTools = [ - "newFileCreated", - "editedExistingFile", - "write_to_file", - "apply_diff", - "create_file", - "modify_file", - ] - return editTools.includes(toolName) -} - -/** - * Make a file path absolute by resolving it against the workspace path. - */ -function makeAbsolutePath(filePath: string, workspacePath?: string): string { - if (path.isAbsolute(filePath)) { - return filePath - } - - if (workspacePath) { - return path.resolve(workspacePath, filePath) - } - - // Return as-is if no workspace path available - return filePath -} - -// ============================================================================= -// Tool Kind Mapping -// ============================================================================= - -/** - * Map internal tool names to ACP tool kinds. + * This file re-exports from the translator/ module for backward compatibility. + * The translator has been split into focused modules for better maintainability: * - * ACP defines these tool kinds for special UI treatment: - * - read: Reading files or data - * - edit: Modifying files or content - * - delete: Removing files or data - * - move: Moving or renaming files - * - search: Searching for information - * - execute: Running commands or code - * - think: Internal reasoning or planning - * - fetch: Retrieving external data - * - switch_mode: Switching the current session mode - * - other: Other tool types (default) + * - translator/diff-parser.ts: Unified diff parsing + * - translator/location-extractor.ts: File location extraction + * - translator/prompt-extractor.ts: Prompt content extraction + * - translator/tool-parser.ts: Tool information parsing + * - translator/message-translator.ts: Main message translation + * + * Import from this file or directly from translator/index.ts */ -export function mapToolKind(toolName: string): acp.ToolKind { - const lowerName = toolName.toLowerCase() - // Switch mode operations (check first as it's specific) - if (lowerName.includes("switch_mode") || lowerName.includes("switchmode") || lowerName.includes("set_mode")) { - return "switch_mode" - } - - // Think/reasoning operations - if ( - lowerName.includes("think") || - lowerName.includes("reason") || - lowerName.includes("plan") || - lowerName.includes("analyze") - ) { - return "think" - } - - // Search operations (check before read since "search" was previously mapped to read) - if (lowerName.includes("search") || lowerName.includes("find") || lowerName.includes("grep")) { - return "search" - } - - // Delete operations (check BEFORE move since "remove" contains "move" substring) - if (lowerName.includes("delete") || lowerName.includes("remove")) { - return "delete" - } - - // Move/rename operations - if (lowerName.includes("move") || lowerName.includes("rename")) { - return "move" - } - - // Edit operations - if ( - lowerName.includes("write") || - lowerName.includes("edit") || - lowerName.includes("modify") || - lowerName.includes("create") || - lowerName.includes("diff") || - lowerName.includes("apply") - ) { - return "edit" - } - - // Fetch operations (check BEFORE read since "http_get" contains "get" substring) - // Note: "browser" is NOT included here since browser tools are disabled in CLI - if ( - lowerName.includes("fetch") || - lowerName.includes("http") || - lowerName.includes("url") || - lowerName.includes("web_request") - ) { - return "fetch" - } - - // Read operations - if ( - lowerName.includes("read") || - lowerName.includes("list") || - lowerName.includes("inspect") || - lowerName.includes("get") - ) { - return "read" - } - - // Command/execute operations - if (lowerName.includes("command") || lowerName.includes("execute") || lowerName.includes("run")) { - return "execute" - } - - // Default to other - return "other" -} - -// ============================================================================= -// Ask Type Helpers -// ============================================================================= - -/** - * Ask types that require permission from the user. - */ -const PERMISSION_ASKS: ClineAsk[] = ["tool", "command", "browser_action_launch", "use_mcp_server"] - -/** - * Check if an ask type requires permission. - */ -export function isPermissionAsk(ask: ClineAsk): boolean { - return PERMISSION_ASKS.includes(ask) -} - -/** - * Ask types that indicate task completion. - */ -const COMPLETION_ASKS: ClineAsk[] = ["completion_result", "api_req_failed", "mistake_limit_reached"] - -/** - * Check if an ask type indicates task completion. - */ -export function isCompletionAsk(ask: ClineAsk): boolean { - return COMPLETION_ASKS.includes(ask) -} - -// ============================================================================= -// Prompt Content Translation -// ============================================================================= - -/** - * Extract text content from ACP prompt content blocks. - */ -export function extractPromptText(prompt: acp.ContentBlock[]): string { - const textParts: string[] = [] - - for (const block of prompt) { - switch (block.type) { - case "text": - textParts.push(block.text) - break - case "resource_link": - // Reference to a file or resource - textParts.push(`@${block.uri}`) - break - case "resource": - // Embedded resource content - if (block.resource && "text" in block.resource) { - textParts.push(`Content from ${block.resource.uri}:\n${block.resource.text}`) - } - break - case "image": - case "audio": - // Binary content - note it but don't include - textParts.push(`[${block.type} content]`) - break - } - } - - return textParts.join("\n") -} - -/** - * Extract images from ACP prompt content blocks. - */ -export function extractPromptImages(prompt: acp.ContentBlock[]): string[] { - const images: string[] = [] - - for (const block of prompt) { - if (block.type === "image" && block.data) { - images.push(block.data) - } - } - - return images -} - -// ============================================================================= -// Permission Options -// ============================================================================= - -/** - * Create standard permission options for a tool call. - */ -export function createPermissionOptions(ask: ClineAsk): acp.PermissionOption[] { - const baseOptions: acp.PermissionOption[] = [ - { optionId: "allow", name: "Allow", kind: "allow_once" }, - { optionId: "reject", name: "Reject", kind: "reject_once" }, - ] - - // Add "allow always" option for certain ask types - if (ask === "tool" || ask === "command") { - return [{ optionId: "allow_always", name: "Always Allow", kind: "allow_always" }, ...baseOptions] - } - - return baseOptions -} - -// ============================================================================= -// Tool Call Building -// ============================================================================= - -/** - * Build an ACP ToolCall from a ClineMessage. - * @param message - The ClineMessage to parse - * @param workspacePath - Optional workspace path to resolve relative paths - */ -export function buildToolCallFromMessage(message: ClineMessage, workspacePath?: string): acp.ToolCall { - const toolInfo = parseToolFromMessage(message, workspacePath) - - const toolCall: acp.ToolCall = { - toolCallId: toolInfo?.id || `tool-${message.ts}`, - title: toolInfo?.title || message.text?.slice(0, 100) || "Tool execution", - kind: toolInfo ? mapToolKind(toolInfo.name) : "other", - status: "pending", - locations: toolInfo?.locations || [], - rawInput: toolInfo?.params || {}, - } - - // Include content if available (e.g., diffs for file operations) - if (toolInfo?.content && toolInfo.content.length > 0) { - toolCall.content = toolInfo.content - } - - return toolCall -} +// Re-export everything from the translator module +export { + // Diff parsing + parseUnifiedDiff, + isUnifiedDiff, + type ParsedDiff, + // Location extraction + extractLocations, + extractFilePathsFromSearchResults, + type LocationParams, + // Prompt extraction + extractPromptText, + extractPromptImages, + extractPromptResources, + // Tool parsing + parseToolFromMessage, + generateToolTitle, + extractToolContent, + buildToolCallFromMessage, + type ToolCallInfo, + // Message translation + translateToAcpUpdate, + isPermissionAsk, + isCompletionAsk, + createPermissionOptions, + // Backward compatibility + mapToolKind, +} from "./translator/index.js" diff --git a/apps/cli/src/acp/translator/diff-parser.ts b/apps/cli/src/acp/translator/diff-parser.ts new file mode 100644 index 0000000000..ec66b22dc9 --- /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 0000000000..011f83e620 --- /dev/null +++ b/apps/cli/src/acp/translator/index.ts @@ -0,0 +1,43 @@ +/** + * Translator Module + * + * Re-exports all translator functionality for backward compatibility. + * Import from this module to use the translator features. + * + * The translator is split into focused modules: + * - diff-parser: Unified diff parsing + * - location-extractor: File location extraction + * - prompt-extractor: Prompt content extraction + * - tool-parser: Tool information parsing + * - message-translator: Main message translation + */ + +// Diff parsing +export { parseUnifiedDiff, isUnifiedDiff, type ParsedDiff } from "./diff-parser.js" + +// Location extraction +export { extractLocations, extractFilePathsFromSearchResults, type LocationParams } from "./location-extractor.js" + +// Prompt extraction +export { extractPromptText, extractPromptImages, extractPromptResources } from "./prompt-extractor.js" + +// Tool parsing +export { + parseToolFromMessage, + generateToolTitle, + extractToolContent, + buildToolCallFromMessage, + type ToolCallInfo, +} from "./tool-parser.js" + +// Message translation +export { + translateToAcpUpdate, + isPermissionAsk, + isCompletionAsk, + createPermissionOptions, +} from "./message-translator.js" + +// Re-export mapToolKind for backward compatibility +// (now uses mapToolToKind from tool-registry internally) +export { mapToolToKind as mapToolKind } from "../tool-registry.js" diff --git a/apps/cli/src/acp/translator/location-extractor.ts b/apps/cli/src/acp/translator/location-extractor.ts new file mode 100644 index 0000000000..2d53c7d7cf --- /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 0000000000..938b098e1e --- /dev/null +++ b/apps/cli/src/acp/translator/message-translator.ts @@ -0,0 +1,179 @@ +/** + * Message Translator + * + * Translates between internal ClineMessage format and ACP protocol format. + * This is the main bridge between Roo Code's message system and the ACP protocol. + */ + +import type * as acp from "@agentclientprotocol/sdk" +import type { ClineMessage, ClineAsk } from "@roo-code/types" + +import { mapToolToKind } from "../tool-registry.js" +import { parseToolFromMessage } from "./tool-parser.js" + +// ============================================================================= +// Message to ACP Update Translation +// ============================================================================= + +/** + * Translate an internal ClineMessage to an ACP session update. + * Returns null if the message type should not be sent to ACP. + * + * @param message - Internal ClineMessage + * @returns ACP session update or null + */ +export function translateToAcpUpdate(message: ClineMessage): acp.SessionNotification["update"] | null { + if (message.type === "say") { + switch (message.say) { + case "text": + // Agent text output + return { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: message.text || "" }, + } + + case "reasoning": + // Agent reasoning/thinking + return { + sessionUpdate: "agent_thought_chunk", + content: { type: "text", text: message.text || "" }, + } + + case "shell_integration_warning": + case "mcp_server_request_started": + case "mcp_server_response": + // Tool-related messages + return translateToolSayMessage(message) + + case "user_feedback": + // User feedback doesn't need to be sent to ACP client + return null + + case "error": + // Error messages + return { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: `Error: ${message.text || ""}` }, + } + + case "completion_result": + // Completion is handled at prompt level + return null + + case "api_req_started": + case "api_req_finished": + case "api_req_retried": + case "api_req_retry_delayed": + case "api_req_deleted": + // API request lifecycle events - not sent to ACP + return null + + case "command_output": + // Command execution - handled through tool_call + return null + + default: + // Unknown message type + return null + } + } + + // Ask messages are handled separately through permission flow + return null +} + +/** + * Translate a tool say message to ACP format. + * + * @param message - Tool-related ClineMessage + * @returns ACP session update or null + */ +function translateToolSayMessage(message: ClineMessage): acp.SessionNotification["update"] | null { + const toolInfo = parseToolFromMessage(message) + if (!toolInfo) { + return null + } + + if (message.partial) { + // Tool in progress + return { + sessionUpdate: "tool_call", + toolCallId: toolInfo.id, + title: toolInfo.title, + kind: mapToolToKind(toolInfo.name), + status: "in_progress" as const, + locations: toolInfo.locations, + rawInput: toolInfo.params, + } + } else { + // Tool completed + return { + sessionUpdate: "tool_call_update", + toolCallId: toolInfo.id, + status: "completed" as const, + content: [], + rawOutput: toolInfo.params, + } + } +} + +// ============================================================================= +// Ask Type Helpers +// ============================================================================= + +/** + * Ask types that require permission from the user. + */ +const PERMISSION_ASKS: readonly ClineAsk[] = ["tool", "command", "browser_action_launch", "use_mcp_server"] + +/** + * Check if an ask type requires permission. + * + * @param ask - The ask type to check + * @returns true if permission is required + */ +export function isPermissionAsk(ask: ClineAsk): boolean { + return PERMISSION_ASKS.includes(ask) +} + +/** + * Ask types that indicate task completion. + */ +const COMPLETION_ASKS: readonly ClineAsk[] = ["completion_result", "api_req_failed", "mistake_limit_reached"] + +/** + * Check if an ask type indicates task completion. + * + * @param ask - The ask type to check + * @returns true if this indicates completion + */ +export function isCompletionAsk(ask: ClineAsk): boolean { + return COMPLETION_ASKS.includes(ask) +} + +// ============================================================================= +// Permission Options +// ============================================================================= + +/** + * Create standard permission options for a tool call. + * + * Returns options like "Allow", "Reject", and optionally "Always Allow" + * for certain tool types. + * + * @param ask - The ask type + * @returns Array of permission options + */ +export function createPermissionOptions(ask: ClineAsk): acp.PermissionOption[] { + const baseOptions: acp.PermissionOption[] = [ + { optionId: "allow", name: "Allow", kind: "allow_once" }, + { optionId: "reject", name: "Reject", kind: "reject_once" }, + ] + + // Add "allow always" option for certain ask types + if (ask === "tool" || ask === "command") { + return [{ optionId: "allow_always", name: "Always Allow", kind: "allow_always" }, ...baseOptions] + } + + return baseOptions +} diff --git a/apps/cli/src/acp/translator/prompt-extractor.ts b/apps/cli/src/acp/translator/prompt-extractor.ts new file mode 100644 index 0000000000..3d135869d2 --- /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 0000000000..aac517bff9 --- /dev/null +++ b/apps/cli/src/acp/translator/tool-parser.ts @@ -0,0 +1,237 @@ +/** + * Tool Parser + * + * Parses tool information from ClineMessage format. + * Extracts tool name, parameters, and generates titles. + */ + +import * as path from "node:path" +import type * as acp from "@agentclientprotocol/sdk" +import type { ClineMessage } from "@roo-code/types" + +import { mapToolToKind, isEditTool as isFileEditTool } from "../tool-registry.js" +import { extractLocations } from "./location-extractor.js" +import { parseUnifiedDiff } from "./diff-parser.js" +import { resolveFilePathUnsafe } from "../utils/index.js" + +// ============================================================================= +// Types +// ============================================================================= + +/** + * Parsed tool call information. + */ +export interface ToolCallInfo { + /** Unique identifier for the tool call */ + id: string + /** Tool name */ + name: string + /** Human-readable title */ + title: string + /** Tool parameters */ + params: Record + /** File locations involved */ + locations: acp.ToolCallLocation[] + /** Tool content (diffs, etc.) */ + content?: acp.ToolCallContent[] +} + +// ============================================================================= +// Tool Call ID Generation +// ============================================================================= + +/** + * Generate a tool call ID from a ClineMessage timestamp. + * + * Uses the message timestamp directly, which provides: + * - Deterministic IDs - same message always produces same ID + * - Natural deduplication - duplicate waitingForInput events use same ID + * - Easy debugging - can correlate ACP tool calls to ClineMessages + * - Sortable by creation time + * + * @param timestamp - ClineMessage timestamp (message.ts) + * @returns Tool call ID + */ +function generateToolCallId(timestamp: number): string { + return `tool-${timestamp}` +} + +// ============================================================================= +// Tool Parsing +// ============================================================================= + +/** + * Parse tool information from a ClineMessage. + * + * Handles two formats: + * 1. JSON format: Message text is JSON with tool name and parameters + * 2. Text format: Tool name extracted from text like "Using/Executing/Running X" + * + * @param message - The ClineMessage to parse + * @param workspacePath - Optional workspace path to resolve relative paths + * @returns Parsed tool info or null if parsing fails + */ +export function parseToolFromMessage(message: ClineMessage, workspacePath?: string): ToolCallInfo | null { + if (!message.text) { + return null + } + + // Tool messages typically have JSON content describing the tool + try { + // Try to parse as JSON first + if (message.text.startsWith("{")) { + const parsed = JSON.parse(message.text) as Record + const toolName = (parsed.tool as string) || "unknown" + const filePath = (parsed.path as string) || undefined + + return { + id: generateToolCallId(message.ts), + name: toolName, + title: generateToolTitle(toolName, filePath), + params: parsed, + locations: extractLocations(parsed, workspacePath), + content: extractToolContent(parsed, workspacePath), + } + } + } catch { + // Not JSON, try to extract tool info from text + } + + // Extract tool name from text content + const toolMatch = message.text.match(/(?:Using|Executing|Running)\s+(\w+)/i) + const toolName = toolMatch?.[1] || "unknown" + + return { + id: generateToolCallId(message.ts), + name: toolName, + title: message.text.slice(0, 100), + params: {}, + locations: [], + } +} + +// ============================================================================= +// Tool Title Generation +// ============================================================================= + +/** + * Generate a human-readable title for a tool operation. + * + * Maps tool names to descriptive titles, optionally including file names. + * + * @param toolName - The tool name + * @param filePath - Optional file path for context + * @returns Human-readable title + */ +export function generateToolTitle(toolName: string, filePath?: string): string { + const fileName = filePath ? path.basename(filePath) : undefined + + // Map tool names to human-readable titles + const toolTitles: Record = { + // File creation + newFileCreated: fileName ? `Creating ${fileName}` : "Creating file", + write_to_file: fileName ? `Writing ${fileName}` : "Writing file", + create_file: fileName ? `Creating ${fileName}` : "Creating file", + + // File editing + editedExistingFile: fileName ? `Edit ${fileName}` : "Edit file", + apply_diff: fileName ? `Edit ${fileName}` : "Edit file", + appliedDiff: fileName ? `Edit ${fileName}` : "Edit file", + modify_file: fileName ? `Edit ${fileName}` : "Edit file", + + // File reading + read_file: fileName ? `Read ${fileName}` : "Read file", + readFile: fileName ? `Read ${fileName}` : "Read file", + + // File listing + list_files: filePath ? `Listing files in ${filePath}` : "Listing files", + listFiles: filePath ? `Listing files in ${filePath}` : "Listing files", + + // File search + search_files: "Searching files", + searchFiles: "Searching files", + + // Command execution + execute_command: "Running command", + executeCommand: "Running command", + + // Browser actions + browser_action: "Browser action", + browserAction: "Browser action", + } + + return toolTitles[toolName] || (fileName ? `${toolName}: ${fileName}` : toolName) +} + +// ============================================================================= +// Tool Content Extraction +// ============================================================================= + +/** + * Extract tool content for ACP (diffs, text, etc.) + * + * For file edit tools, parses the content as a unified diff. + * + * @param params - Tool parameters + * @param workspacePath - Optional workspace path + * @returns Array of tool content or undefined + */ +export function extractToolContent( + params: Record, + workspacePath?: string, +): acp.ToolCallContent[] | undefined { + const content: acp.ToolCallContent[] = [] + + // Check if this is a file operation with diff content + const filePath = params.path as string | undefined + const diffContent = params.content as string | undefined + const toolName = params.tool as string | undefined + + if (filePath && diffContent && isFileEditTool(toolName || "")) { + const absolutePath = resolveFilePathUnsafe(filePath, workspacePath) + const parsedDiff = parseUnifiedDiff(diffContent) + + if (parsedDiff) { + // Use ACP diff format + content.push({ + type: "diff", + path: absolutePath, + oldText: parsedDiff.oldText, + newText: parsedDiff.newText, + } as acp.ToolCallContent) + } + } + + return content.length > 0 ? content : undefined +} + +// ============================================================================= +// Tool Call Building +// ============================================================================= + +/** + * Build an ACP ToolCall from a ClineMessage. + * + * @param message - The ClineMessage to parse + * @param workspacePath - Optional workspace path to resolve relative paths + * @returns ACP ToolCall object + */ +export function buildToolCallFromMessage(message: ClineMessage, workspacePath?: string): acp.ToolCall { + const toolInfo = parseToolFromMessage(message, workspacePath) + + const toolCall: acp.ToolCall = { + toolCallId: toolInfo?.id || generateToolCallId(message.ts), + title: toolInfo?.title || message.text?.slice(0, 100) || "Tool execution", + kind: toolInfo ? mapToolToKind(toolInfo.name) : "other", + status: "pending", + locations: toolInfo?.locations || [], + rawInput: toolInfo?.params || {}, + } + + // Include content if available (e.g., diffs for file operations) + if (toolInfo?.content && toolInfo.content.length > 0) { + toolCall.content = toolInfo.content + } + + return toolCall +} diff --git a/apps/cli/src/acp/update-buffer.ts b/apps/cli/src/acp/update-buffer.ts index dc96c7affd..362ccb3e80 100644 --- a/apps/cli/src/acp/update-buffer.ts +++ b/apps/cli/src/acp/update-buffer.ts @@ -7,7 +7,8 @@ */ import type * as acp from "@agentclientprotocol/sdk" -import { acpLog } from "./logger.js" +import type { IAcpLogger } from "./interfaces.js" +import { NullLogger } from "./interfaces.js" // ============================================================================= // Types (exported) @@ -20,6 +21,8 @@ interface UpdateBufferOptions { minBufferSize?: number /** Maximum time in ms before flushing (default: 500) */ flushDelayMs?: number + /** Logger instance (optional, defaults to NullLogger) */ + logger?: IAcpLogger } type TextChunkUpdate = { @@ -56,6 +59,7 @@ function isTextChunkUpdate(update: SessionUpdate): update is TextChunkUpdate { export class UpdateBuffer { private readonly minBufferSize: number private readonly flushDelayMs: number + private readonly logger: IAcpLogger /** Buffered text for agent_message_chunk */ private messageBuffer = "" @@ -71,6 +75,7 @@ export class UpdateBuffer { constructor(sendUpdate: (update: SessionUpdate) => Promise, options: UpdateBufferOptions = {}) { this.minBufferSize = options.minBufferSize ?? 200 this.flushDelayMs = options.flushDelayMs ?? 500 + this.logger = options.logger ?? new NullLogger() this.sendUpdate = sendUpdate } @@ -106,7 +111,7 @@ export class UpdateBuffer { return } - acpLog.debug( + this.logger.debug( "UpdateBuffer", `Flushing buffers: message=${this.messageBuffer.length}, thought=${this.thoughtBuffer.length}`, ) @@ -142,7 +147,7 @@ export class UpdateBuffer { this.messageBuffer = "" this.thoughtBuffer = "" this.hasPendingContent = false - acpLog.debug("UpdateBuffer", "Buffer reset") + this.logger.debug("UpdateBuffer", "Buffer reset") } /** @@ -176,7 +181,10 @@ export class UpdateBuffer { // Check if we should flush based on size const totalSize = this.messageBuffer.length + this.thoughtBuffer.length if (totalSize >= this.minBufferSize) { - acpLog.debug("UpdateBuffer", `Size threshold reached (${totalSize} >= ${this.minBufferSize}), flushing`) + this.logger.debug( + "UpdateBuffer", + `Size threshold reached (${totalSize} >= ${this.minBufferSize}), flushing`, + ) void this.flush() return } @@ -195,7 +203,7 @@ export class UpdateBuffer { this.flushTimer = setTimeout(() => { this.flushTimer = null - acpLog.debug("UpdateBuffer", "Flush timer expired") + this.logger.debug("UpdateBuffer", "Flush timer expired") void this.flush() }, this.flushDelayMs) } diff --git a/apps/cli/src/acp/utils/format-utils.ts b/apps/cli/src/acp/utils/format-utils.ts new file mode 100644 index 0000000000..73632bdfb3 --- /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 0000000000..37b7bd1280 --- /dev/null +++ b/apps/cli/src/acp/utils/index.ts @@ -0,0 +1,29 @@ +/** + * ACP Utilities Module + * + * Shared utilities for the ACP implementation. + */ + +export { + // Configuration + type FormatConfig, + DEFAULT_FORMAT_CONFIG, + // Result type + type Result, + ok, + err, + // Formatting functions + formatSearchResults, + formatReadContent, + wrapInCodeBlock, + // Content extraction + extractContentFromParams, + // File operations + readFileContent, + readFileContentAsync, + resolveFilePath, + resolveFilePathUnsafe, + // Validation + isUserEcho, + hasValidFilePath, +} from "./format-utils.js" diff --git a/apps/cli/src/agent/extension-host.ts b/apps/cli/src/agent/extension-host.ts index 968de48e9f..07ac2e0785 100644 --- a/apps/cli/src/agent/extension-host.ts +++ b/apps/cli/src/agent/extension-host.ts @@ -163,6 +163,7 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac // Initialize output manager. this.outputManager = new OutputManager({ disabled: options.disableOutput, + debug: options.debug, }) // Initialize prompt manager with console mode callbacks. diff --git a/apps/cli/src/agent/message-processor.ts b/apps/cli/src/agent/message-processor.ts index dce32a93e7..d8d7842806 100644 --- a/apps/cli/src/agent/message-processor.ts +++ b/apps/cli/src/agent/message-processor.ts @@ -105,6 +105,9 @@ export class MessageProcessor { * @param message - The raw message from the extension */ processMessage(message: ExtensionMessage): void { + // Debug logging for ALL messages to trace flow (always enabled for debugging) + console.error(`[MessageProcessor-DEBUG] processMessage: type=${message.type}`) + if (this.options.debug) { debugLog("[MessageProcessor] Received message", { type: message.type }) } @@ -248,6 +251,12 @@ export class MessageProcessor { const clineMessage = message.clineMessage + // Debug logging for messageUpdated + const msgType = clineMessage.type === "ask" ? `ask:${clineMessage.ask}` : `say:${clineMessage.say}` + console.error( + `[MessageProcessor-DEBUG] handleMessageUpdated: ${msgType}, ts=${clineMessage.ts}, partial=${clineMessage.partial}, textLen=${clineMessage.text?.length || 0}`, + ) + const previousState = this.store.getAgentState() // Update the message in the store @@ -422,6 +431,12 @@ export class MessageProcessor { // A more sophisticated implementation would track seen message timestamps const lastMessage = messages[messages.length - 1] if (lastMessage) { + // Debug logging for emitted messages + const msgType = lastMessage.type === "ask" ? `ask:${lastMessage.ask}` : `say:${lastMessage.say}` + console.error( + `[MessageProcessor-DEBUG] emitNewMessageEvents (last of ${messages.length}): ${msgType}, ts=${lastMessage.ts}, partial=${lastMessage.partial}, textLen=${lastMessage.text?.length || 0}`, + ) + // DEBUG: Log all emitted ask messages to trace partial handling if (this.options.debug && lastMessage.type === "ask") { debugLog("[MessageProcessor] EMIT message", { diff --git a/apps/cli/src/agent/output-manager.ts b/apps/cli/src/agent/output-manager.ts index f657b2802e..f6b74d28b2 100644 --- a/apps/cli/src/agent/output-manager.ts +++ b/apps/cli/src/agent/output-manager.ts @@ -13,8 +13,17 @@ * - Can be disabled for TUI mode where Ink controls the terminal */ +import fs from "fs" import { ClineMessage, ClineSay } from "@roo-code/types" +// Debug logging to file (for CLI debugging without breaking TUI) +const DEBUG_LOG = "/tmp/roo-cli-debug.log" +function debugLog(message: string, data?: unknown) { + const timestamp = new Date().toISOString() + const entry = data ? `[${timestamp}] ${message}: ${JSON.stringify(data, null, 2)}\n` : `[${timestamp}] ${message}\n` + fs.appendFileSync(DEBUG_LOG, entry) +} + import { Observable } from "./events.js" // ============================================================================= @@ -58,6 +67,12 @@ export interface OutputManagerOptions { * Stream for error output (default: process.stderr). */ stderr?: NodeJS.WriteStream + + /** + * When true, outputs verbose debug info for tool requests. + * Enabled by -d flag in CLI. + */ + debug?: boolean } // ============================================================================= @@ -68,6 +83,7 @@ export class OutputManager { private disabled: boolean private stdout: NodeJS.WriteStream private stderr: NodeJS.WriteStream + private debug: boolean /** * Track displayed messages by ts to avoid duplicate output. @@ -113,6 +129,7 @@ export class OutputManager { this.disabled = options.disabled ?? false this.stdout = options.stdout ?? process.stdout this.stderr = options.stderr ?? process.stderr + this.debug = options.debug ?? false } // =========================================================================== @@ -158,12 +175,26 @@ export class OutputManager { } } + /** + * Get a timestamp for debug output. + */ + private getTimestamp(): string { + const now = new Date() + return `[${now.toISOString().slice(11, 23)}]` + } + + /** + * Whether to include timestamps in output (for debugging). + */ + private showTimestamps = !!process.env.DEBUG_TIMESTAMPS + /** * Output a simple text line with a label. */ output(label: string, text?: string): void { if (this.disabled) return - const message = text ? `${label} ${text}\n` : `${label}\n` + const ts = this.showTimestamps ? `${this.getTimestamp()} ` : "" + const message = text ? `${ts}${label} ${text}\n` : `${ts}${label}\n` this.stdout.write(message) } @@ -172,7 +203,8 @@ export class OutputManager { */ outputError(label: string, text?: string): void { if (this.disabled) return - const message = text ? `${label} ${text}\n` : `${label}\n` + const ts = this.showTimestamps ? `${this.getTimestamp()} ` : "" + const message = text ? `${ts}${label} ${text}\n` : `${ts}${label}\n` this.stderr.write(message) } @@ -181,7 +213,8 @@ export class OutputManager { */ writeRaw(text: string): void { if (this.disabled) return - this.stdout.write(text) + const ts = this.showTimestamps ? `${this.getTimestamp()} ` : "" + this.stdout.write(ts + text) } /** @@ -233,6 +266,7 @@ export class OutputManager { this.hasStreamedTerminalOutput = false this.toolContentStreamed.clear() this.toolContentTruncated.clear() + this.toolLastDisplayedCharCount.clear() this.streamingState.next({ ts: null, isStreaming: false }) } @@ -420,11 +454,25 @@ export class OutputManager { */ private toolContentTruncated = new Set() + /** + * Track the last displayed character count for streaming updates. + */ + private toolLastDisplayedCharCount = new Map() + /** * Maximum lines to show when streaming file content. */ private static readonly MAX_PREVIEW_LINES = 5 + /** + * Helper to write debug output to stderr with timestamp. + */ + private debugOutput(message: string): void { + if (!this.debug) return + const ts = this.getTimestamp() + this.stderr.write(`${ts} [DEBUG] ${message}\n`) + } + /** * Output tool request (file create/edit/delete) with streaming content preview. * Shows the file content being written (up to 20 lines), then final state when complete. @@ -448,17 +496,56 @@ export class OutputManager { // Use default if not JSON } + // Debug output: show every tool request message + this.debugOutput( + `outputToolRequest: ts=${ts} partial=${isPartial} tool=${toolName} path="${toolPath}" contentLen=${content.length}`, + ) + + debugLog("[outputToolRequest] called", { + ts, + isPartial, + toolName, + toolPath, + contentLen: content.length, + }) + if (isPartial && text) { const previousContent = this.toolContentStreamed.get(ts) || "" const previous = this.streamedContent.get(ts) + const currentLineCount = content === "" ? 0 : content.split("\n").length - if (!previous) { - // First partial - show header with path (if has valid extension) - // Check for valid extension: must have a dot followed by 1+ characters - const hasValidExtension = /\.[a-zA-Z0-9]+$/.test(toolPath) - const pathInfo = hasValidExtension ? ` ${toolPath}` : "" - this.writeRaw(`\n[${toolName}]${pathInfo}\n`) + // Check for valid extension: must have a dot followed by 1+ characters + const hasValidExtension = /\.[a-zA-Z0-9]+$/.test(toolPath) + + // Don't show header until we have BOTH a valid path AND some content. + // This prevents showing "[newFileCreated] (0 chars)" followed by a long + // pause while the LLM generates the content. + const shouldShowHeader = hasValidExtension && content.length > 0 + + if (!previous && shouldShowHeader) { + // First partial with valid path and content - show header + const pathInfo = ` ${toolPath}` + debugLog("[outputToolRequest] FIRST PARTIAL - header", { + toolName, + toolPath, + contentLen: content.length, + }) + this.writeRaw(`\n[${toolName}]${pathInfo} (${content.length} chars)\n`) this.streamedContent.set(ts, { ts, text, headerShown: true }) + this.toolLastDisplayedCharCount.set(ts, content.length) + this.currentlyStreamingTs = ts + this.streamingState.next({ ts, isStreaming: true }) + } else if (!previous && !shouldShowHeader) { + // Early partial without valid path/content - track but don't show yet + // Just set headerShown: false to track we've seen this ts + this.streamedContent.set(ts, { ts, text, headerShown: false }) + } else if (previous && !previous.headerShown && shouldShowHeader) { + // Path and content now valid - show the header now + const pathInfo = ` ${toolPath}` + debugLog("[outputToolRequest] DEFERRED HEADER", { toolName, toolPath, contentLen: content.length }) + this.writeRaw(`\n[${toolName}]${pathInfo} (${content.length} chars)\n`) + this.streamedContent.set(ts, { ts, text, headerShown: true }) + this.toolLastDisplayedCharCount.set(ts, content.length) this.currentlyStreamingTs = ts this.streamingState.next({ ts, isStreaming: true }) } @@ -468,7 +555,6 @@ export class OutputManager { const delta = content.slice(previousContent.length) // Check if we're still within the preview limit const previousLineCount = previousContent === "" ? 0 : previousContent.split("\n").length - const currentLineCount = content === "" ? 0 : content.split("\n").length const previouslyTruncated = this.toolContentTruncated.has(ts) if (!previouslyTruncated) { @@ -477,7 +563,6 @@ export class OutputManager { this.writeRaw(delta) } else if (previousLineCount < OutputManager.MAX_PREVIEW_LINES) { // Just crossed the limit - output remaining lines up to limit, mark as truncated - // (truncation message will be shown at completion with final count) const linesToShow = OutputManager.MAX_PREVIEW_LINES - previousLineCount const deltaLines = delta.split("\n") const truncatedDelta = deltaLines.slice(0, linesToShow).join("\n") @@ -485,24 +570,34 @@ export class OutputManager { this.writeRaw(truncatedDelta) } this.toolContentTruncated.add(ts) + // Show streaming indicator with char count + this.writeRaw(`\n... streaming (${content.length} chars)`) + this.toolLastDisplayedCharCount.set(ts, content.length) } else { // Already at/past limit but not yet marked - just mark as truncated this.toolContentTruncated.add(ts) } + } else { + // Already truncated - update streaming char count on each update + // Output on new lines so updates are visible in captured output + const lastDisplayed = this.toolLastDisplayedCharCount.get(ts) || 0 + if (content.length !== lastDisplayed) { + this.writeRaw(`\n... streaming (${content.length} chars)`) + this.toolLastDisplayedCharCount.set(ts, content.length) + } } - // If already truncated, don't output more content this.toolContentStreamed.set(ts, content) } this.displayedMessages.set(ts, { ts, text, partial: true }) } else if (!isPartial && !alreadyDisplayedComplete) { - // Tool request complete - check if we need to show truncation message + // Tool request complete const previousContent = this.toolContentStreamed.get(ts) || "" const currentLineCount = content === "" ? 0 : content.split("\n").length + const wasTruncated = this.toolContentTruncated.has(ts) - // Show truncation message if content exceeds preview limit - // (We only mark as truncated during partials, the actual message is shown here with final count) - if (currentLineCount > OutputManager.MAX_PREVIEW_LINES && previousContent) { + // Show final truncation message + if (wasTruncated && previousContent) { const remainingLines = currentLineCount - OutputManager.MAX_PREVIEW_LINES this.writeRaw(`\n... (${remainingLines} more lines)\n`) } @@ -517,6 +612,7 @@ export class OutputManager { // Clean up tool content tracking this.toolContentStreamed.delete(ts) this.toolContentTruncated.delete(ts) + this.toolLastDisplayedCharCount.delete(ts) } } diff --git a/apps/cli/src/types/constants.ts b/apps/cli/src/types/constants.ts index 04abb90ad4..f11973bf45 100644 --- a/apps/cli/src/types/constants.ts +++ b/apps/cli/src/types/constants.ts @@ -3,7 +3,7 @@ import { reasoningEffortsExtended } from "@roo-code/types" export const DEFAULT_FLAGS = { mode: "code", reasoningEffort: "medium" as const, - model: "anthropic/claude-opus-4.5", + model: "anthropic/claude-4.5-sonnet", provider: "openrouter", } diff --git a/apps/cli/src/ui/hooks/useClientEvents.ts b/apps/cli/src/ui/hooks/useClientEvents.ts index 30758858be..110d14ce4a 100644 --- a/apps/cli/src/ui/hooks/useClientEvents.ts +++ b/apps/cli/src/ui/hooks/useClientEvents.ts @@ -200,9 +200,15 @@ export function useClientEvents({ client, nonInteractive }: UseClientEventsOptio toolDisplayName = toolInfo.tool as string toolDisplayOutput = formatToolOutput(toolInfo) toolData = extractToolData(toolInfo) - } catch { + } catch (err) { // Use raw text if not valid JSON - may happen during early streaming parseError = true + tuiLogger.debug("ask:partial-tool:parse-error", { + id: messageId, + textLen: text.length, + textPreview: text.substring(0, 100), + error: String(err), + }) } tuiLogger.debug("ask:partial-tool", { @@ -210,6 +216,8 @@ export function useClientEvents({ client, nonInteractive }: UseClientEventsOptio textLen: text.length, toolName: toolName || "none", hasToolData: !!toolData, + toolDataPath: toolData?.path, + toolDataContentLen: toolData?.content?.length || 0, parseError, }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3dc925f38c..daa03f7379 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,7 +84,7 @@ importers: dependencies: '@agentclientprotocol/sdk': specifier: ^0.12.0 - version: 0.12.0(zod@3.25.76) + version: 0.12.0(zod@4.3.5) '@inkjs/ui': specifier: ^2.0.0 version: 2.0.0(ink@6.6.0(@types/react@18.3.23)(react@19.2.3)) @@ -121,6 +121,9 @@ importers: superjson: specifier: ^2.2.6 version: 2.2.6 + zod: + specifier: ^4.3.5 + version: 4.3.5 zustand: specifier: ^5.0.0 version: 5.0.9(@types/react@18.3.23)(react@19.2.3) @@ -10667,6 +10670,9 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.3.5: + resolution: {integrity: sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==} + zustand@5.0.9: resolution: {integrity: sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==} engines: {node: '>=12.20.0'} @@ -10692,9 +10698,9 @@ snapshots: '@adobe/css-tools@4.4.2': {} - '@agentclientprotocol/sdk@0.12.0(zod@3.25.76)': + '@agentclientprotocol/sdk@0.12.0(zod@4.3.5)': dependencies: - zod: 3.25.76 + zod: 4.3.5 '@alcalzone/ansi-tokenize@0.2.3': dependencies: @@ -21727,6 +21733,8 @@ snapshots: zod@3.25.76: {} + zod@4.3.5: {} + zustand@5.0.9(@types/react@18.3.23)(react@19.2.3): optionalDependencies: '@types/react': 18.3.23 diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index fd791729b4..e33e567cb3 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) })