mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-09 22:31:08 +00:00
Clean refactor
This commit is contained in:
parent
74881a4f4f
commit
4b43a0d865
35 changed files with 6554 additions and 1411 deletions
|
|
@ -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": {
|
||||
|
|
|
|||
314
apps/cli/src/acp/__tests__/command-stream.test.ts
Normal file
314
apps/cli/src/acp/__tests__/command-stream.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
298
apps/cli/src/acp/__tests__/content-formatter.test.ts
Normal file
298
apps/cli/src/acp/__tests__/content-formatter.test.ts
Normal 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)")
|
||||
})
|
||||
})
|
||||
373
apps/cli/src/acp/__tests__/prompt-state.test.ts
Normal file
373
apps/cli/src/acp/__tests__/prompt-state.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
495
apps/cli/src/acp/__tests__/tool-content-stream.test.ts
Normal file
495
apps/cli/src/acp/__tests__/tool-content-stream.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
495
apps/cli/src/acp/__tests__/tool-handler.test.ts
Normal file
495
apps/cli/src/acp/__tests__/tool-handler.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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")
|
||||
})
|
||||
|
|
|
|||
265
apps/cli/src/acp/command-stream.ts
Normal file
265
apps/cli/src/acp/command-stream.ts
Normal 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
|
||||
}
|
||||
}
|
||||
221
apps/cli/src/acp/content-formatter.ts
Normal file
221
apps/cli/src/acp/content-formatter.ts
Normal 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 }
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
378
apps/cli/src/acp/interfaces.ts
Normal file
378
apps/cli/src/acp/interfaces.ts
Normal 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 {}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
254
apps/cli/src/acp/prompt-state.ts
Normal file
254
apps/cli/src/acp/prompt-state.ts
Normal 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)
|
||||
}
|
||||
431
apps/cli/src/acp/session-event-handler.ts
Normal file
431
apps/cli/src/acp/session-event-handler.ts
Normal 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
217
apps/cli/src/acp/tool-content-stream.ts
Normal file
217
apps/cli/src/acp/tool-content-stream.ts
Normal 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`,
|
||||
)
|
||||
}
|
||||
}
|
||||
480
apps/cli/src/acp/tool-handler.ts
Normal file
480
apps/cli/src/acp/tool-handler.ts
Normal 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 }
|
||||
530
apps/cli/src/acp/tool-registry.ts
Normal file
530
apps/cli/src/acp/tool-registry.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
106
apps/cli/src/acp/translator/diff-parser.ts
Normal file
106
apps/cli/src/acp/translator/diff-parser.ts
Normal 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("+++"))
|
||||
}
|
||||
43
apps/cli/src/acp/translator/index.ts
Normal file
43
apps/cli/src/acp/translator/index.ts
Normal 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"
|
||||
136
apps/cli/src/acp/translator/location-extractor.ts
Normal file
136
apps/cli/src/acp/translator/location-extractor.ts
Normal 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
|
||||
}
|
||||
179
apps/cli/src/acp/translator/message-translator.ts
Normal file
179
apps/cli/src/acp/translator/message-translator.ts
Normal 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
|
||||
}
|
||||
101
apps/cli/src/acp/translator/prompt-extractor.ts
Normal file
101
apps/cli/src/acp/translator/prompt-extractor.ts
Normal 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
|
||||
}
|
||||
237
apps/cli/src/acp/translator/tool-parser.ts
Normal file
237
apps/cli/src/acp/translator/tool-parser.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
379
apps/cli/src/acp/utils/format-utils.ts
Normal file
379
apps/cli/src/acp/utils/format-utils.ts
Normal 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)
|
||||
}
|
||||
29
apps/cli/src/acp/utils/index.ts
Normal file
29
apps/cli/src/acp/utils/index.ts
Normal 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"
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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", {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
14
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue