Clean refactor

This commit is contained in:
cte 2026-01-11 14:32:22 -08:00
parent 74881a4f4f
commit 4b43a0d865
35 changed files with 6554 additions and 1411 deletions

View file

@ -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": {

View file

@ -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<Record<string, unknown>>
let manager: CommandStreamManager
beforeEach(() => {
deltaTracker = new DeltaTracker()
sentUpdates = []
sendUpdate = (update) => {
sentUpdates.push(update as Record<string, unknown>)
}
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<string, unknown>).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)
})
})
})

View file

@ -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)")
})
})

View file

@ -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")
})
})

View file

@ -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<Record<string, unknown>>
let manager: ToolContentStreamManager
beforeEach(() => {
deltaTracker = new DeltaTracker()
sentUpdates = []
sendUpdate = (update) => {
sentUpdates.push(update as Record<string, unknown>)
}
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)
})
})
})

View file

@ -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<string, unknown> = {}): 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<string, unknown>
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)
})
})
})

View file

@ -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")
})

View file

@ -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<string, PendingCommand> = new Map()
/**
* Track which command executions have sent the opening code fence.
* Used to wrap command output in markdown code blocks.
*/
private commandCodeFencesSent: Set<string> = 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<string, string> = 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
}
}

View file

@ -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<string, unknown>, 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<string, unknown>,
workspacePath: string,
): Promise<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 = 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<FormatConfig>) {
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, unknown>): string | undefined {
return extractContentFromParams(rawInput)
}
/**
* Extract file content for readFile operations.
* Delegates to the standalone extractFileContent function.
*/
extractFileContent(rawInput: Record<string, unknown>, workspacePath: string): string | undefined {
return extractFileContent(rawInput, workspacePath)
}
}
// =============================================================================
// Factory Function
// =============================================================================
/**
* Create a new content formatter with optional configuration.
*/
export function createContentFormatter(config?: Partial<FormatConfig>): ContentFormatter {
return new ContentFormatter(config)
}
// =============================================================================
// Type Exports
// =============================================================================
export type { FormatConfig as ContentFormatterConfig }

View file

@ -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"

View file

@ -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<acp.PromptResponse>
/**
* Cancel the current prompt.
*/
cancel(): void
/**
* Set the session mode.
*/
setMode(mode: string): void
/**
* Dispose of the session and release resources.
*/
dispose(): Promise<void>
/**
* 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<K extends keyof ExtensionClientEvents>(event: K, handler: ExtensionClientEvents[K]): void
off<K extends keyof ExtensionClientEvents>(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<void>
/**
* Dispose of the extension host.
*/
dispose(): Promise<void>
/**
* 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<void>
/**
* Flush all pending buffered content.
*/
flush(): Promise<void>
/**
* 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<PromptCompletionResult>
/**
* 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<void>) => 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 {}
}

View file

@ -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

View file

@ -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<PromptCompletionResult> {
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)
}

View file

@ -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<Record<ClineSay, StreamConfig>> for type safety.
*/
type DeltaStreamConfigMap = Partial<Record<ClineSay, StreamConfig>>
/**
* 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<string> = 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<void> {
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)
}

File diff suppressed because it is too large Load diff

View file

@ -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<number> = 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<string, unknown>
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`,
)
}
}

View file

@ -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<string, unknown>) || {}
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<string, unknown>) || {}
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<string, unknown>) || {}
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<string, unknown>) || {}
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 }

View file

@ -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<ToolCategory, Set<string>> = new Map()
private readonly toolDefinitions: Record<ToolCategory, readonly string[]>
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<typeof FilePathParamsSchema>
export type FileWriteParams = z.infer<typeof FileWriteParamsSchema>
export type FileMoveParams = z.infer<typeof FileMoveParamsSchema>
export type SearchParams = z.infer<typeof SearchParamsSchema>
export type ListFilesParams = z.infer<typeof ListFilesParamsSchema>
export type CommandParams = z.infer<typeof CommandParamsSchema>
export type ThinkParams = z.infer<typeof ThinkParamsSchema>
export type SwitchModeParams = z.infer<typeof SwitchModeParamsSchema>
export type GenericToolParams = z.infer<typeof GenericToolParamsSchema>
/**
* 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<T> = { 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<ToolParams> {
// 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<typeof ToolMessageSchema>
/**
* 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
}
}

View file

@ -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<string, unknown>
locations: acp.ToolCallLocation[]
content?: acp.ToolCallContent[]
}
// =============================================================================
// Message to ACP Update Translation
// =============================================================================
/**
* Translate an internal ClineMessage to an ACP session update.
* Returns null if the message type should not be sent to ACP.
*/
export function translateToAcpUpdate(message: ClineMessage): acp.SessionNotification["update"] | null {
if (message.type === "say") {
switch (message.say) {
case "text":
// Agent text output
return {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: message.text || "" },
}
case "reasoning":
// Agent reasoning/thinking
return {
sessionUpdate: "agent_thought_chunk",
content: { type: "text", text: message.text || "" },
}
case "shell_integration_warning":
case "mcp_server_request_started":
case "mcp_server_response":
// Tool-related messages
return translateToolSayMessage(message)
case "user_feedback":
// User feedback doesn't need to be sent to ACP client
return null
case "error":
// Error messages
return {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: `Error: ${message.text || ""}` },
}
case "completion_result":
// Completion is handled at prompt level
return null
case "api_req_started":
case "api_req_finished":
case "api_req_retried":
case "api_req_retry_delayed":
case "api_req_deleted":
// API request lifecycle events - not sent to ACP
return null
case "command_output":
// Command execution - handled through tool_call
return null
default:
// Unknown message type
return null
}
}
// Ask messages are handled separately through permission flow
return null
}
/**
* Translate a tool say message to ACP format.
*/
function translateToolSayMessage(message: ClineMessage): acp.SessionNotification["update"] | null {
const toolInfo = parseToolFromMessage(message)
if (!toolInfo) {
return null
}
if (message.partial) {
// Tool in progress
return {
sessionUpdate: "tool_call",
toolCallId: toolInfo.id,
title: toolInfo.title,
kind: mapToolKind(toolInfo.name),
status: "in_progress" as const,
locations: toolInfo.locations,
rawInput: toolInfo.params,
}
} else {
// Tool completed
return {
sessionUpdate: "tool_call_update",
toolCallId: toolInfo.id,
status: "completed" as const,
content: [],
rawOutput: toolInfo.params,
}
}
}
// =============================================================================
// Tool Information Parsing
// =============================================================================
/**
* Parse tool information from a ClineMessage.
* @param message - The ClineMessage to parse
* @param workspacePath - Optional workspace path to resolve relative paths
*/
export function parseToolFromMessage(message: ClineMessage, workspacePath?: string): ToolCallInfo | null {
if (!message.text) {
return null
}
// Tool messages typically have JSON content describing the tool
try {
// Try to parse as JSON first
if (message.text.startsWith("{")) {
const parsed = JSON.parse(message.text) as Record<string, unknown>
const toolName = (parsed.tool as string) || "unknown"
const filePath = (parsed.path as string) || undefined
return {
id: `tool-${message.ts}`,
name: toolName,
title: generateToolTitle(toolName, filePath),
params: parsed,
locations: extractLocations(parsed, workspacePath),
content: extractToolContent(parsed, workspacePath),
}
}
} catch {
// Not JSON, try to extract tool info from text
}
// Extract tool name from text content
const toolMatch = message.text.match(/(?:Using|Executing|Running)\s+(\w+)/i)
const toolName = toolMatch?.[1] || "unknown"
return {
id: `tool-${message.ts}`,
name: toolName,
title: message.text.slice(0, 100),
params: {},
locations: [],
}
}
/**
* Generate a human-readable title for a tool operation.
*/
function generateToolTitle(toolName: string, filePath?: string): string {
const fileName = filePath ? path.basename(filePath) : undefined
// Map tool names to human-readable titles
const toolTitles: Record<string, string> = {
// File creation
newFileCreated: fileName ? `Creating ${fileName}` : "Creating file",
write_to_file: fileName ? `Writing ${fileName}` : "Writing file",
create_file: fileName ? `Creating ${fileName}` : "Creating file",
// File editing
editedExistingFile: fileName ? `Edit ${fileName}` : "Edit file",
apply_diff: fileName ? `Edit ${fileName}` : "Edit file",
appliedDiff: fileName ? `Edit ${fileName}` : "Edit file",
modify_file: fileName ? `Edit ${fileName}` : "Edit file",
// File reading
read_file: fileName ? `Read ${fileName}` : "Read file",
readFile: fileName ? `Read ${fileName}` : "Read file",
// File listing
list_files: filePath ? `Listing files in ${filePath}` : "Listing files",
listFiles: filePath ? `Listing files in ${filePath}` : "Listing files",
// File search
search_files: "Searching files",
searchFiles: "Searching files",
// Command execution
execute_command: "Running command",
executeCommand: "Running command",
// Browser actions
browser_action: "Browser action",
browserAction: "Browser action",
}
return toolTitles[toolName] || (fileName ? `${toolName}: ${fileName}` : toolName)
}
/**
* Extract file locations from tool parameters.
* @param params - Tool parameters
* @param workspacePath - Optional workspace path to resolve relative paths
*/
function extractLocations(params: Record<string, unknown>, workspacePath?: string): acp.ToolCallLocation[] {
const locations: acp.ToolCallLocation[] = []
const toolName = (params.tool as string | undefined)?.toLowerCase() || ""
// For search tools, the 'path' parameter is a search scope directory, not a file being accessed.
// Don't include it in locations. Instead, try to extract file paths from search results.
if (isSearchTool(toolName)) {
// Try to extract file paths from search results content
const content = params.content as string | undefined
if (content) {
const fileLocations = extractFilePathsFromSearchResults(content, workspacePath)
return fileLocations
}
return []
}
// For list_files tools, the 'path' is a directory being listed, which is valid to include
// but we should mark it as a directory operation rather than a file access
if (isListFilesTool(toolName)) {
const dirPath = params.path as string | undefined
if (dirPath) {
const absolutePath = makeAbsolutePath(dirPath, workspacePath)
locations.push({ path: absolutePath })
}
return locations
}
// Check for common path parameters (for file operations)
const pathParams = ["path", "file", "filePath", "file_path"]
for (const param of pathParams) {
if (typeof params[param] === "string") {
const filePath = params[param] as string
const absolutePath = makeAbsolutePath(filePath, workspacePath)
locations.push({ path: absolutePath })
}
}
// Check for directory parameters separately (for directory operations)
const dirParams = ["directory", "dir"]
for (const param of dirParams) {
if (typeof params[param] === "string") {
const dirPath = params[param] as string
const absolutePath = makeAbsolutePath(dirPath, workspacePath)
locations.push({ path: absolutePath })
}
}
// Check for paths array
if (Array.isArray(params.paths)) {
for (const p of params.paths) {
if (typeof p === "string") {
const absolutePath = makeAbsolutePath(p, workspacePath)
locations.push({ path: absolutePath })
}
}
}
return locations
}
/**
* Check if a tool name is a search operation.
*/
function isSearchTool(toolName: string): boolean {
const searchTools = ["search_files", "searchfiles", "codebase_search", "codebasesearch", "grep", "ripgrep"]
return searchTools.includes(toolName) || toolName.includes("search")
}
/**
* Check if a tool name is a list files operation.
*/
function isListFilesTool(toolName: string): boolean {
const listTools = ["list_files", "listfiles", "listfilestoplevel", "listfilesrecursive"]
return listTools.includes(toolName) || toolName.includes("listfiles")
}
/**
* Extract file paths from search results content.
* Search results typically have format: "# path/to/file.ts" for each matched file
*/
function extractFilePathsFromSearchResults(content: string, workspacePath?: string): acp.ToolCallLocation[] {
const locations: acp.ToolCallLocation[] = []
const seenPaths = new Set<string>()
// Match file headers in search results (e.g., "# src/utils.ts" or "## path/to/file.js")
const fileHeaderPattern = /^#+\s+(.+?\.[a-zA-Z0-9]+)\s*$/gm
let match
while ((match = fileHeaderPattern.exec(content)) !== null) {
const filePath = match[1]!.trim()
// Skip if we've already seen this path or if it looks like a markdown header (not a file path)
if (seenPaths.has(filePath) || (!filePath.includes("/") && !filePath.includes("."))) {
continue
}
seenPaths.add(filePath)
const absolutePath = makeAbsolutePath(filePath, workspacePath)
locations.push({ path: absolutePath })
}
return locations
}
/**
* Extract tool content for ACP (diffs, text, etc.)
*/
function extractToolContent(
params: Record<string, unknown>,
workspacePath?: string,
): acp.ToolCallContent[] | undefined {
const content: acp.ToolCallContent[] = []
// Check if this is a file operation with diff content
const filePath = params.path as string | undefined
const diffContent = params.content as string | undefined
const toolName = params.tool as string | undefined
if (filePath && diffContent && isFileEditTool(toolName || "")) {
const absolutePath = makeAbsolutePath(filePath, workspacePath)
const parsedDiff = parseUnifiedDiff(diffContent)
if (parsedDiff) {
// Use ACP diff format
content.push({
type: "diff",
path: absolutePath,
oldText: parsedDiff.oldText,
newText: parsedDiff.newText,
} as acp.ToolCallContent)
}
}
return content.length > 0 ? content : undefined
}
/**
* Parse a unified diff string to extract old and new text.
*/
function parseUnifiedDiff(diffString: string): { oldText: string | null; newText: string } | null {
if (!diffString) {
return null
}
// Check if this is a unified diff format
if (!diffString.includes("@@") && !diffString.includes("---") && !diffString.includes("+++")) {
// Not a diff, treat as raw content
return { oldText: null, newText: diffString }
}
const lines = diffString.split("\n")
const oldLines: string[] = []
const newLines: string[] = []
let inHunk = false
let isNewFile = false
for (const line of lines) {
// Check for new file indicator
if (line.startsWith("--- /dev/null")) {
isNewFile = true
continue
}
// Skip diff headers
if (line.startsWith("===") || line.startsWith("---") || line.startsWith("+++") || line.startsWith("@@")) {
if (line.startsWith("@@")) {
inHunk = true
}
continue
}
if (!inHunk) {
continue
}
if (line.startsWith("-")) {
// Removed line (old content)
oldLines.push(line.slice(1))
} else if (line.startsWith("+")) {
// Added line (new content)
newLines.push(line.slice(1))
} else if (line.startsWith(" ") || line === "") {
// Context line (in both old and new)
const contextLine = line.startsWith(" ") ? line.slice(1) : line
oldLines.push(contextLine)
newLines.push(contextLine)
}
}
return {
oldText: isNewFile ? null : oldLines.join("\n") || null,
newText: newLines.join("\n"),
}
}
/**
* Check if a tool name represents a file edit operation.
*/
function isFileEditTool(toolName: string): boolean {
const editTools = [
"newFileCreated",
"editedExistingFile",
"write_to_file",
"apply_diff",
"create_file",
"modify_file",
]
return editTools.includes(toolName)
}
/**
* Make a file path absolute by resolving it against the workspace path.
*/
function makeAbsolutePath(filePath: string, workspacePath?: string): string {
if (path.isAbsolute(filePath)) {
return filePath
}
if (workspacePath) {
return path.resolve(workspacePath, filePath)
}
// Return as-is if no workspace path available
return filePath
}
// =============================================================================
// Tool Kind Mapping
// =============================================================================
/**
* Map internal tool names to ACP tool kinds.
* 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"

View file

@ -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("+++"))
}

View file

@ -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"

View file

@ -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<string, unknown>, workspacePath?: string): acp.ToolCallLocation[] {
const locations: acp.ToolCallLocation[] = []
const toolName = (params.tool as string | undefined)?.toLowerCase() || ""
// For search tools, the 'path' parameter is a search scope directory, not a file being accessed.
// Don't include it in locations. Instead, try to extract file paths from search results.
if (isSearchTool(toolName)) {
// Try to extract file paths from search results content
const content = params.content as string | undefined
if (content) {
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<string>()
// Match file headers in search results (e.g., "# src/utils.ts" or "## path/to/file.js")
const fileHeaderPattern = /^#+\s+(.+?\.[a-zA-Z0-9]+)\s*$/gm
let match
while ((match = fileHeaderPattern.exec(content)) !== null) {
const filePath = match[1]!.trim()
// Skip if we've already seen this path or if it looks like a markdown header (not a file path)
if (seenPaths.has(filePath) || (!filePath.includes("/") && !filePath.includes("."))) {
continue
}
seenPaths.add(filePath)
const absolutePath = resolveFilePathUnsafe(filePath, workspacePath)
locations.push({ path: absolutePath })
}
return locations
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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<string, unknown>
/** 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<string, unknown>
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<string, string> = {
// File creation
newFileCreated: fileName ? `Creating ${fileName}` : "Creating file",
write_to_file: fileName ? `Writing ${fileName}` : "Writing file",
create_file: fileName ? `Creating ${fileName}` : "Creating file",
// File editing
editedExistingFile: fileName ? `Edit ${fileName}` : "Edit file",
apply_diff: fileName ? `Edit ${fileName}` : "Edit file",
appliedDiff: fileName ? `Edit ${fileName}` : "Edit file",
modify_file: fileName ? `Edit ${fileName}` : "Edit file",
// File reading
read_file: fileName ? `Read ${fileName}` : "Read file",
readFile: fileName ? `Read ${fileName}` : "Read file",
// File listing
list_files: filePath ? `Listing files in ${filePath}` : "Listing files",
listFiles: filePath ? `Listing files in ${filePath}` : "Listing files",
// File search
search_files: "Searching files",
searchFiles: "Searching files",
// Command execution
execute_command: "Running command",
executeCommand: "Running command",
// Browser actions
browser_action: "Browser action",
browserAction: "Browser action",
}
return toolTitles[toolName] || (fileName ? `${toolName}: ${fileName}` : toolName)
}
// =============================================================================
// 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<string, unknown>,
workspacePath?: string,
): acp.ToolCallContent[] | undefined {
const content: acp.ToolCallContent[] = []
// Check if this is a file operation with diff content
const filePath = params.path as string | undefined
const diffContent = params.content as string | undefined
const toolName = params.tool as string | undefined
if (filePath && diffContent && isFileEditTool(toolName || "")) {
const absolutePath = 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
}

View file

@ -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<void>, 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)
}

View file

@ -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<T> = { ok: true; value: T } | { ok: false; error: string }
/**
* Create a successful result.
*/
export function ok<T>(value: T): Result<T> {
return { ok: true, value }
}
/**
* Create a failed result.
*/
export function err<T>(error: string): Result<T> {
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<string>()
let match
while ((match = filePattern.exec(content)) !== null) {
if (match[1]) {
files.add(match[1])
}
}
// Sort files alphabetically
const fileList = Array.from(files).sort((a, b) => a.localeCompare(b))
// Build the formatted output
if (fileList.length === 0) {
// No files found, return 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, unknown>): 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<string> {
// 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<string, unknown>, workspacePath: string): Result<string> {
// 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<string, unknown>,
workspacePath: string,
): Promise<Result<string>> {
// 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)
}

View file

@ -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"

View file

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

View file

@ -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", {

View file

@ -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<number>()
/**
* Track the last displayed character count for streaming updates.
*/
private toolLastDisplayedCharCount = new Map<number, number>()
/**
* 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)
}
}

View file

@ -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",
}

View file

@ -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,
})

14
pnpm-lock.yaml generated
View file

@ -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

View file

@ -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)
})