diff --git a/apps/cli/docs/AGENT_LOOP.md b/apps/cli/docs/AGENT_LOOP.md new file mode 100644 index 00000000000..8dcb0651f29 --- /dev/null +++ b/apps/cli/docs/AGENT_LOOP.md @@ -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** diff --git a/apps/cli/docs/AGENT_LOOP_STATE_DETECTION.md b/apps/cli/docs/AGENT_LOOP_STATE_DETECTION.md deleted file mode 100644 index cf05e0755a8..00000000000 --- a/apps/cli/docs/AGENT_LOOP_STATE_DETECTION.md +++ /dev/null @@ -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(undefined) -const [enableButtons, setEnableButtons] = useState(false) -const [primaryButtonText, setPrimaryButtonText] = useState(undefined) -const [secondaryButtonText, setSecondaryButtonText] = useState(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. diff --git a/apps/cli/src/__tests__/index.test.ts b/apps/cli/src/__tests__/index.test.ts index cc146259e92..aa9649373d0 100644 --- a/apps/cli/src/__tests__/index.test.ts +++ b/apps/cli/src/__tests__/index.test.ts @@ -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, "../../../..") } -const extensionPath = findExtensionPath() -const hasExtension = !!extensionPath -const ripgrepAvailable = hasRipgrep() +function isCliBuilt(): boolean { + return fs.existsSync(path.join(findCliRoot(), "dist", "index.js")) +} -// Create a temporary workspace directory for tests -function createTempWorkspace(): string { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "roo-cli-test-")) - return tempDir +function isExtensionBuilt(): boolean { + const monorepoRoot = findMonorepoRoot() + const extensionPath = path.join(monorepoRoot, "src/dist") + return fs.existsSync(path.join(extensionPath, "extension.js")) } -// Clean up temporary workspace -function cleanupWorkspace(workspacePath: string): void { - try { - fs.rmSync(workspacePath, { recursive: true, force: true }) - } catch { - // Ignore cleanup errors +function buildCliIfNeeded(): void { + if (!isCliBuilt()) { + execSync("pnpm build", { cwd: findCliRoot(), stdio: "inherit" }) + console.log("CLI 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 buildExtensionIfNeeded(): void { + if (!isExtensionBuilt()) { + execSync("pnpm --filter roo-cline bundle", { cwd: findMonorepoRoot(), stdio: "inherit" }) + console.log("Extension build complete.") + } +} - beforeAll(() => { - console.log("Integration tests running with:") - console.log(` - API Key: ${OPENROUTER_API_KEY?.substring(0, 12)}...`) - console.log(` - Extension Path: ${extensionPath}`) +function runCli( + args: string[], + options: { timeout?: number } = {}, +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + return new Promise((resolve) => { + const timeout = options.timeout ?? 60000 + + 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!, - }) - - // Test activation - await host.activate() - - // Track state messages - const stateMessages: unknown[] = [] - host.on("extensionWebviewMessage", (msg: Record) => { - 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).vscode).toBeUndefined() - expect((global as Record).__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 - }) + proc.on("close", (code: number | null) => { + clearTimeout(timeoutId) + resolve({ stdout, stderr, exitCode: timedOut ? -1 : (code ?? 1) }) + }) - 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 + proc.on("error", (error: Error) => { + clearTimeout(timeoutId) + stderr += error.message + resolve({ stdout, stderr, exitCode: 1 }) + }) }) +} - 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 +describe.skipIf(!RUN_INTEGRATION_TESTS || !hasApiKey)("CLI Integration Tests", () => { + beforeAll(() => { + buildExtensionIfNeeded() + buildCliIfNeeded() }) - it("should have ripgrep check", () => { - if (ripgrepAvailable) { - console.log("ripgrep is available") - } else { - console.log("ripgrep not found, integration tests will be skipped") + 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 }, + ) + + console.log("CLI stdout:", result.stdout) + + 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) }) diff --git a/apps/cli/src/extension-client/client.test.ts b/apps/cli/src/agent/__tests__/extension-client.test.ts similarity index 90% rename from apps/cli/src/extension-client/client.test.ts rename to apps/cli/src/agent/__tests__/extension-client.test.ts index 2bbbd6fbcc3..03de87c4891 100644 --- a/apps/cli/src/extension-client/client.test.ts +++ b/apps/cli/src/agent/__tests__/extension-client.test.ts @@ -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 { - 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) }) diff --git a/apps/cli/src/extension-host/__tests__/extension-host.test.ts b/apps/cli/src/agent/__tests__/extension-host.test.ts similarity index 92% rename from apps/cli/src/extension-host/__tests__/extension-host.test.ts rename to apps/cli/src/agent/__tests__/extension-host.test.ts index 4c51f84a459..1691bf2bb6c 100644 --- a/apps/cli/src/extension-host/__tests__/extension-host.test.ts +++ b/apps/cli/src/agent/__tests__/extension-host.test.ts @@ -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>(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>(host, "buildApiConfiguration") - - expect(config.apiProvider).toBe("openrouter") - }) - - it("should handle missing apiKey gracefully", () => { - const host = createTestHost({ - provider: "anthropic", - model: "test-model", - }) - - const config = callPrivate>(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() diff --git a/apps/cli/src/extension-client/agent-state.ts b/apps/cli/src/agent/agent-state.ts similarity index 94% rename from apps/cli/src/extension-client/agent-state.ts rename to apps/cli/src/agent/agent-state.ts index 35235c3df00..ca4a099ccab 100644 --- a/apps/cli/src/extension-client/agent-state.ts +++ b/apps/cli/src/agent/agent-state.ts @@ -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 } } diff --git a/apps/cli/src/extension-host/ask-dispatcher.ts b/apps/cli/src/agent/ask-dispatcher.ts similarity index 98% rename from apps/cli/src/extension-host/ask-dispatcher.ts rename to apps/cli/src/agent/ask-dispatcher.ts index f7889c35705..8d57e4547cd 100644 --- a/apps/cli/src/extension-host/ask-dispatcher.ts +++ b/apps/cli/src/agent/ask-dispatcher.ts @@ -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 diff --git a/apps/cli/src/extension-client/events.ts b/apps/cli/src/agent/events.ts similarity index 99% rename from apps/cli/src/extension-client/events.ts rename to apps/cli/src/agent/events.ts index b9eaf929fab..1934993febe 100644 --- a/apps/cli/src/extension-client/events.ts +++ b/apps/cli/src/agent/events.ts @@ -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 diff --git a/apps/cli/src/extension-client/client.ts b/apps/cli/src/agent/extension-client.ts similarity index 97% rename from apps/cli/src/extension-client/client.ts rename to apps/cli/src/agent/extension-client.ts index a1c5397cb22..8efc346057f 100644 --- a/apps/cli/src/extension-client/client.ts +++ b/apps/cli/src/agent/extension-client.ts @@ -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) + }) } // =========================================================================== diff --git a/apps/cli/src/extension-host/extension-host.ts b/apps/cli/src/agent/extension-host.ts similarity index 83% rename from apps/cli/src/extension-host/extension-host.ts rename to apps/cli/src/agent/extension-host.ts index 05096f97ee7..5e569f42c15 100644 --- a/apps/cli/src/extension-host/extension-host.ts +++ b/apps/cli/src/agent/extension-host.ts @@ -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" - -// 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 { SupportedProvider } from "@/types/index.js" +import type { User } from "@/lib/sdk/index.js" +import { getProviderSettings } from "@/lib/utils/provider.js" + +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).vscode = this.vscode ;(global as Record).__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) } - private getApiKeyFromEnv(provider: string): string | undefined { - const envVarMap: Record = { - 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 { if (!this.isWebviewReady) { await new Promise((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 { - // 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).vscode delete (global as Record).__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 }) diff --git a/apps/cli/src/extension-host/index.ts b/apps/cli/src/agent/index.ts similarity index 100% rename from apps/cli/src/extension-host/index.ts rename to apps/cli/src/agent/index.ts diff --git a/apps/cli/src/extension-client/message-processor.ts b/apps/cli/src/agent/message-processor.ts similarity index 99% rename from apps/cli/src/extension-client/message-processor.ts rename to apps/cli/src/agent/message-processor.ts index e44987229d9..9ae298caf01 100644 --- a/apps/cli/src/extension-client/message-processor.ts +++ b/apps/cli/src/agent/message-processor.ts @@ -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 { diff --git a/apps/cli/src/extension-host/output-manager.ts b/apps/cli/src/agent/output-manager.ts similarity index 98% rename from apps/cli/src/extension-host/output-manager.ts rename to apps/cli/src/agent/output-manager.ts index 10ff4fe0b93..0863546f6c4 100644 --- a/apps/cli/src/extension-host/output-manager.ts +++ b/apps/cli/src/agent/output-manager.ts @@ -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 diff --git a/apps/cli/src/extension-host/prompt-manager.ts b/apps/cli/src/agent/prompt-manager.ts similarity index 100% rename from apps/cli/src/extension-host/prompt-manager.ts rename to apps/cli/src/agent/prompt-manager.ts diff --git a/apps/cli/src/extension-client/state-store.ts b/apps/cli/src/agent/state-store.ts similarity index 99% rename from apps/cli/src/extension-client/state-store.ts rename to apps/cli/src/agent/state-store.ts index 9c4fc78f01d..d502e7bae0e 100644 --- a/apps/cli/src/extension-client/state-store.ts +++ b/apps/cli/src/agent/state-store.ts @@ -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" // ============================================================================= diff --git a/apps/cli/src/commands/auth/login.ts b/apps/cli/src/commands/auth/login.ts index 15e0479cb7b..14966f2d156 100644 --- a/apps/cli/src/commands/auth/login.ts +++ b/apps/cli/src/commands/auth/login.ts @@ -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 diff --git a/apps/cli/src/commands/auth/logout.ts b/apps/cli/src/commands/auth/logout.ts index 4ddd80025b1..61c3cb37a49 100644 --- a/apps/cli/src/commands/auth/logout.ts +++ b/apps/cli/src/commands/auth/logout.ts @@ -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 diff --git a/apps/cli/src/commands/auth/status.ts b/apps/cli/src/commands/auth/status.ts index e45a636414b..9e81adfda8a 100644 --- a/apps/cli/src/commands/auth/status.ts +++ b/apps/cli/src/commands/auth/status.ts @@ -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 diff --git a/apps/cli/src/commands/cli/run.ts b/apps/cli/src/commands/cli/run.ts index a091271a71c..5b305ce2751 100644 --- a/apps/cli/src/commands/cli/run.ts +++ b/apps/cli/src/commands/cli/run.ts @@ -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 { 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, getDefaultExtensionPath } from "../../extension-host/utils.js" -import { runOnboarding } from "../../lib/utils/onboarding.js" -import { VERSION } from "../../lib/utils/version.js" +import { + FlagOptions, + isSupportedProvider, + OnboardingProviderChoice, + supportedProviders, + ASCII_ROO, + DEFAULT_FLAGS, + REASONING_EFFORTS, + SDK_BASE_URL, +} from "@/types/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 { 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 [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") diff --git a/apps/cli/src/extension-client/index.ts b/apps/cli/src/extension-client/index.ts deleted file mode 100644 index 82d98f19902..00000000000 --- a/apps/cli/src/extension-client/index.ts +++ /dev/null @@ -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" diff --git a/apps/cli/src/extension-client/types.ts b/apps/cli/src/extension-client/types.ts deleted file mode 100644 index fee429fc0d1..00000000000 --- a/apps/cli/src/extension-client/types.ts +++ /dev/null @@ -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 -} diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index fad07e68769..8d3f5af521e 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -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() diff --git a/apps/cli/src/lib/storage/settings.ts b/apps/cli/src/lib/storage/settings.ts index c42260d9bc3..86a2d9243e5 100644 --- a/apps/cli/src/lib/storage/settings.ts +++ b/apps/cli/src/lib/storage/settings.ts @@ -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" diff --git a/apps/cli/src/extension-host/__tests__/utils.test.ts b/apps/cli/src/lib/utils/__tests__/extension.test.ts similarity index 59% rename from apps/cli/src/extension-host/__tests__/utils.test.ts rename to apps/cli/src/lib/utils/__tests__/extension.test.ts index 419a4fcaf55..31fdbe87f00 100644 --- a/apps/cli/src/extension-host/__tests__/utils.test.ts +++ b/apps/cli/src/lib/utils/__tests__/extension.test.ts @@ -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 diff --git a/apps/cli/src/lib/utils/__tests__/provider.test.ts b/apps/cli/src/lib/utils/__tests__/provider.test.ts new file mode 100644 index 00000000000..70d8a2a5557 --- /dev/null +++ b/apps/cli/src/lib/utils/__tests__/provider.test.ts @@ -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() + }) +}) diff --git a/apps/cli/src/lib/utils/context-window.ts b/apps/cli/src/lib/utils/context-window.ts index 6112cf4dd21..c1224c8b1ec 100644 --- a/apps/cli/src/lib/utils/context-window.ts +++ b/apps/cli/src/lib/utils/context-window.ts @@ -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 diff --git a/apps/cli/src/extension-host/utils.ts b/apps/cli/src/lib/utils/extension.ts similarity index 63% rename from apps/cli/src/extension-host/utils.ts rename to apps/cli/src/lib/utils/extension.ts index 4c958825d22..904940ec004 100644 --- a/apps/cli/src/extension-host/utils.ts +++ b/apps/cli/src/lib/utils/extension.ts @@ -1,28 +1,6 @@ import path from "path" import fs from "fs" -import type { SupportedProvider } from "../types/types.js" - -const envVarMap: Record = { - // 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. diff --git a/apps/cli/src/lib/utils/onboarding.ts b/apps/cli/src/lib/utils/onboarding.ts index 92fa11f55bd..176bc6a3441 100644 --- a/apps/cli/src/lib/utils/onboarding.ts +++ b/apps/cli/src/lib/utils/onboarding.ts @@ -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 { const { render } = await import("ink") diff --git a/apps/cli/src/lib/utils/provider.ts b/apps/cli/src/lib/utils/provider.ts new file mode 100644 index 00000000000..64aec430c1b --- /dev/null +++ b/apps/cli/src/lib/utils/provider.ts @@ -0,0 +1,61 @@ +import { RooCodeSettings } from "@roo-code/types" + +import type { SupportedProvider } from "@/types/index.js" + +const envVarMap: Record = { + 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 +} diff --git a/apps/cli/src/types/types.ts b/apps/cli/src/types/types.ts index 5fcddaf836a..cd64c9b1629 100644 --- a/apps/cli/src/types/types.ts +++ b/apps/cli/src/types/types.ts @@ -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", diff --git a/apps/cli/src/ui/App.tsx b/apps/cli/src/ui/App.tsx index c0cb97d244b..7ffb425c224 100644 --- a/apps/cli/src/ui/App.tsx +++ b/apps/cli/src/ui/App.tsx @@ -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 diff --git a/apps/cli/src/ui/components/ChatHistoryItem.tsx b/apps/cli/src/ui/components/ChatHistoryItem.tsx index 8b52a2b1604..c51b0faddbc 100644 --- a/apps/cli/src/ui/components/ChatHistoryItem.tsx +++ b/apps/cli/src/ui/components/ChatHistoryItem.tsx @@ -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" diff --git a/apps/cli/src/ui/components/Header.tsx b/apps/cli/src/ui/components/Header.tsx index 045ce3c67db..987ff9179de 100644 --- a/apps/cli/src/ui/components/Header.tsx +++ b/apps/cli/src/ui/components/Header.tsx @@ -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" diff --git a/apps/cli/src/ui/components/HorizontalLine.tsx b/apps/cli/src/ui/components/HorizontalLine.tsx index 50b16aea00d..e46bf12c0a8 100644 --- a/apps/cli/src/ui/components/HorizontalLine.tsx +++ b/apps/cli/src/ui/components/HorizontalLine.tsx @@ -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 diff --git a/apps/cli/src/ui/components/MultilineTextInput.tsx b/apps/cli/src/ui/components/MultilineTextInput.tsx index b17e48440f8..f551d1a266c 100644 --- a/apps/cli/src/ui/components/MultilineTextInput.tsx +++ b/apps/cli/src/ui/components/MultilineTextInput.tsx @@ -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 { /** diff --git a/apps/cli/src/ui/components/ScrollIndicator.tsx b/apps/cli/src/ui/components/ScrollIndicator.tsx index d86d9695990..864d48650d7 100644 --- a/apps/cli/src/ui/components/ScrollIndicator.tsx +++ b/apps/cli/src/ui/components/ScrollIndicator.tsx @@ -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 ( diff --git a/apps/cli/src/ui/components/ToastDisplay.tsx b/apps/cli/src/ui/components/ToastDisplay.tsx index 58c99e2ed95..9684fd304f4 100644 --- a/apps/cli/src/ui/components/ToastDisplay.tsx +++ b/apps/cli/src/ui/components/ToastDisplay.tsx @@ -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 diff --git a/apps/cli/src/ui/components/autocomplete/AutocompleteInput.tsx b/apps/cli/src/ui/components/autocomplete/AutocompleteInput.tsx index 2f2fbd5bf99..3ebb51d2267 100644 --- a/apps/cli/src/ui/components/autocomplete/AutocompleteInput.tsx +++ b/apps/cli/src/ui/components/autocomplete/AutocompleteInput.tsx @@ -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 { /** Placeholder text when input is empty */ diff --git a/apps/cli/src/ui/components/autocomplete/triggers/SlashCommandTrigger.tsx b/apps/cli/src/ui/components/autocomplete/triggers/SlashCommandTrigger.tsx index 1a7886f3c86..1741a88d227 100644 --- a/apps/cli/src/ui/components/autocomplete/triggers/SlashCommandTrigger.tsx +++ b/apps/cli/src/ui/components/autocomplete/triggers/SlashCommandTrigger.tsx @@ -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 diff --git a/apps/cli/src/ui/components/onboarding/OnboardingScreen.tsx b/apps/cli/src/ui/components/onboarding/OnboardingScreen.tsx index dacb17c8cf8..86c15f5b274 100644 --- a/apps/cli/src/ui/components/onboarding/OnboardingScreen.tsx +++ b/apps/cli/src/ui/components/onboarding/OnboardingScreen.tsx @@ -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 diff --git a/apps/cli/src/ui/components/tools/BrowserTool.tsx b/apps/cli/src/ui/components/tools/BrowserTool.tsx index ebbd3fb815d..5e6d51857ab 100644 --- a/apps/cli/src/ui/components/tools/BrowserTool.tsx +++ b/apps/cli/src/ui/components/tools/BrowserTool.tsx @@ -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 diff --git a/apps/cli/src/ui/components/tools/CommandTool.tsx b/apps/cli/src/ui/components/tools/CommandTool.tsx index c79a6b3d26d..969836ce958 100644 --- a/apps/cli/src/ui/components/tools/CommandTool.tsx +++ b/apps/cli/src/ui/components/tools/CommandTool.tsx @@ -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" diff --git a/apps/cli/src/ui/components/tools/CompletionTool.tsx b/apps/cli/src/ui/components/tools/CompletionTool.tsx index e116c76903d..cc648902acf 100644 --- a/apps/cli/src/ui/components/tools/CompletionTool.tsx +++ b/apps/cli/src/ui/components/tools/CompletionTool.tsx @@ -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" diff --git a/apps/cli/src/ui/components/tools/FileReadTool.tsx b/apps/cli/src/ui/components/tools/FileReadTool.tsx index 332b5335be5..b61e443614f 100644 --- a/apps/cli/src/ui/components/tools/FileReadTool.tsx +++ b/apps/cli/src/ui/components/tools/FileReadTool.tsx @@ -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" diff --git a/apps/cli/src/ui/components/tools/FileWriteTool.tsx b/apps/cli/src/ui/components/tools/FileWriteTool.tsx index 2264b65b83a..0523f2f696a 100644 --- a/apps/cli/src/ui/components/tools/FileWriteTool.tsx +++ b/apps/cli/src/ui/components/tools/FileWriteTool.tsx @@ -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" diff --git a/apps/cli/src/ui/components/tools/GenericTool.tsx b/apps/cli/src/ui/components/tools/GenericTool.tsx index 78eeaaf791d..00f835d8ad2 100644 --- a/apps/cli/src/ui/components/tools/GenericTool.tsx +++ b/apps/cli/src/ui/components/tools/GenericTool.tsx @@ -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" diff --git a/apps/cli/src/ui/components/tools/SearchTool.tsx b/apps/cli/src/ui/components/tools/SearchTool.tsx index 6761cca217b..4b55607a6f2 100644 --- a/apps/cli/src/ui/components/tools/SearchTool.tsx +++ b/apps/cli/src/ui/components/tools/SearchTool.tsx @@ -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 diff --git a/apps/cli/src/ui/components/tools/__tests__/CommandTool.test.tsx b/apps/cli/src/ui/components/tools/__tests__/CommandTool.test.tsx index 04064e6487a..ea097e9ef1c 100644 --- a/apps/cli/src/ui/components/tools/__tests__/CommandTool.test.tsx +++ b/apps/cli/src/ui/components/tools/__tests__/CommandTool.test.tsx @@ -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", () => { diff --git a/apps/cli/src/ui/components/tools/types.ts b/apps/cli/src/ui/components/tools/types.ts index 65c79633077..28a1b5faa02 100644 --- a/apps/cli/src/ui/components/tools/types.ts +++ b/apps/cli/src/ui/components/tools/types.ts @@ -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"] diff --git a/apps/cli/src/ui/components/tools/utils.ts b/apps/cli/src/ui/components/tools/utils.ts index 235e7430675..5eaee33b127 100644 --- a/apps/cli/src/ui/components/tools/utils.ts +++ b/apps/cli/src/ui/components/tools/utils.ts @@ -1,7 +1,3 @@ -/** - * Utility functions for tool rendering - */ - import type { IconName } from "../Icon.js" /** diff --git a/apps/cli/src/ui/hooks/useExtensionHost.ts b/apps/cli/src/ui/hooks/useExtensionHost.ts index 35770f0480b..949fe0a5a6d 100644 --- a/apps/cli/src/ui/hooks/useExtensionHost.ts +++ b/apps/cli/src/ui/hooks/useExtensionHost.ts @@ -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 diff --git a/apps/cli/src/ui/hooks/useGlobalInput.ts b/apps/cli/src/ui/hooks/useGlobalInput.ts index 31b2deb71d0..31d0f1b8406 100644 --- a/apps/cli/src/ui/hooks/useGlobalInput.ts +++ b/apps/cli/src/ui/hooks/useGlobalInput.ts @@ -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" diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index 7a8eac19aac..c4f8a15a490 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -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"] diff --git a/apps/cli/vitest.config.ts b/apps/cli/vitest.config.ts index 63c3348dd0d..5b6e725d8c6 100644 --- a/apps/cli/vitest.config.ts +++ b/apps/cli/vitest.config.ts @@ -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", diff --git a/packages/core/src/custom-tools/__tests__/custom-tool-registry.spec.ts b/packages/core/src/custom-tools/__tests__/custom-tool-registry.spec.ts index 967ae2e8df7..c1838440c24 100644 --- a/packages/core/src/custom-tools/__tests__/custom-tool-registry.spec.ts +++ b/packages/core/src/custom-tools/__tests__/custom-tool-registry.spec.ts @@ -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)