Compare commits

...

24 commits

Author SHA1 Message Date
cte
9c9220ad8d chore(cli): prepare release v0.0.46 2026-01-12 16:21:17 -08:00
cte
22288a2b1e Fix the build 2026-01-12 16:20:29 -08:00
cte
7b0103901f Merge branch 'feature/roo-473-show-all-tool-uses-immediately-rather-than-on-partial-false' into cte/acp 2026-01-12 16:08:48 -08:00
cte
6796702afd Merge branch 'main' into feature/roo-473-show-all-tool-uses-immediately-rather-than-on-partial-false 2026-01-12 16:08:24 -08:00
cte
1150158d55 Fix model switching 2026-01-12 16:06:14 -08:00
cte
1b867d517b Merge branch 'feature/roo-473-show-all-tool-uses-immediately-rather-than-on-partial-false' into cte/acp 2026-01-11 18:57:07 -08:00
cte
0dcc439b79 Merge main 2026-01-11 18:56:17 -08:00
Roo Code
1991eb6517 fix: use DEFAULT_FLAGS.model as single source of truth for default model ID 2026-01-12 02:50:47 +00:00
Roo Code
510cf8fc8a fix: change default model to opus and fix model ID mismatch 2026-01-12 02:49:18 +00:00
cte
29e7045b81 Logging cleanup 2026-01-11 18:47:38 -08:00
cte
4b4afceeb6 Logging cleanup 2026-01-11 18:45:14 -08:00
cte
4ac8a460db Fix ACP task cancellation 2026-01-11 18:40:45 -08:00
cte
7492abb9d0 Fix tests 2026-01-11 16:29:13 -08:00
cte
e92a0fca06 Add mode and model pickers 2026-01-11 16:19:16 -08:00
cte
4b43a0d865 Clean refactor 2026-01-11 14:32:22 -08:00
cte
74881a4f4f Fix command output streaming 2026-01-11 00:20:56 -08:00
cte
89f26ac053 Update README 2026-01-11 00:02:19 -08:00
cte
844de64153 Some cleanup 2026-01-11 00:00:44 -08:00
cte
22fa95f692 Remove cruft 2026-01-10 23:51:27 -08:00
Roo Code
e6b1d20bd6 fix: use DEFAULT_FLAGS.model for model fallback in agent.ts 2026-01-11 07:45:21 +00:00
cte
4ccca5b88f More progress 2026-01-10 23:24:54 -08:00
cte
a3f02bdefd Streaming comment execution & streaming file writes 2026-01-10 13:31:27 -08:00
cte
569055ea9a Better type safety 2026-01-10 01:34:24 -08:00
cte
3903fe5ada Some cleanup in ExtensionHost 2026-01-10 01:00:02 -08:00
76 changed files with 11390 additions and 589 deletions

View file

@ -5,6 +5,34 @@ All notable changes to the `@roo-code/cli` package will be documented in this fi
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.0.46] - 2026-01-12
### Added
- **Text User Interface (TUI)**: Major new interactive terminal UI with React/Ink for enhanced user experience ([#10480](https://github.com/RooCodeInc/Roo-Code/pull/10480))
- Interactive mode and model pickers for easy selection
- Improved task management and navigation
- CLI release script now supports local installation for testing ([#10597](https://github.com/RooCodeInc/Roo-Code/pull/10597))
### Changed
- Default model changed to `anthropic/claude-opus-4.5` ([#10544](https://github.com/RooCodeInc/Roo-Code/pull/10544))
- File organization improvements for better maintainability ([#10599](https://github.com/RooCodeInc/Roo-Code/pull/10599))
- Cleanup in ExtensionHost for better code organization ([#10600](https://github.com/RooCodeInc/Roo-Code/pull/10600))
- Updated README documentation
- Logging cleanup and improvements
### Fixed
- Model switching issues (model ID mismatch)
- ACP task cancellation handling
- Command output streaming
- Use `DEFAULT_FLAGS.model` as single source of truth for default model ID
### Tests
- Updated tests for model changes
## [0.0.45] - 2026-01-08
### Changed

View file

@ -157,7 +157,7 @@ Tokens are valid for 90 days. The CLI will prompt you to re-authenticate when yo
| `-y, --yes` | Non-interactive mode: auto-approve all actions | `false` |
| `-k, --api-key <key>` | API key for the LLM provider | From env var |
| `-p, --provider <provider>` | API provider (anthropic, openai, openrouter, etc.) | `openrouter` |
| `-m, --model <model>` | Model to use | `anthropic/claude-sonnet-4.5` |
| `-m, --model <model>` | Model to use | `anthropic/claude-opus-4.5` |
| `-M, --mode <mode>` | Mode to start in (code, architect, ask, debug, etc.) | `code` |
| `-r, --reasoning-effort <effort>` | Reasoning effort level (unspecified, disabled, none, minimal, low, medium, high, xhigh) | `medium` |
| `--ephemeral` | Run without persisting state (uses temporary storage) | `false` |
@ -171,6 +171,56 @@ Tokens are valid for 90 days. The CLI will prompt you to re-authenticate when yo
| `roo auth logout` | Clear stored authentication token |
| `roo auth status` | Show current authentication status |
## ACP (Agent Client Protocol) Integration
The CLI supports the [Agent Client Protocol (ACP)](https://agentclientprotocol.com), allowing ACP-compatible editors like [Zed](https://zed.dev) to use Roo Code as their AI coding assistant.
### Running ACP Server Mode
Start the CLI in ACP server mode:
```bash
roo acp [options]
```
**ACP Options:**
| Option | Description | Default |
| --------------------------- | -------------------------------------------- | ----------------------------- |
| `-e, --extension <path>` | Path to the extension bundle directory | Auto-detected |
| `-p, --provider <provider>` | API provider (anthropic, openai, openrouter) | `openrouter` |
| `-m, --model <model>` | Model to use | `anthropic/claude-opus-4.5` |
| `-M, --mode <mode>` | Initial mode (code, architect, ask, debug) | `code` |
| `-k, --api-key <key>` | API key for the LLM provider | From env var |
### Configuring Zed
Add the following to your Zed settings (`settings.json`):
```json
{
"agent_servers": {
"Roo Code": {
"command": "roo",
"args": ["acp"]
}
}
}
```
If you need to specify options:
```json
{
"agent_servers": {
"Roo Code": {
"command": "roo",
"args": ["acp", "-e", "/path/to/extension", "-m", "anthropic/claude-sonnet-4.5"]
}
}
}
```
## Environment Variables
The CLI will look for API keys in environment variables if not provided via `--api-key`:

View file

@ -37,11 +37,57 @@ interface ClineMessage {
| **say** | Informational - agent is telling you something | No |
| **ask** | Interactive - agent needs something from you | Usually yes |
## The Key Insight
## The Key Insight: How the CLI Knows When to Prompt
> **The agent loop stops whenever the last message is an `ask` type (with `partial: false`).**
The CLI doesn't receive any special "waiting" signal from the extension. Instead, it simply **looks at the last message** and asks three questions:
The specific `ask` value tells you exactly what the agent needs.
### Is the agent waiting for user input?
```
isWaitingForInput = true when ALL of these are true:
1. Last message type is "ask" (not "say")
2. Last message is NOT partial: true (streaming is complete)
3. The ask type is "blocking" (not "command_output")
```
That's it. No timing. No special signals. Just look at what the last message is.
### Why this works
When the extension needs user input:
- It sends an `ask` message and **blocks** (waits for response)
- The ask stays as the last message until the CLI responds
- CLI sees the ask → prompts user → sends response → extension continues
When auto-approval is enabled:
- Extension sends an `ask` message
- Extension **immediately auto-responds** to its own ask (doesn't wait)
- New messages quickly follow the ask
- CLI sees the ask but it's quickly superseded by newer messages
- State never "settles" at waiting because the extension kept going
### The Simple Logic
```typescript
function isWaitingForInput(messages) {
const lastMessage = messages.at(-1)
// Still streaming? Not waiting.
if (lastMessage?.partial === true) return false
// Not an ask? Not waiting.
if (lastMessage?.type !== "ask") return false
// Non-blocking ask? Not waiting.
if (lastMessage?.ask === "command_output") return false
// It's a blocking ask that's done streaming → waiting!
return true
}
```
## Ask Categories
@ -348,8 +394,8 @@ Example output:
## Summary
1. **Agent communicates via `ClineMessage` stream**
2. **Last message determines state**
3. **`ask` messages (non-partial) block the agent**
4. **Ask category determines required action**
2. **State detection is simple: look at the last message**
3. **Waiting = last message is a non-partial, blocking `ask`**
4. **Auto-approval works by the extension auto-responding to its own asks**
5. **`partial: true` or `api_req_started` without cost = streaming**
6. **`ExtensionClient` is the single source of truth**

View file

@ -1,6 +1,6 @@
{
"name": "@roo-code/cli",
"version": "0.0.45",
"version": "0.0.46",
"description": "Roo Code CLI - Run the Roo Code agent from the command line",
"private": true,
"type": "module",
@ -21,6 +21,7 @@
"clean": "rimraf dist .turbo"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.12.0",
"@inkjs/ui": "^2.0.0",
"@roo-code/core": "workspace:^",
"@roo-code/types": "workspace:^",
@ -33,6 +34,7 @@
"p-wait-for": "^5.0.2",
"react": "^19.1.0",
"superjson": "^2.2.6",
"zod": "^4.3.5",
"zustand": "^5.0.0"
},
"devDependencies": {

View file

@ -0,0 +1,247 @@
import type * as acp from "@agentclientprotocol/sdk"
import { RooCodeAgent } from "../agent.js"
import type { AcpSessionOptions } from "../session.js"
vi.mock("@/commands/auth/index.js", () => ({
login: vi.fn().mockResolvedValue({ success: true }),
logout: vi.fn().mockResolvedValue({ success: true }),
status: vi.fn().mockResolvedValue({ authenticated: false }),
}))
vi.mock("../session.js", () => ({
AcpSession: {
create: vi.fn().mockResolvedValue({
prompt: vi.fn().mockResolvedValue({ stopReason: "end_turn" }),
cancel: vi.fn(),
setMode: vi.fn(),
dispose: vi.fn().mockResolvedValue(undefined),
getSessionId: vi.fn().mockReturnValue("test-session-id"),
}),
},
}))
describe("RooCodeAgent", () => {
let agent: RooCodeAgent
let mockConnection: acp.AgentSideConnection
const defaultOptions: AcpSessionOptions = {
extensionPath: "/test/extension",
provider: "openrouter",
apiKey: "test-key",
model: "test-model",
mode: "code",
}
beforeEach(() => {
mockConnection = {
sessionUpdate: vi.fn().mockResolvedValue(undefined),
requestPermission: vi.fn().mockResolvedValue({
outcome: { outcome: "selected", optionId: "allow" },
}),
readTextFile: vi.fn().mockResolvedValue({ content: "test content" }),
writeTextFile: vi.fn().mockResolvedValue({}),
createTerminal: vi.fn(),
extMethod: vi.fn(),
extNotification: vi.fn(),
signal: new AbortController().signal,
closed: Promise.resolve(),
} as unknown as acp.AgentSideConnection
agent = new RooCodeAgent(defaultOptions, mockConnection)
})
afterEach(() => {
vi.clearAllMocks()
})
describe("initialize", () => {
it("should return protocol version and capabilities", async () => {
const result = await agent.initialize({
protocolVersion: 1,
})
expect(result.protocolVersion).toBeDefined()
expect(result.agentCapabilities).toBeDefined()
expect(result.agentCapabilities?.loadSession).toBe(false)
expect(result.agentCapabilities?.promptCapabilities?.image).toBe(true)
})
it("should return auth methods", async () => {
const result = await agent.initialize({
protocolVersion: 1,
})
expect(result.authMethods).toBeDefined()
expect(result.authMethods).toHaveLength(1)
const methods = result.authMethods!
expect(methods[0]!.id).toBe("roo")
})
it("should store client capabilities", async () => {
const clientCapabilities: acp.ClientCapabilities = {
fs: {
readTextFile: true,
writeTextFile: true,
},
}
await agent.initialize({
protocolVersion: 1,
clientCapabilities,
})
// Capabilities should be stored for use in newSession
// This is tested indirectly through the session creation
})
})
describe("authenticate", () => {
it("should throw for invalid auth method", async () => {
await expect(
agent.authenticate({
methodId: "invalid-method",
}),
).rejects.toThrow()
})
})
describe("newSession", () => {
it("should create a new session", async () => {
const result = await agent.newSession({
cwd: "/test/workspace",
mcpServers: [],
})
expect(result.sessionId).toBeDefined()
expect(typeof result.sessionId).toBe("string")
})
it("should throw auth error when not authenticated and no API key", async () => {
// Create agent without API key
const agentWithoutKey = new RooCodeAgent({ ...defaultOptions, apiKey: undefined }, mockConnection)
// Mock environment to not have API key
const originalEnv = process.env.OPENROUTER_API_KEY
delete process.env.OPENROUTER_API_KEY
try {
await expect(
agentWithoutKey.newSession({
cwd: "/test/workspace",
mcpServers: [],
}),
).rejects.toThrow()
} finally {
if (originalEnv) {
process.env.OPENROUTER_API_KEY = originalEnv
}
}
})
})
describe("prompt", () => {
it("should forward prompt to session", async () => {
// Setup
const { sessionId } = await agent.newSession({
cwd: "/test/workspace",
mcpServers: [],
})
// Execute
const result = await agent.prompt({
sessionId,
prompt: [{ type: "text", text: "Hello, world!" }],
})
// Verify
expect(result.stopReason).toBe("end_turn")
})
it("should throw for invalid session ID", async () => {
await expect(
agent.prompt({
sessionId: "invalid-session",
prompt: [{ type: "text", text: "Hello" }],
}),
).rejects.toThrow("Session not found")
})
})
describe("cancel", () => {
it("should cancel session prompt", async () => {
// Setup
const { sessionId } = await agent.newSession({
cwd: "/test/workspace",
mcpServers: [],
})
// Execute - should not throw
await agent.cancel({ sessionId })
})
it("should handle cancel for non-existent session gracefully", async () => {
// Should not throw for invalid session
await agent.cancel({ sessionId: "non-existent" })
})
})
describe("setSessionMode", () => {
it("should set session mode", async () => {
// Setup
const { sessionId } = await agent.newSession({
cwd: "/test/workspace",
mcpServers: [],
})
// Execute
const result = await agent.setSessionMode({
sessionId,
modeId: "architect",
})
// Verify
expect(result).toEqual({})
})
it("should throw for invalid mode", async () => {
// Setup
const { sessionId } = await agent.newSession({
cwd: "/test/workspace",
mcpServers: [],
})
// Execute
await expect(
agent.setSessionMode({
sessionId,
modeId: "invalid-mode",
}),
).rejects.toThrow("Unknown mode")
})
it("should throw for invalid session", async () => {
await expect(
agent.setSessionMode({
sessionId: "invalid-session",
modeId: "code",
}),
).rejects.toThrow("Session not found")
})
})
describe("dispose", () => {
it("should dispose all sessions", async () => {
// Setup
await agent.newSession({ cwd: "/test/workspace1", mcpServers: [] })
await agent.newSession({ cwd: "/test/workspace2", mcpServers: [] })
// Execute
await agent.dispose()
// Verify - creating new session should work (sessions map is cleared)
// The next newSession would create a fresh session
})
})
})

View file

@ -0,0 +1,314 @@
/**
* Tests for CommandStreamManager
*
* Tests the command output streaming functionality extracted from session.ts.
*/
import type { ClineMessage } from "@roo-code/types"
import { DeltaTracker } from "../delta-tracker.js"
import { CommandStreamManager } from "../command-stream.js"
import { NullLogger } from "../interfaces.js"
import type { SendUpdateFn } from "../interfaces.js"
describe("CommandStreamManager", () => {
let deltaTracker: DeltaTracker
let sendUpdate: SendUpdateFn
let sentUpdates: Array<Record<string, unknown>>
let manager: CommandStreamManager
beforeEach(() => {
deltaTracker = new DeltaTracker()
sentUpdates = []
sendUpdate = (update) => {
sentUpdates.push(update as Record<string, unknown>)
}
manager = new CommandStreamManager({
deltaTracker,
sendUpdate,
logger: new NullLogger(),
})
})
describe("isCommandOutputMessage", () => {
it("returns true for command_output say messages", () => {
const message: ClineMessage = {
type: "say",
say: "command_output",
ts: Date.now(),
text: "output",
}
expect(manager.isCommandOutputMessage(message)).toBe(true)
})
it("returns false for other say types", () => {
const message: ClineMessage = {
type: "say",
say: "text",
ts: Date.now(),
text: "hello",
}
expect(manager.isCommandOutputMessage(message)).toBe(false)
})
it("returns false for ask messages", () => {
const message: ClineMessage = {
type: "ask",
ask: "command",
ts: Date.now(),
text: "run command",
}
expect(manager.isCommandOutputMessage(message)).toBe(false)
})
})
describe("trackCommand", () => {
it("tracks a pending command", () => {
manager.trackCommand("call-1", "npm test", 12345)
expect(manager.getPendingCommandCount()).toBe(1)
})
it("tracks multiple commands", () => {
manager.trackCommand("call-1", "npm test", 12345)
manager.trackCommand("call-2", "npm build", 12346)
expect(manager.getPendingCommandCount()).toBe(2)
})
it("overwrites command with same ID", () => {
manager.trackCommand("call-1", "npm test", 12345)
manager.trackCommand("call-1", "npm build", 12346)
expect(manager.getPendingCommandCount()).toBe(1)
})
})
describe("handleExecutionOutput", () => {
it("does nothing without a pending command", () => {
manager.handleExecutionOutput("exec-1", "Hello")
expect(sentUpdates.length).toBe(0)
})
it("sends opening code fence as agent_message_chunk on first output", () => {
manager.trackCommand("call-1", "npm test", 12345)
manager.handleExecutionOutput("exec-1", "Hello")
// First message is opening fence, second is the content
expect(sentUpdates.length).toBe(2)
expect(sentUpdates[0]).toEqual({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "```\n" },
})
expect(sentUpdates[1]).toEqual({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "Hello" },
})
})
it("sends only delta content on subsequent calls (no fence)", () => {
manager.trackCommand("call-1", "npm test", 12345)
manager.handleExecutionOutput("exec-1", "Hello")
sentUpdates.length = 0 // Clear previous updates
manager.handleExecutionOutput("exec-1", "Hello World")
// Only the delta " World" is sent, no fence
expect(sentUpdates.length).toBe(1)
expect(sentUpdates[0]).toEqual({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: " World" },
})
})
it("tracks code fence by toolCallId not executionId", () => {
manager.trackCommand("call-1", "npm test", 12345)
// First execution stream
manager.handleExecutionOutput("exec-1", "First")
expect(manager.hasOpenCodeFences()).toBe(true)
// Second execution stream for same command - no new opening fence since toolCallId already has one
manager.handleExecutionOutput("exec-2", "Second")
// Should still only have one open fence (tracked by toolCallId)
expect(manager.hasOpenCodeFences()).toBe(true)
// Second call should NOT have opening fence since toolCallId already has one
// sentUpdates[0] = opening fence, sentUpdates[1] = "First", sentUpdates[2] = "Second"
expect(sentUpdates[2]).toEqual({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "Second" },
})
})
it("sends streaming output as agent_message_chunk", () => {
manager.trackCommand("call-1", "npm test", 12345)
manager.handleExecutionOutput("exec-1", "Running...")
// Opening fence + content
const contentUpdate = sentUpdates.find(
(u) =>
u.sessionUpdate === "agent_message_chunk" && (u.content as { text: string }).text === "Running...",
)
expect(contentUpdate).toEqual({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "Running..." },
})
})
})
describe("handleCommandOutput", () => {
it("ignores partial messages", () => {
const message: ClineMessage = {
type: "say",
say: "command_output",
ts: Date.now(),
text: "partial output",
partial: true,
}
manager.handleCommandOutput(message)
expect(sentUpdates.length).toBe(0)
})
it("sends closing fence and completion when streaming was used", () => {
// Track command and open a code fence via execution output
manager.trackCommand("call-1", "npm test", 12345)
manager.handleExecutionOutput("exec-1", "output")
expect(manager.hasOpenCodeFences()).toBe(true)
sentUpdates.length = 0 // Clear
const message: ClineMessage = {
type: "say",
say: "command_output",
ts: Date.now(),
text: "final output",
partial: false,
}
manager.handleCommandOutput(message)
// First: closing fence as agent_message_chunk
expect(sentUpdates[0]).toEqual({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "```\n" },
})
// Second: tool_call_update with completed status (no content, just rawOutput)
expect(sentUpdates[1]).toEqual({
sessionUpdate: "tool_call_update",
toolCallId: "call-1",
status: "completed",
rawOutput: { output: "final output" },
})
expect(manager.hasOpenCodeFences()).toBe(false)
})
it("sends completion update for pending command without streaming", () => {
manager.trackCommand("call-1", "npm test", 12345)
const message: ClineMessage = {
type: "say",
say: "command_output",
ts: Date.now(),
text: "Test passed!",
partial: false,
}
manager.handleCommandOutput(message)
// No streaming, so no closing fence - just the completion update
const completionUpdate = sentUpdates.find(
(u) => u.sessionUpdate === "tool_call_update" && u.status === "completed",
)
expect(completionUpdate).toEqual({
sessionUpdate: "tool_call_update",
toolCallId: "call-1",
status: "completed",
rawOutput: { output: "Test passed!" },
})
})
it("removes pending command after completion", () => {
manager.trackCommand("call-1", "npm test", 12345)
expect(manager.getPendingCommandCount()).toBe(1)
const message: ClineMessage = {
type: "say",
say: "command_output",
ts: Date.now(),
text: "done",
partial: false,
}
manager.handleCommandOutput(message)
expect(manager.getPendingCommandCount()).toBe(0)
})
it("picks most recent pending command when multiple exist", () => {
manager.trackCommand("call-1", "npm test", 12345)
manager.trackCommand("call-2", "npm build", 12346) // More recent
const message: ClineMessage = {
type: "say",
say: "command_output",
ts: Date.now(),
text: "done",
partial: false,
}
manager.handleCommandOutput(message)
const completionUpdate = sentUpdates.find((u) => u.sessionUpdate === "tool_call_update")
expect((completionUpdate as Record<string, unknown>).toolCallId).toBe("call-2")
})
})
describe("reset", () => {
it("clears code fence tracking", () => {
manager.trackCommand("call-1", "npm test", 12345)
manager.handleExecutionOutput("exec-1", "output")
expect(manager.hasOpenCodeFences()).toBe(true)
manager.reset()
expect(manager.hasOpenCodeFences()).toBe(false)
})
it("clears pending commands to avoid stale entries", () => {
// Pending commands from previous prompts would cause duplicate completion messages
manager.trackCommand("call-1", "npm test", 12345)
manager.reset()
expect(manager.getPendingCommandCount()).toBe(0)
})
})
describe("getPendingCommandCount", () => {
it("returns 0 when no commands tracked", () => {
expect(manager.getPendingCommandCount()).toBe(0)
})
it("returns correct count", () => {
manager.trackCommand("call-1", "cmd1", 1)
manager.trackCommand("call-2", "cmd2", 2)
expect(manager.getPendingCommandCount()).toBe(2)
})
})
describe("hasOpenCodeFences", () => {
it("returns false initially", () => {
expect(manager.hasOpenCodeFences()).toBe(false)
})
it("returns true after execution output with pending command", () => {
manager.trackCommand("call-1", "npm test", 12345)
manager.handleExecutionOutput("exec-1", "output")
expect(manager.hasOpenCodeFences()).toBe(true)
})
it("returns false after reset", () => {
manager.trackCommand("call-1", "npm test", 12345)
manager.handleExecutionOutput("exec-1", "output")
manager.reset()
expect(manager.hasOpenCodeFences()).toBe(false)
})
})
})

View file

@ -0,0 +1,298 @@
/**
* Content Formatter Unit Tests
*
* Tests for the ContentFormatter class.
*/
import { ContentFormatter, createContentFormatter } from "../content-formatter.js"
describe("ContentFormatter", () => {
describe("formatToolResult", () => {
const formatter = new ContentFormatter()
it("should format search results", () => {
const content = "Found 5 results.\n\n# src/file.ts\n 1 | match"
const result = formatter.formatToolResult("search", content)
expect(result).toContain("Found 5 results in 1 file")
expect(result).toContain("- src/file.ts")
expect(result).toMatch(/^```/)
expect(result).toMatch(/```$/)
})
it("should format read results", () => {
const content = "line1\nline2\nline3"
const result = formatter.formatToolResult("read", content)
expect(result).toContain("line1")
expect(result).toContain("line2")
expect(result).toContain("line3")
expect(result).toMatch(/^```/)
expect(result).toMatch(/```$/)
})
it("should return content unchanged for unknown kinds", () => {
const content = "some content"
const result = formatter.formatToolResult("unknown", content)
expect(result).toBe(content)
})
})
describe("formatSearchResults", () => {
const formatter = new ContentFormatter()
it("should extract file count and result count", () => {
const content = "Found 10 results.\n\n# src/a.ts\n 1 | code\n\n# src/b.ts\n 5 | code"
const result = formatter.formatSearchResults(content)
expect(result).toContain("Found 10 results in 2 files")
})
it("should list unique files alphabetically", () => {
const content = "Found 3 results.\n\n# src/z.ts\n 1 | a\n\n# src/a.ts\n 2 | b\n\n# src/m.ts\n 3 | c"
const result = formatter.formatSearchResults(content)
const lines = result.split("\n")
const fileLines = lines.filter((l) => l.startsWith("- "))
expect(fileLines[0]).toBe("- src/a.ts")
expect(fileLines[1]).toBe("- src/m.ts")
expect(fileLines[2]).toBe("- src/z.ts")
})
it("should deduplicate repeated file paths", () => {
const content =
"Found 5 results.\n\n# src/file.ts\n 1 | a\n\n# src/file.ts\n 5 | b\n\n# src/other.ts\n 10 | c"
const result = formatter.formatSearchResults(content)
expect(result).toContain("in 2 files")
expect((result.match(/- src\/file\.ts/g) || []).length).toBe(1)
})
it("should handle no files found", () => {
const content = "No results found"
const result = formatter.formatSearchResults(content)
expect(result).toBe("No results found")
})
it("should handle singular result", () => {
const content = "Found 1 result.\n\n# src/file.ts\n 1 | match"
const result = formatter.formatSearchResults(content)
expect(result).toContain("Found 1 result in 1 file")
})
it("should handle missing result count", () => {
const content = "# src/file.ts\n 1 | match"
const result = formatter.formatSearchResults(content)
expect(result).toContain("Found matches in 1 file")
})
})
describe("formatReadResults", () => {
it("should return short content unchanged", () => {
const formatter = new ContentFormatter({ maxReadLines: 100 })
const content = "line1\nline2\nline3"
const result = formatter.formatReadResults(content)
expect(result).toBe(content)
})
it("should truncate long content", () => {
const formatter = new ContentFormatter({ maxReadLines: 5 })
const lines = Array.from({ length: 10 }, (_, i) => `line${i + 1}`)
const content = lines.join("\n")
const result = formatter.formatReadResults(content)
expect(result).toContain("line1")
expect(result).toContain("line5")
expect(result).not.toContain("line6")
expect(result).toContain("... (5 more lines)")
})
it("should handle exactly maxReadLines", () => {
const formatter = new ContentFormatter({ maxReadLines: 5 })
const lines = Array.from({ length: 5 }, (_, i) => `line${i + 1}`)
const content = lines.join("\n")
const result = formatter.formatReadResults(content)
expect(result).toBe(content)
})
it("should use default maxReadLines of 100", () => {
const formatter = new ContentFormatter()
const lines = Array.from({ length: 105 }, (_, i) => `line${i + 1}`)
const content = lines.join("\n")
const result = formatter.formatReadResults(content)
expect(result).toContain("... (5 more lines)")
})
})
describe("wrapInCodeBlock", () => {
const formatter = new ContentFormatter()
it("should wrap content in code block", () => {
const result = formatter.wrapInCodeBlock("some code")
expect(result).toBe("```\nsome code\n```")
})
it("should support language specification", () => {
const result = formatter.wrapInCodeBlock("const x = 1", "typescript")
expect(result).toBe("```typescript\nconst x = 1\n```")
})
it("should handle empty content", () => {
const result = formatter.wrapInCodeBlock("")
expect(result).toBe("```\n\n```")
})
it("should handle multiline content", () => {
const result = formatter.wrapInCodeBlock("line1\nline2\nline3")
expect(result).toBe("```\nline1\nline2\nline3\n```")
})
})
describe("extractContentFromRawInput", () => {
const formatter = new ContentFormatter()
it("should extract content field", () => {
const result = formatter.extractContentFromRawInput({ content: "my content" })
expect(result).toBe("my content")
})
it("should extract text field", () => {
const result = formatter.extractContentFromRawInput({ text: "my text" })
expect(result).toBe("my text")
})
it("should extract result field", () => {
const result = formatter.extractContentFromRawInput({ result: "my result" })
expect(result).toBe("my result")
})
it("should extract output field", () => {
const result = formatter.extractContentFromRawInput({ output: "my output" })
expect(result).toBe("my output")
})
it("should extract fileContent field", () => {
const result = formatter.extractContentFromRawInput({ fileContent: "my file content" })
expect(result).toBe("my file content")
})
it("should extract data field", () => {
const result = formatter.extractContentFromRawInput({ data: "my data" })
expect(result).toBe("my data")
})
it("should prioritize content over other fields", () => {
const result = formatter.extractContentFromRawInput({
content: "content value",
text: "text value",
result: "result value",
})
expect(result).toBe("content value")
})
it("should return undefined for empty object", () => {
const result = formatter.extractContentFromRawInput({})
expect(result).toBeUndefined()
})
it("should return undefined for empty string values", () => {
const result = formatter.extractContentFromRawInput({ content: "", text: "" })
expect(result).toBeUndefined()
})
it("should skip non-string values", () => {
const result = formatter.extractContentFromRawInput({
content: 123 as unknown as string,
text: "valid text",
})
expect(result).toBe("valid text")
})
})
describe("extractFileContent", () => {
const formatter = new ContentFormatter()
it("should use extractContentFromRawInput for non-readFile tools", () => {
const result = formatter.extractFileContent({ tool: "list_files", content: "file list" }, "/workspace")
expect(result).toBe("file list")
})
it("should return undefined for readFile with no path", () => {
const result = formatter.extractFileContent({ tool: "readFile" }, "/workspace")
expect(result).toBeUndefined()
})
// Note: actual file reading is tested in integration tests
})
describe("isUserEcho", () => {
const formatter = new ContentFormatter()
it("should return false for null prompt", () => {
expect(formatter.isUserEcho("any text", null)).toBe(false)
})
it("should detect exact match", () => {
expect(formatter.isUserEcho("hello world", "hello world")).toBe(true)
})
it("should be case insensitive", () => {
expect(formatter.isUserEcho("Hello World", "hello world")).toBe(true)
})
it("should handle whitespace differences", () => {
expect(formatter.isUserEcho(" hello world ", "hello world")).toBe(true)
})
it("should detect text contained in prompt (truncated)", () => {
expect(formatter.isUserEcho("write a function", "write a function that adds numbers")).toBe(true)
})
it("should detect prompt contained in text (wrapped)", () => {
expect(formatter.isUserEcho("User said: write a function here", "write a function")).toBe(true)
})
it("should not match short strings", () => {
expect(formatter.isUserEcho("test", "this is a test prompt")).toBe(false)
})
it("should not match completely different text", () => {
expect(formatter.isUserEcho("completely different", "original prompt text")).toBe(false)
})
it("should handle empty strings", () => {
expect(formatter.isUserEcho("", "prompt")).toBe(false)
expect(formatter.isUserEcho("text", "")).toBe(false)
})
})
})
describe("createContentFormatter", () => {
it("should create a formatter with default config", () => {
const formatter = createContentFormatter()
expect(formatter).toBeInstanceOf(ContentFormatter)
})
it("should accept custom config", () => {
const formatter = createContentFormatter({ maxReadLines: 50 })
// Test that custom config is used
const lines = Array.from({ length: 55 }, (_, i) => `line${i + 1}`)
const content = lines.join("\n")
const result = formatter.formatReadResults(content)
expect(result).toContain("... (5 more lines)")
})
})

View file

@ -0,0 +1,137 @@
import { DeltaTracker } from "../delta-tracker.js"
describe("DeltaTracker", () => {
let tracker: DeltaTracker
beforeEach(() => {
tracker = new DeltaTracker()
})
describe("getDelta", () => {
it("returns full text on first call for a new id", () => {
const delta = tracker.getDelta("msg1", "Hello World")
expect(delta).toBe("Hello World")
})
it("returns only new content on subsequent calls", () => {
tracker.getDelta("msg1", "Hello")
const delta = tracker.getDelta("msg1", "Hello World")
expect(delta).toBe(" World")
})
it("returns empty string when text unchanged", () => {
tracker.getDelta("msg1", "Hello")
const delta = tracker.getDelta("msg1", "Hello")
expect(delta).toBe("")
})
it("tracks multiple ids independently", () => {
tracker.getDelta("msg1", "Hello")
tracker.getDelta("msg2", "Goodbye")
const delta1 = tracker.getDelta("msg1", "Hello World")
const delta2 = tracker.getDelta("msg2", "Goodbye World")
expect(delta1).toBe(" World")
expect(delta2).toBe(" World")
})
it("works with numeric ids (timestamps)", () => {
const ts1 = 1234567890
const ts2 = 1234567891
tracker.getDelta(ts1, "First message")
tracker.getDelta(ts2, "Second message")
const delta1 = tracker.getDelta(ts1, "First message updated")
const delta2 = tracker.getDelta(ts2, "Second message updated")
expect(delta1).toBe(" updated")
expect(delta2).toBe(" updated")
})
it("handles incremental streaming correctly", () => {
// Simulate streaming tokens
expect(tracker.getDelta("msg", "H")).toBe("H")
expect(tracker.getDelta("msg", "He")).toBe("e")
expect(tracker.getDelta("msg", "Hel")).toBe("l")
expect(tracker.getDelta("msg", "Hell")).toBe("l")
expect(tracker.getDelta("msg", "Hello")).toBe("o")
})
})
describe("peekDelta", () => {
it("returns delta without updating tracking", () => {
tracker.getDelta("msg1", "Hello")
// Peek should show the delta
expect(tracker.peekDelta("msg1", "Hello World")).toBe(" World")
// But tracking should be unchanged, so getDelta still returns full delta
expect(tracker.getDelta("msg1", "Hello World")).toBe(" World")
// Now peek should show empty
expect(tracker.peekDelta("msg1", "Hello World")).toBe("")
})
})
describe("reset", () => {
it("clears all tracking", () => {
tracker.getDelta("msg1", "Hello")
tracker.getDelta("msg2", "World")
tracker.reset()
// After reset, should get full text again
expect(tracker.getDelta("msg1", "Hello")).toBe("Hello")
expect(tracker.getDelta("msg2", "World")).toBe("World")
})
})
describe("resetId", () => {
it("clears tracking for specific id only", () => {
tracker.getDelta("msg1", "Hello")
tracker.getDelta("msg2", "World")
tracker.resetId("msg1")
// msg1 should be reset
expect(tracker.getDelta("msg1", "Hello")).toBe("Hello")
// msg2 should still be tracked
expect(tracker.getDelta("msg2", "World")).toBe("")
})
})
describe("getPosition", () => {
it("returns 0 for untracked ids", () => {
expect(tracker.getPosition("unknown")).toBe(0)
})
it("returns current position for tracked ids", () => {
tracker.getDelta("msg1", "Hello")
expect(tracker.getPosition("msg1")).toBe(5)
tracker.getDelta("msg1", "Hello World")
expect(tracker.getPosition("msg1")).toBe(11)
})
})
describe("edge cases", () => {
it("handles empty strings", () => {
expect(tracker.getDelta("msg1", "")).toBe("")
expect(tracker.getDelta("msg1", "Hello")).toBe("Hello")
})
it("handles unicode correctly", () => {
tracker.getDelta("msg1", "Hello 👋")
const delta = tracker.getDelta("msg1", "Hello 👋 World 🌍")
expect(delta).toBe(" World 🌍")
})
it("handles multiline text", () => {
tracker.getDelta("msg1", "Line 1\n")
const delta = tracker.getDelta("msg1", "Line 1\nLine 2\n")
expect(delta).toBe("Line 2\n")
})
})
})

View file

@ -0,0 +1,233 @@
/**
* Tests for ModelService
*/
import { ModelService, createModelService } from "../model-service.js"
import { DEFAULT_MODELS } from "../types.js"
// Mock fetch globally
const mockFetch = vi.fn()
global.fetch = mockFetch
describe("ModelService", () => {
beforeEach(() => {
vi.clearAllMocks()
mockFetch.mockReset()
})
describe("constructor", () => {
it("should create a ModelService with default options", () => {
const service = new ModelService()
expect(service).toBeInstanceOf(ModelService)
})
it("should create a ModelService with custom options", () => {
const service = new ModelService({
apiUrl: "https://custom.api.com",
apiKey: "test-key",
timeout: 10000,
})
expect(service).toBeInstanceOf(ModelService)
})
})
describe("createModelService factory", () => {
it("should create a ModelService instance", () => {
const service = createModelService()
expect(service).toBeInstanceOf(ModelService)
})
it("should pass options to ModelService", () => {
const service = createModelService({
apiKey: "test-api-key",
})
expect(service).toBeInstanceOf(ModelService)
})
})
describe("fetchAvailableModels", () => {
it("should return cached models on subsequent calls", async () => {
const service = new ModelService()
// First call - should fetch from API
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
object: "list",
data: [
{ id: "model-1", owned_by: "test" },
{ id: "model-2", owned_by: "test" },
],
}),
})
const firstResult = await service.fetchAvailableModels()
expect(mockFetch).toHaveBeenCalledTimes(1)
// Second call - should use cache
const secondResult = await service.fetchAvailableModels()
expect(mockFetch).toHaveBeenCalledTimes(1) // No additional fetch
expect(secondResult).toEqual(firstResult)
})
it("should return DEFAULT_MODELS when API fails", async () => {
const service = new ModelService()
mockFetch.mockRejectedValueOnce(new Error("Network error"))
const result = await service.fetchAvailableModels()
expect(result).toEqual(DEFAULT_MODELS)
})
it("should return DEFAULT_MODELS when API returns non-OK status", async () => {
const service = new ModelService()
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
})
const result = await service.fetchAvailableModels()
expect(result).toEqual(DEFAULT_MODELS)
})
it("should return DEFAULT_MODELS when API returns invalid response", async () => {
const service = new ModelService()
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ invalid: "response" }),
})
const result = await service.fetchAvailableModels()
expect(result).toEqual(DEFAULT_MODELS)
})
it("should return DEFAULT_MODELS on timeout", async () => {
const service = new ModelService({ timeout: 100 })
// Mock a fetch that never resolves
mockFetch.mockImplementationOnce(
() =>
new Promise((_, reject) => {
setTimeout(() => reject(new DOMException("Aborted", "AbortError")), 50)
}),
)
const result = await service.fetchAvailableModels()
expect(result).toEqual(DEFAULT_MODELS)
})
it("should transform API response to AcpModel format using name and description fields", async () => {
const service = new ModelService()
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: [
{
id: "anthropic/claude-3-sonnet",
name: "Claude 3 Sonnet",
description: "A balanced model for most tasks",
owned_by: "anthropic",
},
{
id: "openai/gpt-4",
name: "GPT-4",
description: "OpenAI's flagship model",
owned_by: "openai",
},
],
}),
})
const result = await service.fetchAvailableModels()
// Should include transformed models with name and description from API
expect(result).toHaveLength(2)
expect(result).toContainEqual({
modelId: "anthropic/claude-3-sonnet",
name: "Claude 3 Sonnet",
description: "A balanced model for most tasks",
})
expect(result).toContainEqual({
modelId: "openai/gpt-4",
name: "GPT-4",
description: "OpenAI's flagship model",
})
})
it("should sort models by model ID", async () => {
const service = new ModelService()
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: [
{ id: "openai/gpt-4", name: "GPT-4" },
{ id: "anthropic/claude-3-sonnet", name: "Claude 3 Sonnet" },
{ id: "google/gemini-pro", name: "Gemini Pro" },
],
}),
})
const result = await service.fetchAvailableModels()
// Should be sorted by model ID
expect(result[0]!.modelId).toBe("anthropic/claude-3-sonnet")
expect(result[1]!.modelId).toBe("google/gemini-pro")
expect(result[2]!.modelId).toBe("openai/gpt-4")
})
it("should include Authorization header when apiKey is provided", async () => {
const service = new ModelService({ apiKey: "test-api-key" })
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ data: [] }),
})
await service.fetchAvailableModels()
expect(mockFetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: expect.objectContaining({
Authorization: "Bearer test-api-key",
}),
}),
)
})
})
describe("clearCache", () => {
it("should clear the cached models", async () => {
const service = new ModelService()
// First fetch
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: [{ id: "model-1" }],
}),
})
await service.fetchAvailableModels()
expect(mockFetch).toHaveBeenCalledTimes(1)
// Clear cache
service.clearCache()
// Second fetch - should call API again
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: [{ id: "model-2" }],
}),
})
await service.fetchAvailableModels()
expect(mockFetch).toHaveBeenCalledTimes(2)
})
})
})

View file

@ -0,0 +1,397 @@
import type { TodoItem } from "@roo-code/types"
import {
todoItemToPlanEntry,
todoListToPlanUpdate,
parseTodoListFromMessage,
isTodoListMessage,
extractTodoListFromMessage,
createPlanUpdateFromMessage,
type PriorityConfig,
} from "../translator/plan-translator.js"
describe("Plan Translator", () => {
// ===========================================================================
// Test Data
// ===========================================================================
const createTodoItem = (
content: string,
status: "pending" | "in_progress" | "completed",
id?: string,
): TodoItem => ({
id: id ?? `todo-${Date.now()}`,
content,
status,
})
// ===========================================================================
// todoItemToPlanEntry
// ===========================================================================
describe("todoItemToPlanEntry", () => {
it("converts a todo item to a plan entry with default config", () => {
const todo = createTodoItem("Implement feature X", "pending")
const entry = todoItemToPlanEntry(todo)
expect(entry).toEqual({
content: "Implement feature X",
priority: "medium",
status: "pending",
})
})
it("assigns high priority to in_progress items by default", () => {
const todo = createTodoItem("Working on feature", "in_progress")
const entry = todoItemToPlanEntry(todo)
expect(entry.priority).toBe("high")
expect(entry.status).toBe("in_progress")
})
it("preserves completed status", () => {
const todo = createTodoItem("Done task", "completed")
const entry = todoItemToPlanEntry(todo)
expect(entry.status).toBe("completed")
})
it("respects custom priority config", () => {
const todo = createTodoItem("Low priority task", "pending")
const config: PriorityConfig = {
defaultPriority: "low",
prioritizeInProgress: false,
prioritizeByOrder: false,
highPriorityCount: 3,
}
const entry = todoItemToPlanEntry(todo, 0, 1, config)
expect(entry.priority).toBe("low")
})
it("uses order-based priority when enabled", () => {
const config: PriorityConfig = {
defaultPriority: "medium",
prioritizeInProgress: false,
prioritizeByOrder: true,
highPriorityCount: 2,
}
// First 2 items should be high priority
const first = todoItemToPlanEntry(createTodoItem("First", "pending"), 0, 6, config)
const second = todoItemToPlanEntry(createTodoItem("Second", "pending"), 1, 6, config)
expect(first.priority).toBe("high")
expect(second.priority).toBe("high")
// Items 3-4 (first half) should be medium
const third = todoItemToPlanEntry(createTodoItem("Third", "pending"), 2, 6, config)
expect(third.priority).toBe("medium")
// Items past the halfway point should be low
const fifth = todoItemToPlanEntry(createTodoItem("Fifth", "pending"), 4, 6, config)
expect(fifth.priority).toBe("low")
})
it("prioritizes in_progress over order when both enabled", () => {
const config: PriorityConfig = {
defaultPriority: "low",
prioritizeInProgress: true,
prioritizeByOrder: true,
highPriorityCount: 1,
}
// Even at the end of the list, in_progress should be high
const inProgress = todoItemToPlanEntry(createTodoItem("In progress", "in_progress"), 5, 6, config)
expect(inProgress.priority).toBe("high")
})
})
// ===========================================================================
// todoListToPlanUpdate
// ===========================================================================
describe("todoListToPlanUpdate", () => {
it("converts an empty array to a plan with no entries", () => {
const update = todoListToPlanUpdate([])
expect(update).toEqual({
sessionUpdate: "plan",
entries: [],
})
})
it("converts a list of todos to a plan update", () => {
const todos: TodoItem[] = [
createTodoItem("Task 1", "completed"),
createTodoItem("Task 2", "in_progress"),
createTodoItem("Task 3", "pending"),
]
const update = todoListToPlanUpdate(todos)
expect(update.sessionUpdate).toBe("plan")
expect(update.entries).toHaveLength(3)
expect(update.entries[0]).toEqual({
content: "Task 1",
priority: "medium",
status: "completed",
})
expect(update.entries[1]).toEqual({
content: "Task 2",
priority: "high", // in_progress gets high priority
status: "in_progress",
})
expect(update.entries[2]).toEqual({
content: "Task 3",
priority: "medium",
status: "pending",
})
})
it("accepts partial config overrides", () => {
const todos = [createTodoItem("Task", "pending")]
const update = todoListToPlanUpdate(todos, { defaultPriority: "high" })
expect(update.entries[0]?.priority).toBe("high")
})
})
// ===========================================================================
// parseTodoListFromMessage
// ===========================================================================
describe("parseTodoListFromMessage", () => {
it("parses valid todo list JSON", () => {
const text = JSON.stringify({
tool: "updateTodoList",
todos: [
{ id: "1", content: "Task 1", status: "pending" },
{ id: "2", content: "Task 2", status: "completed" },
],
})
const result = parseTodoListFromMessage(text)
expect(result).toEqual([
{ id: "1", content: "Task 1", status: "pending" },
{ id: "2", content: "Task 2", status: "completed" },
])
})
it("returns null for invalid JSON", () => {
expect(parseTodoListFromMessage("not json")).toBeNull()
expect(parseTodoListFromMessage("{invalid}")).toBeNull()
})
it("returns null for JSON without updateTodoList tool", () => {
expect(parseTodoListFromMessage(JSON.stringify({ tool: "other" }))).toBeNull()
expect(parseTodoListFromMessage(JSON.stringify({ todos: [] }))).toBeNull()
})
it("returns null for JSON with non-array todos", () => {
expect(parseTodoListFromMessage(JSON.stringify({ tool: "updateTodoList", todos: "not array" }))).toBeNull()
})
})
// ===========================================================================
// isTodoListMessage
// ===========================================================================
describe("isTodoListMessage", () => {
it("detects tool ask messages with updateTodoList", () => {
const message = {
type: "ask",
ask: "tool",
text: JSON.stringify({ tool: "updateTodoList", todos: [] }),
}
expect(isTodoListMessage(message)).toBe(true)
})
it("detects user_edit_todos say messages", () => {
const message = {
type: "say",
say: "user_edit_todos",
text: JSON.stringify({ tool: "updateTodoList", todos: [] }),
}
expect(isTodoListMessage(message)).toBe(true)
})
it("returns false for other ask types", () => {
const message = {
type: "ask",
ask: "command",
text: JSON.stringify({ tool: "updateTodoList", todos: [] }),
}
expect(isTodoListMessage(message)).toBe(false)
})
it("returns false for other say types", () => {
const message = {
type: "say",
say: "text",
text: JSON.stringify({ tool: "updateTodoList", todos: [] }),
}
expect(isTodoListMessage(message)).toBe(false)
})
it("returns false for messages without text", () => {
const message = {
type: "ask",
ask: "tool",
}
expect(isTodoListMessage(message)).toBe(false)
})
it("returns false for tool messages with other tools", () => {
const message = {
type: "ask",
ask: "tool",
text: JSON.stringify({ tool: "read_file", path: "/some/path" }),
}
expect(isTodoListMessage(message)).toBe(false)
})
})
// ===========================================================================
// extractTodoListFromMessage
// ===========================================================================
describe("extractTodoListFromMessage", () => {
it("extracts todos from tool ask message", () => {
const todos = [{ id: "1", content: "Task", status: "pending" }]
const message = {
type: "ask",
ask: "tool",
text: JSON.stringify({ tool: "updateTodoList", todos }),
}
expect(extractTodoListFromMessage(message)).toEqual(todos)
})
it("extracts todos from user_edit_todos say message", () => {
const todos = [{ id: "1", content: "Task", status: "completed" }]
const message = {
type: "say",
say: "user_edit_todos",
text: JSON.stringify({ tool: "updateTodoList", todos }),
}
expect(extractTodoListFromMessage(message)).toEqual(todos)
})
it("returns null for non-todo messages", () => {
expect(extractTodoListFromMessage({ type: "say", say: "text", text: "Hello" })).toBeNull()
})
it("returns null for messages without text", () => {
expect(extractTodoListFromMessage({ type: "ask", ask: "tool" })).toBeNull()
})
})
// ===========================================================================
// createPlanUpdateFromMessage
// ===========================================================================
describe("createPlanUpdateFromMessage", () => {
it("creates plan update from valid todo message", () => {
const todos = [
{ id: "1", content: "First task", status: "in_progress" as const },
{ id: "2", content: "Second task", status: "pending" as const },
]
const message = {
type: "ask",
ask: "tool",
text: JSON.stringify({ tool: "updateTodoList", todos }),
}
const update = createPlanUpdateFromMessage(message)
expect(update).not.toBeNull()
expect(update?.sessionUpdate).toBe("plan")
expect(update?.entries).toHaveLength(2)
expect(update?.entries[0]).toEqual({
content: "First task",
priority: "high", // in_progress
status: "in_progress",
})
})
it("returns null for non-todo messages", () => {
const message = {
type: "say",
say: "text",
text: "Just some text",
}
expect(createPlanUpdateFromMessage(message)).toBeNull()
})
it("returns null for empty todo list", () => {
const message = {
type: "ask",
ask: "tool",
text: JSON.stringify({ tool: "updateTodoList", todos: [] }),
}
expect(createPlanUpdateFromMessage(message)).toBeNull()
})
it("accepts custom priority config", () => {
const todos = [{ id: "1", content: "Task", status: "pending" as const }]
const message = {
type: "ask",
ask: "tool",
text: JSON.stringify({ tool: "updateTodoList", todos }),
}
const update = createPlanUpdateFromMessage(message, { defaultPriority: "low" })
expect(update?.entries[0]?.priority).toBe("low")
})
})
// ===========================================================================
// Edge Cases
// ===========================================================================
describe("edge cases", () => {
it("handles todos with special characters in content", () => {
const todo = createTodoItem('Task with "quotes" and <html>', "pending")
const entry = todoItemToPlanEntry(todo)
expect(entry.content).toBe('Task with "quotes" and <html>')
})
it("handles todos with unicode content", () => {
const todo = createTodoItem("Task with emoji 🚀 and unicode ñ", "pending")
const entry = todoItemToPlanEntry(todo)
expect(entry.content).toBe("Task with emoji 🚀 and unicode ñ")
})
it("handles very long content", () => {
const longContent = "A".repeat(10000)
const todo = createTodoItem(longContent, "pending")
const entry = todoItemToPlanEntry(todo)
expect(entry.content).toBe(longContent)
})
it("handles malformed JSON gracefully", () => {
const message = {
type: "ask",
ask: "tool",
text: '{"tool": "updateTodoList", "todos": [{"broken',
}
expect(isTodoListMessage(message)).toBe(false)
expect(extractTodoListFromMessage(message)).toBeNull()
expect(createPlanUpdateFromMessage(message)).toBeNull()
})
})
})

View file

@ -0,0 +1,373 @@
/**
* Prompt State Machine Unit Tests
*
* Tests for the PromptStateMachine class.
*/
import { PromptStateMachine, createPromptStateMachine } from "../prompt-state.js"
describe("PromptStateMachine", () => {
describe("initial state", () => {
it("should start in idle state", () => {
const sm = new PromptStateMachine()
expect(sm.getState()).toBe("idle")
})
it("should have null abort signal initially", () => {
const sm = new PromptStateMachine()
expect(sm.getAbortSignal()).toBeNull()
})
it("should have null prompt text initially", () => {
const sm = new PromptStateMachine()
expect(sm.getCurrentPromptText()).toBeNull()
})
})
describe("canStartPrompt", () => {
it("should return true when idle", () => {
const sm = new PromptStateMachine()
expect(sm.canStartPrompt()).toBe(true)
})
it("should return false when processing", async () => {
const sm = new PromptStateMachine()
sm.startPrompt("test")
expect(sm.canStartPrompt()).toBe(false)
// Clean up
sm.complete(true)
})
})
describe("isProcessing", () => {
it("should return false when idle", () => {
const sm = new PromptStateMachine()
expect(sm.isProcessing()).toBe(false)
})
it("should return true when processing", () => {
const sm = new PromptStateMachine()
sm.startPrompt("test")
expect(sm.isProcessing()).toBe(true)
// Clean up
sm.complete(true)
})
it("should return false after completion", () => {
const sm = new PromptStateMachine()
sm.startPrompt("test")
sm.complete(true)
expect(sm.isProcessing()).toBe(false)
})
})
describe("startPrompt", () => {
it("should transition to processing state", () => {
const sm = new PromptStateMachine()
sm.startPrompt("test prompt")
expect(sm.getState()).toBe("processing")
// Clean up
sm.complete(true)
})
it("should store the prompt text", () => {
const sm = new PromptStateMachine()
sm.startPrompt("my test prompt")
expect(sm.getCurrentPromptText()).toBe("my test prompt")
// Clean up
sm.complete(true)
})
it("should create abort signal", () => {
const sm = new PromptStateMachine()
sm.startPrompt("test")
expect(sm.getAbortSignal()).not.toBeNull()
expect(sm.getAbortSignal()?.aborted).toBe(false)
// Clean up
sm.complete(true)
})
it("should return a promise", () => {
const sm = new PromptStateMachine()
const promise = sm.startPrompt("test")
expect(promise).toBeInstanceOf(Promise)
// Clean up
sm.complete(true)
})
it("should resolve with end_turn on successful completion", async () => {
const sm = new PromptStateMachine()
const promise = sm.startPrompt("test")
sm.complete(true)
const result = await promise
expect(result.stopReason).toBe("end_turn")
})
it("should resolve with refusal on failed completion", async () => {
const sm = new PromptStateMachine()
const promise = sm.startPrompt("test")
sm.complete(false)
const result = await promise
expect(result.stopReason).toBe("refusal")
})
it("should resolve with cancelled on cancel", async () => {
const sm = new PromptStateMachine()
const promise = sm.startPrompt("test")
sm.cancel()
const result = await promise
expect(result.stopReason).toBe("cancelled")
})
it("should cancel existing prompt if called while processing", async () => {
const sm = new PromptStateMachine()
const promise1 = sm.startPrompt("first prompt")
// Start a second prompt (should cancel first)
sm.startPrompt("second prompt")
// First promise should resolve with cancelled
const result1 = await promise1
expect(result1.stopReason).toBe("cancelled")
// Clean up
sm.complete(true)
})
})
describe("complete", () => {
it("should transition to idle state", () => {
const sm = new PromptStateMachine()
sm.startPrompt("test")
sm.complete(true)
expect(sm.getState()).toBe("idle")
})
it("should return end_turn for success", () => {
const sm = new PromptStateMachine()
sm.startPrompt("test")
const stopReason = sm.complete(true)
expect(stopReason).toBe("end_turn")
})
it("should return refusal for failure", () => {
const sm = new PromptStateMachine()
sm.startPrompt("test")
const stopReason = sm.complete(false)
expect(stopReason).toBe("refusal")
})
it("should clear prompt text", () => {
const sm = new PromptStateMachine()
sm.startPrompt("test")
sm.complete(true)
expect(sm.getCurrentPromptText()).toBeNull()
})
it("should clear abort controller", () => {
const sm = new PromptStateMachine()
sm.startPrompt("test")
sm.complete(true)
expect(sm.getAbortSignal()).toBeNull()
})
it("should be idempotent (multiple calls ignored)", () => {
const sm = new PromptStateMachine()
sm.startPrompt("test")
const result1 = sm.complete(true)
const result2 = sm.complete(false) // Should be ignored
expect(result1).toBe("end_turn")
expect(result2).toBe("refusal") // Returns mapped value but doesn't change state
expect(sm.getState()).toBe("idle")
})
})
describe("cancel", () => {
it("should abort the signal", () => {
const sm = new PromptStateMachine()
sm.startPrompt("test")
const signal = sm.getAbortSignal()
sm.cancel()
expect(signal?.aborted).toBe(true)
})
it("should transition to idle state", async () => {
const sm = new PromptStateMachine()
const promise = sm.startPrompt("test")
sm.cancel()
await promise
expect(sm.getState()).toBe("idle")
})
it("should be safe to call when idle", () => {
const sm = new PromptStateMachine()
// Should not throw
expect(() => sm.cancel()).not.toThrow()
expect(sm.getState()).toBe("idle")
})
it("should be idempotent (multiple calls safe)", () => {
const sm = new PromptStateMachine()
sm.startPrompt("test")
sm.cancel()
sm.cancel() // Should not throw
expect(sm.getState()).toBe("idle")
})
})
describe("reset", () => {
it("should transition to idle state", () => {
const sm = new PromptStateMachine()
sm.startPrompt("test")
sm.reset()
expect(sm.getState()).toBe("idle")
})
it("should clear prompt text", () => {
const sm = new PromptStateMachine()
sm.startPrompt("test")
sm.reset()
expect(sm.getCurrentPromptText()).toBeNull()
})
it("should clear abort controller", () => {
const sm = new PromptStateMachine()
sm.startPrompt("test")
sm.reset()
expect(sm.getAbortSignal()).toBeNull()
})
it("should abort any pending operation", () => {
const sm = new PromptStateMachine()
sm.startPrompt("test")
const signal = sm.getAbortSignal()
sm.reset()
expect(signal?.aborted).toBe(true)
})
it("should be safe to call when idle", () => {
const sm = new PromptStateMachine()
expect(() => sm.reset()).not.toThrow()
expect(sm.getState()).toBe("idle")
})
})
describe("abort signal integration", () => {
it("should trigger abort handler on cancel", async () => {
const sm = new PromptStateMachine()
const promise = sm.startPrompt("test")
let abortHandlerCalled = false
sm.getAbortSignal()?.addEventListener("abort", () => {
abortHandlerCalled = true
})
sm.cancel()
await promise
expect(abortHandlerCalled).toBe(true)
})
it("should resolve promise via abort handler", async () => {
const sm = new PromptStateMachine()
const promise = sm.startPrompt("test")
sm.cancel()
const result = await promise
expect(result.stopReason).toBe("cancelled")
})
})
describe("lifecycle scenarios", () => {
it("should handle multiple prompt cycles", async () => {
const sm = new PromptStateMachine()
// First cycle
const promise1 = sm.startPrompt("prompt 1")
expect(sm.isProcessing()).toBe(true)
sm.complete(true)
const result1 = await promise1
expect(result1.stopReason).toBe("end_turn")
expect(sm.isProcessing()).toBe(false)
// Second cycle
const promise2 = sm.startPrompt("prompt 2")
expect(sm.isProcessing()).toBe(true)
expect(sm.getCurrentPromptText()).toBe("prompt 2")
sm.complete(false)
const result2 = await promise2
expect(result2.stopReason).toBe("refusal")
// Third cycle with cancellation
const promise3 = sm.startPrompt("prompt 3")
sm.cancel()
const result3 = await promise3
expect(result3.stopReason).toBe("cancelled")
})
it("should handle rapid start/cancel cycles", async () => {
const sm = new PromptStateMachine()
const promises: Promise<{ stopReason: string }>[] = []
for (let i = 0; i < 5; i++) {
const promise = sm.startPrompt(`prompt ${i}`)
promises.push(promise)
sm.cancel()
}
// All should resolve with cancelled
const results = await Promise.all(promises)
expect(results.every((r) => r.stopReason === "cancelled")).toBe(true)
})
})
})
describe("createPromptStateMachine", () => {
it("should create a new state machine", () => {
const sm = createPromptStateMachine()
expect(sm).toBeInstanceOf(PromptStateMachine)
expect(sm.getState()).toBe("idle")
})
})

View file

@ -0,0 +1,397 @@
/**
* Integration tests for ACP Plan updates via session-event-handler.
*
* Tests the end-to-end flow of:
* 1. Extension sending todo list update messages
* 2. Session-event-handler detecting and translating them
* 3. ACP plan updates being sent to the connection
*/
import type { ClineMessage } from "@roo-code/types"
import {
SessionEventHandler,
createSessionEventHandler,
type SessionEventHandlerDeps,
} from "../session-event-handler.js"
import type { IAcpLogger, IDeltaTracker, IPromptStateMachine } from "../interfaces.js"
import { ToolHandlerRegistry } from "../tool-handler.js"
// =============================================================================
// Mock Setup
// =============================================================================
const createMockLogger = (): IAcpLogger => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
request: vi.fn(),
response: vi.fn(),
notification: vi.fn(),
})
const createMockDeltaTracker = (): IDeltaTracker => ({
getDelta: vi.fn().mockReturnValue(null),
peekDelta: vi.fn().mockReturnValue(null),
reset: vi.fn(),
resetId: vi.fn(),
})
const createMockPromptState = (): IPromptStateMachine => ({
getState: vi.fn().mockReturnValue("processing"),
getAbortSignal: vi.fn().mockReturnValue(null),
getPromptText: vi.fn().mockReturnValue(""),
canStartPrompt: vi.fn().mockReturnValue(false),
isProcessing: vi.fn().mockReturnValue(true), // Return true so messages are processed
startPrompt: vi.fn().mockReturnValue(Promise.resolve({ stopReason: "end_turn" })),
complete: vi.fn().mockReturnValue("end_turn"),
transitionToComplete: vi.fn(),
cancel: vi.fn(),
reset: vi.fn(),
})
const createMockCommandStreamManager = () => ({
handleExecutionOutput: vi.fn(),
handleCommandOutput: vi.fn(),
isCommandOutputMessage: vi.fn().mockReturnValue(false),
trackCommand: vi.fn(),
reset: vi.fn(),
})
const createMockToolContentStreamManager = () => ({
handleToolContentStreaming: vi.fn(),
isToolAskMessage: vi.fn().mockReturnValue(false),
reset: vi.fn(),
})
const createMockExtensionClient = () => {
const handlers: Record<string, ((data: unknown) => void)[]> = {}
return {
on: vi.fn((event: string, handler: (data: unknown) => void) => {
handlers[event] = handlers[event] || []
handlers[event]!.push(handler)
return { on: vi.fn(), off: vi.fn() }
}),
off: vi.fn(),
emit: (event: string, data: unknown) => {
handlers[event]?.forEach((h) => h(data))
},
respond: vi.fn(),
approve: vi.fn(),
reject: vi.fn(),
}
}
const createMockExtensionHost = () => ({
on: vi.fn(),
off: vi.fn(),
client: createMockExtensionClient(),
activate: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn().mockResolvedValue(undefined),
sendToExtension: vi.fn(),
})
// =============================================================================
// Tests
// =============================================================================
describe("Session Plan Integration", () => {
let eventHandler: SessionEventHandler
let mockSendUpdate: ReturnType<typeof vi.fn>
let mockClient: ReturnType<typeof createMockExtensionClient>
let deps: SessionEventHandlerDeps
beforeEach(() => {
mockSendUpdate = vi.fn()
mockClient = createMockExtensionClient()
deps = {
logger: createMockLogger(),
client: mockClient,
extensionHost: createMockExtensionHost(),
promptState: createMockPromptState(),
deltaTracker: createMockDeltaTracker(),
commandStreamManager: createMockCommandStreamManager(),
toolContentStreamManager: createMockToolContentStreamManager(),
toolHandlerRegistry: new ToolHandlerRegistry(),
sendUpdate: mockSendUpdate,
approveAction: vi.fn(),
respondWithText: vi.fn(),
sendToExtension: vi.fn(),
workspacePath: "/test/workspace",
initialModeId: "code",
isCancelling: vi.fn().mockReturnValue(false),
}
eventHandler = createSessionEventHandler(deps)
eventHandler.setupEventHandlers()
})
describe("todo list message detection", () => {
it("detects and sends plan update for updateTodoList tool ask message", () => {
const todoMessage: ClineMessage = {
type: "ask",
ask: "tool",
ts: Date.now(),
text: JSON.stringify({
tool: "updateTodoList",
todos: [
{ id: "1", content: "First task", status: "completed" },
{ id: "2", content: "Second task", status: "in_progress" },
{ id: "3", content: "Third task", status: "pending" },
],
}),
}
// Emit the message through the mock client
mockClient.emit("message", todoMessage)
// Verify plan update was sent
expect(mockSendUpdate).toHaveBeenCalledWith(
expect.objectContaining({
sessionUpdate: "plan",
entries: expect.arrayContaining([
expect.objectContaining({
content: "First task",
status: "completed",
priority: expect.any(String),
}),
expect.objectContaining({
content: "Second task",
status: "in_progress",
priority: "high", // in_progress gets high priority
}),
expect.objectContaining({
content: "Third task",
status: "pending",
priority: expect.any(String),
}),
]),
}),
)
})
it("detects and sends plan update for user_edit_todos say message", () => {
const editMessage: ClineMessage = {
type: "say",
say: "user_edit_todos",
ts: Date.now(),
text: JSON.stringify({
tool: "updateTodoList",
todos: [{ id: "1", content: "Edited task", status: "completed" }],
}),
}
mockClient.emit("message", editMessage)
expect(mockSendUpdate).toHaveBeenCalledWith(
expect.objectContaining({
sessionUpdate: "plan",
entries: [
expect.objectContaining({
content: "Edited task",
status: "completed",
}),
],
}),
)
})
it("does not send plan update for other tool ask messages", () => {
const otherToolMessage: ClineMessage = {
type: "ask",
ask: "tool",
ts: Date.now(),
text: JSON.stringify({
tool: "read_file",
path: "/some/file.txt",
}),
}
mockClient.emit("message", otherToolMessage)
// Should not have sent a plan update (but may send other updates)
const planUpdateCalls = mockSendUpdate.mock.calls.filter(
(call) => (call[0] as { sessionUpdate?: string })?.sessionUpdate === "plan",
)
expect(planUpdateCalls).toHaveLength(0)
})
it("does not send plan update for empty todo list", () => {
const emptyTodoMessage: ClineMessage = {
type: "ask",
ask: "tool",
ts: Date.now(),
text: JSON.stringify({
tool: "updateTodoList",
todos: [],
}),
}
mockClient.emit("message", emptyTodoMessage)
// Should not have sent a plan update for empty list
const planUpdateCalls = mockSendUpdate.mock.calls.filter(
(call) => (call[0] as { sessionUpdate?: string })?.sessionUpdate === "plan",
)
expect(planUpdateCalls).toHaveLength(0)
})
})
describe("priority assignment", () => {
it("assigns high priority to in_progress items", () => {
const todoMessage: ClineMessage = {
type: "ask",
ask: "tool",
ts: Date.now(),
text: JSON.stringify({
tool: "updateTodoList",
todos: [
{ id: "1", content: "Pending task", status: "pending" },
{ id: "2", content: "In progress task", status: "in_progress" },
{ id: "3", content: "Completed task", status: "completed" },
],
}),
}
mockClient.emit("message", todoMessage)
const planUpdateCall = mockSendUpdate.mock.calls.find(
(call) => (call[0] as { sessionUpdate?: string })?.sessionUpdate === "plan",
)
expect(planUpdateCall).toBeDefined()
const entries = (planUpdateCall![0] as { entries: Array<{ content: string; priority: string }> }).entries
const inProgressEntry = entries.find((e) => e.content === "In progress task")
expect(inProgressEntry?.priority).toBe("high")
})
it("assigns medium priority to pending and completed items by default", () => {
const todoMessage: ClineMessage = {
type: "ask",
ask: "tool",
ts: Date.now(),
text: JSON.stringify({
tool: "updateTodoList",
todos: [
{ id: "1", content: "Pending task", status: "pending" },
{ id: "2", content: "Completed task", status: "completed" },
],
}),
}
mockClient.emit("message", todoMessage)
const planUpdateCall = mockSendUpdate.mock.calls.find(
(call) => (call[0] as { sessionUpdate?: string })?.sessionUpdate === "plan",
)
expect(planUpdateCall).toBeDefined()
const entries = (planUpdateCall![0] as { entries: Array<{ content: string; priority: string }> }).entries
const pendingEntry = entries.find((e) => e.content === "Pending task")
const completedEntry = entries.find((e) => e.content === "Completed task")
expect(pendingEntry?.priority).toBe("medium")
expect(completedEntry?.priority).toBe("medium")
})
})
describe("message updates (streaming)", () => {
it("sends plan update when message is updated", () => {
const todoMessage: ClineMessage = {
type: "ask",
ask: "tool",
ts: Date.now(),
text: JSON.stringify({
tool: "updateTodoList",
todos: [{ id: "1", content: "Initial task", status: "pending" }],
}),
}
// First message
mockClient.emit("message", todoMessage)
// Updated message with more todos
const updatedMessage: ClineMessage = {
...todoMessage,
text: JSON.stringify({
tool: "updateTodoList",
todos: [
{ id: "1", content: "Initial task", status: "completed" },
{ id: "2", content: "New task", status: "pending" },
],
}),
}
mockClient.emit("messageUpdated", updatedMessage)
// Should have sent 2 plan updates (one for each message)
const planUpdateCalls = mockSendUpdate.mock.calls.filter(
(call) => (call[0] as { sessionUpdate?: string })?.sessionUpdate === "plan",
)
expect(planUpdateCalls.length).toBeGreaterThanOrEqual(2)
})
})
describe("logging", () => {
it("sends plan updates without verbose logging", () => {
const todoMessage: ClineMessage = {
type: "ask",
ask: "tool",
ts: Date.now(),
text: JSON.stringify({
tool: "updateTodoList",
todos: [{ id: "1", content: "Test task", status: "pending" }],
}),
}
mockClient.emit("message", todoMessage)
// Plan update should be sent without verbose logging
const planUpdateCalls = mockSendUpdate.mock.calls.filter(
(call) => (call[0] as { sessionUpdate?: string })?.sessionUpdate === "plan",
)
expect(planUpdateCalls).toHaveLength(1)
})
})
describe("reset behavior", () => {
it("continues to detect plan updates after reset", () => {
const todoMessage: ClineMessage = {
type: "ask",
ask: "tool",
ts: Date.now(),
text: JSON.stringify({
tool: "updateTodoList",
todos: [{ id: "1", content: "Task 1", status: "pending" }],
}),
}
mockClient.emit("message", todoMessage)
mockSendUpdate.mockClear()
// Reset the event handler
eventHandler.reset()
// Send another todo message
const anotherMessage: ClineMessage = {
...todoMessage,
ts: Date.now() + 1,
text: JSON.stringify({
tool: "updateTodoList",
todos: [{ id: "2", content: "Task 2", status: "pending" }],
}),
}
mockClient.emit("message", anotherMessage)
// Should still detect and send plan update
const planUpdateCalls = mockSendUpdate.mock.calls.filter(
(call) => (call[0] as { sessionUpdate?: string })?.sessionUpdate === "plan",
)
expect(planUpdateCalls).toHaveLength(1)
})
})
})

View file

@ -0,0 +1,301 @@
import type * as acp from "@agentclientprotocol/sdk"
import { AgentLoopState } from "@/agent/agent-state.js"
// Track registered event handlers for simulation
type EventHandler = (data: unknown) => void
const clientEventHandlers: Map<string, EventHandler[]> = new Map()
vi.mock("@/agent/extension-host.js", () => {
const mockClient = {
on: vi.fn().mockImplementation((event: string, handler: EventHandler) => {
const handlers = clientEventHandlers.get(event) || []
handlers.push(handler)
clientEventHandlers.set(event, handlers)
return mockClient
}),
off: vi.fn().mockReturnThis(),
respond: vi.fn(),
approve: vi.fn(),
reject: vi.fn(),
getAgentState: vi.fn().mockReturnValue({
state: AgentLoopState.RUNNING,
isRunning: true,
isStreaming: false,
currentAsk: null,
}),
}
return {
ExtensionHost: vi.fn().mockImplementation(() => ({
client: mockClient,
activate: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn().mockResolvedValue(undefined),
sendToExtension: vi.fn(),
// Add on/off methods for extension host events (e.g., extensionWebviewMessage)
on: vi.fn().mockReturnThis(),
off: vi.fn().mockReturnThis(),
})),
}
})
/**
* Simulate the extension responding to a cancel by emitting a state change to a terminal state.
*/
function simulateExtensionCancelResponse(): void {
const handlers = clientEventHandlers.get("stateChange") || []
handlers.forEach((handler) => {
handler({
previousState: { state: AgentLoopState.RUNNING, isRunning: true, isStreaming: false },
currentState: { state: AgentLoopState.IDLE, isRunning: false, isStreaming: false },
})
})
}
import { AcpSession, type AcpSessionOptions } from "../session.js"
import { ExtensionHost } from "@/agent/extension-host.js"
describe("AcpSession", () => {
let mockConnection: acp.AgentSideConnection
const defaultOptions: AcpSessionOptions = {
extensionPath: "/test/extension",
provider: "openrouter",
apiKey: "test-api-key",
model: "test-model",
mode: "code",
}
beforeEach(() => {
// Clear registered event handlers between tests
clientEventHandlers.clear()
mockConnection = {
sessionUpdate: vi.fn().mockResolvedValue(undefined),
requestPermission: vi.fn().mockResolvedValue({
outcome: { outcome: "selected", optionId: "allow" },
}),
readTextFile: vi.fn().mockResolvedValue({ content: "test content" }),
writeTextFile: vi.fn().mockResolvedValue({}),
createTerminal: vi.fn(),
extMethod: vi.fn(),
extNotification: vi.fn(),
signal: new AbortController().signal,
closed: Promise.resolve(),
} as unknown as acp.AgentSideConnection
vi.clearAllMocks()
})
afterEach(() => {
vi.clearAllMocks()
clientEventHandlers.clear()
})
describe("create", () => {
it("should create a session with a unique ID", async () => {
const session = await AcpSession.create({
sessionId: "test-session-1",
cwd: "/test/workspace",
connection: mockConnection,
options: defaultOptions,
deps: {},
})
expect(session).toBeDefined()
expect(session.getSessionId()).toBe("test-session-1")
})
it("should create ExtensionHost with correct config", async () => {
await AcpSession.create({
sessionId: "test-session-2",
cwd: "/test/workspace",
connection: mockConnection,
options: defaultOptions,
deps: {},
})
expect(ExtensionHost).toHaveBeenCalledWith(
expect.objectContaining({
extensionPath: "/test/extension",
workspacePath: "/test/workspace",
provider: "openrouter",
apiKey: "test-api-key",
model: "test-model",
mode: "code",
}),
)
})
it("should accept client capabilities", async () => {
const session = await AcpSession.create({
sessionId: "test-session-3",
cwd: "/test/workspace",
connection: mockConnection,
options: defaultOptions,
deps: {},
})
expect(session).toBeDefined()
})
it("should activate the extension host", async () => {
await AcpSession.create({
sessionId: "test-session-4",
cwd: "/test/workspace",
connection: mockConnection,
options: defaultOptions,
deps: {},
})
const mockHostInstance = vi.mocked(ExtensionHost).mock.results[0]!.value
expect(mockHostInstance.activate).toHaveBeenCalled()
})
})
describe("prompt", () => {
it("should send a task to the extension host", async () => {
const session = await AcpSession.create({
sessionId: "test-session",
cwd: "/test/workspace",
connection: mockConnection,
options: defaultOptions,
deps: {},
})
const mockHostInstance = vi.mocked(ExtensionHost).mock.results[0]!.value
// Start the prompt (don't await - it waits for taskCompleted event)
const promptPromise = session.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "Hello, world!" }],
})
// Verify the task was sent
expect(mockHostInstance.sendToExtension).toHaveBeenCalledWith(
expect.objectContaining({
type: "newTask",
text: "Hello, world!",
}),
)
// Cancel to resolve the promise - simulate extension responding to cancel
session.cancel()
simulateExtensionCancelResponse()
const result = await promptPromise
expect(result.stopReason).toBe("cancelled")
})
it("should handle image prompts", async () => {
const session = await AcpSession.create({
sessionId: "test-session",
cwd: "/test/workspace",
connection: mockConnection,
options: defaultOptions,
deps: {},
})
const mockHostInstance = vi.mocked(ExtensionHost).mock.results[0]!.value
const promptPromise = session.prompt({
sessionId: "test-session",
prompt: [
{ type: "text", text: "Describe this image" },
{ type: "image", mimeType: "image/png", data: "base64data" },
],
})
// Images are extracted as raw base64 data, text includes [image content] placeholder
expect(mockHostInstance.sendToExtension).toHaveBeenCalledWith(
expect.objectContaining({
type: "newTask",
images: expect.arrayContaining(["base64data"]),
}),
)
session.cancel()
simulateExtensionCancelResponse()
await promptPromise
})
})
describe("cancel", () => {
it("should send cancel message to extension host", async () => {
const session = await AcpSession.create({
sessionId: "test-session",
cwd: "/test/workspace",
connection: mockConnection,
options: defaultOptions,
deps: {},
})
const mockHostInstance = vi.mocked(ExtensionHost).mock.results[0]!.value
// Start a prompt first
const promptPromise = session.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "Hello" }],
})
// Cancel and simulate extension responding
session.cancel()
simulateExtensionCancelResponse()
expect(mockHostInstance.sendToExtension).toHaveBeenCalledWith({ type: "cancelTask" })
await promptPromise
})
})
describe("setMode", () => {
it("should update the session mode", async () => {
const session = await AcpSession.create({
sessionId: "test-session",
cwd: "/test/workspace",
connection: mockConnection,
options: defaultOptions,
deps: {},
})
const mockHostInstance = vi.mocked(ExtensionHost).mock.results[0]!.value
session.setMode("architect")
expect(mockHostInstance.sendToExtension).toHaveBeenCalledWith({
type: "updateSettings",
updatedSettings: { mode: "architect" },
})
})
})
describe("dispose", () => {
it("should dispose the extension host", async () => {
const session = await AcpSession.create({
sessionId: "test-session",
cwd: "/test/workspace",
connection: mockConnection,
options: defaultOptions,
deps: {},
})
const mockHostInstance = vi.mocked(ExtensionHost).mock.results[0]!.value
await session.dispose()
expect(mockHostInstance.dispose).toHaveBeenCalled()
})
})
describe("getSessionId", () => {
it("should return the session ID", async () => {
const session = await AcpSession.create({
sessionId: "my-unique-session-id",
cwd: "/test/workspace",
connection: mockConnection,
options: defaultOptions,
deps: {},
})
expect(session.getSessionId()).toBe("my-unique-session-id")
})
})
})

View file

@ -0,0 +1,495 @@
/**
* Tests for ToolContentStreamManager
*
* Tests the tool content (file creates/edits) streaming functionality
* extracted from session.ts.
*/
import type { ClineMessage } from "@roo-code/types"
import { DeltaTracker } from "../delta-tracker.js"
import { ToolContentStreamManager } from "../tool-content-stream.js"
import { NullLogger } from "../interfaces.js"
import type { SendUpdateFn } from "../interfaces.js"
describe("ToolContentStreamManager", () => {
let deltaTracker: DeltaTracker
let sendUpdate: SendUpdateFn
let sentUpdates: Array<Record<string, unknown>>
let manager: ToolContentStreamManager
beforeEach(() => {
deltaTracker = new DeltaTracker()
sentUpdates = []
sendUpdate = (update) => {
sentUpdates.push(update as Record<string, unknown>)
}
manager = new ToolContentStreamManager({
deltaTracker,
sendUpdate,
logger: new NullLogger(),
})
})
describe("isToolAskMessage", () => {
it("returns true for tool ask messages", () => {
const message: ClineMessage = {
type: "ask",
ask: "tool",
ts: Date.now(),
text: "{}",
}
expect(manager.isToolAskMessage(message)).toBe(true)
})
it("returns false for other ask types", () => {
const message: ClineMessage = {
type: "ask",
ask: "command",
ts: Date.now(),
text: "npm test",
}
expect(manager.isToolAskMessage(message)).toBe(false)
})
it("returns false for say messages", () => {
const message: ClineMessage = {
type: "say",
say: "text",
ts: Date.now(),
text: "hello",
}
expect(manager.isToolAskMessage(message)).toBe(false)
})
})
describe("handleToolContentStreaming", () => {
describe("file write tool detection", () => {
const fileWriteTools = [
"newFileCreated",
"write_to_file",
"create_file",
"editedExistingFile",
"apply_diff",
"modify_file",
]
fileWriteTools.forEach((toolName) => {
it(`handles ${toolName} as a file write tool`, () => {
const message: ClineMessage = {
type: "ask",
ask: "tool",
ts: 12345,
text: JSON.stringify({
tool: toolName,
path: "test.ts",
content: "content",
}),
partial: true,
}
const result = manager.handleToolContentStreaming(message)
expect(result).toBe(true)
// Should send header since it's a file write tool
expect(sentUpdates.length).toBeGreaterThan(0)
})
})
it("skips non-file tools", () => {
const message: ClineMessage = {
type: "ask",
ask: "tool",
ts: 12345,
text: JSON.stringify({
tool: "read_file",
path: "test.ts",
}),
partial: true,
}
const result = manager.handleToolContentStreaming(message)
expect(result).toBe(true) // Handled by skipping
expect(sentUpdates.length).toBe(0) // Nothing sent
})
})
describe("header management", () => {
it("sends header on first valid path", () => {
const message: ClineMessage = {
type: "ask",
ask: "tool",
ts: 12345,
text: JSON.stringify({
tool: "write_to_file",
path: "src/index.ts",
content: "",
}),
partial: true,
}
manager.handleToolContentStreaming(message)
expect(sentUpdates.length).toBe(1)
expect(sentUpdates[0]).toEqual({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "\n**Creating src/index.ts**\n```\n" },
})
})
it("only sends header once per message", () => {
const ts = 12345
// First call
manager.handleToolContentStreaming({
type: "ask",
ask: "tool",
ts,
text: JSON.stringify({
tool: "write_to_file",
path: "test.ts",
content: "line 1",
}),
partial: true,
})
const headerCount1 = sentUpdates.filter((u) =>
((u.content as { text: string }).text || "").includes("**Creating"),
).length
// Second call with same ts
manager.handleToolContentStreaming({
type: "ask",
ask: "tool",
ts,
text: JSON.stringify({
tool: "write_to_file",
path: "test.ts",
content: "line 1\nline 2",
}),
partial: true,
})
const headerCount2 = sentUpdates.filter((u) =>
((u.content as { text: string }).text || "").includes("**Creating"),
).length
expect(headerCount1).toBe(1)
expect(headerCount2).toBe(1) // Still 1, no duplicate
})
it("waits for valid path before sending header", () => {
// Path without extension is not valid
const message: ClineMessage = {
type: "ask",
ask: "tool",
ts: 12345,
text: JSON.stringify({
tool: "write_to_file",
path: "incomplete",
content: "content",
}),
partial: true,
}
manager.handleToolContentStreaming(message)
expect(sentUpdates.length).toBe(0) // No header yet
})
it("validates path has file extension", () => {
const validPaths = ["test.ts", "README.md", "config.json", "src/utils.js"]
const invalidPaths = ["test", "src/folder/", "noextension"]
validPaths.forEach((path) => {
sentUpdates.length = 0
manager = new ToolContentStreamManager({
deltaTracker: new DeltaTracker(),
sendUpdate,
logger: new NullLogger(),
})
manager.handleToolContentStreaming({
type: "ask",
ask: "tool",
ts: Date.now(),
text: JSON.stringify({ tool: "write_to_file", path, content: "" }),
partial: true,
})
expect(sentUpdates.length).toBeGreaterThan(0)
})
invalidPaths.forEach((path) => {
sentUpdates.length = 0
manager = new ToolContentStreamManager({
deltaTracker: new DeltaTracker(),
sendUpdate,
logger: new NullLogger(),
})
manager.handleToolContentStreaming({
type: "ask",
ask: "tool",
ts: Date.now(),
text: JSON.stringify({ tool: "write_to_file", path, content: "x" }),
partial: true,
})
expect(sentUpdates.length).toBe(0)
})
})
})
describe("content streaming", () => {
it("streams content deltas", () => {
const ts = 12345
// First chunk
manager.handleToolContentStreaming({
type: "ask",
ask: "tool",
ts,
text: JSON.stringify({
tool: "write_to_file",
path: "test.ts",
content: "const x = 1;",
}),
partial: true,
})
// Header + content
expect(sentUpdates.length).toBe(2)
expect(sentUpdates[1]).toEqual({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "const x = 1;" },
})
// Second chunk with more content
manager.handleToolContentStreaming({
type: "ask",
ask: "tool",
ts,
text: JSON.stringify({
tool: "write_to_file",
path: "test.ts",
content: "const x = 1;\nconst y = 2;",
}),
partial: true,
})
// Should only send the delta
expect(sentUpdates.length).toBe(3)
expect(sentUpdates[2]).toEqual({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "\nconst y = 2;" },
})
})
it("handles multiple tool streams independently", () => {
// First tool
manager.handleToolContentStreaming({
type: "ask",
ask: "tool",
ts: 1000,
text: JSON.stringify({
tool: "write_to_file",
path: "file1.ts",
content: "content1",
}),
partial: true,
})
// Second tool
manager.handleToolContentStreaming({
type: "ask",
ask: "tool",
ts: 2000,
text: JSON.stringify({
tool: "write_to_file",
path: "file2.ts",
content: "content2",
}),
partial: true,
})
// Both should get headers
const headers = sentUpdates.filter((u) =>
((u.content as { text: string }).text || "").includes("**Creating"),
)
expect(headers.length).toBe(2)
})
})
describe("completion", () => {
it("sends closing code fence on complete", () => {
const ts = 12345
// Partial message
manager.handleToolContentStreaming({
type: "ask",
ask: "tool",
ts,
text: JSON.stringify({
tool: "write_to_file",
path: "test.ts",
content: "content",
}),
partial: true,
})
sentUpdates.length = 0 // Clear
// Complete message
manager.handleToolContentStreaming({
type: "ask",
ask: "tool",
ts,
text: JSON.stringify({
tool: "write_to_file",
path: "test.ts",
content: "content",
}),
partial: false,
})
expect(sentUpdates[0]).toEqual({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "\n```\n" },
})
})
it("cleans up header tracking on complete", () => {
const ts = 12345
// Partial
manager.handleToolContentStreaming({
type: "ask",
ask: "tool",
ts,
text: JSON.stringify({
tool: "write_to_file",
path: "test.ts",
content: "content",
}),
partial: true,
})
expect(manager.getActiveHeaderCount()).toBe(1)
// Complete
manager.handleToolContentStreaming({
type: "ask",
ask: "tool",
ts,
text: JSON.stringify({
tool: "write_to_file",
path: "test.ts",
content: "content",
}),
partial: false,
})
expect(manager.getActiveHeaderCount()).toBe(0)
})
it("does not send code fence if no header was sent", () => {
const ts = 12345
// Complete message without prior partial (no header sent)
manager.handleToolContentStreaming({
type: "ask",
ask: "tool",
ts,
text: JSON.stringify({
tool: "write_to_file",
path: "test.ts",
content: "content",
}),
partial: false,
})
// Should not send closing fence
const closingFences = sentUpdates.filter((u) =>
((u.content as { text: string }).text || "").includes("```"),
)
expect(closingFences.length).toBe(0)
})
})
describe("JSON parsing", () => {
it("handles invalid JSON gracefully", () => {
const message: ClineMessage = {
type: "ask",
ask: "tool",
ts: 12345,
text: "{incomplete json",
partial: true,
}
const result = manager.handleToolContentStreaming(message)
expect(result).toBe(true) // Handled by returning early
expect(sentUpdates.length).toBe(0)
})
it("handles empty text", () => {
const message: ClineMessage = {
type: "ask",
ask: "tool",
ts: 12345,
text: "",
partial: true,
}
const result = manager.handleToolContentStreaming(message)
expect(result).toBe(true)
expect(sentUpdates.length).toBe(0)
})
})
})
describe("reset", () => {
it("clears header tracking", () => {
manager.handleToolContentStreaming({
type: "ask",
ask: "tool",
ts: 12345,
text: JSON.stringify({
tool: "write_to_file",
path: "test.ts",
content: "content",
}),
partial: true,
})
expect(manager.getActiveHeaderCount()).toBe(1)
manager.reset()
expect(manager.getActiveHeaderCount()).toBe(0)
})
})
describe("getActiveHeaderCount", () => {
it("returns 0 initially", () => {
expect(manager.getActiveHeaderCount()).toBe(0)
})
it("returns correct count after streaming", () => {
manager.handleToolContentStreaming({
type: "ask",
ask: "tool",
ts: 1000,
text: JSON.stringify({ tool: "write_to_file", path: "a.ts", content: "" }),
partial: true,
})
manager.handleToolContentStreaming({
type: "ask",
ask: "tool",
ts: 2000,
text: JSON.stringify({ tool: "write_to_file", path: "b.ts", content: "" }),
partial: true,
})
expect(manager.getActiveHeaderCount()).toBe(2)
})
})
})

View file

@ -0,0 +1,495 @@
/**
* Tool Handler Unit Tests
*
* Tests for the ToolHandler abstraction and ToolHandlerRegistry.
*/
import type { ClineMessage, ClineAsk } from "@roo-code/types"
import {
ToolHandlerRegistry,
CommandToolHandler,
FileEditToolHandler,
FileReadToolHandler,
SearchToolHandler,
ListFilesToolHandler,
DefaultToolHandler,
type ToolHandlerContext,
} from "../tool-handler.js"
import { parseToolFromMessage } from "../translator.js"
import { NullLogger } from "../interfaces.js"
// =============================================================================
// Test Utilities
// =============================================================================
const testLogger = new NullLogger()
function createContext(message: ClineMessage, ask: ClineAsk, workspacePath = "/workspace"): ToolHandlerContext {
return {
message,
ask,
workspacePath,
toolInfo: parseToolFromMessage(message, workspacePath),
logger: testLogger,
}
}
function createToolMessage(tool: string, params: Record<string, unknown> = {}): ClineMessage {
return {
ts: Date.now(),
type: "say",
say: "text",
text: JSON.stringify({ tool, ...params }),
}
}
// =============================================================================
// CommandToolHandler Tests
// =============================================================================
describe("CommandToolHandler", () => {
const handler = new CommandToolHandler()
describe("canHandle", () => {
it("should handle command asks", () => {
const context = createContext(createToolMessage("execute_command", { command: "ls" }), "command")
expect(handler.canHandle(context)).toBe(true)
})
it("should not handle tool asks", () => {
const context = createContext(createToolMessage("read_file", { path: "test.ts" }), "tool")
expect(handler.canHandle(context)).toBe(false)
})
it("should not handle browser_action_launch asks", () => {
const context = createContext(createToolMessage("browser_action", {}), "browser_action_launch")
expect(handler.canHandle(context)).toBe(false)
})
})
describe("handle", () => {
it("should return execute kind for commands", () => {
const context = createContext(createToolMessage("execute_command", { command: "npm test" }), "command")
const result = handler.handle(context)
expect(result.initialUpdate).toMatchObject({
sessionUpdate: "tool_call",
kind: "execute",
status: "in_progress",
})
})
it("should track as pending command", () => {
const message = createToolMessage("execute_command", { command: "npm test" })
const context = createContext(message, "command")
const result = handler.handle(context)
expect(result.trackAsPendingCommand).toBeDefined()
expect(result.trackAsPendingCommand?.command).toBe(message.text)
expect(result.trackAsPendingCommand?.ts).toBe(message.ts)
})
it("should not include completion update", () => {
const context = createContext(createToolMessage("execute_command", { command: "ls" }), "command")
const result = handler.handle(context)
expect(result.completionUpdate).toBeUndefined()
})
})
})
// =============================================================================
// FileEditToolHandler Tests
// =============================================================================
describe("FileEditToolHandler", () => {
const handler = new FileEditToolHandler()
describe("canHandle", () => {
it("should handle write_to_file tool", () => {
const context = createContext(createToolMessage("write_to_file", { path: "test.ts" }), "tool")
expect(handler.canHandle(context)).toBe(true)
})
it("should handle apply_diff tool", () => {
const context = createContext(createToolMessage("apply_diff", { path: "test.ts" }), "tool")
expect(handler.canHandle(context)).toBe(true)
})
it("should handle create_file tool", () => {
const context = createContext(createToolMessage("create_file", { path: "new.ts" }), "tool")
expect(handler.canHandle(context)).toBe(true)
})
it("should handle newFileCreated tool", () => {
const context = createContext(createToolMessage("newFileCreated", { path: "new.ts" }), "tool")
expect(handler.canHandle(context)).toBe(true)
})
it("should handle editedExistingFile tool", () => {
const context = createContext(createToolMessage("editedExistingFile", { path: "test.ts" }), "tool")
expect(handler.canHandle(context)).toBe(true)
})
it("should not handle read_file tool", () => {
const context = createContext(createToolMessage("read_file", { path: "test.ts" }), "tool")
expect(handler.canHandle(context)).toBe(false)
})
it("should not handle command asks", () => {
const context = createContext(createToolMessage("write_to_file", { path: "test.ts" }), "command")
expect(handler.canHandle(context)).toBe(false)
})
})
describe("handle", () => {
it("should return edit kind", () => {
const context = createContext(createToolMessage("write_to_file", { path: "test.ts" }), "tool")
const result = handler.handle(context)
expect(result.initialUpdate).toMatchObject({
sessionUpdate: "tool_call",
kind: "edit",
status: "in_progress",
})
})
it("should include completion update", () => {
const context = createContext(createToolMessage("write_to_file", { path: "test.ts" }), "tool")
const result = handler.handle(context)
expect(result.completionUpdate).toMatchObject({
sessionUpdate: "tool_call_update",
status: "completed",
})
})
it("should not track as pending command", () => {
const context = createContext(createToolMessage("write_to_file", { path: "test.ts" }), "tool")
const result = handler.handle(context)
expect(result.trackAsPendingCommand).toBeUndefined()
})
})
})
// =============================================================================
// FileReadToolHandler Tests
// =============================================================================
describe("FileReadToolHandler", () => {
const handler = new FileReadToolHandler()
describe("canHandle", () => {
it("should handle read_file tool", () => {
const context = createContext(createToolMessage("read_file", { path: "test.ts" }), "tool")
expect(handler.canHandle(context)).toBe(true)
})
it("should handle readFile tool", () => {
const context = createContext(createToolMessage("readFile", { path: "test.ts" }), "tool")
expect(handler.canHandle(context)).toBe(true)
})
it("should not handle write_to_file tool", () => {
const context = createContext(createToolMessage("write_to_file", { path: "test.ts" }), "tool")
expect(handler.canHandle(context)).toBe(false)
})
it("should not handle command asks", () => {
const context = createContext(createToolMessage("read_file", { path: "test.ts" }), "command")
expect(handler.canHandle(context)).toBe(false)
})
})
describe("handle", () => {
it("should return read kind", () => {
const context = createContext(createToolMessage("read_file", { path: "test.ts" }), "tool")
const result = handler.handle(context)
expect(result.initialUpdate).toMatchObject({
sessionUpdate: "tool_call",
kind: "read",
status: "in_progress",
})
})
it("should include completion update", () => {
const context = createContext(createToolMessage("read_file", { path: "test.ts" }), "tool")
const result = handler.handle(context)
expect(result.completionUpdate).toMatchObject({
sessionUpdate: "tool_call_update",
status: "completed",
})
})
})
})
// =============================================================================
// SearchToolHandler Tests
// =============================================================================
describe("SearchToolHandler", () => {
const handler = new SearchToolHandler()
describe("canHandle", () => {
it("should handle search_files tool", () => {
const context = createContext(createToolMessage("search_files", { regex: "test" }), "tool")
expect(handler.canHandle(context)).toBe(true)
})
it("should handle searchFiles tool", () => {
const context = createContext(createToolMessage("searchFiles", { regex: "test" }), "tool")
expect(handler.canHandle(context)).toBe(true)
})
it("should handle codebase_search tool", () => {
const context = createContext(createToolMessage("codebase_search", { query: "test" }), "tool")
expect(handler.canHandle(context)).toBe(true)
})
it("should handle grep tool", () => {
const context = createContext(createToolMessage("grep", { pattern: "test" }), "tool")
expect(handler.canHandle(context)).toBe(true)
})
it("should not handle custom tool with search in name (exact matching)", () => {
const context = createContext(createToolMessage("custom_search_tool", {}), "tool")
// With exact matching, "custom_search_tool" won't match the search category
expect(handler.canHandle(context)).toBe(false)
})
it("should not handle read_file tool", () => {
const context = createContext(createToolMessage("read_file", { path: "test.ts" }), "tool")
expect(handler.canHandle(context)).toBe(false)
})
})
describe("handle", () => {
it("should return search kind", () => {
const context = createContext(createToolMessage("search_files", { regex: "test" }), "tool")
const result = handler.handle(context)
expect(result.initialUpdate).toMatchObject({
sessionUpdate: "tool_call",
kind: "search",
status: "in_progress",
})
})
it("should format search results in completion", () => {
const searchResults = "Found 5 results.\n\n# src/file1.ts\n 1 | match\n\n# src/file2.ts\n 2 | match"
const context = createContext(createToolMessage("search_files", { content: searchResults }), "tool")
const result = handler.handle(context)
expect(result.completionUpdate).toMatchObject({
sessionUpdate: "tool_call_update",
status: "completed",
})
// Content should be formatted - cast to access content property
const completionUpdate = result.completionUpdate as Record<string, unknown>
expect(completionUpdate?.content).toBeDefined()
})
})
})
// =============================================================================
// ListFilesToolHandler Tests
// =============================================================================
describe("ListFilesToolHandler", () => {
const handler = new ListFilesToolHandler()
describe("canHandle", () => {
it("should handle list_files tool", () => {
const context = createContext(createToolMessage("list_files", { path: "src" }), "tool")
expect(handler.canHandle(context)).toBe(true)
})
it("should handle listFiles tool", () => {
const context = createContext(createToolMessage("listFiles", { path: "src" }), "tool")
expect(handler.canHandle(context)).toBe(true)
})
it("should handle listFilesTopLevel tool", () => {
const context = createContext(createToolMessage("listFilesTopLevel", { path: "src" }), "tool")
expect(handler.canHandle(context)).toBe(true)
})
it("should handle listFilesRecursive tool", () => {
const context = createContext(createToolMessage("listFilesRecursive", { path: "src" }), "tool")
expect(handler.canHandle(context)).toBe(true)
})
it("should not handle read_file tool", () => {
const context = createContext(createToolMessage("read_file", { path: "test.ts" }), "tool")
expect(handler.canHandle(context)).toBe(false)
})
})
describe("handle", () => {
it("should return read kind", () => {
const context = createContext(createToolMessage("list_files", { path: "src" }), "tool")
const result = handler.handle(context)
expect(result.initialUpdate).toMatchObject({
sessionUpdate: "tool_call",
kind: "read",
status: "in_progress",
})
})
})
})
// =============================================================================
// DefaultToolHandler Tests
// =============================================================================
describe("DefaultToolHandler", () => {
const handler = new DefaultToolHandler()
describe("canHandle", () => {
it("should always return true", () => {
const context1 = createContext(createToolMessage("unknown_tool", {}), "tool")
const context2 = createContext(createToolMessage("custom_operation", {}), "tool")
const context3 = createContext(createToolMessage("any_tool", {}), "browser_action_launch")
expect(handler.canHandle(context1)).toBe(true)
expect(handler.canHandle(context2)).toBe(true)
expect(handler.canHandle(context3)).toBe(true)
})
})
describe("handle", () => {
it("should map tool kind from tool name (exact matching)", () => {
// Use exact tool name from TOOL_CATEGORIES.think
const context = createContext(createToolMessage("think", {}), "tool")
const result = handler.handle(context)
expect(result.initialUpdate).toMatchObject({
sessionUpdate: "tool_call",
kind: "think",
status: "in_progress",
})
})
it("should return other kind for unknown tools (exact matching)", () => {
// Tool names that don't exactly match categories return "other"
const context = createContext(createToolMessage("think_about_it", {}), "tool")
const result = handler.handle(context)
expect(result.initialUpdate).toMatchObject({
sessionUpdate: "tool_call",
kind: "other",
status: "in_progress",
})
})
it("should include completion update", () => {
const context = createContext(createToolMessage("custom_tool", {}), "tool")
const result = handler.handle(context)
expect(result.completionUpdate).toMatchObject({
sessionUpdate: "tool_call_update",
status: "completed",
})
})
})
})
// =============================================================================
// ToolHandlerRegistry Tests
// =============================================================================
describe("ToolHandlerRegistry", () => {
describe("getHandler", () => {
const registry = new ToolHandlerRegistry()
it("should return CommandToolHandler for command asks", () => {
const context = createContext(createToolMessage("execute_command", {}), "command")
const handler = registry.getHandler(context)
expect(handler).toBeInstanceOf(CommandToolHandler)
})
it("should return FileEditToolHandler for edit tools", () => {
const context = createContext(createToolMessage("write_to_file", { path: "test.ts" }), "tool")
const handler = registry.getHandler(context)
expect(handler).toBeInstanceOf(FileEditToolHandler)
})
it("should return FileReadToolHandler for read tools", () => {
const context = createContext(createToolMessage("read_file", { path: "test.ts" }), "tool")
const handler = registry.getHandler(context)
expect(handler).toBeInstanceOf(FileReadToolHandler)
})
it("should return SearchToolHandler for search tools", () => {
const context = createContext(createToolMessage("search_files", {}), "tool")
const handler = registry.getHandler(context)
expect(handler).toBeInstanceOf(SearchToolHandler)
})
it("should return ListFilesToolHandler for list tools", () => {
const context = createContext(createToolMessage("list_files", {}), "tool")
const handler = registry.getHandler(context)
expect(handler).toBeInstanceOf(ListFilesToolHandler)
})
it("should return DefaultToolHandler for unknown tools", () => {
const context = createContext(createToolMessage("unknown_tool", {}), "tool")
const handler = registry.getHandler(context)
expect(handler).toBeInstanceOf(DefaultToolHandler)
})
})
describe("handle", () => {
const registry = new ToolHandlerRegistry()
it("should dispatch to correct handler and return result", () => {
const context = createContext(createToolMessage("execute_command", {}), "command")
const result = registry.handle(context)
expect(result.initialUpdate).toMatchObject({
sessionUpdate: "tool_call",
kind: "execute",
status: "in_progress",
})
expect(result.trackAsPendingCommand).toBeDefined()
})
})
describe("createContext", () => {
it("should create a valid context", () => {
const message = createToolMessage("read_file", { path: "test.ts" })
const context = ToolHandlerRegistry.createContext(message, "tool", "/workspace", testLogger)
expect(context.message).toBe(message)
expect(context.ask).toBe("tool")
expect(context.workspacePath).toBe("/workspace")
expect(context.toolInfo).toBeDefined()
expect(context.toolInfo?.name).toBe("read_file")
expect(context.logger).toBe(testLogger)
})
})
describe("custom handlers", () => {
it("should accept custom handler list", () => {
const customHandler = new DefaultToolHandler()
const registry = new ToolHandlerRegistry([customHandler])
const context = createContext(createToolMessage("any_tool", {}), "command")
const handler = registry.getHandler(context)
expect(handler).toBe(customHandler)
})
})
})

View file

@ -0,0 +1,502 @@
import type { ClineMessage } from "@roo-code/types"
import {
translateToAcpUpdate,
parseToolFromMessage,
mapToolKind,
isPermissionAsk,
isCompletionAsk,
extractPromptText,
extractPromptImages,
createPermissionOptions,
buildToolCallFromMessage,
} from "../translator.js"
describe("translateToAcpUpdate", () => {
it("should translate text say messages", () => {
const message: ClineMessage = {
ts: 12345,
type: "say",
say: "text",
text: "Hello, world!",
}
const result = translateToAcpUpdate(message)
expect(result).toEqual({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "Hello, world!" },
})
})
it("should translate reasoning say messages", () => {
const message: ClineMessage = {
ts: 12345,
type: "say",
say: "reasoning",
text: "I'm thinking about this...",
}
const result = translateToAcpUpdate(message)
expect(result).toEqual({
sessionUpdate: "agent_thought_chunk",
content: { type: "text", text: "I'm thinking about this..." },
})
})
it("should translate error say messages", () => {
const message: ClineMessage = {
ts: 12345,
type: "say",
say: "error",
text: "Something went wrong",
}
const result = translateToAcpUpdate(message)
expect(result).toEqual({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "Error: Something went wrong" },
})
})
it("should return null for completion_result", () => {
const message: ClineMessage = {
ts: 12345,
type: "say",
say: "completion_result",
text: "Task completed",
}
const result = translateToAcpUpdate(message)
expect(result).toBeNull()
})
it("should return null for ask messages", () => {
const message: ClineMessage = {
ts: 12345,
type: "ask",
ask: "tool",
text: "Approve this tool?",
}
const result = translateToAcpUpdate(message)
expect(result).toBeNull()
})
})
describe("parseToolFromMessage", () => {
it("should parse JSON tool messages", () => {
const message: ClineMessage = {
ts: 12345,
type: "say",
say: "shell_integration_warning",
text: JSON.stringify({
tool: "read_file",
path: "/test/file.txt",
}),
}
const result = parseToolFromMessage(message)
expect(result).not.toBeNull()
expect(result?.name).toBe("read_file")
// Title is now human-readable based on tool name and filename
expect(result?.title).toBe("Read file.txt")
expect(result?.locations).toHaveLength(1)
expect(result!.locations[0]!.path).toBe("/test/file.txt")
})
it("should extract tool name from text content", () => {
const message: ClineMessage = {
ts: 12345,
type: "say",
say: "shell_integration_warning",
text: "Using write_file to create the file",
}
const result = parseToolFromMessage(message)
expect(result).not.toBeNull()
expect(result?.name).toBe("write_file")
})
it("should return null for empty text", () => {
const message: ClineMessage = {
ts: 12345,
type: "say",
say: "shell_integration_warning",
text: "",
}
const result = parseToolFromMessage(message)
expect(result).toBeNull()
})
})
describe("mapToolKind", () => {
it("should map read operations", () => {
// Uses exact matching with normalized tool names from TOOL_CATEGORIES
expect(mapToolKind("read_file")).toBe("read")
expect(mapToolKind("readFile")).toBe("read")
})
it("should map list_files to read kind", () => {
// list operations are read-like in the ACP protocol
expect(mapToolKind("list_files")).toBe("read")
expect(mapToolKind("listFiles")).toBe("read")
expect(mapToolKind("listFilesTopLevel")).toBe("read")
expect(mapToolKind("listFilesRecursive")).toBe("read")
})
it("should map edit operations", () => {
expect(mapToolKind("write_to_file")).toBe("edit")
expect(mapToolKind("apply_diff")).toBe("edit")
expect(mapToolKind("modify_file")).toBe("edit")
expect(mapToolKind("create_file")).toBe("edit")
expect(mapToolKind("newFileCreated")).toBe("edit")
expect(mapToolKind("editedExistingFile")).toBe("edit")
})
it("should map delete operations", () => {
expect(mapToolKind("delete_file")).toBe("delete")
expect(mapToolKind("deleteFile")).toBe("delete")
expect(mapToolKind("remove_file")).toBe("delete")
expect(mapToolKind("removeFile")).toBe("delete")
})
it("should map move operations", () => {
expect(mapToolKind("move_file")).toBe("move")
expect(mapToolKind("moveFile")).toBe("move")
expect(mapToolKind("rename_file")).toBe("move")
expect(mapToolKind("renameFile")).toBe("move")
})
it("should map search operations", () => {
expect(mapToolKind("search_files")).toBe("search")
expect(mapToolKind("searchFiles")).toBe("search")
expect(mapToolKind("codebase_search")).toBe("search")
expect(mapToolKind("codebaseSearch")).toBe("search")
expect(mapToolKind("grep")).toBe("search")
expect(mapToolKind("ripgrep")).toBe("search")
})
it("should map execute operations", () => {
expect(mapToolKind("execute_command")).toBe("execute")
expect(mapToolKind("executeCommand")).toBe("execute")
expect(mapToolKind("run_command")).toBe("execute")
expect(mapToolKind("runCommand")).toBe("execute")
})
it("should map think operations", () => {
expect(mapToolKind("think")).toBe("think")
expect(mapToolKind("reason")).toBe("think")
expect(mapToolKind("plan")).toBe("think")
expect(mapToolKind("analyze")).toBe("think")
})
it("should map fetch operations", () => {
// Note: browser_action is NOT mapped to fetch because browser tools are disabled in CLI
expect(mapToolKind("fetch")).toBe("fetch")
expect(mapToolKind("web_request")).toBe("fetch")
expect(mapToolKind("webRequest")).toBe("fetch")
expect(mapToolKind("http_get")).toBe("fetch")
expect(mapToolKind("httpGet")).toBe("fetch")
expect(mapToolKind("http_post")).toBe("fetch")
expect(mapToolKind("url_fetch")).toBe("fetch")
})
it("should map browser_action to other (browser tools disabled in CLI)", () => {
// browser_action intentionally maps to "other" because browser tools are disabled in CLI mode
expect(mapToolKind("browser_action")).toBe("other")
})
it("should map switch_mode operations", () => {
expect(mapToolKind("switch_mode")).toBe("switch_mode")
expect(mapToolKind("switchMode")).toBe("switch_mode")
expect(mapToolKind("set_mode")).toBe("switch_mode")
expect(mapToolKind("setMode")).toBe("switch_mode")
})
it("should return other for unknown operations", () => {
expect(mapToolKind("unknown_tool")).toBe("other")
expect(mapToolKind("custom_operation")).toBe("other")
// Tool names that don't exactly match categories also return other
expect(mapToolKind("inspect_code")).toBe("other")
expect(mapToolKind("get_info")).toBe("other")
})
})
describe("isPermissionAsk", () => {
it("should return true for permission-required asks", () => {
expect(isPermissionAsk("tool")).toBe(true)
expect(isPermissionAsk("command")).toBe(true)
expect(isPermissionAsk("browser_action_launch")).toBe(true)
expect(isPermissionAsk("use_mcp_server")).toBe(true)
})
it("should return false for other asks", () => {
expect(isPermissionAsk("followup")).toBe(false)
expect(isPermissionAsk("completion_result")).toBe(false)
expect(isPermissionAsk("api_req_failed")).toBe(false)
})
})
describe("isCompletionAsk", () => {
it("should return true for completion asks", () => {
expect(isCompletionAsk("completion_result")).toBe(true)
expect(isCompletionAsk("api_req_failed")).toBe(true)
expect(isCompletionAsk("mistake_limit_reached")).toBe(true)
})
it("should return false for other asks", () => {
expect(isCompletionAsk("tool")).toBe(false)
expect(isCompletionAsk("followup")).toBe(false)
expect(isCompletionAsk("command")).toBe(false)
})
})
describe("extractPromptText", () => {
it("should extract text from text blocks", () => {
const prompt = [
{ type: "text" as const, text: "Hello" },
{ type: "text" as const, text: "World" },
]
const result = extractPromptText(prompt)
expect(result).toBe("Hello\nWorld")
})
it("should handle resource_link blocks", () => {
const prompt = [
{ type: "text" as const, text: "Check this file:" },
{
type: "resource_link" as const,
uri: "file:///test/file.txt",
name: "file.txt",
mimeType: "text/plain",
},
]
const result = extractPromptText(prompt)
expect(result).toContain("@file:///test/file.txt")
})
it("should handle image blocks", () => {
const prompt = [
{ type: "text" as const, text: "Look at this:" },
{
type: "image" as const,
data: "base64data",
mimeType: "image/png",
},
]
const result = extractPromptText(prompt)
expect(result).toContain("[image content]")
})
})
describe("extractPromptImages", () => {
it("should extract image data", () => {
const prompt = [
{ type: "text" as const, text: "Check this:" },
{
type: "image" as const,
data: "base64data1",
mimeType: "image/png",
},
{
type: "image" as const,
data: "base64data2",
mimeType: "image/jpeg",
},
]
const result = extractPromptImages(prompt)
expect(result).toHaveLength(2)
expect(result[0]).toBe("base64data1")
expect(result[1]).toBe("base64data2")
})
it("should return empty array when no images", () => {
const prompt = [{ type: "text" as const, text: "No images here" }]
const result = extractPromptImages(prompt)
expect(result).toHaveLength(0)
})
})
describe("createPermissionOptions", () => {
it("should include always allow for tool asks", () => {
const options = createPermissionOptions("tool")
expect(options).toHaveLength(3)
expect(options[0]!.optionId).toBe("allow_always")
expect(options[0]!.kind).toBe("allow_always")
})
it("should include always allow for command asks", () => {
const options = createPermissionOptions("command")
expect(options).toHaveLength(3)
expect(options[0]!.optionId).toBe("allow_always")
})
it("should have basic options for other asks", () => {
const options = createPermissionOptions("browser_action_launch")
expect(options).toHaveLength(2)
expect(options[0]!.optionId).toBe("allow")
expect(options[1]!.optionId).toBe("reject")
})
})
describe("buildToolCallFromMessage", () => {
it("should build a valid tool call", () => {
const message: ClineMessage = {
ts: 12345,
type: "say",
say: "shell_integration_warning",
text: JSON.stringify({
tool: "read_file",
path: "/test/file.txt",
}),
}
const result = buildToolCallFromMessage(message)
// Tool ID is deterministic based on message timestamp for debugging
expect(result.toolCallId).toBe("tool-12345")
// Title is now human-readable based on tool name and filename
expect(result.title).toBe("Read file.txt")
expect(result.kind).toBe("read")
expect(result.status).toBe("pending")
expect(result.locations).toHaveLength(1)
})
it("should handle messages without text", () => {
const message: ClineMessage = {
ts: 12345,
type: "say",
say: "shell_integration_warning",
}
const result = buildToolCallFromMessage(message)
// Tool ID is deterministic based on message timestamp for debugging
expect(result.toolCallId).toBe("tool-12345")
expect(result.kind).toBe("other")
})
it("should not include search path as location for search tools", () => {
const message: ClineMessage = {
ts: 12345,
type: "say",
say: "shell_integration_warning",
text: JSON.stringify({
tool: "searchFiles",
path: "src",
regex: ".*",
filePattern: "*utils*",
}),
}
const result = buildToolCallFromMessage(message, "/workspace/project")
// Search path "src" should NOT become a location
expect(result.kind).toBe("search")
expect(result.locations).toHaveLength(0)
})
it("should extract file paths from search results content", () => {
const message: ClineMessage = {
ts: 12345,
type: "say",
say: "shell_integration_warning",
text: JSON.stringify({
tool: "search_files",
path: "cli",
regex: ".*",
content:
"Found 2 results.\n\n# src/utils/helpers.ts\n 1 | export function helper() {}\n\n# src/components/Button.tsx\n 5 | const Button = () => {}",
}),
}
const result = buildToolCallFromMessage(message, "/workspace")
expect(result.kind).toBe("search")
// Should extract file paths from the search results
expect(result.locations!).toHaveLength(2)
expect(result.locations![0]!.path).toBe("/workspace/src/utils/helpers.ts")
expect(result.locations![1]!.path).toBe("/workspace/src/components/Button.tsx")
})
it("should include directory path for list_files tools", () => {
const message: ClineMessage = {
ts: 12345,
type: "say",
say: "shell_integration_warning",
text: JSON.stringify({
tool: "list_files",
path: "src/components",
}),
}
const result = buildToolCallFromMessage(message, "/workspace")
expect(result.kind).toBe("read")
// Directory path should be included for list_files
expect(result.locations!).toHaveLength(1)
expect(result.locations![0]!.path).toBe("/workspace/src/components")
})
it("should handle codebase_search tool", () => {
const message: ClineMessage = {
ts: 12345,
type: "say",
say: "shell_integration_warning",
text: JSON.stringify({
tool: "codebase_search",
query: "find all utils",
path: ".",
content: "# lib/utils.js\n 10 | function util() {}",
}),
}
const result = buildToolCallFromMessage(message, "/project")
expect(result.kind).toBe("search")
expect(result.locations!).toHaveLength(1)
expect(result.locations![0]!.path).toBe("/project/lib/utils.js")
})
it("should deduplicate file paths in search results", () => {
const message: ClineMessage = {
ts: 12345,
type: "say",
say: "shell_integration_warning",
text: JSON.stringify({
tool: "searchFiles",
path: "src",
content: "# src/file.ts\n 1 | match1\n\n# src/file.ts\n 5 | match2\n\n# src/other.ts\n 3 | match3",
}),
}
const result = buildToolCallFromMessage(message, "/workspace")
// Should deduplicate: src/file.ts appears twice but should only be included once
expect(result.locations!).toHaveLength(2)
expect(result.locations![0]!.path).toBe("/workspace/src/file.ts")
expect(result.locations![1]!.path).toBe("/workspace/src/other.ts")
})
})

243
apps/cli/src/acp/agent.ts Normal file
View file

@ -0,0 +1,243 @@
/**
* RooCodeAgent
*
* Implements the ACP Agent interface to expose Roo Code as an ACP-compatible agent.
* This allows ACP clients like Zed to use Roo Code as their AI coding assistant.
*/
import {
type Agent,
type ClientCapabilities,
type CancelNotification,
// Requests + Responses
type InitializeRequest,
type InitializeResponse,
type NewSessionRequest,
type NewSessionResponse,
type SetSessionModeRequest,
type SetSessionModeResponse,
type SetSessionModelRequest,
type SetSessionModelResponse,
type AuthenticateRequest,
type AuthenticateResponse,
type PromptRequest,
type PromptResponse,
// Classes
AgentSideConnection,
RequestError,
// Constants
PROTOCOL_VERSION,
} from "@agentclientprotocol/sdk"
import { randomUUID } from "node:crypto"
import { DEFAULT_FLAGS } from "@/types/constants.js"
import { envVarMap } from "@/lib/utils/provider.js"
import { login, status } from "@/commands/auth/index.js"
import { AVAILABLE_MODES, DEFAULT_MODELS } from "./types.js"
import { type AcpSessionOptions, AcpSession } from "./session.js"
import { acpLog } from "./logger.js"
import { ModelService, createModelService } from "./model-service.js"
/**
* RooCodeAgent implements the ACP Agent interface.
*
* It manages multiple sessions, each with its own ExtensionHost instance,
* and handles protocol-level operations like initialization and authentication.
*/
export class RooCodeAgent implements Agent {
private sessions: Map<string, AcpSession> = new Map()
private clientCapabilities: ClientCapabilities | undefined
private isAuthenticated = false
private readonly modelService: ModelService
constructor(
private readonly options: AcpSessionOptions,
private readonly connection: AgentSideConnection,
) {
acpLog.info("Agent", `RooCodeAgent constructor: connection=${connection}`)
this.modelService = createModelService({ apiKey: options.apiKey })
}
async initialize(params: InitializeRequest): Promise<InitializeResponse> {
acpLog.request("initialize", params)
this.clientCapabilities = params.clientCapabilities
// Check if already authenticated via environment or existing credentials.
const { authenticated } = await status({ verbose: false })
acpLog.debug("Agent", `Auth status: ${authenticated ? "authenticated" : "not authenticated"}`)
return {
protocolVersion: PROTOCOL_VERSION,
authMethods: [
{
id: "roo",
name: "Sign in with Roo Code Cloud",
description: `Sign in with your Roo Code Cloud account or BYOK by exporting an API key Environment Variable (${Object.values(envVarMap).join(", ")})`,
},
],
agentCapabilities: {
loadSession: false,
promptCapabilities: {
image: true,
embeddedContext: true,
},
},
}
}
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
acpLog.request("newSession", params)
// @TODO: Detect other env vars for different provider and choose
// the correct provider or throw.
if (!this.isAuthenticated) {
const apiKey = this.options.apiKey || process.env.OPENROUTER_API_KEY
if (!apiKey) {
acpLog.error("Agent", "newSession failed: not authenticated and no API key")
throw RequestError.authRequired()
}
this.isAuthenticated = true
}
const sessionId = randomUUID()
const provider = this.options.provider || "openrouter"
const apiKey = this.options.apiKey || process.env.OPENROUTER_API_KEY
const mode = this.options.mode || AVAILABLE_MODES[0]!.id
const model = this.options.model || DEFAULT_FLAGS.model
const session = await AcpSession.create({
sessionId,
cwd: params.cwd,
connection: this.connection,
options: {
extensionPath: this.options.extensionPath,
provider,
apiKey,
model,
mode,
},
deps: {
logger: acpLog,
},
})
this.sessions.set(sessionId, session)
const availableModels = await this.modelService.fetchAvailableModels()
const modelExists = availableModels.some((m) => m.modelId === model)
const response: NewSessionResponse = {
sessionId,
modes: { currentModeId: mode, availableModes: AVAILABLE_MODES },
models: {
availableModels,
currentModelId: modelExists ? model : DEFAULT_MODELS[0]!.modelId,
},
}
acpLog.response("newSession", response)
return response
}
async setSessionMode(params: SetSessionModeRequest): Promise<SetSessionModeResponse | void> {
acpLog.request("setSessionMode", params)
const session = this.sessions.get(params.sessionId)
if (!session) {
acpLog.error("Agent", `setSessionMode failed: session not found: ${params.sessionId}`)
throw RequestError.invalidParams(undefined, `Session not found: ${params.sessionId}`)
}
const mode = AVAILABLE_MODES.find((m) => m.id === params.modeId)
if (!mode) {
acpLog.error("Agent", `setSessionMode failed: unknown mode: ${params.modeId}`)
throw RequestError.invalidParams(undefined, `Unknown mode: ${params.modeId}`)
}
session.setMode(params.modeId)
acpLog.response("setSessionMode", {})
return {}
}
async unstable_setSessionModel?(params: SetSessionModelRequest): Promise<SetSessionModelResponse | void> {
acpLog.request("setSessionMode", params)
const session = this.sessions.get(params.sessionId)
if (!session) {
acpLog.error("Agent", `unstable_setSessionModel failed: session not found: ${params.sessionId}`)
throw RequestError.invalidParams(undefined, `Session not found: ${params.sessionId}`)
}
const availableModels = await this.modelService.fetchAvailableModels()
const modelExists = availableModels.some((m) => m.modelId === params.modelId)
if (!modelExists) {
acpLog.error("Agent", `unstable_setSessionModel failed: model not found: ${params.modelId}`)
throw RequestError.invalidParams(undefined, `Model not found: ${params.modelId}`)
}
session.setModel(params.modelId)
acpLog.response("unstable_setSessionModel", {})
return {}
}
async authenticate(params: AuthenticateRequest): Promise<AuthenticateResponse | void> {
acpLog.request("authenticate", params)
if (params.methodId !== "roo") {
throw RequestError.invalidParams(undefined, `Invalid auth method: ${params.methodId}`)
}
const result = await login({ verbose: false })
if (!result.success) {
throw RequestError.authRequired(undefined, "Failed to authenticate with Roo Code Cloud")
}
this.isAuthenticated = true
acpLog.response("authenticate", {})
return {}
}
async prompt(params: PromptRequest): Promise<PromptResponse> {
acpLog.request("prompt", {
sessionId: params.sessionId,
promptLength: params.prompt?.length ?? 0,
})
const session = this.sessions.get(params.sessionId)
if (!session) {
acpLog.error("Agent", `prompt failed: session not found: ${params.sessionId}`)
throw RequestError.invalidParams(undefined, `Session not found: ${params.sessionId}`)
}
const response = await session.prompt(params)
acpLog.response("prompt", response)
return response
}
async cancel(params: CancelNotification): Promise<void> {
acpLog.request("cancel", { sessionId: params.sessionId })
const session = this.sessions.get(params.sessionId)
if (session) {
session.cancel()
acpLog.info("Agent", `Cancelled session: ${params.sessionId}`)
} else {
acpLog.warn("Agent", `cancel: session not found: ${params.sessionId}`)
}
}
async dispose(): Promise<void> {
acpLog.info("Agent", `Disposing ${this.sessions.size} sessions`)
const disposals = Array.from(this.sessions.values()).map((session) => session.dispose())
await Promise.all(disposals)
this.sessions.clear()
acpLog.info("Agent", "All sessions disposed")
}
}

View file

@ -0,0 +1,243 @@
/**
* CommandStreamManager
*
* Manages streaming of command execution output with code fence wrapping.
* Handles both live command execution events and final command_output messages.
*/
import type { ClineMessage } from "@roo-code/types"
import type { IDeltaTracker, IAcpLogger, SendUpdateFn } from "./interfaces.js"
// =============================================================================
// Types
// =============================================================================
/**
* Information about a pending command execution.
*/
export interface PendingCommand {
toolCallId: string
command: string
ts: number
}
/**
* Options for creating a CommandStreamManager.
*/
export interface CommandStreamManagerOptions {
/** Delta tracker for tracking already-sent content */
deltaTracker: IDeltaTracker
/** Callback to send session updates */
sendUpdate: SendUpdateFn
/** Logger instance */
logger: IAcpLogger
}
// =============================================================================
// CommandStreamManager Class
// =============================================================================
/**
* Manages command output streaming with proper code fence wrapping.
*
* Responsibilities:
* - Track pending command tool calls
* - Handle live command execution output (with code fences)
* - Handle final command_output messages
* - Send tool_call_update notifications
*/
export class CommandStreamManager {
/**
* Track pending command tool calls for the "Run Command" UI.
* Maps tool call ID to command info.
*/
private pendingCommandCalls: Map<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 })
}
/**
* Handle a command_output message from the extension.
* This handles the final tool_call_update for completion, plus the closing fence.
*
* NOTE: Streaming output is handled by handleExecutionOutput().
* This method handles:
* 1. Sending the closing code fence as agent_message_chunk (if streaming occurred)
* 2. Sending the final tool_call_update with status "completed"
*/
handleCommandOutput(message: ClineMessage): void {
const output = message.text || ""
const isPartial = message.partial === true
// Skip partial updates - streaming is handled by handleExecutionOutput().
if (isPartial) {
return
}
// Handle completion - update the tool call UI.
const pendingCall = this.findMostRecentPendingCommand()
if (pendingCall) {
// Send closing code fence as agent_message_chunk if we had streaming output.
const hadStreamingOutput = this.commandCodeFencesSent.has(pendingCall.toolCallId)
if (hadStreamingOutput) {
this.sendUpdate({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "```\n" },
})
this.commandCodeFencesSent.delete(pendingCall.toolCallId)
}
// Command completed - send final tool_call_update with completed status.
// Note: Zed doesn't display tool_call_update content, so we just mark it complete.
this.sendUpdate({
sessionUpdate: "tool_call_update",
toolCallId: pendingCall.toolCallId,
status: "completed",
rawOutput: { output },
})
this.pendingCommandCalls.delete(pendingCall.toolCallId)
}
}
/**
* Handle streaming command execution output (live terminal output).
* This provides real-time output during command execution.
*
* Sends output as agent_message_chunk messages for Zed visibility.
* The tool_call UI is updated separately in session-event-handler.
*
* Output is wrapped in markdown code blocks:
* - Opening fence ``` sent on first chunk
* - Subsequent chunks sent as-is (deltas only)
* - Closing fence ``` sent in handleCommandOutput()
*
* Uses executionId toolCallId mapping for robust routing.
*/
handleExecutionOutput(executionId: string, output: string): void {
// Find or establish the toolCallId for this executionId.
let toolCallId = this.executionToToolCallId.get(executionId)
if (!toolCallId) {
// First output for this executionId - establish the mapping.
const pendingCall = this.findMostRecentPendingCommand()
if (!pendingCall) {
return
}
toolCallId = pendingCall.toolCallId
this.executionToToolCallId.set(executionId, toolCallId)
}
// Use executionId as the message key for delta tracking.
const delta = this.deltaTracker.getDelta(executionId, output)
if (!delta) {
return
}
// Send opening code fence on first chunk
const isFirstChunk = !this.commandCodeFencesSent.has(toolCallId)
if (isFirstChunk) {
this.commandCodeFencesSent.add(toolCallId)
this.sendUpdate({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "```\n" },
})
}
// Send the delta as agent_message_chunk for Zed visibility.
this.sendUpdate({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: delta },
})
}
/**
* Check if a message is a command_output message that this manager handles.
*/
isCommandOutputMessage(message: ClineMessage): boolean {
return message.type === "say" && message.say === "command_output"
}
/**
* Reset state for a new prompt.
* Call when starting a new prompt to clear all pending state.
*/
reset(): void {
// Clear all pending commands - any from previous prompts are now stale
// and would cause duplicate completion messages if not cleaned up.
this.pendingCommandCalls.clear()
this.commandCodeFencesSent.clear()
this.executionToToolCallId.clear()
}
/**
* Get the number of pending commands (for testing/debugging).
*/
getPendingCommandCount(): number {
return this.pendingCommandCalls.size
}
/**
* Check if there are any open code fences (for testing/debugging).
*/
hasOpenCodeFences(): boolean {
return this.commandCodeFencesSent.size > 0
}
/**
* Find the most recent pending command call.
*/
private findMostRecentPendingCommand(): PendingCommand | undefined {
let pendingCall: PendingCommand | undefined
for (const [, call] of this.pendingCommandCalls) {
if (!pendingCall || call.ts > pendingCall.ts) {
pendingCall = call
}
}
return pendingCall
}
}

View file

@ -0,0 +1,221 @@
/**
* Content Formatter
*
* Provides content formatting for ACP UI display.
*
* This module offers two usage patterns:
*
* 1. **Direct function imports** (preferred for simple use cases):
* ```ts
* import { formatSearchResults, wrapInCodeBlock } from './content-formatter.js'
* const formatted = wrapInCodeBlock(formatSearchResults(content))
* ```
*
* 2. **Class-based DI** (for dependency injection in tests):
* ```ts
* import { ContentFormatter, type IContentFormatter } from './content-formatter.js'
* const formatter: IContentFormatter = new ContentFormatter()
* ```
*/
import type { IContentFormatter } from "./interfaces.js"
import {
formatSearchResults,
formatReadContent,
wrapInCodeBlock,
isUserEcho,
readFileContent,
readFileContentAsync,
extractContentFromParams,
type FormatConfig,
DEFAULT_FORMAT_CONFIG,
} from "./utils/index.js"
import { acpLog } from "./logger.js"
// =============================================================================
// Direct Exports (Preferred)
// =============================================================================
// Re-export utility functions for direct use
export { formatSearchResults, formatReadContent, wrapInCodeBlock, isUserEcho }
// =============================================================================
// Tool Result Formatting
// =============================================================================
/**
* Format tool result content based on the tool kind.
*
* Applies appropriate formatting (search summary, truncation, code blocks)
* based on the tool type.
*
* @param kind - The tool kind (search, read, etc.)
* @param content - The raw content to format
* @param config - Optional formatting configuration
* @returns Formatted content
*/
export function formatToolResult(kind: string, content: string, config: FormatConfig = DEFAULT_FORMAT_CONFIG): string {
switch (kind) {
case "search":
return wrapInCodeBlock(formatSearchResults(content))
case "read":
return wrapInCodeBlock(formatReadContent(content, config))
default:
return content
}
}
/**
* Extract file content for readFile operations.
*
* For readFile tools, the rawInput.content field contains the file PATH
* (not the contents), so we need to read the actual file.
*
* @param rawInput - Tool parameters
* @param workspacePath - Workspace path for resolving relative paths
* @returns File content or error message, or undefined if no path
*/
export function extractFileContent(rawInput: Record<string, unknown>, workspacePath: string): string | undefined {
const toolName = (rawInput.tool as string | undefined)?.toLowerCase() || ""
// Only read file content for readFile tools
if (toolName !== "readfile" && toolName !== "read_file") {
return extractContentFromParams(rawInput)
}
// Check if we have a path before attempting to read
const filePath = rawInput.content as string | undefined
const relativePath = rawInput.path as string | undefined
if (!filePath && !relativePath) {
acpLog.warn("ContentFormatter", "readFile tool has no path")
return undefined
}
const result = readFileContent(rawInput, workspacePath)
if (result.ok) {
acpLog.debug("ContentFormatter", `Read file content: ${result.value.length} chars`)
return result.value
} else {
acpLog.error("ContentFormatter", result.error)
return `Error reading file: ${result.error}`
}
}
/**
* Extract file content asynchronously for readFile operations.
*
* @param rawInput - Tool parameters
* @param workspacePath - Workspace path for resolving relative paths
* @returns Promise with file content or error message
*/
export async function extractFileContentAsync(
rawInput: Record<string, unknown>,
workspacePath: string,
): Promise<string | undefined> {
const toolName = (rawInput.tool as string | undefined)?.toLowerCase() || ""
// Only read file content for readFile tools
if (toolName !== "readfile" && toolName !== "read_file") {
return extractContentFromParams(rawInput)
}
// Check if we have a path before attempting to read
const filePath = rawInput.content as string | undefined
const relativePath = rawInput.path as string | undefined
if (!filePath && !relativePath) {
acpLog.warn("ContentFormatter", "readFile tool has no path")
return undefined
}
const result = await readFileContentAsync(rawInput, workspacePath)
if (result.ok) {
acpLog.debug("ContentFormatter", `Read file content: ${result.value.length} chars`)
return result.value
} else {
acpLog.error("ContentFormatter", result.error)
return `Error reading file: ${result.error}`
}
}
// =============================================================================
// ContentFormatter Class (for DI)
// =============================================================================
/**
* Formats content for display in the ACP client UI.
*
* Implements IContentFormatter interface for dependency injection.
* For simple use cases, prefer the direct function exports above.
*
* @example
* ```ts
* // In production code
* const formatter = new ContentFormatter()
*
* // In tests with mock
* const mockFormatter: IContentFormatter = {
* formatToolResult: vi.fn(),
* // ...
* }
* ```
*/
export class ContentFormatter implements IContentFormatter {
private readonly config: FormatConfig
constructor(config?: Partial<FormatConfig>) {
this.config = { ...DEFAULT_FORMAT_CONFIG, ...config }
}
formatToolResult(kind: string, content: string): string {
return formatToolResult(kind, content, this.config)
}
formatSearchResults(content: string): string {
return formatSearchResults(content)
}
formatReadResults(content: string): string {
return formatReadContent(content, this.config)
}
wrapInCodeBlock(content: string, language?: string): string {
return wrapInCodeBlock(content, language)
}
isUserEcho(text: string, promptText: string | null): boolean {
return isUserEcho(text, promptText)
}
/**
* Extract content from rawInput parameters.
* Tries common field names for content.
*/
extractContentFromRawInput(rawInput: Record<string, unknown>): string | undefined {
return extractContentFromParams(rawInput)
}
/**
* Extract file content for readFile operations.
* Delegates to the standalone extractFileContent function.
*/
extractFileContent(rawInput: Record<string, unknown>, workspacePath: string): string | undefined {
return extractFileContent(rawInput, workspacePath)
}
}
// =============================================================================
// Factory Function
// =============================================================================
/**
* Create a new content formatter with optional configuration.
*/
export function createContentFormatter(config?: Partial<FormatConfig>): ContentFormatter {
return new ContentFormatter(config)
}
// =============================================================================
// Type Exports
// =============================================================================
export type { FormatConfig as ContentFormatterConfig }

View file

@ -0,0 +1,71 @@
/**
* DeltaTracker - Utility for computing text deltas
*
* Tracks what portion of text content has been sent and returns only
* the new (delta) portion on subsequent calls. This ensures streaming
* content is sent incrementally without duplication.
*
* @example
* ```ts
* const tracker = new DeltaTracker()
*
* tracker.getDelta("msg1", "Hello") // returns "Hello"
* tracker.getDelta("msg1", "Hello World") // returns " World"
* tracker.getDelta("msg1", "Hello World!") // returns "!"
*
* tracker.reset() // Clear all tracking for new prompt
* ```
*/
export class DeltaTracker {
private positions: Map<string | number, number> = new Map()
/**
* Get the delta (new portion) of text that hasn't been sent yet.
* Automatically updates internal tracking when there's new content.
*
* @param id - Unique identifier for the content stream (e.g., message timestamp)
* @param fullText - The full accumulated text so far
* @returns The new portion of text (delta), or empty string if nothing new
*/
getDelta(id: string | number, fullText: string): string {
const lastPos = this.positions.get(id) ?? 0
const delta = fullText.slice(lastPos)
if (delta.length > 0) {
this.positions.set(id, fullText.length)
}
return delta
}
/**
* Check if there would be a delta without updating tracking.
* Useful for conditional logic without side effects.
*/
peekDelta(id: string | number, fullText: string): string {
const lastPos = this.positions.get(id) ?? 0
return fullText.slice(lastPos)
}
/**
* Reset all tracking. Call when starting a new prompt/session.
*/
reset(): void {
this.positions.clear()
}
/**
* Reset tracking for a specific ID only.
*/
resetId(id: string | number): void {
this.positions.delete(id)
}
/**
* Get the current tracked position for an ID.
* Returns 0 if not tracked.
*/
getPosition(id: string | number): number {
return this.positions.get(id) ?? 0
}
}

View file

@ -0,0 +1,2 @@
export { RooCodeAgent } from "./agent.js"
export { acpLog } from "./logger.js"

View file

@ -0,0 +1,375 @@
/**
* ACP Interfaces
*
* Defines interfaces for dependency injection and testability.
* These interfaces allow for mocking in tests and swapping implementations.
*/
import type * as acp from "@agentclientprotocol/sdk"
// =============================================================================
// Logger Interface
// =============================================================================
/**
* Interface for ACP logging.
* Allows for different logging implementations (file, console, mock for tests).
*/
export interface IAcpLogger {
/**
* Log an info message.
*/
info(component: string, message: string, data?: unknown): void
/**
* Log a debug message.
*/
debug(component: string, message: string, data?: unknown): void
/**
* Log a warning message.
*/
warn(component: string, message: string, data?: unknown): void
/**
* Log an error message.
*/
error(component: string, message: string, data?: unknown): void
/**
* Log an incoming request.
*/
request(method: string, params?: unknown): void
/**
* Log an outgoing response.
*/
response(method: string, result?: unknown): void
/**
* Log an outgoing notification.
*/
notification(method: string, params?: unknown): void
}
// =============================================================================
// Content Formatter Interface
// =============================================================================
/**
* Interface for content formatting operations.
*/
export interface IContentFormatter {
/**
* Format tool result content based on the tool kind.
*/
formatToolResult(kind: string, content: string): string
/**
* Format search results into a clean summary with file list.
*/
formatSearchResults(content: string): string
/**
* Format read results by truncating long file contents.
*/
formatReadResults(content: string): string
/**
* Wrap content in markdown code block for better rendering.
*/
wrapInCodeBlock(content: string, language?: string): string
/**
* Check if a text message is an echo of the user's prompt.
*/
isUserEcho(text: string, promptText: string | null): boolean
}
// =============================================================================
// Session Interface
// =============================================================================
/**
* Interface for ACP Session.
* Enables mocking for tests.
*/
export interface IAcpSession {
/**
* Process a prompt request from the ACP client.
*/
prompt(params: acp.PromptRequest): Promise<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
// =============================================================================
/**
* Events emitted by the extension host.
*/
export interface ExtensionHostEvents {
extensionWebviewMessage: (msg: unknown) => void
}
/**
* Interface for extension host interactions.
*/
export interface IExtensionHost {
/**
* Get the extension client for event handling.
*/
readonly client: IExtensionClient
/**
* Subscribe to extension host events.
*/
on<K extends keyof ExtensionHostEvents>(event: K, handler: ExtensionHostEvents[K]): void
/**
* Unsubscribe from extension host events.
*/
off<K extends keyof ExtensionHostEvents>(event: K, handler: ExtensionHostEvents[K]): void
/**
* 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
}
// =============================================================================
// 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
/**
* Transition to completion with a specific stop reason.
* This allows direct control over the stop reason (e.g., for cancellation).
*/
transitionToComplete(stopReason: acp.StopReason): void
/**
* Cancel the current prompt.
*/
cancel(): void
/**
* Reset to idle state.
*/
reset(): void
}
// =============================================================================
// Stream Manager Interfaces
// =============================================================================
/**
* Callback to send an ACP session update.
*/
export type SendUpdateFn = (update: acp.SessionNotification["update"]) => void
/**
* Options for creating stream managers.
*/
export interface StreamManagerOptions {
/** Delta tracker for tracking already-sent content */
deltaTracker: IDeltaTracker
/** Callback to send session updates */
sendUpdate: SendUpdateFn
/** Logger instance */
logger: IAcpLogger
}
/**
* Interface for command output streaming.
*/
export interface ICommandStreamManager {
trackCommand(toolCallId: string, command: string, ts: number): void
handleCommandOutput(message: unknown): void
handleExecutionOutput(executionId: string, output: string): void
isCommandOutputMessage(message: unknown): boolean
reset(): void
}
/**
* Interface for tool content streaming.
*/
export interface IToolContentStreamManager {
isToolAskMessage(message: unknown): boolean
handleToolContentStreaming(message: unknown): boolean
reset(): void
}
// =============================================================================
// Session Dependencies
// =============================================================================
/**
* Dependencies required for creating an AcpSession.
* Enables dependency injection for testing.
*/
export interface AcpSessionDependencies {
/** Logger instance */
logger?: IAcpLogger
/** Content formatter instance */
contentFormatter?: IContentFormatter
/** Delta tracker factory */
createDeltaTracker?: () => IDeltaTracker
/** Prompt state machine factory */
createPromptStateMachine?: () => IPromptStateMachine
}
// =============================================================================
// Null/Mock Implementations for Testing
// =============================================================================
/**
* No-op logger implementation for testing.
*/
export class NullLogger implements IAcpLogger {
info(_component: string, _message: string, _data?: unknown): void {}
debug(_component: string, _message: string, _data?: unknown): void {}
warn(_component: string, _message: string, _data?: unknown): void {}
error(_component: string, _message: string, _data?: unknown): void {}
request(_method: string, _params?: unknown): void {}
response(_method: string, _result?: unknown): void {}
notification(_method: string, _params?: unknown): void {}
}

188
apps/cli/src/acp/logger.ts Normal file
View file

@ -0,0 +1,188 @@
/**
* ACP Logger
*
* Provides file-based logging for ACP debugging.
* Logs are written to ~/.roo/acp.log by default.
*
* Since ACP uses stdin/stdout for protocol communication,
* we cannot use console.log for debugging. This logger writes
* to a file instead.
*/
import * as fs from "node:fs"
import * as path from "node:path"
import * as os from "node:os"
import type { IAcpLogger } from "./interfaces.js"
// =============================================================================
// Configuration
// =============================================================================
const DEFAULT_LOG_DIR = path.join(os.homedir(), ".roo")
const DEFAULT_LOG_FILE = "acp.log"
const MAX_LOG_SIZE = 10 * 1024 * 1024 // 10MB
// =============================================================================
// Logger Class
// =============================================================================
class AcpLogger implements IAcpLogger {
private logPath: string
private enabled: boolean = true
private stream: fs.WriteStream | null = null
constructor() {
const logDir = process.env.ROO_ACP_LOG_DIR || DEFAULT_LOG_DIR
const logFile = process.env.ROO_ACP_LOG_FILE || DEFAULT_LOG_FILE
this.logPath = path.join(logDir, logFile)
// Disable logging if explicitly set to false
if (process.env.ROO_ACP_LOG === "false") {
this.enabled = false
}
}
/**
* Initialize the logger.
* Creates the log directory if it doesn't exist.
*/
private ensureLogFile(): void {
if (!this.enabled) return
try {
const logDir = path.dirname(this.logPath)
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir, { recursive: true })
}
// Rotate log if too large
if (fs.existsSync(this.logPath)) {
const stats = fs.statSync(this.logPath)
if (stats.size > MAX_LOG_SIZE) {
const rotatedPath = `${this.logPath}.1`
if (fs.existsSync(rotatedPath)) {
fs.unlinkSync(rotatedPath)
}
fs.renameSync(this.logPath, rotatedPath)
}
}
// Open stream if not already open
if (!this.stream) {
this.stream = fs.createWriteStream(this.logPath, { flags: "a" })
}
} catch (_error) {
// Silently disable logging on error
this.enabled = false
}
}
/**
* Format a log message with timestamp and level.
*/
private formatMessage(level: string, component: string, message: string, data?: unknown): string {
const timestamp = new Date().toISOString()
let formatted = `[${timestamp}] [${level}] [${component}] ${message}`
if (data !== undefined) {
try {
const dataStr = JSON.stringify(data, null, 2)
formatted += `\n${dataStr}`
} catch {
formatted += ` [Data: unserializable]`
}
}
return formatted + "\n"
}
/**
* Write a log entry.
*/
private write(level: string, component: string, message: string, data?: unknown): void {
if (!this.enabled) return
this.ensureLogFile()
if (this.stream) {
const formatted = this.formatMessage(level, component, message, data)
this.stream.write(formatted)
}
}
/**
* Log an info message.
*/
info(component: string, message: string, data?: unknown): void {
this.write("INFO", component, message, data)
}
/**
* Log a debug message.
*/
debug(component: string, message: string, data?: unknown): void {
this.write("DEBUG", component, message, data)
}
/**
* Log a warning message.
*/
warn(component: string, message: string, data?: unknown): void {
this.write("WARN", component, message, data)
}
/**
* Log an error message.
*/
error(component: string, message: string, data?: unknown): void {
this.write("ERROR", component, message, data)
}
/**
* Log an incoming request.
*/
request(method: string, params?: unknown): void {
this.write("REQUEST", "ACP", `${method}`, params)
}
/**
* Log an outgoing response.
*/
response(method: string, result?: unknown): void {
this.write("RESPONSE", "ACP", `${method}`, result)
}
/**
* Log an outgoing notification.
*/
notification(method: string, params?: unknown): void {
this.write("NOTIFY", "ACP", `${method}`, params)
}
/**
* Get the log file path.
*/
getLogPath(): string {
return this.logPath
}
/**
* Close the logger.
*/
close(): void {
if (this.stream) {
this.stream.end()
this.stream = null
}
}
}
// =============================================================================
// Singleton Export
// =============================================================================
export const acpLog = new AcpLogger()
// Log startup
acpLog.info("Logger", `ACP logging initialized. Log file: ${acpLog.getLogPath()}`)

View file

@ -0,0 +1,130 @@
/**
* Model Service for ACP
*
* Fetches and caches available models from the Roo Code API.
*/
import type { ModelInfo } from "@agentclientprotocol/sdk"
import { DEFAULT_MODELS } from "./types.js"
import { acpLog } from "./logger.js"
const DEFAULT_API_URL = "https://api.roocode.com"
const DEFAULT_TIMEOUT = 5_000
interface RooModel {
id: string
name: string
description?: string
object?: string
created?: number
owned_by?: string
}
export interface ModelServiceOptions {
/** Base URL for the API (defaults to DEFAULT_API_URL) */
apiUrl?: string
/** API key for authentication */
apiKey?: string
/** Request timeout in milliseconds (defaults to DEFAULT_TIMEOUT) */
timeout?: number
}
/**
* Service for fetching and managing available models.
*/
export class ModelService {
private readonly apiUrl: string
private readonly apiKey?: string
private readonly timeout: number
private cachedModels: ModelInfo[] | null = null
constructor(options: ModelServiceOptions = {}) {
this.apiUrl = options.apiUrl || DEFAULT_API_URL
this.apiKey = options.apiKey
this.timeout = options.timeout || DEFAULT_TIMEOUT
}
/**
* Fetch available models from the API.
* Returns cached models if available, otherwise fetches from API.
* Falls back to default models on error.
*/
async fetchAvailableModels(): Promise<ModelInfo[]> {
if (this.cachedModels) {
return this.cachedModels
}
try {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), this.timeout)
const headers: Record<string, string> = {
"Content-Type": "application/json",
}
if (this.apiKey) {
headers["Authorization"] = `Bearer ${this.apiKey}`
}
const response = await fetch(`${this.apiUrl}/proxy/v1/models`, {
method: "GET",
headers,
signal: controller.signal,
})
clearTimeout(timeoutId)
if (!response.ok) {
acpLog.warn("ModelService", `API returned ${response.status}, using default models`)
this.cachedModels = DEFAULT_MODELS
return this.cachedModels
}
const data = await response.json()
if (!data.data || !Array.isArray(data.data)) {
acpLog.warn("ModelService", "Invalid API response format, using default models")
this.cachedModels = DEFAULT_MODELS
return this.cachedModels
}
this.cachedModels = this.translateModels(data.data)
return this.cachedModels
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
acpLog.warn("ModelService", "Request timed out, using default models")
} else {
acpLog.warn(
"ModelService",
`Failed to fetch models: ${error instanceof Error ? error.message : String(error)}`,
)
}
this.cachedModels = DEFAULT_MODELS
return this.cachedModels
}
}
/**
* Clear the cached models, forcing a refresh on next fetch.
*/
clearCache(): void {
this.cachedModels = null
}
private translateModels(data: RooModel[]): ModelInfo[] {
const models: ModelInfo[] = data
.map(({ id, name, description }) => ({ modelId: id, name, description }))
.sort((a, b) => a.modelId.localeCompare(b.modelId))
return models.length === 0 ? DEFAULT_MODELS : models
}
}
/**
* Create a new ModelService instance.
*/
export function createModelService(options?: ModelServiceOptions): ModelService {
return new ModelService(options)
}

View file

@ -0,0 +1,245 @@
/**
* Prompt State Machine
*
* Manages the lifecycle state of a prompt turn in a type-safe way.
* Replaces boolean flags with explicit state transitions and guards.
*
* State transitions:
* idle -> processing (on startPrompt)
* processing -> idle (on complete/cancel)
* idle -> idle (reset)
*
* This state machine ensures:
* - Only one prompt can be active at a time
* - State transitions are valid
* - Stop reasons are correctly mapped
*/
import type * as acp from "@agentclientprotocol/sdk"
import type { IAcpLogger } from "./interfaces.js"
import { NullLogger } from "./interfaces.js"
// =============================================================================
// Types
// =============================================================================
/**
* Valid states for a prompt turn.
*
* - idle: No prompt is being processed, ready for new prompts
* - processing: A prompt is actively being processed
*/
export type PromptStateType = "idle" | "processing"
/**
* Result of completing a prompt.
*/
export interface PromptCompletionResult {
stopReason: acp.StopReason
}
/**
* Events that can occur during prompt lifecycle.
*/
export type PromptEvent =
| { type: "START_PROMPT" }
| { type: "COMPLETE"; success: boolean }
| { type: "CANCEL" }
| { type: "RESET" }
/**
* Options for creating a PromptStateMachine.
*/
export interface PromptStateMachineOptions {
/** Logger instance (optional, defaults to NullLogger) */
logger?: IAcpLogger
}
// =============================================================================
// PromptStateMachine Class
// =============================================================================
/**
* State machine for managing prompt lifecycle.
*
* Provides explicit state transitions with validation,
* replacing ad-hoc boolean flag management.
*/
export class PromptStateMachine {
private state: PromptStateType = "idle"
private abortController: AbortController | null = null
private resolvePrompt: ((result: PromptCompletionResult) => void) | null = null
private currentPromptText: string | null = null
private readonly logger: IAcpLogger
constructor(options: PromptStateMachineOptions = {}) {
this.logger = options.logger ?? new NullLogger()
}
/**
* Get the current state.
*/
getState(): PromptStateType {
return this.state
}
/**
* Get the abort signal for the current prompt.
*/
getAbortSignal(): AbortSignal | null {
return this.abortController?.signal ?? null
}
/**
* Get the current prompt text (for echo detection).
*/
getCurrentPromptText(): string | null {
return this.currentPromptText
}
/**
* Alias for getCurrentPromptText for compatibility.
*/
getPromptText(): string | null {
return this.currentPromptText
}
/**
* Check if a prompt can be started.
*/
canStartPrompt(): boolean {
return this.state === "idle"
}
/**
* Check if currently processing a prompt.
*/
isProcessing(): boolean {
return this.state === "processing"
}
/**
* Start a new prompt.
*
* @param promptText - The user's prompt text (for echo detection)
* @returns A promise that resolves when the prompt completes
* @throws If a prompt is already in progress
*/
startPrompt(promptText: string): Promise<PromptCompletionResult> {
if (this.state !== "idle") {
// Cancel existing prompt first
this.cancel()
}
this.state = "processing"
this.abortController = new AbortController()
this.currentPromptText = promptText
return new Promise((resolve) => {
this.resolvePrompt = resolve
// Handle abort signal
this.abortController?.signal.addEventListener("abort", () => {
if (this.state === "processing") {
this.transitionToComplete("cancelled")
}
})
})
}
/**
* Complete the prompt with success or failure.
*
* @param success - Whether the task completed successfully
* @returns The stop reason that was used
*/
complete(success: boolean): acp.StopReason {
const stopReason = this.mapSuccessToStopReason(success)
this.transitionToComplete(stopReason)
return stopReason
}
/**
* Cancel the current prompt.
*
* Safe to call even if no prompt is active.
*/
cancel(): void {
if (this.state !== "processing") {
return
}
this.abortController?.abort()
// Note: The abort handler will call transitionToComplete
}
/**
* Reset to idle state.
*
* Should be called when starting a new prompt to ensure clean state.
*/
reset(): void {
// Clean up any pending resources
if (this.abortController) {
this.abortController.abort()
this.abortController = null
}
this.state = "idle"
this.resolvePrompt = null
this.currentPromptText = null
}
// ===========================================================================
// Public Methods (for direct control)
// ===========================================================================
/**
* Transition to completion and resolve the promise.
* This is public to allow direct control of the stop reason (e.g., for cancellation).
*/
transitionToComplete(stopReason: acp.StopReason): void {
if (this.state !== "processing") {
return
}
this.state = "idle"
// Resolve the promise
if (this.resolvePrompt) {
this.resolvePrompt({ stopReason })
this.resolvePrompt = null
}
// Clean up
this.abortController = null
this.currentPromptText = null
}
/**
* Map task success to ACP stop reason.
*
* ACP defines these stop reasons:
* - end_turn: Normal completion
* - max_tokens: Token limit reached
* - max_turn_requests: Request limit reached
* - refusal: Agent refused to continue
* - cancelled: User cancelled
*/
private mapSuccessToStopReason(success: boolean): acp.StopReason {
// Use "refusal" for failed tasks as it's the closest match
// (indicates the task couldn't continue normally)
return success ? "end_turn" : "refusal"
}
}
// =============================================================================
// Factory Function
// =============================================================================
/**
* Create a new prompt state machine.
*/
export function createPromptStateMachine(options?: PromptStateMachineOptions): PromptStateMachine {
return new PromptStateMachine(options)
}

View file

@ -0,0 +1,575 @@
/**
* Session Event Handler
*
* Handles events from the ExtensionClient and ExtensionHost, translating them to ACP updates.
*/
import type { SessionMode } from "@agentclientprotocol/sdk"
import type {
ClineMessage,
ClineAsk,
ClineSay,
ExtensionMessage,
ExtensionState,
WebviewMessage,
ModeConfig,
} from "@roo-code/types"
import type { WaitingForInputEvent, TaskCompletedEvent, CommandExecutionOutputEvent } from "@/agent/events.js"
import {
translateToAcpUpdate,
isPermissionAsk,
isCompletionAsk,
isTodoListMessage,
createPlanUpdateFromMessage,
} from "./translator.js"
import { isUserEcho } from "./utils/index.js"
import type {
IAcpLogger,
IExtensionClient,
IExtensionHost,
IPromptStateMachine,
ICommandStreamManager,
IToolContentStreamManager,
IDeltaTracker,
SendUpdateFn,
} from "./interfaces.js"
import { ToolHandlerRegistry } from "./tool-handler.js"
// =============================================================================
// Streaming Configuration
// =============================================================================
/**
* Configuration for streaming content types.
* Defines which message types should be delta-streamed and how.
*/
interface StreamConfig {
/** ACP update type to use */
readonly updateType: "agent_message_chunk" | "agent_thought_chunk"
/** Optional transform to apply to the text before delta tracking */
readonly textTransform?: (text: string) => string
}
/**
* Type for the delta stream configuration map.
* Uses Partial<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
/** Extension host for host-level events (modes, etc.) */
extensionHost: IExtensionHost
/** Prompt state machine */
promptState: IPromptStateMachine
/** Delta tracker for streaming */
deltaTracker: IDeltaTracker
/** Command stream manager */
commandStreamManager: ICommandStreamManager
/** Tool content stream manager */
toolContentStreamManager: IToolContentStreamManager
/** Tool handler registry */
toolHandlerRegistry: ToolHandlerRegistry
/** Callback to send updates */
sendUpdate: SendUpdateFn
/** Callback to approve extension actions */
approveAction: () => void
/** Callback to respond with text */
respondWithText: (text: string) => void
/** Callback to send message to extension */
sendToExtension: (message: WebviewMessage) => void
/** Workspace path */
workspacePath: string
/** Initial mode ID */
initialModeId: string
/** Callback to check if cancellation is in progress */
isCancelling: () => boolean
}
/**
* Callback for task completion.
*/
export type TaskCompletedCallback = (success: boolean) => void
/**
* Callback for mode changes.
*/
export type ModeChangedCallback = (modeId: string, availableModes: SessionMode[]) => void
// =============================================================================
// SessionEventHandler Class
// =============================================================================
/**
* Handles events from the ExtensionClient and ExtensionHost, translating them to ACP updates.
*
* Responsibilities:
* - Subscribe to extension client events
* - Subscribe to extension host events (mode changes, etc.)
* - Handle streaming for text/reasoning messages
* - Handle tool permission requests
* - Handle task completion
* - Track mode state changes
*/
export class SessionEventHandler {
private readonly logger: IAcpLogger
private readonly client: IExtensionClient
private readonly extensionHost: IExtensionHost
private readonly promptState: IPromptStateMachine
private readonly deltaTracker: IDeltaTracker
private readonly commandStreamManager: ICommandStreamManager
private readonly toolContentStreamManager: IToolContentStreamManager
private readonly toolHandlerRegistry: ToolHandlerRegistry
private readonly sendUpdate: SendUpdateFn
private readonly approveAction: () => void
private readonly respondWithText: (text: string) => void
private readonly sendToExtension: (message: WebviewMessage) => void
private readonly workspacePath: string
private readonly isCancelling: () => boolean
private taskCompletedCallback: TaskCompletedCallback | null = null
private modeChangedCallback: ModeChangedCallback | null = null
/** Current mode ID (Roo Code mode like 'code', 'architect', etc.) */
private currentModeId: string
/** Available modes from extension state */
private availableModes: SessionMode[] = []
/** Listener for extension host messages */
private extensionMessageListener: ((msg: unknown) => void) | null = null
/**
* Track processed permission requests to prevent duplicates.
* The extension may fire multiple waitingForInput events for the same tool call
* as the message is updated. We deduplicate by generating a stable key from
* the ask type and relevant content.
*/
private processedPermissions: Set<string> = new Set()
constructor(deps: SessionEventHandlerDeps) {
this.logger = deps.logger
this.client = deps.client
this.extensionHost = deps.extensionHost
this.promptState = deps.promptState
this.deltaTracker = deps.deltaTracker
this.commandStreamManager = deps.commandStreamManager
this.toolContentStreamManager = deps.toolContentStreamManager
this.toolHandlerRegistry = deps.toolHandlerRegistry
this.sendUpdate = deps.sendUpdate
this.approveAction = deps.approveAction
this.respondWithText = deps.respondWithText
this.sendToExtension = deps.sendToExtension
this.workspacePath = deps.workspacePath
this.currentModeId = deps.initialModeId
this.isCancelling = deps.isCancelling
}
// ===========================================================================
// Public API
// ===========================================================================
/**
* Set up event handlers to translate ExtensionClient and ExtensionHost events to ACP updates.
*/
setupEventHandlers(): void {
// Handle new messages
this.client.on("message", (msg: unknown) => {
this.handleMessage(msg as ClineMessage)
})
// Handle message updates (partial -> complete)
this.client.on("messageUpdated", (msg: unknown) => {
this.handleMessage(msg as ClineMessage)
})
// Handle permission requests (tool calls, commands, etc.)
this.client.on("waitingForInput", (event: unknown) => {
void this.handleWaitingForInput(event as WaitingForInputEvent)
})
// Handle streaming command execution output (live terminal output)
this.client.on("commandExecutionOutput", (event: unknown) => {
const cmdEvent = event as CommandExecutionOutputEvent
this.commandStreamManager.handleExecutionOutput(cmdEvent.executionId, cmdEvent.output)
})
// Handle task completion
this.client.on("taskCompleted", (event: unknown) => {
this.handleTaskCompleted(event as TaskCompletedEvent)
})
// Handle extension host messages (modes, state, etc.)
this.extensionMessageListener = (msg: unknown) => {
this.handleExtensionMessage(msg as ExtensionMessage)
}
this.extensionHost.on("extensionWebviewMessage", this.extensionMessageListener)
}
/**
* Set the callback for task completion.
*/
onTaskCompleted(callback: TaskCompletedCallback): void {
this.taskCompletedCallback = callback
}
/**
* Set the callback for mode changes.
*/
onModeChanged(callback: ModeChangedCallback): void {
this.modeChangedCallback = callback
}
/**
* Get the current mode ID.
*/
getCurrentModeId(): string {
return this.currentModeId
}
/**
* Get the available modes.
*/
getAvailableModes(): SessionMode[] {
return this.availableModes
}
/**
* Reset state for a new prompt.
*/
reset(): void {
this.deltaTracker.reset()
this.commandStreamManager.reset()
this.toolContentStreamManager.reset()
this.processedPermissions.clear()
}
/**
* Clean up event listeners.
*/
cleanup(): void {
if (this.extensionMessageListener) {
this.extensionHost.off("extensionWebviewMessage", this.extensionMessageListener)
this.extensionMessageListener = null
}
}
// ===========================================================================
// Message Handling
// ===========================================================================
/**
* Handle an incoming message from the extension.
*
* Uses the declarative DELTA_STREAM_CONFIG to automatically determine
* which message types should be delta-streamed and how.
*/
private handleMessage(message: ClineMessage): void {
// Don't process messages if there's no active prompt
// NOTE: isCancelling guard REMOVED - we now show all content even during cancellation
// so the user can see exactly what was produced before the task paused
if (!this.promptState.isProcessing()) {
return
}
// === TEST LOGGING: Log messages that arrive during cancellation ===
if (this.isCancelling()) {
const msgType = message.type === "say" ? `say:${message.say}` : `ask:${message.ask}`
const partial = message.partial ? "PARTIAL" : "COMPLETE"
this.logger.info("EventHandler", `MSG DURING CANCEL (processing): ${msgType} ${partial} ts=${message.ts}`)
}
// Handle todo list updates - translate to ACP plan updates
// Detects both tool asks for updateTodoList and user_edit_todos say messages
if (isTodoListMessage(message)) {
const planUpdate = createPlanUpdateFromMessage(message)
if (planUpdate) {
this.sendUpdate(planUpdate)
}
// Don't return - let the message also be processed by other handlers
// (e.g., for permission requests that may follow)
}
// Handle streaming for tool ask messages (file creates/edits)
// These contain content that grows as the LLM generates it
if (this.toolContentStreamManager.isToolAskMessage(message)) {
this.toolContentStreamManager.handleToolContentStreaming(message)
return
}
// Check if this is a streaming message type
if (message.type === "say" && message.text && message.say) {
// Handle command_output specially for the "Run Command" UI
if (this.commandStreamManager.isCommandOutputMessage(message)) {
this.commandStreamManager.handleCommandOutput(message)
return
}
const config = getStreamConfig(message.say)
if (config) {
// Filter out user message echo
if (message.say === "text" && isUserEcho(message.text, this.promptState.getPromptText())) {
return
}
// Apply text transform if configured (e.g., "Error: " prefix)
const textToSend = config.textTransform ? config.textTransform(message.text) : message.text
// Get delta using the tracker (handles all bookkeeping automatically)
const delta = this.deltaTracker.getDelta(message.ts, textToSend)
if (delta) {
this.sendUpdate({
sessionUpdate: config.updateType,
content: { type: "text", text: delta },
})
}
return
}
}
// For non-streaming message types, use the translator
const update = translateToAcpUpdate(message)
if (update) {
this.sendUpdate(update)
}
}
// ===========================================================================
// Permission Handling
// ===========================================================================
/**
* Handle waiting for input events (permission requests).
*/
private async handleWaitingForInput(event: WaitingForInputEvent): Promise<void> {
const { ask, message } = event
const askType = ask as ClineAsk
// Don't auto-approve asks if there's no active prompt or if cancellation is in progress
if (!this.promptState.isProcessing() || this.isCancelling()) {
// === TEST LOGGING: Skipped ask due to cancellation ===
if (this.isCancelling()) {
this.logger.info("EventHandler", `ASK SKIPPED (cancelling): ask=${askType} ts=${message.ts}`)
}
return
}
// Handle permission-required asks
if (isPermissionAsk(askType)) {
this.handlePermissionRequest(message, askType)
return
}
// Handle completion asks
if (isCompletionAsk(askType)) {
// Completion is handled by taskCompleted event
return
}
// Handle followup questions - auto-continue for now
// In a more sophisticated implementation, these could be surfaced
// to the ACP client for user input
if (askType === "followup") {
this.respondWithText("")
return
}
// Handle resume_task - auto-resume
if (askType === "resume_task") {
this.approveAction()
return
}
// Handle API failures - auto-retry for now
if (askType === "api_req_failed") {
this.approveAction()
return
}
// Default: approve and continue
this.approveAction()
}
/**
* Handle a permission request for a tool call.
*
* Uses the ToolHandlerRegistry for polymorphic dispatch to the appropriate
* handler based on tool type. Auto-approves all tool calls without prompting
* the user, allowing autonomous operation.
*
* For commands, tracks the call to enable the "Run Command" UI with output.
* For other tools (search, read, etc.), both initial and completion updates
* are sent immediately as the results are already available.
*/
private handlePermissionRequest(message: ClineMessage, ask: ClineAsk): void {
// Generate a stable key for deduplication based on ask type and content
// The extension may fire multiple waitingForInput events for the same tool
// as the message is updated. We use the message text as a stable identifier.
const permissionKey = `${ask}:${message.text || ""}`
// Check if we've already processed this permission request
if (this.processedPermissions.has(permissionKey)) {
// Still need to approve the action to unblock the extension
this.approveAction()
return
}
// Mark this permission as processed
this.processedPermissions.add(permissionKey)
// Create context for the tool handler
const context = ToolHandlerRegistry.createContext(message, ask, this.workspacePath, this.logger)
// Dispatch to the appropriate handler via the registry
const result = this.toolHandlerRegistry.handle(context)
// Send the initial in_progress update
this.sendUpdate(result.initialUpdate)
// Track pending commands for the "Run Command" UI
if (result.trackAsPendingCommand) {
const { toolCallId, command, ts } = result.trackAsPendingCommand
this.commandStreamManager.trackCommand(toolCallId, command, ts)
}
// Send completion update if available (non-command tools)
if (result.completionUpdate) {
this.sendUpdate(result.completionUpdate)
}
// Auto-approve the tool call
this.approveAction()
}
// ===========================================================================
// Task Completion
// ===========================================================================
/**
* Handle task completion.
*/
private handleTaskCompleted(event: TaskCompletedEvent): void {
if (this.taskCompletedCallback) {
this.taskCompletedCallback(event.success)
}
}
// ===========================================================================
// Extension Message Handling (Modes, State)
// ===========================================================================
/**
* Handle extension messages for mode and state updates.
*/
private handleExtensionMessage(msg: ExtensionMessage): void {
// Handle "modes" message - list of available modes
if (msg.type === "modes" && msg.modes) {
this.availableModes = msg.modes.map((m) => ({
id: m.slug,
name: m.name,
description: undefined,
}))
}
// Handle "state" message - includes current mode
if (msg.type === "state" && msg.state) {
const state = msg.state as ExtensionState
if (state.mode && state.mode !== this.currentModeId) {
this.currentModeId = state.mode
// Send mode update notification
this.sendUpdate({
sessionUpdate: "current_mode_update",
currentModeId: this.currentModeId,
})
// Notify callback
if (this.modeChangedCallback) {
this.modeChangedCallback(this.currentModeId, this.availableModes)
}
}
// Update available modes from customModes
if (state.customModes && Array.isArray(state.customModes)) {
this.updateAvailableModesFromConfig(state.customModes as ModeConfig[])
}
}
}
/**
* Update available modes from ModeConfig array.
*/
private updateAvailableModesFromConfig(modes: ModeConfig[]): void {
this.availableModes = modes.map((m) => ({
id: m.slug,
name: m.name,
description: undefined,
}))
}
}
// =============================================================================
// Factory Function
// =============================================================================
/**
* Create a new SessionEventHandler instance.
*/
export function createSessionEventHandler(deps: SessionEventHandlerDeps): SessionEventHandler {
return new SessionEventHandler(deps)
}

403
apps/cli/src/acp/session.ts Normal file
View file

@ -0,0 +1,403 @@
/**
* ACP Session
*
* Manages a single ACP session, wrapping an ExtensionHost instance.
* Handles message translation, event streaming, and permission requests.
*/
import {
type SessionUpdate,
type PromptRequest,
type PromptResponse,
type SessionModeState,
AgentSideConnection,
} from "@agentclientprotocol/sdk"
import type { SupportedProvider } from "@/types/types.js"
import { getProviderSettings } from "@/lib/utils/provider.js"
import { type ExtensionHostOptions, ExtensionHost } from "@/agent/extension-host.js"
import { AgentLoopState } from "@/agent/agent-state.js"
import { DEFAULT_MODELS } from "./types.js"
import { extractPromptText, extractPromptImages } from "./translator.js"
import { acpLog } from "./logger.js"
import { DeltaTracker } from "./delta-tracker.js"
import { PromptStateMachine } from "./prompt-state.js"
import { ToolHandlerRegistry } from "./tool-handler.js"
import { CommandStreamManager } from "./command-stream.js"
import { ToolContentStreamManager } from "./tool-content-stream.js"
import { SessionEventHandler, createSessionEventHandler } from "./session-event-handler.js"
import type {
IAcpSession,
IAcpLogger,
IDeltaTracker,
IPromptStateMachine,
AcpSessionDependencies,
} from "./interfaces.js"
import { type Result, ok, err } from "./utils/index.js"
export interface AcpSessionOptions {
extensionPath: string
provider: SupportedProvider
apiKey?: string
model: string
mode: string
}
/**
* AcpSession wraps an ExtensionHost instance and bridges it to the ACP protocol.
*
* Each ACP session creates its own ExtensionHost, which loads the extension
* in a sandboxed environment. The session translates events from the
* ExtensionClient to ACP session updates and handles permission requests.
*/
export class AcpSession implements IAcpSession {
/** Logger instance (injected) */
private readonly logger: IAcpLogger
/** State machine for prompt lifecycle management */
private readonly promptState: IPromptStateMachine
/** Delta tracker for streaming content - ensures only new text is sent */
private readonly deltaTracker: IDeltaTracker
/** Tool handler registry for polymorphic tool dispatch */
private readonly toolHandlerRegistry: ToolHandlerRegistry
/** Command stream manager for handling command output */
private readonly commandStreamManager: CommandStreamManager
/** Tool content stream manager for handling file creates/edits */
private readonly toolContentStreamManager: ToolContentStreamManager
/** Session event handler for managing extension events */
private readonly eventHandler: SessionEventHandler
/** Current model ID */
private currentModelId: string = DEFAULT_MODELS[0]!.modelId
/** Track if we're in the process of cancelling a task */
private isCancelling: boolean = false
private constructor(
private readonly sessionId: string,
private readonly extensionHost: ExtensionHost,
private readonly connection: AgentSideConnection,
private readonly workspacePath: string,
private readonly options: AcpSessionOptions,
deps: AcpSessionDependencies = {},
) {
this.logger = deps.logger ?? acpLog
this.promptState = deps.createPromptStateMachine?.() ?? new PromptStateMachine({ logger: this.logger })
this.deltaTracker = deps.createDeltaTracker?.() ?? new DeltaTracker()
const sendUpdate = (update: SessionUpdate) => connection.sessionUpdate({ sessionId: this.sessionId, update })
this.toolHandlerRegistry = new ToolHandlerRegistry()
this.commandStreamManager = new CommandStreamManager({
deltaTracker: this.deltaTracker,
sendUpdate,
logger: this.logger,
})
this.toolContentStreamManager = new ToolContentStreamManager({
deltaTracker: this.deltaTracker,
sendUpdate,
logger: this.logger,
})
// Create event handler with extension host for mode tracking.
this.eventHandler = createSessionEventHandler({
logger: this.logger,
client: extensionHost.client,
extensionHost,
promptState: this.promptState,
deltaTracker: this.deltaTracker,
commandStreamManager: this.commandStreamManager,
toolContentStreamManager: this.toolContentStreamManager,
toolHandlerRegistry: this.toolHandlerRegistry,
sendUpdate,
approveAction: () => this.extensionHost.client.approve(),
respondWithText: (text: string, images?: string[]) => this.extensionHost.client.respond(text, images),
sendToExtension: (message) => this.extensionHost.sendToExtension(message),
workspacePath,
initialModeId: this.options.mode,
isCancelling: () => this.isCancelling,
})
this.eventHandler.onTaskCompleted((success) => this.handleTaskCompleted(success))
// Listen for state changes to log and detect cancellation completion.
this.extensionHost.client.on("stateChange", (event) => {
const prev = event.previousState
const curr = event.currentState
// Only log if something actually changed.
const stateChanged =
prev.state !== curr.state ||
prev.isRunning !== curr.isRunning ||
prev.isStreaming !== curr.isStreaming ||
prev.currentAsk !== curr.currentAsk
if (stateChanged) {
this.logger.info(
"ExtensionClient",
`STATE: ${prev.state}${curr.state} (running=${curr.isRunning}, streaming=${curr.isStreaming}, ask=${curr.currentAsk || "none"})`,
)
}
// If we're cancelling and the extension transitions to NO_TASK or IDLE, complete the cancellation
// NO_TASK: messages were cleared
// IDLE: task stopped (e.g., completion_result, api_req_failed, or just stopped)
if (this.isCancelling) {
const newState = curr.state
const isTerminalState =
newState === AgentLoopState.NO_TASK ||
newState === AgentLoopState.IDLE ||
newState === AgentLoopState.RESUMABLE
// Also check if the agent is no longer running/streaming (it has stopped processing)
const hasStopped = !curr.isRunning && !curr.isStreaming
if (isTerminalState || hasStopped) {
this.handleCancellationComplete()
}
}
})
}
/**
* Create a new AcpSession.
*
* This initializes an ExtensionHost for the given working directory
* and sets up event handlers to stream updates to the ACP client.
*/
static async create({
sessionId,
cwd,
connection,
options,
deps,
}: {
sessionId: string
cwd: string
connection: AgentSideConnection
options: AcpSessionOptions
deps: AcpSessionDependencies
}): Promise<AcpSession> {
const hostOptions: ExtensionHostOptions = {
mode: options.mode,
user: null,
provider: options.provider as ExtensionHostOptions["provider"],
apiKey: options.apiKey,
model: options.model,
workspacePath: cwd,
extensionPath: options.extensionPath,
disableOutput: true, // ACP mode: disable direct output, we stream through ACP.
ephemeral: true, // Don't persist state - ACP clients manage their own sessions.
}
const extensionHost = new ExtensionHost(hostOptions)
await extensionHost.activate()
const session = new AcpSession(sessionId, extensionHost, connection, cwd, options, deps)
session.setupEventHandlers()
return session
}
// ===========================================================================
// Event Handlers
// ===========================================================================
/**
* Set up event handlers to translate ExtensionClient events to ACP updates.
* This includes both ExtensionClient events and ExtensionHost events (modes, state).
*/
private setupEventHandlers(): void {
this.eventHandler.setupEventHandlers()
}
/**
* Reset state for a new prompt.
*/
private resetForNewPrompt(): void {
this.eventHandler.reset()
this.isCancelling = false
}
/**
* Handle task completion.
*/
private handleTaskCompleted(success: boolean): void {
// If we're cancelling, override the stop reason to "cancelled"
if (this.isCancelling) {
this.handleCancellationComplete()
} else {
// Normal completion
this.promptState.complete(success)
}
}
/**
* Handle cancellation completion.
* Called when the extension has finished cancelling (either via taskCompleted or NO_TASK transition).
*/
private handleCancellationComplete(): void {
if (!this.isCancelling) {
return // Already handled
}
this.isCancelling = false
// Directly transition to complete with "cancelled" stop reason
this.promptState.transitionToComplete("cancelled")
}
// ===========================================================================
// ACP Methods
// ===========================================================================
/**
* Process a prompt request from the ACP client.
*/
async prompt(params: PromptRequest): Promise<PromptResponse> {
// Extract text and images from prompt.
const text = extractPromptText(params.prompt)
const images = extractPromptImages(params.prompt)
// Check if we're in a resumable state (paused after cancel).
// If so, resume the existing conversation instead of starting fresh.
const currentState = this.extensionHost.client.getAgentState()
if (currentState.state === AgentLoopState.RESUMABLE && currentState.currentAsk === "resume_task") {
this.logger.info(
"Session",
`RESUME TASK: resuming paused task with user input (was ask=${currentState.currentAsk})`,
)
// Reset state for the resumed prompt (but don't cancel - task is already paused)
this.eventHandler.reset()
this.isCancelling = false
// Start tracking the prompt
const promise = this.promptState.startPrompt(text)
// Resume the task with the user's message as follow-up
this.extensionHost.client.respond(text, images.length > 0 ? images : undefined)
return promise
}
// Cancel any pending prompt.
this.cancel()
// Reset state for new prompt.
this.resetForNewPrompt()
// Start the prompt using the state machine.
const promise = this.promptState.startPrompt(text)
if (images.length > 0) {
this.extensionHost.sendToExtension({ type: "newTask", text, images })
} else {
this.extensionHost.sendToExtension({ type: "newTask", text })
}
return promise
}
/**
* Cancel the current prompt.
*/
cancel(): void {
if (this.promptState.isProcessing()) {
this.isCancelling = true
// Content continues flowing to the client during cancellation so users
// see what the LLM was generating when cancel was triggered.
this.extensionHost.sendToExtension({ type: "cancelTask" })
// We wait for the extension to send a taskCompleted event or transition to NO_TASK
// which will trigger handleTaskCompleted -> promptState.transitionToComplete("cancelled")
}
}
/**
* Set the session mode (Roo Code operational mode like 'code', 'architect').
* The mode change is tracked by the event handler which listens to extension state updates.
*/
setMode(mode: string): void {
this.extensionHost.sendToExtension({ type: "updateSettings", updatedSettings: { mode } })
}
/**
* Set the current model.
* This updates the provider settings to use the specified model.
*/
setModel(modelId: string): void {
this.currentModelId = modelId
const updatedSettings = getProviderSettings(this.options.provider, this.options.apiKey, modelId)
this.extensionHost.sendToExtension({ type: "updateSettings", updatedSettings })
}
/**
* Get the current mode state (delegated to event handler).
*/
getModeState(): SessionModeState {
return {
currentModeId: this.eventHandler.getCurrentModeId(),
availableModes: this.eventHandler.getAvailableModes(),
}
}
/**
* Get the current mode ID (delegated to event handler).
*/
getCurrentModeId(): string {
return this.eventHandler.getCurrentModeId()
}
/**
* Get the current model ID.
*/
getCurrentModelId(): string {
return this.currentModelId
}
/**
* Dispose of the session and release resources.
*/
async dispose(): Promise<void> {
this.cancel()
this.eventHandler.cleanup()
await this.extensionHost.dispose()
}
// ===========================================================================
// Helpers
// ===========================================================================
/**
* Send an update directly to the ACP client.
*
* @returns Result indicating success or failure with error details.
*/
private async sendUpdate(update: SessionUpdate): Promise<Result<void>> {
try {
// Log the update being sent to ACP connection (commented out - too noisy)
this.logger.info("Session", `OUT: ${JSON.stringify(update)}`)
await this.connection.sessionUpdate({ sessionId: this.sessionId, update })
return ok(undefined)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
this.logger.error("Session", `Failed to send update: ${errorMessage}`, error)
return err(`Failed to send update to ACP client: ${errorMessage}`)
}
}
/**
* Get the session ID.
*/
getSessionId(): string {
return this.sessionId
}
}

View file

@ -0,0 +1,204 @@
/**
* ToolContentStreamManager
*
* Manages streaming of tool content (file creates/edits) with headers and code fences.
* Provides live feedback as files are being written by the LLM.
*
* Extracted from session.ts to separate the tool content streaming concern.
*/
import type { ClineMessage } from "@roo-code/types"
import type { IDeltaTracker, IAcpLogger, SendUpdateFn } from "./interfaces.js"
import { isFileWriteTool } from "./tool-registry.js"
import { hasValidFilePath } from "./utils/index.js"
// =============================================================================
// Types
// =============================================================================
/**
* Options for creating a ToolContentStreamManager.
*/
export interface ToolContentStreamManagerOptions {
/** Delta tracker for tracking already-sent content */
deltaTracker: IDeltaTracker
/** Callback to send session updates */
sendUpdate: SendUpdateFn
/** Logger instance */
logger: IAcpLogger
}
// =============================================================================
// ToolContentStreamManager Class
// =============================================================================
/**
* Manages streaming of tool content for file creates/edits.
*
* Responsibilities:
* - Track which tools have sent their header
* - Stream file content as it's being generated
* - Wrap content in proper markdown code blocks
* - Clean up tracking state
*/
export class ToolContentStreamManager {
/**
* Track which tool content streams have sent their header.
* Used to show file path before streaming content.
*/
private toolContentHeadersSent: Set<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)) {
return true // Handled (by skipping)
}
// Check if we have valid path and content to start streaming
// Path must have a file extension to be considered valid (uses shared utility)
const validPath = hasValidFilePath(toolPath)
const hasContent = content.length > 0
if (isPartial) {
this.handlePartialMessage(ts, toolPath, content, validPath, hasContent)
} else {
this.handleCompleteMessage(ts, toolPath, content)
}
return true // Handled
}
/**
* Reset state for a new prompt.
*/
reset(): void {
this.toolContentHeadersSent.clear()
}
/**
* Get the number of active headers (for testing/debugging).
*/
getActiveHeaderCount(): number {
return this.toolContentHeadersSent.size
}
// ===========================================================================
// Private Methods
// ===========================================================================
/**
* Parse a tool message to extract tool info.
* Returns null if JSON is incomplete (expected early in streaming).
*/
private parseToolMessage(text: string): { toolName: string; toolPath: string; content: string } | null {
try {
const toolInfo = JSON.parse(text || "{}") as Record<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.sendUpdate({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: `\n**Creating ${toolPath}**\n\`\`\`\n` },
})
}
// Stream content deltas when content becomes available
if (hasValidPath && hasContent) {
// Use a unique key for delta tracking: "tool-content-{ts}"
const deltaKey = `tool-content-${ts}`
const delta = this.deltaTracker.getDelta(deltaKey, content)
if (delta) {
this.sendUpdate({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: delta },
})
}
}
}
/**
* Handle a complete (non-partial) tool message.
*/
private handleCompleteMessage(ts: number, _toolPath: string, _content: string): void {
// Message complete - finish streaming and clean up
if (this.toolContentHeadersSent.has(ts)) {
// Send closing code fence
this.sendUpdate({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "\n```\n" },
})
this.toolContentHeadersSent.delete(ts)
}
// Note: The actual tool_call notification will be sent via handleWaitingForInput
// when the waitingForInput event fires (which happens when partial becomes false)
}
}

View file

@ -0,0 +1,480 @@
/**
* Tool Handler Abstraction
*
* Provides a polymorphic interface for handling different tool types.
* Each handler knows how to process a specific category of tool operations,
* enabling cleaner separation of concerns and easier testing.
*/
import type * as acp from "@agentclientprotocol/sdk"
import type { ClineMessage, ClineAsk } from "@roo-code/types"
import { parseToolFromMessage, type ToolCallInfo } from "./translator.js"
import type { IAcpLogger } from "./interfaces.js"
import { isEditTool, isReadTool, isSearchTool, isListFilesTool, mapToolToKind } from "./tool-registry.js"
import {
formatSearchResults,
formatReadContent,
wrapInCodeBlock,
readFileContent,
extractContentFromParams,
DEFAULT_FORMAT_CONFIG,
} from "./utils/index.js"
// =============================================================================
// Types
// =============================================================================
/**
* Context passed to tool handlers for processing.
*/
export interface ToolHandlerContext {
/** The original message from the extension */
message: ClineMessage
/** The ask type if this is a permission request */
ask: ClineAsk
/** Workspace path for resolving relative file paths */
workspacePath: string
/** Parsed tool information from the message */
toolInfo: ToolCallInfo | null
/** Logger instance */
logger: IAcpLogger
}
/**
* Result of handling a tool call.
*/
export interface ToolHandleResult {
/** Initial tool_call update to send */
initialUpdate: acp.SessionNotification["update"]
/** Completion update to send (for non-command tools) */
completionUpdate?: acp.SessionNotification["update"]
/** Whether to track this as a pending command */
trackAsPendingCommand?: {
toolCallId: string
command: string
ts: number
}
}
/**
* Interface for tool handlers.
*
* Each implementation handles a specific category of tools (commands, files, search, etc.)
* and knows how to create the appropriate ACP updates.
*/
export interface ToolHandler {
/**
* Check if this handler can process the given tool.
*/
canHandle(context: ToolHandlerContext): boolean
/**
* Handle the tool call and return the appropriate updates.
*/
handle(context: ToolHandlerContext): ToolHandleResult
}
// =============================================================================
// Base Handler
// =============================================================================
/**
* Base class providing common functionality for tool handlers.
*/
abstract class BaseToolHandler implements ToolHandler {
abstract canHandle(context: ToolHandlerContext): boolean
abstract handle(context: ToolHandlerContext): ToolHandleResult
/**
* Build the basic tool call structure from context.
*/
protected buildBaseToolCall(context: ToolHandlerContext, kindOverride?: acp.ToolKind): acp.ToolCall {
const { message, toolInfo } = context
return {
toolCallId: toolInfo?.id || `tool-${message.ts}`,
title: toolInfo?.title || message.text?.slice(0, 100) || "Tool execution",
kind: kindOverride ?? (toolInfo ? mapToolToKind(toolInfo.name) : "other"),
status: "pending",
locations: toolInfo?.locations || [],
rawInput: toolInfo?.params || {},
}
}
/**
* Create the initial in_progress update.
*/
protected createInitialUpdate(
toolCall: acp.ToolCall,
kindOverride?: acp.ToolKind,
): acp.SessionNotification["update"] {
return {
sessionUpdate: "tool_call",
...toolCall,
kind: kindOverride ?? toolCall.kind,
status: "in_progress",
}
}
}
// =============================================================================
// Command Tool Handler
// =============================================================================
/**
* Handles command execution tools.
*
* Commands are special because:
* - They use "execute" kind for the "Run Command" UI
* - They track pending calls for output correlation
* - Completion comes via command_output messages, not immediately
*/
export class CommandToolHandler extends BaseToolHandler {
canHandle(context: ToolHandlerContext): boolean {
return context.ask === "command"
}
handle(context: ToolHandlerContext): ToolHandleResult {
const { message, logger } = context
const toolCall = this.buildBaseToolCall(context, "execute")
logger.info("CommandToolHandler", `Handling command: ${toolCall.toolCallId}`)
return {
initialUpdate: this.createInitialUpdate(toolCall, "execute"),
trackAsPendingCommand: {
toolCallId: toolCall.toolCallId,
command: message.text || "",
ts: message.ts,
},
}
}
}
// =============================================================================
// File Edit Tool Handler
// =============================================================================
/**
* Handles file editing operations (write, apply_diff, create, modify).
*
* File edits include diff content in the completion update for UI display.
*/
export class FileEditToolHandler extends BaseToolHandler {
canHandle(context: ToolHandlerContext): boolean {
if (context.ask !== "tool") return false
const toolName = context.toolInfo?.name || ""
return isEditTool(toolName)
}
handle(context: ToolHandlerContext): ToolHandleResult {
const { toolInfo, logger } = context
const toolCall = this.buildBaseToolCall(context, "edit")
// Include diff content if available
if (toolInfo?.content && toolInfo.content.length > 0) {
toolCall.content = toolInfo.content
}
logger.info("FileEditToolHandler", `Handling file edit: ${toolCall.toolCallId}`)
const completionUpdate: acp.SessionNotification["update"] = {
sessionUpdate: "tool_call_update",
toolCallId: toolCall.toolCallId,
status: "completed",
rawOutput: toolInfo?.params || {},
}
// Include diff content in completion
if (toolInfo?.content && toolInfo.content.length > 0) {
completionUpdate.content = toolInfo.content
}
return {
initialUpdate: this.createInitialUpdate(toolCall, "edit"),
completionUpdate,
}
}
}
// =============================================================================
// File Read Tool Handler
// =============================================================================
/**
* Handles file reading operations.
*
* For readFile tools, the rawInput.content contains the file PATH (not contents),
* so we need to read the actual file content.
*/
export class FileReadToolHandler extends BaseToolHandler {
canHandle(context: ToolHandlerContext): boolean {
if (context.ask !== "tool") return false
const toolName = context.toolInfo?.name || ""
return isReadTool(toolName)
}
handle(context: ToolHandlerContext): ToolHandleResult {
const { toolInfo, workspacePath, logger } = context
const toolCall = this.buildBaseToolCall(context, "read")
const rawInput = (toolInfo?.params as Record<string, unknown>) || {}
logger.info("FileReadToolHandler", `Handling file read: ${toolCall.toolCallId}`)
// Read actual file content using shared utility
const result = readFileContent(rawInput, workspacePath)
const fileContent = result.ok ? result.value : result.error
// Format the content (truncate if needed, wrap in code block)
const formattedContent = fileContent
? wrapInCodeBlock(formatReadContent(fileContent, DEFAULT_FORMAT_CONFIG))
: undefined
const completionUpdate: acp.SessionNotification["update"] = {
sessionUpdate: "tool_call_update",
toolCallId: toolCall.toolCallId,
status: "completed",
rawOutput: rawInput,
}
if (formattedContent) {
completionUpdate.content = [
{
type: "content",
content: { type: "text", text: formattedContent },
},
]
}
return {
initialUpdate: this.createInitialUpdate(toolCall, "read"),
completionUpdate,
}
}
}
// =============================================================================
// Search Tool Handler
// =============================================================================
/**
* Handles search operations (search_files, codebase_search, grep, etc.).
*
* Search results are formatted into a clean file list with summary.
*/
export class SearchToolHandler extends BaseToolHandler {
canHandle(context: ToolHandlerContext): boolean {
if (context.ask !== "tool") return false
const toolName = context.toolInfo?.name || ""
return isSearchTool(toolName)
}
handle(context: ToolHandlerContext): ToolHandleResult {
const { toolInfo, logger } = context
const toolCall = this.buildBaseToolCall(context, "search")
const rawInput = (toolInfo?.params as Record<string, unknown>) || {}
logger.info("SearchToolHandler", `Handling search: ${toolCall.toolCallId}`)
// Format search results using shared utility
const rawContent = rawInput.content as string | undefined
const formattedContent = rawContent ? wrapInCodeBlock(formatSearchResults(rawContent)) : undefined
const completionUpdate: acp.SessionNotification["update"] = {
sessionUpdate: "tool_call_update",
toolCallId: toolCall.toolCallId,
status: "completed",
rawOutput: rawInput,
}
if (formattedContent) {
completionUpdate.content = [
{
type: "content",
content: { type: "text", text: formattedContent },
},
]
}
return {
initialUpdate: this.createInitialUpdate(toolCall, "search"),
completionUpdate,
}
}
}
// =============================================================================
// List Files Tool Handler
// =============================================================================
/**
* Handles list_files operations.
*/
export class ListFilesToolHandler extends BaseToolHandler {
canHandle(context: ToolHandlerContext): boolean {
if (context.ask !== "tool") return false
const toolName = context.toolInfo?.name || ""
return isListFilesTool(toolName)
}
handle(context: ToolHandlerContext): ToolHandleResult {
const { toolInfo, logger } = context
const toolCall = this.buildBaseToolCall(context, "read")
const rawInput = (toolInfo?.params as Record<string, unknown>) || {}
logger.info("ListFilesToolHandler", `Handling list files: ${toolCall.toolCallId}`)
// Extract content using shared utility
const rawContent = extractContentFromParams(rawInput)
const completionUpdate: acp.SessionNotification["update"] = {
sessionUpdate: "tool_call_update",
toolCallId: toolCall.toolCallId,
status: "completed",
rawOutput: rawInput,
}
if (rawContent) {
completionUpdate.content = [
{
type: "content",
content: { type: "text", text: rawContent },
},
]
}
return {
initialUpdate: this.createInitialUpdate(toolCall, "read"),
completionUpdate,
}
}
}
// =============================================================================
// Default Tool Handler
// =============================================================================
/**
* Fallback handler for tools not matched by other handlers.
*/
export class DefaultToolHandler extends BaseToolHandler {
canHandle(_context: ToolHandlerContext): boolean {
// Default handler always matches as fallback
return true
}
handle(context: ToolHandlerContext): ToolHandleResult {
const { toolInfo, logger } = context
const toolCall = this.buildBaseToolCall(context)
const rawInput = (toolInfo?.params as Record<string, unknown>) || {}
logger.info("DefaultToolHandler", `Handling tool: ${toolCall.toolCallId}, kind: ${toolCall.kind}`)
// Extract content using shared utility
const rawContent = extractContentFromParams(rawInput)
const completionUpdate: acp.SessionNotification["update"] = {
sessionUpdate: "tool_call_update",
toolCallId: toolCall.toolCallId,
status: "completed",
rawOutput: rawInput,
}
if (rawContent) {
completionUpdate.content = [
{
type: "content",
content: { type: "text", text: rawContent },
},
]
}
return {
initialUpdate: this.createInitialUpdate(toolCall),
completionUpdate,
}
}
}
// =============================================================================
// Tool Handler Registry
// =============================================================================
/**
* Registry that manages tool handlers and dispatches to the appropriate one.
*
* Handlers are checked in order - the first one that canHandle() returns true wins.
* DefaultToolHandler should always be last as it accepts everything.
*/
export class ToolHandlerRegistry {
private readonly handlers: ToolHandler[]
constructor(handlers?: ToolHandler[]) {
// Default handler order - more specific handlers first
this.handlers = handlers || [
new CommandToolHandler(),
new FileEditToolHandler(),
new FileReadToolHandler(),
new SearchToolHandler(),
new ListFilesToolHandler(),
new DefaultToolHandler(),
]
}
/**
* Find the appropriate handler for the given context.
*/
getHandler(context: ToolHandlerContext): ToolHandler {
for (const handler of this.handlers) {
if (handler.canHandle(context)) {
return handler
}
}
// Should never happen if DefaultToolHandler is last
throw new Error("No handler found for tool - DefaultToolHandler should always match")
}
/**
* Handle a tool call by finding the appropriate handler and dispatching.
*/
handle(context: ToolHandlerContext): ToolHandleResult {
const handler = this.getHandler(context)
return handler.handle(context)
}
/**
* Create a context object from message and ask.
*/
static createContext(
message: ClineMessage,
ask: ClineAsk,
workspacePath: string,
logger: IAcpLogger,
): ToolHandlerContext {
return {
message,
ask,
workspacePath,
toolInfo: parseToolFromMessage(message, workspacePath),
logger,
}
}
}
// =============================================================================
// Exports
// =============================================================================
export { BaseToolHandler }

View file

@ -0,0 +1,530 @@
/**
* Tool Registry
*
* Centralized registry for tool type definitions, categories, and validation schemas.
* Provides type-safe tool identification and parameter validation.
*
* Uses exact matching with normalized tool names to avoid fragile substring matching.
*/
import { z } from "zod"
import type * as acp from "@agentclientprotocol/sdk"
// =============================================================================
// Tool Category Registry Class
// =============================================================================
/**
* Tool category names.
*/
export type ToolCategory =
| "edit"
| "read"
| "search"
| "list"
| "execute"
| "delete"
| "move"
| "think"
| "fetch"
| "switchMode"
| "fileWrite"
/**
* Registry for tool categories with automatic Set generation.
*
* This class ensures that TOOL_CATEGORIES and lookup Sets are always in sync
* by generating Sets automatically from the category definitions.
*/
class ToolCategoryRegistry {
private readonly categories: Map<ToolCategory, Set<string>> = new Map()
private readonly toolDefinitions: Record<ToolCategory, readonly string[]>
constructor() {
// Define tool categories with their associated tool names
// All tool names are stored in normalized form (lowercase, no separators)
this.toolDefinitions = {
/** File edit operations (create, write, modify) */
edit: [
"newfilecreated",
"editedexistingfile",
"writetofile",
"applydiff",
"applieddiff",
"createfile",
"modifyfile",
],
/** File read operations */
read: ["readfile"],
/** File/codebase search operations */
search: ["searchfiles", "codebasesearch", "grep", "ripgrep"],
/** Directory listing operations */
list: ["listfiles", "listfilestoplevel", "listfilesrecursive"],
/** Command/shell execution */
execute: ["executecommand", "runcommand"],
/** File deletion */
delete: ["deletefile", "removefile"],
/** File move/rename */
move: ["movefile", "renamefile"],
/** Reasoning/thinking operations */
think: ["think", "reason", "plan", "analyze"],
/** External fetch/HTTP operations */
fetch: ["fetch", "httpget", "httppost", "urlfetch", "webrequest"],
/** Mode switching operations */
switchMode: ["switchmode", "setmode"],
/** File write operations (for streaming detection) */
fileWrite: ["newfilecreated", "writetofile", "createfile", "editedexistingfile", "applydiff", "modifyfile"],
}
// Build Sets automatically from definitions
for (const [category, tools] of Object.entries(this.toolDefinitions)) {
this.categories.set(category as ToolCategory, new Set(tools))
}
}
/**
* Check if a tool name belongs to a specific category.
* Uses O(1) Set lookup.
*/
isInCategory(toolName: string, category: ToolCategory): boolean {
const normalized = this.normalizeToolName(toolName)
return this.categories.get(category)?.has(normalized) ?? false
}
/**
* Get all tools in a category.
*/
getToolsInCategory(category: ToolCategory): readonly string[] {
return this.toolDefinitions[category]
}
/**
* Get all category names.
*/
getCategoryNames(): ToolCategory[] {
return Object.keys(this.toolDefinitions) as ToolCategory[]
}
/**
* Normalize a tool name for comparison.
* Converts to lowercase and removes all separators (-, _).
*/
private normalizeToolName(name: string): string {
return name.toLowerCase().replace(/[-_]/g, "")
}
}
// =============================================================================
// Singleton Registry Instance
// =============================================================================
/**
* Global tool category registry instance.
*/
const toolCategoryRegistry = new ToolCategoryRegistry()
// =============================================================================
// Legacy Exports for Backward Compatibility
// =============================================================================
/**
* Tool categories with their associated tool names.
* @deprecated Use toolCategoryRegistry methods instead
*/
export const TOOL_CATEGORIES = {
edit: toolCategoryRegistry.getToolsInCategory("edit"),
read: toolCategoryRegistry.getToolsInCategory("read"),
search: toolCategoryRegistry.getToolsInCategory("search"),
list: toolCategoryRegistry.getToolsInCategory("list"),
execute: toolCategoryRegistry.getToolsInCategory("execute"),
delete: toolCategoryRegistry.getToolsInCategory("delete"),
move: toolCategoryRegistry.getToolsInCategory("move"),
think: toolCategoryRegistry.getToolsInCategory("think"),
fetch: toolCategoryRegistry.getToolsInCategory("fetch"),
switchMode: toolCategoryRegistry.getToolsInCategory("switchMode"),
fileWrite: toolCategoryRegistry.getToolsInCategory("fileWrite"),
} as const
// =============================================================================
// Type Definitions
// =============================================================================
/**
* All known tool names (union of all categories)
*/
export type KnownToolName = (typeof TOOL_CATEGORIES)[ToolCategory][number]
// =============================================================================
// Tool Category Detection Functions
// =============================================================================
/**
* Check if a tool name belongs to a specific category using exact matching.
* Uses the centralized registry for O(1) lookup.
*/
export function isToolInCategory(toolName: string, category: ToolCategory): boolean {
return toolCategoryRegistry.isInCategory(toolName, category)
}
/**
* Check if tool is an edit operation.
*/
export function isEditTool(toolName: string): boolean {
return isToolInCategory(toolName, "edit")
}
/**
* Check if tool is a read operation.
*/
export function isReadTool(toolName: string): boolean {
return isToolInCategory(toolName, "read")
}
/**
* Check if tool is a search operation.
*/
export function isSearchTool(toolName: string): boolean {
return isToolInCategory(toolName, "search")
}
/**
* Check if tool is a list files operation.
*/
export function isListFilesTool(toolName: string): boolean {
return isToolInCategory(toolName, "list")
}
/**
* Check if tool is a command execution operation.
*/
export function isExecuteTool(toolName: string): boolean {
return isToolInCategory(toolName, "execute")
}
/**
* Check if tool is a delete operation.
*/
export function isDeleteTool(toolName: string): boolean {
return isToolInCategory(toolName, "delete")
}
/**
* Check if tool is a move/rename operation.
*/
export function isMoveTool(toolName: string): boolean {
return isToolInCategory(toolName, "move")
}
/**
* Check if tool is a think/reasoning operation.
*/
export function isThinkTool(toolName: string): boolean {
return isToolInCategory(toolName, "think")
}
/**
* Check if tool is an external fetch operation.
*/
export function isFetchTool(toolName: string): boolean {
return isToolInCategory(toolName, "fetch")
}
/**
* Check if tool is a mode switching operation.
*/
export function isSwitchModeTool(toolName: string): boolean {
return isToolInCategory(toolName, "switchMode")
}
/**
* Check if tool is a file write operation (for streaming).
*/
export function isFileWriteTool(toolName: string): boolean {
return isToolInCategory(toolName, "fileWrite")
}
// =============================================================================
// Tool Kind Mapping
// =============================================================================
/**
* Map a tool name to an ACP ToolKind.
*
* ACP defines these tool kinds for special UI treatment:
* - read: Reading files or data
* - edit: Modifying files or content
* - delete: Removing files or data
* - move: Moving or renaming files
* - search: Searching for information
* - execute: Running commands or code
* - think: Internal reasoning or planning
* - fetch: Retrieving external data
* - switch_mode: Switching the current session mode
* - other: Other tool types (default)
*
* Uses exact category matching for reliability. Falls back to "other" for unknown tools.
*/
export function mapToolToKind(toolName: string): acp.ToolKind {
// Check exact category matches in priority order
// Order matters only for overlapping categories (like fileWrite and edit)
if (isToolInCategory(toolName, "switchMode")) {
return "switch_mode"
}
if (isToolInCategory(toolName, "think")) {
return "think"
}
if (isToolInCategory(toolName, "search")) {
return "search"
}
if (isToolInCategory(toolName, "delete")) {
return "delete"
}
if (isToolInCategory(toolName, "move")) {
return "move"
}
if (isToolInCategory(toolName, "edit")) {
return "edit"
}
if (isToolInCategory(toolName, "fetch")) {
return "fetch"
}
if (isToolInCategory(toolName, "read")) {
return "read"
}
if (isToolInCategory(toolName, "list")) {
return "read" // list operations are read-like
}
if (isToolInCategory(toolName, "execute")) {
return "execute"
}
// Default to other for unknown tools
return "other"
}
// =============================================================================
// Zod Schemas for Tool Parameters
// =============================================================================
/**
* Base schema for all tool parameters.
*/
const BaseToolParamsSchema = z.object({
tool: z.string(),
})
/**
* Schema for file path tools (read, delete, etc.)
*/
export const FilePathParamsSchema = BaseToolParamsSchema.extend({
path: z.string(),
content: z.string().optional(),
})
/**
* Schema for file write/create tools.
*/
export const FileWriteParamsSchema = BaseToolParamsSchema.extend({
path: z.string(),
content: z.string(),
})
/**
* Schema for file move/rename tools.
*/
export const FileMoveParamsSchema = BaseToolParamsSchema.extend({
path: z.string(),
newPath: z.string().optional(),
destination: z.string().optional(),
})
/**
* Schema for search tools.
*/
export const SearchParamsSchema = BaseToolParamsSchema.extend({
path: z.string().optional(),
regex: z.string().optional(),
query: z.string().optional(),
pattern: z.string().optional(),
filePattern: z.string().optional(),
content: z.string().optional(),
})
/**
* Schema for list files tools.
*/
export const ListFilesParamsSchema = BaseToolParamsSchema.extend({
path: z.string(),
recursive: z.boolean().optional(),
content: z.string().optional(),
})
/**
* Schema for command execution tools.
*/
export const CommandParamsSchema = BaseToolParamsSchema.extend({
command: z.string().optional(),
cwd: z.string().optional(),
})
/**
* Schema for think/reasoning tools.
*/
export const ThinkParamsSchema = BaseToolParamsSchema.extend({
thought: z.string().optional(),
reasoning: z.string().optional(),
analysis: z.string().optional(),
})
/**
* Schema for mode switching tools.
*/
export const SwitchModeParamsSchema = BaseToolParamsSchema.extend({
mode: z.string().optional(),
modeId: z.string().optional(),
})
/**
* Generic tool params schema (for unknown tools).
*/
export const GenericToolParamsSchema = BaseToolParamsSchema.passthrough()
// =============================================================================
// Parameter Types
// =============================================================================
export type FilePathParams = z.infer<typeof FilePathParamsSchema>
export type FileWriteParams = z.infer<typeof FileWriteParamsSchema>
export type FileMoveParams = z.infer<typeof FileMoveParamsSchema>
export type SearchParams = z.infer<typeof SearchParamsSchema>
export type ListFilesParams = z.infer<typeof ListFilesParamsSchema>
export type CommandParams = z.infer<typeof CommandParamsSchema>
export type ThinkParams = z.infer<typeof ThinkParamsSchema>
export type SwitchModeParams = z.infer<typeof SwitchModeParamsSchema>
export type GenericToolParams = z.infer<typeof GenericToolParamsSchema>
/**
* Union of all tool parameter types.
*/
export type ToolParams =
| FilePathParams
| FileWriteParams
| FileMoveParams
| SearchParams
| ListFilesParams
| CommandParams
| ThinkParams
| SwitchModeParams
| GenericToolParams
// =============================================================================
// Parameter Validation
// =============================================================================
/**
* Result of parameter validation.
*/
export type ValidationResult<T> = { success: true; data: T } | { success: false; error: z.ZodError }
/**
* Validate tool parameters against the appropriate schema.
*
* @param toolName - Name of the tool
* @param params - Raw parameters to validate
* @returns Validation result with typed params or error
*/
export function validateToolParams(toolName: string, params: unknown): ValidationResult<ToolParams> {
// Select schema based on tool category
let schema: z.ZodSchema
if (isEditTool(toolName)) {
schema = FileWriteParamsSchema
} else if (isReadTool(toolName)) {
schema = FilePathParamsSchema
} else if (isSearchTool(toolName)) {
schema = SearchParamsSchema
} else if (isListFilesTool(toolName)) {
schema = ListFilesParamsSchema
} else if (isExecuteTool(toolName)) {
schema = CommandParamsSchema
} else if (isDeleteTool(toolName)) {
schema = FilePathParamsSchema
} else if (isMoveTool(toolName)) {
schema = FileMoveParamsSchema
} else if (isThinkTool(toolName)) {
schema = ThinkParamsSchema
} else if (isSwitchModeTool(toolName)) {
schema = SwitchModeParamsSchema
} else {
// Use generic schema for unknown tools
schema = GenericToolParamsSchema
}
const result = schema.safeParse(params)
if (result.success) {
return { success: true, data: result.data as ToolParams }
}
return { success: false, error: result.error }
}
/**
* Parse and validate tool parameters, returning undefined on failure.
* Use when validation failure should be handled gracefully.
*
* @param toolName - Name of the tool
* @param params - Raw parameters to validate
* @returns Validated params or undefined
*/
export function parseToolParams(toolName: string, params: unknown): ToolParams | undefined {
const result = validateToolParams(toolName, params)
return result.success ? result.data : undefined
}
// =============================================================================
// Tool Message Parsing
// =============================================================================
/**
* Schema for parsing tool JSON from message text.
*/
export const ToolMessageSchema = z
.object({
tool: z.string(),
path: z.string().optional(),
content: z.string().optional(),
})
.passthrough()
export type ToolMessage = z.infer<typeof ToolMessageSchema>
/**
* Parse tool information from a JSON message.
*
* @param text - JSON text to parse
* @returns Parsed tool message or undefined if invalid
*/
export function parseToolMessage(text: string): ToolMessage | undefined {
if (!text.startsWith("{")) {
return undefined
}
try {
const parsed = JSON.parse(text)
const result = ToolMessageSchema.safeParse(parsed)
return result.success ? result.data : undefined
} catch {
return undefined
}
}

View file

@ -0,0 +1,56 @@
/**
* ACP Message Translator
*
* This file re-exports from the translator/ module for backward compatibility.
* The translator has been split into focused modules for better maintainability:
*
* - translator/diff-parser.ts: Unified diff parsing
* - translator/location-extractor.ts: File location extraction
* - translator/prompt-extractor.ts: Prompt content extraction
* - translator/tool-parser.ts: Tool information parsing
* - translator/message-translator.ts: Main message translation
* - translator/plan-translator.ts: TodoItem to ACP PlanEntry translation
*
* Import from this file or directly from translator/index.ts
*/
// Re-export everything from the translator module
export {
// Diff parsing
parseUnifiedDiff,
isUnifiedDiff,
type ParsedDiff,
// Location extraction
extractLocations,
extractFilePathsFromSearchResults,
type LocationParams,
// Prompt extraction
extractPromptText,
extractPromptImages,
extractPromptResources,
// Tool parsing
parseToolFromMessage,
generateToolTitle,
extractToolContent,
buildToolCallFromMessage,
type ToolCallInfo,
// Message translation
translateToAcpUpdate,
isPermissionAsk,
isCompletionAsk,
createPermissionOptions,
// Backward compatibility
mapToolKind,
// Plan translation (TodoItem to ACP PlanEntry)
todoItemToPlanEntry,
todoListToPlanUpdate,
parseTodoListFromMessage,
isTodoListMessage,
extractTodoListFromMessage,
createPlanUpdateFromMessage,
type PlanEntry,
type PlanEntryPriority,
type PlanEntryStatus,
type PlanUpdate,
type PriorityConfig,
} from "./translator/index.js"

View file

@ -0,0 +1,106 @@
/**
* Diff Parser
*
* Parses unified diff format to extract old and new text.
* Used for displaying file changes in ACP tool calls.
*/
// =============================================================================
// Types
// =============================================================================
/**
* Result of parsing a unified diff.
*/
export interface ParsedDiff {
/** Original text (null for new files) */
oldText: string | null
/** New text content */
newText: string
}
// =============================================================================
// Diff Parsing
// =============================================================================
/**
* Parse a unified diff string to extract old and new text.
*
* Handles standard unified diff format:
* ```
* --- a/file.txt
* +++ b/file.txt
* @@ -1,3 +1,4 @@
* context line
* -removed line
* +added line
* more context
* ```
*
* For non-diff content (raw file content), returns { oldText: null, newText: content }.
*
* @param diffString - The diff string to parse
* @returns Parsed diff with old and new text, or null if invalid
*/
export function parseUnifiedDiff(diffString: string): ParsedDiff | null {
if (!diffString) {
return null
}
// Check if this is a unified diff format
if (!diffString.includes("@@") && !diffString.includes("---") && !diffString.includes("+++")) {
// Not a diff, treat as raw content
return { oldText: null, newText: diffString }
}
const lines = diffString.split("\n")
const oldLines: string[] = []
const newLines: string[] = []
let inHunk = false
let isNewFile = false
for (const line of lines) {
// Check for new file indicator
if (line.startsWith("--- /dev/null")) {
isNewFile = true
continue
}
// Skip diff headers
if (line.startsWith("===") || line.startsWith("---") || line.startsWith("+++") || line.startsWith("@@")) {
if (line.startsWith("@@")) {
inHunk = true
}
continue
}
if (!inHunk) {
continue
}
if (line.startsWith("-")) {
// Removed line (old content)
oldLines.push(line.slice(1))
} else if (line.startsWith("+")) {
// Added line (new content)
newLines.push(line.slice(1))
} else if (line.startsWith(" ") || line === "") {
// Context line (in both old and new)
const contextLine = line.startsWith(" ") ? line.slice(1) : line
oldLines.push(contextLine)
newLines.push(contextLine)
}
}
return {
oldText: isNewFile ? null : oldLines.join("\n") || null,
newText: newLines.join("\n"),
}
}
/**
* Check if a string appears to be a unified diff.
*/
export function isUnifiedDiff(content: string): boolean {
return content.includes("@@") || (content.includes("---") && content.includes("+++"))
}

View file

@ -0,0 +1,59 @@
/**
* Translator Module
*
* Re-exports all translator functionality for backward compatibility.
* Import from this module to use the translator features.
*
* The translator is split into focused modules:
* - diff-parser: Unified diff parsing
* - location-extractor: File location extraction
* - prompt-extractor: Prompt content extraction
* - tool-parser: Tool information parsing
* - message-translator: Main message translation
* - plan-translator: TodoItem to ACP PlanEntry translation
*/
// Diff parsing
export { parseUnifiedDiff, isUnifiedDiff, type ParsedDiff } from "./diff-parser.js"
// Location extraction
export { extractLocations, extractFilePathsFromSearchResults, type LocationParams } from "./location-extractor.js"
// Prompt extraction
export { extractPromptText, extractPromptImages, extractPromptResources } from "./prompt-extractor.js"
// Tool parsing
export {
parseToolFromMessage,
generateToolTitle,
extractToolContent,
buildToolCallFromMessage,
type ToolCallInfo,
} from "./tool-parser.js"
// Message translation
export {
translateToAcpUpdate,
isPermissionAsk,
isCompletionAsk,
createPermissionOptions,
} from "./message-translator.js"
// Re-export mapToolKind for backward compatibility
// (now uses mapToolToKind from tool-registry internally)
export { mapToolToKind as mapToolKind } from "../tool-registry.js"
// Plan translation (TodoItem to ACP PlanEntry)
export {
todoItemToPlanEntry,
todoListToPlanUpdate,
parseTodoListFromMessage,
isTodoListMessage,
extractTodoListFromMessage,
createPlanUpdateFromMessage,
type PlanEntry,
type PlanEntryPriority,
type PlanEntryStatus,
type PlanUpdate,
type PriorityConfig,
} from "./plan-translator.js"

View file

@ -0,0 +1,136 @@
/**
* Location Extractor
*
* Extracts file locations from tool parameters for ACP tool calls.
* Handles various parameter formats and tool-specific behaviors.
*/
import type * as acp from "@agentclientprotocol/sdk"
import { isSearchTool, isListFilesTool } from "../tool-registry.js"
import { resolveFilePathUnsafe } from "../utils/index.js"
// =============================================================================
// Types
// =============================================================================
/**
* Parameters that may contain file locations.
*/
export interface LocationParams {
tool?: string
path?: string
file?: string
filePath?: string
file_path?: string
directory?: string
dir?: string
paths?: string[]
content?: string
}
// =============================================================================
// Location Extraction
// =============================================================================
/**
* Extract file locations from tool parameters.
*
* Handles different tool types:
* - Search tools: Extract file paths from search results
* - List files: Include the directory being listed
* - File operations: Extract path from standard parameters
*
* @param params - Tool parameters
* @param workspacePath - Optional workspace path to resolve relative paths
* @returns Array of tool call locations
*/
export function extractLocations(params: Record<string, unknown>, workspacePath?: string): acp.ToolCallLocation[] {
const locations: acp.ToolCallLocation[] = []
const toolName = (params.tool as string | undefined)?.toLowerCase() || ""
// For search tools, the 'path' parameter is a search scope directory, not a file being accessed.
// Don't include it in locations. Instead, try to extract file paths from search results.
if (isSearchTool(toolName)) {
// Try to extract file paths from search results content
const content = params.content as string | undefined
if (content) {
return extractFilePathsFromSearchResults(content, workspacePath)
}
return []
}
// For list_files tools, the 'path' is a directory being listed, which is valid to include
// but we should mark it as a directory operation rather than a file access
if (isListFilesTool(toolName)) {
const dirPath = params.path as string | undefined
if (dirPath) {
const absolutePath = resolveFilePathUnsafe(dirPath, workspacePath)
locations.push({ path: absolutePath })
}
return locations
}
// Check for common path parameters (for file operations)
const pathParams = ["path", "file", "filePath", "file_path"]
for (const param of pathParams) {
if (typeof params[param] === "string") {
const filePath = params[param] as string
const absolutePath = resolveFilePathUnsafe(filePath, workspacePath)
locations.push({ path: absolutePath })
}
}
// Check for directory parameters separately (for directory operations)
const dirParams = ["directory", "dir"]
for (const param of dirParams) {
if (typeof params[param] === "string") {
const dirPath = params[param] as string
const absolutePath = resolveFilePathUnsafe(dirPath, workspacePath)
locations.push({ path: absolutePath })
}
}
// Check for paths array
if (Array.isArray(params.paths)) {
for (const p of params.paths) {
if (typeof p === "string") {
const absolutePath = resolveFilePathUnsafe(p, workspacePath)
locations.push({ path: absolutePath })
}
}
}
return locations
}
/**
* Extract file paths from search results content.
*
* Search results typically have format: "# path/to/file.ts" for each matched file.
*
* @param content - Search results content
* @param workspacePath - Optional workspace path
* @returns Array of locations from search results
*/
export function extractFilePathsFromSearchResults(content: string, workspacePath?: string): acp.ToolCallLocation[] {
const locations: acp.ToolCallLocation[] = []
const seenPaths = new Set<string>()
// Match file headers in search results (e.g., "# src/utils.ts" or "## path/to/file.js")
const fileHeaderPattern = /^#+\s+(.+?\.[a-zA-Z0-9]+)\s*$/gm
let match
while ((match = fileHeaderPattern.exec(content)) !== null) {
const filePath = match[1]!.trim()
// Skip if we've already seen this path or if it looks like a markdown header (not a file path)
if (seenPaths.has(filePath) || (!filePath.includes("/") && !filePath.includes("."))) {
continue
}
seenPaths.add(filePath)
const absolutePath = resolveFilePathUnsafe(filePath, workspacePath)
locations.push({ path: absolutePath })
}
return locations
}

View file

@ -0,0 +1,179 @@
/**
* Message Translator
*
* Translates between internal ClineMessage format and ACP protocol format.
* This is the main bridge between Roo Code's message system and the ACP protocol.
*/
import type * as acp from "@agentclientprotocol/sdk"
import type { ClineMessage, ClineAsk } from "@roo-code/types"
import { mapToolToKind } from "../tool-registry.js"
import { parseToolFromMessage } from "./tool-parser.js"
// =============================================================================
// Message to ACP Update Translation
// =============================================================================
/**
* Translate an internal ClineMessage to an ACP session update.
* Returns null if the message type should not be sent to ACP.
*
* @param message - Internal ClineMessage
* @returns ACP session update or null
*/
export function translateToAcpUpdate(message: ClineMessage): acp.SessionNotification["update"] | null {
if (message.type === "say") {
switch (message.say) {
case "text":
// Agent text output
return {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: message.text || "" },
}
case "reasoning":
// Agent reasoning/thinking
return {
sessionUpdate: "agent_thought_chunk",
content: { type: "text", text: message.text || "" },
}
case "shell_integration_warning":
case "mcp_server_request_started":
case "mcp_server_response":
// Tool-related messages
return translateToolSayMessage(message)
case "user_feedback":
// User feedback doesn't need to be sent to ACP client
return null
case "error":
// Error messages
return {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: `Error: ${message.text || ""}` },
}
case "completion_result":
// Completion is handled at prompt level
return null
case "api_req_started":
case "api_req_finished":
case "api_req_retried":
case "api_req_retry_delayed":
case "api_req_deleted":
// API request lifecycle events - not sent to ACP
return null
case "command_output":
// Command execution - handled through tool_call
return null
default:
// Unknown message type
return null
}
}
// Ask messages are handled separately through permission flow
return null
}
/**
* Translate a tool say message to ACP format.
*
* @param message - Tool-related ClineMessage
* @returns ACP session update or null
*/
function translateToolSayMessage(message: ClineMessage): acp.SessionNotification["update"] | null {
const toolInfo = parseToolFromMessage(message)
if (!toolInfo) {
return null
}
if (message.partial) {
// Tool in progress
return {
sessionUpdate: "tool_call",
toolCallId: toolInfo.id,
title: toolInfo.title,
kind: mapToolToKind(toolInfo.name),
status: "in_progress" as const,
locations: toolInfo.locations,
rawInput: toolInfo.params,
}
} else {
// Tool completed
return {
sessionUpdate: "tool_call_update",
toolCallId: toolInfo.id,
status: "completed" as const,
content: [],
rawOutput: toolInfo.params,
}
}
}
// =============================================================================
// Ask Type Helpers
// =============================================================================
/**
* Ask types that require permission from the user.
*/
const PERMISSION_ASKS: readonly ClineAsk[] = ["tool", "command", "browser_action_launch", "use_mcp_server"]
/**
* Check if an ask type requires permission.
*
* @param ask - The ask type to check
* @returns true if permission is required
*/
export function isPermissionAsk(ask: ClineAsk): boolean {
return PERMISSION_ASKS.includes(ask)
}
/**
* Ask types that indicate task completion.
*/
const COMPLETION_ASKS: readonly ClineAsk[] = ["completion_result", "api_req_failed", "mistake_limit_reached"]
/**
* Check if an ask type indicates task completion.
*
* @param ask - The ask type to check
* @returns true if this indicates completion
*/
export function isCompletionAsk(ask: ClineAsk): boolean {
return COMPLETION_ASKS.includes(ask)
}
// =============================================================================
// Permission Options
// =============================================================================
/**
* Create standard permission options for a tool call.
*
* Returns options like "Allow", "Reject", and optionally "Always Allow"
* for certain tool types.
*
* @param ask - The ask type
* @returns Array of permission options
*/
export function createPermissionOptions(ask: ClineAsk): acp.PermissionOption[] {
const baseOptions: acp.PermissionOption[] = [
{ optionId: "allow", name: "Allow", kind: "allow_once" },
{ optionId: "reject", name: "Reject", kind: "reject_once" },
]
// Add "allow always" option for certain ask types
if (ask === "tool" || ask === "command") {
return [{ optionId: "allow_always", name: "Always Allow", kind: "allow_always" }, ...baseOptions]
}
return baseOptions
}

View file

@ -0,0 +1,260 @@
/**
* Plan Translator
*
* Translates between Roo CLI TodoItem format and ACP PlanEntry format.
* This enables the agent to communicate execution plans to ACP clients
* when using the update_todo_list tool.
*
* @see https://agentclientprotocol.com/protocol/agent-plan
*/
import type { TodoItem } from "@roo-code/types"
// =============================================================================
// Types
// =============================================================================
/**
* Priority levels for plan entries.
* Maps to ACP PlanEntryPriority.
*/
export type PlanEntryPriority = "high" | "medium" | "low"
/**
* Status levels for plan entries.
* Maps to ACP PlanEntryStatus (same as TodoStatus).
*/
export type PlanEntryStatus = "pending" | "in_progress" | "completed"
/**
* A single entry in the execution plan.
* Represents a task or goal that the agent intends to accomplish.
*/
export interface PlanEntry {
/** Human-readable description of what this task aims to accomplish */
content: string
/** The relative importance of this task */
priority: PlanEntryPriority
/** Current execution status of this task */
status: PlanEntryStatus
}
/**
* ACP Plan session update payload.
*/
export interface PlanUpdate {
sessionUpdate: "plan"
entries: PlanEntry[]
}
/**
* Configuration for priority assignment when converting todos to plan entries.
*/
export interface PriorityConfig {
/** Default priority for all items (default: "medium") */
defaultPriority: PlanEntryPriority
/** Assign high priority to in_progress items (default: true) */
prioritizeInProgress: boolean
/** Assign higher priority to earlier items in the list (default: false) */
prioritizeByOrder: boolean
/** Number of top items to mark as high priority when prioritizeByOrder is true */
highPriorityCount: number
}
/**
* Default priority configuration.
*/
const DEFAULT_PRIORITY_CONFIG: PriorityConfig = {
defaultPriority: "medium",
prioritizeInProgress: true,
prioritizeByOrder: false,
highPriorityCount: 3,
}
// =============================================================================
// Priority Determination
// =============================================================================
/**
* Determine the priority of a todo item based on configuration.
*
* @param item - The todo item
* @param index - Position in the list (0-based)
* @param total - Total number of items
* @param config - Priority configuration
* @returns The determined priority
*/
function determinePriority(item: TodoItem, index: number, total: number, config: PriorityConfig): PlanEntryPriority {
// In-progress items get high priority
if (config.prioritizeInProgress && item.status === "in_progress") {
return "high"
}
// Order-based priority
if (config.prioritizeByOrder && total > 0) {
if (index < config.highPriorityCount) {
return "high"
}
if (index < Math.floor(total / 2)) {
return "medium"
}
return "low"
}
return config.defaultPriority
}
// =============================================================================
// Translation Functions
// =============================================================================
/**
* Translate a single TodoItem to a PlanEntry.
*
* @param item - The todo item to translate
* @param index - Position in the list (0-based)
* @param total - Total number of items
* @param config - Priority configuration
* @returns The translated plan entry
*/
export function todoItemToPlanEntry(
item: TodoItem,
index: number = 0,
total: number = 1,
config: PriorityConfig = DEFAULT_PRIORITY_CONFIG,
): PlanEntry {
return {
content: item.content,
priority: determinePriority(item, index, total, config),
status: item.status,
}
}
/**
* Translate an array of TodoItems to a PlanUpdate.
*
* @param todos - Array of todo items
* @param config - Optional partial priority configuration
* @returns The plan update payload
*/
export function todoListToPlanUpdate(todos: TodoItem[], config?: Partial<PriorityConfig>): PlanUpdate {
const mergedConfig: PriorityConfig = { ...DEFAULT_PRIORITY_CONFIG, ...config }
const total = todos.length
return {
sessionUpdate: "plan",
entries: todos.map((item, index) => todoItemToPlanEntry(item, index, total, mergedConfig)),
}
}
// =============================================================================
// Message Detection and Parsing
// =============================================================================
/**
* Parsed todo list message structure.
*/
interface ParsedTodoMessage {
tool: "updateTodoList"
todos: TodoItem[]
}
/**
* Type guard to check if parsed JSON is a valid todo list message.
*/
function isParsedTodoMessage(obj: unknown): obj is ParsedTodoMessage {
if (!obj || typeof obj !== "object") return false
const record = obj as Record<string, unknown>
return record.tool === "updateTodoList" && Array.isArray(record.todos)
}
/**
* Parse todo list from a tool message text.
*
* @param text - The message text (JSON string)
* @returns Array of TodoItems or null if not a valid todo message
*/
export function parseTodoListFromMessage(text: string): TodoItem[] | null {
try {
const parsed: unknown = JSON.parse(text)
if (isParsedTodoMessage(parsed)) {
return parsed.todos
}
} catch {
// Not valid JSON - ignore
}
return null
}
/**
* Minimal message interface for detection.
*/
interface MessageLike {
type: string
ask?: string
say?: string
text?: string
}
/**
* Check if a message contains a todo list update.
*
* Detects two types of messages:
* 1. Tool ask messages with updateTodoList
* 2. user_edit_todos say messages (when user edits the todo list)
*
* @param message - The message to check
* @returns true if message contains a todo list update
*/
export function isTodoListMessage(message: MessageLike): boolean {
// Check for tool ask message with updateTodoList
if (message.type === "ask" && message.ask === "tool" && message.text) {
const todos = parseTodoListFromMessage(message.text)
return todos !== null
}
// Check for user_edit_todos say message
if (message.type === "say" && message.say === "user_edit_todos" && message.text) {
const todos = parseTodoListFromMessage(message.text)
return todos !== null
}
return false
}
/**
* Extract todo list from a message if present.
*
* @param message - The message to extract from
* @returns Array of TodoItems or null if not a todo message
*/
export function extractTodoListFromMessage(message: MessageLike): TodoItem[] | null {
if (!message.text) return null
if (message.type === "ask" && message.ask === "tool") {
return parseTodoListFromMessage(message.text)
}
if (message.type === "say" && message.say === "user_edit_todos") {
return parseTodoListFromMessage(message.text)
}
return null
}
/**
* Create a plan update from a message if it contains a todo list.
*
* Convenience function that combines detection, extraction, and translation.
*
* @param message - The message to process
* @param config - Optional priority configuration
* @returns PlanUpdate or null if message doesn't contain todos
*/
export function createPlanUpdateFromMessage(message: MessageLike, config?: Partial<PriorityConfig>): PlanUpdate | null {
const todos = extractTodoListFromMessage(message)
if (!todos || todos.length === 0) {
return null
}
return todoListToPlanUpdate(todos, config)
}

View file

@ -0,0 +1,101 @@
/**
* Prompt Extractor
*
* Extracts text and images from ACP prompt content blocks.
* Handles various content block types including text, resources, and media.
*/
import type * as acp from "@agentclientprotocol/sdk"
// =============================================================================
// Text Extraction
// =============================================================================
/**
* Extract text content from ACP prompt content blocks.
*
* Handles these content block types:
* - text: Direct text content
* - resource_link: Reference to a file or resource (converted to @uri format)
* - resource: Embedded resource with text content
* - image/audio: Noted as placeholders
*
* @param prompt - Array of ACP content blocks
* @returns Combined text from all blocks
*/
export function extractPromptText(prompt: acp.ContentBlock[]): string {
const textParts: string[] = []
for (const block of prompt) {
switch (block.type) {
case "text":
textParts.push(block.text)
break
case "resource_link":
// Reference to a file or resource
textParts.push(`@${block.uri}`)
break
case "resource":
// Embedded resource content
if (block.resource && "text" in block.resource) {
textParts.push(`Content from ${block.resource.uri}:\n${block.resource.text}`)
}
break
case "image":
case "audio":
// Binary content - note it but don't include
textParts.push(`[${block.type} content]`)
break
}
}
return textParts.join("\n")
}
// =============================================================================
// Image Extraction
// =============================================================================
/**
* Extract images from ACP prompt content blocks.
*
* Extracts base64-encoded image data from image content blocks.
*
* @param prompt - Array of ACP content blocks
* @returns Array of base64-encoded image data strings
*/
export function extractPromptImages(prompt: acp.ContentBlock[]): string[] {
const images: string[] = []
for (const block of prompt) {
if (block.type === "image" && block.data) {
images.push(block.data)
}
}
return images
}
// =============================================================================
// Resource Extraction
// =============================================================================
/**
* Extract resource URIs from ACP prompt content blocks.
*
* @param prompt - Array of ACP content blocks
* @returns Array of resource URIs
*/
export function extractPromptResources(prompt: acp.ContentBlock[]): string[] {
const resources: string[] = []
for (const block of prompt) {
if (block.type === "resource_link") {
resources.push(block.uri)
} else if (block.type === "resource" && block.resource) {
resources.push(block.resource.uri)
}
}
return resources
}

View file

@ -0,0 +1,241 @@
/**
* Tool Parser
*
* Parses tool information from ClineMessage format.
* Extracts tool name, parameters, and generates titles.
*/
import * as path from "node:path"
import type * as acp from "@agentclientprotocol/sdk"
import type { ClineMessage } from "@roo-code/types"
import { mapToolToKind, isEditTool as isFileEditTool } from "../tool-registry.js"
import { extractLocations } from "./location-extractor.js"
import { parseUnifiedDiff } from "./diff-parser.js"
import { resolveFilePathUnsafe } from "../utils/index.js"
// =============================================================================
// Types
// =============================================================================
/**
* Parsed tool call information.
*/
export interface ToolCallInfo {
/** Unique identifier for the tool call */
id: string
/** Tool name */
name: string
/** Human-readable title */
title: string
/** Tool parameters */
params: Record<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",
// Plan updates
updateTodoList: "Update plan",
update_todo_list: "Update plan",
}
return toolTitles[toolName] || (fileName ? `${toolName}: ${fileName}` : toolName)
}
// =============================================================================
// Tool Content Extraction
// =============================================================================
/**
* Extract tool content for ACP (diffs, text, etc.)
*
* For file edit tools, parses the content as a unified diff.
*
* @param params - Tool parameters
* @param workspacePath - Optional workspace path
* @returns Array of tool content or undefined
*/
export function extractToolContent(
params: Record<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
}

42
apps/cli/src/acp/types.ts Normal file
View file

@ -0,0 +1,42 @@
import type { ModelInfo, SessionMode } from "@agentclientprotocol/sdk"
export const DEFAULT_MODELS: ModelInfo[] = [
{
modelId: "anthropic/claude-opus-4.5",
name: "Claude Opus 4.5",
description: "Most capable for complex work",
},
{
modelId: "anthropic/claude-sonnet-4.5",
name: "Claude Sonnet 4.5",
description: "Best balance of speed and capability",
},
{
modelId: "anthropic/claude-haiku-4.5",
name: "Claude Haiku 4.5",
description: "Fastest for quick answers",
},
]
export const AVAILABLE_MODES: SessionMode[] = [
{
id: "code",
name: "Code",
description: "Write, modify, and refactor code",
},
{
id: "architect",
name: "Architect",
description: "Plan and design system architecture",
},
{
id: "ask",
name: "Ask",
description: "Ask questions and get explanations",
},
{
id: "debug",
name: "Debug",
description: "Debug issues and troubleshoot problems",
},
]

View file

@ -0,0 +1,379 @@
/**
* Format Utilities
*
* Shared formatting and content extraction utilities for ACP.
* Extracted to eliminate code duplication across modules.
*/
import * as fs from "node:fs"
import * as fsPromises from "node:fs/promises"
import * as path from "node:path"
// =============================================================================
// Configuration
// =============================================================================
/**
* Default configuration for content formatting.
*/
export interface FormatConfig {
/** Maximum number of lines to show for read results */
maxReadLines: number
}
export const DEFAULT_FORMAT_CONFIG: FormatConfig = {
maxReadLines: 100,
}
// =============================================================================
// Result Type for Error Handling
// =============================================================================
/**
* Result type for operations that can fail.
* Provides explicit success/failure indication instead of returning error strings.
*/
export type Result<T> = { ok: true; value: T } | { ok: false; error: string }
/**
* Create a successful result.
*/
export function ok<T>(value: T): Result<T> {
return { ok: true, value }
}
/**
* Create a failed result.
*/
export function err<T>(error: string): Result<T> {
return { ok: false, error }
}
// =============================================================================
// Search Result Formatting
// =============================================================================
/**
* Format search results into a clean summary with file list.
*
* Input format (verbose):
* ```
* Found 112 results.
*
* # src/acp/__tests__/agent.test.ts
* 9 |
* 10 | // Mock the auth module
* ...
*
* # README.md
* 105 |
* ...
* ```
*
* Output format (clean):
* ```
* Found 112 results in 20 files
*
* - src/acp/__tests__/agent.test.ts
* - README.md
* ...
* ```
*/
export function formatSearchResults(content: string): string {
// Extract count from "Found X results" line
const countMatch = content.match(/Found (\d+) results?/)
const resultCount = countMatch?.[1] ? parseInt(countMatch[1], 10) : null
// Extract unique file paths from "# path/to/file" lines
const filePattern = /^# (.+)$/gm
const files = new Set<string>()
let match
while ((match = filePattern.exec(content)) !== null) {
if (match[1]) {
files.add(match[1])
}
}
// Sort files alphabetically
const fileList = Array.from(files).sort((a, b) => a.localeCompare(b))
// Build the formatted output
if (fileList.length === 0) {
// No files found, return first line (might be "No results found" or similar)
return content.split("\n")[0] || content
}
const summary =
resultCount !== null
? `Found ${resultCount} result${resultCount !== 1 ? "s" : ""} in ${fileList.length} file${fileList.length !== 1 ? "s" : ""}`
: `Found matches in ${fileList.length} file${fileList.length !== 1 ? "s" : ""}`
// Use markdown list format
const formattedFiles = fileList.map((f) => `- ${f}`).join("\n")
return `${summary}\n\n${formattedFiles}`
}
// =============================================================================
// Read Content Formatting
// =============================================================================
/**
* Format read results by truncating long file contents.
*
* @param content - The raw file content
* @param config - Optional configuration overrides
* @returns Truncated content with indicator if truncated
*/
export function formatReadContent(content: string, config: FormatConfig = DEFAULT_FORMAT_CONFIG): string {
const lines = content.split("\n")
if (lines.length <= config.maxReadLines) {
return content
}
// Truncate and add indicator
const truncated = lines.slice(0, config.maxReadLines).join("\n")
const remaining = lines.length - config.maxReadLines
return `${truncated}\n\n... (${remaining} more lines)`
}
// =============================================================================
// Code Block Wrapping
// =============================================================================
/**
* Wrap content in markdown code block for better rendering.
*
* @param content - Content to wrap
* @param language - Optional language for syntax highlighting
* @returns Content wrapped in markdown code fences
*/
export function wrapInCodeBlock(content: string, language?: string): string {
const fence = language ? `\`\`\`${language}` : "```"
return `${fence}\n${content}\n\`\`\``
}
// =============================================================================
// Content Extraction from Raw Input
// =============================================================================
/**
* Common field names to check when extracting content from tool parameters.
*/
const CONTENT_FIELDS = ["content", "text", "result", "output", "fileContent", "data"] as const
/**
* Extract content from raw input parameters.
*
* Tries common field names for content. Returns the first non-empty string found.
*
* @param rawInput - Tool parameters object
* @returns Extracted content or undefined if not found
*/
export function extractContentFromParams(rawInput: Record<string, unknown>): string | undefined {
for (const field of CONTENT_FIELDS) {
const value = rawInput[field]
if (typeof value === "string" && value.length > 0) {
return value
}
}
return undefined
}
// =============================================================================
// File Reading
// =============================================================================
/**
* Resolve a file path to absolute, using workspace path if relative.
* Includes path traversal protection when workspace path is provided.
*
* @param filePath - File path (may be relative or absolute)
* @param workspacePath - Workspace path for resolving relative paths
* @returns Result with absolute path, or error if path traversal detected
*/
export function resolveFilePath(filePath: string, workspacePath?: string): Result<string> {
// Normalize the path to resolve any . or .. segments
const normalizedPath = path.normalize(filePath)
if (path.isAbsolute(normalizedPath)) {
// For absolute paths with workspace, verify it's within workspace
if (workspacePath) {
const normalizedWorkspace = path.normalize(workspacePath)
if (!normalizedPath.startsWith(normalizedWorkspace + path.sep) && normalizedPath !== normalizedWorkspace) {
return err(`Path traversal detected: ${filePath} is outside workspace ${workspacePath}`)
}
}
return ok(normalizedPath)
}
if (workspacePath) {
const resolved = path.resolve(workspacePath, normalizedPath)
const normalizedWorkspace = path.normalize(workspacePath)
// Verify resolved path is within workspace (prevents ../../../etc/passwd attacks)
if (!resolved.startsWith(normalizedWorkspace + path.sep) && resolved !== normalizedWorkspace) {
return err(`Path traversal detected: ${filePath} resolves outside workspace ${workspacePath}`)
}
return ok(resolved)
}
// Return as-is if no workspace path available
return ok(normalizedPath)
}
/**
* Resolve a file path to absolute (legacy version without Result wrapper).
*
* @deprecated Use resolveFilePath() with Result type for better error handling
* @param filePath - File path (may be relative or absolute)
* @param workspacePath - Workspace path for resolving relative paths
* @returns Absolute path (returns original path on error)
*/
export function resolveFilePathUnsafe(filePath: string, workspacePath?: string): string {
const result = resolveFilePath(filePath, workspacePath)
return result.ok ? result.value : filePath
}
/**
* Read file content from the filesystem (synchronous version).
*
* For readFile tools, the rawInput.content field contains the file PATH
* (not the contents), so we need to read the actual file.
*
* @deprecated Use readFileContentAsync() for non-blocking I/O
* @param rawInput - Tool parameters (must contain path or content with file path)
* @param workspacePath - Workspace path for resolving relative paths
* @returns Result with file content or error message
*/
export function readFileContent(rawInput: Record<string, unknown>, workspacePath: string): Result<string> {
// The "content" field in readFile contains the absolute path
const filePath = rawInput.content as string | undefined
const relativePath = rawInput.path as string | undefined
// Try absolute path first, then relative path
let pathToRead: string | undefined
if (filePath) {
const resolved = resolveFilePath(filePath, workspacePath)
if (!resolved.ok) return resolved
pathToRead = resolved.value
} else if (relativePath) {
const resolved = resolveFilePath(relativePath, workspacePath)
if (!resolved.ok) return resolved
pathToRead = resolved.value
}
if (!pathToRead) {
return err("readFile tool has no path")
}
try {
const content = fs.readFileSync(pathToRead, "utf-8")
return ok(content)
} catch (error) {
return err(`Failed to read file ${pathToRead}: ${error}`)
}
}
/**
* Read file content from the filesystem (asynchronous version).
*
* For readFile tools, the rawInput.content field contains the file PATH
* (not the contents), so we need to read the actual file.
*
* @param rawInput - Tool parameters (must contain path or content with file path)
* @param workspacePath - Workspace path for resolving relative paths
* @returns Promise resolving to Result with file content or error message
*/
export async function readFileContentAsync(
rawInput: Record<string, unknown>,
workspacePath: string,
): Promise<Result<string>> {
// The "content" field in readFile contains the absolute path
const filePath = rawInput.content as string | undefined
const relativePath = rawInput.path as string | undefined
// Try absolute path first, then relative path
let pathToRead: string | undefined
if (filePath) {
const resolved = resolveFilePath(filePath, workspacePath)
if (!resolved.ok) return resolved
pathToRead = resolved.value
} else if (relativePath) {
const resolved = resolveFilePath(relativePath, workspacePath)
if (!resolved.ok) return resolved
pathToRead = resolved.value
}
if (!pathToRead) {
return err("readFile tool has no path")
}
try {
const content = await fsPromises.readFile(pathToRead, "utf-8")
return ok(content)
} catch (error) {
return err(`Failed to read file ${pathToRead}: ${error}`)
}
}
// =============================================================================
// User Echo Detection
// =============================================================================
/**
* Check if a text message is an echo of the user's prompt.
*
* When the extension starts processing a task, it often sends a `text`
* message containing the user's input. Since the ACP client already
* displays the user's message, we should filter this out.
*
* Uses fuzzy matching to handle minor differences (whitespace, etc.).
*
* @param text - The text to check
* @param promptText - The original prompt text to compare against
* @returns true if the text appears to be an echo of the prompt
*/
export function isUserEcho(text: string, promptText: string | null): boolean {
if (!promptText) {
return false
}
// Normalize both strings for comparison
const normalizedPrompt = promptText.trim().toLowerCase()
const normalizedText = text.trim().toLowerCase()
// Exact match
if (normalizedText === normalizedPrompt) {
return true
}
// Check if text is contained in prompt (might be truncated)
if (normalizedPrompt.includes(normalizedText) && normalizedText.length > 10) {
return true
}
// Check if prompt is contained in text (might have wrapper)
if (normalizedText.includes(normalizedPrompt) && normalizedPrompt.length > 10) {
return true
}
return false
}
// =============================================================================
// Validation Helpers
// =============================================================================
/**
* Check if a path looks like a valid file path (has extension).
*
* @param filePath - Path to check
* @returns true if the path has a file extension
*/
export function hasValidFilePath(filePath: string): boolean {
return /\.[a-zA-Z0-9]+$/.test(filePath)
}

View file

@ -0,0 +1,29 @@
/**
* ACP Utilities Module
*
* Shared utilities for the ACP implementation.
*/
export {
// Configuration
type FormatConfig,
DEFAULT_FORMAT_CONFIG,
// Result type
type Result,
ok,
err,
// Formatting functions
formatSearchResults,
formatReadContent,
wrapInCodeBlock,
// Content extraction
extractContentFromParams,
// File operations
readFileContent,
readFileContentAsync,
resolveFilePath,
resolveFilePathUnsafe,
// Validation
isUserEcho,
hasValidFilePath,
} from "./format-utils.js"

View file

@ -98,12 +98,11 @@ describe("ExtensionHost", () => {
const host = new ExtensionHost(options)
// Options are stored but integrationTest is set to true
// Options are stored as provided
const storedOptions = getPrivate<ExtensionHostOptions>(host, "options")
expect(storedOptions.mode).toBe(options.mode)
expect(storedOptions.workspacePath).toBe(options.workspacePath)
expect(storedOptions.extensionPath).toBe(options.extensionPath)
expect(storedOptions.integrationTest).toBe(true) // Always set to true in constructor
})
it("should be an EventEmitter instance", () => {
@ -281,8 +280,8 @@ describe("ExtensionHost", () => {
describe("quiet mode", () => {
describe("setupQuietMode", () => {
it("should not modify console when integrationTest is true", () => {
// By default, constructor sets integrationTest = true
const host = createTestHost()
// Explicitly set integrationTest = true
const host = createTestHost({ integrationTest: true })
const originalLog = console.log
callPrivate(host, "setupQuietMode")

View file

@ -135,6 +135,7 @@ export class AskDispatcher {
}
// Skip partial messages (wait for complete)
// Note: Streaming output for partial tool/command messages is handled by OutputManager
if (message.partial) {
return { handled: false }
}
@ -356,9 +357,12 @@ export class AskDispatcher {
* Handle command execution approval.
*/
private async handleCommandApproval(ts: number, text: string): Promise<AskHandleResult> {
this.outputManager.output("\n[command request]")
this.outputManager.output(` Command: ${text || "(no command specified)"}`)
this.outputManager.markDisplayed(ts, text || "", false)
// Skip output if we already streamed this command via partial messages
if (!this.outputManager.isAlreadyDisplayed(ts)) {
this.outputManager.output("\n[command request]")
this.outputManager.output(` Command: ${text || "(no command specified)"}`)
this.outputManager.markDisplayed(ts, text || "", false)
}
if (this.nonInteractive) {
// Auto-approved by extension settings
@ -380,46 +384,49 @@ export class AskDispatcher {
* Handle tool execution approval.
*/
private async handleToolApproval(ts: number, text: string): Promise<AskHandleResult> {
let toolName = "unknown"
let toolInfo: Record<string, unknown> = {}
// Skip output if we already streamed this tool request via partial messages
if (!this.outputManager.isAlreadyDisplayed(ts)) {
let toolName = "unknown"
let toolInfo: Record<string, unknown> = {}
try {
toolInfo = JSON.parse(text) as Record<string, unknown>
toolName = (toolInfo.tool as string) || "unknown"
} catch {
// Use raw text if not JSON
}
const isProtected = toolInfo.isProtected === true
if (isProtected) {
this.outputManager.output(`\n[Tool Request] ${toolName} [PROTECTED CONFIGURATION FILE]`)
this.outputManager.output(`⚠️ WARNING: This tool wants to modify a protected configuration file.`)
this.outputManager.output(
` Protected files include .rooignore, .roo/*, and other sensitive config files.`,
)
} else {
this.outputManager.output(`\n[Tool Request] ${toolName}`)
}
// Display tool details
for (const [key, value] of Object.entries(toolInfo)) {
if (key === "tool" || key === "isProtected") continue
let displayValue: string
if (typeof value === "string") {
displayValue = value.length > 200 ? value.substring(0, 200) + "..." : value
} else if (typeof value === "object" && value !== null) {
const json = JSON.stringify(value)
displayValue = json.length > 200 ? json.substring(0, 200) + "..." : json
} else {
displayValue = String(value)
try {
toolInfo = JSON.parse(text) as Record<string, unknown>
toolName = (toolInfo.tool as string) || "unknown"
} catch {
// Use raw text if not JSON
}
this.outputManager.output(` ${key}: ${displayValue}`)
}
const isProtected = toolInfo.isProtected === true
this.outputManager.markDisplayed(ts, text || "", false)
if (isProtected) {
this.outputManager.output(`\n[Tool Request] ${toolName} [PROTECTED CONFIGURATION FILE]`)
this.outputManager.output(`⚠️ WARNING: This tool wants to modify a protected configuration file.`)
this.outputManager.output(
` Protected files include .rooignore, .roo/*, and other sensitive config files.`,
)
} else {
this.outputManager.output(`\n[Tool Request] ${toolName}`)
}
// Display tool details
for (const [key, value] of Object.entries(toolInfo)) {
if (key === "tool" || key === "isProtected") continue
let displayValue: string
if (typeof value === "string") {
displayValue = value.length > 200 ? value.substring(0, 200) + "..." : value
} else if (typeof value === "object" && value !== null) {
const json = JSON.stringify(value)
displayValue = json.length > 200 ? json.substring(0, 200) + "..." : json
} else {
displayValue = String(value)
}
this.outputManager.output(` ${key}: ${displayValue}`)
}
this.outputManager.markDisplayed(ts, text || "", false)
}
if (this.nonInteractive) {
// Auto-approved by extension settings (unless protected)

View file

@ -76,6 +76,11 @@ export interface ClientEventMap {
*/
modeChanged: ModeChangedEvent
/**
* Emitted when command execution output is received (streaming terminal output).
*/
commandExecutionOutput: CommandExecutionOutputEvent
/**
* Emitted on any error during message processing.
*/
@ -128,6 +133,16 @@ export interface ModeChangedEvent {
currentMode: string
}
/**
* Event payload for command execution output (streaming terminal output).
*/
export interface CommandExecutionOutputEvent {
/** Unique execution ID */
executionId: string
/** The terminal output received so far */
output: string
}
// =============================================================================
// Typed Event Emitter
// =============================================================================

View file

@ -432,6 +432,7 @@ export class ExtensionClient {
const message: WebviewMessage = {
type: "cancelTask",
}
this.sendMessage(message)
}
@ -467,6 +468,7 @@ export class ExtensionClient {
type: "terminalOperation",
terminalOperation: "continue",
}
this.sendMessage(message)
}
@ -480,6 +482,7 @@ export class ExtensionClient {
type: "terminalOperation",
terminalOperation: "abort",
}
this.sendMessage(message)
}

View file

@ -31,12 +31,13 @@ import type { User } from "@/lib/sdk/index.js"
import { getProviderSettings } from "@/lib/utils/provider.js"
import { createEphemeralStorageDir } from "@/lib/storage/index.js"
import type { WaitingForInputEvent, TaskCompletedEvent } from "./events.js"
import type { WaitingForInputEvent, TaskCompletedEvent, CommandExecutionOutputEvent } from "./events.js"
import type { AgentStateInfo } from "./agent-state.js"
import { ExtensionClient } from "./extension-client.js"
import { OutputManager } from "./output-manager.js"
import { PromptManager } from "./prompt-manager.js"
import { AskDispatcher } from "./ask-dispatcher.js"
import { testLog } from "./test-logger.js"
// Pre-configured logger for CLI message activity debugging.
const cliLogger = new DebugLogger("CLI")
@ -152,7 +153,7 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac
super()
this.options = options
this.options.integrationTest = true
// this.options.integrationTest = true
// Initialize client - single source of truth for agent state (including mode).
this.client = new ExtensionClient({
@ -163,6 +164,7 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac
// Initialize output manager.
this.outputManager = new OutputManager({
disabled: options.disableOutput,
debug: options.debug,
})
// Initialize prompt manager with console mode callbacks.
@ -189,6 +191,18 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac
commandExecutionTimeout: 30,
browserToolEnabled: false,
enableCheckpoints: false,
// Disable preventFocusDisruption experiment for CLI - it's only
// relevant for VSCode diff views and preventing it causes tool
// messages to not stream during LLM generation.
experiments: {
multiFileApplyDiff: false,
powerSteering: false,
preventFocusDisruption: false,
imageGeneration: false,
runSlashCommand: false,
multipleNativeToolCalls: false,
customTools: false,
},
...getProviderSettings(this.options.provider, this.options.apiKey, this.options.model),
}
@ -234,15 +248,53 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac
* The client emits events, managers handle them.
*/
private setupClientEventHandlers(): void {
// === TEST LOGGING: State changes (matches ACP session.ts logging) ===
this.client.on("stateChange", (event) => {
const prev = event.previousState
const curr = event.currentState
// Only log if something actually changed
const stateChanged =
prev.state !== curr.state ||
prev.isRunning !== curr.isRunning ||
prev.isStreaming !== curr.isStreaming ||
prev.currentAsk !== curr.currentAsk
if (stateChanged) {
testLog.info(
"ExtensionClient",
`STATE: ${prev.state}${curr.state} (running=${curr.isRunning}, streaming=${curr.isStreaming}, ask=${curr.currentAsk || "none"})`,
)
}
})
// Handle new messages - delegate to OutputManager.
this.client.on("message", (msg: ClineMessage) => {
this.logMessageDebug(msg, "new")
// DEBUG: Log all incoming messages with timestamp (only when -d flag is set)
if (this.options.debug) {
const ts = new Date().toISOString()
const msgType = msg.type === "say" ? `say:${msg.say}` : `ask:${msg.ask}`
const partial = msg.partial ? "PARTIAL" : "COMPLETE"
process.stdout.write(`\n[DEBUG ${ts}] NEW ${msgType} ${partial} ts=${msg.ts}\n`)
}
this.outputManager.outputMessage(msg)
})
// Handle message updates - delegate to OutputManager.
this.client.on("messageUpdated", (msg: ClineMessage) => {
this.logMessageDebug(msg, "updated")
// DEBUG: Log all message updates with timestamp (only when -d flag is set)
if (this.options.debug) {
const ts = new Date().toISOString()
const msgType = msg.type === "say" ? `say:${msg.say}` : `ask:${msg.ask}`
const partial = msg.partial ? "PARTIAL" : "COMPLETE"
process.stdout.write(`\n[DEBUG ${ts}] UPDATED ${msgType} ${partial} ts=${msg.ts}\n`)
}
this.outputManager.outputMessage(msg)
})
@ -259,6 +311,11 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac
this.outputManager.outputCompletionResult(event.message.ts, event.message.text || "")
}
})
// Handle streaming terminal output from commandExecutionStatus messages.
this.client.on("commandExecutionOutput", (event: CommandExecutionOutputEvent) => {
this.outputManager.outputStreamingTerminalOutput(event.executionId, event.output)
})
}
// ==========================================================================
@ -436,9 +493,6 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac
this.sendToExtension({ type: "newTask", text: prompt })
return new Promise((resolve, reject) => {
let timeoutId: NodeJS.Timeout | null = null
const timeoutMs: number = 110_000
const completeHandler = () => {
cleanup()
resolve()
@ -450,23 +504,10 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac
}
const cleanup = () => {
if (timeoutId) {
clearTimeout(timeoutId)
timeoutId = null
}
this.client.off("taskCompleted", completeHandler)
this.client.off("error", errorHandler)
}
// Set timeout to prevent indefinite hanging.
timeoutId = setTimeout(() => {
cleanup()
reject(
new Error(`Task completion timeout after ${timeoutMs}ms - no completion or error event received`),
)
}, timeoutMs)
this.client.once("taskCompleted", completeHandler)
this.client.once("error", errorHandler)
})

View file

@ -1 +1,3 @@
export * from "./extension-host.js"
export { ExtensionClient } from "./extension-client.js"
export type { WaitingForInputEvent, TaskCompletedEvent, CommandExecutionOutputEvent } from "./events.js"

View file

@ -21,7 +21,13 @@ import { ExtensionMessage, ClineMessage } from "@roo-code/types"
import { debugLog } from "@roo-code/core/cli"
import type { StateStore } from "./state-store.js"
import type { TypedEventEmitter, AgentStateChangeEvent, WaitingForInputEvent, TaskCompletedEvent } from "./events.js"
import type {
TypedEventEmitter,
AgentStateChangeEvent,
WaitingForInputEvent,
TaskCompletedEvent,
CommandExecutionOutputEvent,
} from "./events.js"
import {
isSignificantStateChange,
transitionedToWaiting,
@ -121,6 +127,10 @@ export class MessageProcessor {
this.handleInvoke(message)
break
case "commandExecutionStatus":
this.handleCommandExecutionStatus(message)
break
default:
// Other message types are not relevant to state detection
if (this.options.debug) {
@ -237,6 +247,7 @@ export class MessageProcessor {
}
const clineMessage = message.clineMessage
const previousState = this.store.getAgentState()
// Update the message in the store
@ -277,6 +288,45 @@ export class MessageProcessor {
// But they might trigger state changes through subsequent messages
}
/**
* Handle a "commandExecutionStatus" message - streaming terminal output.
*
* This message is sent during command execution to provide live terminal
* output before the final command_output message is created.
*/
private handleCommandExecutionStatus(message: ExtensionMessage): void {
if (!message.text) {
return
}
try {
const status = JSON.parse(message.text) as { status: string; executionId?: string; output?: string }
// Only emit for "output" status which contains terminal output
if (status.status === "output" && status.executionId && status.output !== undefined) {
if (this.options.debug) {
debugLog("[MessageProcessor] Command execution output", {
executionId: status.executionId,
outputLength: status.output.length,
})
}
const event: CommandExecutionOutputEvent = {
executionId: status.executionId,
output: status.output,
}
this.emitter.emit("commandExecutionOutput", event)
}
} catch {
// Ignore parse errors
if (this.options.debug) {
debugLog("[MessageProcessor] Failed to parse commandExecutionStatus", {
text: message.text?.substring(0, 100),
})
}
}
}
// ===========================================================================
// Event Emission Helpers
// ===========================================================================
@ -372,6 +422,15 @@ export class MessageProcessor {
// A more sophisticated implementation would track seen message timestamps
const lastMessage = messages[messages.length - 1]
if (lastMessage) {
// DEBUG: Log all emitted ask messages to trace partial handling
if (this.options.debug && lastMessage.type === "ask") {
debugLog("[MessageProcessor] EMIT message", {
ask: lastMessage.ask,
partial: lastMessage.partial,
textLen: lastMessage.text?.length || 0,
ts: lastMessage.ts,
})
}
this.emitter.emit("message", lastMessage)
}
}

View file

@ -13,8 +13,17 @@
* - Can be disabled for TUI mode where Ink controls the terminal
*/
import fs from "fs"
import { ClineMessage, ClineSay } from "@roo-code/types"
// Debug logging to file (for CLI debugging without breaking TUI)
const DEBUG_LOG = "/tmp/roo-cli-debug.log"
function debugLog(message: string, data?: unknown) {
const timestamp = new Date().toISOString()
const entry = data ? `[${timestamp}] ${message}: ${JSON.stringify(data, null, 2)}\n` : `[${timestamp}] ${message}\n`
fs.appendFileSync(DEBUG_LOG, entry)
}
import { Observable } from "./events.js"
// =============================================================================
@ -58,6 +67,12 @@ export interface OutputManagerOptions {
* Stream for error output (default: process.stderr).
*/
stderr?: NodeJS.WriteStream
/**
* When true, outputs verbose debug info for tool requests.
* Enabled by -d flag in CLI.
*/
debug?: boolean
}
// =============================================================================
@ -68,6 +83,7 @@ export class OutputManager {
private disabled: boolean
private stdout: NodeJS.WriteStream
private stderr: NodeJS.WriteStream
private debug: boolean
/**
* Track displayed messages by ts to avoid duplicate output.
@ -90,6 +106,16 @@ export class OutputManager {
*/
private loggedFirstPartial = new Set<number>()
/**
* Track streaming terminal output by execution ID.
*/
private terminalOutputByExecutionId = new Map<string, string>()
/**
* Flag to track if we've streamed any terminal output (to skip command_output).
*/
private hasStreamedTerminalOutput = false
/**
* Observable for streaming state changes.
* External systems can subscribe to know when streaming starts/ends.
@ -103,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
}
// ===========================================================================
@ -126,20 +153,48 @@ export class OutputManager {
if (msg.type === "say" && msg.say) {
this.outputSayMessage(ts, msg.say, text, isPartial, alreadyDisplayedComplete, skipFirstUserMessage)
} else if (msg.type === "ask" && msg.ask) {
// For ask messages, we only output command_output here
// Other asks are handled by AskDispatcher
if (msg.ask === "command_output") {
this.outputCommandOutput(ts, text, isPartial, alreadyDisplayedComplete)
// Handle streaming output for different ask types
switch (msg.ask) {
case "command_output":
this.outputCommandOutput(ts, text, isPartial, alreadyDisplayedComplete)
break
case "tool":
// Stream tool requests (file create/edit/delete) as they come in
this.outputToolRequest(ts, text, isPartial, alreadyDisplayedComplete)
break
case "command":
// Stream command requests as they come in
this.outputCommandRequest(ts, text, isPartial, alreadyDisplayedComplete)
break
// Other ask types (followup, completion_result, etc.) are handled by AskDispatcher
// when complete (partial: false)
}
}
}
/**
* 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)
}
@ -148,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)
}
@ -157,13 +213,21 @@ 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)
}
/**
* Check if a message has already been fully displayed.
* Check if a message has already been displayed (streamed or complete).
* Returns true if we've streamed content for this ts OR if we've fully displayed it.
*/
isAlreadyDisplayed(ts: number): boolean {
// Check if we've streamed any content for this message
// (streamedContent is set during streaming, before displayedMessages is finalized)
if (this.streamedContent.has(ts)) {
return true
}
// Check if we've fully displayed this message
const displayed = this.displayedMessages.get(ts)
return displayed !== undefined && !displayed.partial
}
@ -198,6 +262,11 @@ export class OutputManager {
this.streamedContent.clear()
this.currentlyStreamingTs = null
this.loggedFirstPartial.clear()
this.terminalOutputByExecutionId.clear()
this.hasStreamedTerminalOutput = false
this.toolContentStreamed.clear()
this.toolContentTruncated.clear()
this.toolLastDisplayedCharCount.clear()
this.streamingState.next({ ts: null, isStreaming: false })
}
@ -335,6 +404,7 @@ export class OutputManager {
/**
* Output command_output (shared between say and ask types).
* Skips output if we've already streamed terminal output via commandExecutionStatus.
*/
outputCommandOutput(
ts: number,
@ -342,6 +412,15 @@ export class OutputManager {
isPartial: boolean,
alreadyDisplayedComplete: boolean | undefined,
): void {
// Skip if we've already streamed terminal output - avoid duplicate display
if (this.hasStreamedTerminalOutput) {
// Mark as displayed but don't output - we already showed it via [terminal]
if (!isPartial) {
this.displayedMessages.set(ts, { ts, text, partial: false })
}
return
}
if (isPartial && text) {
this.streamContent(ts, text, "[command output]")
this.displayedMessages.set(ts, { ts, text, partial: true })
@ -365,6 +444,209 @@ export class OutputManager {
}
}
/**
* Track streamed tool content separately (content grows, not the full JSON text).
*/
private toolContentStreamed = new Map<number, string>()
/**
* Track which tool messages have already shown truncation marker.
*/
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.
*/
private outputToolRequest(
ts: number,
text: string,
isPartial: boolean,
alreadyDisplayedComplete: boolean | undefined,
): void {
// Parse tool info to get the tool name, path, and content for display
let toolName = "tool"
let toolPath = ""
let content = ""
try {
const toolInfo = JSON.parse(text) as Record<string, unknown>
toolName = (toolInfo.tool as string) || "tool"
toolPath = (toolInfo.path as string) || ""
content = (toolInfo.content as string) || ""
} catch {
// 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
// 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 })
}
// Stream content delta (new content since last update)
if (content.length > previousContent.length && content.startsWith(previousContent)) {
const delta = content.slice(previousContent.length)
// Check if we're still within the preview limit
const previousLineCount = previousContent === "" ? 0 : previousContent.split("\n").length
const previouslyTruncated = this.toolContentTruncated.has(ts)
if (!previouslyTruncated) {
if (currentLineCount <= OutputManager.MAX_PREVIEW_LINES) {
// Still under limit - output the delta
this.writeRaw(delta)
} else if (previousLineCount < OutputManager.MAX_PREVIEW_LINES) {
// Just crossed the limit - output remaining lines up to limit, mark as truncated
const linesToShow = OutputManager.MAX_PREVIEW_LINES - previousLineCount
const deltaLines = delta.split("\n")
const truncatedDelta = deltaLines.slice(0, linesToShow).join("\n")
if (truncatedDelta) {
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)
}
}
this.toolContentStreamed.set(ts, content)
}
this.displayedMessages.set(ts, { ts, text, partial: true })
} else if (!isPartial && !alreadyDisplayedComplete) {
// Tool request complete
const previousContent = this.toolContentStreamed.get(ts) || ""
const currentLineCount = content === "" ? 0 : content.split("\n").length
const wasTruncated = this.toolContentTruncated.has(ts)
// Show final truncation message
if (wasTruncated && previousContent) {
const remainingLines = currentLineCount - OutputManager.MAX_PREVIEW_LINES
this.writeRaw(`\n... (${remainingLines} more lines)\n`)
}
// Show final stats
const pathInfo = toolPath ? ` ${toolPath}` : ""
const charCount = content.length
this.writeRaw(`[${toolName}]${pathInfo} complete (${currentLineCount} lines, ${charCount} chars)\n`)
this.currentlyStreamingTs = null
this.streamingState.next({ ts: null, isStreaming: false })
this.displayedMessages.set(ts, { ts, text, partial: false })
// Clean up tool content tracking
this.toolContentStreamed.delete(ts)
this.toolContentTruncated.delete(ts)
this.toolLastDisplayedCharCount.delete(ts)
}
}
/**
* Output command request with streaming support.
* Streams partial content as it arrives from the LLM.
*/
private outputCommandRequest(
ts: number,
text: string,
isPartial: boolean,
alreadyDisplayedComplete: boolean | undefined,
): void {
if (isPartial && text) {
this.streamContent(ts, text, "[command]")
this.displayedMessages.set(ts, { ts, text, partial: true })
} else if (!isPartial && !alreadyDisplayedComplete) {
// Command request complete - finish the stream
// Note: AskDispatcher will handle the actual prompt/approval
const streamed = this.streamedContent.get(ts)
if (streamed) {
if (text.length > streamed.text.length && text.startsWith(streamed.text)) {
const delta = text.slice(streamed.text.length)
this.writeRaw(delta)
}
this.finishStream(ts)
}
// Don't output non-streamed content here - AskDispatcher handles complete command requests
this.displayedMessages.set(ts, { ts, text, partial: false })
}
}
// ===========================================================================
// Streaming Helpers
// ===========================================================================
@ -411,4 +693,38 @@ export class OutputManager {
this.displayedMessages.set(ts, { ts, text: text || "", partial: false })
}
}
// ===========================================================================
// Terminal Output Streaming (commandExecutionStatus)
// ===========================================================================
/**
* Output streaming terminal output from commandExecutionStatus messages.
* This provides live terminal output during command execution, before
* the final command_output message is created.
*
* @param executionId - Unique execution ID for this command
* @param output - The accumulated terminal output so far
*/
outputStreamingTerminalOutput(executionId: string, output: string): void {
if (this.disabled) return
// Mark that we've streamed terminal output (to skip command_output later)
this.hasStreamedTerminalOutput = true
const previousOutput = this.terminalOutputByExecutionId.get(executionId)
if (!previousOutput) {
// First time seeing this execution - output header and initial content
this.writeRaw("\n[terminal] ")
this.writeRaw(output)
this.terminalOutputByExecutionId.set(executionId, output)
} else if (output.length > previousOutput.length && output.startsWith(previousOutput)) {
// Output has grown - write only the delta
const delta = output.slice(previousOutput.length)
this.writeRaw(delta)
this.terminalOutputByExecutionId.set(executionId, output)
}
// If output hasn't grown or doesn't start with previous, ignore (likely reset)
}
}

View file

@ -0,0 +1,115 @@
/**
* Test Logger for CLI/ACP Cancellation Debugging
*
* This writes logs to ~/.roo/cli-acp-test.log for comparing CLI
* behavior with ACP during cancellation testing.
*
* Format matches ACP logger for easy side-by-side comparison.
*/
import * as fs from "node:fs"
import * as path from "node:path"
import * as os from "node:os"
const LOG_DIR = path.join(os.homedir(), ".roo")
const LOG_FILE = path.join(LOG_DIR, "cli-acp-test.log")
let stream: fs.WriteStream | null = null
/**
* Ensure log file and directory exist.
*/
function ensureLogFile(): void {
try {
if (!fs.existsSync(LOG_DIR)) {
fs.mkdirSync(LOG_DIR, { recursive: true })
}
if (!stream) {
stream = fs.createWriteStream(LOG_FILE, { flags: "a" })
}
} catch {
// Silently fail
}
}
/**
* Format and write a log entry.
*/
function write(level: string, component: string, message: string, data?: unknown): void {
ensureLogFile()
if (!stream) return
const timestamp = new Date().toISOString()
let formatted = `[${timestamp}] [${level}] [${component}] ${message}`
if (data !== undefined) {
try {
const dataStr = JSON.stringify(data, null, 2)
formatted += `\n${dataStr}`
} catch {
formatted += ` [Data: unserializable]`
}
}
stream.write(formatted + "\n")
}
/**
* Test logger for CLI cancellation debugging.
*
* Usage:
* testLog.info("ExtensionClient", "STATE: idle → running (running=true, streaming=true, ask=none)")
* testLog.info("Session", "CANCEL: triggered")
*/
export const testLog = {
info(component: string, message: string, data?: unknown): void {
write("INFO", component, message, data)
},
debug(component: string, message: string, data?: unknown): void {
write("DEBUG", component, message, data)
},
warn(component: string, message: string, data?: unknown): void {
write("WARN", component, message, data)
},
error(component: string, message: string, data?: unknown): void {
write("ERROR", component, message, data)
},
/**
* Clear the log file (call at start of test session).
*/
clear(): void {
try {
if (stream) {
stream.end()
stream = null
}
fs.writeFileSync(LOG_FILE, "")
} catch {
// Silently fail
}
},
/**
* Get the log file path.
*/
getLogPath(): string {
return LOG_FILE
},
/**
* Close the logger.
*/
close(): void {
if (stream) {
stream.end()
stream = null
}
},
}
// Log startup
testLog.info("TestLogger", `CLI test logging initialized. Log file: ${LOG_FILE}`)

View file

@ -0,0 +1,81 @@
import { Readable, Writable } from "node:stream"
import path from "node:path"
import { fileURLToPath } from "node:url"
import * as acpSdk from "@agentclientprotocol/sdk"
import { type SupportedProvider, DEFAULT_FLAGS } from "@/types/index.js"
import { getDefaultExtensionPath } from "@/lib/utils/extension.js"
import { RooCodeAgent, acpLog } from "@/acp/index.js"
export interface AcpCommandOptions {
extension?: string
provider?: SupportedProvider
model?: string
mode?: string
apiKey?: string
}
export async function runAcpServer(options: AcpCommandOptions): Promise<void> {
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const extensionPath = options.extension || getDefaultExtensionPath(__dirname)
if (!extensionPath) {
console.error("Error: Extension path not found. Use --extension to specify the path.")
process.exit(1)
}
// Set up stdio streams for ACP communication.
// Note: We write to stdout (agent -> client) and read from stdin (client -> agent).
const stdout = Writable.toWeb(process.stdout) as WritableStream<Uint8Array>
const stdin = Readable.toWeb(process.stdin) as ReadableStream<Uint8Array>
const stream = acpSdk.ndJsonStream(stdout, stdin)
acpLog.info("Command", "ACP stream created, waiting for connection")
let agent: RooCodeAgent | null = null
const connection = new acpSdk.AgentSideConnection((conn: acpSdk.AgentSideConnection) => {
acpLog.info("Command", "Agent connection established")
agent = new RooCodeAgent(
{
extensionPath,
provider: options.provider ?? DEFAULT_FLAGS.provider,
model: options.model || DEFAULT_FLAGS.model,
mode: options.mode || DEFAULT_FLAGS.mode,
apiKey: options.apiKey || process.env.OPENROUTER_API_KEY,
},
conn,
)
return agent
}, stream)
const cleanup = async () => {
acpLog.info("Command", "Received shutdown signal, cleaning up")
if (agent) {
await agent.dispose()
}
acpLog.info("Command", "Cleanup complete, exiting")
process.exit(0)
}
process.on("SIGINT", cleanup)
process.on("SIGTERM", cleanup)
acpLog.info("Command", "Waiting for connection to close")
await connection.closed
acpLog.info("Command", "Connection closed")
}
export async function acp(options: AcpCommandOptions): Promise<void> {
try {
await runAcpServer(options)
} catch (error) {
acpLog.error("Command", "Fatal error", error)
console.error(error)
process.exit(1)
}
}

View file

@ -168,7 +168,7 @@ export async function run(workspaceArg: string, options: FlagOptions) {
console.log(ASCII_ROO)
console.log()
console.log(
`[roo] Running ${options.model || "default"} (${options.reasoningEffort || "default"}) on ${provider} in ${options.mode || "default"} mode in ${workspacePath}`,
`[roo] Running ${options.model || DEFAULT_FLAGS.model} (${options.reasoningEffort || "default"}) on ${provider} in ${options.mode || "default"} mode in ${workspacePath}`,
)
const host = new ExtensionHost({

View file

@ -1,2 +1,3 @@
export * from "./auth/index.js"
export * from "./cli/index.js"
export * from "./acp/index.js"

View file

@ -2,7 +2,7 @@ import { Command } from "commander"
import { DEFAULT_FLAGS } from "@/types/constants.js"
import { VERSION } from "@/lib/utils/version.js"
import { run, login, logout, status } from "@/commands/index.js"
import { run, login, logout, status, acp } from "@/commands/index.js"
const program = new Command()
@ -62,4 +62,14 @@ authCommand
process.exit(result.authenticated ? 0 : 1)
})
program
.command("acp")
.description("Start ACP server mode for integration with editors like Zed")
.option("-e, --extension <path>", "Path to the extension bundle directory")
.option("-p, --provider <provider>", "API provider (anthropic, openai, openrouter, etc.)", DEFAULT_FLAGS.provider)
.option("-m, --model <model>", "Model to use", DEFAULT_FLAGS.model)
.option("-M, --mode <mode>", "Initial mode (code, architect, ask, debug)", DEFAULT_FLAGS.mode)
.option("-k, --api-key <key>", "API key for the LLM provider")
.action(acp)
program.parse()

View file

@ -2,7 +2,7 @@ import { RooCodeSettings } from "@roo-code/types"
import type { SupportedProvider } from "@/types/index.js"
const envVarMap: Record<SupportedProvider, string> = {
export const envVarMap: Record<SupportedProvider, string> = {
anthropic: "ANTHROPIC_API_KEY",
"openai-native": "OPENAI_API_KEY",
gemini: "GOOGLE_API_KEY",

View file

@ -4,6 +4,7 @@ export const DEFAULT_FLAGS = {
mode: "code",
reasoningEffort: "medium" as const,
model: "anthropic/claude-opus-4.5",
provider: "openrouter" as const,
}
export const REASONING_EFFORTS = [...reasoningEffortsExtended, "unspecified", "disabled"]

View file

@ -18,12 +18,13 @@ import {
useTerminalSize,
useToast,
useExtensionHost,
useMessageHandlers,
useTaskSubmit,
useGlobalInput,
useFollowupCountdown,
useFocusManagement,
usePickerHandlers,
useClientEvents,
useExtensionState,
} from "./hooks/index.js"
// Import extracted utilities.
@ -159,16 +160,14 @@ function AppInner({
// Toast notifications for ephemeral messages (e.g., mode changes).
const { currentToast, showInfo } = useToast()
const {
handleExtensionMessage,
seenMessageIds,
pendingCommandRef: _pendingCommandRef,
firstTextMessageSkipped,
} = useMessageHandlers({
nonInteractive,
})
// Handle non-message extension state (modes, file search, commands, task history)
const { handleExtensionState } = useExtensionState()
const { sendToExtension, runTask, cleanup } = useExtensionHost({
// Track seen message IDs and first text message skip for task submission
const seenMessageIds = useRef<Set<string>>(new Set())
const firstTextMessageSkipped = useRef(false)
const { client, sendToExtension, runTask, cleanup } = useExtensionHost({
initialPrompt,
mode,
reasoningEffort,
@ -182,10 +181,27 @@ function AppInner({
nonInteractive,
ephemeral,
exitOnComplete,
onExtensionMessage: handleExtensionMessage,
onExtensionState: handleExtensionState,
createExtensionHost,
})
// Subscribe to ExtensionClient events for unified message handling
const { reset: resetClientEvents } = useClientEvents({
client,
nonInteractive,
})
// Reset tracking state when task is cleared
useEffect(() => {
if (!client) return
const unsubscribe = client.on("taskCleared" as "stateChange", () => {
seenMessageIds.current.clear()
firstTextMessageSkipped.current = false
resetClientEvents()
})
return unsubscribe
}, [client, resetClientEvents])
// Initialize task submit hook
const { handleSubmit, handleApprove, handleReject } = useTaskSubmit({
sendToExtension,

View file

@ -1,5 +1,6 @@
import { memo } from "react"
// memo temporarily removed for debugging
import { Box, Newline, Text } from "ink"
import { DebugLogger } from "@roo-code/core/cli"
import type { TUIMessage } from "../types.js"
import * as theme from "../theme.js"
@ -7,6 +8,8 @@ import * as theme from "../theme.js"
import TodoDisplay from "./TodoDisplay.js"
import { getToolRenderer } from "./tools/index.js"
const renderLogger = new DebugLogger("RENDER")
/**
* Tool categories for styling
*/
@ -215,6 +218,27 @@ function ChatHistoryItem({ message }: ChatHistoryItemProps) {
</Box>
)
case "tool": {
// Parse rawContent to get content for logging
let parsedContent = ""
try {
const parsed = JSON.parse(content) as Record<string, unknown>
parsedContent = ((parsed.content as string) || "").substring(0, 50)
} catch {
// Not JSON
}
renderLogger.debug("ChatHistoryItem:tool", {
id: message.id,
toolName: message.toolName,
hasToolData: !!message.toolData,
toolDataTool: message.toolData?.tool,
toolDataPath: message.toolData?.path,
toolDataContent: message.toolData?.content?.substring(0, 50),
rawContentLen: content.length,
parsedContent,
partial: message.partial,
})
// Special rendering for update_todo_list tool - show full TODO list
if (
(message.toolName === "update_todo_list" || message.toolName === "updateTodoList") &&
@ -249,4 +273,5 @@ function ChatHistoryItem({ message }: ChatHistoryItemProps) {
}
}
export default memo(ChatHistoryItem)
// Temporarily disable memo to debug streaming rendering issues
export default ChatHistoryItem

View file

@ -41,7 +41,6 @@ export function createHelpTrigger(): AutocompleteTrigger<HelpShortcutResult> {
id: "help",
triggerChar: "?",
position: "line-start",
consumeTrigger: true,
detectTrigger: (lineText: string): TriggerDetectionResult | null => {
// Check if line starts with ? (after optional whitespace)

View file

@ -157,13 +157,5 @@ describe("HelpTrigger", () => {
expect(trigger.emptyMessage).toBe("No matching shortcuts")
expect(trigger.debounceMs).toBe(0)
})
it("should have consumeTrigger set to true", () => {
const trigger = createHelpTrigger()
// The ? character should be consumed (not inserted into input)
// when the help menu is triggered
expect(trigger.consumeTrigger).toBe(true)
})
})
})

View file

@ -210,13 +210,6 @@ describe("HistoryTrigger", () => {
expect(trigger.debounceMs).toBe(100)
})
it("should not have consumeTrigger set (# character appears in input)", () => {
const trigger = createHistoryTrigger({ getHistory: () => mockHistoryItems })
// The # character should remain in the input like other triggers
expect(trigger.consumeTrigger).toBeUndefined()
})
it("should call getHistory when searching", () => {
const getHistoryMock = vi.fn(() => mockHistoryItems)
const trigger = createHistoryTrigger({ getHistory: getHistoryMock })

View file

@ -97,14 +97,6 @@ export interface AutocompleteTrigger<T extends AutocompleteItem = AutocompleteIt
* @default 150
*/
debounceMs?: number
/**
* Whether the trigger character should be consumed (not shown in input).
* When true, the trigger character is treated as a control character
* that activates the picker but doesn't appear in the text input.
* @default false
*/
consumeTrigger?: boolean
}
/**

View file

@ -56,19 +56,6 @@ export function useAutocompletePicker<T extends AutocompleteItem>(
return lines[lines.length - 1] || ""
}, [])
/**
* Get the input value with the trigger character removed.
* Used when a trigger has consumeTrigger: true.
*/
const getConsumedValue = useCallback((value: string, lastLine: string, triggerIndex: number): string => {
const lines = value.split("\n")
const lastLineIndex = lines.length - 1
// Remove the trigger character from the last line
const newLastLine = lastLine.slice(0, triggerIndex) + lastLine.slice(triggerIndex + 1)
lines[lastLineIndex] = newLastLine
return lines.join("\n")
}, [])
/**
* Handle input value changes - detects triggers and initiates search.
* Returns an object indicating if the input should be modified (for consumeTrigger).
@ -120,10 +107,6 @@ export function useAutocompletePicker<T extends AutocompleteItem>(
if (query === lastQuery && state.isOpen && state.activeTrigger?.id === foundTrigger.id) {
// Same query, same trigger - no need to search again
// Still return consumed value if trigger consumes input
if (foundTrigger.consumeTrigger) {
return { consumedValue: getConsumedValue(value, lastLine, foundTriggerInfo.triggerIndex) }
}
return {}
}
@ -207,14 +190,9 @@ export function useAutocompletePicker<T extends AutocompleteItem>(
debounceTimersRef.current.set(foundTrigger.id, timer)
// Return consumed value if trigger consumes input
if (foundTrigger.consumeTrigger) {
return { consumedValue: getConsumedValue(value, lastLine, foundTriggerInfo.triggerIndex) }
}
return {}
},
[triggers, state.isOpen, state.activeTrigger?.id, getLastLine, getConsumedValue],
[triggers, state.isOpen, state.activeTrigger?.id, getLastLine],
)
/**

View file

@ -4,11 +4,11 @@ import * as theme from "../../theme.js"
import { Icon } from "../Icon.js"
import type { ToolRendererProps } from "./types.js"
import { truncateText, sanitizeContent, getToolDisplayName, getToolIconName, parseDiff } from "./utils.js"
import { sanitizeContent, getToolDisplayName, getToolIconName, parseDiff } from "./utils.js"
const MAX_DIFF_LINES = 15
const MAX_PREVIEW_LINES = 5
export function FileWriteTool({ toolData }: ToolRendererProps) {
export function FileWriteTool({ toolData, rawContent }: ToolRendererProps) {
const iconName = getToolIconName(toolData.tool)
const displayName = getToolDisplayName(toolData.tool)
const path = toolData.path || ""
@ -18,6 +18,20 @@ export function FileWriteTool({ toolData }: ToolRendererProps) {
const isOutsideWorkspace = toolData.isOutsideWorkspace
const isNewFile = toolData.tool === "newFileCreated" || toolData.tool === "write_to_file"
// For streaming: rawContent is updated with each message, so parse it for live content
// toolData.content may be stale during streaming due to debounce optimization
let liveContent = toolData.content || ""
if (rawContent && isNewFile) {
try {
const parsed = JSON.parse(rawContent) as Record<string, unknown>
if (parsed.content && typeof parsed.content === "string") {
liveContent = parsed.content
}
} catch {
// Use toolData.content if rawContent isn't valid JSON
}
}
// Handle batch diff operations
if (toolData.batchDiffs && toolData.batchDiffs.length > 0) {
return (
@ -57,9 +71,20 @@ export function FileWriteTool({ toolData }: ToolRendererProps) {
}
// Single file write
const { text: previewDiff, truncated, hiddenLines } = truncateText(diff, MAX_DIFF_LINES)
// For new files, display streaming content; for edits, show diff
const diffHunks = diff ? parseDiff(diff) : []
// Process content for display - split into lines and truncate
const sanitizedContent = isNewFile && liveContent ? sanitizeContent(liveContent) : ""
const contentLines = sanitizedContent ? sanitizedContent.split("\n") : []
const displayLines = contentLines.slice(0, MAX_PREVIEW_LINES)
const truncatedLineCount = contentLines.length - MAX_PREVIEW_LINES
const isContentTruncated = truncatedLineCount > 0
// Stats for the header
const totalLines = contentLines.length
const totalChars = liveContent.length
return (
<Box flexDirection="column" paddingX={1} marginBottom={1}>
{/* Header row with path on same line */}
@ -76,15 +101,15 @@ export function FileWriteTool({ toolData }: ToolRendererProps) {
</Text>
</>
)}
{isNewFile && (
{isNewFile && !diffStats && (
<Text color={theme.successColor} bold>
{" "}
NEW
</Text>
)}
{/* Diff stats badge */}
{diffStats && (
{/* Stats - show line/char count for streaming, or diff stats when complete */}
{diffStats ? (
<>
<Text color={theme.dimText}> </Text>
<Text color={theme.successColor} bold>
@ -95,6 +120,14 @@ export function FileWriteTool({ toolData }: ToolRendererProps) {
-{diffStats.removed}
</Text>
</>
) : (
isNewFile &&
totalChars > 0 && (
<Text color={theme.dimText}>
{" "}
({totalLines} lines, {totalChars} chars)
</Text>
)
)}
{/* Warning badges */}
@ -107,7 +140,23 @@ export function FileWriteTool({ toolData }: ToolRendererProps) {
)}
</Box>
{/* Diff preview */}
{/* Streaming content preview for new files (before diff is available) */}
{isNewFile && !diff && displayLines.length > 0 && (
<Box flexDirection="column" marginLeft={2} marginTop={1}>
{displayLines.map((line, index) => (
<Text key={index} color={theme.toolText}>
{line}
</Text>
))}
{isContentTruncated && (
<Text color={theme.dimText} dimColor>
... ({truncatedLineCount} more lines)
</Text>
)}
</Box>
)}
{/* Diff preview for edits */}
{diffHunks.length > 0 && (
<Box flexDirection="column" marginLeft={2} marginTop={1}>
{diffHunks.slice(0, 2).map((hunk, hunkIndex) => (
@ -149,13 +198,20 @@ export function FileWriteTool({ toolData }: ToolRendererProps) {
</Box>
)}
{/* Fallback to raw diff if no hunks parsed */}
{diffHunks.length === 0 && previewDiff && (
{/* Fallback: show raw diff content if no hunks parsed and not streaming new file */}
{!isNewFile && diffHunks.length === 0 && diff && (
<Box flexDirection="column" marginLeft={2} marginTop={1}>
<Text color={theme.toolText}>{previewDiff}</Text>
{truncated && (
{diff
.split("\n")
.slice(0, MAX_PREVIEW_LINES)
.map((line, index) => (
<Text key={index} color={theme.toolText}>
{line}
</Text>
))}
{diff.split("\n").length > MAX_PREVIEW_LINES && (
<Text color={theme.dimText} dimColor>
... ({hiddenLines} more lines)
... ({diff.split("\n").length - MAX_PREVIEW_LINES} more lines)
</Text>
)}
</Box>

View file

@ -6,17 +6,19 @@ export { useInputHistory } from "./useInputHistory.js"
// Export new extracted hooks
export { useFollowupCountdown } from "./useFollowupCountdown.js"
export { useFocusManagement } from "./useFocusManagement.js"
export { useMessageHandlers } from "./useMessageHandlers.js"
export { useExtensionHost } from "./useExtensionHost.js"
export { useTaskSubmit } from "./useTaskSubmit.js"
export { useGlobalInput } from "./useGlobalInput.js"
export { usePickerHandlers } from "./usePickerHandlers.js"
export { useClientEvents } from "./useClientEvents.js"
export { useExtensionState } from "./useExtensionState.js"
// Export types
export type { UseFollowupCountdownOptions } from "./useFollowupCountdown.js"
export type { UseFocusManagementOptions, UseFocusManagementReturn } from "./useFocusManagement.js"
export type { UseMessageHandlersOptions, UseMessageHandlersReturn } from "./useMessageHandlers.js"
export type { UseExtensionHostOptions, UseExtensionHostReturn } from "./useExtensionHost.js"
export type { UseTaskSubmitOptions, UseTaskSubmitReturn } from "./useTaskSubmit.js"
export type { UseGlobalInputOptions } from "./useGlobalInput.js"
export type { UsePickerHandlersOptions, UsePickerHandlersReturn } from "./usePickerHandlers.js"
export type { UseClientEventsOptions, UseClientEventsReturn } from "./useClientEvents.js"
export type { UseExtensionStateReturn } from "./useExtensionState.js"

View file

@ -0,0 +1,516 @@
/**
* useClientEvents - Bridge ExtensionClient events to TUI state
*
* This hook subscribes to ExtensionClient events (the same events used by
* non-TUI mode) and transforms them into TUI messages/state updates.
*
* This unifies the message handling logic between TUI and non-TUI modes:
* - Non-TUI: ExtensionClient events OutputManager/AskDispatcher
* - TUI: ExtensionClient events useClientEvents Zustand store
*/
import { useEffect, useRef, useCallback } from "react"
import type { ClineMessage, ClineAsk, ClineSay, TodoItem } from "@roo-code/types"
import { consolidateTokenUsage, consolidateApiRequests, consolidateCommands, DebugLogger } from "@roo-code/core/cli"
// Debug logger using same pattern as extension-host.ts
const tuiLogger = new DebugLogger("TUI")
import type { ExtensionClient } from "@/agent/index.js"
import type { WaitingForInputEvent, CommandExecutionOutputEvent } from "@/agent/events.js"
import type { TUIMessage, ToolData, PendingAsk } from "../types.js"
import { useCLIStore } from "../store.js"
import { extractToolData, formatToolOutput, formatToolAskMessage, parseTodosFromToolInfo } from "../utils/tools.js"
export interface UseClientEventsOptions {
client: ExtensionClient | null
nonInteractive: boolean
}
export interface UseClientEventsReturn {
/** Reset tracking state (call when starting new task) */
reset: () => void
}
/**
* Hook that subscribes to ExtensionClient events and updates TUI state.
*
* Key events:
* - `message`: New ClineMessage transform to TUIMessage and add to store
* - `messageUpdated`: Updated ClineMessage update existing TUIMessage
* - `waitingForInput`: Ask needing input set pendingAsk
*/
export function useClientEvents({ client, nonInteractive }: UseClientEventsOptions): UseClientEventsReturn {
const { addMessage, setPendingAsk, setLoading, setTokenUsage, currentTodos, setTodos } = useCLIStore()
// Track seen message timestamps to filter duplicates
const seenMessageIds = useRef<Set<string>>(new Set())
const firstTextMessageSkipped = useRef(false)
// Track pending command for injecting into command_output toolData
const pendingCommandRef = useRef<string | null>(null)
// Track the message ID of the current command being executed (for streaming updates)
const currentCommandMessageIdRef = useRef<string | null>(null)
// Track if we've streamed command output (to skip duplicate command_output say message)
const hasStreamedCommandOutputRef = useRef(false)
// Track the message ID of partial tool asks (for streaming file write updates)
const partialToolMessageIdRef = useRef<string | null>(null)
/**
* Reset tracking state (call when starting new task)
*/
const reset = useCallback(() => {
seenMessageIds.current.clear()
firstTextMessageSkipped.current = false
pendingCommandRef.current = null
currentCommandMessageIdRef.current = null
hasStreamedCommandOutputRef.current = false
partialToolMessageIdRef.current = null
}, [])
/**
* Transform a ClineMessage to TUIMessage and add to store
*/
const processClineMessage = useCallback(
(msg: ClineMessage) => {
const ts = msg.ts
const messageId = ts.toString()
const text = msg.text || ""
const partial = msg.partial || false
const isResuming = useCLIStore.getState().isResumingTask
// DEBUG: Log all ask messages to trace partial handling
if (msg.type === "ask") {
tuiLogger.debug("ask:received", {
ask: msg.ask,
partial,
textLen: text.length,
id: messageId,
})
}
if (msg.type === "say" && msg.say) {
processSayMessage(messageId, msg.say, text, partial, isResuming)
} else if (msg.type === "ask" && msg.ask) {
processAskMessage(messageId, msg.ask, text, partial)
}
},
[nonInteractive, currentTodos],
)
/**
* Process "say" type messages
*/
const processSayMessage = useCallback(
(messageId: string, say: ClineSay, text: string, partial: boolean, isResuming: boolean) => {
// Skip certain message types
if (say === "checkpoint_saved" || say === "api_req_started" || say === "user_feedback") {
seenMessageIds.current.add(messageId)
return
}
// Skip first text message for new tasks (it's the user's prompt echo)
if (say === "text" && !firstTextMessageSkipped.current && !isResuming) {
firstTextMessageSkipped.current = true
seenMessageIds.current.add(messageId)
return
}
// Skip if already seen (non-partial)
if (seenMessageIds.current.has(messageId) && !partial) {
return
}
let role: TUIMessage["role"] = "assistant"
let toolName: string | undefined
let toolDisplayName: string | undefined
let toolDisplayOutput: string | undefined
let toolData: ToolData | undefined
if (say === "command_output") {
// Skip command_output say message if we've already streamed the output
// The streaming updates went to the command ask message directly
if (hasStreamedCommandOutputRef.current) {
seenMessageIds.current.add(messageId)
// Reset for next command
hasStreamedCommandOutputRef.current = false
currentCommandMessageIdRef.current = null
return
}
// Non-streamed case: add the command output message
role = "tool"
toolName = "execute_command"
toolDisplayName = "bash"
toolDisplayOutput = text
const trackedCommand = pendingCommandRef.current
toolData = { tool: "execute_command", command: trackedCommand || undefined, output: text }
pendingCommandRef.current = null
} else if (say === "reasoning") {
role = "thinking"
}
seenMessageIds.current.add(messageId)
addMessage({
id: messageId,
role,
content: text || "",
toolName,
toolDisplayName,
toolDisplayOutput,
partial,
originalType: say,
toolData,
})
},
[addMessage],
)
/**
* Process "ask" type messages
*/
const processAskMessage = useCallback(
(messageId: string, ask: ClineAsk, text: string, partial: boolean) => {
// DEBUG: Log entry to processAskMessage
tuiLogger.debug("ask:process", {
ask,
partial,
nonInteractive,
id: messageId,
})
// Handle partial tool asks in nonInteractive mode - stream file content as it arrives
// This allows FileWriteTool to show immediately and update as content streams
if (partial && ask === "tool" && nonInteractive) {
// Parse tool info to extract streaming content
let toolName: string | undefined
let toolDisplayName: string | undefined
let toolDisplayOutput: string | undefined
let toolData: ToolData | undefined
let parseError = false
try {
const toolInfo = JSON.parse(text) as Record<string, unknown>
toolName = toolInfo.tool as string
toolDisplayName = toolInfo.tool as string
toolDisplayOutput = formatToolOutput(toolInfo)
toolData = extractToolData(toolInfo)
} 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", {
id: messageId,
textLen: text.length,
toolName: toolName || "none",
hasToolData: !!toolData,
toolDataPath: toolData?.path,
toolDataContentLen: toolData?.content?.length || 0,
parseError,
})
// Track that we're streaming this tool ask
partialToolMessageIdRef.current = messageId
// Add/update the message with partial content
// Use raw JSON text as content so FileWriteTool can parse live content during streaming
addMessage({
id: messageId,
role: "tool",
content: text, // Raw JSON text - needed for streaming content parsing
toolName,
toolDisplayName,
toolDisplayOutput,
partial: true, // Mark as partial for UI to show loading state
originalType: ask,
toolData,
})
return
}
// Skip other partial ask messages - wait for complete
if (partial) {
return
}
// Skip if already processed (but allow updates to partial tool messages)
if (seenMessageIds.current.has(messageId) && partialToolMessageIdRef.current !== messageId) {
return
}
// Skip command_output asks (non-blocking)
if (ask === "command_output") {
seenMessageIds.current.add(messageId)
return
}
// Handle resume tasks - don't set pendingAsk
if (ask === "resume_task" || ask === "resume_completed_task") {
seenMessageIds.current.add(messageId)
setLoading(false)
useCLIStore.getState().setHasStartedTask(true)
useCLIStore.getState().setIsResumingTask(false)
return
}
// Track pending command
if (ask === "command") {
pendingCommandRef.current = text
}
// Handle completion result
if (ask === "completion_result") {
seenMessageIds.current.add(messageId)
// Completion is handled by taskCompleted event
// Just add the message for display
try {
const completionInfo = JSON.parse(text) as Record<string, unknown>
const toolData: ToolData = {
tool: "attempt_completion",
result: completionInfo.result as string | undefined,
content: completionInfo.result as string | undefined,
}
addMessage({
id: messageId,
role: "tool",
content: text,
toolName: "attempt_completion",
toolDisplayName: "Task Complete",
toolDisplayOutput: formatToolOutput({ tool: "attempt_completion", ...completionInfo }),
originalType: ask,
toolData,
})
} catch {
addMessage({
id: messageId,
role: "tool",
content: text || "Task completed",
toolName: "attempt_completion",
toolDisplayName: "Task Complete",
toolDisplayOutput: "✅ Task completed",
originalType: ask,
toolData: { tool: "attempt_completion", content: text },
})
}
return
}
// For tool/command asks in nonInteractive mode, add as message (auto-approved)
if (nonInteractive && ask !== "followup") {
seenMessageIds.current.add(messageId)
if (ask === "tool") {
// Clear partial tracking - this is the final message
const wasPartial = partialToolMessageIdRef.current === messageId
partialToolMessageIdRef.current = null
let toolName: string | undefined
let toolDisplayName: string | undefined
let toolDisplayOutput: string | undefined
let toolData: ToolData | undefined
let todos: TodoItem[] | undefined
let previousTodos: TodoItem[] | undefined
try {
const toolInfo = JSON.parse(text) as Record<string, unknown>
toolName = toolInfo.tool as string
toolDisplayName = toolInfo.tool as string
toolDisplayOutput = formatToolOutput(toolInfo)
toolData = extractToolData(toolInfo)
// Handle todo list updates
if (toolName === "update_todo_list" || toolName === "updateTodoList") {
const parsedTodos = parseTodosFromToolInfo(toolInfo)
if (parsedTodos && parsedTodos.length > 0) {
todos = parsedTodos
previousTodos = [...currentTodos]
setTodos(parsedTodos)
}
}
} catch {
// Use raw text if not valid JSON
}
addMessage({
id: messageId,
role: "tool",
content: text, // Raw JSON text - needed for tool renderers to parse live content
toolName,
toolDisplayName,
toolDisplayOutput,
partial: false, // Final message - not partial
originalType: ask,
toolData,
todos,
previousTodos,
})
// If we were streaming, the update already happened via addMessage
if (wasPartial) {
return
}
} else if (ask === "command") {
// For command asks, add as tool message with command but no output yet
// Store the message ID so streaming can update it
currentCommandMessageIdRef.current = messageId
pendingCommandRef.current = text
addMessage({
id: messageId,
role: "tool",
content: "",
toolName: "execute_command",
toolDisplayName: "bash",
originalType: ask,
toolData: { tool: "execute_command", command: text },
})
} else {
// Other asks - add as assistant message
addMessage({
id: messageId,
role: "assistant",
content: text || "",
originalType: ask,
})
}
return
}
// Interactive mode - set pending ask for user input
seenMessageIds.current.add(messageId)
let suggestions: Array<{ answer: string; mode?: string | null }> | undefined
let questionText = text
if (ask === "followup") {
try {
const data = JSON.parse(text)
questionText = data.question || text
suggestions = Array.isArray(data.suggest) ? data.suggest : undefined
} catch {
// Use raw text
}
} else if (ask === "tool") {
try {
const toolInfo = JSON.parse(text) as Record<string, unknown>
questionText = formatToolAskMessage(toolInfo)
} catch {
// Use raw text
}
}
const pendingAsk: PendingAsk = {
id: messageId,
type: ask,
content: questionText,
suggestions,
}
setPendingAsk(pendingAsk)
},
[addMessage, setPendingAsk, setLoading, nonInteractive, currentTodos, setTodos],
)
/**
* Handle waitingForInput event from ExtensionClient
* This is emitted when an ask message needs user input
*/
const handleWaitingForInput = useCallback(
(event: WaitingForInputEvent) => {
const msg = event.message
if (msg.type === "ask" && msg.ask) {
processAskMessage(msg.ts.toString(), msg.ask, msg.text || "", false)
}
},
[processAskMessage],
)
// Subscribe to client events
useEffect(() => {
tuiLogger.debug("useEffect:client", { hasClient: !!client })
if (!client) return
tuiLogger.debug("useEffect:subscribing", { clientId: "ExtensionClient" })
// Subscribe to message events
const unsubMessage = client.on("message", processClineMessage)
const unsubUpdated = client.on("messageUpdated", processClineMessage)
const unsubWaiting = client.on("waitingForInput", handleWaitingForInput)
// Handle streaming terminal output during command execution.
// This updates the existing command message with live output.
const unsubCommandOutput = client.on("commandExecutionOutput", (event: CommandExecutionOutputEvent) => {
// Mark that we've streamed output (to skip the final command_output say message)
hasStreamedCommandOutputRef.current = true
// If we have a command message ID, update that message's output by re-adding with same ID
const msgId = currentCommandMessageIdRef.current
if (msgId) {
// Re-add the message with the same ID to update it (addMessage handles updates)
addMessage({
id: msgId,
role: "tool",
content: event.output,
toolName: "execute_command",
toolDisplayName: "bash",
toolDisplayOutput: event.output, // This is what CommandTool displays
partial: false, // Non-partial to bypass debounce
originalType: "command",
toolData: {
tool: "execute_command",
command: pendingCommandRef.current || undefined,
output: event.output,
},
})
} else {
// Fallback: create a new message if we don't have a command message ID
const streamingMsgId = `streaming-cmd-${event.executionId}`
addMessage({
id: streamingMsgId,
role: "tool",
content: event.output,
toolName: "execute_command",
toolDisplayName: "bash",
toolDisplayOutput: event.output,
partial: false,
originalType: "command_output",
toolData: {
tool: "execute_command",
command: pendingCommandRef.current || undefined,
output: event.output,
},
})
}
})
// Update token usage when messages change
const unsubStateChange = client.on("stateChange", () => {
const messages = client.getMessages()
if (messages.length > 1) {
const processed = consolidateApiRequests(consolidateCommands(messages.slice(1)))
const metrics = consolidateTokenUsage(processed)
setTokenUsage(metrics)
}
})
return () => {
unsubMessage()
unsubUpdated()
unsubWaiting()
unsubCommandOutput()
unsubStateChange()
}
}, [client, processClineMessage, handleWaitingForInput, setTokenUsage])
return { reset }
}

View file

@ -1,21 +1,27 @@
import { useEffect, useRef, useCallback, useMemo } from "react"
import { useEffect, useRef, useState, useCallback, useMemo } from "react"
import { useApp } from "ink"
import { randomUUID } from "crypto"
import type { ExtensionMessage, WebviewMessage } from "@roo-code/types"
import { ExtensionHostInterface, ExtensionHostOptions } from "@/agent/index.js"
import { ExtensionClient, ExtensionHostInterface, ExtensionHostOptions } from "@/agent/index.js"
import { useCLIStore } from "../store.js"
export interface UseExtensionHostOptions extends ExtensionHostOptions {
initialPrompt?: string
exitOnComplete?: boolean
onExtensionMessage: (msg: ExtensionMessage) => void
/**
* Handle non-message extension state (modes, file search, commands, etc.)
* ClineMessage processing should use useClientEvents instead.
*/
onExtensionState?: (msg: ExtensionMessage) => void
createExtensionHost: (options: ExtensionHostOptions) => ExtensionHostInterface
}
export interface UseExtensionHostReturn {
isReady: boolean
/** ExtensionClient for subscribing to message events */
client: ExtensionClient | null
sendToExtension: ((msg: WebviewMessage) => void) | null
runTask: ((prompt: string) => Promise<void>) | null
cleanup: () => Promise<void>
@ -43,19 +49,23 @@ export function useExtensionHost({
nonInteractive,
ephemeral,
exitOnComplete,
onExtensionMessage,
onExtensionState,
createExtensionHost,
}: UseExtensionHostOptions): UseExtensionHostReturn {
const { exit } = useApp()
const { addMessage, setComplete, setLoading, setHasStartedTask, setError } = useCLIStore()
const hostRef = useRef<ExtensionHostInterface | null>(null)
// Use state for client so that consumers re-render when it becomes available.
// This is critical for useClientEvents which needs the client to subscribe to events.
const [client, setClient] = useState<ExtensionClient | null>(null)
const isReadyRef = useRef(false)
const cleanup = useCallback(async () => {
if (hostRef.current) {
await hostRef.current.dispose()
hostRef.current = null
setClient(null)
isReadyRef.current = false
}
}, [])
@ -78,9 +88,15 @@ export function useExtensionHost({
})
hostRef.current = host
// Setting client via state triggers re-render so useClientEvents
// receives the valid client and can subscribe to events.
setClient(host.client)
isReadyRef.current = true
host.on("extensionWebviewMessage", (msg) => onExtensionMessage(msg as ExtensionMessage))
// Handle non-message state updates (modes, file search, commands, task history)
if (onExtensionState) {
host.on("extensionWebviewMessage", (msg) => onExtensionState(msg as ExtensionMessage))
}
host.client.on("taskCompleted", async () => {
setComplete(true)
@ -142,9 +158,9 @@ export function useExtensionHost({
return hostRef.current.runTask(prompt)
}, [])
// Memoized return object to prevent unnecessary re-renders in consumers.
// Return object includes client state directly so consumers re-render when client changes.
return useMemo(
() => ({ isReady: isReadyRef.current, sendToExtension, runTask, cleanup }),
[sendToExtension, runTask, cleanup],
() => ({ isReady: isReadyRef.current, client, sendToExtension, runTask, cleanup }),
[client, sendToExtension, runTask, cleanup],
)
}

View file

@ -0,0 +1,83 @@
/**
* useExtensionState - Handle non-message extension state updates
*
* This hook handles extension state that is NOT part of ClineMessage processing:
* - Mode changes (current mode, available modes)
* - File search results
* - Slash commands list
* - Task history
* - Router models
*
* ClineMessage processing is handled by useClientEvents, which subscribes to
* ExtensionClient events (the unified approach for both TUI and non-TUI modes).
*/
import { useCallback } from "react"
import type { ExtensionMessage } from "@roo-code/types"
import type { FileResult, SlashCommandResult, ModeResult } from "../components/autocomplete/index.js"
import { useCLIStore } from "../store.js"
export interface UseExtensionStateReturn {
handleExtensionState: (msg: ExtensionMessage) => void
}
/**
* Hook to handle non-message extension state updates.
* This is used alongside useClientEvents which handles ClineMessage events.
*/
export function useExtensionState(): UseExtensionStateReturn {
const {
setFileSearchResults,
setAllSlashCommands,
setAvailableModes,
setCurrentMode,
setTaskHistory,
setRouterModels,
} = useCLIStore()
/**
* Handle extension messages that contain state updates.
* Only processes non-ClineMessage state.
*/
const handleExtensionState = useCallback(
(msg: ExtensionMessage) => {
if (msg.type === "state") {
const state = msg.state
if (!state) {
return
}
// Extract and update current mode from state
const newMode = state.mode
if (newMode) {
setCurrentMode(newMode)
}
// Extract and update task history from state
const newTaskHistory = state.taskHistory
if (newTaskHistory && Array.isArray(newTaskHistory)) {
setTaskHistory(newTaskHistory)
}
// Note: ClineMessages are handled by useClientEvents via ExtensionClient events
} else if (msg.type === "fileSearchResults") {
setFileSearchResults((msg.results as FileResult[]) || [])
} else if (msg.type === "commands") {
setAllSlashCommands((msg.commands as SlashCommandResult[]) || [])
} else if (msg.type === "modes") {
setAvailableModes((msg.modes as ModeResult[]) || [])
} else if (msg.type === "routerModels") {
if (msg.routerModels) {
setRouterModels(msg.routerModels)
}
}
},
[setFileSearchResults, setAllSlashCommands, setAvailableModes, setCurrentMode, setTaskHistory, setRouterModels],
)
return { handleExtensionState }
}

View file

@ -1,410 +0,0 @@
import { useCallback, useRef } from "react"
import type { ExtensionMessage, ClineMessage, ClineAsk, ClineSay, TodoItem } from "@roo-code/types"
import { consolidateTokenUsage, consolidateApiRequests, consolidateCommands } from "@roo-code/core/cli"
import type { TUIMessage, ToolData } from "../types.js"
import type { FileResult, SlashCommandResult, ModeResult } from "../components/autocomplete/index.js"
import { useCLIStore } from "../store.js"
import { extractToolData, formatToolOutput, formatToolAskMessage, parseTodosFromToolInfo } from "../utils/tools.js"
export interface UseMessageHandlersOptions {
nonInteractive: boolean
}
export interface UseMessageHandlersReturn {
handleExtensionMessage: (msg: ExtensionMessage) => void
seenMessageIds: React.MutableRefObject<Set<string>>
pendingCommandRef: React.MutableRefObject<string | null>
firstTextMessageSkipped: React.MutableRefObject<boolean>
}
/**
* Hook to handle messages from the extension.
*
* Processes three types of messages:
* 1. "say" messages - Information from the agent (text, tool output, reasoning)
* 2. "ask" messages - Requests for user input (approvals, followup questions)
* 3. Extension state updates - Mode changes, task history, file search results
*
* Transforms ClineMessage format to TUIMessage format and updates the store.
*/
export function useMessageHandlers({ nonInteractive }: UseMessageHandlersOptions): UseMessageHandlersReturn {
const {
addMessage,
setPendingAsk,
setComplete,
setLoading,
setHasStartedTask,
setFileSearchResults,
setAllSlashCommands,
setAvailableModes,
setCurrentMode,
setTokenUsage,
setRouterModels,
setTaskHistory,
currentTodos,
setTodos,
} = useCLIStore()
// Track seen message timestamps to filter duplicates and the prompt echo
const seenMessageIds = useRef<Set<string>>(new Set())
const firstTextMessageSkipped = useRef(false)
// Track pending command for injecting into command_output toolData
const pendingCommandRef = useRef<string | null>(null)
/**
* Map extension "say" messages to TUI messages
*/
const handleSayMessage = useCallback(
(ts: number, say: ClineSay, text: string, partial: boolean) => {
const messageId = ts.toString()
const isResuming = useCLIStore.getState().isResumingTask
if (say === "checkpoint_saved") {
return
}
if (say === "api_req_started") {
return
}
if (say === "user_feedback") {
seenMessageIds.current.add(messageId)
return
}
// Skip first text message ONLY for new tasks, not resumed tasks
// When resuming, we want to show all historical messages including the first one
if (say === "text" && !firstTextMessageSkipped.current && !isResuming) {
firstTextMessageSkipped.current = true
seenMessageIds.current.add(messageId)
return
}
if (seenMessageIds.current.has(messageId) && !partial) {
return
}
let role: TUIMessage["role"] = "assistant"
let toolName: string | undefined
let toolDisplayName: string | undefined
let toolDisplayOutput: string | undefined
let toolData: ToolData | undefined
if (say === "command_output") {
role = "tool"
toolName = "execute_command"
toolDisplayName = "bash"
toolDisplayOutput = text
const trackedCommand = pendingCommandRef.current
toolData = { tool: "execute_command", command: trackedCommand || undefined, output: text }
pendingCommandRef.current = null
} else if (say === "reasoning") {
role = "thinking"
}
seenMessageIds.current.add(messageId)
addMessage({
id: messageId,
role,
content: text || "",
toolName,
toolDisplayName,
toolDisplayOutput,
partial,
originalType: say,
toolData,
})
},
[addMessage],
)
/**
* Handle extension "ask" messages
*/
const handleAskMessage = useCallback(
(ts: number, ask: ClineAsk, text: string, partial: boolean) => {
const messageId = ts.toString()
if (partial) {
return
}
if (seenMessageIds.current.has(messageId)) {
return
}
if (ask === "command_output") {
seenMessageIds.current.add(messageId)
return
}
// Handle resume_task and resume_completed_task - stop loading and show text input
// Do not set pendingAsk - just stop loading so user sees normal input to type new message
if (ask === "resume_task" || ask === "resume_completed_task") {
seenMessageIds.current.add(messageId)
setLoading(false)
// Mark that a task has been started so subsequent messages continue the task
// (instead of starting a brand new task via runTask)
setHasStartedTask(true)
// Clear the resuming flag since we're now ready for interaction
// Historical messages should already be displayed from state processing
useCLIStore.getState().setIsResumingTask(false)
// Do not set pendingAsk - let the normal text input appear
return
}
if (ask === "completion_result") {
seenMessageIds.current.add(messageId)
setComplete(true)
setLoading(false)
// Parse the completion result and add a message for CompletionTool to render
try {
const completionInfo = JSON.parse(text) as Record<string, unknown>
const toolData: ToolData = {
tool: "attempt_completion",
result: completionInfo.result as string | undefined,
content: completionInfo.result as string | undefined,
}
addMessage({
id: messageId,
role: "tool",
content: text,
toolName: "attempt_completion",
toolDisplayName: "Task Complete",
toolDisplayOutput: formatToolOutput({ tool: "attempt_completion", ...completionInfo }),
originalType: ask,
toolData,
})
} catch {
// If parsing fails, still add a basic completion message
addMessage({
id: messageId,
role: "tool",
content: text || "Task completed",
toolName: "attempt_completion",
toolDisplayName: "Task Complete",
toolDisplayOutput: "✅ Task completed",
originalType: ask,
toolData: {
tool: "attempt_completion",
content: text,
},
})
}
return
}
// Track pending command BEFORE nonInteractive handling
// This ensures we capture the command text for later injection into command_output toolData
if (ask === "command") {
pendingCommandRef.current = text
}
if (nonInteractive && ask !== "followup") {
seenMessageIds.current.add(messageId)
if (ask === "tool") {
let toolName: string | undefined
let toolDisplayName: string | undefined
let toolDisplayOutput: string | undefined
let formattedContent = text || ""
let toolData: ToolData | undefined
let todos: TodoItem[] | undefined
let previousTodos: TodoItem[] | undefined
try {
const toolInfo = JSON.parse(text) as Record<string, unknown>
toolName = toolInfo.tool as string
toolDisplayName = toolInfo.tool as string
toolDisplayOutput = formatToolOutput(toolInfo)
formattedContent = formatToolAskMessage(toolInfo)
// Extract structured toolData for rich rendering
toolData = extractToolData(toolInfo)
// Special handling for update_todo_list tool - extract todos
if (toolName === "update_todo_list" || toolName === "updateTodoList") {
const parsedTodos = parseTodosFromToolInfo(toolInfo)
if (parsedTodos && parsedTodos.length > 0) {
todos = parsedTodos
// Capture previous todos before updating global state
previousTodos = [...currentTodos]
setTodos(parsedTodos)
}
}
} catch {
// Use raw text if not valid JSON
}
addMessage({
id: messageId,
role: "tool",
content: formattedContent,
toolName,
toolDisplayName,
toolDisplayOutput,
originalType: ask,
toolData,
todos,
previousTodos,
})
} else {
addMessage({
id: messageId,
role: "assistant",
content: text || "",
originalType: ask,
})
}
return
}
let suggestions: Array<{ answer: string; mode?: string | null }> | undefined
let questionText = text
if (ask === "followup") {
try {
const data = JSON.parse(text)
questionText = data.question || text
suggestions = Array.isArray(data.suggest) ? data.suggest : undefined
} catch {
// Use raw text
}
} else if (ask === "tool") {
try {
const toolInfo = JSON.parse(text) as Record<string, unknown>
questionText = formatToolAskMessage(toolInfo)
} catch {
// Use raw text if not valid JSON
}
}
// Note: ask === "command" is handled above before the nonInteractive block
seenMessageIds.current.add(messageId)
setPendingAsk({
id: messageId,
type: ask,
content: questionText,
suggestions,
})
},
[addMessage, setPendingAsk, setComplete, setLoading, setHasStartedTask, nonInteractive, currentTodos, setTodos],
)
/**
* Handle all extension messages
*/
const handleExtensionMessage = useCallback(
(msg: ExtensionMessage) => {
if (msg.type === "state") {
const state = msg.state
if (!state) {
return
}
// Extract and update current mode from state
const newMode = state.mode
if (newMode) {
setCurrentMode(newMode)
}
// Extract and update task history from state
const newTaskHistory = state.taskHistory
if (newTaskHistory && Array.isArray(newTaskHistory)) {
setTaskHistory(newTaskHistory)
}
const clineMessages = state.clineMessages
if (clineMessages) {
for (const clineMsg of clineMessages) {
const ts = clineMsg.ts
const type = clineMsg.type
const say = clineMsg.say
const ask = clineMsg.ask
const text = clineMsg.text || ""
const partial = clineMsg.partial || false
if (type === "say" && say) {
handleSayMessage(ts, say, text, partial)
} else if (type === "ask" && ask) {
handleAskMessage(ts, ask, text, partial)
}
}
// Compute token usage metrics from clineMessages
// Skip first message (task prompt) as per webview UI pattern
if (clineMessages.length > 1) {
const processed = consolidateApiRequests(
consolidateCommands(clineMessages.slice(1) as ClineMessage[]),
)
const metrics = consolidateTokenUsage(processed)
setTokenUsage(metrics)
}
}
// After processing state, clear the resuming flag if it was set
// This ensures the flag is cleared even if no resume_task ask message is received
if (useCLIStore.getState().isResumingTask) {
useCLIStore.getState().setIsResumingTask(false)
}
} else if (msg.type === "messageUpdated") {
const clineMessage = msg.clineMessage
if (!clineMessage) {
return
}
const ts = clineMessage.ts
const type = clineMessage.type
const say = clineMessage.say
const ask = clineMessage.ask
const text = clineMessage.text || ""
const partial = clineMessage.partial || false
if (type === "say" && say) {
handleSayMessage(ts, say, text, partial)
} else if (type === "ask" && ask) {
handleAskMessage(ts, ask, text, partial)
}
} else if (msg.type === "fileSearchResults") {
setFileSearchResults((msg.results as FileResult[]) || [])
} else if (msg.type === "commands") {
setAllSlashCommands((msg.commands as SlashCommandResult[]) || [])
} else if (msg.type === "modes") {
setAvailableModes((msg.modes as ModeResult[]) || [])
} else if (msg.type === "routerModels") {
if (msg.routerModels) {
setRouterModels(msg.routerModels)
}
}
},
[
handleSayMessage,
handleAskMessage,
setFileSearchResults,
setAllSlashCommands,
setAvailableModes,
setCurrentMode,
setTokenUsage,
setRouterModels,
setTaskHistory,
],
)
return {
handleExtensionMessage,
seenMessageIds,
pendingCommandRef,
firstTextMessageSkipped,
}
}

View file

@ -1,10 +1,13 @@
import { create } from "zustand"
import type { TokenUsage, ProviderSettings, TodoItem } from "@roo-code/types"
import { DebugLogger } from "@roo-code/core/cli"
import type { TUIMessage, PendingAsk, TaskHistoryItem } from "./types.js"
import type { FileResult, SlashCommandResult, ModeResult } from "./components/autocomplete/index.js"
const storeLogger = new DebugLogger("STORE")
/**
* Shallow array equality check - compares array length and element references.
* Used to prevent unnecessary state updates when array content hasn't changed.
@ -162,10 +165,24 @@ export const useCLIStore = create<CLIState & CLIActions>((set, get) => ({
// For NEW messages (not updates) - always apply immediately
if (existingIndex === -1) {
storeLogger.debug("addMessage:new", {
id: msg.id,
role: msg.role,
toolName: msg.toolName || "none",
partial: msg.partial,
hasToolData: !!msg.toolData,
msgCount: state.messages.length + 1,
})
set({ messages: [...state.messages, msg] })
return
}
storeLogger.debug("addMessage:update", {
id: msg.id,
partial: msg.partial,
existingIndex,
})
// For UPDATES to existing messages:
// If partial (streaming) and message exists, debounce the update
if (msg.partial) {

View file

@ -11,8 +11,18 @@ export default defineConfig({
banner: {
js: "#!/usr/bin/env node",
},
// Bundle workspace packages that export TypeScript
noExternal: ["@roo-code/core", "@roo-code/core/cli", "@roo-code/types", "@roo-code/vscode-shim"],
// Bundle workspace packages and ESM-only npm dependencies to create a self-contained CLI
noExternal: [
// Workspace packages
"@roo-code/core",
"@roo-code/core/cli",
"@roo-code/types",
"@roo-code/vscode-shim",
// ESM-only npm dependencies that need to be bundled
"@agentclientprotocol/sdk",
"p-wait-for",
"zod",
],
external: [
// Keep native modules external
"@anthropic-ai/sdk",

View file

@ -98,8 +98,7 @@ const features: Feature[] = [
{
icon: Brain,
title: "Model Agnostic",
description:
"Bring your own keys or use the Roo Code Router with access to all top models with no markup.",
description: "Bring your own keys or use the Roo Code Router with access to all top models with no markup.",
},
{
icon: Github,
@ -153,7 +152,7 @@ export default function CloudPage() {
Your AI Team <span className="text-violet-500">in the Cloud</span>
</h1>
<p className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto">
Create your agent team in the Cloud, give them access to GitHub, and start delegating tasks
Create your agent team in the Cloud, give them access to GitHub, and start delegating tasks
from Web and Slack.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">

View file

@ -234,8 +234,8 @@ export default function PricingPage() {
<div className="text-sm text-muted-foreground">
<p className="">
On any plan, you can use your own LLM provider API key or use the built-in Roo Code
Router curated models to work with Roo with no markup, including the
latest Gemini, GPT and Claude. Paid with credits.
Router curated models to work with Roo with no markup, including the latest
Gemini, GPT and Claude. Paid with credits.
<Link href="/provider" className="underline hover:no-underline ml-1">
See per model pricing.
</Link>

20
pnpm-lock.yaml generated
View file

@ -82,6 +82,9 @@ importers:
apps/cli:
dependencies:
'@agentclientprotocol/sdk':
specifier: ^0.12.0
version: 0.12.0(zod@4.3.5)
'@inkjs/ui':
specifier: ^2.0.0
version: 2.0.0(ink@6.6.0(@types/react@18.3.23)(react@19.2.3))
@ -118,6 +121,9 @@ importers:
superjson:
specifier: ^2.2.6
version: 2.2.6
zod:
specifier: ^4.3.5
version: 4.3.5
zustand:
specifier: ^5.0.0
version: 5.0.9(@types/react@18.3.23)(react@19.2.3)
@ -1356,6 +1362,11 @@ packages:
'@adobe/css-tools@4.4.2':
resolution: {integrity: sha512-baYZExFpsdkBNuvGKTKWCwKH57HRZLVtycZS05WTQNVOiXVSeAki3nU35zlRbToeMW8aHlJfyS+1C4BOv27q0A==}
'@agentclientprotocol/sdk@0.12.0':
resolution: {integrity: sha512-V8uH/KK1t7utqyJmTA7y7DzKu6+jKFIXM+ZVouz8E55j8Ej2RV42rEvPKn3/PpBJlliI5crcGk1qQhZ7VwaepA==}
peerDependencies:
zod: ^3.25.0 || ^4.0.0
'@alcalzone/ansi-tokenize@0.2.3':
resolution: {integrity: sha512-jsElTJ0sQ4wHRz+C45tfect76BwbTbgkgKByOzpCN9xG61N5V6u/glvg1CsNJhq2xJIFpKHSwG3D2wPPuEYOrQ==}
engines: {node: '>=18'}
@ -10659,6 +10670,9 @@ packages:
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
zod@4.3.5:
resolution: {integrity: sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==}
zustand@5.0.9:
resolution: {integrity: sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==}
engines: {node: '>=12.20.0'}
@ -10684,6 +10698,10 @@ snapshots:
'@adobe/css-tools@4.4.2': {}
'@agentclientprotocol/sdk@0.12.0(zod@4.3.5)':
dependencies:
zod: 4.3.5
'@alcalzone/ansi-tokenize@0.2.3':
dependencies:
ansi-styles: 6.2.3
@ -21715,6 +21733,8 @@ snapshots:
zod@3.25.76: {}
zod@4.3.5: {}
zustand@5.0.9(@types/react@18.3.23)(react@19.2.3):
optionalDependencies:
'@types/react': 18.3.23

View file

@ -26,6 +26,12 @@ interface WriteToFileParams {
export class WriteToFileTool extends BaseTool<"write_to_file"> {
readonly name = "write_to_file" as const
/**
* Track whether we've sent the initial "tool starting" notification.
* This allows us to send an immediate notification before path stabilizes.
*/
private hasNotifiedToolStart = false
parseLegacy(params: Partial<Record<string, string>>): WriteToFileParams {
return {
path: params.path || "",
@ -124,6 +130,10 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> {
task.diffViewProvider.originalContent = ""
}
// Send partial message immediately to indicate tool is starting (before file write)
const partialMessage = JSON.stringify(sharedMessageProps)
await task.ask("tool", partialMessage, true).catch(() => {})
let unified = fileExists
? formatResponse.createPrettyPatch(relPath, task.diffViewProvider.originalContent, newContent)
: convertNewFileToUnifiedDiff(newContent, relPath)
@ -200,11 +210,33 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> {
}
}
/**
* Reset partial state tracking, including the tool start notification flag.
*/
override resetPartialState(): void {
super.resetPartialState()
this.hasNotifiedToolStart = false
}
override async handlePartial(task: Task, block: ToolUse<"write_to_file">): Promise<void> {
const relPath: string | undefined = block.params.path
let newContent: string | undefined = block.params.content
// Wait for path to stabilize before showing UI (prevents truncated paths)
// Send an immediate "tool starting" notification on first partial call
// This ensures CLI sees the tool start immediately, before path stabilizes
if (!this.hasNotifiedToolStart && relPath) {
this.hasNotifiedToolStart = true
const startMessage: ClineSayTool = {
tool: "newFileCreated", // Will be updated when we know if file exists
path: relPath,
content: "",
isOutsideWorkspace: false,
isProtected: false,
}
await task.ask("tool", JSON.stringify(startMessage), true).catch(() => {})
}
// Wait for path to stabilize before showing full UI (prevents truncated paths)
if (!this.hasPathStabilized(relPath) || newContent === undefined) {
return
}
@ -216,10 +248,6 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> {
EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION,
)
if (isPreventFocusDisruptionEnabled) {
return
}
// relPath is guaranteed non-null after hasPathStabilized
let fileExists: boolean
const absolutePath = path.resolve(task.cwd, relPath!)
@ -248,9 +276,15 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> {
isProtected: isWriteProtected,
}
// Always send partial messages to keep CLI informed during streaming
const partialMessage = JSON.stringify(sharedMessageProps)
await task.ask("tool", partialMessage, block.partial).catch(() => {})
// Skip diff view operations when experiment is enabled (prevents focus disruption in VSCode)
if (isPreventFocusDisruptionEnabled) {
return
}
if (newContent) {
if (!task.diffViewProvider.isEditing) {
await task.diffViewProvider.open(relPath!)

View file

@ -400,14 +400,14 @@ describe("writeToFileTool", () => {
})
it("streams content updates during partial execution after path stabilizes", async () => {
// First call - path not yet stabilized, early return (no file operations)
// First call - sends early "tool starting" notification, but no file operations yet
await executeWriteFileTool({}, { isPartial: true })
expect(mockCline.ask).not.toHaveBeenCalled()
expect(mockCline.ask).toHaveBeenCalledTimes(1) // Early notification sent
expect(mockCline.diffViewProvider.open).not.toHaveBeenCalled()
// Second call with same path - path is now stabilized, file operations proceed
await executeWriteFileTool({}, { isPartial: true })
expect(mockCline.ask).toHaveBeenCalled()
expect(mockCline.ask).toHaveBeenCalledTimes(2) // Additional call after path stabilizes
expect(mockCline.diffViewProvider.open).toHaveBeenCalledWith(testFilePath)
expect(mockCline.diffViewProvider.update).toHaveBeenCalledWith(testContent, false)
})