More file organization for the cli (#10599)

This commit is contained in:
Chris Estreich 2026-01-09 23:22:51 -08:00 committed by GitHub
parent e8ed344b0f
commit fc21101a99
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
55 changed files with 759 additions and 1245 deletions

344
apps/cli/docs/AGENT_LOOP.md Normal file
View file

@ -0,0 +1,344 @@
# CLI Agent Loop
This document explains how the Roo Code CLI detects and tracks the agent loop state.
## Overview
The CLI needs to know when the agent is:
- **Running** (actively processing)
- **Streaming** (receiving content from the API)
- **Waiting for input** (needs user approval or answer)
- **Idle** (task completed or failed)
This is accomplished by analyzing the messages the extension sends to the client.
## The Message Model
All agent activity is communicated through **ClineMessages** - a stream of timestamped messages that represent everything the agent does.
### Message Structure
```typescript
interface ClineMessage {
ts: number // Unique timestamp identifier
type: "ask" | "say" // Message category
ask?: ClineAsk // Specific ask type (when type="ask")
say?: ClineSay // Specific say type (when type="say")
text?: string // Message content
partial?: boolean // Is this message still streaming?
}
```
### Two Types of Messages
| Type | Purpose | Blocks Agent? |
| ------- | ---------------------------------------------- | ------------- |
| **say** | Informational - agent is telling you something | No |
| **ask** | Interactive - agent needs something from you | Usually yes |
## The Key Insight
> **The agent loop stops whenever the last message is an `ask` type (with `partial: false`).**
The specific `ask` value tells you exactly what the agent needs.
## Ask Categories
The CLI categorizes asks into four groups:
### 1. Interactive Asks → `WAITING_FOR_INPUT` state
These require user action to continue:
| Ask Type | What It Means | Required Response |
| ----------------------- | --------------------------------- | ----------------- |
| `tool` | Wants to edit/create/delete files | Approve or Reject |
| `command` | Wants to run a terminal command | Approve or Reject |
| `followup` | Asking a question | Text answer |
| `browser_action_launch` | Wants to use the browser | Approve or Reject |
| `use_mcp_server` | Wants to use an MCP server | Approve or Reject |
### 2. Idle Asks → `IDLE` state
These indicate the task has stopped:
| Ask Type | What It Means | Response Options |
| ------------------------------- | --------------------------- | --------------------------- |
| `completion_result` | Task completed successfully | New task or feedback |
| `api_req_failed` | API request failed | Retry or new task |
| `mistake_limit_reached` | Too many errors | Continue anyway or new task |
| `auto_approval_max_req_reached` | Auto-approval limit hit | Continue manually or stop |
| `resume_completed_task` | Viewing completed task | New task |
### 3. Resumable Asks → `RESUMABLE` state
| Ask Type | What It Means | Response Options |
| ------------- | ------------------------- | ----------------- |
| `resume_task` | Task paused mid-execution | Resume or abandon |
### 4. Non-Blocking Asks → `RUNNING` state
| Ask Type | What It Means | Response Options |
| ---------------- | ------------------ | ----------------- |
| `command_output` | Command is running | Continue or abort |
## Streaming Detection
The agent is **streaming** when:
1. **`partial: true`** on the last message, OR
2. **An `api_req_started` message exists** with `cost: undefined` in its text field
```typescript
// Streaming detection pseudocode
function isStreaming(messages) {
const lastMessage = messages.at(-1)
// Check partial flag (primary indicator)
if (lastMessage?.partial === true) {
return true
}
// Check for in-progress API request
const apiReq = messages.findLast((m) => m.say === "api_req_started")
if (apiReq?.text) {
const data = JSON.parse(apiReq.text)
if (data.cost === undefined) {
return true // API request not yet complete
}
}
return false
}
```
## State Machine
```
┌─────────────────┐
│ NO_TASK │ (no messages)
└────────┬────────┘
│ newTask
┌─────────────────────────────┐
┌───▶│ RUNNING │◀───┐
│ └──────────┬──────────────────┘ │
│ │ │
│ ┌──────────┼──────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────┐ ┌─────────┐ ┌──────────┐ │
│ │STREAM│ │WAITING_ │ │ IDLE │ │
│ │ ING │ │FOR_INPUT│ │ │ │
│ └──┬───┘ └────┬────┘ └────┬─────┘ │
│ │ │ │ │
│ │ done │ approved │ newTask │
└────┴───────────┴────────────┘ │
┌──────────────┐ │
│ RESUMABLE │────────────────────────┘
└──────────────┘ resumed
```
## Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ ExtensionHost │
│ │
│ ┌──────────────────┐ │
│ │ Extension │──── extensionWebviewMessage ─────┐ │
│ │ (Task.ts) │ │ │
│ └──────────────────┘ │ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ ExtensionClient │ │
│ │ (Single Source of Truth) │ │
│ │ │ │
│ │ ┌─────────────────┐ ┌────────────────────┐ │ │
│ │ │ MessageProcessor │───▶│ StateStore │ │ │
│ │ │ │ │ (clineMessages) │ │ │
│ │ └─────────────────┘ └────────┬───────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ detectAgentState() │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ Events: stateChange, message, waitingForInput, etc. │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │
│ │ OutputManager │ │ AskDispatcher │ │ PromptManager │ │
│ │ (stdout) │ │ (ask routing) │ │ (user input) │ │
│ └────────────────┘ └────────────────┘ └────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
## Component Responsibilities
### ExtensionClient
The **single source of truth** for agent state. It:
- Receives all messages from the extension
- Stores them in the `StateStore`
- Computes the current state via `detectAgentState()`
- Emits events when state changes
```typescript
const client = new ExtensionClient({
sendMessage: (msg) => extensionHost.sendToExtension(msg),
debug: true, // Writes to ~/.roo/cli-debug.log
})
// Query state at any time
const state = client.getAgentState()
if (state.isWaitingForInput) {
console.log(`Agent needs: ${state.currentAsk}`)
}
// Subscribe to events
client.on("waitingForInput", (event) => {
console.log(`Waiting for: ${event.ask}`)
})
```
### StateStore
Holds the `clineMessages` array and computed state:
```typescript
interface StoreState {
messages: ClineMessage[] // The raw message array
agentState: AgentStateInfo // Computed state
isInitialized: boolean // Have we received any state?
}
```
### MessageProcessor
Handles incoming messages from the extension:
- `"state"` messages → Update `clineMessages` array
- `"messageUpdated"` messages → Update single message in array
- Emits events for state transitions
### AskDispatcher
Routes asks to appropriate handlers:
- Uses type guards: `isIdleAsk()`, `isInteractiveAsk()`, etc.
- Coordinates between `OutputManager` and `PromptManager`
- In non-interactive mode (`-y` flag), auto-approves everything
### OutputManager
Handles all CLI output:
- Streams partial content with delta computation
- Tracks what's been displayed to avoid duplicates
- Writes directly to `process.stdout` (bypasses quiet mode)
### PromptManager
Handles user input:
- Yes/no prompts
- Text input prompts
- Timed prompts with auto-defaults
## Response Messages
When the agent is waiting, send these responses:
```typescript
// Approve an action (tool, command, browser, MCP)
client.sendMessage({
type: "askResponse",
askResponse: "yesButtonClicked",
})
// Reject an action
client.sendMessage({
type: "askResponse",
askResponse: "noButtonClicked",
})
// Answer a question
client.sendMessage({
type: "askResponse",
askResponse: "messageResponse",
text: "My answer here",
})
// Start a new task
client.sendMessage({
type: "newTask",
text: "Build a web app",
})
// Cancel current task
client.sendMessage({
type: "cancelTask",
})
```
## Type Guards
The CLI uses type guards from `@roo-code/types` for categorization:
```typescript
import { isIdleAsk, isInteractiveAsk, isResumableAsk, isNonBlockingAsk } from "@roo-code/types"
const ask = message.ask
if (isInteractiveAsk(ask)) {
// Needs approval: tool, command, followup, etc.
} else if (isIdleAsk(ask)) {
// Task stopped: completion_result, api_req_failed, etc.
} else if (isResumableAsk(ask)) {
// Task paused: resume_task
} else if (isNonBlockingAsk(ask)) {
// Command running: command_output
}
```
## Debug Logging
Enable with `-d` flag. Logs go to `~/.roo/cli-debug.log`:
```bash
roo -d -y -P "Build something" --no-tui
```
View logs:
```bash
tail -f ~/.roo/cli-debug.log
```
Example output:
```
[MessageProcessor] State update: {
"messageCount": 5,
"lastMessage": {
"msgType": "ask:completion_result"
},
"stateTransition": "running → idle",
"currentAsk": "completion_result",
"isWaitingForInput": true
}
[MessageProcessor] EMIT waitingForInput: { "ask": "completion_result" }
[MessageProcessor] EMIT taskCompleted: { "success": true }
```
## Summary
1. **Agent communicates via `ClineMessage` stream**
2. **Last message determines state**
3. **`ask` messages (non-partial) block the agent**
4. **Ask category determines required action**
5. **`partial: true` or `api_req_started` without cost = streaming**
6. **`ExtensionClient` is the single source of truth**

View file

@ -1,456 +0,0 @@
# Agent Loop State Detection in the Roo Code Webview Client
This document explains how the webview client detects when the agent loop has stopped and is waiting on the client to resume. This is essential knowledge for implementing an alternative client.
## Overview
The Roo Code extension uses a message-based architecture where the extension host (server) communicates with the webview client through typed messages. The agent loop state is determined by analyzing the `clineMessages` array in the extension state, specifically looking at the **last message's type and properties**.
## Architecture Diagram
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Extension Host (Server) │
│ │
│ ┌─────────────┐ ┌──────────────────────────────────────────────┐ │
│ │ Task.ts │────────▶│ RooCodeEventName events │ │
│ └─────────────┘ │ • TaskActive • TaskInteractive │ │
│ │ • TaskIdle • TaskResumable │ │
│ └──────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
│ postMessage("state")
┌─────────────────────────────────────────────────────────────────────────────┐
│ Webview Client │
│ │
│ ┌──────────────────────┐ ┌─────────────────────┐ │
│ │ ExtensionStateContext│─────▶│ ChatView.tsx │ │
│ │ clineMessages[] │ │ │ │
│ └──────────────────────┘ │ ┌───────────────┐ │ │
│ │ │lastMessage │ │ │
│ │ │ .type │ │ │
│ │ │ .ask / .say │ │ │
│ │ │ .partial │ │ │
│ │ └───────┬───────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌───────────────┐ │ │
│ │ │ State Detection│ │ │
│ │ │ Logic │ │ │
│ │ └───────┬───────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌───────────────┐ │ │
│ │ │ UI State │ │ │
│ │ │ • clineAsk │ │ │
│ │ │ • buttons │ │ │
│ │ └───────────────┘ │ │
│ └─────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
```
## Key Message Types
### ClineMessage Structure
Defined in [`packages/types/src/message.ts`](../packages/types/src/message.ts):
```typescript
interface ClineMessage {
ts: number // Timestamp identifier
type: "ask" | "say" // Message category
ask?: ClineAsk // Ask type (when type="ask")
say?: ClineSay // Say type (when type="say")
text?: string // Message content
partial?: boolean // Is streaming incomplete?
// ... other fields
}
```
## Ask Type Categories
The `ClineAsk` types are categorized into four groups that determine when the agent is waiting. These are defined in [`packages/types/src/message.ts`](../packages/types/src/message.ts):
### 1. Idle Asks - Task effectively finished
These indicate the agent loop has stopped and the task is in a terminal or error state.
```typescript
const idleAsks = [
"completion_result", // Task completed successfully
"api_req_failed", // API request failed
"resume_completed_task", // Resume a completed task
"mistake_limit_reached", // Too many errors encountered
"auto_approval_max_req_reached", // Auto-approval limit hit
] as const
```
**Helper function:** `isIdleAsk(ask: ClineAsk): boolean`
### 2. Interactive Asks - Approval needed
These indicate the agent is waiting for user approval or input to proceed.
```typescript
const interactiveAsks = [
"followup", // Follow-up question asked
"command", // Permission to execute command
"tool", // Permission for file operations
"browser_action_launch", // Permission to use browser
"use_mcp_server", // Permission for MCP server
] as const
```
**Helper function:** `isInteractiveAsk(ask: ClineAsk): boolean`
### 3. Resumable Asks - Task paused
These indicate the task is paused and can be resumed.
```typescript
const resumableAsks = ["resume_task"] as const
```
**Helper function:** `isResumableAsk(ask: ClineAsk): boolean`
### 4. Non-Blocking Asks - No actual approval needed
These are informational and don't block the agent loop.
```typescript
const nonBlockingAsks = ["command_output"] as const
```
**Helper function:** `isNonBlockingAsk(ask: ClineAsk): boolean`
## Client-Side State Detection
### ChatView State Management
The [`ChatView`](../webview-ui/src/components/chat/ChatView.tsx) component maintains several state variables:
```typescript
const [clineAsk, setClineAsk] = useState<ClineAsk | undefined>(undefined)
const [enableButtons, setEnableButtons] = useState<boolean>(false)
const [primaryButtonText, setPrimaryButtonText] = useState<string | undefined>(undefined)
const [secondaryButtonText, setSecondaryButtonText] = useState<string | undefined>(undefined)
const [sendingDisabled, setSendingDisabled] = useState(false)
```
### Detection Logic
The state is determined by a `useDeepCompareEffect` that watches `lastMessage` and `secondLastMessage`:
```typescript
useDeepCompareEffect(() => {
if (lastMessage) {
switch (lastMessage.type) {
case "ask":
const isPartial = lastMessage.partial === true
switch (lastMessage.ask) {
case "api_req_failed":
// Agent loop stopped - API failed, needs retry or new task
setSendingDisabled(true)
setClineAsk("api_req_failed")
setEnableButtons(true)
break
case "mistake_limit_reached":
// Agent loop stopped - too many errors
setSendingDisabled(false)
setClineAsk("mistake_limit_reached")
setEnableButtons(true)
break
case "followup":
// Agent loop stopped - waiting for user answer
setSendingDisabled(isPartial)
setClineAsk("followup")
setEnableButtons(true)
break
case "tool":
case "command":
case "browser_action_launch":
case "use_mcp_server":
// Agent loop stopped - waiting for approval
setSendingDisabled(isPartial)
setClineAsk(lastMessage.ask)
setEnableButtons(!isPartial)
break
case "completion_result":
// Agent loop stopped - task complete
setSendingDisabled(isPartial)
setClineAsk("completion_result")
setEnableButtons(!isPartial)
break
case "resume_task":
case "resume_completed_task":
// Agent loop stopped - task paused/completed
setSendingDisabled(false)
setClineAsk(lastMessage.ask)
setEnableButtons(true)
break
}
break
}
}
}, [lastMessage, secondLastMessage])
```
### Streaming Detection
To determine if the agent is still streaming a response:
```typescript
const isStreaming = useMemo(() => {
// Check if current ask has buttons visible
const isLastAsk = !!modifiedMessages.at(-1)?.ask
const isToolCurrentlyAsking =
isLastAsk && clineAsk !== undefined && enableButtons && primaryButtonText !== undefined
if (isToolCurrentlyAsking) return false
// Check if message is partial (still streaming)
const isLastMessagePartial = modifiedMessages.at(-1)?.partial === true
if (isLastMessagePartial) return true
// Check if last API request finished (has cost)
const lastApiReqStarted = findLast(modifiedMessages, (m) => m.say === "api_req_started")
if (lastApiReqStarted?.text) {
const cost = JSON.parse(lastApiReqStarted.text).cost
if (cost === undefined) return true // Still streaming
}
return false
}, [modifiedMessages, clineAsk, enableButtons, primaryButtonText])
```
## Implementing State Detection in an Alternative Client
### Step 1: Subscribe to State Updates
```typescript
// Listen for state messages from extension
window.addEventListener("message", (event) => {
const message = event.data
if (message.type === "state") {
const clineMessages = message.state.clineMessages
detectAgentState(clineMessages)
}
})
```
### Step 2: Detect Agent State
```typescript
type AgentLoopState =
| "running" // Agent is actively processing
| "streaming" // Agent is streaming a response
| "interactive" // Waiting for tool/command approval
| "followup" // Waiting for user to answer a question
| "idle" // Task completed or errored out
| "resumable" // Task paused, can be resumed
function detectAgentState(messages: ClineMessage[]): AgentLoopState {
const lastMessage = messages.at(-1)
if (!lastMessage) return "running"
// Check if still streaming
if (lastMessage.partial === true) {
return "streaming"
}
// Check if it's an ask message
if (lastMessage.type === "ask" && lastMessage.ask) {
const ask = lastMessage.ask
// Idle states - task effectively stopped
if (
[
"completion_result",
"api_req_failed",
"resume_completed_task",
"mistake_limit_reached",
"auto_approval_max_req_reached",
].includes(ask)
) {
return "idle"
}
// Resumable state
if (ask === "resume_task") {
return "resumable"
}
// Follow-up question
if (ask === "followup") {
return "followup"
}
// Interactive approval needed
if (["command", "tool", "browser_action_launch", "use_mcp_server"].includes(ask)) {
return "interactive"
}
// Non-blocking (command_output)
if (ask === "command_output") {
return "running" // Can proceed or interrupt
}
}
// Check for API request in progress
const lastApiReq = messages.findLast((m) => m.say === "api_req_started")
if (lastApiReq?.text) {
try {
const data = JSON.parse(lastApiReq.text)
if (data.cost === undefined) {
return "streaming"
}
} catch {}
}
return "running"
}
```
### Step 3: Respond to Agent State
```typescript
// Send response back to extension
function respondToAsk(response: ClineAskResponse, text?: string, images?: string[]) {
vscode.postMessage({
type: "askResponse",
askResponse: response, // "yesButtonClicked" | "noButtonClicked" | "messageResponse"
text,
images,
})
}
// Start a new task
function startNewTask(text: string, images?: string[]) {
vscode.postMessage({
type: "newTask",
text,
images,
})
}
// Clear current task
function clearTask() {
vscode.postMessage({ type: "clearTask" })
}
// Cancel streaming task
function cancelTask() {
vscode.postMessage({ type: "cancelTask" })
}
// Terminal operations for command_output
function terminalOperation(operation: "continue" | "abort") {
vscode.postMessage({ type: "terminalOperation", terminalOperation: operation })
}
```
## Response Actions by State
| State | Primary Action | Secondary Action |
| ----------------------- | ---------------------------- | -------------------------- |
| `api_req_failed` | Retry (`yesButtonClicked`) | New Task (`clearTask`) |
| `mistake_limit_reached` | Proceed (`yesButtonClicked`) | New Task (`clearTask`) |
| `followup` | Answer (`messageResponse`) | - |
| `tool` | Approve (`yesButtonClicked`) | Reject (`noButtonClicked`) |
| `command` | Run (`yesButtonClicked`) | Reject (`noButtonClicked`) |
| `browser_action_launch` | Approve (`yesButtonClicked`) | Reject (`noButtonClicked`) |
| `use_mcp_server` | Approve (`yesButtonClicked`) | Reject (`noButtonClicked`) |
| `completion_result` | New Task (`clearTask`) | - |
| `resume_task` | Resume (`yesButtonClicked`) | Terminate (`clearTask`) |
| `resume_completed_task` | New Task (`clearTask`) | - |
| `command_output` | Proceed (`continue`) | Kill (`abort`) |
## Extension-Side Event Emission
The extension emits task state events from [`src/core/task/Task.ts`](../src/core/task/Task.ts):
```
┌─────────────────┐
│ Task Started │
└────────┬────────┘
┌─────────────────┐
┌────▶│ TaskActive │◀────┐
│ └────────┬────────┘ │
│ │ │
│ ┌─────────┼─────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌───┐ ┌───────┐ ┌─────┐ │
│ │Idle│ │Interact│ │Resume│ │
│ │Ask │ │iveAsk │ │ableAsk│ │
│ └─┬──┘ └───┬───┘ └──┬──┘ │
│ │ │ │ │
│ ▼ │ │ │
│ ┌──────┐ │ │ │
│ │TaskIdle│ │ │ │
│ └──────┘ │ │ │
│ ▼ │ │
│ ┌───────────────┐ │ │
│ │TaskInteractive│ │ │
│ └───────┬───────┘ │ │
│ │ │ │
│ │ User │ │
│ │ approves│ │
│ │ ▼ │
│ │ ┌───────────┐
│ │ │TaskResumable│
│ │ └─────┬─────┘
│ │ │
│ │ User │
│ │ resumes│
│ │ │
└──────────────┴────────┘
```
The extension uses helper functions to categorize asks and emit the appropriate events:
- `isInteractiveAsk()` → emits `TaskInteractive`
- `isIdleAsk()` → emits `TaskIdle`
- `isResumableAsk()` → emits `TaskResumable`
## WebviewMessage Types for Responses
When responding to asks, use the appropriate `WebviewMessage` type (defined in [`packages/types/src/vscode-extension-host.ts`](../packages/types/src/vscode-extension-host.ts)):
```typescript
interface WebviewMessage {
type:
| "askResponse" // Respond to an ask
| "newTask" // Start a new task
| "clearTask" // Clear/end current task
| "cancelTask" // Cancel running task
| "terminalOperation" // Control terminal output
// ... many other types
askResponse?: ClineAskResponse // "yesButtonClicked" | "noButtonClicked" | "messageResponse" | "objectResponse"
text?: string
images?: string[]
terminalOperation?: "continue" | "abort"
}
```
## Summary
To correctly detect when the agent loop has stopped in an alternative client:
1. **Monitor `clineMessages`** from state updates
2. **Check the last message's `type` and `ask`/`say` properties**
3. **Check `partial` flag** to detect streaming
4. **For API request status**, parse the `api_req_started` message's `text` field and check if `cost` is defined
5. **Use the ask category functions** (`isIdleAsk`, `isInteractiveAsk`, etc.) to determine the appropriate UI state
6. **Respond with the correct `askResponse` type** based on user action
The key insight is that the agent loop stops whenever a message with `type: "ask"` arrives, and the specific `ask` value determines what kind of response the agent is waiting for.

View file

@ -4,19 +4,17 @@
* These tests require:
* 1. RUN_CLI_INTEGRATION_TESTS=true environment variable (opt-in)
* 2. A valid OPENROUTER_API_KEY environment variable
* 3. A built extension at src/dist
* 4. ripgrep binary available (vscode-ripgrep or system ripgrep)
* 3. A built CLI at apps/cli/dist (will auto-build if missing)
* 4. A built extension at src/dist (will auto-build if missing)
*
* Run with: RUN_CLI_INTEGRATION_TESTS=true OPENROUTER_API_KEY=sk-or-v1-... pnpm test
*/
// pnpm --filter @roo-code/cli test src/__tests__/index.test.ts
import { ExtensionHost } from "../extension-host/extension-host.js"
import path from "path"
import fs from "fs"
import os from "os"
import { execSync } from "child_process"
import { execSync, spawn, type ChildProcess } from "child_process"
import { fileURLToPath } from "url"
const __filename = fileURLToPath(import.meta.url)
@ -26,161 +24,103 @@ const RUN_INTEGRATION_TESTS = process.env.RUN_CLI_INTEGRATION_TESTS === "true"
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY
const hasApiKey = !!OPENROUTER_API_KEY
// Find the extension path - we need a built extension for integration tests
function findExtensionPath(): string | null {
// From apps/cli/src/__tests__, go up to monorepo root then to src/dist
const monorepoPath = path.resolve(__dirname, "../../../../src/dist")
if (fs.existsSync(path.join(monorepoPath, "extension.js"))) {
return monorepoPath
}
// Also try from the apps/cli level
const altPath = path.resolve(__dirname, "../../../src/dist")
if (fs.existsSync(path.join(altPath, "extension.js"))) {
return altPath
}
return null
function findCliRoot(): string {
// From apps/cli/src/__tests__, go up to apps/cli.
return path.resolve(__dirname, "../..")
}
// Check if ripgrep is available (required by the extension for file listing)
function hasRipgrep(): boolean {
try {
// Try vscode-ripgrep first (installed as dependency)
const vscodeRipgrepPath = path.resolve(__dirname, "../../../../node_modules/@vscode/ripgrep/bin/rg")
if (fs.existsSync(vscodeRipgrepPath)) {
return true
}
// Try system ripgrep
execSync("rg --version", { stdio: "ignore" })
return true
} catch {
return false
function findMonorepoRoot(): string {
// From apps/cli/src/__tests__, go up to monorepo root.
return path.resolve(__dirname, "../../../..")
}
function isCliBuilt(): boolean {
return fs.existsSync(path.join(findCliRoot(), "dist", "index.js"))
}
function isExtensionBuilt(): boolean {
const monorepoRoot = findMonorepoRoot()
const extensionPath = path.join(monorepoRoot, "src/dist")
return fs.existsSync(path.join(extensionPath, "extension.js"))
}
function buildCliIfNeeded(): void {
if (!isCliBuilt()) {
execSync("pnpm build", { cwd: findCliRoot(), stdio: "inherit" })
console.log("CLI build complete.")
}
}
const extensionPath = findExtensionPath()
const hasExtension = !!extensionPath
const ripgrepAvailable = hasRipgrep()
// Create a temporary workspace directory for tests
function createTempWorkspace(): string {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "roo-cli-test-"))
return tempDir
}
// Clean up temporary workspace
function cleanupWorkspace(workspacePath: string): void {
try {
fs.rmSync(workspacePath, { recursive: true, force: true })
} catch {
// Ignore cleanup errors
function buildExtensionIfNeeded(): void {
if (!isExtensionBuilt()) {
execSync("pnpm --filter roo-cline bundle", { cwd: findMonorepoRoot(), stdio: "inherit" })
console.log("Extension build complete.")
}
}
describe.skipIf(!RUN_INTEGRATION_TESTS || !hasApiKey || !hasExtension || !ripgrepAvailable)(
"CLI Integration Tests (requires RUN_CLI_INTEGRATION_TESTS=true, OPENROUTER_API_KEY, built extension, and ripgrep)",
() => {
let workspacePath: string
let host: ExtensionHost
function runCli(
args: string[],
options: { timeout?: number } = {},
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
return new Promise((resolve) => {
const timeout = options.timeout ?? 60000
beforeAll(() => {
console.log("Integration tests running with:")
console.log(` - API Key: ${OPENROUTER_API_KEY?.substring(0, 12)}...`)
console.log(` - Extension Path: ${extensionPath}`)
let stdout = ""
let stderr = ""
let timedOut = false
const proc: ChildProcess = spawn("pnpm", ["start", ...args], {
cwd: findCliRoot(),
env: { ...process.env, OPENROUTER_API_KEY, NO_COLOR: "1", FORCE_COLOR: "0" },
stdio: ["pipe", "pipe", "pipe"],
})
beforeEach(() => {
workspacePath = createTempWorkspace()
const timeoutId = setTimeout(() => {
timedOut = true
proc.kill("SIGTERM")
}, timeout)
proc.stdout?.on("data", (data: Buffer) => {
stdout += data.toString()
})
afterEach(async () => {
if (host) {
await host.dispose()
}
cleanupWorkspace(workspacePath)
proc.stderr?.on("data", (data: Buffer) => {
stderr += data.toString()
})
/**
* Main integration test - tests the complete end-to-end flow
*
* NOTE: Due to the extension using singletons (TelemetryService, etc.),
* only one integration test can run per process. This single test covers
* the main functionality: activation, task execution, completion, and disposal.
*/
it("should complete end-to-end task execution with proper lifecycle", async () => {
host = new ExtensionHost({
mode: "code",
user: null,
provider: "openrouter",
apiKey: OPENROUTER_API_KEY!,
model: "anthropic/claude-haiku-4.5", // Use fast, cheap model for tests.
workspacePath,
extensionPath: extensionPath!,
})
proc.on("close", (code: number | null) => {
clearTimeout(timeoutId)
resolve({ stdout, stderr, exitCode: timedOut ? -1 : (code ?? 1) })
})
// Test activation
await host.activate()
proc.on("error", (error: Error) => {
clearTimeout(timeoutId)
stderr += error.message
resolve({ stdout, stderr, exitCode: 1 })
})
})
}
// Track state messages
const stateMessages: unknown[] = []
host.on("extensionWebviewMessage", (msg: Record<string, unknown>) => {
if (msg.type === "state") {
stateMessages.push(msg)
}
})
// Test task execution with completion
// Note: runTask internally waits for webview to be ready before sending messages
await expect(host.runTask("Say hello in exactly 5 words")).resolves.toBeUndefined()
// After task completes, webview should have been ready
expect(host.isInInitialSetup()).toBe(false)
// Verify we received state updates
expect(stateMessages.length).toBeGreaterThan(0)
// Test disposal
await host.dispose()
expect((global as Record<string, unknown>).vscode).toBeUndefined()
expect((global as Record<string, unknown>).__extensionHost).toBeUndefined()
}, 120000) // 2 minute timeout
},
)
// Additional test to verify skip behavior
describe("Integration test skip behavior", () => {
it("should require RUN_CLI_INTEGRATION_TESTS=true", () => {
if (RUN_INTEGRATION_TESTS) {
console.log("RUN_CLI_INTEGRATION_TESTS=true, integration tests are enabled")
} else {
console.log("RUN_CLI_INTEGRATION_TESTS is not set to 'true', integration tests will be skipped")
}
expect(true).toBe(true) // Always passes
describe.skipIf(!RUN_INTEGRATION_TESTS || !hasApiKey)("CLI Integration Tests", () => {
beforeAll(() => {
buildExtensionIfNeeded()
buildCliIfNeeded()
})
it("should have OPENROUTER_API_KEY check", () => {
if (hasApiKey) {
console.log("OPENROUTER_API_KEY is set")
} else {
console.log("OPENROUTER_API_KEY is not set, integration tests will be skipped")
}
expect(true).toBe(true) // Always passes
})
it("should complete end-to-end task execution via CLI", async () => {
const result = await runCli(
["--no-tui", "-m", "anthropic/claude-sonnet-4.5", "-M", "ask", "-r", "disabled", "-P", "1+1=?"],
{ timeout: 30_000 },
)
it("should have extension check", () => {
if (hasExtension) {
console.log(`Extension found at: ${extensionPath}`)
} else {
console.log("Extension not found, integration tests will be skipped")
}
expect(true).toBe(true) // Always passes
})
console.log("CLI stdout:", result.stdout)
it("should have ripgrep check", () => {
if (ripgrepAvailable) {
console.log("ripgrep is available")
} else {
console.log("ripgrep not found, integration tests will be skipped")
if (result.stderr) {
console.log("CLI stderr:", result.stderr)
}
expect(true).toBe(true) // Always passes
})
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain("2")
expect(result.stdout).toContain("[task complete]")
}, 30_000)
})

View file

@ -1,50 +1,23 @@
/**
* Tests for the Roo Code Client
*
* These tests verify:
* - State detection logic
* - Event emission
* - Response sending
* - State transitions
*/
import {
type ClineMessage,
type ExtensionMessage,
createMockClient,
AgentLoopState,
detectAgentState,
isIdleAsk,
isResumableAsk,
isInteractiveAsk,
isNonBlockingAsk,
} from "./index.js"
} from "@roo-code/types"
// =============================================================================
// Test Helpers
// =============================================================================
import { AgentLoopState, detectAgentState } from "../agent-state.js"
import { createMockClient } from "../extension-client.js"
function createMessage(overrides: Partial<ClineMessage>): ClineMessage {
return {
ts: Date.now() + Math.random() * 1000, // Unique timestamp
type: "say",
...overrides,
}
return { ts: Date.now() + Math.random() * 1000, type: "say", ...overrides }
}
function createStateMessage(messages: ClineMessage[]): ExtensionMessage {
return {
type: "state",
state: {
clineMessages: messages,
},
}
return { type: "state", state: { clineMessages: messages } } as ExtensionMessage
}
// =============================================================================
// State Detection Tests
// =============================================================================
describe("detectAgentState", () => {
describe("NO_TASK state", () => {
it("should return NO_TASK for empty messages array", () => {
@ -73,7 +46,7 @@ describe("detectAgentState", () => {
const messages = [
createMessage({
say: "api_req_started",
text: JSON.stringify({ tokensIn: 100 }), // No cost field
text: JSON.stringify({ tokensIn: 100 }), // No cost field.
}),
]
const state = detectAgentState(messages)
@ -207,10 +180,6 @@ describe("detectAgentState", () => {
})
})
// =============================================================================
// Type Guard Tests
// =============================================================================
describe("Type Guards", () => {
describe("isIdleAsk", () => {
it("should return true for idle asks", () => {
@ -266,10 +235,6 @@ describe("Type Guards", () => {
})
})
// =============================================================================
// ExtensionClient Tests
// =============================================================================
describe("ExtensionClient", () => {
describe("State queries", () => {
it("should return NO_TASK when not initialized", () => {
@ -333,7 +298,7 @@ describe("ExtensionClient", () => {
unsubscribe()
client.handleMessage(createStateMessage([createMessage({ say: "text", ts: Date.now() + 1 })]))
expect(callCount).toBe(1) // Should not increase
expect(callCount).toBe(1) // Should not increase.
})
})
@ -461,14 +426,14 @@ describe("ExtensionClient", () => {
it("should handle messageUpdated messages", () => {
const { client } = createMockClient()
// First, set initial state
// First, set initial state.
client.handleMessage(
createStateMessage([createMessage({ ts: 123, type: "ask", ask: "tool", partial: true })]),
)
expect(client.isStreaming()).toBe(true)
// Now update the message
// Now update the message.
client.handleMessage({
type: "messageUpdated",
clineMessage: createMessage({ ts: 123, type: "ask", ask: "tool", partial: false }),
@ -496,10 +461,6 @@ describe("ExtensionClient", () => {
})
})
// =============================================================================
// Integration Tests
// =============================================================================
describe("Integration", () => {
it("should handle a complete task flow", () => {
const { client } = createMockClient()
@ -509,18 +470,18 @@ describe("Integration", () => {
states.push(event.currentState.state)
})
// 1. Task starts, API request begins
// 1. Task starts, API request begins.
client.handleMessage(
createStateMessage([
createMessage({
say: "api_req_started",
text: JSON.stringify({}), // No cost = streaming
text: JSON.stringify({}), // No cost = streaming.
}),
]),
)
expect(client.isStreaming()).toBe(true)
// 2. API request completes
// 2. API request completes.
client.handleMessage(
createStateMessage([
createMessage({
@ -533,7 +494,7 @@ describe("Integration", () => {
expect(client.isStreaming()).toBe(false)
expect(client.isRunning()).toBe(true)
// 3. Tool ask (partial)
// 3. Tool ask (partial).
client.handleMessage(
createStateMessage([
createMessage({
@ -546,7 +507,7 @@ describe("Integration", () => {
)
expect(client.isStreaming()).toBe(true)
// 4. Tool ask (complete)
// 4. Tool ask (complete).
client.handleMessage(
createStateMessage([
createMessage({
@ -560,7 +521,7 @@ describe("Integration", () => {
expect(client.isWaitingForInput()).toBe(true)
expect(client.getCurrentAsk()).toBe("tool")
// 5. User approves, task completes
// 5. User approves, task completes.
client.handleMessage(
createStateMessage([
createMessage({
@ -576,7 +537,7 @@ describe("Integration", () => {
expect(client.getCurrentState()).toBe(AgentLoopState.IDLE)
expect(client.getCurrentAsk()).toBe("completion_result")
// Verify we saw the expected state transitions
// Verify we saw the expected state transitions.
expect(states).toContain(AgentLoopState.STREAMING)
expect(states).toContain(AgentLoopState.RUNNING)
expect(states).toContain(AgentLoopState.WAITING_FOR_INPUT)
@ -584,15 +545,11 @@ describe("Integration", () => {
})
})
// =============================================================================
// Edge Case Tests
// =============================================================================
describe("Edge Cases", () => {
describe("Messages with missing or empty text field", () => {
it("should handle ask message with missing text field", () => {
const messages = [createMessage({ type: "ask", ask: "tool", partial: false })]
// text is undefined by default
// Text is undefined by default.
const state = detectAgentState(messages)
expect(state.state).toBe(AgentLoopState.WAITING_FOR_INPUT)
expect(state.currentAsk).toBe("tool")
@ -616,8 +573,8 @@ describe("Edge Cases", () => {
it("should handle api_req_started with empty text field as streaming", () => {
const messages = [createMessage({ say: "api_req_started", text: "" })]
const state = detectAgentState(messages)
// Empty text is treated as "no text yet" = still in progress (streaming)
// This matches the behavior: !message.text is true for "" (falsy)
// Empty text is treated as "no text yet" = still in progress (streaming).
// This matches the behavior: !message.text is true for "" (falsy).
expect(state.state).toBe(AgentLoopState.STREAMING)
expect(state.isStreaming).toBe(true)
})
@ -625,7 +582,7 @@ describe("Edge Cases", () => {
it("should handle api_req_started with invalid JSON", () => {
const messages = [createMessage({ say: "api_req_started", text: "not valid json" })]
const state = detectAgentState(messages)
// Invalid JSON should not crash, should return not streaming
// Invalid JSON should not crash, should return not streaming.
expect(state.state).toBe(AgentLoopState.RUNNING)
expect(state.isStreaming).toBe(false)
})
@ -633,7 +590,7 @@ describe("Edge Cases", () => {
it("should handle api_req_started with null text", () => {
const messages = [createMessage({ say: "api_req_started", text: undefined })]
const state = detectAgentState(messages)
// No text means still in progress (streaming)
// No text means still in progress (streaming).
expect(state.state).toBe(AgentLoopState.STREAMING)
expect(state.isStreaming).toBe(true)
})
@ -641,7 +598,7 @@ describe("Edge Cases", () => {
it("should handle api_req_started with cost of 0", () => {
const messages = [createMessage({ say: "api_req_started", text: JSON.stringify({ cost: 0 }) })]
const state = detectAgentState(messages)
// cost: 0 is defined (not undefined), so NOT streaming
// cost: 0 is defined (not undefined), so NOT streaming.
expect(state.state).toBe(AgentLoopState.RUNNING)
expect(state.isStreaming).toBe(false)
})
@ -649,7 +606,7 @@ describe("Edge Cases", () => {
it("should handle api_req_started with cost of null", () => {
const messages = [createMessage({ say: "api_req_started", text: JSON.stringify({ cost: null }) })]
const state = detectAgentState(messages)
// cost: null is defined (not undefined), so NOT streaming
// cost: null is defined (not undefined), so NOT streaming.
expect(state.state).toBe(AgentLoopState.RUNNING)
expect(state.isStreaming).toBe(false)
})
@ -660,7 +617,7 @@ describe("Edge Cases", () => {
createMessage({ say: "text", text: "Some text" }),
]
const state = detectAgentState(messages)
// Last message is say:text, but api_req_started has no cost
// Last message is say:text, but api_req_started has no cost.
expect(state.state).toBe(AgentLoopState.STREAMING)
expect(state.isStreaming).toBe(true)
})
@ -675,7 +632,7 @@ describe("Edge Cases", () => {
states.push(event.currentState.state)
})
// Rapid updates
// Rapid updates.
client.handleMessage(createStateMessage([createMessage({ say: "text" })]))
client.handleMessage(createStateMessage([createMessage({ type: "ask", ask: "tool", partial: true })]))
client.handleMessage(createStateMessage([createMessage({ type: "ask", ask: "tool", partial: false })]))
@ -683,7 +640,7 @@ describe("Edge Cases", () => {
createStateMessage([createMessage({ type: "ask", ask: "completion_result", partial: false })]),
)
// Should have tracked all transitions
// Should have tracked all transitions.
expect(states.length).toBeGreaterThanOrEqual(3)
expect(states).toContain(AgentLoopState.STREAMING)
expect(states).toContain(AgentLoopState.WAITING_FOR_INPUT)
@ -701,24 +658,26 @@ describe("Edge Cases", () => {
})
it("should use last message for state detection", () => {
// Multiple messages, last one determines state
// Multiple messages, last one determines state.
const messages = [
createMessage({ type: "ask", ask: "tool", partial: false }),
createMessage({ say: "text", text: "Tool executed" }),
createMessage({ type: "ask", ask: "completion_result", partial: false }),
]
const state = detectAgentState(messages)
// Last message is completion_result, so IDLE
// Last message is completion_result, so IDLE.
expect(state.state).toBe(AgentLoopState.IDLE)
expect(state.currentAsk).toBe("completion_result")
})
it("should handle very long message arrays", () => {
// Create many messages
// Create many messages.
const messages: ClineMessage[] = []
for (let i = 0; i < 100; i++) {
messages.push(createMessage({ say: "text", text: `Message ${i}` }))
}
messages.push(createMessage({ type: "ask", ask: "followup", partial: false }))
const state = detectAgentState(messages)
@ -730,14 +689,7 @@ describe("Edge Cases", () => {
describe("State message edge cases", () => {
it("should handle state message with empty clineMessages", () => {
const { client } = createMockClient()
client.handleMessage({
type: "state",
state: {
clineMessages: [],
},
})
client.handleMessage({ type: "state", state: { clineMessages: [] } } as unknown as ExtensionMessage)
expect(client.getCurrentState()).toBe(AgentLoopState.NO_TASK)
expect(client.isInitialized()).toBe(true)
})
@ -751,7 +703,7 @@ describe("Edge Cases", () => {
state: {} as any,
})
// Should not crash, state should remain unchanged
// Should not crash, state should remain unchanged.
expect(client.getCurrentState()).toBe(AgentLoopState.NO_TASK)
})
@ -795,7 +747,7 @@ describe("Edge Cases", () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const messages = [createMessage({ type: "ask", ask: "unknown_type" as any, partial: false })]
const state = detectAgentState(messages)
// Unknown ask type should default to RUNNING
// Unknown ask type should default to RUNNING.
expect(state.state).toBe(AgentLoopState.RUNNING)
})

View file

@ -1,4 +1,4 @@
// pnpm --filter @roo-code/cli test src/extension-host/__tests__/extension-host.test.ts
// pnpm --filter @roo-code/cli test src/agent/__tests__/extension-host.test.ts
import { EventEmitter } from "events"
import fs from "fs"
@ -7,7 +7,6 @@ import path from "path"
import type { WebviewMessage } from "@roo-code/types"
import type { SupportedProvider } from "../../types/index.js"
import { type ExtensionHostOptions, ExtensionHost } from "../extension-host.js"
vi.mock("@roo-code/vscode-shim", () => ({
@ -18,7 +17,7 @@ vi.mock("@roo-code/vscode-shim", () => ({
}))
/**
* Create a test ExtensionHost with default options
* Create a test ExtensionHost with default options.
*/
function createTestHost({
mode = "code",
@ -115,84 +114,6 @@ describe("ExtensionHost", () => {
})
})
describe("buildApiConfiguration", () => {
it.each([
[
"anthropic",
"test-key",
"test-model",
{ apiProvider: "anthropic", apiKey: "test-key", apiModelId: "test-model" },
],
[
"openrouter",
"or-key",
"or-model",
{
apiProvider: "openrouter",
openRouterApiKey: "or-key",
openRouterModelId: "or-model",
},
],
[
"gemini",
"gem-key",
"gem-model",
{ apiProvider: "gemini", geminiApiKey: "gem-key", apiModelId: "gem-model" },
],
[
"openai-native",
"oai-key",
"oai-model",
{ apiProvider: "openai-native", openAiNativeApiKey: "oai-key", apiModelId: "oai-model" },
],
[
"vercel-ai-gateway",
"vai-key",
"vai-model",
{
apiProvider: "vercel-ai-gateway",
vercelAiGatewayApiKey: "vai-key",
vercelAiGatewayModelId: "vai-model",
},
],
])("should configure %s provider correctly", (provider, apiKey, model, expected) => {
const host = createTestHost({
provider: provider as SupportedProvider,
apiKey,
model,
})
const config = callPrivate<Record<string, unknown>>(host, "buildApiConfiguration")
expect(config).toEqual(expected)
})
it("should use default provider when not specified", () => {
const host = createTestHost({
apiKey: "test-key",
model: "test-model",
})
const config = callPrivate<Record<string, unknown>>(host, "buildApiConfiguration")
expect(config.apiProvider).toBe("openrouter")
})
it("should handle missing apiKey gracefully", () => {
const host = createTestHost({
provider: "anthropic",
model: "test-model",
})
const config = callPrivate<Record<string, unknown>>(host, "buildApiConfiguration")
expect(config.apiProvider).toBe("anthropic")
expect(config.apiKey).toBeUndefined()
expect(config.apiModelId).toBe("test-model")
})
})
describe("webview provider registration", () => {
it("should register webview provider", () => {
const host = createTestHost()

View file

@ -9,11 +9,7 @@
* and the specific `ask` value determines what kind of response the agent is waiting for.
*/
import type { ClineMessage, ClineAsk, ApiReqStartedText } from "./types.js"
import { isIdleAsk, isResumableAsk, isInteractiveAsk, isNonBlockingAsk } from "./types.js"
// Re-export the type guards for convenience
export { isIdleAsk, isResumableAsk, isInteractiveAsk, isNonBlockingAsk }
import { ClineMessage, ClineAsk, isIdleAsk, isResumableAsk, isInteractiveAsk, isNonBlockingAsk } from "@roo-code/types"
// =============================================================================
// Agent Loop State Enum
@ -30,9 +26,9 @@ export { isIdleAsk, isResumableAsk, isInteractiveAsk, isNonBlockingAsk }
* newTask
*
*
* RUNNING
*
*
* RUNNING
*
*
*
*
*
@ -166,6 +162,18 @@ export interface AgentStateInfo {
// State Detection Functions
// =============================================================================
/**
* Structure of the text field in api_req_started messages.
* Used to determine if the API request has completed (cost is defined).
*/
export interface ApiReqStartedText {
cost?: number // Undefined while streaming, defined when complete.
tokensIn?: number
tokensOut?: number
cacheWrites?: number
cacheReads?: number
}
/**
* Check if an API request is still in progress (streaming).
*
@ -176,22 +184,27 @@ export interface AgentStateInfo {
* Once the request completes, the cost field will be populated.
*/
function isApiRequestInProgress(messages: ClineMessage[]): boolean {
// Find the last api_req_started message
// Using reverse iteration for efficiency (most recent first)
// Find the last api_req_started message.
// Using reverse iteration for efficiency (most recent first).
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i]
if (!message) continue
if (!message) {
continue
}
if (message.say === "api_req_started") {
if (!message.text) {
// No text yet means still in progress
// No text yet means still in progress.
return true
}
try {
const data: ApiReqStartedText = JSON.parse(message.text)
// cost is undefined while streaming, defined when complete
// cost is undefined while streaming, defined when complete.
return data.cost === undefined
} catch {
// Parse error - assume not in progress
// Parse error - assume not in progress.
return false
}
}

View file

@ -14,13 +14,22 @@
* - Sends responses back through a provided callback
*/
import {
type WebviewMessage,
type ClineMessage,
type ClineAsk,
type ClineAskResponse,
isIdleAsk,
isInteractiveAsk,
isResumableAsk,
isNonBlockingAsk,
} from "@roo-code/types"
import { debugLog } from "@roo-code/core/cli"
import type { WebviewMessage, ClineMessage, ClineAsk, ClineAskResponse } from "../extension-client/types.js"
import { isIdleAsk, isInteractiveAsk, isResumableAsk, isNonBlockingAsk } from "../extension-client/index.js"
import { FOLLOWUP_TIMEOUT_SECONDS } from "@/types/index.js"
import type { OutputManager } from "./output-manager.js"
import type { PromptManager } from "./prompt-manager.js"
import { FOLLOWUP_TIMEOUT_SECONDS } from "../types/constants.js"
// =============================================================================
// Types

View file

@ -7,8 +7,10 @@
*/
import { EventEmitter } from "events"
import { ClineMessage, ClineAsk } from "@roo-code/types"
import type { AgentStateInfo } from "./agent-state.js"
import type { ClineMessage, ClineAsk } from "./types.js"
// =============================================================================
// Event Types

View file

@ -27,8 +27,10 @@
* ```
*/
import type { ExtensionMessage, WebviewMessage, ClineAskResponse, ClineMessage, ClineAsk } from "@roo-code/types"
import { StateStore } from "./state-store.js"
import { MessageProcessor, MessageProcessorOptions, parseExtensionMessage } from "./message-processor.js"
import { MessageProcessor, parseExtensionMessage } from "./message-processor.js"
import {
TypedEventEmitter,
type ClientEventMap,
@ -36,10 +38,9 @@ import {
type WaitingForInputEvent,
} from "./events.js"
import { AgentLoopState, type AgentStateInfo } from "./agent-state.js"
import type { ExtensionMessage, WebviewMessage, ClineAskResponse, ClineMessage, ClineAsk } from "./types.js"
// =============================================================================
// Client Configuration
// Extension Client Configuration
// =============================================================================
/**
@ -126,19 +127,13 @@ export class ExtensionClient {
constructor(config: ExtensionClientConfig) {
this.sendMessage = config.sendMessage
this.debug = config.debug ?? false
// Initialize components
this.store = new StateStore({
maxHistorySize: config.maxHistorySize ?? 0,
})
this.store = new StateStore({ maxHistorySize: config.maxHistorySize ?? 0 })
this.emitter = new TypedEventEmitter()
const processorOptions: MessageProcessorOptions = {
this.processor = new MessageProcessor(this.store, this.emitter, {
emitAllStateChanges: config.emitAllStateChanges ?? true,
debug: config.debug ?? false,
}
this.processor = new MessageProcessor(this.store, this.emitter, processorOptions)
})
}
// ===========================================================================

View file

@ -21,31 +21,28 @@ import { fileURLToPath } from "url"
import fs from "fs"
import os from "os"
import { ReasoningEffortExtended, RooCodeSettings, WebviewMessage } from "@roo-code/types"
import type {
ClineMessage,
ExtensionMessage,
ReasoningEffortExtended,
RooCodeSettings,
WebviewMessage,
} from "@roo-code/types"
import { createVSCodeAPI, setRuntimeConfigValues } from "@roo-code/vscode-shim"
import { DebugLogger } from "@roo-code/core/cli"
import { SupportedProvider } from "../types/types.js"
import { User } from "../lib/sdk/types.js"
import type { SupportedProvider } from "@/types/index.js"
import type { User } from "@/lib/sdk/index.js"
import { getProviderSettings } from "@/lib/utils/provider.js"
// Client module - single source of truth for agent state
import {
type AgentStateInfo,
type AgentStateChangeEvent,
type WaitingForInputEvent,
type TaskCompletedEvent,
type ClineMessage,
type ExtensionMessage,
ExtensionClient,
AgentLoopState,
} from "../extension-client/index.js"
// Managers for output, prompting, and ask handling
import type { AgentStateChangeEvent, WaitingForInputEvent, TaskCompletedEvent } from "./events.js"
import { type AgentStateInfo, AgentLoopState } from "./agent-state.js"
import { ExtensionClient } from "./extension-client.js"
import { OutputManager } from "./output-manager.js"
import { PromptManager } from "./prompt-manager.js"
import { AskDispatcher } from "./ask-dispatcher.js"
// Pre-configured logger for CLI message activity debugging
// Pre-configured logger for CLI message activity debugging.
const cliLogger = new DebugLogger("CLI")
// Get the CLI package root directory (for finding node_modules/@vscode/ripgrep)
@ -79,6 +76,11 @@ export interface ExtensionHostOptions {
* When true, uses a temporary storage directory that is cleaned up on exit.
*/
ephemeral?: boolean
/**
* When true, don't suppress node warnings and console output since we're
* running in an integration test and we want to see the output.
*/
integrationTest?: boolean
}
interface ExtensionModule {
@ -155,36 +157,37 @@ export class ExtensionHost extends EventEmitter {
constructor(options: ExtensionHostOptions) {
super()
this.options = options
this.currentMode = options.mode || null
// Initialize client - single source of truth for agent state
// Initialize client - single source of truth for agent state.
this.client = new ExtensionClient({
sendMessage: (msg) => this.sendToExtension(msg),
debug: options.debug, // Enable debug logging in the client
debug: options.debug, // Enable debug logging in the client.
})
// Initialize output manager
// Initialize output manager.
this.outputManager = new OutputManager({
disabled: options.disableOutput,
})
// Initialize prompt manager with console mode callbacks
// Initialize prompt manager with console mode callbacks.
this.promptManager = new PromptManager({
onBeforePrompt: () => this.restoreConsole(),
onAfterPrompt: () => this.setupQuietMode(),
})
// Initialize ask dispatcher
// Initialize ask dispatcher.
this.askDispatcher = new AskDispatcher({
outputManager: this.outputManager,
promptManager: this.promptManager,
sendMessage: (msg) => this.sendToExtension(msg),
nonInteractive: options.nonInteractive,
disabled: options.disableOutput, // TUI mode handles asks directly
disabled: options.disableOutput, // TUI mode handles asks directly.
})
// Wire up client events
// Wire up client events.
this.setupClientEventHandlers()
}
@ -197,30 +200,30 @@ export class ExtensionHost extends EventEmitter {
* The client emits events, managers handle them.
*/
private setupClientEventHandlers(): void {
// Forward state changes for external consumers
// Forward state changes for external consumers.
this.client.on("stateChange", (event: AgentStateChangeEvent) => {
this.emit("agentStateChange", event)
})
// Handle new messages - delegate to OutputManager
// Handle new messages - delegate to OutputManager.
this.client.on("message", (msg: ClineMessage) => {
this.logMessageDebug(msg, "new")
this.outputManager.outputMessage(msg)
})
// Handle message updates - delegate to OutputManager
// Handle message updates - delegate to OutputManager.
this.client.on("messageUpdated", (msg: ClineMessage) => {
this.logMessageDebug(msg, "updated")
this.outputManager.outputMessage(msg)
})
// Handle waiting for input - delegate to AskDispatcher
// Handle waiting for input - delegate to AskDispatcher.
this.client.on("waitingForInput", (event: WaitingForInputEvent) => {
this.emit("agentWaitingForInput", event)
this.handleWaitingForInput(event)
this.askDispatcher.handleAsk(event.message)
})
// Handle task completion
// Handle task completion.
this.client.on("taskCompleted", (event: TaskCompletedEvent) => {
this.emit("agentTaskCompleted", event)
this.handleTaskCompleted(event)
@ -242,25 +245,17 @@ export class ExtensionHost extends EventEmitter {
}
}
/**
* Handle waiting for input - delegate to AskDispatcher.
*/
private handleWaitingForInput(event: WaitingForInputEvent): void {
// AskDispatcher handles all ask logic
this.askDispatcher.handleAsk(event.message)
}
/**
* Handle task completion.
*/
private handleTaskCompleted(event: TaskCompletedEvent): void {
// Output completion message via OutputManager
// Note: completion_result is an "ask" type, not a "say" type
// Output completion message via OutputManager.
// Note: completion_result is an "ask" type, not a "say" type.
if (event.message && event.message.type === "ask" && event.message.ask === "completion_result") {
this.outputManager.outputCompletionResult(event.message.ts, event.message.text || "")
}
// Emit taskComplete for waitForCompletion
// Emit taskComplete for waitForCompletion.
this.emit("taskComplete")
}
@ -275,6 +270,10 @@ export class ExtensionHost extends EventEmitter {
}
private setupQuietMode(): void {
if (this.options.integrationTest) {
return
}
this.originalConsole = {
log: console.log,
warn: console.warn,
@ -282,6 +281,7 @@ export class ExtensionHost extends EventEmitter {
debug: console.debug,
info: console.info,
}
console.log = () => {}
console.warn = () => {}
console.debug = () => {}
@ -289,6 +289,10 @@ export class ExtensionHost extends EventEmitter {
}
private restoreConsole(): void {
if (this.options.integrationTest) {
return
}
if (this.originalConsole) {
console.log = this.originalConsole.log
console.warn = this.originalConsole.warn
@ -320,18 +324,20 @@ export class ExtensionHost extends EventEmitter {
this.setupQuietMode()
const bundlePath = path.join(this.options.extensionPath, "extension.js")
if (!fs.existsSync(bundlePath)) {
this.restoreConsole()
throw new Error(`Extension bundle not found at: ${bundlePath}`)
}
let storageDir: string | undefined
if (this.options.ephemeral) {
storageDir = await this.createEphemeralStorageDir()
this.ephemeralStorageDir = storageDir
}
// Create VSCode API mock
// Create VSCode API mock.
this.vscode = createVSCodeAPI(this.options.extensionPath, this.options.workspacePath, undefined, {
appRoot: CLI_PACKAGE_ROOT,
storageDir,
@ -339,7 +345,7 @@ export class ExtensionHost extends EventEmitter {
;(global as Record<string, unknown>).vscode = this.vscode
;(global as Record<string, unknown>).__extensionHost = this
// Set up module resolution
// Set up module resolution.
const require = createRequire(import.meta.url)
const Module = require("module")
const originalResolve = Module._resolveFilename
@ -468,64 +474,6 @@ export class ExtensionHost extends EventEmitter {
setRuntimeConfigValues("roo-cline", settings as Record<string, unknown>)
}
private getApiKeyFromEnv(provider: string): string | undefined {
const envVarMap: Record<string, string> = {
anthropic: "ANTHROPIC_API_KEY",
openai: "OPENAI_API_KEY",
"openai-native": "OPENAI_API_KEY",
openrouter: "OPENROUTER_API_KEY",
google: "GOOGLE_API_KEY",
gemini: "GOOGLE_API_KEY",
bedrock: "AWS_ACCESS_KEY_ID",
ollama: "OLLAMA_API_KEY",
mistral: "MISTRAL_API_KEY",
deepseek: "DEEPSEEK_API_KEY",
xai: "XAI_API_KEY",
groq: "GROQ_API_KEY",
}
const envVar = envVarMap[provider.toLowerCase()] || `${provider.toUpperCase().replace(/-/g, "_")}_API_KEY`
return process.env[envVar]
}
private buildApiConfiguration(): RooCodeSettings {
const provider = this.options.provider
const apiKey = this.options.apiKey || this.getApiKeyFromEnv(provider)
const model = this.options.model
const config: RooCodeSettings = { apiProvider: provider }
switch (provider) {
case "anthropic":
if (apiKey) config.apiKey = apiKey
if (model) config.apiModelId = model
break
case "openai-native":
if (apiKey) config.openAiNativeApiKey = apiKey
if (model) config.apiModelId = model
break
case "gemini":
if (apiKey) config.geminiApiKey = apiKey
if (model) config.apiModelId = model
break
case "openrouter":
if (apiKey) config.openRouterApiKey = apiKey
if (model) config.openRouterModelId = model
break
case "vercel-ai-gateway":
if (apiKey) config.vercelAiGatewayApiKey = apiKey
if (model) config.vercelAiGatewayModelId = model
break
case "roo":
if (apiKey) config.rooApiKey = apiKey
if (model) config.apiModelId = model
break
default:
if (apiKey) config.apiKey = apiKey
if (model) config.apiModelId = model
}
return config
}
async runTask(prompt: string): Promise<void> {
if (!this.isWebviewReady) {
await new Promise<void>((resolve) => this.once("webviewReady", resolve))
@ -539,7 +487,7 @@ export class ExtensionHost extends EventEmitter {
commandExecutionTimeout: 30,
browserToolEnabled: false,
enableCheckpoints: false,
...this.buildApiConfiguration(),
...getProviderSettings(this.options.provider, this.options.apiKey, this.options.model),
}
const settings: RooCodeSettings = this.options.nonInteractive
@ -670,20 +618,20 @@ export class ExtensionHost extends EventEmitter {
// ==========================================================================
async dispose(): Promise<void> {
// Clear managers
// Clear managers.
this.outputManager.clear()
this.askDispatcher.clear()
// Remove message listener
// Remove message listener.
if (this.messageListener) {
this.off("extensionWebviewMessage", this.messageListener)
this.messageListener = null
}
// Reset client
// Reset client.
this.client.reset()
// Deactivate extension
// Deactivate extension.
if (this.extensionModule?.deactivate) {
try {
await this.extensionModule.deactivate()
@ -692,20 +640,20 @@ export class ExtensionHost extends EventEmitter {
}
}
// Clear references
// Clear references.
this.vscode = null
this.extensionModule = null
this.extensionAPI = null
this.webviewProviders.clear()
// Clear globals
// Clear globals.
delete (global as Record<string, unknown>).vscode
delete (global as Record<string, unknown>).__extensionHost
// Restore console
// Restore console.
this.restoreConsole()
// Clean up ephemeral storage
// Clean up ephemeral storage.
if (this.ephemeralStorageDir) {
try {
await fs.promises.rm(this.ephemeralStorageDir, { recursive: true, force: true })

View file

@ -17,9 +17,9 @@
* - "invoke": Command invocations
*/
import { ExtensionMessage, ClineMessage } from "@roo-code/types"
import { debugLog } from "@roo-code/core/cli"
import type { ExtensionMessage, ClineMessage } from "./types.js"
import type { StateStore } from "./state-store.js"
import type { TypedEventEmitter, AgentStateChangeEvent, WaitingForInputEvent, TaskCompletedEvent } from "./events.js"
import {

View file

@ -13,8 +13,9 @@
* - Can be disabled for TUI mode where Ink controls the terminal
*/
import { Observable } from "../extension-client/events.js"
import type { ClineMessage, ClineSay } from "../extension-client/types.js"
import { ClineMessage, ClineSay } from "@roo-code/types"
import { Observable } from "./events.js"
// =============================================================================
// Types

View file

@ -12,8 +12,9 @@
* - Queryable: Current state is always accessible
*/
import { ClineMessage, ExtensionState } from "@roo-code/types"
import { detectAgentState, AgentStateInfo, AgentLoopState } from "./agent-state.js"
import type { ClineMessage, ExtensionState } from "./types.js"
import { Observable } from "./events.js"
// =============================================================================

View file

@ -3,8 +3,8 @@ import { randomBytes } from "crypto"
import net from "net"
import { exec } from "child_process"
import { AUTH_BASE_URL } from "../../types/constants.js"
import { saveToken } from "../../lib/storage/credentials.js"
import { AUTH_BASE_URL } from "@/types/index.js"
import { saveToken } from "@/lib/storage/index.js"
export interface LoginOptions {
timeout?: number

View file

@ -1,4 +1,4 @@
import { clearToken, hasToken, getCredentialsPath } from "../../lib/storage/credentials.js"
import { clearToken, hasToken, getCredentialsPath } from "@/lib/storage/index.js"
export interface LogoutOptions {
verbose?: boolean

View file

@ -1,5 +1,5 @@
import { loadToken, loadCredentials, getCredentialsPath } from "../../lib/storage/index.js"
import { isTokenExpired, isTokenValid, getTokenExpirationDate } from "../../lib/auth/index.js"
import { loadToken, loadCredentials, getCredentialsPath } from "@/lib/storage/index.js"
import { isTokenExpired, isTokenValid, getTokenExpirationDate } from "@/lib/auth/index.js"
export interface StatusOptions {
verbose?: boolean

View file

@ -7,16 +7,25 @@ import { createElement } from "react"
import { isProviderName } from "@roo-code/types"
import { setLogger } from "@roo-code/vscode-shim"
import { FlagOptions, isSupportedProvider, OnboardingProviderChoice, supportedProviders } from "../../types/types.js"
import { ASCII_ROO, DEFAULT_FLAGS, REASONING_EFFORTS, SDK_BASE_URL } from "../../types/constants.js"
import {
FlagOptions,
isSupportedProvider,
OnboardingProviderChoice,
supportedProviders,
ASCII_ROO,
DEFAULT_FLAGS,
REASONING_EFFORTS,
SDK_BASE_URL,
} from "@/types/index.js"
import { ExtensionHost, ExtensionHostOptions } from "../../extension-host/index.js"
import { type User, createClient } from "@/lib/sdk/index.js"
import { loadToken, hasToken, loadSettings } from "@/lib/storage/index.js"
import { getEnvVarName, getApiKeyFromEnv } from "@/lib/utils/provider.js"
import { runOnboarding } from "@/lib/utils/onboarding.js"
import { getDefaultExtensionPath } from "@/lib/utils/extension.js"
import { VERSION } from "@/lib/utils/version.js"
import { type User, createClient } from "../../lib/sdk/index.js"
import { loadToken, hasToken, loadSettings } from "../../lib/storage/index.js"
import { getEnvVarName, getApiKeyFromEnv, getDefaultExtensionPath } from "../../extension-host/utils.js"
import { runOnboarding } from "../../lib/utils/onboarding.js"
import { VERSION } from "../../lib/utils/version.js"
import { ExtensionHost, ExtensionHostOptions } from "@/agent/index.js"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
@ -29,6 +38,7 @@ export async function run(workspaceArg: string, options: FlagOptions) {
})
const isTuiSupported = process.stdin.isTTY && process.stdout.isTTY
const isTuiEnabled = options.tui && isTuiSupported
const extensionPath = options.extension || getDefaultExtensionPath(__dirname)
const workspacePath = path.resolve(workspaceArg)
@ -45,7 +55,7 @@ export async function run(workspaceArg: string, options: FlagOptions) {
let user: User | null = null
let useCloudProvider = false
if (isTuiSupported) {
if (isTuiEnabled) {
let { onboardingProviderChoice } = await loadSettings()
if (!onboardingProviderChoice) {
@ -68,7 +78,7 @@ export async function run(workspaceArg: string, options: FlagOptions) {
apiKey = token
user = me?.type === "user" ? me.user : null
} catch {
// Token may be expired or invalid - user will need to re-authenticate
// Token may be expired or invalid - user will need to re-authenticate.
}
}
}
@ -86,6 +96,7 @@ export async function run(workspaceArg: string, options: FlagOptions) {
)
console.error(`[CLI] For ${provider}, set ${getEnvVarName(provider)}`)
}
process.exit(1)
}
@ -106,20 +117,18 @@ export async function run(workspaceArg: string, options: FlagOptions) {
process.exit(1)
}
const useTui = options.tui && isTuiSupported
if (options.tui && !isTuiSupported) {
console.log("[CLI] TUI disabled (no TTY support), falling back to plain text mode")
}
if (!useTui && !options.prompt) {
if (!isTuiEnabled && !options.prompt) {
console.error("[CLI] Error: prompt is required in plain text mode")
console.error("[CLI] Usage: roo [workspace] -P <prompt> [options]")
console.error("[CLI] Use TUI mode (without --no-tui) for interactive input")
process.exit(1)
}
if (useTui) {
if (isTuiEnabled) {
try {
const { render } = await import("ink")
const { App } = await import("../../ui/App.js")

View file

@ -1,79 +0,0 @@
/**
* Roo Code Client Library
*
* Provides state detection and event-based tracking for the Roo Code agent loop.
*/
// Main Client
export { ExtensionClient, createClient, createMockClient } from "./client.js"
// State Detection
export {
AgentLoopState,
type AgentStateInfo,
type RequiredAction,
detectAgentState,
isAgentWaitingForInput,
isAgentRunning,
isContentStreaming,
} from "./agent-state.js"
// Events
export {
TypedEventEmitter,
Observable,
type Observer,
type Unsubscribe,
type ClientEventMap,
type AgentStateChangeEvent,
type WaitingForInputEvent,
type TaskCompletedEvent,
isSignificantStateChange,
transitionedToWaiting,
transitionedToRunning,
streamingStarted,
streamingEnded,
taskCompleted,
} from "./events.js"
// State Store
export { StateStore, type StoreState, getDefaultStore, resetDefaultStore } from "./state-store.js"
// Message Processing
export {
MessageProcessor,
type MessageProcessorOptions,
isValidClineMessage,
isValidExtensionMessage,
parseExtensionMessage,
parseApiReqStartedText,
} from "./message-processor.js"
// Types - Re-exported from @roo-code/types
export {
type ClineAsk,
type IdleAsk,
type ResumableAsk,
type InteractiveAsk,
type NonBlockingAsk,
clineAsks,
idleAsks,
resumableAsks,
interactiveAsks,
nonBlockingAsks,
isIdleAsk,
isResumableAsk,
isInteractiveAsk,
isNonBlockingAsk,
type ClineSay,
clineSays,
type ClineMessage,
type ToolProgressStatus,
type ContextCondense,
type ContextTruncation,
type ClineAskResponse,
type WebviewMessage,
type ExtensionMessage,
type ExtensionState,
type ApiReqStartedText,
} from "./types.js"

View file

@ -1,88 +0,0 @@
/**
* Type definitions for Roo Code client
*
* Re-exports types from @roo-code/types and adds client-specific types.
*/
import type { ClineMessage as RooCodeClineMessage, ExtensionMessage as RooCodeExtensionMessage } from "@roo-code/types"
// =============================================================================
// Re-export all types from @roo-code/types
// =============================================================================
// Message types
export type {
ClineAsk,
IdleAsk,
ResumableAsk,
InteractiveAsk,
NonBlockingAsk,
ClineSay,
ClineMessage,
ToolProgressStatus,
ContextCondense,
ContextTruncation,
} from "@roo-code/types"
// Ask arrays and type guards
export {
clineAsks,
idleAsks,
resumableAsks,
interactiveAsks,
nonBlockingAsks,
clineSays,
isIdleAsk,
isResumableAsk,
isInteractiveAsk,
isNonBlockingAsk,
} from "@roo-code/types"
// Webview message types
export type { WebviewMessage, ClineAskResponse } from "@roo-code/types"
// =============================================================================
// Client-specific types
// =============================================================================
/**
* Simplified ExtensionState for client purposes.
*
* The full ExtensionState from @roo-code/types has many required fields,
* but for agent loop state detection, we only need clineMessages.
* This type allows partial state updates while still being compatible
* with the full type.
*/
export interface ExtensionState {
clineMessages: RooCodeClineMessage[]
/** Allow other fields from the full ExtensionState to pass through */
[key: string]: unknown
}
/**
* Simplified ExtensionMessage for client purposes.
*
* We only care about certain message types for state detection.
* Other fields pass through unchanged.
*/
export interface ExtensionMessage {
type: RooCodeExtensionMessage["type"]
state?: ExtensionState
clineMessage?: RooCodeClineMessage
action?: string
invoke?: "newChat" | "sendMessage" | "primaryButtonClick" | "secondaryButtonClick" | "setChatBoxMessage"
/** Allow other fields to pass through */
[key: string]: unknown
}
/**
* Structure of the text field in api_req_started messages.
* Used to determine if the API request has completed (cost is defined).
*/
export interface ApiReqStartedText {
cost?: number // Undefined while streaming, defined when complete
tokensIn?: number
tokensOut?: number
cacheWrites?: number
cacheReads?: number
}

View file

@ -1,9 +1,8 @@
import { Command } from "commander"
import { DEFAULT_FLAGS } from "./types/constants.js"
import { run, login, logout, status } from "./commands/index.js"
import { VERSION } from "./lib/utils/version.js"
import { DEFAULT_FLAGS } from "@/types/constants.js"
import { VERSION } from "@/lib/utils/version.js"
import { run, login, logout, status } from "@/commands/index.js"
const program = new Command()

View file

@ -1,7 +1,7 @@
import fs from "fs/promises"
import path from "path"
import type { CliSettings } from "../../types/types.js"
import type { CliSettings } from "@/types/index.js"
import { getConfigDir } from "./index.js"

View file

@ -1,43 +1,10 @@
import fs from "fs"
import path from "path"
import { getApiKeyFromEnv, getDefaultExtensionPath } from "../utils.js"
import { getDefaultExtensionPath } from "../extension.js"
vi.mock("fs")
describe("getApiKeyFromEnv", () => {
const originalEnv = process.env
beforeEach(() => {
// Reset process.env before each test.
process.env = { ...originalEnv }
})
afterEach(() => {
process.env = originalEnv
})
it("should return API key from environment variable for anthropic", () => {
process.env.ANTHROPIC_API_KEY = "test-anthropic-key"
expect(getApiKeyFromEnv("anthropic")).toBe("test-anthropic-key")
})
it("should return API key from environment variable for openrouter", () => {
process.env.OPENROUTER_API_KEY = "test-openrouter-key"
expect(getApiKeyFromEnv("openrouter")).toBe("test-openrouter-key")
})
it("should return API key from environment variable for openai", () => {
process.env.OPENAI_API_KEY = "test-openai-key"
expect(getApiKeyFromEnv("openai-native")).toBe("test-openai-key")
})
it("should return undefined when API key is not set", () => {
delete process.env.ANTHROPIC_API_KEY
expect(getApiKeyFromEnv("anthropic")).toBeUndefined()
})
})
describe("getDefaultExtensionPath", () => {
const originalEnv = process.env

View file

@ -0,0 +1,34 @@
import { getApiKeyFromEnv } from "../provider.js"
describe("getApiKeyFromEnv", () => {
const originalEnv = process.env
beforeEach(() => {
// Reset process.env before each test.
process.env = { ...originalEnv }
})
afterEach(() => {
process.env = originalEnv
})
it("should return API key from environment variable for anthropic", () => {
process.env.ANTHROPIC_API_KEY = "test-anthropic-key"
expect(getApiKeyFromEnv("anthropic")).toBe("test-anthropic-key")
})
it("should return API key from environment variable for openrouter", () => {
process.env.OPENROUTER_API_KEY = "test-openrouter-key"
expect(getApiKeyFromEnv("openrouter")).toBe("test-openrouter-key")
})
it("should return API key from environment variable for openai", () => {
process.env.OPENAI_API_KEY = "test-openai-key"
expect(getApiKeyFromEnv("openai-native")).toBe("test-openai-key")
})
it("should return undefined when API key is not set", () => {
delete process.env.ANTHROPIC_API_KEY
expect(getApiKeyFromEnv("anthropic")).toBeUndefined()
})
})

View file

@ -1,6 +1,6 @@
import type { ProviderSettings } from "@roo-code/types"
import type { RouterModels } from "../../ui/store.js"
import type { RouterModels } from "@/ui/store.js"
const DEFAULT_CONTEXT_WINDOW = 200_000

View file

@ -1,28 +1,6 @@
import path from "path"
import fs from "fs"
import type { SupportedProvider } from "../types/types.js"
const envVarMap: Record<SupportedProvider, string> = {
// Frontier Labs
anthropic: "ANTHROPIC_API_KEY",
"openai-native": "OPENAI_API_KEY",
gemini: "GOOGLE_API_KEY",
// Routers
openrouter: "OPENROUTER_API_KEY",
"vercel-ai-gateway": "VERCEL_AI_GATEWAY_API_KEY",
roo: "ROO_API_KEY",
}
export function getEnvVarName(provider: SupportedProvider): string {
return envVarMap[provider]
}
export function getApiKeyFromEnv(provider: SupportedProvider): string | undefined {
const envVar = getEnvVarName(provider)
return process.env[envVar]
}
/**
* Get the default path to the extension bundle.
* This assumes the CLI is installed alongside the built extension.

View file

@ -1,8 +1,8 @@
import { createElement } from "react"
import { type OnboardingResult, OnboardingProviderChoice } from "../../types/types.js"
import { login } from "../../commands/index.js"
import { saveSettings } from "../storage/settings.js"
import { type OnboardingResult, OnboardingProviderChoice } from "@/types/index.js"
import { login } from "@/commands/index.js"
import { saveSettings } from "@/lib/storage/index.js"
export async function runOnboarding(): Promise<OnboardingResult> {
const { render } = await import("ink")

View file

@ -0,0 +1,61 @@
import { RooCodeSettings } from "@roo-code/types"
import type { SupportedProvider } from "@/types/index.js"
const envVarMap: Record<SupportedProvider, string> = {
anthropic: "ANTHROPIC_API_KEY",
"openai-native": "OPENAI_API_KEY",
gemini: "GOOGLE_API_KEY",
openrouter: "OPENROUTER_API_KEY",
"vercel-ai-gateway": "VERCEL_AI_GATEWAY_API_KEY",
roo: "ROO_API_KEY",
}
export function getEnvVarName(provider: SupportedProvider): string {
return envVarMap[provider]
}
export function getApiKeyFromEnv(provider: SupportedProvider): string | undefined {
const envVar = getEnvVarName(provider)
return process.env[envVar]
}
export function getProviderSettings(
provider: SupportedProvider,
apiKey: string | undefined,
model: string | undefined,
): RooCodeSettings {
const config: RooCodeSettings = { apiProvider: provider }
switch (provider) {
case "anthropic":
if (apiKey) config.apiKey = apiKey
if (model) config.apiModelId = model
break
case "openai-native":
if (apiKey) config.openAiNativeApiKey = apiKey
if (model) config.apiModelId = model
break
case "gemini":
if (apiKey) config.geminiApiKey = apiKey
if (model) config.apiModelId = model
break
case "openrouter":
if (apiKey) config.openRouterApiKey = apiKey
if (model) config.openRouterModelId = model
break
case "vercel-ai-gateway":
if (apiKey) config.vercelAiGatewayApiKey = apiKey
if (model) config.vercelAiGatewayModelId = model
break
case "roo":
if (apiKey) config.rooApiKey = apiKey
if (model) config.apiModelId = model
break
default:
if (apiKey) config.apiKey = apiKey
if (model) config.apiModelId = model
}
return config
}

View file

@ -1,5 +1,4 @@
import { ProviderName } from "@roo-code/types"
import { ReasoningEffortExtended } from "@roo-code/types"
import type { ProviderName, ReasoningEffortExtended } from "@roo-code/types"
export const supportedProviders = [
"anthropic",

View file

@ -3,15 +3,17 @@ import { Select } from "@inkjs/ui"
import { useState, useEffect, useCallback, useRef, useMemo } from "react"
import type { WebviewMessage } from "@roo-code/types"
import { getGlobalCommandsForAutocomplete } from "../lib/utils/commands.js"
import { arePathsEqual } from "../lib/utils/path.js"
import { getContextWindow } from "../lib/utils/context-window.js"
import * as theme from "./theme.js"
import { ExtensionHostOptions } from "@/agent/index.js"
import { getGlobalCommandsForAutocomplete } from "@/lib/utils/commands.js"
import { arePathsEqual } from "@/lib/utils/path.js"
import { getContextWindow } from "@/lib/utils/context-window.js"
import * as theme from "./theme.js"
import { useCLIStore } from "./store.js"
import { useUIStateStore } from "./stores/uiStateStore.js"
// Import extracted hooks
// Import extracted hooks.
import {
TerminalSizeProvider,
useTerminalSize,
@ -25,10 +27,10 @@ import {
usePickerHandlers,
} from "./hooks/index.js"
// Import extracted utilities
// Import extracted utilities.
import { getView } from "./utils/index.js"
// Import components
// Import components.
import Header from "./components/Header.js"
import ChatHistoryItem from "./components/ChatHistoryItem.js"
import LoadingText from "./components/LoadingText.js"
@ -54,7 +56,6 @@ import {
} from "./components/autocomplete/index.js"
import { ScrollArea, useScrollToBottom } from "./components/ScrollArea.js"
import ScrollIndicator from "./components/ScrollIndicator.js"
import { ExtensionHostOptions } from "../extension-host/extension-host.js"
const PICKER_HEIGHT = 10

View file

@ -1,8 +1,9 @@
import { memo } from "react"
import { Box, Newline, Text } from "ink"
import * as theme from "../theme.js"
import type { TUIMessage } from "../types.js"
import * as theme from "../theme.js"
import TodoDisplay from "./TodoDisplay.js"
import { getToolRenderer } from "./tools/index.js"

View file

@ -3,8 +3,9 @@ import { Text, Box } from "ink"
import type { TokenUsage } from "@roo-code/types"
import { ASCII_ROO } from "../../types/constants.js"
import { User } from "../../lib/sdk/types.js"
import { ASCII_ROO } from "@/types/constants.js"
import { User } from "@/lib/sdk/types.js"
import { useTerminalSize } from "../hooks/TerminalSizeContext.js"
import * as theme from "../theme.js"

View file

@ -1,14 +1,12 @@
import { Text } from "ink"
import { useTerminalSize } from "../hooks/TerminalSizeContext.js"
import * as theme from "../theme.js"
import { useTerminalSize } from "../hooks/TerminalSizeContext.js"
interface HorizontalLineProps {
active?: boolean
}
/**
* Full-width horizontal line component - uses terminal size from context
*/
export function HorizontalLine({ active = false }: HorizontalLineProps) {
const { columns } = useTerminalSize()
const color = active ? theme.borderColorActive : theme.borderColor

View file

@ -16,7 +16,7 @@
import { useState, useEffect, useMemo, useCallback, useRef } from "react"
import { Box, Text, useInput, type Key } from "ink"
import { isGlobalInputSequence } from "../../lib/utils/input.js"
import { isGlobalInputSequence } from "@/lib/utils/input.js"
export interface MultilineTextInputProps {
/**

View file

@ -10,10 +10,10 @@ interface ScrollIndicatorProps {
}
function ScrollIndicator({ scrollTop, maxScroll, isScrollFocused = false }: ScrollIndicatorProps) {
// Calculate percentage - show 100% when at bottom or no scrolling needed
// Calculate percentage - show 100% when at bottom or no scrolling needed.
const percentage = maxScroll > 0 ? Math.round((scrollTop / maxScroll) * 100) : 100
// Color changes based on focus state
// Color changes based on focus state.
const color = isScrollFocused ? theme.scrollActiveColor : theme.dimText
return (

View file

@ -1,17 +1,13 @@
import { memo } from "react"
import { Text, Box } from "ink"
import type { Toast, ToastType } from "../hooks/useToast.js"
import * as theme from "../theme.js"
import type { Toast, ToastType } from "../hooks/useToast.js"
interface ToastDisplayProps {
/** The current toast to display (null if no toast) */
toast: Toast | null
}
/**
* Get the color for a toast based on its type
*/
function getToastColor(type: ToastType): string {
switch (type) {
case "success":
@ -26,9 +22,6 @@ function getToastColor(type: ToastType): string {
}
}
/**
* Get the icon/prefix for a toast based on its type
*/
function getToastIcon(type: ToastType): string {
switch (type) {
case "success":
@ -43,12 +36,6 @@ function getToastIcon(type: ToastType): string {
}
}
/**
* ToastDisplay component for showing ephemeral messages in the status bar.
*
* Displays the current toast with appropriate styling based on type.
* When no toast is present, renders nothing.
*/
function ToastDisplay({ toast }: ToastDisplayProps) {
if (!toast) {
return null

View file

@ -1,11 +1,12 @@
import { useInput } from "ink"
import { useState, useCallback, useEffect, useImperativeHandle, forwardRef, useRef, type Ref } from "react"
import { MultilineTextInput } from "../MultilineTextInput.js"
import { useInputHistory } from "../../hooks/useInputHistory.js"
import { useAutocompletePicker } from "./useAutocompletePicker.js"
import { useTerminalSize } from "../../hooks/TerminalSizeContext.js"
import { MultilineTextInput } from "../MultilineTextInput.js"
import type { AutocompleteItem, AutocompleteTrigger, AutocompletePickerState } from "./types.js"
import { useAutocompletePicker } from "./useAutocompletePicker.js"
export interface AutocompleteInputProps<T extends AutocompleteItem = AutocompleteItem> {
/** Placeholder text when input is empty */

View file

@ -1,8 +1,9 @@
import { Box, Text } from "ink"
import fuzzysort from "fuzzysort"
import { GlobalCommandAction } from "@/lib/utils/commands.js"
import type { AutocompleteTrigger, AutocompleteItem, TriggerDetectionResult } from "../types.js"
import { GlobalCommandAction } from "../../../../lib/utils/commands.js"
export interface SlashCommandResult extends AutocompleteItem {
name: string

View file

@ -1,8 +1,7 @@
import { Box, Text } from "ink"
import { Select } from "@inkjs/ui"
import { OnboardingProviderChoice } from "../../../types/types.js"
import { ASCII_ROO } from "../../../types/constants.js"
import { OnboardingProviderChoice, ASCII_ROO } from "@/types/index.js"
export interface OnboardingScreenProps {
onSelect: (choice: OnboardingProviderChoice) => void

View file

@ -1,12 +1,8 @@
/**
* Renderer for browser actions
* Handles: browser_action
*/
import { Box, Text } from "ink"
import * as theme from "../../theme.js"
import { Icon } from "../Icon.js"
import type { ToolRendererProps } from "./types.js"
import { getToolDisplayName, getToolIconName } from "./utils.js"
@ -29,7 +25,7 @@ export function BrowserTool({ toolData }: ToolRendererProps) {
const action = toolData.action || ""
const url = toolData.url || ""
const coordinate = toolData.coordinate || ""
const content = toolData.content || "" // May contain text for type action
const content = toolData.content || "" // May contain text for type action.
const actionLabel = ACTION_LABELS[action] || action

View file

@ -2,6 +2,7 @@ import { Box, Text } from "ink"
import * as theme from "../../theme.js"
import { Icon } from "../Icon.js"
import type { ToolRendererProps } from "./types.js"
import { truncateText, sanitizeContent, getToolIconName } from "./utils.js"

View file

@ -1,6 +1,7 @@
import { Box, Text } from "ink"
import * as theme from "../../theme.js"
import type { ToolRendererProps } from "./types.js"
import { truncateText, sanitizeContent } from "./utils.js"

View file

@ -1,12 +1,8 @@
/**
* Renderer for file read operations
* Handles: readFile, fetchInstructions, listFilesTopLevel, listFilesRecursive
*/
import { Box, Text } from "ink"
import * as theme from "../../theme.js"
import { Icon } from "../Icon.js"
import type { ToolRendererProps } from "./types.js"
import { truncateText, sanitizeContent, getToolDisplayName, getToolIconName } from "./utils.js"

View file

@ -1,12 +1,8 @@
/**
* Renderer for file write operations
* Handles: editedExistingFile, appliedDiff, newFileCreated, write_to_file
*/
import { Box, Text } from "ink"
import * as theme from "../../theme.js"
import { Icon } from "../Icon.js"
import type { ToolRendererProps } from "./types.js"
import { truncateText, sanitizeContent, getToolDisplayName, getToolIconName, parseDiff } from "./utils.js"

View file

@ -1,12 +1,8 @@
/**
* Generic fallback renderer for unknown tools
* Used when no specific renderer exists for a tool type
*/
import { Box, Text } from "ink"
import * as theme from "../../theme.js"
import { Icon } from "../Icon.js"
import type { ToolRendererProps } from "./types.js"
import { truncateText, sanitizeContent, getToolDisplayName, getToolIconName } from "./utils.js"

View file

@ -1,12 +1,8 @@
/**
* Renderer for search operations
* Handles: searchFiles, codebaseSearch
*/
import { Box, Text } from "ink"
import * as theme from "../../theme.js"
import { Icon } from "../Icon.js"
import type { ToolRendererProps } from "./types.js"
import { truncateText, sanitizeContent, getToolDisplayName, getToolIconName } from "./utils.js"
@ -21,7 +17,7 @@ export function SearchTool({ toolData }: ToolRendererProps) {
const path = toolData.path || ""
const content = toolData.content ? sanitizeContent(toolData.content) : ""
// Parse search results if content looks like results
// Parse search results if content looks like results.
const resultLines = content.split("\n").filter((line) => line.trim())
const matchCount = resultLines.length

View file

@ -1,7 +1,7 @@
import { render } from "ink-testing-library"
import { CommandTool } from "../CommandTool.js"
import type { ToolRendererProps } from "../types.js"
import { CommandTool } from "../CommandTool.js"
describe("CommandTool", () => {
describe("command display", () => {

View file

@ -1,22 +1,10 @@
/**
* Types for tool renderer components
*/
import type { ToolData } from "../../types.js"
/**
* Props passed to all tool renderer components
*/
export interface ToolRendererProps {
/** Structured tool data */
toolData: ToolData
/** Raw content fallback (JSON string) */
rawContent?: string
}
/**
* Tool category for grouping similar tools
*/
export type ToolCategory =
| "file-read"
| "file-write"
@ -27,9 +15,6 @@ export type ToolCategory =
| "completion"
| "other"
/**
* Get the category for a tool based on its name
*/
export function getToolCategory(toolName: string): ToolCategory {
const fileReadTools = [
"readFile",
@ -40,6 +25,7 @@ export function getToolCategory(toolName: string): ToolCategory {
"listFilesRecursive",
"list_files",
]
const fileWriteTools = [
"editedExistingFile",
"appliedDiff",
@ -48,6 +34,7 @@ export function getToolCategory(toolName: string): ToolCategory {
"write_to_file",
"writeToFile",
]
const searchTools = ["searchFiles", "search_files", "codebaseSearch", "codebase_search"]
const commandTools = ["execute_command", "executeCommand"]
const browserTools = ["browser_action", "browserAction"]

View file

@ -1,7 +1,3 @@
/**
* Utility functions for tool rendering
*/
import type { IconName } from "../Icon.js"
/**

View file

@ -3,9 +3,9 @@ import { useApp } from "ink"
import { randomUUID } from "crypto"
import type { ExtensionMessage, WebviewMessage } from "@roo-code/types"
import { useCLIStore } from "../store.js"
import { ExtensionHostOptions } from "@/agent/index.js"
import { ExtensionHostOptions } from "../../extension-host/extension-host.js"
import { useCLIStore } from "../store.js"
interface ExtensionHostInterface {
// eslint-disable-next-line @typescript-eslint/no-explicit-any

View file

@ -2,7 +2,7 @@ import { useEffect, useRef } from "react"
import { useInput } from "ink"
import type { WebviewMessage } from "@roo-code/types"
import { matchesGlobalSequence } from "../../lib/utils/input.js"
import { matchesGlobalSequence } from "@/lib/utils/input.js"
import type { ModeResult } from "../components/autocomplete/index.js"
import { useUIStateStore } from "../stores/uiStateStore.js"

View file

@ -4,7 +4,11 @@
"types": ["vitest/globals"],
"outDir": "dist",
"jsx": "react-jsx",
"jsxImportSource": "react"
"jsxImportSource": "react",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src", "*.config.ts"],
"exclude": ["node_modules"]

View file

@ -1,6 +1,12 @@
import path from "path"
import { defineConfig } from "vitest/config"
export default defineConfig({
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
},
},
test: {
globals: true,
environment: "node",

View file

@ -227,7 +227,7 @@ describe("CustomToolRegistry", () => {
expect(result.loaded).toContain("simple")
expect(registry.has("simple")).toBe(true)
}, 60000)
}, 120_000)
it("should handle named exports", async () => {
const result = await registry.loadFromDirectory(TEST_FIXTURES_DIR)