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/),
|
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).
|
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
|
## [0.0.45] - 2026-01-08
|
||||||
|
|
||||||
### Changed
|
### 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` |
|
| `-y, --yes` | Non-interactive mode: auto-approve all actions | `false` |
|
||||||
| `-k, --api-key <key>` | API key for the LLM provider | From env var |
|
| `-k, --api-key <key>` | API key for the LLM provider | From env var |
|
||||||
| `-p, --provider <provider>` | API provider (anthropic, openai, openrouter, etc.) | `openrouter` |
|
| `-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` |
|
| `-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` |
|
| `-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` |
|
| `--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 logout` | Clear stored authentication token |
|
||||||
| `roo auth status` | Show current authentication status |
|
| `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
|
## Environment Variables
|
||||||
|
|
||||||
The CLI will look for API keys in environment variables if not provided via `--api-key`:
|
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 |
|
| **say** | Informational - agent is telling you something | No |
|
||||||
| **ask** | Interactive - agent needs something from you | Usually yes |
|
| **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
|
## Ask Categories
|
||||||
|
|
||||||
|
|
@ -348,8 +394,8 @@ Example output:
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
1. **Agent communicates via `ClineMessage` stream**
|
1. **Agent communicates via `ClineMessage` stream**
|
||||||
2. **Last message determines state**
|
2. **State detection is simple: look at the last message**
|
||||||
3. **`ask` messages (non-partial) block the agent**
|
3. **Waiting = last message is a non-partial, blocking `ask`**
|
||||||
4. **Ask category determines required action**
|
4. **Auto-approval works by the extension auto-responding to its own asks**
|
||||||
5. **`partial: true` or `api_req_started` without cost = streaming**
|
5. **`partial: true` or `api_req_started` without cost = streaming**
|
||||||
6. **`ExtensionClient` is the single source of truth**
|
6. **`ExtensionClient` is the single source of truth**
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@roo-code/cli",
|
"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",
|
"description": "Roo Code CLI - Run the Roo Code agent from the command line",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
@ -21,6 +21,7 @@
|
||||||
"clean": "rimraf dist .turbo"
|
"clean": "rimraf dist .turbo"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@agentclientprotocol/sdk": "^0.12.0",
|
||||||
"@inkjs/ui": "^2.0.0",
|
"@inkjs/ui": "^2.0.0",
|
||||||
"@roo-code/core": "workspace:^",
|
"@roo-code/core": "workspace:^",
|
||||||
"@roo-code/types": "workspace:^",
|
"@roo-code/types": "workspace:^",
|
||||||
|
|
@ -33,6 +34,7 @@
|
||||||
"p-wait-for": "^5.0.2",
|
"p-wait-for": "^5.0.2",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"superjson": "^2.2.6",
|
"superjson": "^2.2.6",
|
||||||
|
"zod": "^4.3.5",
|
||||||
"zustand": "^5.0.0"
|
"zustand": "^5.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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)
|
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")
|
const storedOptions = getPrivate<ExtensionHostOptions>(host, "options")
|
||||||
expect(storedOptions.mode).toBe(options.mode)
|
expect(storedOptions.mode).toBe(options.mode)
|
||||||
expect(storedOptions.workspacePath).toBe(options.workspacePath)
|
expect(storedOptions.workspacePath).toBe(options.workspacePath)
|
||||||
expect(storedOptions.extensionPath).toBe(options.extensionPath)
|
expect(storedOptions.extensionPath).toBe(options.extensionPath)
|
||||||
expect(storedOptions.integrationTest).toBe(true) // Always set to true in constructor
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should be an EventEmitter instance", () => {
|
it("should be an EventEmitter instance", () => {
|
||||||
|
|
@ -281,8 +280,8 @@ describe("ExtensionHost", () => {
|
||||||
describe("quiet mode", () => {
|
describe("quiet mode", () => {
|
||||||
describe("setupQuietMode", () => {
|
describe("setupQuietMode", () => {
|
||||||
it("should not modify console when integrationTest is true", () => {
|
it("should not modify console when integrationTest is true", () => {
|
||||||
// By default, constructor sets integrationTest = true
|
// Explicitly set integrationTest = true
|
||||||
const host = createTestHost()
|
const host = createTestHost({ integrationTest: true })
|
||||||
const originalLog = console.log
|
const originalLog = console.log
|
||||||
|
|
||||||
callPrivate(host, "setupQuietMode")
|
callPrivate(host, "setupQuietMode")
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,7 @@ export class AskDispatcher {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip partial messages (wait for complete)
|
// Skip partial messages (wait for complete)
|
||||||
|
// Note: Streaming output for partial tool/command messages is handled by OutputManager
|
||||||
if (message.partial) {
|
if (message.partial) {
|
||||||
return { handled: false }
|
return { handled: false }
|
||||||
}
|
}
|
||||||
|
|
@ -356,9 +357,12 @@ export class AskDispatcher {
|
||||||
* Handle command execution approval.
|
* Handle command execution approval.
|
||||||
*/
|
*/
|
||||||
private async handleCommandApproval(ts: number, text: string): Promise<AskHandleResult> {
|
private async handleCommandApproval(ts: number, text: string): Promise<AskHandleResult> {
|
||||||
this.outputManager.output("\n[command request]")
|
// Skip output if we already streamed this command via partial messages
|
||||||
this.outputManager.output(` Command: ${text || "(no command specified)"}`)
|
if (!this.outputManager.isAlreadyDisplayed(ts)) {
|
||||||
this.outputManager.markDisplayed(ts, text || "", false)
|
this.outputManager.output("\n[command request]")
|
||||||
|
this.outputManager.output(` Command: ${text || "(no command specified)"}`)
|
||||||
|
this.outputManager.markDisplayed(ts, text || "", false)
|
||||||
|
}
|
||||||
|
|
||||||
if (this.nonInteractive) {
|
if (this.nonInteractive) {
|
||||||
// Auto-approved by extension settings
|
// Auto-approved by extension settings
|
||||||
|
|
@ -380,46 +384,49 @@ export class AskDispatcher {
|
||||||
* Handle tool execution approval.
|
* Handle tool execution approval.
|
||||||
*/
|
*/
|
||||||
private async handleToolApproval(ts: number, text: string): Promise<AskHandleResult> {
|
private async handleToolApproval(ts: number, text: string): Promise<AskHandleResult> {
|
||||||
let toolName = "unknown"
|
// Skip output if we already streamed this tool request via partial messages
|
||||||
let toolInfo: Record<string, unknown> = {}
|
if (!this.outputManager.isAlreadyDisplayed(ts)) {
|
||||||
|
let toolName = "unknown"
|
||||||
|
let toolInfo: Record<string, unknown> = {}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
toolInfo = JSON.parse(text) as Record<string, unknown>
|
toolInfo = JSON.parse(text) as Record<string, unknown>
|
||||||
toolName = (toolInfo.tool as string) || "unknown"
|
toolName = (toolInfo.tool as string) || "unknown"
|
||||||
} catch {
|
} catch {
|
||||||
// Use raw text if not JSON
|
// 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
if (this.nonInteractive) {
|
||||||
// Auto-approved by extension settings (unless protected)
|
// Auto-approved by extension settings (unless protected)
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,11 @@ export interface ClientEventMap {
|
||||||
*/
|
*/
|
||||||
modeChanged: ModeChangedEvent
|
modeChanged: ModeChangedEvent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emitted when command execution output is received (streaming terminal output).
|
||||||
|
*/
|
||||||
|
commandExecutionOutput: CommandExecutionOutputEvent
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Emitted on any error during message processing.
|
* Emitted on any error during message processing.
|
||||||
*/
|
*/
|
||||||
|
|
@ -128,6 +133,16 @@ export interface ModeChangedEvent {
|
||||||
currentMode: string
|
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
|
// Typed Event Emitter
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -432,6 +432,7 @@ export class ExtensionClient {
|
||||||
const message: WebviewMessage = {
|
const message: WebviewMessage = {
|
||||||
type: "cancelTask",
|
type: "cancelTask",
|
||||||
}
|
}
|
||||||
|
|
||||||
this.sendMessage(message)
|
this.sendMessage(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -467,6 +468,7 @@ export class ExtensionClient {
|
||||||
type: "terminalOperation",
|
type: "terminalOperation",
|
||||||
terminalOperation: "continue",
|
terminalOperation: "continue",
|
||||||
}
|
}
|
||||||
|
|
||||||
this.sendMessage(message)
|
this.sendMessage(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -480,6 +482,7 @@ export class ExtensionClient {
|
||||||
type: "terminalOperation",
|
type: "terminalOperation",
|
||||||
terminalOperation: "abort",
|
terminalOperation: "abort",
|
||||||
}
|
}
|
||||||
|
|
||||||
this.sendMessage(message)
|
this.sendMessage(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,12 +31,13 @@ import type { User } from "@/lib/sdk/index.js"
|
||||||
import { getProviderSettings } from "@/lib/utils/provider.js"
|
import { getProviderSettings } from "@/lib/utils/provider.js"
|
||||||
import { createEphemeralStorageDir } from "@/lib/storage/index.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 type { AgentStateInfo } from "./agent-state.js"
|
||||||
import { ExtensionClient } from "./extension-client.js"
|
import { ExtensionClient } from "./extension-client.js"
|
||||||
import { OutputManager } from "./output-manager.js"
|
import { OutputManager } from "./output-manager.js"
|
||||||
import { PromptManager } from "./prompt-manager.js"
|
import { PromptManager } from "./prompt-manager.js"
|
||||||
import { AskDispatcher } from "./ask-dispatcher.js"
|
import { AskDispatcher } from "./ask-dispatcher.js"
|
||||||
|
import { testLog } from "./test-logger.js"
|
||||||
|
|
||||||
// Pre-configured logger for CLI message activity debugging.
|
// Pre-configured logger for CLI message activity debugging.
|
||||||
const cliLogger = new DebugLogger("CLI")
|
const cliLogger = new DebugLogger("CLI")
|
||||||
|
|
@ -152,7 +153,7 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac
|
||||||
super()
|
super()
|
||||||
|
|
||||||
this.options = options
|
this.options = options
|
||||||
this.options.integrationTest = true
|
// this.options.integrationTest = true
|
||||||
|
|
||||||
// Initialize client - single source of truth for agent state (including mode).
|
// Initialize client - single source of truth for agent state (including mode).
|
||||||
this.client = new ExtensionClient({
|
this.client = new ExtensionClient({
|
||||||
|
|
@ -163,6 +164,7 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac
|
||||||
// Initialize output manager.
|
// Initialize output manager.
|
||||||
this.outputManager = new OutputManager({
|
this.outputManager = new OutputManager({
|
||||||
disabled: options.disableOutput,
|
disabled: options.disableOutput,
|
||||||
|
debug: options.debug,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Initialize prompt manager with console mode callbacks.
|
// Initialize prompt manager with console mode callbacks.
|
||||||
|
|
@ -189,6 +191,18 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac
|
||||||
commandExecutionTimeout: 30,
|
commandExecutionTimeout: 30,
|
||||||
browserToolEnabled: false,
|
browserToolEnabled: false,
|
||||||
enableCheckpoints: 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),
|
...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.
|
* The client emits events, managers handle them.
|
||||||
*/
|
*/
|
||||||
private setupClientEventHandlers(): void {
|
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.
|
// Handle new messages - delegate to OutputManager.
|
||||||
this.client.on("message", (msg: ClineMessage) => {
|
this.client.on("message", (msg: ClineMessage) => {
|
||||||
this.logMessageDebug(msg, "new")
|
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)
|
this.outputManager.outputMessage(msg)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Handle message updates - delegate to OutputManager.
|
// Handle message updates - delegate to OutputManager.
|
||||||
this.client.on("messageUpdated", (msg: ClineMessage) => {
|
this.client.on("messageUpdated", (msg: ClineMessage) => {
|
||||||
this.logMessageDebug(msg, "updated")
|
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)
|
this.outputManager.outputMessage(msg)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -259,6 +311,11 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac
|
||||||
this.outputManager.outputCompletionResult(event.message.ts, event.message.text || "")
|
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 })
|
this.sendToExtension({ type: "newTask", text: prompt })
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let timeoutId: NodeJS.Timeout | null = null
|
|
||||||
const timeoutMs: number = 110_000
|
|
||||||
|
|
||||||
const completeHandler = () => {
|
const completeHandler = () => {
|
||||||
cleanup()
|
cleanup()
|
||||||
resolve()
|
resolve()
|
||||||
|
|
@ -450,23 +504,10 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac
|
||||||
}
|
}
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
if (timeoutId) {
|
|
||||||
clearTimeout(timeoutId)
|
|
||||||
timeoutId = null
|
|
||||||
}
|
|
||||||
|
|
||||||
this.client.off("taskCompleted", completeHandler)
|
this.client.off("taskCompleted", completeHandler)
|
||||||
this.client.off("error", errorHandler)
|
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("taskCompleted", completeHandler)
|
||||||
this.client.once("error", errorHandler)
|
this.client.once("error", errorHandler)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1 +1,3 @@
|
||||||
export * from "./extension-host.js"
|
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 { debugLog } from "@roo-code/core/cli"
|
||||||
|
|
||||||
import type { StateStore } from "./state-store.js"
|
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 {
|
import {
|
||||||
isSignificantStateChange,
|
isSignificantStateChange,
|
||||||
transitionedToWaiting,
|
transitionedToWaiting,
|
||||||
|
|
@ -121,6 +127,10 @@ export class MessageProcessor {
|
||||||
this.handleInvoke(message)
|
this.handleInvoke(message)
|
||||||
break
|
break
|
||||||
|
|
||||||
|
case "commandExecutionStatus":
|
||||||
|
this.handleCommandExecutionStatus(message)
|
||||||
|
break
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// Other message types are not relevant to state detection
|
// Other message types are not relevant to state detection
|
||||||
if (this.options.debug) {
|
if (this.options.debug) {
|
||||||
|
|
@ -237,6 +247,7 @@ export class MessageProcessor {
|
||||||
}
|
}
|
||||||
|
|
||||||
const clineMessage = message.clineMessage
|
const clineMessage = message.clineMessage
|
||||||
|
|
||||||
const previousState = this.store.getAgentState()
|
const previousState = this.store.getAgentState()
|
||||||
|
|
||||||
// Update the message in the store
|
// Update the message in the store
|
||||||
|
|
@ -277,6 +288,45 @@ export class MessageProcessor {
|
||||||
// But they might trigger state changes through subsequent messages
|
// 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
|
// Event Emission Helpers
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
@ -372,6 +422,15 @@ export class MessageProcessor {
|
||||||
// A more sophisticated implementation would track seen message timestamps
|
// A more sophisticated implementation would track seen message timestamps
|
||||||
const lastMessage = messages[messages.length - 1]
|
const lastMessage = messages[messages.length - 1]
|
||||||
if (lastMessage) {
|
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)
|
this.emitter.emit("message", lastMessage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,17 @@
|
||||||
* - Can be disabled for TUI mode where Ink controls the terminal
|
* - Can be disabled for TUI mode where Ink controls the terminal
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import fs from "fs"
|
||||||
import { ClineMessage, ClineSay } from "@roo-code/types"
|
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"
|
import { Observable } from "./events.js"
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -58,6 +67,12 @@ export interface OutputManagerOptions {
|
||||||
* Stream for error output (default: process.stderr).
|
* Stream for error output (default: process.stderr).
|
||||||
*/
|
*/
|
||||||
stderr?: NodeJS.WriteStream
|
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 disabled: boolean
|
||||||
private stdout: NodeJS.WriteStream
|
private stdout: NodeJS.WriteStream
|
||||||
private stderr: NodeJS.WriteStream
|
private stderr: NodeJS.WriteStream
|
||||||
|
private debug: boolean
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Track displayed messages by ts to avoid duplicate output.
|
* Track displayed messages by ts to avoid duplicate output.
|
||||||
|
|
@ -90,6 +106,16 @@ export class OutputManager {
|
||||||
*/
|
*/
|
||||||
private loggedFirstPartial = new Set<number>()
|
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.
|
* Observable for streaming state changes.
|
||||||
* External systems can subscribe to know when streaming starts/ends.
|
* External systems can subscribe to know when streaming starts/ends.
|
||||||
|
|
@ -103,6 +129,7 @@ export class OutputManager {
|
||||||
this.disabled = options.disabled ?? false
|
this.disabled = options.disabled ?? false
|
||||||
this.stdout = options.stdout ?? process.stdout
|
this.stdout = options.stdout ?? process.stdout
|
||||||
this.stderr = options.stderr ?? process.stderr
|
this.stderr = options.stderr ?? process.stderr
|
||||||
|
this.debug = options.debug ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
@ -126,20 +153,48 @@ export class OutputManager {
|
||||||
if (msg.type === "say" && msg.say) {
|
if (msg.type === "say" && msg.say) {
|
||||||
this.outputSayMessage(ts, msg.say, text, isPartial, alreadyDisplayedComplete, skipFirstUserMessage)
|
this.outputSayMessage(ts, msg.say, text, isPartial, alreadyDisplayedComplete, skipFirstUserMessage)
|
||||||
} else if (msg.type === "ask" && msg.ask) {
|
} else if (msg.type === "ask" && msg.ask) {
|
||||||
// For ask messages, we only output command_output here
|
// Handle streaming output for different ask types
|
||||||
// Other asks are handled by AskDispatcher
|
switch (msg.ask) {
|
||||||
if (msg.ask === "command_output") {
|
case "command_output":
|
||||||
this.outputCommandOutput(ts, text, isPartial, alreadyDisplayedComplete)
|
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 a simple text line with a label.
|
||||||
*/
|
*/
|
||||||
output(label: string, text?: string): void {
|
output(label: string, text?: string): void {
|
||||||
if (this.disabled) return
|
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)
|
this.stdout.write(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -148,7 +203,8 @@ export class OutputManager {
|
||||||
*/
|
*/
|
||||||
outputError(label: string, text?: string): void {
|
outputError(label: string, text?: string): void {
|
||||||
if (this.disabled) return
|
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)
|
this.stderr.write(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -157,13 +213,21 @@ export class OutputManager {
|
||||||
*/
|
*/
|
||||||
writeRaw(text: string): void {
|
writeRaw(text: string): void {
|
||||||
if (this.disabled) return
|
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 {
|
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)
|
const displayed = this.displayedMessages.get(ts)
|
||||||
return displayed !== undefined && !displayed.partial
|
return displayed !== undefined && !displayed.partial
|
||||||
}
|
}
|
||||||
|
|
@ -198,6 +262,11 @@ export class OutputManager {
|
||||||
this.streamedContent.clear()
|
this.streamedContent.clear()
|
||||||
this.currentlyStreamingTs = null
|
this.currentlyStreamingTs = null
|
||||||
this.loggedFirstPartial.clear()
|
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 })
|
this.streamingState.next({ ts: null, isStreaming: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -335,6 +404,7 @@ export class OutputManager {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Output command_output (shared between say and ask types).
|
* Output command_output (shared between say and ask types).
|
||||||
|
* Skips output if we've already streamed terminal output via commandExecutionStatus.
|
||||||
*/
|
*/
|
||||||
outputCommandOutput(
|
outputCommandOutput(
|
||||||
ts: number,
|
ts: number,
|
||||||
|
|
@ -342,6 +412,15 @@ export class OutputManager {
|
||||||
isPartial: boolean,
|
isPartial: boolean,
|
||||||
alreadyDisplayedComplete: boolean | undefined,
|
alreadyDisplayedComplete: boolean | undefined,
|
||||||
): void {
|
): 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) {
|
if (isPartial && text) {
|
||||||
this.streamContent(ts, text, "[command output]")
|
this.streamContent(ts, text, "[command output]")
|
||||||
this.displayedMessages.set(ts, { ts, text, partial: true })
|
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
|
// Streaming Helpers
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
@ -411,4 +693,38 @@ export class OutputManager {
|
||||||
this.displayedMessages.set(ts, { ts, text: text || "", partial: false })
|
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(ASCII_ROO)
|
||||||
console.log()
|
console.log()
|
||||||
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({
|
const host = new ExtensionHost({
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,3 @@
|
||||||
export * from "./auth/index.js"
|
export * from "./auth/index.js"
|
||||||
export * from "./cli/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 { DEFAULT_FLAGS } from "@/types/constants.js"
|
||||||
import { VERSION } from "@/lib/utils/version.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()
|
const program = new Command()
|
||||||
|
|
||||||
|
|
@ -62,4 +62,14 @@ authCommand
|
||||||
process.exit(result.authenticated ? 0 : 1)
|
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()
|
program.parse()
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { RooCodeSettings } from "@roo-code/types"
|
||||||
|
|
||||||
import type { SupportedProvider } from "@/types/index.js"
|
import type { SupportedProvider } from "@/types/index.js"
|
||||||
|
|
||||||
const envVarMap: Record<SupportedProvider, string> = {
|
export const envVarMap: Record<SupportedProvider, string> = {
|
||||||
anthropic: "ANTHROPIC_API_KEY",
|
anthropic: "ANTHROPIC_API_KEY",
|
||||||
"openai-native": "OPENAI_API_KEY",
|
"openai-native": "OPENAI_API_KEY",
|
||||||
gemini: "GOOGLE_API_KEY",
|
gemini: "GOOGLE_API_KEY",
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ export const DEFAULT_FLAGS = {
|
||||||
mode: "code",
|
mode: "code",
|
||||||
reasoningEffort: "medium" as const,
|
reasoningEffort: "medium" as const,
|
||||||
model: "anthropic/claude-opus-4.5",
|
model: "anthropic/claude-opus-4.5",
|
||||||
|
provider: "openrouter" as const,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const REASONING_EFFORTS = [...reasoningEffortsExtended, "unspecified", "disabled"]
|
export const REASONING_EFFORTS = [...reasoningEffortsExtended, "unspecified", "disabled"]
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,13 @@ import {
|
||||||
useTerminalSize,
|
useTerminalSize,
|
||||||
useToast,
|
useToast,
|
||||||
useExtensionHost,
|
useExtensionHost,
|
||||||
useMessageHandlers,
|
|
||||||
useTaskSubmit,
|
useTaskSubmit,
|
||||||
useGlobalInput,
|
useGlobalInput,
|
||||||
useFollowupCountdown,
|
useFollowupCountdown,
|
||||||
useFocusManagement,
|
useFocusManagement,
|
||||||
usePickerHandlers,
|
usePickerHandlers,
|
||||||
|
useClientEvents,
|
||||||
|
useExtensionState,
|
||||||
} from "./hooks/index.js"
|
} from "./hooks/index.js"
|
||||||
|
|
||||||
// Import extracted utilities.
|
// Import extracted utilities.
|
||||||
|
|
@ -159,16 +160,14 @@ function AppInner({
|
||||||
// Toast notifications for ephemeral messages (e.g., mode changes).
|
// Toast notifications for ephemeral messages (e.g., mode changes).
|
||||||
const { currentToast, showInfo } = useToast()
|
const { currentToast, showInfo } = useToast()
|
||||||
|
|
||||||
const {
|
// Handle non-message extension state (modes, file search, commands, task history)
|
||||||
handleExtensionMessage,
|
const { handleExtensionState } = useExtensionState()
|
||||||
seenMessageIds,
|
|
||||||
pendingCommandRef: _pendingCommandRef,
|
|
||||||
firstTextMessageSkipped,
|
|
||||||
} = useMessageHandlers({
|
|
||||||
nonInteractive,
|
|
||||||
})
|
|
||||||
|
|
||||||
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,
|
initialPrompt,
|
||||||
mode,
|
mode,
|
||||||
reasoningEffort,
|
reasoningEffort,
|
||||||
|
|
@ -182,10 +181,27 @@ function AppInner({
|
||||||
nonInteractive,
|
nonInteractive,
|
||||||
ephemeral,
|
ephemeral,
|
||||||
exitOnComplete,
|
exitOnComplete,
|
||||||
onExtensionMessage: handleExtensionMessage,
|
onExtensionState: handleExtensionState,
|
||||||
createExtensionHost,
|
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
|
// Initialize task submit hook
|
||||||
const { handleSubmit, handleApprove, handleReject } = useTaskSubmit({
|
const { handleSubmit, handleApprove, handleReject } = useTaskSubmit({
|
||||||
sendToExtension,
|
sendToExtension,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { memo } from "react"
|
// memo temporarily removed for debugging
|
||||||
import { Box, Newline, Text } from "ink"
|
import { Box, Newline, Text } from "ink"
|
||||||
|
import { DebugLogger } from "@roo-code/core/cli"
|
||||||
|
|
||||||
import type { TUIMessage } from "../types.js"
|
import type { TUIMessage } from "../types.js"
|
||||||
import * as theme from "../theme.js"
|
import * as theme from "../theme.js"
|
||||||
|
|
@ -7,6 +8,8 @@ import * as theme from "../theme.js"
|
||||||
import TodoDisplay from "./TodoDisplay.js"
|
import TodoDisplay from "./TodoDisplay.js"
|
||||||
import { getToolRenderer } from "./tools/index.js"
|
import { getToolRenderer } from "./tools/index.js"
|
||||||
|
|
||||||
|
const renderLogger = new DebugLogger("RENDER")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tool categories for styling
|
* Tool categories for styling
|
||||||
*/
|
*/
|
||||||
|
|
@ -215,6 +218,27 @@ function ChatHistoryItem({ message }: ChatHistoryItemProps) {
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
case "tool": {
|
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
|
// Special rendering for update_todo_list tool - show full TODO list
|
||||||
if (
|
if (
|
||||||
(message.toolName === "update_todo_list" || message.toolName === "updateTodoList") &&
|
(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",
|
id: "help",
|
||||||
triggerChar: "?",
|
triggerChar: "?",
|
||||||
position: "line-start",
|
position: "line-start",
|
||||||
consumeTrigger: true,
|
|
||||||
|
|
||||||
detectTrigger: (lineText: string): TriggerDetectionResult | null => {
|
detectTrigger: (lineText: string): TriggerDetectionResult | null => {
|
||||||
// Check if line starts with ? (after optional whitespace)
|
// Check if line starts with ? (after optional whitespace)
|
||||||
|
|
|
||||||
|
|
@ -157,13 +157,5 @@ describe("HelpTrigger", () => {
|
||||||
expect(trigger.emptyMessage).toBe("No matching shortcuts")
|
expect(trigger.emptyMessage).toBe("No matching shortcuts")
|
||||||
expect(trigger.debounceMs).toBe(0)
|
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)
|
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", () => {
|
it("should call getHistory when searching", () => {
|
||||||
const getHistoryMock = vi.fn(() => mockHistoryItems)
|
const getHistoryMock = vi.fn(() => mockHistoryItems)
|
||||||
const trigger = createHistoryTrigger({ getHistory: getHistoryMock })
|
const trigger = createHistoryTrigger({ getHistory: getHistoryMock })
|
||||||
|
|
|
||||||
|
|
@ -97,14 +97,6 @@ export interface AutocompleteTrigger<T extends AutocompleteItem = AutocompleteIt
|
||||||
* @default 150
|
* @default 150
|
||||||
*/
|
*/
|
||||||
debounceMs?: number
|
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] || ""
|
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.
|
* Handle input value changes - detects triggers and initiates search.
|
||||||
* Returns an object indicating if the input should be modified (for consumeTrigger).
|
* 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) {
|
if (query === lastQuery && state.isOpen && state.activeTrigger?.id === foundTrigger.id) {
|
||||||
// Same query, same trigger - no need to search again
|
// 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 {}
|
return {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -207,14 +190,9 @@ export function useAutocompletePicker<T extends AutocompleteItem>(
|
||||||
|
|
||||||
debounceTimersRef.current.set(foundTrigger.id, timer)
|
debounceTimersRef.current.set(foundTrigger.id, timer)
|
||||||
|
|
||||||
// Return consumed value if trigger consumes input
|
|
||||||
if (foundTrigger.consumeTrigger) {
|
|
||||||
return { consumedValue: getConsumedValue(value, lastLine, foundTriggerInfo.triggerIndex) }
|
|
||||||
}
|
|
||||||
|
|
||||||
return {}
|
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 { Icon } from "../Icon.js"
|
||||||
|
|
||||||
import type { ToolRendererProps } from "./types.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 iconName = getToolIconName(toolData.tool)
|
||||||
const displayName = getToolDisplayName(toolData.tool)
|
const displayName = getToolDisplayName(toolData.tool)
|
||||||
const path = toolData.path || ""
|
const path = toolData.path || ""
|
||||||
|
|
@ -18,6 +18,20 @@ export function FileWriteTool({ toolData }: ToolRendererProps) {
|
||||||
const isOutsideWorkspace = toolData.isOutsideWorkspace
|
const isOutsideWorkspace = toolData.isOutsideWorkspace
|
||||||
const isNewFile = toolData.tool === "newFileCreated" || toolData.tool === "write_to_file"
|
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
|
// Handle batch diff operations
|
||||||
if (toolData.batchDiffs && toolData.batchDiffs.length > 0) {
|
if (toolData.batchDiffs && toolData.batchDiffs.length > 0) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -57,9 +71,20 @@ export function FileWriteTool({ toolData }: ToolRendererProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single file write
|
// 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) : []
|
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 (
|
return (
|
||||||
<Box flexDirection="column" paddingX={1} marginBottom={1}>
|
<Box flexDirection="column" paddingX={1} marginBottom={1}>
|
||||||
{/* Header row with path on same line */}
|
{/* Header row with path on same line */}
|
||||||
|
|
@ -76,15 +101,15 @@ export function FileWriteTool({ toolData }: ToolRendererProps) {
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{isNewFile && (
|
{isNewFile && !diffStats && (
|
||||||
<Text color={theme.successColor} bold>
|
<Text color={theme.successColor} bold>
|
||||||
{" "}
|
{" "}
|
||||||
NEW
|
NEW
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Diff stats badge */}
|
{/* Stats - show line/char count for streaming, or diff stats when complete */}
|
||||||
{diffStats && (
|
{diffStats ? (
|
||||||
<>
|
<>
|
||||||
<Text color={theme.dimText}> </Text>
|
<Text color={theme.dimText}> </Text>
|
||||||
<Text color={theme.successColor} bold>
|
<Text color={theme.successColor} bold>
|
||||||
|
|
@ -95,6 +120,14 @@ export function FileWriteTool({ toolData }: ToolRendererProps) {
|
||||||
-{diffStats.removed}
|
-{diffStats.removed}
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
|
) : (
|
||||||
|
isNewFile &&
|
||||||
|
totalChars > 0 && (
|
||||||
|
<Text color={theme.dimText}>
|
||||||
|
{" "}
|
||||||
|
({totalLines} lines, {totalChars} chars)
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Warning badges */}
|
{/* Warning badges */}
|
||||||
|
|
@ -107,7 +140,23 @@ export function FileWriteTool({ toolData }: ToolRendererProps) {
|
||||||
)}
|
)}
|
||||||
</Box>
|
</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 && (
|
{diffHunks.length > 0 && (
|
||||||
<Box flexDirection="column" marginLeft={2} marginTop={1}>
|
<Box flexDirection="column" marginLeft={2} marginTop={1}>
|
||||||
{diffHunks.slice(0, 2).map((hunk, hunkIndex) => (
|
{diffHunks.slice(0, 2).map((hunk, hunkIndex) => (
|
||||||
|
|
@ -149,13 +198,20 @@ export function FileWriteTool({ toolData }: ToolRendererProps) {
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Fallback to raw diff if no hunks parsed */}
|
{/* Fallback: show raw diff content if no hunks parsed and not streaming new file */}
|
||||||
{diffHunks.length === 0 && previewDiff && (
|
{!isNewFile && diffHunks.length === 0 && diff && (
|
||||||
<Box flexDirection="column" marginLeft={2} marginTop={1}>
|
<Box flexDirection="column" marginLeft={2} marginTop={1}>
|
||||||
<Text color={theme.toolText}>{previewDiff}</Text>
|
{diff
|
||||||
{truncated && (
|
.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>
|
<Text color={theme.dimText} dimColor>
|
||||||
... ({hiddenLines} more lines)
|
... ({diff.split("\n").length - MAX_PREVIEW_LINES} more lines)
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -6,17 +6,19 @@ export { useInputHistory } from "./useInputHistory.js"
|
||||||
// Export new extracted hooks
|
// Export new extracted hooks
|
||||||
export { useFollowupCountdown } from "./useFollowupCountdown.js"
|
export { useFollowupCountdown } from "./useFollowupCountdown.js"
|
||||||
export { useFocusManagement } from "./useFocusManagement.js"
|
export { useFocusManagement } from "./useFocusManagement.js"
|
||||||
export { useMessageHandlers } from "./useMessageHandlers.js"
|
|
||||||
export { useExtensionHost } from "./useExtensionHost.js"
|
export { useExtensionHost } from "./useExtensionHost.js"
|
||||||
export { useTaskSubmit } from "./useTaskSubmit.js"
|
export { useTaskSubmit } from "./useTaskSubmit.js"
|
||||||
export { useGlobalInput } from "./useGlobalInput.js"
|
export { useGlobalInput } from "./useGlobalInput.js"
|
||||||
export { usePickerHandlers } from "./usePickerHandlers.js"
|
export { usePickerHandlers } from "./usePickerHandlers.js"
|
||||||
|
export { useClientEvents } from "./useClientEvents.js"
|
||||||
|
export { useExtensionState } from "./useExtensionState.js"
|
||||||
|
|
||||||
// Export types
|
// Export types
|
||||||
export type { UseFollowupCountdownOptions } from "./useFollowupCountdown.js"
|
export type { UseFollowupCountdownOptions } from "./useFollowupCountdown.js"
|
||||||
export type { UseFocusManagementOptions, UseFocusManagementReturn } from "./useFocusManagement.js"
|
export type { UseFocusManagementOptions, UseFocusManagementReturn } from "./useFocusManagement.js"
|
||||||
export type { UseMessageHandlersOptions, UseMessageHandlersReturn } from "./useMessageHandlers.js"
|
|
||||||
export type { UseExtensionHostOptions, UseExtensionHostReturn } from "./useExtensionHost.js"
|
export type { UseExtensionHostOptions, UseExtensionHostReturn } from "./useExtensionHost.js"
|
||||||
export type { UseTaskSubmitOptions, UseTaskSubmitReturn } from "./useTaskSubmit.js"
|
export type { UseTaskSubmitOptions, UseTaskSubmitReturn } from "./useTaskSubmit.js"
|
||||||
export type { UseGlobalInputOptions } from "./useGlobalInput.js"
|
export type { UseGlobalInputOptions } from "./useGlobalInput.js"
|
||||||
export type { UsePickerHandlersOptions, UsePickerHandlersReturn } from "./usePickerHandlers.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 { useApp } from "ink"
|
||||||
import { randomUUID } from "crypto"
|
import { randomUUID } from "crypto"
|
||||||
import type { ExtensionMessage, WebviewMessage } from "@roo-code/types"
|
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"
|
import { useCLIStore } from "../store.js"
|
||||||
|
|
||||||
export interface UseExtensionHostOptions extends ExtensionHostOptions {
|
export interface UseExtensionHostOptions extends ExtensionHostOptions {
|
||||||
initialPrompt?: string
|
initialPrompt?: string
|
||||||
exitOnComplete?: boolean
|
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
|
createExtensionHost: (options: ExtensionHostOptions) => ExtensionHostInterface
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UseExtensionHostReturn {
|
export interface UseExtensionHostReturn {
|
||||||
isReady: boolean
|
isReady: boolean
|
||||||
|
/** ExtensionClient for subscribing to message events */
|
||||||
|
client: ExtensionClient | null
|
||||||
sendToExtension: ((msg: WebviewMessage) => void) | null
|
sendToExtension: ((msg: WebviewMessage) => void) | null
|
||||||
runTask: ((prompt: string) => Promise<void>) | null
|
runTask: ((prompt: string) => Promise<void>) | null
|
||||||
cleanup: () => Promise<void>
|
cleanup: () => Promise<void>
|
||||||
|
|
@ -43,19 +49,23 @@ export function useExtensionHost({
|
||||||
nonInteractive,
|
nonInteractive,
|
||||||
ephemeral,
|
ephemeral,
|
||||||
exitOnComplete,
|
exitOnComplete,
|
||||||
onExtensionMessage,
|
onExtensionState,
|
||||||
createExtensionHost,
|
createExtensionHost,
|
||||||
}: UseExtensionHostOptions): UseExtensionHostReturn {
|
}: UseExtensionHostOptions): UseExtensionHostReturn {
|
||||||
const { exit } = useApp()
|
const { exit } = useApp()
|
||||||
const { addMessage, setComplete, setLoading, setHasStartedTask, setError } = useCLIStore()
|
const { addMessage, setComplete, setLoading, setHasStartedTask, setError } = useCLIStore()
|
||||||
|
|
||||||
const hostRef = useRef<ExtensionHostInterface | null>(null)
|
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 isReadyRef = useRef(false)
|
||||||
|
|
||||||
const cleanup = useCallback(async () => {
|
const cleanup = useCallback(async () => {
|
||||||
if (hostRef.current) {
|
if (hostRef.current) {
|
||||||
await hostRef.current.dispose()
|
await hostRef.current.dispose()
|
||||||
hostRef.current = null
|
hostRef.current = null
|
||||||
|
setClient(null)
|
||||||
isReadyRef.current = false
|
isReadyRef.current = false
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
@ -78,9 +88,15 @@ export function useExtensionHost({
|
||||||
})
|
})
|
||||||
|
|
||||||
hostRef.current = host
|
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
|
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 () => {
|
host.client.on("taskCompleted", async () => {
|
||||||
setComplete(true)
|
setComplete(true)
|
||||||
|
|
@ -142,9 +158,9 @@ export function useExtensionHost({
|
||||||
return hostRef.current.runTask(prompt)
|
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(
|
return useMemo(
|
||||||
() => ({ isReady: isReadyRef.current, sendToExtension, runTask, cleanup }),
|
() => ({ isReady: isReadyRef.current, client, sendToExtension, runTask, cleanup }),
|
||||||
[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 { create } from "zustand"
|
||||||
|
|
||||||
import type { TokenUsage, ProviderSettings, TodoItem } from "@roo-code/types"
|
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 { TUIMessage, PendingAsk, TaskHistoryItem } from "./types.js"
|
||||||
import type { FileResult, SlashCommandResult, ModeResult } from "./components/autocomplete/index.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.
|
* Shallow array equality check - compares array length and element references.
|
||||||
* Used to prevent unnecessary state updates when array content hasn't changed.
|
* 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
|
// For NEW messages (not updates) - always apply immediately
|
||||||
if (existingIndex === -1) {
|
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] })
|
set({ messages: [...state.messages, msg] })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
storeLogger.debug("addMessage:update", {
|
||||||
|
id: msg.id,
|
||||||
|
partial: msg.partial,
|
||||||
|
existingIndex,
|
||||||
|
})
|
||||||
|
|
||||||
// For UPDATES to existing messages:
|
// For UPDATES to existing messages:
|
||||||
// If partial (streaming) and message exists, debounce the update
|
// If partial (streaming) and message exists, debounce the update
|
||||||
if (msg.partial) {
|
if (msg.partial) {
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,18 @@ export default defineConfig({
|
||||||
banner: {
|
banner: {
|
||||||
js: "#!/usr/bin/env node",
|
js: "#!/usr/bin/env node",
|
||||||
},
|
},
|
||||||
// Bundle workspace packages that export TypeScript
|
// Bundle workspace packages and ESM-only npm dependencies to create a self-contained CLI
|
||||||
noExternal: ["@roo-code/core", "@roo-code/core/cli", "@roo-code/types", "@roo-code/vscode-shim"],
|
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: [
|
external: [
|
||||||
// Keep native modules external
|
// Keep native modules external
|
||||||
"@anthropic-ai/sdk",
|
"@anthropic-ai/sdk",
|
||||||
|
|
|
||||||
|
|
@ -98,8 +98,7 @@ const features: Feature[] = [
|
||||||
{
|
{
|
||||||
icon: Brain,
|
icon: Brain,
|
||||||
title: "Model Agnostic",
|
title: "Model Agnostic",
|
||||||
description:
|
description: "Bring your own keys or use the Roo Code Router with access to all top models with no markup.",
|
||||||
"Bring your own keys or use the Roo Code Router with access to all top models with no markup.",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Github,
|
icon: Github,
|
||||||
|
|
@ -153,7 +152,7 @@ export default function CloudPage() {
|
||||||
Your AI Team <span className="text-violet-500">in the Cloud</span>
|
Your AI Team <span className="text-violet-500">in the Cloud</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto">
|
<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.
|
from Web and Slack.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
<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">
|
<div className="text-sm text-muted-foreground">
|
||||||
<p className="">
|
<p className="">
|
||||||
On any plan, you can use your own LLM provider API key or use the built-in Roo Code
|
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
|
Router – curated models to work with Roo with no markup, including the latest
|
||||||
latest Gemini, GPT and Claude. Paid with credits.
|
Gemini, GPT and Claude. Paid with credits.
|
||||||
<Link href="/provider" className="underline hover:no-underline ml-1">
|
<Link href="/provider" className="underline hover:no-underline ml-1">
|
||||||
See per model pricing.
|
See per model pricing.
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
||||||
20
pnpm-lock.yaml
generated
20
pnpm-lock.yaml
generated
|
|
@ -82,6 +82,9 @@ importers:
|
||||||
|
|
||||||
apps/cli:
|
apps/cli:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@agentclientprotocol/sdk':
|
||||||
|
specifier: ^0.12.0
|
||||||
|
version: 0.12.0(zod@4.3.5)
|
||||||
'@inkjs/ui':
|
'@inkjs/ui':
|
||||||
specifier: ^2.0.0
|
specifier: ^2.0.0
|
||||||
version: 2.0.0(ink@6.6.0(@types/react@18.3.23)(react@19.2.3))
|
version: 2.0.0(ink@6.6.0(@types/react@18.3.23)(react@19.2.3))
|
||||||
|
|
@ -118,6 +121,9 @@ importers:
|
||||||
superjson:
|
superjson:
|
||||||
specifier: ^2.2.6
|
specifier: ^2.2.6
|
||||||
version: 2.2.6
|
version: 2.2.6
|
||||||
|
zod:
|
||||||
|
specifier: ^4.3.5
|
||||||
|
version: 4.3.5
|
||||||
zustand:
|
zustand:
|
||||||
specifier: ^5.0.0
|
specifier: ^5.0.0
|
||||||
version: 5.0.9(@types/react@18.3.23)(react@19.2.3)
|
version: 5.0.9(@types/react@18.3.23)(react@19.2.3)
|
||||||
|
|
@ -1356,6 +1362,11 @@ packages:
|
||||||
'@adobe/css-tools@4.4.2':
|
'@adobe/css-tools@4.4.2':
|
||||||
resolution: {integrity: sha512-baYZExFpsdkBNuvGKTKWCwKH57HRZLVtycZS05WTQNVOiXVSeAki3nU35zlRbToeMW8aHlJfyS+1C4BOv27q0A==}
|
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':
|
'@alcalzone/ansi-tokenize@0.2.3':
|
||||||
resolution: {integrity: sha512-jsElTJ0sQ4wHRz+C45tfect76BwbTbgkgKByOzpCN9xG61N5V6u/glvg1CsNJhq2xJIFpKHSwG3D2wPPuEYOrQ==}
|
resolution: {integrity: sha512-jsElTJ0sQ4wHRz+C45tfect76BwbTbgkgKByOzpCN9xG61N5V6u/glvg1CsNJhq2xJIFpKHSwG3D2wPPuEYOrQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -10659,6 +10670,9 @@ packages:
|
||||||
zod@3.25.76:
|
zod@3.25.76:
|
||||||
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
||||||
|
|
||||||
|
zod@4.3.5:
|
||||||
|
resolution: {integrity: sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==}
|
||||||
|
|
||||||
zustand@5.0.9:
|
zustand@5.0.9:
|
||||||
resolution: {integrity: sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==}
|
resolution: {integrity: sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==}
|
||||||
engines: {node: '>=12.20.0'}
|
engines: {node: '>=12.20.0'}
|
||||||
|
|
@ -10684,6 +10698,10 @@ snapshots:
|
||||||
|
|
||||||
'@adobe/css-tools@4.4.2': {}
|
'@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':
|
'@alcalzone/ansi-tokenize@0.2.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
ansi-styles: 6.2.3
|
ansi-styles: 6.2.3
|
||||||
|
|
@ -21715,6 +21733,8 @@ snapshots:
|
||||||
|
|
||||||
zod@3.25.76: {}
|
zod@3.25.76: {}
|
||||||
|
|
||||||
|
zod@4.3.5: {}
|
||||||
|
|
||||||
zustand@5.0.9(@types/react@18.3.23)(react@19.2.3):
|
zustand@5.0.9(@types/react@18.3.23)(react@19.2.3):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 18.3.23
|
'@types/react': 18.3.23
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,12 @@ interface WriteToFileParams {
|
||||||
export class WriteToFileTool extends BaseTool<"write_to_file"> {
|
export class WriteToFileTool extends BaseTool<"write_to_file"> {
|
||||||
readonly name = "write_to_file" as const
|
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 {
|
parseLegacy(params: Partial<Record<string, string>>): WriteToFileParams {
|
||||||
return {
|
return {
|
||||||
path: params.path || "",
|
path: params.path || "",
|
||||||
|
|
@ -124,6 +130,10 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> {
|
||||||
task.diffViewProvider.originalContent = ""
|
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
|
let unified = fileExists
|
||||||
? formatResponse.createPrettyPatch(relPath, task.diffViewProvider.originalContent, newContent)
|
? formatResponse.createPrettyPatch(relPath, task.diffViewProvider.originalContent, newContent)
|
||||||
: convertNewFileToUnifiedDiff(newContent, relPath)
|
: 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> {
|
override async handlePartial(task: Task, block: ToolUse<"write_to_file">): Promise<void> {
|
||||||
const relPath: string | undefined = block.params.path
|
const relPath: string | undefined = block.params.path
|
||||||
let newContent: string | undefined = block.params.content
|
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) {
|
if (!this.hasPathStabilized(relPath) || newContent === undefined) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -216,10 +248,6 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> {
|
||||||
EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION,
|
EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (isPreventFocusDisruptionEnabled) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// relPath is guaranteed non-null after hasPathStabilized
|
// relPath is guaranteed non-null after hasPathStabilized
|
||||||
let fileExists: boolean
|
let fileExists: boolean
|
||||||
const absolutePath = path.resolve(task.cwd, relPath!)
|
const absolutePath = path.resolve(task.cwd, relPath!)
|
||||||
|
|
@ -248,9 +276,15 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> {
|
||||||
isProtected: isWriteProtected,
|
isProtected: isWriteProtected,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Always send partial messages to keep CLI informed during streaming
|
||||||
const partialMessage = JSON.stringify(sharedMessageProps)
|
const partialMessage = JSON.stringify(sharedMessageProps)
|
||||||
await task.ask("tool", partialMessage, block.partial).catch(() => {})
|
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 (newContent) {
|
||||||
if (!task.diffViewProvider.isEditing) {
|
if (!task.diffViewProvider.isEditing) {
|
||||||
await task.diffViewProvider.open(relPath!)
|
await task.diffViewProvider.open(relPath!)
|
||||||
|
|
|
||||||
|
|
@ -400,14 +400,14 @@ describe("writeToFileTool", () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it("streams content updates during partial execution after path stabilizes", async () => {
|
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 })
|
await executeWriteFileTool({}, { isPartial: true })
|
||||||
expect(mockCline.ask).not.toHaveBeenCalled()
|
expect(mockCline.ask).toHaveBeenCalledTimes(1) // Early notification sent
|
||||||
expect(mockCline.diffViewProvider.open).not.toHaveBeenCalled()
|
expect(mockCline.diffViewProvider.open).not.toHaveBeenCalled()
|
||||||
|
|
||||||
// Second call with same path - path is now stabilized, file operations proceed
|
// Second call with same path - path is now stabilized, file operations proceed
|
||||||
await executeWriteFileTool({}, { isPartial: true })
|
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.open).toHaveBeenCalledWith(testFilePath)
|
||||||
expect(mockCline.diffViewProvider.update).toHaveBeenCalledWith(testContent, false)
|
expect(mockCline.diffViewProvider.update).toHaveBeenCalledWith(testContent, false)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue