mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
Compare commits
24 commits
main
...
cli-v0.0.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c9220ad8d | ||
|
|
22288a2b1e | ||
|
|
7b0103901f | ||
|
|
6796702afd | ||
|
|
1150158d55 | ||
|
|
1b867d517b | ||
|
|
0dcc439b79 | ||
|
|
1991eb6517 | ||
|
|
510cf8fc8a | ||
|
|
29e7045b81 | ||
|
|
4b4afceeb6 | ||
|
|
4ac8a460db | ||
|
|
7492abb9d0 | ||
|
|
e92a0fca06 | ||
|
|
4b43a0d865 | ||
|
|
74881a4f4f | ||
|
|
89f26ac053 | ||
|
|
844de64153 | ||
|
|
22fa95f692 | ||
|
|
e6b1d20bd6 | ||
|
|
4ccca5b88f | ||
|
|
a3f02bdefd | ||
|
|
569055ea9a | ||
|
|
3903fe5ada |
76 changed files with 11390 additions and 589 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`:
|
||||
|
|
|
|||
|
|
@ -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**
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
247
apps/cli/src/acp/__tests__/agent.test.ts
Normal file
247
apps/cli/src/acp/__tests__/agent.test.ts
Normal 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
|
||||
})
|
||||
})
|
||||
})
|
||||
314
apps/cli/src/acp/__tests__/command-stream.test.ts
Normal file
314
apps/cli/src/acp/__tests__/command-stream.test.ts
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
/**
|
||||
* Tests for CommandStreamManager
|
||||
*
|
||||
* Tests the command output streaming functionality extracted from session.ts.
|
||||
*/
|
||||
|
||||
import type { ClineMessage } from "@roo-code/types"
|
||||
|
||||
import { DeltaTracker } from "../delta-tracker.js"
|
||||
import { CommandStreamManager } from "../command-stream.js"
|
||||
import { NullLogger } from "../interfaces.js"
|
||||
import type { SendUpdateFn } from "../interfaces.js"
|
||||
|
||||
describe("CommandStreamManager", () => {
|
||||
let deltaTracker: DeltaTracker
|
||||
let sendUpdate: SendUpdateFn
|
||||
let sentUpdates: Array<Record<string, unknown>>
|
||||
let manager: CommandStreamManager
|
||||
|
||||
beforeEach(() => {
|
||||
deltaTracker = new DeltaTracker()
|
||||
sentUpdates = []
|
||||
sendUpdate = (update) => {
|
||||
sentUpdates.push(update as Record<string, unknown>)
|
||||
}
|
||||
manager = new CommandStreamManager({
|
||||
deltaTracker,
|
||||
sendUpdate,
|
||||
logger: new NullLogger(),
|
||||
})
|
||||
})
|
||||
|
||||
describe("isCommandOutputMessage", () => {
|
||||
it("returns true for command_output say messages", () => {
|
||||
const message: ClineMessage = {
|
||||
type: "say",
|
||||
say: "command_output",
|
||||
ts: Date.now(),
|
||||
text: "output",
|
||||
}
|
||||
expect(manager.isCommandOutputMessage(message)).toBe(true)
|
||||
})
|
||||
|
||||
it("returns false for other say types", () => {
|
||||
const message: ClineMessage = {
|
||||
type: "say",
|
||||
say: "text",
|
||||
ts: Date.now(),
|
||||
text: "hello",
|
||||
}
|
||||
expect(manager.isCommandOutputMessage(message)).toBe(false)
|
||||
})
|
||||
|
||||
it("returns false for ask messages", () => {
|
||||
const message: ClineMessage = {
|
||||
type: "ask",
|
||||
ask: "command",
|
||||
ts: Date.now(),
|
||||
text: "run command",
|
||||
}
|
||||
expect(manager.isCommandOutputMessage(message)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("trackCommand", () => {
|
||||
it("tracks a pending command", () => {
|
||||
manager.trackCommand("call-1", "npm test", 12345)
|
||||
expect(manager.getPendingCommandCount()).toBe(1)
|
||||
})
|
||||
|
||||
it("tracks multiple commands", () => {
|
||||
manager.trackCommand("call-1", "npm test", 12345)
|
||||
manager.trackCommand("call-2", "npm build", 12346)
|
||||
expect(manager.getPendingCommandCount()).toBe(2)
|
||||
})
|
||||
|
||||
it("overwrites command with same ID", () => {
|
||||
manager.trackCommand("call-1", "npm test", 12345)
|
||||
manager.trackCommand("call-1", "npm build", 12346)
|
||||
expect(manager.getPendingCommandCount()).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("handleExecutionOutput", () => {
|
||||
it("does nothing without a pending command", () => {
|
||||
manager.handleExecutionOutput("exec-1", "Hello")
|
||||
|
||||
expect(sentUpdates.length).toBe(0)
|
||||
})
|
||||
|
||||
it("sends opening code fence as agent_message_chunk on first output", () => {
|
||||
manager.trackCommand("call-1", "npm test", 12345)
|
||||
manager.handleExecutionOutput("exec-1", "Hello")
|
||||
|
||||
// First message is opening fence, second is the content
|
||||
expect(sentUpdates.length).toBe(2)
|
||||
expect(sentUpdates[0]).toEqual({
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: "```\n" },
|
||||
})
|
||||
expect(sentUpdates[1]).toEqual({
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: "Hello" },
|
||||
})
|
||||
})
|
||||
|
||||
it("sends only delta content on subsequent calls (no fence)", () => {
|
||||
manager.trackCommand("call-1", "npm test", 12345)
|
||||
manager.handleExecutionOutput("exec-1", "Hello")
|
||||
sentUpdates.length = 0 // Clear previous updates
|
||||
|
||||
manager.handleExecutionOutput("exec-1", "Hello World")
|
||||
|
||||
// Only the delta " World" is sent, no fence
|
||||
expect(sentUpdates.length).toBe(1)
|
||||
expect(sentUpdates[0]).toEqual({
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: " World" },
|
||||
})
|
||||
})
|
||||
|
||||
it("tracks code fence by toolCallId not executionId", () => {
|
||||
manager.trackCommand("call-1", "npm test", 12345)
|
||||
|
||||
// First execution stream
|
||||
manager.handleExecutionOutput("exec-1", "First")
|
||||
expect(manager.hasOpenCodeFences()).toBe(true)
|
||||
|
||||
// Second execution stream for same command - no new opening fence since toolCallId already has one
|
||||
manager.handleExecutionOutput("exec-2", "Second")
|
||||
|
||||
// Should still only have one open fence (tracked by toolCallId)
|
||||
expect(manager.hasOpenCodeFences()).toBe(true)
|
||||
|
||||
// Second call should NOT have opening fence since toolCallId already has one
|
||||
// sentUpdates[0] = opening fence, sentUpdates[1] = "First", sentUpdates[2] = "Second"
|
||||
expect(sentUpdates[2]).toEqual({
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: "Second" },
|
||||
})
|
||||
})
|
||||
|
||||
it("sends streaming output as agent_message_chunk", () => {
|
||||
manager.trackCommand("call-1", "npm test", 12345)
|
||||
manager.handleExecutionOutput("exec-1", "Running...")
|
||||
|
||||
// Opening fence + content
|
||||
const contentUpdate = sentUpdates.find(
|
||||
(u) =>
|
||||
u.sessionUpdate === "agent_message_chunk" && (u.content as { text: string }).text === "Running...",
|
||||
)
|
||||
expect(contentUpdate).toEqual({
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: "Running..." },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("handleCommandOutput", () => {
|
||||
it("ignores partial messages", () => {
|
||||
const message: ClineMessage = {
|
||||
type: "say",
|
||||
say: "command_output",
|
||||
ts: Date.now(),
|
||||
text: "partial output",
|
||||
partial: true,
|
||||
}
|
||||
|
||||
manager.handleCommandOutput(message)
|
||||
expect(sentUpdates.length).toBe(0)
|
||||
})
|
||||
|
||||
it("sends closing fence and completion when streaming was used", () => {
|
||||
// Track command and open a code fence via execution output
|
||||
manager.trackCommand("call-1", "npm test", 12345)
|
||||
manager.handleExecutionOutput("exec-1", "output")
|
||||
expect(manager.hasOpenCodeFences()).toBe(true)
|
||||
|
||||
sentUpdates.length = 0 // Clear
|
||||
|
||||
const message: ClineMessage = {
|
||||
type: "say",
|
||||
say: "command_output",
|
||||
ts: Date.now(),
|
||||
text: "final output",
|
||||
partial: false,
|
||||
}
|
||||
|
||||
manager.handleCommandOutput(message)
|
||||
|
||||
// First: closing fence as agent_message_chunk
|
||||
expect(sentUpdates[0]).toEqual({
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: "```\n" },
|
||||
})
|
||||
// Second: tool_call_update with completed status (no content, just rawOutput)
|
||||
expect(sentUpdates[1]).toEqual({
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "call-1",
|
||||
status: "completed",
|
||||
rawOutput: { output: "final output" },
|
||||
})
|
||||
expect(manager.hasOpenCodeFences()).toBe(false)
|
||||
})
|
||||
|
||||
it("sends completion update for pending command without streaming", () => {
|
||||
manager.trackCommand("call-1", "npm test", 12345)
|
||||
|
||||
const message: ClineMessage = {
|
||||
type: "say",
|
||||
say: "command_output",
|
||||
ts: Date.now(),
|
||||
text: "Test passed!",
|
||||
partial: false,
|
||||
}
|
||||
|
||||
manager.handleCommandOutput(message)
|
||||
|
||||
// No streaming, so no closing fence - just the completion update
|
||||
const completionUpdate = sentUpdates.find(
|
||||
(u) => u.sessionUpdate === "tool_call_update" && u.status === "completed",
|
||||
)
|
||||
expect(completionUpdate).toEqual({
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "call-1",
|
||||
status: "completed",
|
||||
rawOutput: { output: "Test passed!" },
|
||||
})
|
||||
})
|
||||
|
||||
it("removes pending command after completion", () => {
|
||||
manager.trackCommand("call-1", "npm test", 12345)
|
||||
expect(manager.getPendingCommandCount()).toBe(1)
|
||||
|
||||
const message: ClineMessage = {
|
||||
type: "say",
|
||||
say: "command_output",
|
||||
ts: Date.now(),
|
||||
text: "done",
|
||||
partial: false,
|
||||
}
|
||||
|
||||
manager.handleCommandOutput(message)
|
||||
expect(manager.getPendingCommandCount()).toBe(0)
|
||||
})
|
||||
|
||||
it("picks most recent pending command when multiple exist", () => {
|
||||
manager.trackCommand("call-1", "npm test", 12345)
|
||||
manager.trackCommand("call-2", "npm build", 12346) // More recent
|
||||
|
||||
const message: ClineMessage = {
|
||||
type: "say",
|
||||
say: "command_output",
|
||||
ts: Date.now(),
|
||||
text: "done",
|
||||
partial: false,
|
||||
}
|
||||
|
||||
manager.handleCommandOutput(message)
|
||||
|
||||
const completionUpdate = sentUpdates.find((u) => u.sessionUpdate === "tool_call_update")
|
||||
expect((completionUpdate as Record<string, unknown>).toolCallId).toBe("call-2")
|
||||
})
|
||||
})
|
||||
|
||||
describe("reset", () => {
|
||||
it("clears code fence tracking", () => {
|
||||
manager.trackCommand("call-1", "npm test", 12345)
|
||||
manager.handleExecutionOutput("exec-1", "output")
|
||||
expect(manager.hasOpenCodeFences()).toBe(true)
|
||||
|
||||
manager.reset()
|
||||
expect(manager.hasOpenCodeFences()).toBe(false)
|
||||
})
|
||||
|
||||
it("clears pending commands to avoid stale entries", () => {
|
||||
// Pending commands from previous prompts would cause duplicate completion messages
|
||||
manager.trackCommand("call-1", "npm test", 12345)
|
||||
manager.reset()
|
||||
expect(manager.getPendingCommandCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getPendingCommandCount", () => {
|
||||
it("returns 0 when no commands tracked", () => {
|
||||
expect(manager.getPendingCommandCount()).toBe(0)
|
||||
})
|
||||
|
||||
it("returns correct count", () => {
|
||||
manager.trackCommand("call-1", "cmd1", 1)
|
||||
manager.trackCommand("call-2", "cmd2", 2)
|
||||
expect(manager.getPendingCommandCount()).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("hasOpenCodeFences", () => {
|
||||
it("returns false initially", () => {
|
||||
expect(manager.hasOpenCodeFences()).toBe(false)
|
||||
})
|
||||
|
||||
it("returns true after execution output with pending command", () => {
|
||||
manager.trackCommand("call-1", "npm test", 12345)
|
||||
manager.handleExecutionOutput("exec-1", "output")
|
||||
expect(manager.hasOpenCodeFences()).toBe(true)
|
||||
})
|
||||
|
||||
it("returns false after reset", () => {
|
||||
manager.trackCommand("call-1", "npm test", 12345)
|
||||
manager.handleExecutionOutput("exec-1", "output")
|
||||
manager.reset()
|
||||
expect(manager.hasOpenCodeFences()).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
298
apps/cli/src/acp/__tests__/content-formatter.test.ts
Normal file
298
apps/cli/src/acp/__tests__/content-formatter.test.ts
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
/**
|
||||
* Content Formatter Unit Tests
|
||||
*
|
||||
* Tests for the ContentFormatter class.
|
||||
*/
|
||||
|
||||
import { ContentFormatter, createContentFormatter } from "../content-formatter.js"
|
||||
|
||||
describe("ContentFormatter", () => {
|
||||
describe("formatToolResult", () => {
|
||||
const formatter = new ContentFormatter()
|
||||
|
||||
it("should format search results", () => {
|
||||
const content = "Found 5 results.\n\n# src/file.ts\n 1 | match"
|
||||
const result = formatter.formatToolResult("search", content)
|
||||
|
||||
expect(result).toContain("Found 5 results in 1 file")
|
||||
expect(result).toContain("- src/file.ts")
|
||||
expect(result).toMatch(/^```/)
|
||||
expect(result).toMatch(/```$/)
|
||||
})
|
||||
|
||||
it("should format read results", () => {
|
||||
const content = "line1\nline2\nline3"
|
||||
const result = formatter.formatToolResult("read", content)
|
||||
|
||||
expect(result).toContain("line1")
|
||||
expect(result).toContain("line2")
|
||||
expect(result).toContain("line3")
|
||||
expect(result).toMatch(/^```/)
|
||||
expect(result).toMatch(/```$/)
|
||||
})
|
||||
|
||||
it("should return content unchanged for unknown kinds", () => {
|
||||
const content = "some content"
|
||||
const result = formatter.formatToolResult("unknown", content)
|
||||
|
||||
expect(result).toBe(content)
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatSearchResults", () => {
|
||||
const formatter = new ContentFormatter()
|
||||
|
||||
it("should extract file count and result count", () => {
|
||||
const content = "Found 10 results.\n\n# src/a.ts\n 1 | code\n\n# src/b.ts\n 5 | code"
|
||||
const result = formatter.formatSearchResults(content)
|
||||
|
||||
expect(result).toContain("Found 10 results in 2 files")
|
||||
})
|
||||
|
||||
it("should list unique files alphabetically", () => {
|
||||
const content = "Found 3 results.\n\n# src/z.ts\n 1 | a\n\n# src/a.ts\n 2 | b\n\n# src/m.ts\n 3 | c"
|
||||
const result = formatter.formatSearchResults(content)
|
||||
|
||||
const lines = result.split("\n")
|
||||
const fileLines = lines.filter((l) => l.startsWith("- "))
|
||||
|
||||
expect(fileLines[0]).toBe("- src/a.ts")
|
||||
expect(fileLines[1]).toBe("- src/m.ts")
|
||||
expect(fileLines[2]).toBe("- src/z.ts")
|
||||
})
|
||||
|
||||
it("should deduplicate repeated file paths", () => {
|
||||
const content =
|
||||
"Found 5 results.\n\n# src/file.ts\n 1 | a\n\n# src/file.ts\n 5 | b\n\n# src/other.ts\n 10 | c"
|
||||
const result = formatter.formatSearchResults(content)
|
||||
|
||||
expect(result).toContain("in 2 files")
|
||||
expect((result.match(/- src\/file\.ts/g) || []).length).toBe(1)
|
||||
})
|
||||
|
||||
it("should handle no files found", () => {
|
||||
const content = "No results found"
|
||||
const result = formatter.formatSearchResults(content)
|
||||
|
||||
expect(result).toBe("No results found")
|
||||
})
|
||||
|
||||
it("should handle singular result", () => {
|
||||
const content = "Found 1 result.\n\n# src/file.ts\n 1 | match"
|
||||
const result = formatter.formatSearchResults(content)
|
||||
|
||||
expect(result).toContain("Found 1 result in 1 file")
|
||||
})
|
||||
|
||||
it("should handle missing result count", () => {
|
||||
const content = "# src/file.ts\n 1 | match"
|
||||
const result = formatter.formatSearchResults(content)
|
||||
|
||||
expect(result).toContain("Found matches in 1 file")
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatReadResults", () => {
|
||||
it("should return short content unchanged", () => {
|
||||
const formatter = new ContentFormatter({ maxReadLines: 100 })
|
||||
const content = "line1\nline2\nline3"
|
||||
const result = formatter.formatReadResults(content)
|
||||
|
||||
expect(result).toBe(content)
|
||||
})
|
||||
|
||||
it("should truncate long content", () => {
|
||||
const formatter = new ContentFormatter({ maxReadLines: 5 })
|
||||
const lines = Array.from({ length: 10 }, (_, i) => `line${i + 1}`)
|
||||
const content = lines.join("\n")
|
||||
const result = formatter.formatReadResults(content)
|
||||
|
||||
expect(result).toContain("line1")
|
||||
expect(result).toContain("line5")
|
||||
expect(result).not.toContain("line6")
|
||||
expect(result).toContain("... (5 more lines)")
|
||||
})
|
||||
|
||||
it("should handle exactly maxReadLines", () => {
|
||||
const formatter = new ContentFormatter({ maxReadLines: 5 })
|
||||
const lines = Array.from({ length: 5 }, (_, i) => `line${i + 1}`)
|
||||
const content = lines.join("\n")
|
||||
const result = formatter.formatReadResults(content)
|
||||
|
||||
expect(result).toBe(content)
|
||||
})
|
||||
|
||||
it("should use default maxReadLines of 100", () => {
|
||||
const formatter = new ContentFormatter()
|
||||
const lines = Array.from({ length: 105 }, (_, i) => `line${i + 1}`)
|
||||
const content = lines.join("\n")
|
||||
const result = formatter.formatReadResults(content)
|
||||
|
||||
expect(result).toContain("... (5 more lines)")
|
||||
})
|
||||
})
|
||||
|
||||
describe("wrapInCodeBlock", () => {
|
||||
const formatter = new ContentFormatter()
|
||||
|
||||
it("should wrap content in code block", () => {
|
||||
const result = formatter.wrapInCodeBlock("some code")
|
||||
|
||||
expect(result).toBe("```\nsome code\n```")
|
||||
})
|
||||
|
||||
it("should support language specification", () => {
|
||||
const result = formatter.wrapInCodeBlock("const x = 1", "typescript")
|
||||
|
||||
expect(result).toBe("```typescript\nconst x = 1\n```")
|
||||
})
|
||||
|
||||
it("should handle empty content", () => {
|
||||
const result = formatter.wrapInCodeBlock("")
|
||||
|
||||
expect(result).toBe("```\n\n```")
|
||||
})
|
||||
|
||||
it("should handle multiline content", () => {
|
||||
const result = formatter.wrapInCodeBlock("line1\nline2\nline3")
|
||||
|
||||
expect(result).toBe("```\nline1\nline2\nline3\n```")
|
||||
})
|
||||
})
|
||||
|
||||
describe("extractContentFromRawInput", () => {
|
||||
const formatter = new ContentFormatter()
|
||||
|
||||
it("should extract content field", () => {
|
||||
const result = formatter.extractContentFromRawInput({ content: "my content" })
|
||||
expect(result).toBe("my content")
|
||||
})
|
||||
|
||||
it("should extract text field", () => {
|
||||
const result = formatter.extractContentFromRawInput({ text: "my text" })
|
||||
expect(result).toBe("my text")
|
||||
})
|
||||
|
||||
it("should extract result field", () => {
|
||||
const result = formatter.extractContentFromRawInput({ result: "my result" })
|
||||
expect(result).toBe("my result")
|
||||
})
|
||||
|
||||
it("should extract output field", () => {
|
||||
const result = formatter.extractContentFromRawInput({ output: "my output" })
|
||||
expect(result).toBe("my output")
|
||||
})
|
||||
|
||||
it("should extract fileContent field", () => {
|
||||
const result = formatter.extractContentFromRawInput({ fileContent: "my file content" })
|
||||
expect(result).toBe("my file content")
|
||||
})
|
||||
|
||||
it("should extract data field", () => {
|
||||
const result = formatter.extractContentFromRawInput({ data: "my data" })
|
||||
expect(result).toBe("my data")
|
||||
})
|
||||
|
||||
it("should prioritize content over other fields", () => {
|
||||
const result = formatter.extractContentFromRawInput({
|
||||
content: "content value",
|
||||
text: "text value",
|
||||
result: "result value",
|
||||
})
|
||||
expect(result).toBe("content value")
|
||||
})
|
||||
|
||||
it("should return undefined for empty object", () => {
|
||||
const result = formatter.extractContentFromRawInput({})
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should return undefined for empty string values", () => {
|
||||
const result = formatter.extractContentFromRawInput({ content: "", text: "" })
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should skip non-string values", () => {
|
||||
const result = formatter.extractContentFromRawInput({
|
||||
content: 123 as unknown as string,
|
||||
text: "valid text",
|
||||
})
|
||||
expect(result).toBe("valid text")
|
||||
})
|
||||
})
|
||||
|
||||
describe("extractFileContent", () => {
|
||||
const formatter = new ContentFormatter()
|
||||
|
||||
it("should use extractContentFromRawInput for non-readFile tools", () => {
|
||||
const result = formatter.extractFileContent({ tool: "list_files", content: "file list" }, "/workspace")
|
||||
expect(result).toBe("file list")
|
||||
})
|
||||
|
||||
it("should return undefined for readFile with no path", () => {
|
||||
const result = formatter.extractFileContent({ tool: "readFile" }, "/workspace")
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
// Note: actual file reading is tested in integration tests
|
||||
})
|
||||
|
||||
describe("isUserEcho", () => {
|
||||
const formatter = new ContentFormatter()
|
||||
|
||||
it("should return false for null prompt", () => {
|
||||
expect(formatter.isUserEcho("any text", null)).toBe(false)
|
||||
})
|
||||
|
||||
it("should detect exact match", () => {
|
||||
expect(formatter.isUserEcho("hello world", "hello world")).toBe(true)
|
||||
})
|
||||
|
||||
it("should be case insensitive", () => {
|
||||
expect(formatter.isUserEcho("Hello World", "hello world")).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle whitespace differences", () => {
|
||||
expect(formatter.isUserEcho(" hello world ", "hello world")).toBe(true)
|
||||
})
|
||||
|
||||
it("should detect text contained in prompt (truncated)", () => {
|
||||
expect(formatter.isUserEcho("write a function", "write a function that adds numbers")).toBe(true)
|
||||
})
|
||||
|
||||
it("should detect prompt contained in text (wrapped)", () => {
|
||||
expect(formatter.isUserEcho("User said: write a function here", "write a function")).toBe(true)
|
||||
})
|
||||
|
||||
it("should not match short strings", () => {
|
||||
expect(formatter.isUserEcho("test", "this is a test prompt")).toBe(false)
|
||||
})
|
||||
|
||||
it("should not match completely different text", () => {
|
||||
expect(formatter.isUserEcho("completely different", "original prompt text")).toBe(false)
|
||||
})
|
||||
|
||||
it("should handle empty strings", () => {
|
||||
expect(formatter.isUserEcho("", "prompt")).toBe(false)
|
||||
expect(formatter.isUserEcho("text", "")).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("createContentFormatter", () => {
|
||||
it("should create a formatter with default config", () => {
|
||||
const formatter = createContentFormatter()
|
||||
expect(formatter).toBeInstanceOf(ContentFormatter)
|
||||
})
|
||||
|
||||
it("should accept custom config", () => {
|
||||
const formatter = createContentFormatter({ maxReadLines: 50 })
|
||||
|
||||
// Test that custom config is used
|
||||
const lines = Array.from({ length: 55 }, (_, i) => `line${i + 1}`)
|
||||
const content = lines.join("\n")
|
||||
const result = formatter.formatReadResults(content)
|
||||
|
||||
expect(result).toContain("... (5 more lines)")
|
||||
})
|
||||
})
|
||||
137
apps/cli/src/acp/__tests__/delta-tracker.test.ts
Normal file
137
apps/cli/src/acp/__tests__/delta-tracker.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
})
|
||||
233
apps/cli/src/acp/__tests__/model-service.test.ts
Normal file
233
apps/cli/src/acp/__tests__/model-service.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
397
apps/cli/src/acp/__tests__/plan-translator.test.ts
Normal file
397
apps/cli/src/acp/__tests__/plan-translator.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
373
apps/cli/src/acp/__tests__/prompt-state.test.ts
Normal file
373
apps/cli/src/acp/__tests__/prompt-state.test.ts
Normal file
|
|
@ -0,0 +1,373 @@
|
|||
/**
|
||||
* Prompt State Machine Unit Tests
|
||||
*
|
||||
* Tests for the PromptStateMachine class.
|
||||
*/
|
||||
|
||||
import { PromptStateMachine, createPromptStateMachine } from "../prompt-state.js"
|
||||
|
||||
describe("PromptStateMachine", () => {
|
||||
describe("initial state", () => {
|
||||
it("should start in idle state", () => {
|
||||
const sm = new PromptStateMachine()
|
||||
expect(sm.getState()).toBe("idle")
|
||||
})
|
||||
|
||||
it("should have null abort signal initially", () => {
|
||||
const sm = new PromptStateMachine()
|
||||
expect(sm.getAbortSignal()).toBeNull()
|
||||
})
|
||||
|
||||
it("should have null prompt text initially", () => {
|
||||
const sm = new PromptStateMachine()
|
||||
expect(sm.getCurrentPromptText()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("canStartPrompt", () => {
|
||||
it("should return true when idle", () => {
|
||||
const sm = new PromptStateMachine()
|
||||
expect(sm.canStartPrompt()).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false when processing", async () => {
|
||||
const sm = new PromptStateMachine()
|
||||
sm.startPrompt("test")
|
||||
|
||||
expect(sm.canStartPrompt()).toBe(false)
|
||||
|
||||
// Clean up
|
||||
sm.complete(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isProcessing", () => {
|
||||
it("should return false when idle", () => {
|
||||
const sm = new PromptStateMachine()
|
||||
expect(sm.isProcessing()).toBe(false)
|
||||
})
|
||||
|
||||
it("should return true when processing", () => {
|
||||
const sm = new PromptStateMachine()
|
||||
sm.startPrompt("test")
|
||||
|
||||
expect(sm.isProcessing()).toBe(true)
|
||||
|
||||
// Clean up
|
||||
sm.complete(true)
|
||||
})
|
||||
|
||||
it("should return false after completion", () => {
|
||||
const sm = new PromptStateMachine()
|
||||
sm.startPrompt("test")
|
||||
sm.complete(true)
|
||||
|
||||
expect(sm.isProcessing()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("startPrompt", () => {
|
||||
it("should transition to processing state", () => {
|
||||
const sm = new PromptStateMachine()
|
||||
sm.startPrompt("test prompt")
|
||||
|
||||
expect(sm.getState()).toBe("processing")
|
||||
|
||||
// Clean up
|
||||
sm.complete(true)
|
||||
})
|
||||
|
||||
it("should store the prompt text", () => {
|
||||
const sm = new PromptStateMachine()
|
||||
sm.startPrompt("my test prompt")
|
||||
|
||||
expect(sm.getCurrentPromptText()).toBe("my test prompt")
|
||||
|
||||
// Clean up
|
||||
sm.complete(true)
|
||||
})
|
||||
|
||||
it("should create abort signal", () => {
|
||||
const sm = new PromptStateMachine()
|
||||
sm.startPrompt("test")
|
||||
|
||||
expect(sm.getAbortSignal()).not.toBeNull()
|
||||
expect(sm.getAbortSignal()?.aborted).toBe(false)
|
||||
|
||||
// Clean up
|
||||
sm.complete(true)
|
||||
})
|
||||
|
||||
it("should return a promise", () => {
|
||||
const sm = new PromptStateMachine()
|
||||
const promise = sm.startPrompt("test")
|
||||
|
||||
expect(promise).toBeInstanceOf(Promise)
|
||||
|
||||
// Clean up
|
||||
sm.complete(true)
|
||||
})
|
||||
|
||||
it("should resolve with end_turn on successful completion", async () => {
|
||||
const sm = new PromptStateMachine()
|
||||
const promise = sm.startPrompt("test")
|
||||
|
||||
sm.complete(true)
|
||||
const result = await promise
|
||||
|
||||
expect(result.stopReason).toBe("end_turn")
|
||||
})
|
||||
|
||||
it("should resolve with refusal on failed completion", async () => {
|
||||
const sm = new PromptStateMachine()
|
||||
const promise = sm.startPrompt("test")
|
||||
|
||||
sm.complete(false)
|
||||
const result = await promise
|
||||
|
||||
expect(result.stopReason).toBe("refusal")
|
||||
})
|
||||
|
||||
it("should resolve with cancelled on cancel", async () => {
|
||||
const sm = new PromptStateMachine()
|
||||
const promise = sm.startPrompt("test")
|
||||
|
||||
sm.cancel()
|
||||
const result = await promise
|
||||
|
||||
expect(result.stopReason).toBe("cancelled")
|
||||
})
|
||||
|
||||
it("should cancel existing prompt if called while processing", async () => {
|
||||
const sm = new PromptStateMachine()
|
||||
const promise1 = sm.startPrompt("first prompt")
|
||||
|
||||
// Start a second prompt (should cancel first)
|
||||
sm.startPrompt("second prompt")
|
||||
|
||||
// First promise should resolve with cancelled
|
||||
const result1 = await promise1
|
||||
expect(result1.stopReason).toBe("cancelled")
|
||||
|
||||
// Clean up
|
||||
sm.complete(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("complete", () => {
|
||||
it("should transition to idle state", () => {
|
||||
const sm = new PromptStateMachine()
|
||||
sm.startPrompt("test")
|
||||
sm.complete(true)
|
||||
|
||||
expect(sm.getState()).toBe("idle")
|
||||
})
|
||||
|
||||
it("should return end_turn for success", () => {
|
||||
const sm = new PromptStateMachine()
|
||||
sm.startPrompt("test")
|
||||
|
||||
const stopReason = sm.complete(true)
|
||||
expect(stopReason).toBe("end_turn")
|
||||
})
|
||||
|
||||
it("should return refusal for failure", () => {
|
||||
const sm = new PromptStateMachine()
|
||||
sm.startPrompt("test")
|
||||
|
||||
const stopReason = sm.complete(false)
|
||||
expect(stopReason).toBe("refusal")
|
||||
})
|
||||
|
||||
it("should clear prompt text", () => {
|
||||
const sm = new PromptStateMachine()
|
||||
sm.startPrompt("test")
|
||||
sm.complete(true)
|
||||
|
||||
expect(sm.getCurrentPromptText()).toBeNull()
|
||||
})
|
||||
|
||||
it("should clear abort controller", () => {
|
||||
const sm = new PromptStateMachine()
|
||||
sm.startPrompt("test")
|
||||
sm.complete(true)
|
||||
|
||||
expect(sm.getAbortSignal()).toBeNull()
|
||||
})
|
||||
|
||||
it("should be idempotent (multiple calls ignored)", () => {
|
||||
const sm = new PromptStateMachine()
|
||||
sm.startPrompt("test")
|
||||
|
||||
const result1 = sm.complete(true)
|
||||
const result2 = sm.complete(false) // Should be ignored
|
||||
|
||||
expect(result1).toBe("end_turn")
|
||||
expect(result2).toBe("refusal") // Returns mapped value but doesn't change state
|
||||
expect(sm.getState()).toBe("idle")
|
||||
})
|
||||
})
|
||||
|
||||
describe("cancel", () => {
|
||||
it("should abort the signal", () => {
|
||||
const sm = new PromptStateMachine()
|
||||
sm.startPrompt("test")
|
||||
const signal = sm.getAbortSignal()
|
||||
|
||||
sm.cancel()
|
||||
|
||||
expect(signal?.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it("should transition to idle state", async () => {
|
||||
const sm = new PromptStateMachine()
|
||||
const promise = sm.startPrompt("test")
|
||||
|
||||
sm.cancel()
|
||||
await promise
|
||||
|
||||
expect(sm.getState()).toBe("idle")
|
||||
})
|
||||
|
||||
it("should be safe to call when idle", () => {
|
||||
const sm = new PromptStateMachine()
|
||||
|
||||
// Should not throw
|
||||
expect(() => sm.cancel()).not.toThrow()
|
||||
expect(sm.getState()).toBe("idle")
|
||||
})
|
||||
|
||||
it("should be idempotent (multiple calls safe)", () => {
|
||||
const sm = new PromptStateMachine()
|
||||
sm.startPrompt("test")
|
||||
|
||||
sm.cancel()
|
||||
sm.cancel() // Should not throw
|
||||
|
||||
expect(sm.getState()).toBe("idle")
|
||||
})
|
||||
})
|
||||
|
||||
describe("reset", () => {
|
||||
it("should transition to idle state", () => {
|
||||
const sm = new PromptStateMachine()
|
||||
sm.startPrompt("test")
|
||||
sm.reset()
|
||||
|
||||
expect(sm.getState()).toBe("idle")
|
||||
})
|
||||
|
||||
it("should clear prompt text", () => {
|
||||
const sm = new PromptStateMachine()
|
||||
sm.startPrompt("test")
|
||||
sm.reset()
|
||||
|
||||
expect(sm.getCurrentPromptText()).toBeNull()
|
||||
})
|
||||
|
||||
it("should clear abort controller", () => {
|
||||
const sm = new PromptStateMachine()
|
||||
sm.startPrompt("test")
|
||||
sm.reset()
|
||||
|
||||
expect(sm.getAbortSignal()).toBeNull()
|
||||
})
|
||||
|
||||
it("should abort any pending operation", () => {
|
||||
const sm = new PromptStateMachine()
|
||||
sm.startPrompt("test")
|
||||
const signal = sm.getAbortSignal()
|
||||
|
||||
sm.reset()
|
||||
|
||||
expect(signal?.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it("should be safe to call when idle", () => {
|
||||
const sm = new PromptStateMachine()
|
||||
|
||||
expect(() => sm.reset()).not.toThrow()
|
||||
expect(sm.getState()).toBe("idle")
|
||||
})
|
||||
})
|
||||
|
||||
describe("abort signal integration", () => {
|
||||
it("should trigger abort handler on cancel", async () => {
|
||||
const sm = new PromptStateMachine()
|
||||
const promise = sm.startPrompt("test")
|
||||
|
||||
let abortHandlerCalled = false
|
||||
sm.getAbortSignal()?.addEventListener("abort", () => {
|
||||
abortHandlerCalled = true
|
||||
})
|
||||
|
||||
sm.cancel()
|
||||
await promise
|
||||
|
||||
expect(abortHandlerCalled).toBe(true)
|
||||
})
|
||||
|
||||
it("should resolve promise via abort handler", async () => {
|
||||
const sm = new PromptStateMachine()
|
||||
const promise = sm.startPrompt("test")
|
||||
|
||||
sm.cancel()
|
||||
const result = await promise
|
||||
|
||||
expect(result.stopReason).toBe("cancelled")
|
||||
})
|
||||
})
|
||||
|
||||
describe("lifecycle scenarios", () => {
|
||||
it("should handle multiple prompt cycles", async () => {
|
||||
const sm = new PromptStateMachine()
|
||||
|
||||
// First cycle
|
||||
const promise1 = sm.startPrompt("prompt 1")
|
||||
expect(sm.isProcessing()).toBe(true)
|
||||
sm.complete(true)
|
||||
const result1 = await promise1
|
||||
expect(result1.stopReason).toBe("end_turn")
|
||||
expect(sm.isProcessing()).toBe(false)
|
||||
|
||||
// Second cycle
|
||||
const promise2 = sm.startPrompt("prompt 2")
|
||||
expect(sm.isProcessing()).toBe(true)
|
||||
expect(sm.getCurrentPromptText()).toBe("prompt 2")
|
||||
sm.complete(false)
|
||||
const result2 = await promise2
|
||||
expect(result2.stopReason).toBe("refusal")
|
||||
|
||||
// Third cycle with cancellation
|
||||
const promise3 = sm.startPrompt("prompt 3")
|
||||
sm.cancel()
|
||||
const result3 = await promise3
|
||||
expect(result3.stopReason).toBe("cancelled")
|
||||
})
|
||||
|
||||
it("should handle rapid start/cancel cycles", async () => {
|
||||
const sm = new PromptStateMachine()
|
||||
|
||||
const promises: Promise<{ stopReason: string }>[] = []
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const promise = sm.startPrompt(`prompt ${i}`)
|
||||
promises.push(promise)
|
||||
sm.cancel()
|
||||
}
|
||||
|
||||
// All should resolve with cancelled
|
||||
const results = await Promise.all(promises)
|
||||
expect(results.every((r) => r.stopReason === "cancelled")).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("createPromptStateMachine", () => {
|
||||
it("should create a new state machine", () => {
|
||||
const sm = createPromptStateMachine()
|
||||
|
||||
expect(sm).toBeInstanceOf(PromptStateMachine)
|
||||
expect(sm.getState()).toBe("idle")
|
||||
})
|
||||
})
|
||||
397
apps/cli/src/acp/__tests__/session-plan-integration.test.ts
Normal file
397
apps/cli/src/acp/__tests__/session-plan-integration.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
301
apps/cli/src/acp/__tests__/session.test.ts
Normal file
301
apps/cli/src/acp/__tests__/session.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
})
|
||||
495
apps/cli/src/acp/__tests__/tool-content-stream.test.ts
Normal file
495
apps/cli/src/acp/__tests__/tool-content-stream.test.ts
Normal file
|
|
@ -0,0 +1,495 @@
|
|||
/**
|
||||
* Tests for ToolContentStreamManager
|
||||
*
|
||||
* Tests the tool content (file creates/edits) streaming functionality
|
||||
* extracted from session.ts.
|
||||
*/
|
||||
|
||||
import type { ClineMessage } from "@roo-code/types"
|
||||
|
||||
import { DeltaTracker } from "../delta-tracker.js"
|
||||
import { ToolContentStreamManager } from "../tool-content-stream.js"
|
||||
import { NullLogger } from "../interfaces.js"
|
||||
import type { SendUpdateFn } from "../interfaces.js"
|
||||
|
||||
describe("ToolContentStreamManager", () => {
|
||||
let deltaTracker: DeltaTracker
|
||||
let sendUpdate: SendUpdateFn
|
||||
let sentUpdates: Array<Record<string, unknown>>
|
||||
let manager: ToolContentStreamManager
|
||||
|
||||
beforeEach(() => {
|
||||
deltaTracker = new DeltaTracker()
|
||||
sentUpdates = []
|
||||
sendUpdate = (update) => {
|
||||
sentUpdates.push(update as Record<string, unknown>)
|
||||
}
|
||||
manager = new ToolContentStreamManager({
|
||||
deltaTracker,
|
||||
sendUpdate,
|
||||
logger: new NullLogger(),
|
||||
})
|
||||
})
|
||||
|
||||
describe("isToolAskMessage", () => {
|
||||
it("returns true for tool ask messages", () => {
|
||||
const message: ClineMessage = {
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
ts: Date.now(),
|
||||
text: "{}",
|
||||
}
|
||||
expect(manager.isToolAskMessage(message)).toBe(true)
|
||||
})
|
||||
|
||||
it("returns false for other ask types", () => {
|
||||
const message: ClineMessage = {
|
||||
type: "ask",
|
||||
ask: "command",
|
||||
ts: Date.now(),
|
||||
text: "npm test",
|
||||
}
|
||||
expect(manager.isToolAskMessage(message)).toBe(false)
|
||||
})
|
||||
|
||||
it("returns false for say messages", () => {
|
||||
const message: ClineMessage = {
|
||||
type: "say",
|
||||
say: "text",
|
||||
ts: Date.now(),
|
||||
text: "hello",
|
||||
}
|
||||
expect(manager.isToolAskMessage(message)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("handleToolContentStreaming", () => {
|
||||
describe("file write tool detection", () => {
|
||||
const fileWriteTools = [
|
||||
"newFileCreated",
|
||||
"write_to_file",
|
||||
"create_file",
|
||||
"editedExistingFile",
|
||||
"apply_diff",
|
||||
"modify_file",
|
||||
]
|
||||
|
||||
fileWriteTools.forEach((toolName) => {
|
||||
it(`handles ${toolName} as a file write tool`, () => {
|
||||
const message: ClineMessage = {
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
ts: 12345,
|
||||
text: JSON.stringify({
|
||||
tool: toolName,
|
||||
path: "test.ts",
|
||||
content: "content",
|
||||
}),
|
||||
partial: true,
|
||||
}
|
||||
|
||||
const result = manager.handleToolContentStreaming(message)
|
||||
expect(result).toBe(true)
|
||||
// Should send header since it's a file write tool
|
||||
expect(sentUpdates.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
it("skips non-file tools", () => {
|
||||
const message: ClineMessage = {
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
ts: 12345,
|
||||
text: JSON.stringify({
|
||||
tool: "read_file",
|
||||
path: "test.ts",
|
||||
}),
|
||||
partial: true,
|
||||
}
|
||||
|
||||
const result = manager.handleToolContentStreaming(message)
|
||||
expect(result).toBe(true) // Handled by skipping
|
||||
expect(sentUpdates.length).toBe(0) // Nothing sent
|
||||
})
|
||||
})
|
||||
|
||||
describe("header management", () => {
|
||||
it("sends header on first valid path", () => {
|
||||
const message: ClineMessage = {
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
ts: 12345,
|
||||
text: JSON.stringify({
|
||||
tool: "write_to_file",
|
||||
path: "src/index.ts",
|
||||
content: "",
|
||||
}),
|
||||
partial: true,
|
||||
}
|
||||
|
||||
manager.handleToolContentStreaming(message)
|
||||
|
||||
expect(sentUpdates.length).toBe(1)
|
||||
expect(sentUpdates[0]).toEqual({
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: "\n**Creating src/index.ts**\n```\n" },
|
||||
})
|
||||
})
|
||||
|
||||
it("only sends header once per message", () => {
|
||||
const ts = 12345
|
||||
|
||||
// First call
|
||||
manager.handleToolContentStreaming({
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
ts,
|
||||
text: JSON.stringify({
|
||||
tool: "write_to_file",
|
||||
path: "test.ts",
|
||||
content: "line 1",
|
||||
}),
|
||||
partial: true,
|
||||
})
|
||||
|
||||
const headerCount1 = sentUpdates.filter((u) =>
|
||||
((u.content as { text: string }).text || "").includes("**Creating"),
|
||||
).length
|
||||
|
||||
// Second call with same ts
|
||||
manager.handleToolContentStreaming({
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
ts,
|
||||
text: JSON.stringify({
|
||||
tool: "write_to_file",
|
||||
path: "test.ts",
|
||||
content: "line 1\nline 2",
|
||||
}),
|
||||
partial: true,
|
||||
})
|
||||
|
||||
const headerCount2 = sentUpdates.filter((u) =>
|
||||
((u.content as { text: string }).text || "").includes("**Creating"),
|
||||
).length
|
||||
|
||||
expect(headerCount1).toBe(1)
|
||||
expect(headerCount2).toBe(1) // Still 1, no duplicate
|
||||
})
|
||||
|
||||
it("waits for valid path before sending header", () => {
|
||||
// Path without extension is not valid
|
||||
const message: ClineMessage = {
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
ts: 12345,
|
||||
text: JSON.stringify({
|
||||
tool: "write_to_file",
|
||||
path: "incomplete",
|
||||
content: "content",
|
||||
}),
|
||||
partial: true,
|
||||
}
|
||||
|
||||
manager.handleToolContentStreaming(message)
|
||||
expect(sentUpdates.length).toBe(0) // No header yet
|
||||
})
|
||||
|
||||
it("validates path has file extension", () => {
|
||||
const validPaths = ["test.ts", "README.md", "config.json", "src/utils.js"]
|
||||
const invalidPaths = ["test", "src/folder/", "noextension"]
|
||||
|
||||
validPaths.forEach((path) => {
|
||||
sentUpdates.length = 0
|
||||
manager = new ToolContentStreamManager({
|
||||
deltaTracker: new DeltaTracker(),
|
||||
sendUpdate,
|
||||
logger: new NullLogger(),
|
||||
})
|
||||
|
||||
manager.handleToolContentStreaming({
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
ts: Date.now(),
|
||||
text: JSON.stringify({ tool: "write_to_file", path, content: "" }),
|
||||
partial: true,
|
||||
})
|
||||
|
||||
expect(sentUpdates.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
invalidPaths.forEach((path) => {
|
||||
sentUpdates.length = 0
|
||||
manager = new ToolContentStreamManager({
|
||||
deltaTracker: new DeltaTracker(),
|
||||
sendUpdate,
|
||||
logger: new NullLogger(),
|
||||
})
|
||||
|
||||
manager.handleToolContentStreaming({
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
ts: Date.now(),
|
||||
text: JSON.stringify({ tool: "write_to_file", path, content: "x" }),
|
||||
partial: true,
|
||||
})
|
||||
|
||||
expect(sentUpdates.length).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("content streaming", () => {
|
||||
it("streams content deltas", () => {
|
||||
const ts = 12345
|
||||
|
||||
// First chunk
|
||||
manager.handleToolContentStreaming({
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
ts,
|
||||
text: JSON.stringify({
|
||||
tool: "write_to_file",
|
||||
path: "test.ts",
|
||||
content: "const x = 1;",
|
||||
}),
|
||||
partial: true,
|
||||
})
|
||||
|
||||
// Header + content
|
||||
expect(sentUpdates.length).toBe(2)
|
||||
expect(sentUpdates[1]).toEqual({
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: "const x = 1;" },
|
||||
})
|
||||
|
||||
// Second chunk with more content
|
||||
manager.handleToolContentStreaming({
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
ts,
|
||||
text: JSON.stringify({
|
||||
tool: "write_to_file",
|
||||
path: "test.ts",
|
||||
content: "const x = 1;\nconst y = 2;",
|
||||
}),
|
||||
partial: true,
|
||||
})
|
||||
|
||||
// Should only send the delta
|
||||
expect(sentUpdates.length).toBe(3)
|
||||
expect(sentUpdates[2]).toEqual({
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: "\nconst y = 2;" },
|
||||
})
|
||||
})
|
||||
|
||||
it("handles multiple tool streams independently", () => {
|
||||
// First tool
|
||||
manager.handleToolContentStreaming({
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
ts: 1000,
|
||||
text: JSON.stringify({
|
||||
tool: "write_to_file",
|
||||
path: "file1.ts",
|
||||
content: "content1",
|
||||
}),
|
||||
partial: true,
|
||||
})
|
||||
|
||||
// Second tool
|
||||
manager.handleToolContentStreaming({
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
ts: 2000,
|
||||
text: JSON.stringify({
|
||||
tool: "write_to_file",
|
||||
path: "file2.ts",
|
||||
content: "content2",
|
||||
}),
|
||||
partial: true,
|
||||
})
|
||||
|
||||
// Both should get headers
|
||||
const headers = sentUpdates.filter((u) =>
|
||||
((u.content as { text: string }).text || "").includes("**Creating"),
|
||||
)
|
||||
expect(headers.length).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("completion", () => {
|
||||
it("sends closing code fence on complete", () => {
|
||||
const ts = 12345
|
||||
|
||||
// Partial message
|
||||
manager.handleToolContentStreaming({
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
ts,
|
||||
text: JSON.stringify({
|
||||
tool: "write_to_file",
|
||||
path: "test.ts",
|
||||
content: "content",
|
||||
}),
|
||||
partial: true,
|
||||
})
|
||||
|
||||
sentUpdates.length = 0 // Clear
|
||||
|
||||
// Complete message
|
||||
manager.handleToolContentStreaming({
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
ts,
|
||||
text: JSON.stringify({
|
||||
tool: "write_to_file",
|
||||
path: "test.ts",
|
||||
content: "content",
|
||||
}),
|
||||
partial: false,
|
||||
})
|
||||
|
||||
expect(sentUpdates[0]).toEqual({
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: "\n```\n" },
|
||||
})
|
||||
})
|
||||
|
||||
it("cleans up header tracking on complete", () => {
|
||||
const ts = 12345
|
||||
|
||||
// Partial
|
||||
manager.handleToolContentStreaming({
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
ts,
|
||||
text: JSON.stringify({
|
||||
tool: "write_to_file",
|
||||
path: "test.ts",
|
||||
content: "content",
|
||||
}),
|
||||
partial: true,
|
||||
})
|
||||
|
||||
expect(manager.getActiveHeaderCount()).toBe(1)
|
||||
|
||||
// Complete
|
||||
manager.handleToolContentStreaming({
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
ts,
|
||||
text: JSON.stringify({
|
||||
tool: "write_to_file",
|
||||
path: "test.ts",
|
||||
content: "content",
|
||||
}),
|
||||
partial: false,
|
||||
})
|
||||
|
||||
expect(manager.getActiveHeaderCount()).toBe(0)
|
||||
})
|
||||
|
||||
it("does not send code fence if no header was sent", () => {
|
||||
const ts = 12345
|
||||
|
||||
// Complete message without prior partial (no header sent)
|
||||
manager.handleToolContentStreaming({
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
ts,
|
||||
text: JSON.stringify({
|
||||
tool: "write_to_file",
|
||||
path: "test.ts",
|
||||
content: "content",
|
||||
}),
|
||||
partial: false,
|
||||
})
|
||||
|
||||
// Should not send closing fence
|
||||
const closingFences = sentUpdates.filter((u) =>
|
||||
((u.content as { text: string }).text || "").includes("```"),
|
||||
)
|
||||
expect(closingFences.length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("JSON parsing", () => {
|
||||
it("handles invalid JSON gracefully", () => {
|
||||
const message: ClineMessage = {
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
ts: 12345,
|
||||
text: "{incomplete json",
|
||||
partial: true,
|
||||
}
|
||||
|
||||
const result = manager.handleToolContentStreaming(message)
|
||||
expect(result).toBe(true) // Handled by returning early
|
||||
expect(sentUpdates.length).toBe(0)
|
||||
})
|
||||
|
||||
it("handles empty text", () => {
|
||||
const message: ClineMessage = {
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
ts: 12345,
|
||||
text: "",
|
||||
partial: true,
|
||||
}
|
||||
|
||||
const result = manager.handleToolContentStreaming(message)
|
||||
expect(result).toBe(true)
|
||||
expect(sentUpdates.length).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("reset", () => {
|
||||
it("clears header tracking", () => {
|
||||
manager.handleToolContentStreaming({
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
ts: 12345,
|
||||
text: JSON.stringify({
|
||||
tool: "write_to_file",
|
||||
path: "test.ts",
|
||||
content: "content",
|
||||
}),
|
||||
partial: true,
|
||||
})
|
||||
|
||||
expect(manager.getActiveHeaderCount()).toBe(1)
|
||||
|
||||
manager.reset()
|
||||
expect(manager.getActiveHeaderCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getActiveHeaderCount", () => {
|
||||
it("returns 0 initially", () => {
|
||||
expect(manager.getActiveHeaderCount()).toBe(0)
|
||||
})
|
||||
|
||||
it("returns correct count after streaming", () => {
|
||||
manager.handleToolContentStreaming({
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
ts: 1000,
|
||||
text: JSON.stringify({ tool: "write_to_file", path: "a.ts", content: "" }),
|
||||
partial: true,
|
||||
})
|
||||
|
||||
manager.handleToolContentStreaming({
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
ts: 2000,
|
||||
text: JSON.stringify({ tool: "write_to_file", path: "b.ts", content: "" }),
|
||||
partial: true,
|
||||
})
|
||||
|
||||
expect(manager.getActiveHeaderCount()).toBe(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
495
apps/cli/src/acp/__tests__/tool-handler.test.ts
Normal file
495
apps/cli/src/acp/__tests__/tool-handler.test.ts
Normal file
|
|
@ -0,0 +1,495 @@
|
|||
/**
|
||||
* Tool Handler Unit Tests
|
||||
*
|
||||
* Tests for the ToolHandler abstraction and ToolHandlerRegistry.
|
||||
*/
|
||||
|
||||
import type { ClineMessage, ClineAsk } from "@roo-code/types"
|
||||
|
||||
import {
|
||||
ToolHandlerRegistry,
|
||||
CommandToolHandler,
|
||||
FileEditToolHandler,
|
||||
FileReadToolHandler,
|
||||
SearchToolHandler,
|
||||
ListFilesToolHandler,
|
||||
DefaultToolHandler,
|
||||
type ToolHandlerContext,
|
||||
} from "../tool-handler.js"
|
||||
import { parseToolFromMessage } from "../translator.js"
|
||||
import { NullLogger } from "../interfaces.js"
|
||||
|
||||
// =============================================================================
|
||||
// Test Utilities
|
||||
// =============================================================================
|
||||
|
||||
const testLogger = new NullLogger()
|
||||
|
||||
function createContext(message: ClineMessage, ask: ClineAsk, workspacePath = "/workspace"): ToolHandlerContext {
|
||||
return {
|
||||
message,
|
||||
ask,
|
||||
workspacePath,
|
||||
toolInfo: parseToolFromMessage(message, workspacePath),
|
||||
logger: testLogger,
|
||||
}
|
||||
}
|
||||
|
||||
function createToolMessage(tool: string, params: Record<string, unknown> = {}): ClineMessage {
|
||||
return {
|
||||
ts: Date.now(),
|
||||
type: "say",
|
||||
say: "text",
|
||||
text: JSON.stringify({ tool, ...params }),
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CommandToolHandler Tests
|
||||
// =============================================================================
|
||||
|
||||
describe("CommandToolHandler", () => {
|
||||
const handler = new CommandToolHandler()
|
||||
|
||||
describe("canHandle", () => {
|
||||
it("should handle command asks", () => {
|
||||
const context = createContext(createToolMessage("execute_command", { command: "ls" }), "command")
|
||||
expect(handler.canHandle(context)).toBe(true)
|
||||
})
|
||||
|
||||
it("should not handle tool asks", () => {
|
||||
const context = createContext(createToolMessage("read_file", { path: "test.ts" }), "tool")
|
||||
expect(handler.canHandle(context)).toBe(false)
|
||||
})
|
||||
|
||||
it("should not handle browser_action_launch asks", () => {
|
||||
const context = createContext(createToolMessage("browser_action", {}), "browser_action_launch")
|
||||
expect(handler.canHandle(context)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("handle", () => {
|
||||
it("should return execute kind for commands", () => {
|
||||
const context = createContext(createToolMessage("execute_command", { command: "npm test" }), "command")
|
||||
const result = handler.handle(context)
|
||||
|
||||
expect(result.initialUpdate).toMatchObject({
|
||||
sessionUpdate: "tool_call",
|
||||
kind: "execute",
|
||||
status: "in_progress",
|
||||
})
|
||||
})
|
||||
|
||||
it("should track as pending command", () => {
|
||||
const message = createToolMessage("execute_command", { command: "npm test" })
|
||||
const context = createContext(message, "command")
|
||||
const result = handler.handle(context)
|
||||
|
||||
expect(result.trackAsPendingCommand).toBeDefined()
|
||||
expect(result.trackAsPendingCommand?.command).toBe(message.text)
|
||||
expect(result.trackAsPendingCommand?.ts).toBe(message.ts)
|
||||
})
|
||||
|
||||
it("should not include completion update", () => {
|
||||
const context = createContext(createToolMessage("execute_command", { command: "ls" }), "command")
|
||||
const result = handler.handle(context)
|
||||
|
||||
expect(result.completionUpdate).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// FileEditToolHandler Tests
|
||||
// =============================================================================
|
||||
|
||||
describe("FileEditToolHandler", () => {
|
||||
const handler = new FileEditToolHandler()
|
||||
|
||||
describe("canHandle", () => {
|
||||
it("should handle write_to_file tool", () => {
|
||||
const context = createContext(createToolMessage("write_to_file", { path: "test.ts" }), "tool")
|
||||
expect(handler.canHandle(context)).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle apply_diff tool", () => {
|
||||
const context = createContext(createToolMessage("apply_diff", { path: "test.ts" }), "tool")
|
||||
expect(handler.canHandle(context)).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle create_file tool", () => {
|
||||
const context = createContext(createToolMessage("create_file", { path: "new.ts" }), "tool")
|
||||
expect(handler.canHandle(context)).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle newFileCreated tool", () => {
|
||||
const context = createContext(createToolMessage("newFileCreated", { path: "new.ts" }), "tool")
|
||||
expect(handler.canHandle(context)).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle editedExistingFile tool", () => {
|
||||
const context = createContext(createToolMessage("editedExistingFile", { path: "test.ts" }), "tool")
|
||||
expect(handler.canHandle(context)).toBe(true)
|
||||
})
|
||||
|
||||
it("should not handle read_file tool", () => {
|
||||
const context = createContext(createToolMessage("read_file", { path: "test.ts" }), "tool")
|
||||
expect(handler.canHandle(context)).toBe(false)
|
||||
})
|
||||
|
||||
it("should not handle command asks", () => {
|
||||
const context = createContext(createToolMessage("write_to_file", { path: "test.ts" }), "command")
|
||||
expect(handler.canHandle(context)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("handle", () => {
|
||||
it("should return edit kind", () => {
|
||||
const context = createContext(createToolMessage("write_to_file", { path: "test.ts" }), "tool")
|
||||
const result = handler.handle(context)
|
||||
|
||||
expect(result.initialUpdate).toMatchObject({
|
||||
sessionUpdate: "tool_call",
|
||||
kind: "edit",
|
||||
status: "in_progress",
|
||||
})
|
||||
})
|
||||
|
||||
it("should include completion update", () => {
|
||||
const context = createContext(createToolMessage("write_to_file", { path: "test.ts" }), "tool")
|
||||
const result = handler.handle(context)
|
||||
|
||||
expect(result.completionUpdate).toMatchObject({
|
||||
sessionUpdate: "tool_call_update",
|
||||
status: "completed",
|
||||
})
|
||||
})
|
||||
|
||||
it("should not track as pending command", () => {
|
||||
const context = createContext(createToolMessage("write_to_file", { path: "test.ts" }), "tool")
|
||||
const result = handler.handle(context)
|
||||
|
||||
expect(result.trackAsPendingCommand).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// FileReadToolHandler Tests
|
||||
// =============================================================================
|
||||
|
||||
describe("FileReadToolHandler", () => {
|
||||
const handler = new FileReadToolHandler()
|
||||
|
||||
describe("canHandle", () => {
|
||||
it("should handle read_file tool", () => {
|
||||
const context = createContext(createToolMessage("read_file", { path: "test.ts" }), "tool")
|
||||
expect(handler.canHandle(context)).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle readFile tool", () => {
|
||||
const context = createContext(createToolMessage("readFile", { path: "test.ts" }), "tool")
|
||||
expect(handler.canHandle(context)).toBe(true)
|
||||
})
|
||||
|
||||
it("should not handle write_to_file tool", () => {
|
||||
const context = createContext(createToolMessage("write_to_file", { path: "test.ts" }), "tool")
|
||||
expect(handler.canHandle(context)).toBe(false)
|
||||
})
|
||||
|
||||
it("should not handle command asks", () => {
|
||||
const context = createContext(createToolMessage("read_file", { path: "test.ts" }), "command")
|
||||
expect(handler.canHandle(context)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("handle", () => {
|
||||
it("should return read kind", () => {
|
||||
const context = createContext(createToolMessage("read_file", { path: "test.ts" }), "tool")
|
||||
const result = handler.handle(context)
|
||||
|
||||
expect(result.initialUpdate).toMatchObject({
|
||||
sessionUpdate: "tool_call",
|
||||
kind: "read",
|
||||
status: "in_progress",
|
||||
})
|
||||
})
|
||||
|
||||
it("should include completion update", () => {
|
||||
const context = createContext(createToolMessage("read_file", { path: "test.ts" }), "tool")
|
||||
const result = handler.handle(context)
|
||||
|
||||
expect(result.completionUpdate).toMatchObject({
|
||||
sessionUpdate: "tool_call_update",
|
||||
status: "completed",
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// SearchToolHandler Tests
|
||||
// =============================================================================
|
||||
|
||||
describe("SearchToolHandler", () => {
|
||||
const handler = new SearchToolHandler()
|
||||
|
||||
describe("canHandle", () => {
|
||||
it("should handle search_files tool", () => {
|
||||
const context = createContext(createToolMessage("search_files", { regex: "test" }), "tool")
|
||||
expect(handler.canHandle(context)).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle searchFiles tool", () => {
|
||||
const context = createContext(createToolMessage("searchFiles", { regex: "test" }), "tool")
|
||||
expect(handler.canHandle(context)).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle codebase_search tool", () => {
|
||||
const context = createContext(createToolMessage("codebase_search", { query: "test" }), "tool")
|
||||
expect(handler.canHandle(context)).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle grep tool", () => {
|
||||
const context = createContext(createToolMessage("grep", { pattern: "test" }), "tool")
|
||||
expect(handler.canHandle(context)).toBe(true)
|
||||
})
|
||||
|
||||
it("should not handle custom tool with search in name (exact matching)", () => {
|
||||
const context = createContext(createToolMessage("custom_search_tool", {}), "tool")
|
||||
// With exact matching, "custom_search_tool" won't match the search category
|
||||
expect(handler.canHandle(context)).toBe(false)
|
||||
})
|
||||
|
||||
it("should not handle read_file tool", () => {
|
||||
const context = createContext(createToolMessage("read_file", { path: "test.ts" }), "tool")
|
||||
expect(handler.canHandle(context)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("handle", () => {
|
||||
it("should return search kind", () => {
|
||||
const context = createContext(createToolMessage("search_files", { regex: "test" }), "tool")
|
||||
const result = handler.handle(context)
|
||||
|
||||
expect(result.initialUpdate).toMatchObject({
|
||||
sessionUpdate: "tool_call",
|
||||
kind: "search",
|
||||
status: "in_progress",
|
||||
})
|
||||
})
|
||||
|
||||
it("should format search results in completion", () => {
|
||||
const searchResults = "Found 5 results.\n\n# src/file1.ts\n 1 | match\n\n# src/file2.ts\n 2 | match"
|
||||
const context = createContext(createToolMessage("search_files", { content: searchResults }), "tool")
|
||||
const result = handler.handle(context)
|
||||
|
||||
expect(result.completionUpdate).toMatchObject({
|
||||
sessionUpdate: "tool_call_update",
|
||||
status: "completed",
|
||||
})
|
||||
|
||||
// Content should be formatted - cast to access content property
|
||||
const completionUpdate = result.completionUpdate as Record<string, unknown>
|
||||
expect(completionUpdate?.content).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// ListFilesToolHandler Tests
|
||||
// =============================================================================
|
||||
|
||||
describe("ListFilesToolHandler", () => {
|
||||
const handler = new ListFilesToolHandler()
|
||||
|
||||
describe("canHandle", () => {
|
||||
it("should handle list_files tool", () => {
|
||||
const context = createContext(createToolMessage("list_files", { path: "src" }), "tool")
|
||||
expect(handler.canHandle(context)).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle listFiles tool", () => {
|
||||
const context = createContext(createToolMessage("listFiles", { path: "src" }), "tool")
|
||||
expect(handler.canHandle(context)).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle listFilesTopLevel tool", () => {
|
||||
const context = createContext(createToolMessage("listFilesTopLevel", { path: "src" }), "tool")
|
||||
expect(handler.canHandle(context)).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle listFilesRecursive tool", () => {
|
||||
const context = createContext(createToolMessage("listFilesRecursive", { path: "src" }), "tool")
|
||||
expect(handler.canHandle(context)).toBe(true)
|
||||
})
|
||||
|
||||
it("should not handle read_file tool", () => {
|
||||
const context = createContext(createToolMessage("read_file", { path: "test.ts" }), "tool")
|
||||
expect(handler.canHandle(context)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("handle", () => {
|
||||
it("should return read kind", () => {
|
||||
const context = createContext(createToolMessage("list_files", { path: "src" }), "tool")
|
||||
const result = handler.handle(context)
|
||||
|
||||
expect(result.initialUpdate).toMatchObject({
|
||||
sessionUpdate: "tool_call",
|
||||
kind: "read",
|
||||
status: "in_progress",
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// DefaultToolHandler Tests
|
||||
// =============================================================================
|
||||
|
||||
describe("DefaultToolHandler", () => {
|
||||
const handler = new DefaultToolHandler()
|
||||
|
||||
describe("canHandle", () => {
|
||||
it("should always return true", () => {
|
||||
const context1 = createContext(createToolMessage("unknown_tool", {}), "tool")
|
||||
const context2 = createContext(createToolMessage("custom_operation", {}), "tool")
|
||||
const context3 = createContext(createToolMessage("any_tool", {}), "browser_action_launch")
|
||||
|
||||
expect(handler.canHandle(context1)).toBe(true)
|
||||
expect(handler.canHandle(context2)).toBe(true)
|
||||
expect(handler.canHandle(context3)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("handle", () => {
|
||||
it("should map tool kind from tool name (exact matching)", () => {
|
||||
// Use exact tool name from TOOL_CATEGORIES.think
|
||||
const context = createContext(createToolMessage("think", {}), "tool")
|
||||
const result = handler.handle(context)
|
||||
|
||||
expect(result.initialUpdate).toMatchObject({
|
||||
sessionUpdate: "tool_call",
|
||||
kind: "think",
|
||||
status: "in_progress",
|
||||
})
|
||||
})
|
||||
|
||||
it("should return other kind for unknown tools (exact matching)", () => {
|
||||
// Tool names that don't exactly match categories return "other"
|
||||
const context = createContext(createToolMessage("think_about_it", {}), "tool")
|
||||
const result = handler.handle(context)
|
||||
|
||||
expect(result.initialUpdate).toMatchObject({
|
||||
sessionUpdate: "tool_call",
|
||||
kind: "other",
|
||||
status: "in_progress",
|
||||
})
|
||||
})
|
||||
|
||||
it("should include completion update", () => {
|
||||
const context = createContext(createToolMessage("custom_tool", {}), "tool")
|
||||
const result = handler.handle(context)
|
||||
|
||||
expect(result.completionUpdate).toMatchObject({
|
||||
sessionUpdate: "tool_call_update",
|
||||
status: "completed",
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// ToolHandlerRegistry Tests
|
||||
// =============================================================================
|
||||
|
||||
describe("ToolHandlerRegistry", () => {
|
||||
describe("getHandler", () => {
|
||||
const registry = new ToolHandlerRegistry()
|
||||
|
||||
it("should return CommandToolHandler for command asks", () => {
|
||||
const context = createContext(createToolMessage("execute_command", {}), "command")
|
||||
const handler = registry.getHandler(context)
|
||||
|
||||
expect(handler).toBeInstanceOf(CommandToolHandler)
|
||||
})
|
||||
|
||||
it("should return FileEditToolHandler for edit tools", () => {
|
||||
const context = createContext(createToolMessage("write_to_file", { path: "test.ts" }), "tool")
|
||||
const handler = registry.getHandler(context)
|
||||
|
||||
expect(handler).toBeInstanceOf(FileEditToolHandler)
|
||||
})
|
||||
|
||||
it("should return FileReadToolHandler for read tools", () => {
|
||||
const context = createContext(createToolMessage("read_file", { path: "test.ts" }), "tool")
|
||||
const handler = registry.getHandler(context)
|
||||
|
||||
expect(handler).toBeInstanceOf(FileReadToolHandler)
|
||||
})
|
||||
|
||||
it("should return SearchToolHandler for search tools", () => {
|
||||
const context = createContext(createToolMessage("search_files", {}), "tool")
|
||||
const handler = registry.getHandler(context)
|
||||
|
||||
expect(handler).toBeInstanceOf(SearchToolHandler)
|
||||
})
|
||||
|
||||
it("should return ListFilesToolHandler for list tools", () => {
|
||||
const context = createContext(createToolMessage("list_files", {}), "tool")
|
||||
const handler = registry.getHandler(context)
|
||||
|
||||
expect(handler).toBeInstanceOf(ListFilesToolHandler)
|
||||
})
|
||||
|
||||
it("should return DefaultToolHandler for unknown tools", () => {
|
||||
const context = createContext(createToolMessage("unknown_tool", {}), "tool")
|
||||
const handler = registry.getHandler(context)
|
||||
|
||||
expect(handler).toBeInstanceOf(DefaultToolHandler)
|
||||
})
|
||||
})
|
||||
|
||||
describe("handle", () => {
|
||||
const registry = new ToolHandlerRegistry()
|
||||
|
||||
it("should dispatch to correct handler and return result", () => {
|
||||
const context = createContext(createToolMessage("execute_command", {}), "command")
|
||||
const result = registry.handle(context)
|
||||
|
||||
expect(result.initialUpdate).toMatchObject({
|
||||
sessionUpdate: "tool_call",
|
||||
kind: "execute",
|
||||
status: "in_progress",
|
||||
})
|
||||
expect(result.trackAsPendingCommand).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("createContext", () => {
|
||||
it("should create a valid context", () => {
|
||||
const message = createToolMessage("read_file", { path: "test.ts" })
|
||||
const context = ToolHandlerRegistry.createContext(message, "tool", "/workspace", testLogger)
|
||||
|
||||
expect(context.message).toBe(message)
|
||||
expect(context.ask).toBe("tool")
|
||||
expect(context.workspacePath).toBe("/workspace")
|
||||
expect(context.toolInfo).toBeDefined()
|
||||
expect(context.toolInfo?.name).toBe("read_file")
|
||||
expect(context.logger).toBe(testLogger)
|
||||
})
|
||||
})
|
||||
|
||||
describe("custom handlers", () => {
|
||||
it("should accept custom handler list", () => {
|
||||
const customHandler = new DefaultToolHandler()
|
||||
const registry = new ToolHandlerRegistry([customHandler])
|
||||
|
||||
const context = createContext(createToolMessage("any_tool", {}), "command")
|
||||
const handler = registry.getHandler(context)
|
||||
|
||||
expect(handler).toBe(customHandler)
|
||||
})
|
||||
})
|
||||
})
|
||||
502
apps/cli/src/acp/__tests__/translator.test.ts
Normal file
502
apps/cli/src/acp/__tests__/translator.test.ts
Normal 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
243
apps/cli/src/acp/agent.ts
Normal 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")
|
||||
}
|
||||
}
|
||||
243
apps/cli/src/acp/command-stream.ts
Normal file
243
apps/cli/src/acp/command-stream.ts
Normal 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
|
||||
}
|
||||
}
|
||||
221
apps/cli/src/acp/content-formatter.ts
Normal file
221
apps/cli/src/acp/content-formatter.ts
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
/**
|
||||
* Content Formatter
|
||||
*
|
||||
* Provides content formatting for ACP UI display.
|
||||
*
|
||||
* This module offers two usage patterns:
|
||||
*
|
||||
* 1. **Direct function imports** (preferred for simple use cases):
|
||||
* ```ts
|
||||
* import { formatSearchResults, wrapInCodeBlock } from './content-formatter.js'
|
||||
* const formatted = wrapInCodeBlock(formatSearchResults(content))
|
||||
* ```
|
||||
*
|
||||
* 2. **Class-based DI** (for dependency injection in tests):
|
||||
* ```ts
|
||||
* import { ContentFormatter, type IContentFormatter } from './content-formatter.js'
|
||||
* const formatter: IContentFormatter = new ContentFormatter()
|
||||
* ```
|
||||
*/
|
||||
|
||||
import type { IContentFormatter } from "./interfaces.js"
|
||||
import {
|
||||
formatSearchResults,
|
||||
formatReadContent,
|
||||
wrapInCodeBlock,
|
||||
isUserEcho,
|
||||
readFileContent,
|
||||
readFileContentAsync,
|
||||
extractContentFromParams,
|
||||
type FormatConfig,
|
||||
DEFAULT_FORMAT_CONFIG,
|
||||
} from "./utils/index.js"
|
||||
import { acpLog } from "./logger.js"
|
||||
|
||||
// =============================================================================
|
||||
// Direct Exports (Preferred)
|
||||
// =============================================================================
|
||||
|
||||
// Re-export utility functions for direct use
|
||||
export { formatSearchResults, formatReadContent, wrapInCodeBlock, isUserEcho }
|
||||
|
||||
// =============================================================================
|
||||
// Tool Result Formatting
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Format tool result content based on the tool kind.
|
||||
*
|
||||
* Applies appropriate formatting (search summary, truncation, code blocks)
|
||||
* based on the tool type.
|
||||
*
|
||||
* @param kind - The tool kind (search, read, etc.)
|
||||
* @param content - The raw content to format
|
||||
* @param config - Optional formatting configuration
|
||||
* @returns Formatted content
|
||||
*/
|
||||
export function formatToolResult(kind: string, content: string, config: FormatConfig = DEFAULT_FORMAT_CONFIG): string {
|
||||
switch (kind) {
|
||||
case "search":
|
||||
return wrapInCodeBlock(formatSearchResults(content))
|
||||
case "read":
|
||||
return wrapInCodeBlock(formatReadContent(content, config))
|
||||
default:
|
||||
return content
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract file content for readFile operations.
|
||||
*
|
||||
* For readFile tools, the rawInput.content field contains the file PATH
|
||||
* (not the contents), so we need to read the actual file.
|
||||
*
|
||||
* @param rawInput - Tool parameters
|
||||
* @param workspacePath - Workspace path for resolving relative paths
|
||||
* @returns File content or error message, or undefined if no path
|
||||
*/
|
||||
export function extractFileContent(rawInput: Record<string, unknown>, workspacePath: string): string | undefined {
|
||||
const toolName = (rawInput.tool as string | undefined)?.toLowerCase() || ""
|
||||
|
||||
// Only read file content for readFile tools
|
||||
if (toolName !== "readfile" && toolName !== "read_file") {
|
||||
return extractContentFromParams(rawInput)
|
||||
}
|
||||
|
||||
// Check if we have a path before attempting to read
|
||||
const filePath = rawInput.content as string | undefined
|
||||
const relativePath = rawInput.path as string | undefined
|
||||
if (!filePath && !relativePath) {
|
||||
acpLog.warn("ContentFormatter", "readFile tool has no path")
|
||||
return undefined
|
||||
}
|
||||
|
||||
const result = readFileContent(rawInput, workspacePath)
|
||||
if (result.ok) {
|
||||
acpLog.debug("ContentFormatter", `Read file content: ${result.value.length} chars`)
|
||||
return result.value
|
||||
} else {
|
||||
acpLog.error("ContentFormatter", result.error)
|
||||
return `Error reading file: ${result.error}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract file content asynchronously for readFile operations.
|
||||
*
|
||||
* @param rawInput - Tool parameters
|
||||
* @param workspacePath - Workspace path for resolving relative paths
|
||||
* @returns Promise with file content or error message
|
||||
*/
|
||||
export async function extractFileContentAsync(
|
||||
rawInput: Record<string, unknown>,
|
||||
workspacePath: string,
|
||||
): Promise<string | undefined> {
|
||||
const toolName = (rawInput.tool as string | undefined)?.toLowerCase() || ""
|
||||
|
||||
// Only read file content for readFile tools
|
||||
if (toolName !== "readfile" && toolName !== "read_file") {
|
||||
return extractContentFromParams(rawInput)
|
||||
}
|
||||
|
||||
// Check if we have a path before attempting to read
|
||||
const filePath = rawInput.content as string | undefined
|
||||
const relativePath = rawInput.path as string | undefined
|
||||
if (!filePath && !relativePath) {
|
||||
acpLog.warn("ContentFormatter", "readFile tool has no path")
|
||||
return undefined
|
||||
}
|
||||
|
||||
const result = await readFileContentAsync(rawInput, workspacePath)
|
||||
if (result.ok) {
|
||||
acpLog.debug("ContentFormatter", `Read file content: ${result.value.length} chars`)
|
||||
return result.value
|
||||
} else {
|
||||
acpLog.error("ContentFormatter", result.error)
|
||||
return `Error reading file: ${result.error}`
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ContentFormatter Class (for DI)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Formats content for display in the ACP client UI.
|
||||
*
|
||||
* Implements IContentFormatter interface for dependency injection.
|
||||
* For simple use cases, prefer the direct function exports above.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // In production code
|
||||
* const formatter = new ContentFormatter()
|
||||
*
|
||||
* // In tests with mock
|
||||
* const mockFormatter: IContentFormatter = {
|
||||
* formatToolResult: vi.fn(),
|
||||
* // ...
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class ContentFormatter implements IContentFormatter {
|
||||
private readonly config: FormatConfig
|
||||
|
||||
constructor(config?: Partial<FormatConfig>) {
|
||||
this.config = { ...DEFAULT_FORMAT_CONFIG, ...config }
|
||||
}
|
||||
|
||||
formatToolResult(kind: string, content: string): string {
|
||||
return formatToolResult(kind, content, this.config)
|
||||
}
|
||||
|
||||
formatSearchResults(content: string): string {
|
||||
return formatSearchResults(content)
|
||||
}
|
||||
|
||||
formatReadResults(content: string): string {
|
||||
return formatReadContent(content, this.config)
|
||||
}
|
||||
|
||||
wrapInCodeBlock(content: string, language?: string): string {
|
||||
return wrapInCodeBlock(content, language)
|
||||
}
|
||||
|
||||
isUserEcho(text: string, promptText: string | null): boolean {
|
||||
return isUserEcho(text, promptText)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract content from rawInput parameters.
|
||||
* Tries common field names for content.
|
||||
*/
|
||||
extractContentFromRawInput(rawInput: Record<string, unknown>): string | undefined {
|
||||
return extractContentFromParams(rawInput)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract file content for readFile operations.
|
||||
* Delegates to the standalone extractFileContent function.
|
||||
*/
|
||||
extractFileContent(rawInput: Record<string, unknown>, workspacePath: string): string | undefined {
|
||||
return extractFileContent(rawInput, workspacePath)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Factory Function
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Create a new content formatter with optional configuration.
|
||||
*/
|
||||
export function createContentFormatter(config?: Partial<FormatConfig>): ContentFormatter {
|
||||
return new ContentFormatter(config)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Type Exports
|
||||
// =============================================================================
|
||||
|
||||
export type { FormatConfig as ContentFormatterConfig }
|
||||
71
apps/cli/src/acp/delta-tracker.ts
Normal file
71
apps/cli/src/acp/delta-tracker.ts
Normal 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
|
||||
}
|
||||
}
|
||||
2
apps/cli/src/acp/index.ts
Normal file
2
apps/cli/src/acp/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { RooCodeAgent } from "./agent.js"
|
||||
export { acpLog } from "./logger.js"
|
||||
375
apps/cli/src/acp/interfaces.ts
Normal file
375
apps/cli/src/acp/interfaces.ts
Normal 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
188
apps/cli/src/acp/logger.ts
Normal 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()}`)
|
||||
130
apps/cli/src/acp/model-service.ts
Normal file
130
apps/cli/src/acp/model-service.ts
Normal 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)
|
||||
}
|
||||
245
apps/cli/src/acp/prompt-state.ts
Normal file
245
apps/cli/src/acp/prompt-state.ts
Normal 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)
|
||||
}
|
||||
575
apps/cli/src/acp/session-event-handler.ts
Normal file
575
apps/cli/src/acp/session-event-handler.ts
Normal 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
403
apps/cli/src/acp/session.ts
Normal 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
|
||||
}
|
||||
}
|
||||
204
apps/cli/src/acp/tool-content-stream.ts
Normal file
204
apps/cli/src/acp/tool-content-stream.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
480
apps/cli/src/acp/tool-handler.ts
Normal file
480
apps/cli/src/acp/tool-handler.ts
Normal file
|
|
@ -0,0 +1,480 @@
|
|||
/**
|
||||
* Tool Handler Abstraction
|
||||
*
|
||||
* Provides a polymorphic interface for handling different tool types.
|
||||
* Each handler knows how to process a specific category of tool operations,
|
||||
* enabling cleaner separation of concerns and easier testing.
|
||||
*/
|
||||
|
||||
import type * as acp from "@agentclientprotocol/sdk"
|
||||
import type { ClineMessage, ClineAsk } from "@roo-code/types"
|
||||
|
||||
import { parseToolFromMessage, type ToolCallInfo } from "./translator.js"
|
||||
import type { IAcpLogger } from "./interfaces.js"
|
||||
import { isEditTool, isReadTool, isSearchTool, isListFilesTool, mapToolToKind } from "./tool-registry.js"
|
||||
import {
|
||||
formatSearchResults,
|
||||
formatReadContent,
|
||||
wrapInCodeBlock,
|
||||
readFileContent,
|
||||
extractContentFromParams,
|
||||
DEFAULT_FORMAT_CONFIG,
|
||||
} from "./utils/index.js"
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Context passed to tool handlers for processing.
|
||||
*/
|
||||
export interface ToolHandlerContext {
|
||||
/** The original message from the extension */
|
||||
message: ClineMessage
|
||||
/** The ask type if this is a permission request */
|
||||
ask: ClineAsk
|
||||
/** Workspace path for resolving relative file paths */
|
||||
workspacePath: string
|
||||
/** Parsed tool information from the message */
|
||||
toolInfo: ToolCallInfo | null
|
||||
/** Logger instance */
|
||||
logger: IAcpLogger
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of handling a tool call.
|
||||
*/
|
||||
export interface ToolHandleResult {
|
||||
/** Initial tool_call update to send */
|
||||
initialUpdate: acp.SessionNotification["update"]
|
||||
/** Completion update to send (for non-command tools) */
|
||||
completionUpdate?: acp.SessionNotification["update"]
|
||||
/** Whether to track this as a pending command */
|
||||
trackAsPendingCommand?: {
|
||||
toolCallId: string
|
||||
command: string
|
||||
ts: number
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for tool handlers.
|
||||
*
|
||||
* Each implementation handles a specific category of tools (commands, files, search, etc.)
|
||||
* and knows how to create the appropriate ACP updates.
|
||||
*/
|
||||
export interface ToolHandler {
|
||||
/**
|
||||
* Check if this handler can process the given tool.
|
||||
*/
|
||||
canHandle(context: ToolHandlerContext): boolean
|
||||
|
||||
/**
|
||||
* Handle the tool call and return the appropriate updates.
|
||||
*/
|
||||
handle(context: ToolHandlerContext): ToolHandleResult
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Base Handler
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Base class providing common functionality for tool handlers.
|
||||
*/
|
||||
abstract class BaseToolHandler implements ToolHandler {
|
||||
abstract canHandle(context: ToolHandlerContext): boolean
|
||||
abstract handle(context: ToolHandlerContext): ToolHandleResult
|
||||
|
||||
/**
|
||||
* Build the basic tool call structure from context.
|
||||
*/
|
||||
protected buildBaseToolCall(context: ToolHandlerContext, kindOverride?: acp.ToolKind): acp.ToolCall {
|
||||
const { message, toolInfo } = context
|
||||
|
||||
return {
|
||||
toolCallId: toolInfo?.id || `tool-${message.ts}`,
|
||||
title: toolInfo?.title || message.text?.slice(0, 100) || "Tool execution",
|
||||
kind: kindOverride ?? (toolInfo ? mapToolToKind(toolInfo.name) : "other"),
|
||||
status: "pending",
|
||||
locations: toolInfo?.locations || [],
|
||||
rawInput: toolInfo?.params || {},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the initial in_progress update.
|
||||
*/
|
||||
protected createInitialUpdate(
|
||||
toolCall: acp.ToolCall,
|
||||
kindOverride?: acp.ToolKind,
|
||||
): acp.SessionNotification["update"] {
|
||||
return {
|
||||
sessionUpdate: "tool_call",
|
||||
...toolCall,
|
||||
kind: kindOverride ?? toolCall.kind,
|
||||
status: "in_progress",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Command Tool Handler
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Handles command execution tools.
|
||||
*
|
||||
* Commands are special because:
|
||||
* - They use "execute" kind for the "Run Command" UI
|
||||
* - They track pending calls for output correlation
|
||||
* - Completion comes via command_output messages, not immediately
|
||||
*/
|
||||
export class CommandToolHandler extends BaseToolHandler {
|
||||
canHandle(context: ToolHandlerContext): boolean {
|
||||
return context.ask === "command"
|
||||
}
|
||||
|
||||
handle(context: ToolHandlerContext): ToolHandleResult {
|
||||
const { message, logger } = context
|
||||
|
||||
const toolCall = this.buildBaseToolCall(context, "execute")
|
||||
|
||||
logger.info("CommandToolHandler", `Handling command: ${toolCall.toolCallId}`)
|
||||
|
||||
return {
|
||||
initialUpdate: this.createInitialUpdate(toolCall, "execute"),
|
||||
trackAsPendingCommand: {
|
||||
toolCallId: toolCall.toolCallId,
|
||||
command: message.text || "",
|
||||
ts: message.ts,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// File Edit Tool Handler
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Handles file editing operations (write, apply_diff, create, modify).
|
||||
*
|
||||
* File edits include diff content in the completion update for UI display.
|
||||
*/
|
||||
export class FileEditToolHandler extends BaseToolHandler {
|
||||
canHandle(context: ToolHandlerContext): boolean {
|
||||
if (context.ask !== "tool") return false
|
||||
|
||||
const toolName = context.toolInfo?.name || ""
|
||||
return isEditTool(toolName)
|
||||
}
|
||||
|
||||
handle(context: ToolHandlerContext): ToolHandleResult {
|
||||
const { toolInfo, logger } = context
|
||||
|
||||
const toolCall = this.buildBaseToolCall(context, "edit")
|
||||
|
||||
// Include diff content if available
|
||||
if (toolInfo?.content && toolInfo.content.length > 0) {
|
||||
toolCall.content = toolInfo.content
|
||||
}
|
||||
|
||||
logger.info("FileEditToolHandler", `Handling file edit: ${toolCall.toolCallId}`)
|
||||
|
||||
const completionUpdate: acp.SessionNotification["update"] = {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
status: "completed",
|
||||
rawOutput: toolInfo?.params || {},
|
||||
}
|
||||
|
||||
// Include diff content in completion
|
||||
if (toolInfo?.content && toolInfo.content.length > 0) {
|
||||
completionUpdate.content = toolInfo.content
|
||||
}
|
||||
|
||||
return {
|
||||
initialUpdate: this.createInitialUpdate(toolCall, "edit"),
|
||||
completionUpdate,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// File Read Tool Handler
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Handles file reading operations.
|
||||
*
|
||||
* For readFile tools, the rawInput.content contains the file PATH (not contents),
|
||||
* so we need to read the actual file content.
|
||||
*/
|
||||
export class FileReadToolHandler extends BaseToolHandler {
|
||||
canHandle(context: ToolHandlerContext): boolean {
|
||||
if (context.ask !== "tool") return false
|
||||
|
||||
const toolName = context.toolInfo?.name || ""
|
||||
return isReadTool(toolName)
|
||||
}
|
||||
|
||||
handle(context: ToolHandlerContext): ToolHandleResult {
|
||||
const { toolInfo, workspacePath, logger } = context
|
||||
|
||||
const toolCall = this.buildBaseToolCall(context, "read")
|
||||
const rawInput = (toolInfo?.params as Record<string, unknown>) || {}
|
||||
|
||||
logger.info("FileReadToolHandler", `Handling file read: ${toolCall.toolCallId}`)
|
||||
|
||||
// Read actual file content using shared utility
|
||||
const result = readFileContent(rawInput, workspacePath)
|
||||
const fileContent = result.ok ? result.value : result.error
|
||||
|
||||
// Format the content (truncate if needed, wrap in code block)
|
||||
const formattedContent = fileContent
|
||||
? wrapInCodeBlock(formatReadContent(fileContent, DEFAULT_FORMAT_CONFIG))
|
||||
: undefined
|
||||
|
||||
const completionUpdate: acp.SessionNotification["update"] = {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
status: "completed",
|
||||
rawOutput: rawInput,
|
||||
}
|
||||
|
||||
if (formattedContent) {
|
||||
completionUpdate.content = [
|
||||
{
|
||||
type: "content",
|
||||
content: { type: "text", text: formattedContent },
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
return {
|
||||
initialUpdate: this.createInitialUpdate(toolCall, "read"),
|
||||
completionUpdate,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Search Tool Handler
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Handles search operations (search_files, codebase_search, grep, etc.).
|
||||
*
|
||||
* Search results are formatted into a clean file list with summary.
|
||||
*/
|
||||
export class SearchToolHandler extends BaseToolHandler {
|
||||
canHandle(context: ToolHandlerContext): boolean {
|
||||
if (context.ask !== "tool") return false
|
||||
|
||||
const toolName = context.toolInfo?.name || ""
|
||||
return isSearchTool(toolName)
|
||||
}
|
||||
|
||||
handle(context: ToolHandlerContext): ToolHandleResult {
|
||||
const { toolInfo, logger } = context
|
||||
|
||||
const toolCall = this.buildBaseToolCall(context, "search")
|
||||
const rawInput = (toolInfo?.params as Record<string, unknown>) || {}
|
||||
|
||||
logger.info("SearchToolHandler", `Handling search: ${toolCall.toolCallId}`)
|
||||
|
||||
// Format search results using shared utility
|
||||
const rawContent = rawInput.content as string | undefined
|
||||
const formattedContent = rawContent ? wrapInCodeBlock(formatSearchResults(rawContent)) : undefined
|
||||
|
||||
const completionUpdate: acp.SessionNotification["update"] = {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
status: "completed",
|
||||
rawOutput: rawInput,
|
||||
}
|
||||
|
||||
if (formattedContent) {
|
||||
completionUpdate.content = [
|
||||
{
|
||||
type: "content",
|
||||
content: { type: "text", text: formattedContent },
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
return {
|
||||
initialUpdate: this.createInitialUpdate(toolCall, "search"),
|
||||
completionUpdate,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// List Files Tool Handler
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Handles list_files operations.
|
||||
*/
|
||||
export class ListFilesToolHandler extends BaseToolHandler {
|
||||
canHandle(context: ToolHandlerContext): boolean {
|
||||
if (context.ask !== "tool") return false
|
||||
|
||||
const toolName = context.toolInfo?.name || ""
|
||||
return isListFilesTool(toolName)
|
||||
}
|
||||
|
||||
handle(context: ToolHandlerContext): ToolHandleResult {
|
||||
const { toolInfo, logger } = context
|
||||
|
||||
const toolCall = this.buildBaseToolCall(context, "read")
|
||||
const rawInput = (toolInfo?.params as Record<string, unknown>) || {}
|
||||
|
||||
logger.info("ListFilesToolHandler", `Handling list files: ${toolCall.toolCallId}`)
|
||||
|
||||
// Extract content using shared utility
|
||||
const rawContent = extractContentFromParams(rawInput)
|
||||
|
||||
const completionUpdate: acp.SessionNotification["update"] = {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
status: "completed",
|
||||
rawOutput: rawInput,
|
||||
}
|
||||
|
||||
if (rawContent) {
|
||||
completionUpdate.content = [
|
||||
{
|
||||
type: "content",
|
||||
content: { type: "text", text: rawContent },
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
return {
|
||||
initialUpdate: this.createInitialUpdate(toolCall, "read"),
|
||||
completionUpdate,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Default Tool Handler
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Fallback handler for tools not matched by other handlers.
|
||||
*/
|
||||
export class DefaultToolHandler extends BaseToolHandler {
|
||||
canHandle(_context: ToolHandlerContext): boolean {
|
||||
// Default handler always matches as fallback
|
||||
return true
|
||||
}
|
||||
|
||||
handle(context: ToolHandlerContext): ToolHandleResult {
|
||||
const { toolInfo, logger } = context
|
||||
|
||||
const toolCall = this.buildBaseToolCall(context)
|
||||
const rawInput = (toolInfo?.params as Record<string, unknown>) || {}
|
||||
|
||||
logger.info("DefaultToolHandler", `Handling tool: ${toolCall.toolCallId}, kind: ${toolCall.kind}`)
|
||||
|
||||
// Extract content using shared utility
|
||||
const rawContent = extractContentFromParams(rawInput)
|
||||
|
||||
const completionUpdate: acp.SessionNotification["update"] = {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
status: "completed",
|
||||
rawOutput: rawInput,
|
||||
}
|
||||
|
||||
if (rawContent) {
|
||||
completionUpdate.content = [
|
||||
{
|
||||
type: "content",
|
||||
content: { type: "text", text: rawContent },
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
return {
|
||||
initialUpdate: this.createInitialUpdate(toolCall),
|
||||
completionUpdate,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tool Handler Registry
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Registry that manages tool handlers and dispatches to the appropriate one.
|
||||
*
|
||||
* Handlers are checked in order - the first one that canHandle() returns true wins.
|
||||
* DefaultToolHandler should always be last as it accepts everything.
|
||||
*/
|
||||
export class ToolHandlerRegistry {
|
||||
private readonly handlers: ToolHandler[]
|
||||
|
||||
constructor(handlers?: ToolHandler[]) {
|
||||
// Default handler order - more specific handlers first
|
||||
this.handlers = handlers || [
|
||||
new CommandToolHandler(),
|
||||
new FileEditToolHandler(),
|
||||
new FileReadToolHandler(),
|
||||
new SearchToolHandler(),
|
||||
new ListFilesToolHandler(),
|
||||
new DefaultToolHandler(),
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the appropriate handler for the given context.
|
||||
*/
|
||||
getHandler(context: ToolHandlerContext): ToolHandler {
|
||||
for (const handler of this.handlers) {
|
||||
if (handler.canHandle(context)) {
|
||||
return handler
|
||||
}
|
||||
}
|
||||
|
||||
// Should never happen if DefaultToolHandler is last
|
||||
throw new Error("No handler found for tool - DefaultToolHandler should always match")
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a tool call by finding the appropriate handler and dispatching.
|
||||
*/
|
||||
handle(context: ToolHandlerContext): ToolHandleResult {
|
||||
const handler = this.getHandler(context)
|
||||
return handler.handle(context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a context object from message and ask.
|
||||
*/
|
||||
static createContext(
|
||||
message: ClineMessage,
|
||||
ask: ClineAsk,
|
||||
workspacePath: string,
|
||||
logger: IAcpLogger,
|
||||
): ToolHandlerContext {
|
||||
return {
|
||||
message,
|
||||
ask,
|
||||
workspacePath,
|
||||
toolInfo: parseToolFromMessage(message, workspacePath),
|
||||
logger,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Exports
|
||||
// =============================================================================
|
||||
|
||||
export { BaseToolHandler }
|
||||
530
apps/cli/src/acp/tool-registry.ts
Normal file
530
apps/cli/src/acp/tool-registry.ts
Normal file
|
|
@ -0,0 +1,530 @@
|
|||
/**
|
||||
* Tool Registry
|
||||
*
|
||||
* Centralized registry for tool type definitions, categories, and validation schemas.
|
||||
* Provides type-safe tool identification and parameter validation.
|
||||
*
|
||||
* Uses exact matching with normalized tool names to avoid fragile substring matching.
|
||||
*/
|
||||
|
||||
import { z } from "zod"
|
||||
import type * as acp from "@agentclientprotocol/sdk"
|
||||
|
||||
// =============================================================================
|
||||
// Tool Category Registry Class
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Tool category names.
|
||||
*/
|
||||
export type ToolCategory =
|
||||
| "edit"
|
||||
| "read"
|
||||
| "search"
|
||||
| "list"
|
||||
| "execute"
|
||||
| "delete"
|
||||
| "move"
|
||||
| "think"
|
||||
| "fetch"
|
||||
| "switchMode"
|
||||
| "fileWrite"
|
||||
|
||||
/**
|
||||
* Registry for tool categories with automatic Set generation.
|
||||
*
|
||||
* This class ensures that TOOL_CATEGORIES and lookup Sets are always in sync
|
||||
* by generating Sets automatically from the category definitions.
|
||||
*/
|
||||
class ToolCategoryRegistry {
|
||||
private readonly categories: Map<ToolCategory, Set<string>> = new Map()
|
||||
private readonly toolDefinitions: Record<ToolCategory, readonly string[]>
|
||||
|
||||
constructor() {
|
||||
// Define tool categories with their associated tool names
|
||||
// All tool names are stored in normalized form (lowercase, no separators)
|
||||
this.toolDefinitions = {
|
||||
/** File edit operations (create, write, modify) */
|
||||
edit: [
|
||||
"newfilecreated",
|
||||
"editedexistingfile",
|
||||
"writetofile",
|
||||
"applydiff",
|
||||
"applieddiff",
|
||||
"createfile",
|
||||
"modifyfile",
|
||||
],
|
||||
|
||||
/** File read operations */
|
||||
read: ["readfile"],
|
||||
|
||||
/** File/codebase search operations */
|
||||
search: ["searchfiles", "codebasesearch", "grep", "ripgrep"],
|
||||
|
||||
/** Directory listing operations */
|
||||
list: ["listfiles", "listfilestoplevel", "listfilesrecursive"],
|
||||
|
||||
/** Command/shell execution */
|
||||
execute: ["executecommand", "runcommand"],
|
||||
|
||||
/** File deletion */
|
||||
delete: ["deletefile", "removefile"],
|
||||
|
||||
/** File move/rename */
|
||||
move: ["movefile", "renamefile"],
|
||||
|
||||
/** Reasoning/thinking operations */
|
||||
think: ["think", "reason", "plan", "analyze"],
|
||||
|
||||
/** External fetch/HTTP operations */
|
||||
fetch: ["fetch", "httpget", "httppost", "urlfetch", "webrequest"],
|
||||
|
||||
/** Mode switching operations */
|
||||
switchMode: ["switchmode", "setmode"],
|
||||
|
||||
/** File write operations (for streaming detection) */
|
||||
fileWrite: ["newfilecreated", "writetofile", "createfile", "editedexistingfile", "applydiff", "modifyfile"],
|
||||
}
|
||||
|
||||
// Build Sets automatically from definitions
|
||||
for (const [category, tools] of Object.entries(this.toolDefinitions)) {
|
||||
this.categories.set(category as ToolCategory, new Set(tools))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tool name belongs to a specific category.
|
||||
* Uses O(1) Set lookup.
|
||||
*/
|
||||
isInCategory(toolName: string, category: ToolCategory): boolean {
|
||||
const normalized = this.normalizeToolName(toolName)
|
||||
return this.categories.get(category)?.has(normalized) ?? false
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tools in a category.
|
||||
*/
|
||||
getToolsInCategory(category: ToolCategory): readonly string[] {
|
||||
return this.toolDefinitions[category]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all category names.
|
||||
*/
|
||||
getCategoryNames(): ToolCategory[] {
|
||||
return Object.keys(this.toolDefinitions) as ToolCategory[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a tool name for comparison.
|
||||
* Converts to lowercase and removes all separators (-, _).
|
||||
*/
|
||||
private normalizeToolName(name: string): string {
|
||||
return name.toLowerCase().replace(/[-_]/g, "")
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Singleton Registry Instance
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Global tool category registry instance.
|
||||
*/
|
||||
const toolCategoryRegistry = new ToolCategoryRegistry()
|
||||
|
||||
// =============================================================================
|
||||
// Legacy Exports for Backward Compatibility
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Tool categories with their associated tool names.
|
||||
* @deprecated Use toolCategoryRegistry methods instead
|
||||
*/
|
||||
export const TOOL_CATEGORIES = {
|
||||
edit: toolCategoryRegistry.getToolsInCategory("edit"),
|
||||
read: toolCategoryRegistry.getToolsInCategory("read"),
|
||||
search: toolCategoryRegistry.getToolsInCategory("search"),
|
||||
list: toolCategoryRegistry.getToolsInCategory("list"),
|
||||
execute: toolCategoryRegistry.getToolsInCategory("execute"),
|
||||
delete: toolCategoryRegistry.getToolsInCategory("delete"),
|
||||
move: toolCategoryRegistry.getToolsInCategory("move"),
|
||||
think: toolCategoryRegistry.getToolsInCategory("think"),
|
||||
fetch: toolCategoryRegistry.getToolsInCategory("fetch"),
|
||||
switchMode: toolCategoryRegistry.getToolsInCategory("switchMode"),
|
||||
fileWrite: toolCategoryRegistry.getToolsInCategory("fileWrite"),
|
||||
} as const
|
||||
|
||||
// =============================================================================
|
||||
// Type Definitions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* All known tool names (union of all categories)
|
||||
*/
|
||||
export type KnownToolName = (typeof TOOL_CATEGORIES)[ToolCategory][number]
|
||||
|
||||
// =============================================================================
|
||||
// Tool Category Detection Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Check if a tool name belongs to a specific category using exact matching.
|
||||
* Uses the centralized registry for O(1) lookup.
|
||||
*/
|
||||
export function isToolInCategory(toolName: string, category: ToolCategory): boolean {
|
||||
return toolCategoryRegistry.isInCategory(toolName, category)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if tool is an edit operation.
|
||||
*/
|
||||
export function isEditTool(toolName: string): boolean {
|
||||
return isToolInCategory(toolName, "edit")
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if tool is a read operation.
|
||||
*/
|
||||
export function isReadTool(toolName: string): boolean {
|
||||
return isToolInCategory(toolName, "read")
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if tool is a search operation.
|
||||
*/
|
||||
export function isSearchTool(toolName: string): boolean {
|
||||
return isToolInCategory(toolName, "search")
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if tool is a list files operation.
|
||||
*/
|
||||
export function isListFilesTool(toolName: string): boolean {
|
||||
return isToolInCategory(toolName, "list")
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if tool is a command execution operation.
|
||||
*/
|
||||
export function isExecuteTool(toolName: string): boolean {
|
||||
return isToolInCategory(toolName, "execute")
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if tool is a delete operation.
|
||||
*/
|
||||
export function isDeleteTool(toolName: string): boolean {
|
||||
return isToolInCategory(toolName, "delete")
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if tool is a move/rename operation.
|
||||
*/
|
||||
export function isMoveTool(toolName: string): boolean {
|
||||
return isToolInCategory(toolName, "move")
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if tool is a think/reasoning operation.
|
||||
*/
|
||||
export function isThinkTool(toolName: string): boolean {
|
||||
return isToolInCategory(toolName, "think")
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if tool is an external fetch operation.
|
||||
*/
|
||||
export function isFetchTool(toolName: string): boolean {
|
||||
return isToolInCategory(toolName, "fetch")
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if tool is a mode switching operation.
|
||||
*/
|
||||
export function isSwitchModeTool(toolName: string): boolean {
|
||||
return isToolInCategory(toolName, "switchMode")
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if tool is a file write operation (for streaming).
|
||||
*/
|
||||
export function isFileWriteTool(toolName: string): boolean {
|
||||
return isToolInCategory(toolName, "fileWrite")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tool Kind Mapping
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Map a tool name to an ACP ToolKind.
|
||||
*
|
||||
* ACP defines these tool kinds for special UI treatment:
|
||||
* - read: Reading files or data
|
||||
* - edit: Modifying files or content
|
||||
* - delete: Removing files or data
|
||||
* - move: Moving or renaming files
|
||||
* - search: Searching for information
|
||||
* - execute: Running commands or code
|
||||
* - think: Internal reasoning or planning
|
||||
* - fetch: Retrieving external data
|
||||
* - switch_mode: Switching the current session mode
|
||||
* - other: Other tool types (default)
|
||||
*
|
||||
* Uses exact category matching for reliability. Falls back to "other" for unknown tools.
|
||||
*/
|
||||
export function mapToolToKind(toolName: string): acp.ToolKind {
|
||||
// Check exact category matches in priority order
|
||||
// Order matters only for overlapping categories (like fileWrite and edit)
|
||||
if (isToolInCategory(toolName, "switchMode")) {
|
||||
return "switch_mode"
|
||||
}
|
||||
if (isToolInCategory(toolName, "think")) {
|
||||
return "think"
|
||||
}
|
||||
if (isToolInCategory(toolName, "search")) {
|
||||
return "search"
|
||||
}
|
||||
if (isToolInCategory(toolName, "delete")) {
|
||||
return "delete"
|
||||
}
|
||||
if (isToolInCategory(toolName, "move")) {
|
||||
return "move"
|
||||
}
|
||||
if (isToolInCategory(toolName, "edit")) {
|
||||
return "edit"
|
||||
}
|
||||
if (isToolInCategory(toolName, "fetch")) {
|
||||
return "fetch"
|
||||
}
|
||||
if (isToolInCategory(toolName, "read")) {
|
||||
return "read"
|
||||
}
|
||||
if (isToolInCategory(toolName, "list")) {
|
||||
return "read" // list operations are read-like
|
||||
}
|
||||
if (isToolInCategory(toolName, "execute")) {
|
||||
return "execute"
|
||||
}
|
||||
|
||||
// Default to other for unknown tools
|
||||
return "other"
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Zod Schemas for Tool Parameters
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Base schema for all tool parameters.
|
||||
*/
|
||||
const BaseToolParamsSchema = z.object({
|
||||
tool: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Schema for file path tools (read, delete, etc.)
|
||||
*/
|
||||
export const FilePathParamsSchema = BaseToolParamsSchema.extend({
|
||||
path: z.string(),
|
||||
content: z.string().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Schema for file write/create tools.
|
||||
*/
|
||||
export const FileWriteParamsSchema = BaseToolParamsSchema.extend({
|
||||
path: z.string(),
|
||||
content: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Schema for file move/rename tools.
|
||||
*/
|
||||
export const FileMoveParamsSchema = BaseToolParamsSchema.extend({
|
||||
path: z.string(),
|
||||
newPath: z.string().optional(),
|
||||
destination: z.string().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Schema for search tools.
|
||||
*/
|
||||
export const SearchParamsSchema = BaseToolParamsSchema.extend({
|
||||
path: z.string().optional(),
|
||||
regex: z.string().optional(),
|
||||
query: z.string().optional(),
|
||||
pattern: z.string().optional(),
|
||||
filePattern: z.string().optional(),
|
||||
content: z.string().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Schema for list files tools.
|
||||
*/
|
||||
export const ListFilesParamsSchema = BaseToolParamsSchema.extend({
|
||||
path: z.string(),
|
||||
recursive: z.boolean().optional(),
|
||||
content: z.string().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Schema for command execution tools.
|
||||
*/
|
||||
export const CommandParamsSchema = BaseToolParamsSchema.extend({
|
||||
command: z.string().optional(),
|
||||
cwd: z.string().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Schema for think/reasoning tools.
|
||||
*/
|
||||
export const ThinkParamsSchema = BaseToolParamsSchema.extend({
|
||||
thought: z.string().optional(),
|
||||
reasoning: z.string().optional(),
|
||||
analysis: z.string().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Schema for mode switching tools.
|
||||
*/
|
||||
export const SwitchModeParamsSchema = BaseToolParamsSchema.extend({
|
||||
mode: z.string().optional(),
|
||||
modeId: z.string().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Generic tool params schema (for unknown tools).
|
||||
*/
|
||||
export const GenericToolParamsSchema = BaseToolParamsSchema.passthrough()
|
||||
|
||||
// =============================================================================
|
||||
// Parameter Types
|
||||
// =============================================================================
|
||||
|
||||
export type FilePathParams = z.infer<typeof FilePathParamsSchema>
|
||||
export type FileWriteParams = z.infer<typeof FileWriteParamsSchema>
|
||||
export type FileMoveParams = z.infer<typeof FileMoveParamsSchema>
|
||||
export type SearchParams = z.infer<typeof SearchParamsSchema>
|
||||
export type ListFilesParams = z.infer<typeof ListFilesParamsSchema>
|
||||
export type CommandParams = z.infer<typeof CommandParamsSchema>
|
||||
export type ThinkParams = z.infer<typeof ThinkParamsSchema>
|
||||
export type SwitchModeParams = z.infer<typeof SwitchModeParamsSchema>
|
||||
export type GenericToolParams = z.infer<typeof GenericToolParamsSchema>
|
||||
|
||||
/**
|
||||
* Union of all tool parameter types.
|
||||
*/
|
||||
export type ToolParams =
|
||||
| FilePathParams
|
||||
| FileWriteParams
|
||||
| FileMoveParams
|
||||
| SearchParams
|
||||
| ListFilesParams
|
||||
| CommandParams
|
||||
| ThinkParams
|
||||
| SwitchModeParams
|
||||
| GenericToolParams
|
||||
|
||||
// =============================================================================
|
||||
// Parameter Validation
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Result of parameter validation.
|
||||
*/
|
||||
export type ValidationResult<T> = { success: true; data: T } | { success: false; error: z.ZodError }
|
||||
|
||||
/**
|
||||
* Validate tool parameters against the appropriate schema.
|
||||
*
|
||||
* @param toolName - Name of the tool
|
||||
* @param params - Raw parameters to validate
|
||||
* @returns Validation result with typed params or error
|
||||
*/
|
||||
export function validateToolParams(toolName: string, params: unknown): ValidationResult<ToolParams> {
|
||||
// Select schema based on tool category
|
||||
let schema: z.ZodSchema
|
||||
|
||||
if (isEditTool(toolName)) {
|
||||
schema = FileWriteParamsSchema
|
||||
} else if (isReadTool(toolName)) {
|
||||
schema = FilePathParamsSchema
|
||||
} else if (isSearchTool(toolName)) {
|
||||
schema = SearchParamsSchema
|
||||
} else if (isListFilesTool(toolName)) {
|
||||
schema = ListFilesParamsSchema
|
||||
} else if (isExecuteTool(toolName)) {
|
||||
schema = CommandParamsSchema
|
||||
} else if (isDeleteTool(toolName)) {
|
||||
schema = FilePathParamsSchema
|
||||
} else if (isMoveTool(toolName)) {
|
||||
schema = FileMoveParamsSchema
|
||||
} else if (isThinkTool(toolName)) {
|
||||
schema = ThinkParamsSchema
|
||||
} else if (isSwitchModeTool(toolName)) {
|
||||
schema = SwitchModeParamsSchema
|
||||
} else {
|
||||
// Use generic schema for unknown tools
|
||||
schema = GenericToolParamsSchema
|
||||
}
|
||||
|
||||
const result = schema.safeParse(params)
|
||||
|
||||
if (result.success) {
|
||||
return { success: true, data: result.data as ToolParams }
|
||||
}
|
||||
|
||||
return { success: false, error: result.error }
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate tool parameters, returning undefined on failure.
|
||||
* Use when validation failure should be handled gracefully.
|
||||
*
|
||||
* @param toolName - Name of the tool
|
||||
* @param params - Raw parameters to validate
|
||||
* @returns Validated params or undefined
|
||||
*/
|
||||
export function parseToolParams(toolName: string, params: unknown): ToolParams | undefined {
|
||||
const result = validateToolParams(toolName, params)
|
||||
return result.success ? result.data : undefined
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tool Message Parsing
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Schema for parsing tool JSON from message text.
|
||||
*/
|
||||
export const ToolMessageSchema = z
|
||||
.object({
|
||||
tool: z.string(),
|
||||
path: z.string().optional(),
|
||||
content: z.string().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
export type ToolMessage = z.infer<typeof ToolMessageSchema>
|
||||
|
||||
/**
|
||||
* Parse tool information from a JSON message.
|
||||
*
|
||||
* @param text - JSON text to parse
|
||||
* @returns Parsed tool message or undefined if invalid
|
||||
*/
|
||||
export function parseToolMessage(text: string): ToolMessage | undefined {
|
||||
if (!text.startsWith("{")) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(text)
|
||||
const result = ToolMessageSchema.safeParse(parsed)
|
||||
return result.success ? result.data : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
56
apps/cli/src/acp/translator.ts
Normal file
56
apps/cli/src/acp/translator.ts
Normal 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"
|
||||
106
apps/cli/src/acp/translator/diff-parser.ts
Normal file
106
apps/cli/src/acp/translator/diff-parser.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
/**
|
||||
* Diff Parser
|
||||
*
|
||||
* Parses unified diff format to extract old and new text.
|
||||
* Used for displaying file changes in ACP tool calls.
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Result of parsing a unified diff.
|
||||
*/
|
||||
export interface ParsedDiff {
|
||||
/** Original text (null for new files) */
|
||||
oldText: string | null
|
||||
/** New text content */
|
||||
newText: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Diff Parsing
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Parse a unified diff string to extract old and new text.
|
||||
*
|
||||
* Handles standard unified diff format:
|
||||
* ```
|
||||
* --- a/file.txt
|
||||
* +++ b/file.txt
|
||||
* @@ -1,3 +1,4 @@
|
||||
* context line
|
||||
* -removed line
|
||||
* +added line
|
||||
* more context
|
||||
* ```
|
||||
*
|
||||
* For non-diff content (raw file content), returns { oldText: null, newText: content }.
|
||||
*
|
||||
* @param diffString - The diff string to parse
|
||||
* @returns Parsed diff with old and new text, or null if invalid
|
||||
*/
|
||||
export function parseUnifiedDiff(diffString: string): ParsedDiff | null {
|
||||
if (!diffString) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Check if this is a unified diff format
|
||||
if (!diffString.includes("@@") && !diffString.includes("---") && !diffString.includes("+++")) {
|
||||
// Not a diff, treat as raw content
|
||||
return { oldText: null, newText: diffString }
|
||||
}
|
||||
|
||||
const lines = diffString.split("\n")
|
||||
const oldLines: string[] = []
|
||||
const newLines: string[] = []
|
||||
let inHunk = false
|
||||
let isNewFile = false
|
||||
|
||||
for (const line of lines) {
|
||||
// Check for new file indicator
|
||||
if (line.startsWith("--- /dev/null")) {
|
||||
isNewFile = true
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip diff headers
|
||||
if (line.startsWith("===") || line.startsWith("---") || line.startsWith("+++") || line.startsWith("@@")) {
|
||||
if (line.startsWith("@@")) {
|
||||
inHunk = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (!inHunk) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (line.startsWith("-")) {
|
||||
// Removed line (old content)
|
||||
oldLines.push(line.slice(1))
|
||||
} else if (line.startsWith("+")) {
|
||||
// Added line (new content)
|
||||
newLines.push(line.slice(1))
|
||||
} else if (line.startsWith(" ") || line === "") {
|
||||
// Context line (in both old and new)
|
||||
const contextLine = line.startsWith(" ") ? line.slice(1) : line
|
||||
oldLines.push(contextLine)
|
||||
newLines.push(contextLine)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
oldText: isNewFile ? null : oldLines.join("\n") || null,
|
||||
newText: newLines.join("\n"),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string appears to be a unified diff.
|
||||
*/
|
||||
export function isUnifiedDiff(content: string): boolean {
|
||||
return content.includes("@@") || (content.includes("---") && content.includes("+++"))
|
||||
}
|
||||
59
apps/cli/src/acp/translator/index.ts
Normal file
59
apps/cli/src/acp/translator/index.ts
Normal 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"
|
||||
136
apps/cli/src/acp/translator/location-extractor.ts
Normal file
136
apps/cli/src/acp/translator/location-extractor.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
/**
|
||||
* Location Extractor
|
||||
*
|
||||
* Extracts file locations from tool parameters for ACP tool calls.
|
||||
* Handles various parameter formats and tool-specific behaviors.
|
||||
*/
|
||||
|
||||
import type * as acp from "@agentclientprotocol/sdk"
|
||||
|
||||
import { isSearchTool, isListFilesTool } from "../tool-registry.js"
|
||||
import { resolveFilePathUnsafe } from "../utils/index.js"
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Parameters that may contain file locations.
|
||||
*/
|
||||
export interface LocationParams {
|
||||
tool?: string
|
||||
path?: string
|
||||
file?: string
|
||||
filePath?: string
|
||||
file_path?: string
|
||||
directory?: string
|
||||
dir?: string
|
||||
paths?: string[]
|
||||
content?: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Location Extraction
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Extract file locations from tool parameters.
|
||||
*
|
||||
* Handles different tool types:
|
||||
* - Search tools: Extract file paths from search results
|
||||
* - List files: Include the directory being listed
|
||||
* - File operations: Extract path from standard parameters
|
||||
*
|
||||
* @param params - Tool parameters
|
||||
* @param workspacePath - Optional workspace path to resolve relative paths
|
||||
* @returns Array of tool call locations
|
||||
*/
|
||||
export function extractLocations(params: Record<string, unknown>, workspacePath?: string): acp.ToolCallLocation[] {
|
||||
const locations: acp.ToolCallLocation[] = []
|
||||
const toolName = (params.tool as string | undefined)?.toLowerCase() || ""
|
||||
|
||||
// For search tools, the 'path' parameter is a search scope directory, not a file being accessed.
|
||||
// Don't include it in locations. Instead, try to extract file paths from search results.
|
||||
if (isSearchTool(toolName)) {
|
||||
// Try to extract file paths from search results content
|
||||
const content = params.content as string | undefined
|
||||
if (content) {
|
||||
return extractFilePathsFromSearchResults(content, workspacePath)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
// For list_files tools, the 'path' is a directory being listed, which is valid to include
|
||||
// but we should mark it as a directory operation rather than a file access
|
||||
if (isListFilesTool(toolName)) {
|
||||
const dirPath = params.path as string | undefined
|
||||
if (dirPath) {
|
||||
const absolutePath = resolveFilePathUnsafe(dirPath, workspacePath)
|
||||
locations.push({ path: absolutePath })
|
||||
}
|
||||
return locations
|
||||
}
|
||||
|
||||
// Check for common path parameters (for file operations)
|
||||
const pathParams = ["path", "file", "filePath", "file_path"]
|
||||
for (const param of pathParams) {
|
||||
if (typeof params[param] === "string") {
|
||||
const filePath = params[param] as string
|
||||
const absolutePath = resolveFilePathUnsafe(filePath, workspacePath)
|
||||
locations.push({ path: absolutePath })
|
||||
}
|
||||
}
|
||||
|
||||
// Check for directory parameters separately (for directory operations)
|
||||
const dirParams = ["directory", "dir"]
|
||||
for (const param of dirParams) {
|
||||
if (typeof params[param] === "string") {
|
||||
const dirPath = params[param] as string
|
||||
const absolutePath = resolveFilePathUnsafe(dirPath, workspacePath)
|
||||
locations.push({ path: absolutePath })
|
||||
}
|
||||
}
|
||||
|
||||
// Check for paths array
|
||||
if (Array.isArray(params.paths)) {
|
||||
for (const p of params.paths) {
|
||||
if (typeof p === "string") {
|
||||
const absolutePath = resolveFilePathUnsafe(p, workspacePath)
|
||||
locations.push({ path: absolutePath })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return locations
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract file paths from search results content.
|
||||
*
|
||||
* Search results typically have format: "# path/to/file.ts" for each matched file.
|
||||
*
|
||||
* @param content - Search results content
|
||||
* @param workspacePath - Optional workspace path
|
||||
* @returns Array of locations from search results
|
||||
*/
|
||||
export function extractFilePathsFromSearchResults(content: string, workspacePath?: string): acp.ToolCallLocation[] {
|
||||
const locations: acp.ToolCallLocation[] = []
|
||||
const seenPaths = new Set<string>()
|
||||
|
||||
// Match file headers in search results (e.g., "# src/utils.ts" or "## path/to/file.js")
|
||||
const fileHeaderPattern = /^#+\s+(.+?\.[a-zA-Z0-9]+)\s*$/gm
|
||||
let match
|
||||
|
||||
while ((match = fileHeaderPattern.exec(content)) !== null) {
|
||||
const filePath = match[1]!.trim()
|
||||
// Skip if we've already seen this path or if it looks like a markdown header (not a file path)
|
||||
if (seenPaths.has(filePath) || (!filePath.includes("/") && !filePath.includes("."))) {
|
||||
continue
|
||||
}
|
||||
seenPaths.add(filePath)
|
||||
const absolutePath = resolveFilePathUnsafe(filePath, workspacePath)
|
||||
locations.push({ path: absolutePath })
|
||||
}
|
||||
|
||||
return locations
|
||||
}
|
||||
179
apps/cli/src/acp/translator/message-translator.ts
Normal file
179
apps/cli/src/acp/translator/message-translator.ts
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
/**
|
||||
* Message Translator
|
||||
*
|
||||
* Translates between internal ClineMessage format and ACP protocol format.
|
||||
* This is the main bridge between Roo Code's message system and the ACP protocol.
|
||||
*/
|
||||
|
||||
import type * as acp from "@agentclientprotocol/sdk"
|
||||
import type { ClineMessage, ClineAsk } from "@roo-code/types"
|
||||
|
||||
import { mapToolToKind } from "../tool-registry.js"
|
||||
import { parseToolFromMessage } from "./tool-parser.js"
|
||||
|
||||
// =============================================================================
|
||||
// Message to ACP Update Translation
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Translate an internal ClineMessage to an ACP session update.
|
||||
* Returns null if the message type should not be sent to ACP.
|
||||
*
|
||||
* @param message - Internal ClineMessage
|
||||
* @returns ACP session update or null
|
||||
*/
|
||||
export function translateToAcpUpdate(message: ClineMessage): acp.SessionNotification["update"] | null {
|
||||
if (message.type === "say") {
|
||||
switch (message.say) {
|
||||
case "text":
|
||||
// Agent text output
|
||||
return {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: message.text || "" },
|
||||
}
|
||||
|
||||
case "reasoning":
|
||||
// Agent reasoning/thinking
|
||||
return {
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
content: { type: "text", text: message.text || "" },
|
||||
}
|
||||
|
||||
case "shell_integration_warning":
|
||||
case "mcp_server_request_started":
|
||||
case "mcp_server_response":
|
||||
// Tool-related messages
|
||||
return translateToolSayMessage(message)
|
||||
|
||||
case "user_feedback":
|
||||
// User feedback doesn't need to be sent to ACP client
|
||||
return null
|
||||
|
||||
case "error":
|
||||
// Error messages
|
||||
return {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: `Error: ${message.text || ""}` },
|
||||
}
|
||||
|
||||
case "completion_result":
|
||||
// Completion is handled at prompt level
|
||||
return null
|
||||
|
||||
case "api_req_started":
|
||||
case "api_req_finished":
|
||||
case "api_req_retried":
|
||||
case "api_req_retry_delayed":
|
||||
case "api_req_deleted":
|
||||
// API request lifecycle events - not sent to ACP
|
||||
return null
|
||||
|
||||
case "command_output":
|
||||
// Command execution - handled through tool_call
|
||||
return null
|
||||
|
||||
default:
|
||||
// Unknown message type
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Ask messages are handled separately through permission flow
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a tool say message to ACP format.
|
||||
*
|
||||
* @param message - Tool-related ClineMessage
|
||||
* @returns ACP session update or null
|
||||
*/
|
||||
function translateToolSayMessage(message: ClineMessage): acp.SessionNotification["update"] | null {
|
||||
const toolInfo = parseToolFromMessage(message)
|
||||
if (!toolInfo) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (message.partial) {
|
||||
// Tool in progress
|
||||
return {
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: toolInfo.id,
|
||||
title: toolInfo.title,
|
||||
kind: mapToolToKind(toolInfo.name),
|
||||
status: "in_progress" as const,
|
||||
locations: toolInfo.locations,
|
||||
rawInput: toolInfo.params,
|
||||
}
|
||||
} else {
|
||||
// Tool completed
|
||||
return {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: toolInfo.id,
|
||||
status: "completed" as const,
|
||||
content: [],
|
||||
rawOutput: toolInfo.params,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Ask Type Helpers
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Ask types that require permission from the user.
|
||||
*/
|
||||
const PERMISSION_ASKS: readonly ClineAsk[] = ["tool", "command", "browser_action_launch", "use_mcp_server"]
|
||||
|
||||
/**
|
||||
* Check if an ask type requires permission.
|
||||
*
|
||||
* @param ask - The ask type to check
|
||||
* @returns true if permission is required
|
||||
*/
|
||||
export function isPermissionAsk(ask: ClineAsk): boolean {
|
||||
return PERMISSION_ASKS.includes(ask)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask types that indicate task completion.
|
||||
*/
|
||||
const COMPLETION_ASKS: readonly ClineAsk[] = ["completion_result", "api_req_failed", "mistake_limit_reached"]
|
||||
|
||||
/**
|
||||
* Check if an ask type indicates task completion.
|
||||
*
|
||||
* @param ask - The ask type to check
|
||||
* @returns true if this indicates completion
|
||||
*/
|
||||
export function isCompletionAsk(ask: ClineAsk): boolean {
|
||||
return COMPLETION_ASKS.includes(ask)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Permission Options
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Create standard permission options for a tool call.
|
||||
*
|
||||
* Returns options like "Allow", "Reject", and optionally "Always Allow"
|
||||
* for certain tool types.
|
||||
*
|
||||
* @param ask - The ask type
|
||||
* @returns Array of permission options
|
||||
*/
|
||||
export function createPermissionOptions(ask: ClineAsk): acp.PermissionOption[] {
|
||||
const baseOptions: acp.PermissionOption[] = [
|
||||
{ optionId: "allow", name: "Allow", kind: "allow_once" },
|
||||
{ optionId: "reject", name: "Reject", kind: "reject_once" },
|
||||
]
|
||||
|
||||
// Add "allow always" option for certain ask types
|
||||
if (ask === "tool" || ask === "command") {
|
||||
return [{ optionId: "allow_always", name: "Always Allow", kind: "allow_always" }, ...baseOptions]
|
||||
}
|
||||
|
||||
return baseOptions
|
||||
}
|
||||
260
apps/cli/src/acp/translator/plan-translator.ts
Normal file
260
apps/cli/src/acp/translator/plan-translator.ts
Normal 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)
|
||||
}
|
||||
101
apps/cli/src/acp/translator/prompt-extractor.ts
Normal file
101
apps/cli/src/acp/translator/prompt-extractor.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
/**
|
||||
* Prompt Extractor
|
||||
*
|
||||
* Extracts text and images from ACP prompt content blocks.
|
||||
* Handles various content block types including text, resources, and media.
|
||||
*/
|
||||
|
||||
import type * as acp from "@agentclientprotocol/sdk"
|
||||
|
||||
// =============================================================================
|
||||
// Text Extraction
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Extract text content from ACP prompt content blocks.
|
||||
*
|
||||
* Handles these content block types:
|
||||
* - text: Direct text content
|
||||
* - resource_link: Reference to a file or resource (converted to @uri format)
|
||||
* - resource: Embedded resource with text content
|
||||
* - image/audio: Noted as placeholders
|
||||
*
|
||||
* @param prompt - Array of ACP content blocks
|
||||
* @returns Combined text from all blocks
|
||||
*/
|
||||
export function extractPromptText(prompt: acp.ContentBlock[]): string {
|
||||
const textParts: string[] = []
|
||||
|
||||
for (const block of prompt) {
|
||||
switch (block.type) {
|
||||
case "text":
|
||||
textParts.push(block.text)
|
||||
break
|
||||
case "resource_link":
|
||||
// Reference to a file or resource
|
||||
textParts.push(`@${block.uri}`)
|
||||
break
|
||||
case "resource":
|
||||
// Embedded resource content
|
||||
if (block.resource && "text" in block.resource) {
|
||||
textParts.push(`Content from ${block.resource.uri}:\n${block.resource.text}`)
|
||||
}
|
||||
break
|
||||
case "image":
|
||||
case "audio":
|
||||
// Binary content - note it but don't include
|
||||
textParts.push(`[${block.type} content]`)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return textParts.join("\n")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Image Extraction
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Extract images from ACP prompt content blocks.
|
||||
*
|
||||
* Extracts base64-encoded image data from image content blocks.
|
||||
*
|
||||
* @param prompt - Array of ACP content blocks
|
||||
* @returns Array of base64-encoded image data strings
|
||||
*/
|
||||
export function extractPromptImages(prompt: acp.ContentBlock[]): string[] {
|
||||
const images: string[] = []
|
||||
|
||||
for (const block of prompt) {
|
||||
if (block.type === "image" && block.data) {
|
||||
images.push(block.data)
|
||||
}
|
||||
}
|
||||
|
||||
return images
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Resource Extraction
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Extract resource URIs from ACP prompt content blocks.
|
||||
*
|
||||
* @param prompt - Array of ACP content blocks
|
||||
* @returns Array of resource URIs
|
||||
*/
|
||||
export function extractPromptResources(prompt: acp.ContentBlock[]): string[] {
|
||||
const resources: string[] = []
|
||||
|
||||
for (const block of prompt) {
|
||||
if (block.type === "resource_link") {
|
||||
resources.push(block.uri)
|
||||
} else if (block.type === "resource" && block.resource) {
|
||||
resources.push(block.resource.uri)
|
||||
}
|
||||
}
|
||||
|
||||
return resources
|
||||
}
|
||||
241
apps/cli/src/acp/translator/tool-parser.ts
Normal file
241
apps/cli/src/acp/translator/tool-parser.ts
Normal 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
42
apps/cli/src/acp/types.ts
Normal 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",
|
||||
},
|
||||
]
|
||||
379
apps/cli/src/acp/utils/format-utils.ts
Normal file
379
apps/cli/src/acp/utils/format-utils.ts
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
/**
|
||||
* Format Utilities
|
||||
*
|
||||
* Shared formatting and content extraction utilities for ACP.
|
||||
* Extracted to eliminate code duplication across modules.
|
||||
*/
|
||||
|
||||
import * as fs from "node:fs"
|
||||
import * as fsPromises from "node:fs/promises"
|
||||
import * as path from "node:path"
|
||||
|
||||
// =============================================================================
|
||||
// Configuration
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Default configuration for content formatting.
|
||||
*/
|
||||
export interface FormatConfig {
|
||||
/** Maximum number of lines to show for read results */
|
||||
maxReadLines: number
|
||||
}
|
||||
|
||||
export const DEFAULT_FORMAT_CONFIG: FormatConfig = {
|
||||
maxReadLines: 100,
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Result Type for Error Handling
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Result type for operations that can fail.
|
||||
* Provides explicit success/failure indication instead of returning error strings.
|
||||
*/
|
||||
export type Result<T> = { ok: true; value: T } | { ok: false; error: string }
|
||||
|
||||
/**
|
||||
* Create a successful result.
|
||||
*/
|
||||
export function ok<T>(value: T): Result<T> {
|
||||
return { ok: true, value }
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a failed result.
|
||||
*/
|
||||
export function err<T>(error: string): Result<T> {
|
||||
return { ok: false, error }
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Search Result Formatting
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Format search results into a clean summary with file list.
|
||||
*
|
||||
* Input format (verbose):
|
||||
* ```
|
||||
* Found 112 results.
|
||||
*
|
||||
* # src/acp/__tests__/agent.test.ts
|
||||
* 9 |
|
||||
* 10 | // Mock the auth module
|
||||
* ...
|
||||
*
|
||||
* # README.md
|
||||
* 105 |
|
||||
* ...
|
||||
* ```
|
||||
*
|
||||
* Output format (clean):
|
||||
* ```
|
||||
* Found 112 results in 20 files
|
||||
*
|
||||
* - src/acp/__tests__/agent.test.ts
|
||||
* - README.md
|
||||
* ...
|
||||
* ```
|
||||
*/
|
||||
export function formatSearchResults(content: string): string {
|
||||
// Extract count from "Found X results" line
|
||||
const countMatch = content.match(/Found (\d+) results?/)
|
||||
const resultCount = countMatch?.[1] ? parseInt(countMatch[1], 10) : null
|
||||
|
||||
// Extract unique file paths from "# path/to/file" lines
|
||||
const filePattern = /^# (.+)$/gm
|
||||
const files = new Set<string>()
|
||||
let match
|
||||
while ((match = filePattern.exec(content)) !== null) {
|
||||
if (match[1]) {
|
||||
files.add(match[1])
|
||||
}
|
||||
}
|
||||
|
||||
// Sort files alphabetically
|
||||
const fileList = Array.from(files).sort((a, b) => a.localeCompare(b))
|
||||
|
||||
// Build the formatted output
|
||||
if (fileList.length === 0) {
|
||||
// No files found, return first line (might be "No results found" or similar)
|
||||
return content.split("\n")[0] || content
|
||||
}
|
||||
|
||||
const summary =
|
||||
resultCount !== null
|
||||
? `Found ${resultCount} result${resultCount !== 1 ? "s" : ""} in ${fileList.length} file${fileList.length !== 1 ? "s" : ""}`
|
||||
: `Found matches in ${fileList.length} file${fileList.length !== 1 ? "s" : ""}`
|
||||
|
||||
// Use markdown list format
|
||||
const formattedFiles = fileList.map((f) => `- ${f}`).join("\n")
|
||||
|
||||
return `${summary}\n\n${formattedFiles}`
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Read Content Formatting
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Format read results by truncating long file contents.
|
||||
*
|
||||
* @param content - The raw file content
|
||||
* @param config - Optional configuration overrides
|
||||
* @returns Truncated content with indicator if truncated
|
||||
*/
|
||||
export function formatReadContent(content: string, config: FormatConfig = DEFAULT_FORMAT_CONFIG): string {
|
||||
const lines = content.split("\n")
|
||||
|
||||
if (lines.length <= config.maxReadLines) {
|
||||
return content
|
||||
}
|
||||
|
||||
// Truncate and add indicator
|
||||
const truncated = lines.slice(0, config.maxReadLines).join("\n")
|
||||
const remaining = lines.length - config.maxReadLines
|
||||
return `${truncated}\n\n... (${remaining} more lines)`
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Code Block Wrapping
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Wrap content in markdown code block for better rendering.
|
||||
*
|
||||
* @param content - Content to wrap
|
||||
* @param language - Optional language for syntax highlighting
|
||||
* @returns Content wrapped in markdown code fences
|
||||
*/
|
||||
export function wrapInCodeBlock(content: string, language?: string): string {
|
||||
const fence = language ? `\`\`\`${language}` : "```"
|
||||
return `${fence}\n${content}\n\`\`\``
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Content Extraction from Raw Input
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Common field names to check when extracting content from tool parameters.
|
||||
*/
|
||||
const CONTENT_FIELDS = ["content", "text", "result", "output", "fileContent", "data"] as const
|
||||
|
||||
/**
|
||||
* Extract content from raw input parameters.
|
||||
*
|
||||
* Tries common field names for content. Returns the first non-empty string found.
|
||||
*
|
||||
* @param rawInput - Tool parameters object
|
||||
* @returns Extracted content or undefined if not found
|
||||
*/
|
||||
export function extractContentFromParams(rawInput: Record<string, unknown>): string | undefined {
|
||||
for (const field of CONTENT_FIELDS) {
|
||||
const value = rawInput[field]
|
||||
if (typeof value === "string" && value.length > 0) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// File Reading
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Resolve a file path to absolute, using workspace path if relative.
|
||||
* Includes path traversal protection when workspace path is provided.
|
||||
*
|
||||
* @param filePath - File path (may be relative or absolute)
|
||||
* @param workspacePath - Workspace path for resolving relative paths
|
||||
* @returns Result with absolute path, or error if path traversal detected
|
||||
*/
|
||||
export function resolveFilePath(filePath: string, workspacePath?: string): Result<string> {
|
||||
// Normalize the path to resolve any . or .. segments
|
||||
const normalizedPath = path.normalize(filePath)
|
||||
|
||||
if (path.isAbsolute(normalizedPath)) {
|
||||
// For absolute paths with workspace, verify it's within workspace
|
||||
if (workspacePath) {
|
||||
const normalizedWorkspace = path.normalize(workspacePath)
|
||||
if (!normalizedPath.startsWith(normalizedWorkspace + path.sep) && normalizedPath !== normalizedWorkspace) {
|
||||
return err(`Path traversal detected: ${filePath} is outside workspace ${workspacePath}`)
|
||||
}
|
||||
}
|
||||
return ok(normalizedPath)
|
||||
}
|
||||
|
||||
if (workspacePath) {
|
||||
const resolved = path.resolve(workspacePath, normalizedPath)
|
||||
const normalizedWorkspace = path.normalize(workspacePath)
|
||||
|
||||
// Verify resolved path is within workspace (prevents ../../../etc/passwd attacks)
|
||||
if (!resolved.startsWith(normalizedWorkspace + path.sep) && resolved !== normalizedWorkspace) {
|
||||
return err(`Path traversal detected: ${filePath} resolves outside workspace ${workspacePath}`)
|
||||
}
|
||||
|
||||
return ok(resolved)
|
||||
}
|
||||
|
||||
// Return as-is if no workspace path available
|
||||
return ok(normalizedPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a file path to absolute (legacy version without Result wrapper).
|
||||
*
|
||||
* @deprecated Use resolveFilePath() with Result type for better error handling
|
||||
* @param filePath - File path (may be relative or absolute)
|
||||
* @param workspacePath - Workspace path for resolving relative paths
|
||||
* @returns Absolute path (returns original path on error)
|
||||
*/
|
||||
export function resolveFilePathUnsafe(filePath: string, workspacePath?: string): string {
|
||||
const result = resolveFilePath(filePath, workspacePath)
|
||||
return result.ok ? result.value : filePath
|
||||
}
|
||||
|
||||
/**
|
||||
* Read file content from the filesystem (synchronous version).
|
||||
*
|
||||
* For readFile tools, the rawInput.content field contains the file PATH
|
||||
* (not the contents), so we need to read the actual file.
|
||||
*
|
||||
* @deprecated Use readFileContentAsync() for non-blocking I/O
|
||||
* @param rawInput - Tool parameters (must contain path or content with file path)
|
||||
* @param workspacePath - Workspace path for resolving relative paths
|
||||
* @returns Result with file content or error message
|
||||
*/
|
||||
export function readFileContent(rawInput: Record<string, unknown>, workspacePath: string): Result<string> {
|
||||
// The "content" field in readFile contains the absolute path
|
||||
const filePath = rawInput.content as string | undefined
|
||||
const relativePath = rawInput.path as string | undefined
|
||||
|
||||
// Try absolute path first, then relative path
|
||||
let pathToRead: string | undefined
|
||||
if (filePath) {
|
||||
const resolved = resolveFilePath(filePath, workspacePath)
|
||||
if (!resolved.ok) return resolved
|
||||
pathToRead = resolved.value
|
||||
} else if (relativePath) {
|
||||
const resolved = resolveFilePath(relativePath, workspacePath)
|
||||
if (!resolved.ok) return resolved
|
||||
pathToRead = resolved.value
|
||||
}
|
||||
|
||||
if (!pathToRead) {
|
||||
return err("readFile tool has no path")
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(pathToRead, "utf-8")
|
||||
return ok(content)
|
||||
} catch (error) {
|
||||
return err(`Failed to read file ${pathToRead}: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read file content from the filesystem (asynchronous version).
|
||||
*
|
||||
* For readFile tools, the rawInput.content field contains the file PATH
|
||||
* (not the contents), so we need to read the actual file.
|
||||
*
|
||||
* @param rawInput - Tool parameters (must contain path or content with file path)
|
||||
* @param workspacePath - Workspace path for resolving relative paths
|
||||
* @returns Promise resolving to Result with file content or error message
|
||||
*/
|
||||
export async function readFileContentAsync(
|
||||
rawInput: Record<string, unknown>,
|
||||
workspacePath: string,
|
||||
): Promise<Result<string>> {
|
||||
// The "content" field in readFile contains the absolute path
|
||||
const filePath = rawInput.content as string | undefined
|
||||
const relativePath = rawInput.path as string | undefined
|
||||
|
||||
// Try absolute path first, then relative path
|
||||
let pathToRead: string | undefined
|
||||
if (filePath) {
|
||||
const resolved = resolveFilePath(filePath, workspacePath)
|
||||
if (!resolved.ok) return resolved
|
||||
pathToRead = resolved.value
|
||||
} else if (relativePath) {
|
||||
const resolved = resolveFilePath(relativePath, workspacePath)
|
||||
if (!resolved.ok) return resolved
|
||||
pathToRead = resolved.value
|
||||
}
|
||||
|
||||
if (!pathToRead) {
|
||||
return err("readFile tool has no path")
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await fsPromises.readFile(pathToRead, "utf-8")
|
||||
return ok(content)
|
||||
} catch (error) {
|
||||
return err(`Failed to read file ${pathToRead}: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// User Echo Detection
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Check if a text message is an echo of the user's prompt.
|
||||
*
|
||||
* When the extension starts processing a task, it often sends a `text`
|
||||
* message containing the user's input. Since the ACP client already
|
||||
* displays the user's message, we should filter this out.
|
||||
*
|
||||
* Uses fuzzy matching to handle minor differences (whitespace, etc.).
|
||||
*
|
||||
* @param text - The text to check
|
||||
* @param promptText - The original prompt text to compare against
|
||||
* @returns true if the text appears to be an echo of the prompt
|
||||
*/
|
||||
export function isUserEcho(text: string, promptText: string | null): boolean {
|
||||
if (!promptText) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Normalize both strings for comparison
|
||||
const normalizedPrompt = promptText.trim().toLowerCase()
|
||||
const normalizedText = text.trim().toLowerCase()
|
||||
|
||||
// Exact match
|
||||
if (normalizedText === normalizedPrompt) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if text is contained in prompt (might be truncated)
|
||||
if (normalizedPrompt.includes(normalizedText) && normalizedText.length > 10) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if prompt is contained in text (might have wrapper)
|
||||
if (normalizedText.includes(normalizedPrompt) && normalizedPrompt.length > 10) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Validation Helpers
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Check if a path looks like a valid file path (has extension).
|
||||
*
|
||||
* @param filePath - Path to check
|
||||
* @returns true if the path has a file extension
|
||||
*/
|
||||
export function hasValidFilePath(filePath: string): boolean {
|
||||
return /\.[a-zA-Z0-9]+$/.test(filePath)
|
||||
}
|
||||
29
apps/cli/src/acp/utils/index.ts
Normal file
29
apps/cli/src/acp/utils/index.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
/**
|
||||
* ACP Utilities Module
|
||||
*
|
||||
* Shared utilities for the ACP implementation.
|
||||
*/
|
||||
|
||||
export {
|
||||
// Configuration
|
||||
type FormatConfig,
|
||||
DEFAULT_FORMAT_CONFIG,
|
||||
// Result type
|
||||
type Result,
|
||||
ok,
|
||||
err,
|
||||
// Formatting functions
|
||||
formatSearchResults,
|
||||
formatReadContent,
|
||||
wrapInCodeBlock,
|
||||
// Content extraction
|
||||
extractContentFromParams,
|
||||
// File operations
|
||||
readFileContent,
|
||||
readFileContentAsync,
|
||||
resolveFilePath,
|
||||
resolveFilePathUnsafe,
|
||||
// Validation
|
||||
isUserEcho,
|
||||
hasValidFilePath,
|
||||
} from "./format-utils.js"
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1 +1,3 @@
|
|||
export * from "./extension-host.js"
|
||||
export { ExtensionClient } from "./extension-client.js"
|
||||
export type { WaitingForInputEvent, TaskCompletedEvent, CommandExecutionOutputEvent } from "./events.js"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
115
apps/cli/src/agent/test-logger.ts
Normal file
115
apps/cli/src/agent/test-logger.ts
Normal 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}`)
|
||||
81
apps/cli/src/commands/acp/index.ts
Normal file
81
apps/cli/src/commands/acp/index.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -1,2 +1,3 @@
|
|||
export * from "./auth/index.js"
|
||||
export * from "./cli/index.js"
|
||||
export * from "./acp/index.js"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
)
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
516
apps/cli/src/ui/hooks/useClientEvents.ts
Normal file
516
apps/cli/src/ui/hooks/useClientEvents.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -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],
|
||||
)
|
||||
}
|
||||
|
|
|
|||
83
apps/cli/src/ui/hooks/useExtensionState.ts
Normal file
83
apps/cli/src/ui/hooks/useExtensionState.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
20
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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!)
|
||||
|
|
|
|||
|
|
@ -400,14 +400,14 @@ describe("writeToFileTool", () => {
|
|||
})
|
||||
|
||||
it("streams content updates during partial execution after path stabilizes", async () => {
|
||||
// First call - path not yet stabilized, early return (no file operations)
|
||||
// First call - sends early "tool starting" notification, but no file operations yet
|
||||
await executeWriteFileTool({}, { isPartial: true })
|
||||
expect(mockCline.ask).not.toHaveBeenCalled()
|
||||
expect(mockCline.ask).toHaveBeenCalledTimes(1) // Early notification sent
|
||||
expect(mockCline.diffViewProvider.open).not.toHaveBeenCalled()
|
||||
|
||||
// Second call with same path - path is now stabilized, file operations proceed
|
||||
await executeWriteFileTool({}, { isPartial: true })
|
||||
expect(mockCline.ask).toHaveBeenCalled()
|
||||
expect(mockCline.ask).toHaveBeenCalledTimes(2) // Additional call after path stabilizes
|
||||
expect(mockCline.diffViewProvider.open).toHaveBeenCalledWith(testFilePath)
|
||||
expect(mockCline.diffViewProvider.update).toHaveBeenCalledWith(testContent, false)
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue