diff --git a/apps/cli/docs/AGENT_LOOP.md b/apps/cli/docs/AGENT_LOOP.md new file mode 100644 index 0000000000..8dcb0651f2 --- /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 cf05e0755a..0000000000 --- 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 cc146259e9..aa9649373d 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, "../../../..") +} + +function isCliBuilt(): boolean { + return fs.existsSync(path.join(findCliRoot(), "dist", "index.js")) +} + +function isExtensionBuilt(): boolean { + const monorepoRoot = findMonorepoRoot() + const extensionPath = path.join(monorepoRoot, "src/dist") + return fs.existsSync(path.join(extensionPath, "extension.js")) +} + +function buildCliIfNeeded(): void { + if (!isCliBuilt()) { + execSync("pnpm build", { cwd: findCliRoot(), stdio: "inherit" }) + console.log("CLI build complete.") } } -const extensionPath = findExtensionPath() -const hasExtension = !!extensionPath -const ripgrepAvailable = hasRipgrep() - -// Create a temporary workspace directory for tests -function createTempWorkspace(): string { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "roo-cli-test-")) - return tempDir -} - -// Clean up temporary workspace -function cleanupWorkspace(workspacePath: string): void { - try { - fs.rmSync(workspacePath, { recursive: true, force: true }) - } catch { - // Ignore cleanup errors +function buildExtensionIfNeeded(): void { + if (!isExtensionBuilt()) { + execSync("pnpm --filter roo-cline bundle", { cwd: findMonorepoRoot(), stdio: "inherit" }) + console.log("Extension build complete.") } } -describe.skipIf(!RUN_INTEGRATION_TESTS || !hasApiKey || !hasExtension || !ripgrepAvailable)( - "CLI Integration Tests (requires RUN_CLI_INTEGRATION_TESTS=true, OPENROUTER_API_KEY, built extension, and ripgrep)", - () => { - let workspacePath: string - let host: ExtensionHost +function runCli( + args: string[], + options: { timeout?: number } = {}, +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + return new Promise((resolve) => { + const timeout = options.timeout ?? 60000 - beforeAll(() => { - console.log("Integration tests running with:") - console.log(` - API Key: ${OPENROUTER_API_KEY?.substring(0, 12)}...`) - console.log(` - Extension Path: ${extensionPath}`) + let stdout = "" + let stderr = "" + let timedOut = false + + const proc: ChildProcess = spawn("pnpm", ["start", ...args], { + cwd: findCliRoot(), + env: { ...process.env, OPENROUTER_API_KEY, NO_COLOR: "1", FORCE_COLOR: "0" }, + stdio: ["pipe", "pipe", "pipe"], }) - beforeEach(() => { - workspacePath = createTempWorkspace() + const timeoutId = setTimeout(() => { + timedOut = true + proc.kill("SIGTERM") + }, timeout) + + proc.stdout?.on("data", (data: Buffer) => { + stdout += data.toString() }) - afterEach(async () => { - if (host) { - await host.dispose() - } - cleanupWorkspace(workspacePath) + proc.stderr?.on("data", (data: Buffer) => { + stderr += data.toString() }) - /** - * Main integration test - tests the complete end-to-end flow - * - * NOTE: Due to the extension using singletons (TelemetryService, etc.), - * only one integration test can run per process. This single test covers - * the main functionality: activation, task execution, completion, and disposal. - */ - it("should complete end-to-end task execution with proper lifecycle", async () => { - host = new ExtensionHost({ - mode: "code", - user: null, - provider: "openrouter", - apiKey: OPENROUTER_API_KEY!, - model: "anthropic/claude-haiku-4.5", // Use fast, cheap model for tests. - workspacePath, - extensionPath: extensionPath!, - }) + proc.on("close", (code: number | null) => { + clearTimeout(timeoutId) + resolve({ stdout, stderr, exitCode: timedOut ? -1 : (code ?? 1) }) + }) - // Test activation - await host.activate() + proc.on("error", (error: Error) => { + clearTimeout(timeoutId) + stderr += error.message + resolve({ stdout, stderr, exitCode: 1 }) + }) + }) +} - // Track state messages - const stateMessages: unknown[] = [] - host.on("extensionWebviewMessage", (msg: Record) => { - 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 +describe.skipIf(!RUN_INTEGRATION_TESTS || !hasApiKey)("CLI Integration Tests", () => { + beforeAll(() => { + buildExtensionIfNeeded() + buildCliIfNeeded() }) - it("should have OPENROUTER_API_KEY check", () => { - if (hasApiKey) { - console.log("OPENROUTER_API_KEY is set") - } else { - console.log("OPENROUTER_API_KEY is not set, integration tests will be skipped") - } - expect(true).toBe(true) // Always passes - }) + it("should complete end-to-end task execution via CLI", async () => { + const result = await runCli( + ["--no-tui", "-m", "anthropic/claude-sonnet-4.5", "-M", "ask", "-r", "disabled", "-P", "1+1=?"], + { timeout: 30_000 }, + ) - it("should have extension check", () => { - if (hasExtension) { - console.log(`Extension found at: ${extensionPath}`) - } else { - console.log("Extension not found, integration tests will be skipped") - } - expect(true).toBe(true) // Always passes - }) + console.log("CLI stdout:", result.stdout) - it("should have ripgrep check", () => { - if (ripgrepAvailable) { - console.log("ripgrep is available") - } else { - console.log("ripgrep not found, integration tests will be skipped") + if (result.stderr) { + console.log("CLI stderr:", result.stderr) } - expect(true).toBe(true) // Always passes - }) + + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain("2") + expect(result.stdout).toContain("[task complete]") + }, 30_000) }) 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 2bbbd6fbcc..03de87c489 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 4c51f84a45..1691bf2bb6 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 35235c3df0..ca4a099cca 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 f7889c3570..8d57e4547c 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 b9eaf929fa..1934993feb 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 a1c5397cb2..8efc346057 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 05096f97ee..5e569f42c1 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" +import type { SupportedProvider } from "@/types/index.js" +import type { User } from "@/lib/sdk/index.js" +import { getProviderSettings } from "@/lib/utils/provider.js" -// Client module - single source of truth for agent state -import { - type AgentStateInfo, - type AgentStateChangeEvent, - type WaitingForInputEvent, - type TaskCompletedEvent, - type ClineMessage, - type ExtensionMessage, - ExtensionClient, - AgentLoopState, -} from "../extension-client/index.js" - -// Managers for output, prompting, and ask handling +import type { AgentStateChangeEvent, WaitingForInputEvent, TaskCompletedEvent } from "./events.js" +import { type AgentStateInfo, AgentLoopState } from "./agent-state.js" +import { ExtensionClient } from "./extension-client.js" import { OutputManager } from "./output-manager.js" import { PromptManager } from "./prompt-manager.js" import { AskDispatcher } from "./ask-dispatcher.js" -// Pre-configured logger for CLI message activity debugging +// Pre-configured logger for CLI message activity debugging. const cliLogger = new DebugLogger("CLI") // Get the CLI package root directory (for finding node_modules/@vscode/ripgrep) @@ -79,6 +76,11 @@ export interface ExtensionHostOptions { * When true, uses a temporary storage directory that is cleaned up on exit. */ ephemeral?: boolean + /** + * When true, don't suppress node warnings and console output since we're + * running in an integration test and we want to see the output. + */ + integrationTest?: boolean } interface ExtensionModule { @@ -155,36 +157,37 @@ export class ExtensionHost extends EventEmitter { constructor(options: ExtensionHostOptions) { super() + this.options = options this.currentMode = options.mode || null - // Initialize client - single source of truth for agent state + // Initialize client - single source of truth for agent state. this.client = new ExtensionClient({ sendMessage: (msg) => this.sendToExtension(msg), - debug: options.debug, // Enable debug logging in the client + debug: options.debug, // Enable debug logging in the client. }) - // Initialize output manager + // Initialize output manager. this.outputManager = new OutputManager({ disabled: options.disableOutput, }) - // Initialize prompt manager with console mode callbacks + // Initialize prompt manager with console mode callbacks. this.promptManager = new PromptManager({ onBeforePrompt: () => this.restoreConsole(), onAfterPrompt: () => this.setupQuietMode(), }) - // Initialize ask dispatcher + // Initialize ask dispatcher. this.askDispatcher = new AskDispatcher({ outputManager: this.outputManager, promptManager: this.promptManager, sendMessage: (msg) => this.sendToExtension(msg), nonInteractive: options.nonInteractive, - disabled: options.disableOutput, // TUI mode handles asks directly + disabled: options.disableOutput, // TUI mode handles asks directly. }) - // Wire up client events + // Wire up client events. this.setupClientEventHandlers() } @@ -197,30 +200,30 @@ export class ExtensionHost extends EventEmitter { * The client emits events, managers handle them. */ private setupClientEventHandlers(): void { - // Forward state changes for external consumers + // Forward state changes for external consumers. this.client.on("stateChange", (event: AgentStateChangeEvent) => { this.emit("agentStateChange", event) }) - // Handle new messages - delegate to OutputManager + // Handle new messages - delegate to OutputManager. this.client.on("message", (msg: ClineMessage) => { this.logMessageDebug(msg, "new") this.outputManager.outputMessage(msg) }) - // Handle message updates - delegate to OutputManager + // Handle message updates - delegate to OutputManager. this.client.on("messageUpdated", (msg: ClineMessage) => { this.logMessageDebug(msg, "updated") this.outputManager.outputMessage(msg) }) - // Handle waiting for input - delegate to AskDispatcher + // Handle waiting for input - delegate to AskDispatcher. this.client.on("waitingForInput", (event: WaitingForInputEvent) => { this.emit("agentWaitingForInput", event) - this.handleWaitingForInput(event) + this.askDispatcher.handleAsk(event.message) }) - // Handle task completion + // Handle task completion. this.client.on("taskCompleted", (event: TaskCompletedEvent) => { this.emit("agentTaskCompleted", event) this.handleTaskCompleted(event) @@ -242,25 +245,17 @@ export class ExtensionHost extends EventEmitter { } } - /** - * Handle waiting for input - delegate to AskDispatcher. - */ - private handleWaitingForInput(event: WaitingForInputEvent): void { - // AskDispatcher handles all ask logic - this.askDispatcher.handleAsk(event.message) - } - /** * Handle task completion. */ private handleTaskCompleted(event: TaskCompletedEvent): void { - // Output completion message via OutputManager - // Note: completion_result is an "ask" type, not a "say" type + // Output completion message via OutputManager. + // Note: completion_result is an "ask" type, not a "say" type. if (event.message && event.message.type === "ask" && event.message.ask === "completion_result") { this.outputManager.outputCompletionResult(event.message.ts, event.message.text || "") } - // Emit taskComplete for waitForCompletion + // Emit taskComplete for waitForCompletion. this.emit("taskComplete") } @@ -275,6 +270,10 @@ export class ExtensionHost extends EventEmitter { } private setupQuietMode(): void { + if (this.options.integrationTest) { + return + } + this.originalConsole = { log: console.log, warn: console.warn, @@ -282,6 +281,7 @@ export class ExtensionHost extends EventEmitter { debug: console.debug, info: console.info, } + console.log = () => {} console.warn = () => {} console.debug = () => {} @@ -289,6 +289,10 @@ export class ExtensionHost extends EventEmitter { } private restoreConsole(): void { + if (this.options.integrationTest) { + return + } + if (this.originalConsole) { console.log = this.originalConsole.log console.warn = this.originalConsole.warn @@ -320,18 +324,20 @@ export class ExtensionHost extends EventEmitter { this.setupQuietMode() const bundlePath = path.join(this.options.extensionPath, "extension.js") + if (!fs.existsSync(bundlePath)) { this.restoreConsole() throw new Error(`Extension bundle not found at: ${bundlePath}`) } let storageDir: string | undefined + if (this.options.ephemeral) { storageDir = await this.createEphemeralStorageDir() this.ephemeralStorageDir = storageDir } - // Create VSCode API mock + // Create VSCode API mock. this.vscode = createVSCodeAPI(this.options.extensionPath, this.options.workspacePath, undefined, { appRoot: CLI_PACKAGE_ROOT, storageDir, @@ -339,7 +345,7 @@ export class ExtensionHost extends EventEmitter { ;(global as Record).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 e44987229d..9ae298caf0 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 10ff4fe0b9..0863546f6c 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 9c4fc78f01..d502e7bae0 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 15e0479cb7..14966f2d15 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 4ddd80025b..61c3cb37a4 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 e45a636414..9e81adfda8 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 a091271a71..5b305ce275 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 { + FlagOptions, + isSupportedProvider, + OnboardingProviderChoice, + supportedProviders, + ASCII_ROO, + DEFAULT_FLAGS, + REASONING_EFFORTS, + SDK_BASE_URL, +} from "@/types/index.js" -import { ExtensionHost, ExtensionHostOptions } from "../../extension-host/index.js" +import { type User, createClient } from "@/lib/sdk/index.js" +import { loadToken, hasToken, loadSettings } from "@/lib/storage/index.js" +import { getEnvVarName, getApiKeyFromEnv } from "@/lib/utils/provider.js" +import { runOnboarding } from "@/lib/utils/onboarding.js" +import { getDefaultExtensionPath } from "@/lib/utils/extension.js" +import { VERSION } from "@/lib/utils/version.js" -import { type User, createClient } from "../../lib/sdk/index.js" -import { loadToken, hasToken, loadSettings } from "../../lib/storage/index.js" -import { getEnvVarName, getApiKeyFromEnv, getDefaultExtensionPath } from "../../extension-host/utils.js" -import { runOnboarding } from "../../lib/utils/onboarding.js" -import { VERSION } from "../../lib/utils/version.js" +import { ExtensionHost, ExtensionHostOptions } from "@/agent/index.js" const __dirname = path.dirname(fileURLToPath(import.meta.url)) @@ -29,6 +38,7 @@ export async function run(workspaceArg: string, options: FlagOptions) { }) const isTuiSupported = process.stdin.isTTY && process.stdout.isTTY + const isTuiEnabled = options.tui && isTuiSupported const extensionPath = options.extension || getDefaultExtensionPath(__dirname) const workspacePath = path.resolve(workspaceArg) @@ -45,7 +55,7 @@ export async function run(workspaceArg: string, options: FlagOptions) { let user: User | null = null let useCloudProvider = false - if (isTuiSupported) { + if (isTuiEnabled) { let { onboardingProviderChoice } = await loadSettings() if (!onboardingProviderChoice) { @@ -68,7 +78,7 @@ export async function run(workspaceArg: string, options: FlagOptions) { apiKey = token user = me?.type === "user" ? me.user : null } catch { - // Token may be expired or invalid - user will need to re-authenticate + // Token may be expired or invalid - user will need to re-authenticate. } } } @@ -86,6 +96,7 @@ export async function run(workspaceArg: string, options: FlagOptions) { ) console.error(`[CLI] For ${provider}, set ${getEnvVarName(provider)}`) } + process.exit(1) } @@ -106,20 +117,18 @@ export async function run(workspaceArg: string, options: FlagOptions) { process.exit(1) } - const useTui = options.tui && isTuiSupported - if (options.tui && !isTuiSupported) { console.log("[CLI] TUI disabled (no TTY support), falling back to plain text mode") } - if (!useTui && !options.prompt) { + if (!isTuiEnabled && !options.prompt) { console.error("[CLI] Error: prompt is required in plain text mode") console.error("[CLI] Usage: roo [workspace] -P [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 82d98f1990..0000000000 --- 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 fee429fc0d..0000000000 --- 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 fad07e6876..8d3f5af521 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 c42260d9bc..86a2d9243e 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 419a4fcaf5..31fdbe87f0 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 0000000000..70d8a2a555 --- /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 6112cf4dd2..c1224c8b1e 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 4c958825d2..904940ec00 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 92fa11f55b..176bc6a344 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 0000000000..64aec430c1 --- /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 5fcddaf836..cd64c9b162 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 c0cb97d244..7ffb425c22 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 8b52a2b160..c51b0faddb 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 045ce3c67d..987ff9179d 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 50b16aea00..e46bf12c0a 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 b17e48440f..f551d1a266 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 d86d969599..864d48650d 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 58c99e2ed9..9684fd304f 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 2f2fbd5bf9..3ebb51d226 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 1a7886f3c8..1741a88d22 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 dacb17c8cf..86c15f5b27 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 ebbd3fb815..5e6d51857a 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 c79a6b3d26..969836ce95 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 e116c76903..cc648902ac 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 332b5335be..b61e443614 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 2264b65b83..0523f2f696 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 78eeaaf791..00f835d8ad 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 6761cca217..4b55607a6f 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 04064e6487..ea097e9ef1 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 65c7963307..28a1b5faa0 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 235e743067..5eaee33b12 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 35770f0480..949fe0a5a6 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 31b2deb71d..31d0f1b840 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 7a8eac19aa..c4f8a15a49 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 63c3348dd0..5b6e725d8c 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 967ae2e8df..c1838440c2 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)