mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-10 22:41:14 +00:00
Organize files
This commit is contained in:
parent
cf14c73095
commit
1c61e7f0c5
68 changed files with 6459 additions and 2449 deletions
344
apps/cli/docs/AGENT_STATE_DETECTION.md
Normal file
344
apps/cli/docs/AGENT_STATE_DETECTION.md
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
# CLI Agent State Detection
|
||||
|
||||
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**
|
||||
|
|
@ -7,7 +7,9 @@
|
|||
* Run with: OPENROUTER_API_KEY=sk-or-v1-... pnpm test
|
||||
*/
|
||||
|
||||
import { ExtensionHost } from "../extension-host.js"
|
||||
// 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"
|
||||
|
|
@ -3,8 +3,8 @@ import { randomBytes } from "crypto"
|
|||
import net from "net"
|
||||
import { exec } from "child_process"
|
||||
|
||||
import { AUTH_BASE_URL } from "../../constants.js"
|
||||
import { saveToken } from "../../storage/credentials.js"
|
||||
import { AUTH_BASE_URL } from "../../types/constants.js"
|
||||
import { saveToken } from "../../lib/storage/credentials.js"
|
||||
|
||||
export interface LoginOptions {
|
||||
timeout?: number
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
import { clearToken } from "../../storage/credentials.js"
|
||||
import { hasToken } from "../../storage/credentials.js"
|
||||
import { getCredentialsPath } from "../../storage/credentials.js"
|
||||
import { clearToken, hasToken, getCredentialsPath } from "../../lib/storage/credentials.js"
|
||||
|
||||
export interface LogoutOptions {
|
||||
verbose?: boolean
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { loadToken, loadCredentials, getCredentialsPath } from "../../storage/credentials.js"
|
||||
import { isTokenExpired, isTokenValid, getTokenExpirationDate } from "../../utils/auth-token.js"
|
||||
import { loadToken, loadCredentials, getCredentialsPath } from "../../lib/storage/credentials.js"
|
||||
import { isTokenExpired, isTokenValid, getTokenExpirationDate } from "../../lib/auth/token.js"
|
||||
|
||||
export interface StatusOptions {
|
||||
verbose?: boolean
|
||||
|
|
|
|||
1
apps/cli/src/commands/cli/index.ts
Normal file
1
apps/cli/src/commands/cli/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./run.js"
|
||||
210
apps/cli/src/commands/cli/run.ts
Normal file
210
apps/cli/src/commands/cli/run.ts
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
import { createElement } from "react"
|
||||
|
||||
import { isProviderName } from "@roo-code/types"
|
||||
import { setLogger } from "@roo-code/vscode-shim"
|
||||
|
||||
import { FlagOptions, isSupportedProvider, OnboardingProviderChoice, supportedProviders } from "../../types/types.js"
|
||||
import { ASCII_ROO, DEFAULT_FLAGS, REASONING_EFFORTS, SDK_BASE_URL } from "../../types/constants.js"
|
||||
|
||||
import { ExtensionHost, ExtensionHostOptions } from "../../extension-host/index.js"
|
||||
|
||||
import { type User, createClient } from "../../lib/sdk/index.js"
|
||||
import { loadToken, hasToken, loadSettings } from "../../lib/storage/index.js"
|
||||
import { getEnvVarName, getApiKeyFromEnv, getDefaultExtensionPath } from "../../extension-host/utils.js"
|
||||
import { runOnboarding } from "../../lib/utils/onboarding.js"
|
||||
import { VERSION } from "../../lib/utils/version.js"
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
export async function run(workspaceArg: string, options: FlagOptions) {
|
||||
setLogger({
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
debug: () => {},
|
||||
})
|
||||
|
||||
const isTuiSupported = process.stdin.isTTY && process.stdout.isTTY
|
||||
const extensionPath = options.extension || getDefaultExtensionPath(__dirname)
|
||||
const workspacePath = path.resolve(workspaceArg)
|
||||
|
||||
if (!isSupportedProvider(options.provider)) {
|
||||
console.error(
|
||||
`[CLI] Error: Invalid provider: ${options.provider}; must be one of: ${supportedProviders.join(", ")}`,
|
||||
)
|
||||
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
let apiKey = options.apiKey || getApiKeyFromEnv(options.provider)
|
||||
let provider = options.provider
|
||||
let user: User | null = null
|
||||
let useCloudProvider = false
|
||||
|
||||
if (isTuiSupported) {
|
||||
let { onboardingProviderChoice } = await loadSettings()
|
||||
|
||||
if (!onboardingProviderChoice) {
|
||||
const result = await runOnboarding()
|
||||
onboardingProviderChoice = result.choice
|
||||
}
|
||||
|
||||
if (onboardingProviderChoice === OnboardingProviderChoice.Roo) {
|
||||
useCloudProvider = true
|
||||
const authenticated = await hasToken()
|
||||
|
||||
if (authenticated) {
|
||||
const token = await loadToken()
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
const client = createClient({ url: SDK_BASE_URL, authToken: token })
|
||||
const me = await client.auth.me.query()
|
||||
provider = "roo"
|
||||
apiKey = token
|
||||
user = me?.type === "user" ? me.user : null
|
||||
} catch {
|
||||
// Token may be expired or invalid - user will need to re-authenticate
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
if (useCloudProvider) {
|
||||
console.error("[CLI] Error: Authentication with Roo Code Cloud failed or was cancelled.")
|
||||
console.error("[CLI] Please run: roo auth login")
|
||||
console.error("[CLI] Or use --api-key to provide your own API key.")
|
||||
} else {
|
||||
console.error(
|
||||
`[CLI] Error: No API key provided. Use --api-key or set the appropriate environment variable.`,
|
||||
)
|
||||
console.error(`[CLI] For ${provider}, set ${getEnvVarName(provider)}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (!fs.existsSync(workspacePath)) {
|
||||
console.error(`[CLI] Error: Workspace path does not exist: ${workspacePath}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (!isProviderName(options.provider)) {
|
||||
console.error(`[CLI] Error: Invalid provider: ${options.provider}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (options.reasoningEffort && !REASONING_EFFORTS.includes(options.reasoningEffort)) {
|
||||
console.error(
|
||||
`[CLI] Error: Invalid reasoning effort: ${options.reasoningEffort}, must be one of: ${REASONING_EFFORTS.join(", ")}`,
|
||||
)
|
||||
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) {
|
||||
console.error("[CLI] Error: prompt is required in plain text mode")
|
||||
console.error("[CLI] Usage: roo [workspace] -P <prompt> [options]")
|
||||
console.error("[CLI] Use TUI mode (without --no-tui) for interactive input")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (useTui) {
|
||||
try {
|
||||
const { render } = await import("ink")
|
||||
const { App } = await import("../../ui/App.js")
|
||||
|
||||
render(
|
||||
createElement(App, {
|
||||
initialPrompt: options.prompt || "",
|
||||
workspacePath: workspacePath,
|
||||
extensionPath: path.resolve(extensionPath),
|
||||
user,
|
||||
provider,
|
||||
apiKey,
|
||||
model: options.model || DEFAULT_FLAGS.model,
|
||||
mode: options.mode || DEFAULT_FLAGS.mode,
|
||||
nonInteractive: options.yes,
|
||||
debug: options.debug,
|
||||
exitOnComplete: options.exitOnComplete,
|
||||
reasoningEffort: options.reasoningEffort,
|
||||
ephemeral: options.ephemeral,
|
||||
version: VERSION,
|
||||
// Create extension host factory for dependency injection.
|
||||
createExtensionHost: (opts: ExtensionHostOptions) => new ExtensionHost(opts),
|
||||
}),
|
||||
// Handle Ctrl+C in App component for double-press exit.
|
||||
{ exitOnCtrlC: false },
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("[CLI] Failed to start TUI:", error instanceof Error ? error.message : String(error))
|
||||
|
||||
if (error instanceof Error) {
|
||||
console.error(error.stack)
|
||||
}
|
||||
|
||||
process.exit(1)
|
||||
}
|
||||
} else {
|
||||
console.log(ASCII_ROO)
|
||||
console.log()
|
||||
console.log(
|
||||
`[roo] Running ${options.model || "default"} (${options.reasoningEffort || "default"}) on ${provider} in ${options.mode || "default"} mode in ${workspacePath}`,
|
||||
)
|
||||
|
||||
const host = new ExtensionHost({
|
||||
mode: options.mode || DEFAULT_FLAGS.mode,
|
||||
reasoningEffort: options.reasoningEffort === "unspecified" ? undefined : options.reasoningEffort,
|
||||
user,
|
||||
provider,
|
||||
apiKey,
|
||||
model: options.model || DEFAULT_FLAGS.model,
|
||||
workspacePath,
|
||||
extensionPath: path.resolve(extensionPath),
|
||||
nonInteractive: options.yes,
|
||||
ephemeral: options.ephemeral,
|
||||
debug: options.debug,
|
||||
})
|
||||
|
||||
process.on("SIGINT", async () => {
|
||||
console.log("\n[CLI] Received SIGINT, shutting down...")
|
||||
await host.dispose()
|
||||
process.exit(130)
|
||||
})
|
||||
|
||||
process.on("SIGTERM", async () => {
|
||||
console.log("\n[CLI] Received SIGTERM, shutting down...")
|
||||
await host.dispose()
|
||||
process.exit(143)
|
||||
})
|
||||
|
||||
try {
|
||||
await host.activate()
|
||||
await host.runTask(options.prompt!)
|
||||
await host.dispose()
|
||||
|
||||
if (!options.waitOnComplete) {
|
||||
process.exit(0)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[CLI] Error:", error instanceof Error ? error.message : String(error))
|
||||
|
||||
if (error instanceof Error) {
|
||||
console.error(error.stack)
|
||||
}
|
||||
|
||||
await host.dispose()
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +1,2 @@
|
|||
export * from "./auth/index.js"
|
||||
export * from "./cli/index.js"
|
||||
|
|
|
|||
453
apps/cli/src/extension-client/agent-state.ts
Normal file
453
apps/cli/src/extension-client/agent-state.ts
Normal file
|
|
@ -0,0 +1,453 @@
|
|||
/**
|
||||
* Agent Loop State Detection
|
||||
*
|
||||
* This module provides the core logic for detecting the current state of the
|
||||
* Roo Code agent loop. The state is determined by analyzing the clineMessages
|
||||
* array, specifically the last message's type and properties.
|
||||
*
|
||||
* Key insight: 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.
|
||||
*/
|
||||
|
||||
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 }
|
||||
|
||||
// =============================================================================
|
||||
// Agent Loop State Enum
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* The possible states of the agent loop.
|
||||
*
|
||||
* State Machine:
|
||||
* ```
|
||||
* ┌─────────────────┐
|
||||
* │ NO_TASK │ (initial state)
|
||||
* └────────┬────────┘
|
||||
* │ newTask
|
||||
* ▼
|
||||
* ┌─────────────────────────────┐
|
||||
* ┌───▶│ RUNNING │◀────┐
|
||||
* │ └──────────┬──────────────────┘ │
|
||||
* │ │ │
|
||||
* │ ┌──────────┼──────────────┐ │
|
||||
* │ │ │ │ │
|
||||
* │ ▼ ▼ ▼ │
|
||||
* │ ┌──────┐ ┌─────────┐ ┌──────────┐ │
|
||||
* │ │STREAM│ │INTERACT │ │ IDLE │ │
|
||||
* │ │ ING │ │ IVE │ │ │ │
|
||||
* │ └──┬───┘ └────┬────┘ └────┬─────┘ │
|
||||
* │ │ │ │ │
|
||||
* │ │ done │ approved │ newTask │
|
||||
* └────┴───────────┴────────────┘ │
|
||||
* │
|
||||
* ┌──────────────┐ │
|
||||
* │ RESUMABLE │────────────────────────┘
|
||||
* └──────────────┘ resumed
|
||||
* ```
|
||||
*/
|
||||
export enum AgentLoopState {
|
||||
/**
|
||||
* No active task. This is the initial state before any task is started,
|
||||
* or after a task has been cleared.
|
||||
*/
|
||||
NO_TASK = "no_task",
|
||||
|
||||
/**
|
||||
* Agent is actively processing. This means:
|
||||
* - The last message is a "say" type (informational), OR
|
||||
* - The last message is a non-blocking ask (command_output)
|
||||
*
|
||||
* In this state, the agent may be:
|
||||
* - Executing tools
|
||||
* - Thinking/reasoning
|
||||
* - Processing between API calls
|
||||
*/
|
||||
RUNNING = "running",
|
||||
|
||||
/**
|
||||
* Agent is streaming a response. This is detected when:
|
||||
* - `partial === true` on the last message, OR
|
||||
* - The last `api_req_started` message has no `cost` in its text field
|
||||
*
|
||||
* Do NOT consider the agent "waiting" while streaming.
|
||||
*/
|
||||
STREAMING = "streaming",
|
||||
|
||||
/**
|
||||
* Agent is waiting for user approval or input. This includes:
|
||||
* - Tool approvals (file operations)
|
||||
* - Command execution permission
|
||||
* - Browser action permission
|
||||
* - MCP server permission
|
||||
* - Follow-up questions
|
||||
*
|
||||
* User must approve, reject, or provide input to continue.
|
||||
*/
|
||||
WAITING_FOR_INPUT = "waiting_for_input",
|
||||
|
||||
/**
|
||||
* Task is in an idle/terminal state. This includes:
|
||||
* - Task completed successfully (completion_result)
|
||||
* - API request failed (api_req_failed)
|
||||
* - Too many errors (mistake_limit_reached)
|
||||
* - Auto-approval limit reached
|
||||
* - Completed task waiting to be resumed
|
||||
*
|
||||
* User can start a new task or retry.
|
||||
*/
|
||||
IDLE = "idle",
|
||||
|
||||
/**
|
||||
* Task is paused and can be resumed. This happens when:
|
||||
* - User navigated away from a task
|
||||
* - Extension was restarted mid-task
|
||||
*
|
||||
* User can resume or abandon the task.
|
||||
*/
|
||||
RESUMABLE = "resumable",
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Detailed State Info
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* What action the user should/can take in the current state.
|
||||
*/
|
||||
export type RequiredAction =
|
||||
| "none" // No action needed (running/streaming)
|
||||
| "approve" // Can approve/reject (tool, command, browser, mcp)
|
||||
| "answer" // Need to answer a question (followup)
|
||||
| "retry_or_new_task" // Can retry or start new task (api_req_failed)
|
||||
| "proceed_or_new_task" // Can proceed or start new task (mistake_limit)
|
||||
| "start_task" // Should start a new task (completion_result)
|
||||
| "resume_or_abandon" // Can resume or abandon (resume_task)
|
||||
| "start_new_task" // Should start new task (resume_completed_task, no_task)
|
||||
| "continue_or_abort" // Can continue or abort (command_output)
|
||||
|
||||
/**
|
||||
* Detailed information about the current agent state.
|
||||
* Provides everything needed to render UI or make decisions.
|
||||
*/
|
||||
export interface AgentStateInfo {
|
||||
/** The high-level state of the agent loop */
|
||||
state: AgentLoopState
|
||||
|
||||
/** Whether the agent is waiting for user input/action */
|
||||
isWaitingForInput: boolean
|
||||
|
||||
/** Whether the agent loop is actively processing */
|
||||
isRunning: boolean
|
||||
|
||||
/** Whether content is being streamed */
|
||||
isStreaming: boolean
|
||||
|
||||
/** The specific ask type if waiting on an ask, undefined otherwise */
|
||||
currentAsk?: ClineAsk
|
||||
|
||||
/** What action the user should/can take */
|
||||
requiredAction: RequiredAction
|
||||
|
||||
/** The timestamp of the last message, useful for tracking */
|
||||
lastMessageTs?: number
|
||||
|
||||
/** The full last message for advanced usage */
|
||||
lastMessage?: ClineMessage
|
||||
|
||||
/** Human-readable description of the current state */
|
||||
description: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// State Detection Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Check if an API request is still in progress (streaming).
|
||||
*
|
||||
* API requests are considered in-progress when:
|
||||
* - An api_req_started message exists
|
||||
* - Its text field, when parsed, has `cost: undefined`
|
||||
*
|
||||
* 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)
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const message = messages[i]
|
||||
if (!message) continue
|
||||
if (message.say === "api_req_started") {
|
||||
if (!message.text) {
|
||||
// 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
|
||||
return data.cost === undefined
|
||||
} catch {
|
||||
// Parse error - assume not in progress
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the required action based on the current ask type.
|
||||
*/
|
||||
function getRequiredAction(ask: ClineAsk): RequiredAction {
|
||||
switch (ask) {
|
||||
case "followup":
|
||||
return "answer"
|
||||
case "command":
|
||||
case "tool":
|
||||
case "browser_action_launch":
|
||||
case "use_mcp_server":
|
||||
return "approve"
|
||||
case "command_output":
|
||||
return "continue_or_abort"
|
||||
case "api_req_failed":
|
||||
return "retry_or_new_task"
|
||||
case "mistake_limit_reached":
|
||||
return "proceed_or_new_task"
|
||||
case "completion_result":
|
||||
return "start_task"
|
||||
case "resume_task":
|
||||
return "resume_or_abandon"
|
||||
case "resume_completed_task":
|
||||
case "auto_approval_max_req_reached":
|
||||
return "start_new_task"
|
||||
default:
|
||||
return "none"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a human-readable description for the current state.
|
||||
*/
|
||||
function getStateDescription(state: AgentLoopState, ask?: ClineAsk): string {
|
||||
switch (state) {
|
||||
case AgentLoopState.NO_TASK:
|
||||
return "No active task. Ready to start a new task."
|
||||
|
||||
case AgentLoopState.RUNNING:
|
||||
return "Agent is actively processing."
|
||||
|
||||
case AgentLoopState.STREAMING:
|
||||
return "Agent is streaming a response."
|
||||
|
||||
case AgentLoopState.WAITING_FOR_INPUT:
|
||||
switch (ask) {
|
||||
case "followup":
|
||||
return "Agent is asking a follow-up question. Please provide an answer."
|
||||
case "command":
|
||||
return "Agent wants to execute a command. Approve or reject."
|
||||
case "tool":
|
||||
return "Agent wants to perform a file operation. Approve or reject."
|
||||
case "browser_action_launch":
|
||||
return "Agent wants to use the browser. Approve or reject."
|
||||
case "use_mcp_server":
|
||||
return "Agent wants to use an MCP server. Approve or reject."
|
||||
default:
|
||||
return "Agent is waiting for user input."
|
||||
}
|
||||
|
||||
case AgentLoopState.IDLE:
|
||||
switch (ask) {
|
||||
case "completion_result":
|
||||
return "Task completed successfully. You can provide feedback or start a new task."
|
||||
case "api_req_failed":
|
||||
return "API request failed. You can retry or start a new task."
|
||||
case "mistake_limit_reached":
|
||||
return "Too many errors encountered. You can proceed anyway or start a new task."
|
||||
case "auto_approval_max_req_reached":
|
||||
return "Auto-approval limit reached. Manual approval required."
|
||||
case "resume_completed_task":
|
||||
return "Previously completed task. Start a new task to continue."
|
||||
default:
|
||||
return "Task is idle."
|
||||
}
|
||||
|
||||
case AgentLoopState.RESUMABLE:
|
||||
return "Task is paused. You can resume or start a new task."
|
||||
|
||||
default:
|
||||
return "Unknown state."
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the current state of the agent loop from the clineMessages array.
|
||||
*
|
||||
* This is the main state detection function. It analyzes the messages array
|
||||
* and returns detailed information about the current agent state.
|
||||
*
|
||||
* @param messages - The clineMessages array from extension state
|
||||
* @returns Detailed state information
|
||||
*/
|
||||
export function detectAgentState(messages: ClineMessage[]): AgentStateInfo {
|
||||
// No messages means no task
|
||||
if (!messages || messages.length === 0) {
|
||||
return {
|
||||
state: AgentLoopState.NO_TASK,
|
||||
isWaitingForInput: false,
|
||||
isRunning: false,
|
||||
isStreaming: false,
|
||||
requiredAction: "start_new_task",
|
||||
description: getStateDescription(AgentLoopState.NO_TASK),
|
||||
}
|
||||
}
|
||||
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
|
||||
// Guard against undefined (should never happen after length check, but TypeScript requires it)
|
||||
if (!lastMessage) {
|
||||
return {
|
||||
state: AgentLoopState.NO_TASK,
|
||||
isWaitingForInput: false,
|
||||
isRunning: false,
|
||||
isStreaming: false,
|
||||
requiredAction: "start_new_task",
|
||||
description: getStateDescription(AgentLoopState.NO_TASK),
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the message is still streaming (partial)
|
||||
// This is the PRIMARY indicator of streaming
|
||||
if (lastMessage.partial === true) {
|
||||
return {
|
||||
state: AgentLoopState.STREAMING,
|
||||
isWaitingForInput: false,
|
||||
isRunning: true,
|
||||
isStreaming: true,
|
||||
currentAsk: lastMessage.ask,
|
||||
requiredAction: "none",
|
||||
lastMessageTs: lastMessage.ts,
|
||||
lastMessage,
|
||||
description: getStateDescription(AgentLoopState.STREAMING),
|
||||
}
|
||||
}
|
||||
|
||||
// Handle "ask" type messages
|
||||
if (lastMessage.type === "ask" && lastMessage.ask) {
|
||||
const ask = lastMessage.ask
|
||||
|
||||
// Non-blocking asks (command_output) - agent is running but can be interrupted
|
||||
if (isNonBlockingAsk(ask)) {
|
||||
return {
|
||||
state: AgentLoopState.RUNNING,
|
||||
isWaitingForInput: false,
|
||||
isRunning: true,
|
||||
isStreaming: false,
|
||||
currentAsk: ask,
|
||||
requiredAction: "continue_or_abort",
|
||||
lastMessageTs: lastMessage.ts,
|
||||
lastMessage,
|
||||
description: "Command is running. You can continue or abort.",
|
||||
}
|
||||
}
|
||||
|
||||
// Idle asks - task has stopped
|
||||
if (isIdleAsk(ask)) {
|
||||
return {
|
||||
state: AgentLoopState.IDLE,
|
||||
isWaitingForInput: true, // User needs to decide what to do next
|
||||
isRunning: false,
|
||||
isStreaming: false,
|
||||
currentAsk: ask,
|
||||
requiredAction: getRequiredAction(ask),
|
||||
lastMessageTs: lastMessage.ts,
|
||||
lastMessage,
|
||||
description: getStateDescription(AgentLoopState.IDLE, ask),
|
||||
}
|
||||
}
|
||||
|
||||
// Resumable asks - task is paused
|
||||
if (isResumableAsk(ask)) {
|
||||
return {
|
||||
state: AgentLoopState.RESUMABLE,
|
||||
isWaitingForInput: true,
|
||||
isRunning: false,
|
||||
isStreaming: false,
|
||||
currentAsk: ask,
|
||||
requiredAction: getRequiredAction(ask),
|
||||
lastMessageTs: lastMessage.ts,
|
||||
lastMessage,
|
||||
description: getStateDescription(AgentLoopState.RESUMABLE, ask),
|
||||
}
|
||||
}
|
||||
|
||||
// Interactive asks - waiting for approval/input
|
||||
if (isInteractiveAsk(ask)) {
|
||||
return {
|
||||
state: AgentLoopState.WAITING_FOR_INPUT,
|
||||
isWaitingForInput: true,
|
||||
isRunning: false,
|
||||
isStreaming: false,
|
||||
currentAsk: ask,
|
||||
requiredAction: getRequiredAction(ask),
|
||||
lastMessageTs: lastMessage.ts,
|
||||
lastMessage,
|
||||
description: getStateDescription(AgentLoopState.WAITING_FOR_INPUT, ask),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For "say" type messages, check if API request is in progress
|
||||
if (isApiRequestInProgress(messages)) {
|
||||
return {
|
||||
state: AgentLoopState.STREAMING,
|
||||
isWaitingForInput: false,
|
||||
isRunning: true,
|
||||
isStreaming: true,
|
||||
requiredAction: "none",
|
||||
lastMessageTs: lastMessage.ts,
|
||||
lastMessage,
|
||||
description: getStateDescription(AgentLoopState.STREAMING),
|
||||
}
|
||||
}
|
||||
|
||||
// Default: agent is running
|
||||
return {
|
||||
state: AgentLoopState.RUNNING,
|
||||
isWaitingForInput: false,
|
||||
isRunning: true,
|
||||
isStreaming: false,
|
||||
requiredAction: "none",
|
||||
lastMessageTs: lastMessage.ts,
|
||||
lastMessage,
|
||||
description: getStateDescription(AgentLoopState.RUNNING),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick check: Is the agent waiting for user input?
|
||||
*
|
||||
* This is a convenience function for simple use cases where you just need
|
||||
* to know if user action is required.
|
||||
*/
|
||||
export function isAgentWaitingForInput(messages: ClineMessage[]): boolean {
|
||||
return detectAgentState(messages).isWaitingForInput
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick check: Is the agent actively running (not waiting)?
|
||||
*/
|
||||
export function isAgentRunning(messages: ClineMessage[]): boolean {
|
||||
const state = detectAgentState(messages)
|
||||
return state.isRunning && !state.isWaitingForInput
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick check: Is content currently streaming?
|
||||
*/
|
||||
export function isContentStreaming(messages: ClineMessage[]): boolean {
|
||||
return detectAgentState(messages).isStreaming
|
||||
}
|
||||
809
apps/cli/src/extension-client/client.test.ts
Normal file
809
apps/cli/src/extension-client/client.test.ts
Normal file
|
|
@ -0,0 +1,809 @@
|
|||
/**
|
||||
* 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"
|
||||
|
||||
// =============================================================================
|
||||
// Test Helpers
|
||||
// =============================================================================
|
||||
|
||||
function createMessage(overrides: Partial<ClineMessage>): ClineMessage {
|
||||
return {
|
||||
ts: Date.now() + Math.random() * 1000, // Unique timestamp
|
||||
type: "say",
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function createStateMessage(messages: ClineMessage[]): ExtensionMessage {
|
||||
return {
|
||||
type: "state",
|
||||
state: {
|
||||
clineMessages: messages,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// State Detection Tests
|
||||
// =============================================================================
|
||||
|
||||
describe("detectAgentState", () => {
|
||||
describe("NO_TASK state", () => {
|
||||
it("should return NO_TASK for empty messages array", () => {
|
||||
const state = detectAgentState([])
|
||||
expect(state.state).toBe(AgentLoopState.NO_TASK)
|
||||
expect(state.isWaitingForInput).toBe(false)
|
||||
expect(state.isRunning).toBe(false)
|
||||
})
|
||||
|
||||
it("should return NO_TASK for undefined messages", () => {
|
||||
const state = detectAgentState(undefined as unknown as ClineMessage[])
|
||||
expect(state.state).toBe(AgentLoopState.NO_TASK)
|
||||
})
|
||||
})
|
||||
|
||||
describe("STREAMING state", () => {
|
||||
it("should detect streaming when partial is true", () => {
|
||||
const messages = [createMessage({ type: "ask", ask: "tool", partial: true })]
|
||||
const state = detectAgentState(messages)
|
||||
expect(state.state).toBe(AgentLoopState.STREAMING)
|
||||
expect(state.isStreaming).toBe(true)
|
||||
expect(state.isWaitingForInput).toBe(false)
|
||||
})
|
||||
|
||||
it("should detect streaming when api_req_started has no cost", () => {
|
||||
const messages = [
|
||||
createMessage({
|
||||
say: "api_req_started",
|
||||
text: JSON.stringify({ tokensIn: 100 }), // No cost field
|
||||
}),
|
||||
]
|
||||
const state = detectAgentState(messages)
|
||||
expect(state.state).toBe(AgentLoopState.STREAMING)
|
||||
expect(state.isStreaming).toBe(true)
|
||||
})
|
||||
|
||||
it("should NOT be streaming when api_req_started has cost", () => {
|
||||
const messages = [
|
||||
createMessage({
|
||||
say: "api_req_started",
|
||||
text: JSON.stringify({ cost: 0.001, tokensIn: 100 }),
|
||||
}),
|
||||
]
|
||||
const state = detectAgentState(messages)
|
||||
expect(state.state).toBe(AgentLoopState.RUNNING)
|
||||
expect(state.isStreaming).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("WAITING_FOR_INPUT state", () => {
|
||||
it("should detect waiting for tool approval", () => {
|
||||
const messages = [createMessage({ type: "ask", ask: "tool", partial: false })]
|
||||
const state = detectAgentState(messages)
|
||||
expect(state.state).toBe(AgentLoopState.WAITING_FOR_INPUT)
|
||||
expect(state.isWaitingForInput).toBe(true)
|
||||
expect(state.currentAsk).toBe("tool")
|
||||
expect(state.requiredAction).toBe("approve")
|
||||
})
|
||||
|
||||
it("should detect waiting for command approval", () => {
|
||||
const messages = [createMessage({ type: "ask", ask: "command", partial: false })]
|
||||
const state = detectAgentState(messages)
|
||||
expect(state.state).toBe(AgentLoopState.WAITING_FOR_INPUT)
|
||||
expect(state.currentAsk).toBe("command")
|
||||
expect(state.requiredAction).toBe("approve")
|
||||
})
|
||||
|
||||
it("should detect waiting for followup answer", () => {
|
||||
const messages = [createMessage({ type: "ask", ask: "followup", partial: false })]
|
||||
const state = detectAgentState(messages)
|
||||
expect(state.state).toBe(AgentLoopState.WAITING_FOR_INPUT)
|
||||
expect(state.currentAsk).toBe("followup")
|
||||
expect(state.requiredAction).toBe("answer")
|
||||
})
|
||||
|
||||
it("should detect waiting for browser_action_launch approval", () => {
|
||||
const messages = [createMessage({ type: "ask", ask: "browser_action_launch", partial: false })]
|
||||
const state = detectAgentState(messages)
|
||||
expect(state.state).toBe(AgentLoopState.WAITING_FOR_INPUT)
|
||||
expect(state.requiredAction).toBe("approve")
|
||||
})
|
||||
|
||||
it("should detect waiting for use_mcp_server approval", () => {
|
||||
const messages = [createMessage({ type: "ask", ask: "use_mcp_server", partial: false })]
|
||||
const state = detectAgentState(messages)
|
||||
expect(state.state).toBe(AgentLoopState.WAITING_FOR_INPUT)
|
||||
expect(state.requiredAction).toBe("approve")
|
||||
})
|
||||
})
|
||||
|
||||
describe("IDLE state", () => {
|
||||
it("should detect completion_result as idle", () => {
|
||||
const messages = [createMessage({ type: "ask", ask: "completion_result", partial: false })]
|
||||
const state = detectAgentState(messages)
|
||||
expect(state.state).toBe(AgentLoopState.IDLE)
|
||||
expect(state.isWaitingForInput).toBe(true)
|
||||
expect(state.requiredAction).toBe("start_task")
|
||||
})
|
||||
|
||||
it("should detect api_req_failed as idle", () => {
|
||||
const messages = [createMessage({ type: "ask", ask: "api_req_failed", partial: false })]
|
||||
const state = detectAgentState(messages)
|
||||
expect(state.state).toBe(AgentLoopState.IDLE)
|
||||
expect(state.requiredAction).toBe("retry_or_new_task")
|
||||
})
|
||||
|
||||
it("should detect mistake_limit_reached as idle", () => {
|
||||
const messages = [createMessage({ type: "ask", ask: "mistake_limit_reached", partial: false })]
|
||||
const state = detectAgentState(messages)
|
||||
expect(state.state).toBe(AgentLoopState.IDLE)
|
||||
expect(state.requiredAction).toBe("proceed_or_new_task")
|
||||
})
|
||||
|
||||
it("should detect auto_approval_max_req_reached as idle", () => {
|
||||
const messages = [createMessage({ type: "ask", ask: "auto_approval_max_req_reached", partial: false })]
|
||||
const state = detectAgentState(messages)
|
||||
expect(state.state).toBe(AgentLoopState.IDLE)
|
||||
expect(state.requiredAction).toBe("start_new_task")
|
||||
})
|
||||
|
||||
it("should detect resume_completed_task as idle", () => {
|
||||
const messages = [createMessage({ type: "ask", ask: "resume_completed_task", partial: false })]
|
||||
const state = detectAgentState(messages)
|
||||
expect(state.state).toBe(AgentLoopState.IDLE)
|
||||
expect(state.requiredAction).toBe("start_new_task")
|
||||
})
|
||||
})
|
||||
|
||||
describe("RESUMABLE state", () => {
|
||||
it("should detect resume_task as resumable", () => {
|
||||
const messages = [createMessage({ type: "ask", ask: "resume_task", partial: false })]
|
||||
const state = detectAgentState(messages)
|
||||
expect(state.state).toBe(AgentLoopState.RESUMABLE)
|
||||
expect(state.isWaitingForInput).toBe(true)
|
||||
expect(state.requiredAction).toBe("resume_or_abandon")
|
||||
})
|
||||
})
|
||||
|
||||
describe("RUNNING state", () => {
|
||||
it("should detect running for say messages", () => {
|
||||
const messages = [
|
||||
createMessage({
|
||||
say: "api_req_started",
|
||||
text: JSON.stringify({ cost: 0.001 }),
|
||||
}),
|
||||
createMessage({ say: "text", text: "Working on it..." }),
|
||||
]
|
||||
const state = detectAgentState(messages)
|
||||
expect(state.state).toBe(AgentLoopState.RUNNING)
|
||||
expect(state.isRunning).toBe(true)
|
||||
expect(state.isWaitingForInput).toBe(false)
|
||||
})
|
||||
|
||||
it("should detect running for command_output (non-blocking)", () => {
|
||||
const messages = [createMessage({ type: "ask", ask: "command_output", partial: false })]
|
||||
const state = detectAgentState(messages)
|
||||
expect(state.state).toBe(AgentLoopState.RUNNING)
|
||||
expect(state.requiredAction).toBe("continue_or_abort")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Type Guard Tests
|
||||
// =============================================================================
|
||||
|
||||
describe("Type Guards", () => {
|
||||
describe("isIdleAsk", () => {
|
||||
it("should return true for idle asks", () => {
|
||||
expect(isIdleAsk("completion_result")).toBe(true)
|
||||
expect(isIdleAsk("api_req_failed")).toBe(true)
|
||||
expect(isIdleAsk("mistake_limit_reached")).toBe(true)
|
||||
expect(isIdleAsk("auto_approval_max_req_reached")).toBe(true)
|
||||
expect(isIdleAsk("resume_completed_task")).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false for non-idle asks", () => {
|
||||
expect(isIdleAsk("tool")).toBe(false)
|
||||
expect(isIdleAsk("followup")).toBe(false)
|
||||
expect(isIdleAsk("resume_task")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isInteractiveAsk", () => {
|
||||
it("should return true for interactive asks", () => {
|
||||
expect(isInteractiveAsk("tool")).toBe(true)
|
||||
expect(isInteractiveAsk("command")).toBe(true)
|
||||
expect(isInteractiveAsk("followup")).toBe(true)
|
||||
expect(isInteractiveAsk("browser_action_launch")).toBe(true)
|
||||
expect(isInteractiveAsk("use_mcp_server")).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false for non-interactive asks", () => {
|
||||
expect(isInteractiveAsk("completion_result")).toBe(false)
|
||||
expect(isInteractiveAsk("command_output")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isResumableAsk", () => {
|
||||
it("should return true for resumable asks", () => {
|
||||
expect(isResumableAsk("resume_task")).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false for non-resumable asks", () => {
|
||||
expect(isResumableAsk("completion_result")).toBe(false)
|
||||
expect(isResumableAsk("tool")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isNonBlockingAsk", () => {
|
||||
it("should return true for non-blocking asks", () => {
|
||||
expect(isNonBlockingAsk("command_output")).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false for blocking asks", () => {
|
||||
expect(isNonBlockingAsk("tool")).toBe(false)
|
||||
expect(isNonBlockingAsk("followup")).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// ExtensionClient Tests
|
||||
// =============================================================================
|
||||
|
||||
describe("ExtensionClient", () => {
|
||||
describe("State queries", () => {
|
||||
it("should return NO_TASK when not initialized", () => {
|
||||
const { client } = createMockClient()
|
||||
expect(client.getCurrentState()).toBe(AgentLoopState.NO_TASK)
|
||||
expect(client.isInitialized()).toBe(false)
|
||||
})
|
||||
|
||||
it("should update state when receiving messages", () => {
|
||||
const { client } = createMockClient()
|
||||
|
||||
const message = createStateMessage([createMessage({ type: "ask", ask: "tool", partial: false })])
|
||||
|
||||
client.handleMessage(message)
|
||||
|
||||
expect(client.isInitialized()).toBe(true)
|
||||
expect(client.getCurrentState()).toBe(AgentLoopState.WAITING_FOR_INPUT)
|
||||
expect(client.isWaitingForInput()).toBe(true)
|
||||
expect(client.getCurrentAsk()).toBe("tool")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Event emission", () => {
|
||||
it("should emit stateChange events", () => {
|
||||
const { client } = createMockClient()
|
||||
const stateChanges: AgentLoopState[] = []
|
||||
|
||||
client.onStateChange((event) => {
|
||||
stateChanges.push(event.currentState.state)
|
||||
})
|
||||
|
||||
client.handleMessage(createStateMessage([createMessage({ type: "ask", ask: "tool", partial: false })]))
|
||||
|
||||
expect(stateChanges).toContain(AgentLoopState.WAITING_FOR_INPUT)
|
||||
})
|
||||
|
||||
it("should emit waitingForInput events", () => {
|
||||
const { client } = createMockClient()
|
||||
const waitingEvents: string[] = []
|
||||
|
||||
client.onWaitingForInput((event) => {
|
||||
waitingEvents.push(event.ask)
|
||||
})
|
||||
|
||||
client.handleMessage(createStateMessage([createMessage({ type: "ask", ask: "followup", partial: false })]))
|
||||
|
||||
expect(waitingEvents).toContain("followup")
|
||||
})
|
||||
|
||||
it("should allow unsubscribing from events", () => {
|
||||
const { client } = createMockClient()
|
||||
let callCount = 0
|
||||
|
||||
const unsubscribe = client.onStateChange(() => {
|
||||
callCount++
|
||||
})
|
||||
|
||||
client.handleMessage(createStateMessage([createMessage({ say: "text" })]))
|
||||
expect(callCount).toBe(1)
|
||||
|
||||
unsubscribe()
|
||||
|
||||
client.handleMessage(createStateMessage([createMessage({ say: "text", ts: Date.now() + 1 })]))
|
||||
expect(callCount).toBe(1) // Should not increase
|
||||
})
|
||||
})
|
||||
|
||||
describe("Response methods", () => {
|
||||
it("should send approve response", () => {
|
||||
const { client, sentMessages } = createMockClient()
|
||||
|
||||
client.approve()
|
||||
|
||||
expect(sentMessages).toHaveLength(1)
|
||||
expect(sentMessages[0]).toEqual({
|
||||
type: "askResponse",
|
||||
askResponse: "yesButtonClicked",
|
||||
text: undefined,
|
||||
images: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it("should send reject response", () => {
|
||||
const { client, sentMessages } = createMockClient()
|
||||
|
||||
client.reject()
|
||||
|
||||
expect(sentMessages).toHaveLength(1)
|
||||
const msg = sentMessages[0]
|
||||
expect(msg).toBeDefined()
|
||||
expect(msg?.askResponse).toBe("noButtonClicked")
|
||||
})
|
||||
|
||||
it("should send text response", () => {
|
||||
const { client, sentMessages } = createMockClient()
|
||||
|
||||
client.respond("My answer", ["image-data"])
|
||||
|
||||
expect(sentMessages).toHaveLength(1)
|
||||
expect(sentMessages[0]).toEqual({
|
||||
type: "askResponse",
|
||||
askResponse: "messageResponse",
|
||||
text: "My answer",
|
||||
images: ["image-data"],
|
||||
})
|
||||
})
|
||||
|
||||
it("should send newTask message", () => {
|
||||
const { client, sentMessages } = createMockClient()
|
||||
|
||||
client.newTask("Build a web app")
|
||||
|
||||
expect(sentMessages).toHaveLength(1)
|
||||
expect(sentMessages[0]).toEqual({
|
||||
type: "newTask",
|
||||
text: "Build a web app",
|
||||
images: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it("should send clearTask message", () => {
|
||||
const { client, sentMessages } = createMockClient()
|
||||
|
||||
client.clearTask()
|
||||
|
||||
expect(sentMessages).toHaveLength(1)
|
||||
expect(sentMessages[0]).toEqual({
|
||||
type: "clearTask",
|
||||
})
|
||||
})
|
||||
|
||||
it("should send cancelTask message", () => {
|
||||
const { client, sentMessages } = createMockClient()
|
||||
|
||||
client.cancelTask()
|
||||
|
||||
expect(sentMessages).toHaveLength(1)
|
||||
expect(sentMessages[0]).toEqual({
|
||||
type: "cancelTask",
|
||||
})
|
||||
})
|
||||
|
||||
it("should send terminal continue operation", () => {
|
||||
const { client, sentMessages } = createMockClient()
|
||||
|
||||
client.continueTerminal()
|
||||
|
||||
expect(sentMessages).toHaveLength(1)
|
||||
expect(sentMessages[0]).toEqual({
|
||||
type: "terminalOperation",
|
||||
terminalOperation: "continue",
|
||||
})
|
||||
})
|
||||
|
||||
it("should send terminal abort operation", () => {
|
||||
const { client, sentMessages } = createMockClient()
|
||||
|
||||
client.abortTerminal()
|
||||
|
||||
expect(sentMessages).toHaveLength(1)
|
||||
expect(sentMessages[0]).toEqual({
|
||||
type: "terminalOperation",
|
||||
terminalOperation: "abort",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("Message handling", () => {
|
||||
it("should handle JSON string messages", () => {
|
||||
const { client } = createMockClient()
|
||||
|
||||
const message = JSON.stringify(
|
||||
createStateMessage([createMessage({ type: "ask", ask: "completion_result", partial: false })]),
|
||||
)
|
||||
|
||||
client.handleMessage(message)
|
||||
|
||||
expect(client.getCurrentState()).toBe(AgentLoopState.IDLE)
|
||||
})
|
||||
|
||||
it("should ignore invalid JSON", () => {
|
||||
const { client } = createMockClient()
|
||||
|
||||
client.handleMessage("not valid json")
|
||||
|
||||
expect(client.getCurrentState()).toBe(AgentLoopState.NO_TASK)
|
||||
})
|
||||
|
||||
it("should handle messageUpdated messages", () => {
|
||||
const { client } = createMockClient()
|
||||
|
||||
// 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
|
||||
client.handleMessage({
|
||||
type: "messageUpdated",
|
||||
clineMessage: createMessage({ ts: 123, type: "ask", ask: "tool", partial: false }),
|
||||
})
|
||||
|
||||
expect(client.isStreaming()).toBe(false)
|
||||
expect(client.isWaitingForInput()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Reset functionality", () => {
|
||||
it("should reset state", () => {
|
||||
const { client } = createMockClient()
|
||||
|
||||
client.handleMessage(createStateMessage([createMessage({ type: "ask", ask: "tool", partial: false })]))
|
||||
|
||||
expect(client.isInitialized()).toBe(true)
|
||||
expect(client.getCurrentState()).toBe(AgentLoopState.WAITING_FOR_INPUT)
|
||||
|
||||
client.reset()
|
||||
|
||||
expect(client.isInitialized()).toBe(false)
|
||||
expect(client.getCurrentState()).toBe(AgentLoopState.NO_TASK)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Integration Tests
|
||||
// =============================================================================
|
||||
|
||||
describe("Integration", () => {
|
||||
it("should handle a complete task flow", () => {
|
||||
const { client } = createMockClient()
|
||||
const states: AgentLoopState[] = []
|
||||
|
||||
client.onStateChange((event) => {
|
||||
states.push(event.currentState.state)
|
||||
})
|
||||
|
||||
// 1. Task starts, API request begins
|
||||
client.handleMessage(
|
||||
createStateMessage([
|
||||
createMessage({
|
||||
say: "api_req_started",
|
||||
text: JSON.stringify({}), // No cost = streaming
|
||||
}),
|
||||
]),
|
||||
)
|
||||
expect(client.isStreaming()).toBe(true)
|
||||
|
||||
// 2. API request completes
|
||||
client.handleMessage(
|
||||
createStateMessage([
|
||||
createMessage({
|
||||
say: "api_req_started",
|
||||
text: JSON.stringify({ cost: 0.001 }),
|
||||
}),
|
||||
createMessage({ say: "text", text: "I'll help you with that." }),
|
||||
]),
|
||||
)
|
||||
expect(client.isStreaming()).toBe(false)
|
||||
expect(client.isRunning()).toBe(true)
|
||||
|
||||
// 3. Tool ask (partial)
|
||||
client.handleMessage(
|
||||
createStateMessage([
|
||||
createMessage({
|
||||
say: "api_req_started",
|
||||
text: JSON.stringify({ cost: 0.001 }),
|
||||
}),
|
||||
createMessage({ say: "text", text: "I'll help you with that." }),
|
||||
createMessage({ type: "ask", ask: "tool", partial: true }),
|
||||
]),
|
||||
)
|
||||
expect(client.isStreaming()).toBe(true)
|
||||
|
||||
// 4. Tool ask (complete)
|
||||
client.handleMessage(
|
||||
createStateMessage([
|
||||
createMessage({
|
||||
say: "api_req_started",
|
||||
text: JSON.stringify({ cost: 0.001 }),
|
||||
}),
|
||||
createMessage({ say: "text", text: "I'll help you with that." }),
|
||||
createMessage({ type: "ask", ask: "tool", partial: false }),
|
||||
]),
|
||||
)
|
||||
expect(client.isWaitingForInput()).toBe(true)
|
||||
expect(client.getCurrentAsk()).toBe("tool")
|
||||
|
||||
// 5. User approves, task completes
|
||||
client.handleMessage(
|
||||
createStateMessage([
|
||||
createMessage({
|
||||
say: "api_req_started",
|
||||
text: JSON.stringify({ cost: 0.001 }),
|
||||
}),
|
||||
createMessage({ say: "text", text: "I'll help you with that." }),
|
||||
createMessage({ type: "ask", ask: "tool", partial: false }),
|
||||
createMessage({ say: "text", text: "File created." }),
|
||||
createMessage({ type: "ask", ask: "completion_result", partial: false }),
|
||||
]),
|
||||
)
|
||||
expect(client.getCurrentState()).toBe(AgentLoopState.IDLE)
|
||||
expect(client.getCurrentAsk()).toBe("completion_result")
|
||||
|
||||
// Verify we saw the expected state transitions
|
||||
expect(states).toContain(AgentLoopState.STREAMING)
|
||||
expect(states).toContain(AgentLoopState.RUNNING)
|
||||
expect(states).toContain(AgentLoopState.WAITING_FOR_INPUT)
|
||||
expect(states).toContain(AgentLoopState.IDLE)
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// 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
|
||||
const state = detectAgentState(messages)
|
||||
expect(state.state).toBe(AgentLoopState.WAITING_FOR_INPUT)
|
||||
expect(state.currentAsk).toBe("tool")
|
||||
})
|
||||
|
||||
it("should handle ask message with empty text field", () => {
|
||||
const messages = [createMessage({ type: "ask", ask: "followup", partial: false, text: "" })]
|
||||
const state = detectAgentState(messages)
|
||||
expect(state.state).toBe(AgentLoopState.WAITING_FOR_INPUT)
|
||||
expect(state.currentAsk).toBe("followup")
|
||||
})
|
||||
|
||||
it("should handle say message with missing text field", () => {
|
||||
const messages = [createMessage({ say: "text" })]
|
||||
const state = detectAgentState(messages)
|
||||
expect(state.state).toBe(AgentLoopState.RUNNING)
|
||||
})
|
||||
})
|
||||
|
||||
describe("api_req_started 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)
|
||||
expect(state.state).toBe(AgentLoopState.STREAMING)
|
||||
expect(state.isStreaming).toBe(true)
|
||||
})
|
||||
|
||||
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
|
||||
expect(state.state).toBe(AgentLoopState.RUNNING)
|
||||
expect(state.isStreaming).toBe(false)
|
||||
})
|
||||
|
||||
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)
|
||||
expect(state.state).toBe(AgentLoopState.STREAMING)
|
||||
expect(state.isStreaming).toBe(true)
|
||||
})
|
||||
|
||||
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
|
||||
expect(state.state).toBe(AgentLoopState.RUNNING)
|
||||
expect(state.isStreaming).toBe(false)
|
||||
})
|
||||
|
||||
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
|
||||
expect(state.state).toBe(AgentLoopState.RUNNING)
|
||||
expect(state.isStreaming).toBe(false)
|
||||
})
|
||||
|
||||
it("should find api_req_started when it's not the last message", () => {
|
||||
const messages = [
|
||||
createMessage({ say: "api_req_started", text: JSON.stringify({ tokensIn: 100 }) }), // No cost = streaming
|
||||
createMessage({ say: "text", text: "Some text" }),
|
||||
]
|
||||
const state = detectAgentState(messages)
|
||||
// Last message is say:text, but api_req_started has no cost
|
||||
expect(state.state).toBe(AgentLoopState.STREAMING)
|
||||
expect(state.isStreaming).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Rapid state transitions", () => {
|
||||
it("should handle multiple rapid state changes", () => {
|
||||
const { client } = createMockClient()
|
||||
const states: AgentLoopState[] = []
|
||||
|
||||
client.onStateChange((event) => {
|
||||
states.push(event.currentState.state)
|
||||
})
|
||||
|
||||
// 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 })]))
|
||||
client.handleMessage(
|
||||
createStateMessage([createMessage({ type: "ask", ask: "completion_result", partial: false })]),
|
||||
)
|
||||
|
||||
// Should have tracked all transitions
|
||||
expect(states.length).toBeGreaterThanOrEqual(3)
|
||||
expect(states).toContain(AgentLoopState.STREAMING)
|
||||
expect(states).toContain(AgentLoopState.WAITING_FOR_INPUT)
|
||||
expect(states).toContain(AgentLoopState.IDLE)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Message array edge cases", () => {
|
||||
it("should handle single message array", () => {
|
||||
const messages = [createMessage({ say: "text", text: "Hello" })]
|
||||
const state = detectAgentState(messages)
|
||||
expect(state.state).toBe(AgentLoopState.RUNNING)
|
||||
expect(state.lastMessage).toBeDefined()
|
||||
expect(state.lastMessageTs).toBe(messages[0]!.ts)
|
||||
})
|
||||
|
||||
it("should use last message for state detection", () => {
|
||||
// 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
|
||||
expect(state.state).toBe(AgentLoopState.IDLE)
|
||||
expect(state.currentAsk).toBe("completion_result")
|
||||
})
|
||||
|
||||
it("should handle very long message arrays", () => {
|
||||
// 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)
|
||||
expect(state.state).toBe(AgentLoopState.WAITING_FOR_INPUT)
|
||||
expect(state.currentAsk).toBe("followup")
|
||||
})
|
||||
})
|
||||
|
||||
describe("State message edge cases", () => {
|
||||
it("should handle state message with empty clineMessages", () => {
|
||||
const { client } = createMockClient()
|
||||
|
||||
client.handleMessage({
|
||||
type: "state",
|
||||
state: {
|
||||
clineMessages: [],
|
||||
},
|
||||
})
|
||||
|
||||
expect(client.getCurrentState()).toBe(AgentLoopState.NO_TASK)
|
||||
expect(client.isInitialized()).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle state message with missing clineMessages", () => {
|
||||
const { client } = createMockClient()
|
||||
|
||||
client.handleMessage({
|
||||
type: "state",
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
state: {} as any,
|
||||
})
|
||||
|
||||
// Should not crash, state should remain unchanged
|
||||
expect(client.getCurrentState()).toBe(AgentLoopState.NO_TASK)
|
||||
})
|
||||
|
||||
it("should handle state message with missing state field", () => {
|
||||
const { client } = createMockClient()
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
client.handleMessage({ type: "state" } as any)
|
||||
|
||||
// Should not crash
|
||||
expect(client.getCurrentState()).toBe(AgentLoopState.NO_TASK)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Partial to complete transitions", () => {
|
||||
it("should transition from streaming to waiting when partial becomes false", () => {
|
||||
const ts = Date.now()
|
||||
const messages1 = [createMessage({ ts, type: "ask", ask: "tool", partial: true })]
|
||||
const messages2 = [createMessage({ ts, type: "ask", ask: "tool", partial: false })]
|
||||
|
||||
const state1 = detectAgentState(messages1)
|
||||
const state2 = detectAgentState(messages2)
|
||||
|
||||
expect(state1.state).toBe(AgentLoopState.STREAMING)
|
||||
expect(state1.isWaitingForInput).toBe(false)
|
||||
|
||||
expect(state2.state).toBe(AgentLoopState.WAITING_FOR_INPUT)
|
||||
expect(state2.isWaitingForInput).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle partial say messages", () => {
|
||||
const messages = [createMessage({ say: "text", text: "Typing...", partial: true })]
|
||||
const state = detectAgentState(messages)
|
||||
expect(state.state).toBe(AgentLoopState.STREAMING)
|
||||
expect(state.isStreaming).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Unknown message types", () => {
|
||||
it("should handle unknown ask types gracefully", () => {
|
||||
// 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
|
||||
expect(state.state).toBe(AgentLoopState.RUNNING)
|
||||
})
|
||||
|
||||
it("should handle unknown say types gracefully", () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const messages = [createMessage({ say: "unknown_say_type" as any })]
|
||||
const state = detectAgentState(messages)
|
||||
expect(state.state).toBe(AgentLoopState.RUNNING)
|
||||
})
|
||||
})
|
||||
})
|
||||
567
apps/cli/src/extension-client/client.ts
Normal file
567
apps/cli/src/extension-client/client.ts
Normal file
|
|
@ -0,0 +1,567 @@
|
|||
/**
|
||||
* Roo Code Client
|
||||
*
|
||||
* This is the main entry point for the client library. It provides a high-level
|
||||
* API for:
|
||||
* - Processing messages from the extension host
|
||||
* - Querying the current agent state
|
||||
* - Subscribing to state change events
|
||||
* - Sending responses back to the extension
|
||||
*
|
||||
* The client is designed to be transport-agnostic. You provide a way to send
|
||||
* messages to the extension, and you feed incoming messages to the client.
|
||||
*
|
||||
* Architecture:
|
||||
* ```
|
||||
* ┌───────────────────────────────────────────────┐
|
||||
* │ ExtensionClient │
|
||||
* │ │
|
||||
* Extension ──────▶ │ MessageProcessor ──▶ StateStore │
|
||||
* Messages │ │ │ │
|
||||
* │ ▼ ▼ │
|
||||
* │ TypedEventEmitter ◀── State/Events │
|
||||
* │ │ │
|
||||
* │ ▼ │
|
||||
* │ Your Event Handlers │
|
||||
* └───────────────────────────────────────────────┘
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { StateStore } from "./state-store.js"
|
||||
import { MessageProcessor, MessageProcessorOptions, parseExtensionMessage } from "./message-processor.js"
|
||||
import {
|
||||
TypedEventEmitter,
|
||||
type ClientEventMap,
|
||||
type AgentStateChangeEvent,
|
||||
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
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Configuration options for the ExtensionClient.
|
||||
*/
|
||||
export interface ExtensionClientConfig {
|
||||
/**
|
||||
* Function to send messages to the extension host.
|
||||
* This is how the client communicates back to the extension.
|
||||
*
|
||||
* Example implementations:
|
||||
* - VSCode webview: (msg) => vscode.postMessage(msg)
|
||||
* - WebSocket: (msg) => socket.send(JSON.stringify(msg))
|
||||
* - IPC: (msg) => process.send(msg)
|
||||
*/
|
||||
sendMessage: (message: WebviewMessage) => void
|
||||
|
||||
/**
|
||||
* Whether to emit events for all state changes or only significant ones.
|
||||
* Default: true
|
||||
*/
|
||||
emitAllStateChanges?: boolean
|
||||
|
||||
/**
|
||||
* Enable debug logging.
|
||||
* Default: false
|
||||
*/
|
||||
debug?: boolean
|
||||
|
||||
/**
|
||||
* Maximum state history size (for debugging).
|
||||
* Set to 0 to disable history tracking.
|
||||
* Default: 0
|
||||
*/
|
||||
maxHistorySize?: number
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Main Client Class
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* ExtensionClient is the main interface for interacting with the Roo Code extension.
|
||||
*
|
||||
* Basic usage:
|
||||
* ```typescript
|
||||
* // Create client with message sender
|
||||
* const client = new ExtensionClient({
|
||||
* sendMessage: (msg) => vscode.postMessage(msg)
|
||||
* })
|
||||
*
|
||||
* // Subscribe to state changes
|
||||
* client.on('stateChange', (event) => {
|
||||
* console.log('State:', event.currentState.state)
|
||||
* })
|
||||
*
|
||||
* // Subscribe to specific events
|
||||
* client.on('waitingForInput', (event) => {
|
||||
* console.log('Waiting for:', event.ask)
|
||||
* })
|
||||
*
|
||||
* // Feed messages from extension
|
||||
* window.addEventListener('message', (e) => {
|
||||
* client.handleMessage(e.data)
|
||||
* })
|
||||
*
|
||||
* // Query state at any time
|
||||
* const state = client.getAgentState()
|
||||
* if (state.isWaitingForInput) {
|
||||
* // Show approval UI
|
||||
* }
|
||||
*
|
||||
* // Send responses
|
||||
* client.approve() // or client.reject() or client.respond('answer')
|
||||
* ```
|
||||
*/
|
||||
export class ExtensionClient {
|
||||
private store: StateStore
|
||||
private processor: MessageProcessor
|
||||
private emitter: TypedEventEmitter
|
||||
private sendMessage: (message: WebviewMessage) => void
|
||||
private debug: boolean
|
||||
|
||||
constructor(config: ExtensionClientConfig) {
|
||||
this.sendMessage = config.sendMessage
|
||||
this.debug = config.debug ?? false
|
||||
|
||||
// Initialize components
|
||||
this.store = new StateStore({
|
||||
maxHistorySize: config.maxHistorySize ?? 0,
|
||||
})
|
||||
|
||||
this.emitter = new TypedEventEmitter()
|
||||
|
||||
const processorOptions: MessageProcessorOptions = {
|
||||
emitAllStateChanges: config.emitAllStateChanges ?? true,
|
||||
debug: config.debug ?? false,
|
||||
}
|
||||
this.processor = new MessageProcessor(this.store, this.emitter, processorOptions)
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Message Handling
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Handle an incoming message from the extension host.
|
||||
*
|
||||
* Call this method whenever you receive a message from the extension.
|
||||
* The client will parse, validate, and process the message, updating
|
||||
* internal state and emitting appropriate events.
|
||||
*
|
||||
* @param message - The raw message (can be ExtensionMessage or JSON string)
|
||||
*/
|
||||
handleMessage(message: ExtensionMessage | string): void {
|
||||
let parsed: ExtensionMessage | undefined
|
||||
|
||||
if (typeof message === "string") {
|
||||
parsed = parseExtensionMessage(message)
|
||||
if (!parsed) {
|
||||
if (this.debug) {
|
||||
console.log("[ExtensionClient] Failed to parse message:", message)
|
||||
}
|
||||
return
|
||||
}
|
||||
} else {
|
||||
parsed = message
|
||||
}
|
||||
|
||||
this.processor.processMessage(parsed)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle multiple messages at once.
|
||||
*/
|
||||
handleMessages(messages: (ExtensionMessage | string)[]): void {
|
||||
for (const message of messages) {
|
||||
this.handleMessage(message)
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// State Queries - Always know the current state
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Get the complete agent state information.
|
||||
*
|
||||
* This returns everything you need to know about the current state:
|
||||
* - The high-level state (running, streaming, waiting, idle, etc.)
|
||||
* - Whether input is needed
|
||||
* - The specific ask type if waiting
|
||||
* - What action is required
|
||||
* - Human-readable description
|
||||
*/
|
||||
getAgentState(): AgentStateInfo {
|
||||
return this.store.getAgentState()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get just the current state enum value.
|
||||
*/
|
||||
getCurrentState(): AgentLoopState {
|
||||
return this.store.getCurrentState()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the agent is waiting for user input.
|
||||
*/
|
||||
isWaitingForInput(): boolean {
|
||||
return this.store.isWaitingForInput()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the agent is actively running.
|
||||
*/
|
||||
isRunning(): boolean {
|
||||
return this.store.isRunning()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if content is currently streaming.
|
||||
*/
|
||||
isStreaming(): boolean {
|
||||
return this.store.isStreaming()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there is an active task.
|
||||
*/
|
||||
hasActiveTask(): boolean {
|
||||
return this.store.getCurrentState() !== AgentLoopState.NO_TASK
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all messages in the current task.
|
||||
*/
|
||||
getMessages(): ClineMessage[] {
|
||||
return this.store.getMessages()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last message.
|
||||
*/
|
||||
getLastMessage(): ClineMessage | undefined {
|
||||
return this.store.getLastMessage()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current ask type if the agent is waiting for input.
|
||||
*/
|
||||
getCurrentAsk(): ClineAsk | undefined {
|
||||
return this.store.getAgentState().currentAsk
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the client has received any state from the extension.
|
||||
*/
|
||||
isInitialized(): boolean {
|
||||
return this.store.isInitialized()
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Event Subscriptions - Realtime notifications
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Subscribe to an event.
|
||||
*
|
||||
* Returns an unsubscribe function for easy cleanup.
|
||||
*
|
||||
* @param event - The event to subscribe to
|
||||
* @param listener - The callback function
|
||||
* @returns Unsubscribe function
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const unsubscribe = client.on('stateChange', (event) => {
|
||||
* console.log(event.currentState)
|
||||
* })
|
||||
*
|
||||
* // Later, to unsubscribe:
|
||||
* unsubscribe()
|
||||
* ```
|
||||
*/
|
||||
on<K extends keyof ClientEventMap>(event: K, listener: (payload: ClientEventMap[K]) => void): () => void {
|
||||
return this.emitter.on(event, listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to an event, triggered only once.
|
||||
*/
|
||||
once<K extends keyof ClientEventMap>(event: K, listener: (payload: ClientEventMap[K]) => void): void {
|
||||
this.emitter.once(event, listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from an event.
|
||||
*/
|
||||
off<K extends keyof ClientEventMap>(event: K, listener: (payload: ClientEventMap[K]) => void): void {
|
||||
this.emitter.off(event, listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all listeners for an event, or all events.
|
||||
*/
|
||||
removeAllListeners<K extends keyof ClientEventMap>(event?: K): void {
|
||||
this.emitter.removeAllListeners(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method: Subscribe only to state changes.
|
||||
*/
|
||||
onStateChange(listener: (event: AgentStateChangeEvent) => void): () => void {
|
||||
return this.on("stateChange", listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method: Subscribe only to waiting events.
|
||||
*/
|
||||
onWaitingForInput(listener: (event: WaitingForInputEvent) => void): () => void {
|
||||
return this.on("waitingForInput", listener)
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Response Methods - Send actions to the extension
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Approve the current action (tool, command, browser, MCP).
|
||||
*
|
||||
* Use when the agent is waiting for approval (interactive asks).
|
||||
*/
|
||||
approve(): void {
|
||||
this.sendResponse("yesButtonClicked")
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject the current action.
|
||||
*
|
||||
* Use when you want to deny a tool, command, or other action.
|
||||
*/
|
||||
reject(): void {
|
||||
this.sendResponse("noButtonClicked")
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a text response.
|
||||
*
|
||||
* Use for:
|
||||
* - Answering follow-up questions
|
||||
* - Providing additional context
|
||||
* - Giving feedback on completion
|
||||
*
|
||||
* @param text - The response text
|
||||
* @param images - Optional base64-encoded images
|
||||
*/
|
||||
respond(text: string, images?: string[]): void {
|
||||
this.sendResponse("messageResponse", text, images)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic method to send any ask response.
|
||||
*
|
||||
* @param response - The response type
|
||||
* @param text - Optional text content
|
||||
* @param images - Optional images
|
||||
*/
|
||||
sendResponse(response: ClineAskResponse, text?: string, images?: string[]): void {
|
||||
const message: WebviewMessage = {
|
||||
type: "askResponse",
|
||||
askResponse: response,
|
||||
text,
|
||||
images,
|
||||
}
|
||||
this.sendMessage(message)
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Task Control Methods
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Start a new task with the given prompt.
|
||||
*
|
||||
* @param text - The task description/prompt
|
||||
* @param images - Optional base64-encoded images
|
||||
*/
|
||||
newTask(text: string, images?: string[]): void {
|
||||
const message: WebviewMessage = {
|
||||
type: "newTask",
|
||||
text,
|
||||
images,
|
||||
}
|
||||
this.sendMessage(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the current task.
|
||||
*
|
||||
* This ends the current task and resets to a fresh state.
|
||||
*/
|
||||
clearTask(): void {
|
||||
const message: WebviewMessage = {
|
||||
type: "clearTask",
|
||||
}
|
||||
this.sendMessage(message)
|
||||
this.processor.notifyTaskCleared()
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a running task.
|
||||
*
|
||||
* Use this to interrupt a task that is currently processing.
|
||||
*/
|
||||
cancelTask(): void {
|
||||
const message: WebviewMessage = {
|
||||
type: "cancelTask",
|
||||
}
|
||||
this.sendMessage(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume a paused task.
|
||||
*
|
||||
* Use when the agent state is RESUMABLE (resume_task ask).
|
||||
*/
|
||||
resumeTask(): void {
|
||||
this.approve() // Resume uses the same response as approve
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry a failed API request.
|
||||
*
|
||||
* Use when the agent state shows api_req_failed.
|
||||
*/
|
||||
retryApiRequest(): void {
|
||||
this.approve() // Retry uses the same response as approve
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Terminal Operation Methods
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Continue terminal output (don't wait for more output).
|
||||
*
|
||||
* Use when the agent is showing command_output and you want to proceed.
|
||||
*/
|
||||
continueTerminal(): void {
|
||||
const message: WebviewMessage = {
|
||||
type: "terminalOperation",
|
||||
terminalOperation: "continue",
|
||||
}
|
||||
this.sendMessage(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort terminal command.
|
||||
*
|
||||
* Use when you want to kill a running terminal command.
|
||||
*/
|
||||
abortTerminal(): void {
|
||||
const message: WebviewMessage = {
|
||||
type: "terminalOperation",
|
||||
terminalOperation: "abort",
|
||||
}
|
||||
this.sendMessage(message)
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Utility Methods
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Reset the client state.
|
||||
*
|
||||
* This clears all internal state and history.
|
||||
* Useful when disconnecting or starting fresh.
|
||||
*/
|
||||
reset(): void {
|
||||
this.store.reset()
|
||||
this.emitter.removeAllListeners()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the state history (if history tracking is enabled).
|
||||
*/
|
||||
getStateHistory() {
|
||||
return this.store.getHistory()
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable debug mode.
|
||||
*/
|
||||
setDebug(enabled: boolean): void {
|
||||
this.debug = enabled
|
||||
this.processor.setDebug(enabled)
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Advanced: Direct Store Access
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Get direct access to the state store.
|
||||
*
|
||||
* This is for advanced use cases where you need more control.
|
||||
* Most users should use the methods above instead.
|
||||
*/
|
||||
getStore(): StateStore {
|
||||
return this.store
|
||||
}
|
||||
|
||||
/**
|
||||
* Get direct access to the event emitter.
|
||||
*/
|
||||
getEmitter(): TypedEventEmitter {
|
||||
return this.emitter
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Factory Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Create a new ExtensionClient instance.
|
||||
*
|
||||
* This is a convenience function that creates a client with default settings.
|
||||
*
|
||||
* @param sendMessage - Function to send messages to the extension
|
||||
* @returns A new ExtensionClient instance
|
||||
*/
|
||||
export function createClient(sendMessage: (message: WebviewMessage) => void): ExtensionClient {
|
||||
return new ExtensionClient({ sendMessage })
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock client for testing.
|
||||
*
|
||||
* The mock client captures all sent messages for verification.
|
||||
*
|
||||
* @returns An object with the client and captured messages
|
||||
*/
|
||||
export function createMockClient(): {
|
||||
client: ExtensionClient
|
||||
sentMessages: WebviewMessage[]
|
||||
clearMessages: () => void
|
||||
} {
|
||||
const sentMessages: WebviewMessage[] = []
|
||||
|
||||
const client = new ExtensionClient({
|
||||
sendMessage: (message) => sentMessages.push(message),
|
||||
debug: false,
|
||||
})
|
||||
|
||||
return {
|
||||
client,
|
||||
sentMessages,
|
||||
clearMessages: () => {
|
||||
sentMessages.length = 0
|
||||
},
|
||||
}
|
||||
}
|
||||
355
apps/cli/src/extension-client/events.ts
Normal file
355
apps/cli/src/extension-client/events.ts
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
/**
|
||||
* Event System for Agent State Changes
|
||||
*
|
||||
* This module provides a strongly-typed event emitter specifically designed
|
||||
* for tracking agent state changes. It uses Node.js EventEmitter under the hood
|
||||
* but provides type safety for all events.
|
||||
*/
|
||||
|
||||
import { EventEmitter } from "events"
|
||||
import type { AgentStateInfo } from "./agent-state.js"
|
||||
import type { ClineMessage, ClineAsk } from "./types.js"
|
||||
|
||||
// =============================================================================
|
||||
// Event Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* All events that can be emitted by the client.
|
||||
*
|
||||
* Design note: We use a string literal union type for event names to ensure
|
||||
* type safety when subscribing to events. The payload type is determined by
|
||||
* the event name.
|
||||
*/
|
||||
export interface ClientEventMap {
|
||||
/**
|
||||
* Emitted whenever the agent state changes.
|
||||
* This is the primary event for tracking state.
|
||||
*/
|
||||
stateChange: AgentStateChangeEvent
|
||||
|
||||
/**
|
||||
* Emitted when a new message is added to the message list.
|
||||
*/
|
||||
message: ClineMessage
|
||||
|
||||
/**
|
||||
* Emitted when an existing message is updated (e.g., partial -> complete).
|
||||
*/
|
||||
messageUpdated: ClineMessage
|
||||
|
||||
/**
|
||||
* Emitted when the agent starts waiting for user input.
|
||||
* Convenience event - you can also use stateChange.
|
||||
*/
|
||||
waitingForInput: WaitingForInputEvent
|
||||
|
||||
/**
|
||||
* Emitted when the agent stops waiting and resumes running.
|
||||
*/
|
||||
resumedRunning: void
|
||||
|
||||
/**
|
||||
* Emitted when the agent starts streaming content.
|
||||
*/
|
||||
streamingStarted: void
|
||||
|
||||
/**
|
||||
* Emitted when streaming ends.
|
||||
*/
|
||||
streamingEnded: void
|
||||
|
||||
/**
|
||||
* Emitted when a task completes (either successfully or with error).
|
||||
*/
|
||||
taskCompleted: TaskCompletedEvent
|
||||
|
||||
/**
|
||||
* Emitted when a task is cleared/cancelled.
|
||||
*/
|
||||
taskCleared: void
|
||||
|
||||
/**
|
||||
* Emitted on any error during message processing.
|
||||
*/
|
||||
error: Error
|
||||
}
|
||||
|
||||
/**
|
||||
* Event payload for state changes.
|
||||
*/
|
||||
export interface AgentStateChangeEvent {
|
||||
/** The previous state info */
|
||||
previousState: AgentStateInfo
|
||||
/** The new/current state info */
|
||||
currentState: AgentStateInfo
|
||||
/** Whether this is a significant state transition (state enum changed) */
|
||||
isSignificantChange: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Event payload when agent starts waiting for input.
|
||||
*/
|
||||
export interface WaitingForInputEvent {
|
||||
/** The specific ask type */
|
||||
ask: ClineAsk
|
||||
/** Full state info for context */
|
||||
stateInfo: AgentStateInfo
|
||||
/** The message that triggered this wait */
|
||||
message: ClineMessage
|
||||
}
|
||||
|
||||
/**
|
||||
* Event payload when a task completes.
|
||||
*/
|
||||
export interface TaskCompletedEvent {
|
||||
/** Whether the task completed successfully */
|
||||
success: boolean
|
||||
/** The final state info */
|
||||
stateInfo: AgentStateInfo
|
||||
/** The completion message if available */
|
||||
message?: ClineMessage
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Typed Event Emitter
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Type-safe event emitter for client events.
|
||||
*
|
||||
* Usage:
|
||||
* ```typescript
|
||||
* const emitter = new TypedEventEmitter()
|
||||
*
|
||||
* // Type-safe subscription
|
||||
* emitter.on('stateChange', (event) => {
|
||||
* console.log(event.currentState) // TypeScript knows this is AgentStateChangeEvent
|
||||
* })
|
||||
*
|
||||
* // Type-safe emission
|
||||
* emitter.emit('stateChange', { previousState, currentState, isSignificantChange })
|
||||
* ```
|
||||
*/
|
||||
export class TypedEventEmitter {
|
||||
private emitter = new EventEmitter()
|
||||
|
||||
/**
|
||||
* Subscribe to an event.
|
||||
*
|
||||
* @param event - The event name
|
||||
* @param listener - The callback function
|
||||
* @returns Function to unsubscribe
|
||||
*/
|
||||
on<K extends keyof ClientEventMap>(event: K, listener: (payload: ClientEventMap[K]) => void): () => void {
|
||||
this.emitter.on(event, listener)
|
||||
return () => this.emitter.off(event, listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to an event, but only once.
|
||||
*
|
||||
* @param event - The event name
|
||||
* @param listener - The callback function
|
||||
*/
|
||||
once<K extends keyof ClientEventMap>(event: K, listener: (payload: ClientEventMap[K]) => void): void {
|
||||
this.emitter.once(event, listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from an event.
|
||||
*
|
||||
* @param event - The event name
|
||||
* @param listener - The callback function to remove
|
||||
*/
|
||||
off<K extends keyof ClientEventMap>(event: K, listener: (payload: ClientEventMap[K]) => void): void {
|
||||
this.emitter.off(event, listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an event.
|
||||
*
|
||||
* @param event - The event name
|
||||
* @param payload - The event payload
|
||||
*/
|
||||
emit<K extends keyof ClientEventMap>(event: K, payload: ClientEventMap[K]): void {
|
||||
this.emitter.emit(event, payload)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all listeners for an event, or all events.
|
||||
*
|
||||
* @param event - Optional event name. If not provided, removes all listeners.
|
||||
*/
|
||||
removeAllListeners<K extends keyof ClientEventMap>(event?: K): void {
|
||||
if (event) {
|
||||
this.emitter.removeAllListeners(event)
|
||||
} else {
|
||||
this.emitter.removeAllListeners()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of listeners for an event.
|
||||
*/
|
||||
listenerCount<K extends keyof ClientEventMap>(event: K): number {
|
||||
return this.emitter.listenerCount(event)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// State Change Detector
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Helper to determine if a state change is "significant".
|
||||
*
|
||||
* A significant change is when the AgentLoopState enum value changes,
|
||||
* as opposed to just internal state updates within the same state.
|
||||
*/
|
||||
export function isSignificantStateChange(previous: AgentStateInfo, current: AgentStateInfo): boolean {
|
||||
return previous.state !== current.state
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to determine if we transitioned to waiting for input.
|
||||
*/
|
||||
export function transitionedToWaiting(previous: AgentStateInfo, current: AgentStateInfo): boolean {
|
||||
return !previous.isWaitingForInput && current.isWaitingForInput
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to determine if we transitioned from waiting to running.
|
||||
*/
|
||||
export function transitionedToRunning(previous: AgentStateInfo, current: AgentStateInfo): boolean {
|
||||
return previous.isWaitingForInput && !current.isWaitingForInput && current.isRunning
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to determine if streaming started.
|
||||
*/
|
||||
export function streamingStarted(previous: AgentStateInfo, current: AgentStateInfo): boolean {
|
||||
return !previous.isStreaming && current.isStreaming
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to determine if streaming ended.
|
||||
*/
|
||||
export function streamingEnded(previous: AgentStateInfo, current: AgentStateInfo): boolean {
|
||||
return previous.isStreaming && !current.isStreaming
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to determine if task completed.
|
||||
*/
|
||||
export function taskCompleted(previous: AgentStateInfo, current: AgentStateInfo): boolean {
|
||||
const completionAsks = ["completion_result", "api_req_failed", "mistake_limit_reached"]
|
||||
const wasNotComplete = !previous.currentAsk || !completionAsks.includes(previous.currentAsk)
|
||||
const isNowComplete = current.currentAsk !== undefined && completionAsks.includes(current.currentAsk)
|
||||
return wasNotComplete && isNowComplete
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Observable Pattern (Alternative API)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Subscription function type for observable pattern.
|
||||
*/
|
||||
export type Observer<T> = (value: T) => void
|
||||
|
||||
/**
|
||||
* Unsubscribe function type.
|
||||
*/
|
||||
export type Unsubscribe = () => void
|
||||
|
||||
/**
|
||||
* Simple observable for state.
|
||||
*
|
||||
* This provides an alternative to the event emitter pattern
|
||||
* for those who prefer a more functional approach.
|
||||
*
|
||||
* Usage:
|
||||
* ```typescript
|
||||
* const stateObservable = new Observable<AgentStateInfo>()
|
||||
*
|
||||
* const unsubscribe = stateObservable.subscribe((state) => {
|
||||
* console.log('New state:', state)
|
||||
* })
|
||||
*
|
||||
* // Later...
|
||||
* unsubscribe()
|
||||
* ```
|
||||
*/
|
||||
export class Observable<T> {
|
||||
private observers: Set<Observer<T>> = new Set()
|
||||
private currentValue: T | undefined
|
||||
|
||||
/**
|
||||
* Create an observable with an optional initial value.
|
||||
*/
|
||||
constructor(initialValue?: T) {
|
||||
this.currentValue = initialValue
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to value changes.
|
||||
*
|
||||
* @param observer - Function called when value changes
|
||||
* @returns Unsubscribe function
|
||||
*/
|
||||
subscribe(observer: Observer<T>): Unsubscribe {
|
||||
this.observers.add(observer)
|
||||
|
||||
// Immediately emit current value if we have one
|
||||
if (this.currentValue !== undefined) {
|
||||
observer(this.currentValue)
|
||||
}
|
||||
|
||||
return () => {
|
||||
this.observers.delete(observer)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the value and notify all subscribers.
|
||||
*/
|
||||
next(value: T): void {
|
||||
this.currentValue = value
|
||||
for (const observer of this.observers) {
|
||||
try {
|
||||
observer(value)
|
||||
} catch (error) {
|
||||
console.error("Error in observer:", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current value without subscribing.
|
||||
*/
|
||||
getValue(): T | undefined {
|
||||
return this.currentValue
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are any subscribers.
|
||||
*/
|
||||
hasSubscribers(): boolean {
|
||||
return this.observers.size > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of subscribers.
|
||||
*/
|
||||
getSubscriberCount(): number {
|
||||
return this.observers.size
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all subscribers.
|
||||
*/
|
||||
clear(): void {
|
||||
this.observers.clear()
|
||||
}
|
||||
}
|
||||
79
apps/cli/src/extension-client/index.ts
Normal file
79
apps/cli/src/extension-client/index.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
/**
|
||||
* 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"
|
||||
465
apps/cli/src/extension-client/message-processor.ts
Normal file
465
apps/cli/src/extension-client/message-processor.ts
Normal file
|
|
@ -0,0 +1,465 @@
|
|||
/**
|
||||
* Message Processor
|
||||
*
|
||||
* This module handles incoming messages from the extension host and dispatches
|
||||
* appropriate state updates and events. It acts as the bridge between raw
|
||||
* extension messages and the client's internal state management.
|
||||
*
|
||||
* Message Flow:
|
||||
* ```
|
||||
* Extension Host ──▶ MessageProcessor ──▶ StateStore ──▶ Events
|
||||
* ```
|
||||
*
|
||||
* The processor handles different message types:
|
||||
* - "state": Full state update from extension
|
||||
* - "messageUpdated": Single message update
|
||||
* - "action": UI action triggers
|
||||
* - "invoke": Command invocations
|
||||
*/
|
||||
|
||||
import { debugLog } from "@roo-code/core/debug-log"
|
||||
|
||||
import type { ExtensionMessage, ClineMessage } from "./types.js"
|
||||
import type { StateStore } from "./state-store.js"
|
||||
import type { TypedEventEmitter, AgentStateChangeEvent, WaitingForInputEvent, TaskCompletedEvent } from "./events.js"
|
||||
import {
|
||||
isSignificantStateChange,
|
||||
transitionedToWaiting,
|
||||
transitionedToRunning,
|
||||
streamingStarted,
|
||||
streamingEnded,
|
||||
taskCompleted,
|
||||
} from "./events.js"
|
||||
import type { AgentStateInfo } from "./agent-state.js"
|
||||
|
||||
// =============================================================================
|
||||
// Message Processor Options
|
||||
// =============================================================================
|
||||
|
||||
export interface MessageProcessorOptions {
|
||||
/**
|
||||
* Whether to emit events for every state change, or only significant ones.
|
||||
* Default: true (emit all changes)
|
||||
*/
|
||||
emitAllStateChanges?: boolean
|
||||
|
||||
/**
|
||||
* Whether to log debug information.
|
||||
* Default: false
|
||||
*/
|
||||
debug?: boolean
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Message Processor Class
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* MessageProcessor handles incoming extension messages and updates state accordingly.
|
||||
*
|
||||
* It is responsible for:
|
||||
* 1. Parsing and validating incoming messages
|
||||
* 2. Updating the state store
|
||||
* 3. Emitting appropriate events
|
||||
*
|
||||
* Usage:
|
||||
* ```typescript
|
||||
* const store = new StateStore()
|
||||
* const emitter = new TypedEventEmitter()
|
||||
* const processor = new MessageProcessor(store, emitter)
|
||||
*
|
||||
* // Process a message from the extension
|
||||
* processor.processMessage(extensionMessage)
|
||||
* ```
|
||||
*/
|
||||
export class MessageProcessor {
|
||||
private store: StateStore
|
||||
private emitter: TypedEventEmitter
|
||||
private options: Required<MessageProcessorOptions>
|
||||
|
||||
constructor(store: StateStore, emitter: TypedEventEmitter, options: MessageProcessorOptions = {}) {
|
||||
this.store = store
|
||||
this.emitter = emitter
|
||||
this.options = {
|
||||
emitAllStateChanges: options.emitAllStateChanges ?? true,
|
||||
debug: options.debug ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Main Processing Methods
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Process an incoming message from the extension host.
|
||||
*
|
||||
* This is the main entry point for all extension messages.
|
||||
* It routes messages to the appropriate handler based on type.
|
||||
*
|
||||
* @param message - The raw message from the extension
|
||||
*/
|
||||
processMessage(message: ExtensionMessage): void {
|
||||
if (this.options.debug) {
|
||||
debugLog("[MessageProcessor] Received message", { type: message.type })
|
||||
}
|
||||
|
||||
try {
|
||||
switch (message.type) {
|
||||
case "state":
|
||||
this.handleStateMessage(message)
|
||||
break
|
||||
|
||||
case "messageUpdated":
|
||||
this.handleMessageUpdated(message)
|
||||
break
|
||||
|
||||
case "action":
|
||||
this.handleAction(message)
|
||||
break
|
||||
|
||||
case "invoke":
|
||||
this.handleInvoke(message)
|
||||
break
|
||||
|
||||
default:
|
||||
// Other message types are not relevant to state detection
|
||||
if (this.options.debug) {
|
||||
debugLog("[MessageProcessor] Ignoring message", { type: message.type })
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error))
|
||||
debugLog("[MessageProcessor] Error processing message", { error: err.message })
|
||||
this.emitter.emit("error", err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process an array of messages (for batch updates).
|
||||
*/
|
||||
processMessages(messages: ExtensionMessage[]): void {
|
||||
for (const message of messages) {
|
||||
this.processMessage(message)
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Message Type Handlers
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Handle a "state" message - full state update from extension.
|
||||
*
|
||||
* This is the most important message type for state detection.
|
||||
* It contains the complete clineMessages array which is the source of truth.
|
||||
*/
|
||||
private handleStateMessage(message: ExtensionMessage): void {
|
||||
if (!message.state) {
|
||||
if (this.options.debug) {
|
||||
debugLog("[MessageProcessor] State message missing state payload")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const { clineMessages } = message.state
|
||||
|
||||
if (!clineMessages) {
|
||||
if (this.options.debug) {
|
||||
debugLog("[MessageProcessor] State message missing clineMessages")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Get previous state for comparison
|
||||
const previousState = this.store.getAgentState()
|
||||
|
||||
// Update the store with new messages
|
||||
// Note: We only call setMessages, NOT setExtensionState, to avoid
|
||||
// double processing (setExtensionState would call setMessages again)
|
||||
this.store.setMessages(clineMessages)
|
||||
|
||||
// Get new state after update
|
||||
const currentState = this.store.getAgentState()
|
||||
|
||||
// Debug logging for state message
|
||||
if (this.options.debug) {
|
||||
const lastMsg = clineMessages[clineMessages.length - 1]
|
||||
const lastMsgInfo = lastMsg
|
||||
? {
|
||||
msgType: lastMsg.type === "ask" ? `ask:${lastMsg.ask}` : `say:${lastMsg.say}`,
|
||||
partial: lastMsg.partial,
|
||||
textPreview: lastMsg.text?.substring(0, 50),
|
||||
}
|
||||
: null
|
||||
debugLog("[MessageProcessor] State update", {
|
||||
messageCount: clineMessages.length,
|
||||
lastMessage: lastMsgInfo,
|
||||
stateTransition: `${previousState.state} → ${currentState.state}`,
|
||||
currentAsk: currentState.currentAsk,
|
||||
isWaitingForInput: currentState.isWaitingForInput,
|
||||
isStreaming: currentState.isStreaming,
|
||||
isRunning: currentState.isRunning,
|
||||
})
|
||||
}
|
||||
|
||||
// Emit events based on state changes
|
||||
this.emitStateChangeEvents(previousState, currentState)
|
||||
|
||||
// Emit new message events for any messages we haven't seen
|
||||
this.emitNewMessageEvents(previousState, currentState, clineMessages)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a "messageUpdated" message - single message update.
|
||||
*
|
||||
* This is sent when a message is modified (e.g., partial -> complete).
|
||||
*/
|
||||
private handleMessageUpdated(message: ExtensionMessage): void {
|
||||
if (!message.clineMessage) {
|
||||
if (this.options.debug) {
|
||||
debugLog("[MessageProcessor] messageUpdated missing clineMessage")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const clineMessage = message.clineMessage
|
||||
const previousState = this.store.getAgentState()
|
||||
|
||||
// Update the message in the store
|
||||
this.store.updateMessage(clineMessage)
|
||||
|
||||
const currentState = this.store.getAgentState()
|
||||
|
||||
// Emit message updated event
|
||||
this.emitter.emit("messageUpdated", clineMessage)
|
||||
|
||||
// Emit state change events
|
||||
this.emitStateChangeEvents(previousState, currentState)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an "action" message - UI action trigger.
|
||||
*
|
||||
* These are typically used to trigger UI behaviors and don't
|
||||
* directly affect agent state, but we can track them if needed.
|
||||
*/
|
||||
private handleAction(message: ExtensionMessage): void {
|
||||
if (this.options.debug) {
|
||||
debugLog("[MessageProcessor] Action", { action: message.action })
|
||||
}
|
||||
// Actions don't affect agent state, but subclasses could override this
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an "invoke" message - command invocation.
|
||||
*
|
||||
* These are commands that should trigger specific behaviors.
|
||||
*/
|
||||
private handleInvoke(message: ExtensionMessage): void {
|
||||
if (this.options.debug) {
|
||||
debugLog("[MessageProcessor] Invoke", { invoke: message.invoke })
|
||||
}
|
||||
// Invokes don't directly affect state detection
|
||||
// But they might trigger state changes through subsequent messages
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Event Emission Helpers
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Emit events based on state changes.
|
||||
*/
|
||||
private emitStateChangeEvents(previousState: AgentStateInfo, currentState: AgentStateInfo): void {
|
||||
const isSignificant = isSignificantStateChange(previousState, currentState)
|
||||
|
||||
// Emit stateChange event
|
||||
if (this.options.emitAllStateChanges || isSignificant) {
|
||||
const changeEvent: AgentStateChangeEvent = {
|
||||
previousState,
|
||||
currentState,
|
||||
isSignificantChange: isSignificant,
|
||||
}
|
||||
this.emitter.emit("stateChange", changeEvent)
|
||||
}
|
||||
|
||||
// Emit specific transition events
|
||||
|
||||
// Waiting for input
|
||||
if (transitionedToWaiting(previousState, currentState)) {
|
||||
if (currentState.currentAsk && currentState.lastMessage) {
|
||||
if (this.options.debug) {
|
||||
debugLog("[MessageProcessor] EMIT waitingForInput", {
|
||||
ask: currentState.currentAsk,
|
||||
action: currentState.requiredAction,
|
||||
})
|
||||
}
|
||||
const waitingEvent: WaitingForInputEvent = {
|
||||
ask: currentState.currentAsk,
|
||||
stateInfo: currentState,
|
||||
message: currentState.lastMessage,
|
||||
}
|
||||
this.emitter.emit("waitingForInput", waitingEvent)
|
||||
}
|
||||
}
|
||||
|
||||
// Resumed running
|
||||
if (transitionedToRunning(previousState, currentState)) {
|
||||
if (this.options.debug) {
|
||||
debugLog("[MessageProcessor] EMIT resumedRunning")
|
||||
}
|
||||
this.emitter.emit("resumedRunning", undefined as void)
|
||||
}
|
||||
|
||||
// Streaming started
|
||||
if (streamingStarted(previousState, currentState)) {
|
||||
if (this.options.debug) {
|
||||
debugLog("[MessageProcessor] EMIT streamingStarted")
|
||||
}
|
||||
this.emitter.emit("streamingStarted", undefined as void)
|
||||
}
|
||||
|
||||
// Streaming ended
|
||||
if (streamingEnded(previousState, currentState)) {
|
||||
if (this.options.debug) {
|
||||
debugLog("[MessageProcessor] EMIT streamingEnded")
|
||||
}
|
||||
this.emitter.emit("streamingEnded", undefined as void)
|
||||
}
|
||||
|
||||
// Task completed
|
||||
if (taskCompleted(previousState, currentState)) {
|
||||
if (this.options.debug) {
|
||||
debugLog("[MessageProcessor] EMIT taskCompleted", {
|
||||
success: currentState.currentAsk === "completion_result",
|
||||
})
|
||||
}
|
||||
const completedEvent: TaskCompletedEvent = {
|
||||
success: currentState.currentAsk === "completion_result",
|
||||
stateInfo: currentState,
|
||||
message: currentState.lastMessage,
|
||||
}
|
||||
this.emitter.emit("taskCompleted", completedEvent)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit events for new messages.
|
||||
*
|
||||
* We compare the previous and current message counts to find new messages.
|
||||
* This is a simple heuristic - for more accuracy, we'd track by timestamp.
|
||||
*/
|
||||
private emitNewMessageEvents(
|
||||
_previousState: AgentStateInfo,
|
||||
_currentState: AgentStateInfo,
|
||||
messages: ClineMessage[],
|
||||
): void {
|
||||
// For now, just emit the last message as new
|
||||
// A more sophisticated implementation would track seen message timestamps
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
if (lastMessage) {
|
||||
this.emitter.emit("message", lastMessage)
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Utility Methods
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Manually trigger a task cleared event.
|
||||
* Call this when you send a clearTask message to the extension.
|
||||
*/
|
||||
notifyTaskCleared(): void {
|
||||
this.store.clear()
|
||||
this.emitter.emit("taskCleared", undefined as void)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable debug logging.
|
||||
*/
|
||||
setDebug(enabled: boolean): void {
|
||||
this.options.debug = enabled
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Message Validation Helpers
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Check if a message is a valid ClineMessage.
|
||||
* Useful for validating messages before processing.
|
||||
*/
|
||||
export function isValidClineMessage(message: unknown): message is ClineMessage {
|
||||
if (!message || typeof message !== "object") {
|
||||
return false
|
||||
}
|
||||
|
||||
const msg = message as Record<string, unknown>
|
||||
|
||||
// Required fields
|
||||
if (typeof msg.ts !== "number") {
|
||||
return false
|
||||
}
|
||||
|
||||
if (msg.type !== "ask" && msg.type !== "say") {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a message is a valid ExtensionMessage.
|
||||
*/
|
||||
export function isValidExtensionMessage(message: unknown): message is ExtensionMessage {
|
||||
if (!message || typeof message !== "object") {
|
||||
return false
|
||||
}
|
||||
|
||||
const msg = message as Record<string, unknown>
|
||||
|
||||
// Must have a type
|
||||
if (typeof msg.type !== "string") {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Message Parsing Utilities
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Parse a JSON string into an ExtensionMessage.
|
||||
* Returns undefined if parsing fails.
|
||||
*/
|
||||
export function parseExtensionMessage(json: string): ExtensionMessage | undefined {
|
||||
try {
|
||||
const parsed = JSON.parse(json)
|
||||
if (isValidExtensionMessage(parsed)) {
|
||||
return parsed
|
||||
}
|
||||
return undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the text field of an api_req_started message.
|
||||
* Returns undefined if parsing fails or text is not present.
|
||||
*/
|
||||
export function parseApiReqStartedText(message: ClineMessage): { cost?: number } | undefined {
|
||||
if (message.say !== "api_req_started" || !message.text) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(message.text)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
380
apps/cli/src/extension-client/state-store.ts
Normal file
380
apps/cli/src/extension-client/state-store.ts
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
/**
|
||||
* State Store
|
||||
*
|
||||
* This module manages the client's internal state, including:
|
||||
* - The clineMessages array (source of truth for agent state)
|
||||
* - The computed agent state info
|
||||
* - Any extension state we want to cache
|
||||
*
|
||||
* The store is designed to be:
|
||||
* - Immutable: State updates create new objects, not mutations
|
||||
* - Observable: Changes trigger notifications
|
||||
* - Queryable: Current state is always accessible
|
||||
*/
|
||||
|
||||
import { detectAgentState, AgentStateInfo, AgentLoopState } from "./agent-state.js"
|
||||
import type { ClineMessage, ExtensionState } from "./types.js"
|
||||
import { Observable } from "./events.js"
|
||||
|
||||
// =============================================================================
|
||||
// Store State Interface
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* The complete state managed by the store.
|
||||
*/
|
||||
export interface StoreState {
|
||||
/**
|
||||
* The array of messages from the extension.
|
||||
* This is the primary data used to compute agent state.
|
||||
*/
|
||||
messages: ClineMessage[]
|
||||
|
||||
/**
|
||||
* The computed agent state info.
|
||||
* Updated automatically when messages change.
|
||||
*/
|
||||
agentState: AgentStateInfo
|
||||
|
||||
/**
|
||||
* Whether we have received any state from the extension.
|
||||
* Useful to distinguish "no task" from "not yet connected".
|
||||
*/
|
||||
isInitialized: boolean
|
||||
|
||||
/**
|
||||
* The last time state was updated.
|
||||
*/
|
||||
lastUpdatedAt: number
|
||||
|
||||
/**
|
||||
* Optional: Cache of extension state fields we might need.
|
||||
* This is a subset of the full ExtensionState.
|
||||
*/
|
||||
extensionState?: Partial<ExtensionState>
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the initial store state.
|
||||
*/
|
||||
function createInitialState(): StoreState {
|
||||
return {
|
||||
messages: [],
|
||||
agentState: detectAgentState([]),
|
||||
isInitialized: false,
|
||||
lastUpdatedAt: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// State Store Class
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* StateStore manages all client state and provides reactive updates.
|
||||
*
|
||||
* Key features:
|
||||
* - Stores the clineMessages array
|
||||
* - Automatically computes agent state when messages change
|
||||
* - Provides observable pattern for state changes
|
||||
* - Tracks state history for debugging (optional)
|
||||
*
|
||||
* Usage:
|
||||
* ```typescript
|
||||
* const store = new StateStore()
|
||||
*
|
||||
* // Subscribe to state changes
|
||||
* store.subscribe((state) => {
|
||||
* console.log('New state:', state.agentState.state)
|
||||
* })
|
||||
*
|
||||
* // Update messages
|
||||
* store.setMessages(newMessages)
|
||||
*
|
||||
* // Query current state
|
||||
* const currentState = store.getState()
|
||||
* ```
|
||||
*/
|
||||
export class StateStore {
|
||||
private state: StoreState
|
||||
private stateObservable: Observable<StoreState>
|
||||
private agentStateObservable: Observable<AgentStateInfo>
|
||||
|
||||
/**
|
||||
* Optional: Track state history for debugging.
|
||||
* Set maxHistorySize to enable.
|
||||
*/
|
||||
private stateHistory: StoreState[] = []
|
||||
private maxHistorySize: number
|
||||
|
||||
constructor(options: { maxHistorySize?: number } = {}) {
|
||||
this.state = createInitialState()
|
||||
this.stateObservable = new Observable<StoreState>(this.state)
|
||||
this.agentStateObservable = new Observable<AgentStateInfo>(this.state.agentState)
|
||||
this.maxHistorySize = options.maxHistorySize ?? 0
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// State Queries
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Get the current complete state.
|
||||
*/
|
||||
getState(): StoreState {
|
||||
return this.state
|
||||
}
|
||||
|
||||
/**
|
||||
* Get just the agent state info.
|
||||
* This is a convenience method for the most common query.
|
||||
*/
|
||||
getAgentState(): AgentStateInfo {
|
||||
return this.state.agentState
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current messages array.
|
||||
*/
|
||||
getMessages(): ClineMessage[] {
|
||||
return this.state.messages
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last message, if any.
|
||||
*/
|
||||
getLastMessage(): ClineMessage | undefined {
|
||||
return this.state.messages[this.state.messages.length - 1]
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the store has been initialized with extension state.
|
||||
*/
|
||||
isInitialized(): boolean {
|
||||
return this.state.isInitialized
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick check: Is the agent currently waiting for input?
|
||||
*/
|
||||
isWaitingForInput(): boolean {
|
||||
return this.state.agentState.isWaitingForInput
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick check: Is the agent currently running?
|
||||
*/
|
||||
isRunning(): boolean {
|
||||
return this.state.agentState.isRunning
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick check: Is content currently streaming?
|
||||
*/
|
||||
isStreaming(): boolean {
|
||||
return this.state.agentState.isStreaming
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current agent loop state enum value.
|
||||
*/
|
||||
getCurrentState(): AgentLoopState {
|
||||
return this.state.agentState.state
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// State Updates
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Set the complete messages array.
|
||||
* This is typically called when receiving a full state update from the extension.
|
||||
*
|
||||
* @param messages - The new messages array
|
||||
* @returns The previous agent state (for comparison)
|
||||
*/
|
||||
setMessages(messages: ClineMessage[]): AgentStateInfo {
|
||||
const previousAgentState = this.state.agentState
|
||||
const newAgentState = detectAgentState(messages)
|
||||
|
||||
this.updateState({
|
||||
messages,
|
||||
agentState: newAgentState,
|
||||
isInitialized: true,
|
||||
lastUpdatedAt: Date.now(),
|
||||
})
|
||||
|
||||
return previousAgentState
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a single message to the end of the messages array.
|
||||
* Useful when receiving incremental updates.
|
||||
*
|
||||
* @param message - The message to add
|
||||
* @returns The previous agent state
|
||||
*/
|
||||
addMessage(message: ClineMessage): AgentStateInfo {
|
||||
const newMessages = [...this.state.messages, message]
|
||||
return this.setMessages(newMessages)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a message in place (e.g., when partial becomes complete).
|
||||
* Finds the message by timestamp and replaces it.
|
||||
*
|
||||
* @param message - The updated message
|
||||
* @returns The previous agent state, or undefined if message not found
|
||||
*/
|
||||
updateMessage(message: ClineMessage): AgentStateInfo | undefined {
|
||||
const index = this.state.messages.findIndex((m) => m.ts === message.ts)
|
||||
if (index === -1) {
|
||||
// Message not found, add it instead
|
||||
return this.addMessage(message)
|
||||
}
|
||||
|
||||
const newMessages = [...this.state.messages]
|
||||
newMessages[index] = message
|
||||
return this.setMessages(newMessages)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all messages and reset to initial state.
|
||||
* Called when a task is cleared/cancelled.
|
||||
*/
|
||||
clear(): void {
|
||||
this.updateState({
|
||||
messages: [],
|
||||
agentState: detectAgentState([]),
|
||||
isInitialized: true, // Still initialized, just empty
|
||||
lastUpdatedAt: Date.now(),
|
||||
extensionState: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset to completely uninitialized state.
|
||||
* Called on disconnect or reset.
|
||||
*/
|
||||
reset(): void {
|
||||
this.state = createInitialState()
|
||||
this.stateHistory = []
|
||||
// Don't notify on reset - we're starting fresh
|
||||
}
|
||||
|
||||
/**
|
||||
* Update cached extension state.
|
||||
* This stores any additional extension state fields we might need.
|
||||
*
|
||||
* @param extensionState - The extension state to cache
|
||||
*/
|
||||
setExtensionState(extensionState: Partial<ExtensionState>): void {
|
||||
// Extract and store messages if present
|
||||
if (extensionState.clineMessages) {
|
||||
this.setMessages(extensionState.clineMessages)
|
||||
}
|
||||
|
||||
// Store the rest of the extension state
|
||||
this.updateState({
|
||||
...this.state,
|
||||
extensionState: {
|
||||
...this.state.extensionState,
|
||||
...extensionState,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Subscriptions
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Subscribe to all state changes.
|
||||
*
|
||||
* @param observer - Callback function receiving the new state
|
||||
* @returns Unsubscribe function
|
||||
*/
|
||||
subscribe(observer: (state: StoreState) => void): () => void {
|
||||
return this.stateObservable.subscribe(observer)
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to agent state changes only.
|
||||
* This is more efficient if you only care about agent state.
|
||||
*
|
||||
* @param observer - Callback function receiving the new agent state
|
||||
* @returns Unsubscribe function
|
||||
*/
|
||||
subscribeToAgentState(observer: (state: AgentStateInfo) => void): () => void {
|
||||
return this.agentStateObservable.subscribe(observer)
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// History (for debugging)
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Get the state history (if enabled).
|
||||
*/
|
||||
getHistory(): StoreState[] {
|
||||
return [...this.stateHistory]
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the state history.
|
||||
*/
|
||||
clearHistory(): void {
|
||||
this.stateHistory = []
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Private Methods
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Internal method to update state and notify observers.
|
||||
*/
|
||||
private updateState(newState: StoreState): void {
|
||||
// Track history if enabled
|
||||
if (this.maxHistorySize > 0) {
|
||||
this.stateHistory.push(this.state)
|
||||
if (this.stateHistory.length > this.maxHistorySize) {
|
||||
this.stateHistory.shift()
|
||||
}
|
||||
}
|
||||
|
||||
this.state = newState
|
||||
|
||||
// Notify observers
|
||||
this.stateObservable.next(this.state)
|
||||
this.agentStateObservable.next(this.state.agentState)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Singleton Store (optional convenience)
|
||||
// =============================================================================
|
||||
|
||||
let defaultStore: StateStore | null = null
|
||||
|
||||
/**
|
||||
* Get the default singleton store instance.
|
||||
* Useful for simple applications that don't need multiple stores.
|
||||
*/
|
||||
export function getDefaultStore(): StateStore {
|
||||
if (!defaultStore) {
|
||||
defaultStore = new StateStore()
|
||||
}
|
||||
return defaultStore
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the default store instance.
|
||||
* Useful for testing or when you need a fresh start.
|
||||
*/
|
||||
export function resetDefaultStore(): void {
|
||||
if (defaultStore) {
|
||||
defaultStore.reset()
|
||||
}
|
||||
defaultStore = null
|
||||
}
|
||||
88
apps/cli/src/extension-client/types.ts
Normal file
88
apps/cli/src/extension-client/types.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
/**
|
||||
* 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
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,4 +1,4 @@
|
|||
// pnpm --filter @roo-code/cli test src/__tests__/extension-host.test.ts
|
||||
// pnpm --filter @roo-code/cli test src/extension-host/__tests__/extension-host.test.ts
|
||||
|
||||
import { EventEmitter } from "events"
|
||||
import fs from "fs"
|
||||
|
|
@ -7,7 +7,7 @@ import path from "path"
|
|||
|
||||
import type { WebviewMessage } from "@roo-code/types"
|
||||
|
||||
import type { SupportedProvider } from "../types.js"
|
||||
import type { SupportedProvider } from "../../types/index.js"
|
||||
import { type ExtensionHostOptions, ExtensionHost } from "../extension-host.js"
|
||||
|
||||
vi.mock("@roo-code/vscode-shim", () => ({
|
||||
|
|
@ -103,6 +103,16 @@ describe("ExtensionHost", () => {
|
|||
expect(getPrivate(host, "vscode")).toBeNull()
|
||||
expect(getPrivate(host, "extensionModule")).toBeNull()
|
||||
})
|
||||
|
||||
it("should initialize managers", () => {
|
||||
const host = createTestHost()
|
||||
|
||||
// Should have client, outputManager, promptManager, and askDispatcher
|
||||
expect(getPrivate(host, "client")).toBeDefined()
|
||||
expect(getPrivate(host, "outputManager")).toBeDefined()
|
||||
expect(getPrivate(host, "promptManager")).toBeDefined()
|
||||
expect(getPrivate(host, "askDispatcher")).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("buildApiConfiguration", () => {
|
||||
|
|
@ -297,525 +307,87 @@ describe("ExtensionHost", () => {
|
|||
})
|
||||
|
||||
describe("handleExtensionMessage", () => {
|
||||
it("should route state messages to handleStateMessage", () => {
|
||||
it("should forward messages to the client", () => {
|
||||
const host = createTestHost()
|
||||
const handleStateSpy = spyOnPrivate(host, "handleStateMessage")
|
||||
const client = host.getExtensionClient()
|
||||
const handleMessageSpy = vi.spyOn(client, "handleMessage")
|
||||
|
||||
callPrivate(host, "handleExtensionMessage", { type: "state", state: {} })
|
||||
callPrivate(host, "handleExtensionMessage", { type: "state", state: { clineMessages: [] } })
|
||||
|
||||
expect(handleStateSpy).toHaveBeenCalled()
|
||||
expect(handleMessageSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should route messageUpdated to handleMessageUpdated", () => {
|
||||
it("should track mode from state messages", () => {
|
||||
const host = createTestHost()
|
||||
const handleMsgUpdatedSpy = spyOnPrivate(host, "handleMessageUpdated")
|
||||
|
||||
callPrivate(host, "handleExtensionMessage", { type: "messageUpdated", clineMessage: {} })
|
||||
callPrivate(host, "handleExtensionMessage", {
|
||||
type: "state",
|
||||
state: { mode: "architect", clineMessages: [] },
|
||||
})
|
||||
|
||||
expect(handleMsgUpdatedSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("handleSayMessage", () => {
|
||||
let host: ExtensionHost
|
||||
let outputSpy: ReturnType<typeof vi.spyOn>
|
||||
let outputErrorSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
host = createTestHost()
|
||||
// Mock process.stdout.write and process.stderr.write which are used by output() and outputError()
|
||||
vi.spyOn(process.stdout, "write").mockImplementation(() => true)
|
||||
vi.spyOn(process.stderr, "write").mockImplementation(() => true)
|
||||
// Spy on the output methods
|
||||
outputSpy = spyOnPrivate(host, "output")
|
||||
outputErrorSpy = spyOnPrivate(host, "outputError")
|
||||
expect(getPrivate(host, "currentMode")).toBe("architect")
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it("should emit taskComplete for completion_result", () => {
|
||||
it("should emit modesUpdated for modes messages", () => {
|
||||
const host = createTestHost()
|
||||
const emitSpy = vi.spyOn(host, "emit")
|
||||
|
||||
callPrivate(host, "handleSayMessage", 123, "completion_result", "Task done", false)
|
||||
callPrivate(host, "handleExtensionMessage", { type: "modes", modes: [] })
|
||||
|
||||
expect(emitSpy).toHaveBeenCalledWith("taskComplete")
|
||||
expect(outputSpy).toHaveBeenCalledWith("\n[task complete]", "Task done")
|
||||
})
|
||||
|
||||
it("should output error messages without emitting taskError", () => {
|
||||
const emitSpy = vi.spyOn(host, "emit")
|
||||
|
||||
callPrivate(host, "handleSayMessage", 123, "error", "Something went wrong", false)
|
||||
|
||||
// Errors are informational - they don't terminate the task
|
||||
// The agent should decide what to do next
|
||||
expect(emitSpy).not.toHaveBeenCalledWith("taskError", "Something went wrong")
|
||||
expect(outputErrorSpy).toHaveBeenCalledWith("\n[error]", "Something went wrong")
|
||||
})
|
||||
|
||||
it("should handle command_output messages", () => {
|
||||
// Mock writeStream since command_output now uses it directly
|
||||
const writeStreamSpy = spyOnPrivate(host, "writeStream")
|
||||
|
||||
callPrivate(host, "handleSayMessage", 123, "command_output", "output text", false)
|
||||
|
||||
// command_output now uses writeStream to bypass quiet mode
|
||||
expect(writeStreamSpy).toHaveBeenCalledWith("\n[command output] ")
|
||||
expect(writeStreamSpy).toHaveBeenCalledWith("output text")
|
||||
expect(writeStreamSpy).toHaveBeenCalledWith("\n")
|
||||
})
|
||||
|
||||
it("should handle tool messages", () => {
|
||||
callPrivate(host, "handleSayMessage", 123, "tool", "tool usage", false)
|
||||
|
||||
expect(outputSpy).toHaveBeenCalledWith("\n[tool]", "tool usage")
|
||||
})
|
||||
|
||||
it("should skip already displayed complete messages", () => {
|
||||
// First display
|
||||
callPrivate(host, "handleSayMessage", 123, "completion_result", "Task done", false)
|
||||
outputSpy.mockClear()
|
||||
|
||||
// Second display should be skipped
|
||||
callPrivate(host, "handleSayMessage", 123, "completion_result", "Task done", false)
|
||||
|
||||
expect(outputSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should not output completion_result for partial messages", () => {
|
||||
const emitSpy = vi.spyOn(host, "emit")
|
||||
|
||||
// Partial message should not trigger output or taskComplete
|
||||
callPrivate(host, "handleSayMessage", 123, "completion_result", "", true)
|
||||
|
||||
expect(outputSpy).not.toHaveBeenCalled()
|
||||
expect(emitSpy).not.toHaveBeenCalledWith("taskComplete")
|
||||
})
|
||||
|
||||
it("should output completion_result text when complete message arrives after partial", () => {
|
||||
const emitSpy = vi.spyOn(host, "emit")
|
||||
|
||||
// First, a partial message with empty text (simulates streaming)
|
||||
callPrivate(host, "handleSayMessage", 123, "completion_result", "", true)
|
||||
outputSpy.mockClear()
|
||||
emitSpy.mockClear()
|
||||
|
||||
// Then, the complete message with the actual completion text
|
||||
callPrivate(host, "handleSayMessage", 123, "completion_result", "Task completed successfully!", false)
|
||||
|
||||
expect(outputSpy).toHaveBeenCalledWith("\n[task complete]", "Task completed successfully!")
|
||||
expect(emitSpy).toHaveBeenCalledWith("taskComplete")
|
||||
})
|
||||
|
||||
it("should track displayed messages", () => {
|
||||
callPrivate(host, "handleSayMessage", 123, "tool", "test", false)
|
||||
|
||||
const displayed = getPrivate<Map<number, unknown>>(host, "displayedMessages")
|
||||
expect(displayed.has(123)).toBe(true)
|
||||
expect(emitSpy).toHaveBeenCalledWith("modesUpdated", { type: "modes", modes: [] })
|
||||
})
|
||||
})
|
||||
|
||||
describe("handleAskMessage", () => {
|
||||
let host: ExtensionHost
|
||||
let outputSpy: ReturnType<typeof vi.spyOn>
|
||||
describe("public agent state API", () => {
|
||||
it("should return agent state from getAgentState()", () => {
|
||||
const host = createTestHost()
|
||||
const state = host.getAgentState()
|
||||
|
||||
beforeEach(() => {
|
||||
// Use nonInteractive mode for display-only behavior tests
|
||||
host = createTestHost({ nonInteractive: true })
|
||||
// Mock process.stdout.write which is used by output()
|
||||
vi.spyOn(process.stdout, "write").mockImplementation(() => true)
|
||||
outputSpy = spyOnPrivate(host, "output")
|
||||
expect(state).toBeDefined()
|
||||
expect(state.state).toBeDefined()
|
||||
expect(state.isWaitingForInput).toBeDefined()
|
||||
expect(state.isRunning).toBeDefined()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
it("should return isWaitingForInput() status", () => {
|
||||
const host = createTestHost()
|
||||
expect(typeof host.isWaitingForInput()).toBe("boolean")
|
||||
})
|
||||
|
||||
it("should handle command type in non-interactive mode", () => {
|
||||
callPrivate(host, "handleAskMessage", 123, "command", "ls -la", false)
|
||||
|
||||
expect(outputSpy).toHaveBeenCalledWith("\n[command]", "ls -la")
|
||||
it("should return isAgentRunning() status", () => {
|
||||
const host = createTestHost()
|
||||
expect(typeof host.isAgentRunning()).toBe("boolean")
|
||||
})
|
||||
|
||||
it("should handle tool type with JSON parsing in non-interactive mode", () => {
|
||||
const toolInfo = JSON.stringify({ tool: "write_file", path: "/test/file.txt" })
|
||||
it("should return the client from getExtensionClient()", () => {
|
||||
const host = createTestHost()
|
||||
const client = host.getExtensionClient()
|
||||
|
||||
callPrivate(host, "handleAskMessage", 123, "tool", toolInfo, false)
|
||||
|
||||
expect(outputSpy).toHaveBeenCalledWith("\n[Tool Request] write_file")
|
||||
expect(outputSpy).toHaveBeenCalledWith(" path: /test/file.txt")
|
||||
expect(client).toBeDefined()
|
||||
expect(typeof client.handleMessage).toBe("function")
|
||||
})
|
||||
|
||||
it("should handle tool type with content preview in non-interactive mode", () => {
|
||||
const toolInfo = JSON.stringify({
|
||||
tool: "write_file",
|
||||
content: "This is the content that will be written to the file. It might be long.",
|
||||
})
|
||||
it("should return the output manager from getOutputManager()", () => {
|
||||
const host = createTestHost()
|
||||
const outputManager = host.getOutputManager()
|
||||
|
||||
callPrivate(host, "handleAskMessage", 123, "tool", toolInfo, false)
|
||||
|
||||
// Content is now shown (all tool parameters are displayed)
|
||||
expect(outputSpy).toHaveBeenCalledWith("\n[Tool Request] write_file")
|
||||
expect(outputSpy).toHaveBeenCalledWith(
|
||||
" content: This is the content that will be written to the file. It might be long.",
|
||||
)
|
||||
expect(outputManager).toBeDefined()
|
||||
expect(typeof outputManager.output).toBe("function")
|
||||
})
|
||||
|
||||
it("should handle tool type with invalid JSON in non-interactive mode", () => {
|
||||
callPrivate(host, "handleAskMessage", 123, "tool", "not json", false)
|
||||
it("should return the prompt manager from getPromptManager()", () => {
|
||||
const host = createTestHost()
|
||||
const promptManager = host.getPromptManager()
|
||||
|
||||
expect(outputSpy).toHaveBeenCalledWith("\n[Tool Request] unknown")
|
||||
expect(promptManager).toBeDefined()
|
||||
})
|
||||
|
||||
it("should not display duplicate messages for same ts", () => {
|
||||
const toolInfo = JSON.stringify({ tool: "read_file" })
|
||||
it("should return the ask dispatcher from getAskDispatcher()", () => {
|
||||
const host = createTestHost()
|
||||
const askDispatcher = host.getAskDispatcher()
|
||||
|
||||
// First call
|
||||
callPrivate(host, "handleAskMessage", 123, "tool", toolInfo, false)
|
||||
outputSpy.mockClear()
|
||||
|
||||
// Same ts - should be duplicate (already displayed)
|
||||
callPrivate(host, "handleAskMessage", 123, "tool", toolInfo, false)
|
||||
|
||||
// Should not log again
|
||||
expect(outputSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should handle other ask types in non-interactive mode", () => {
|
||||
callPrivate(host, "handleAskMessage", 123, "question", "What is your name?", false)
|
||||
|
||||
expect(outputSpy).toHaveBeenCalledWith("\n[question]", "What is your name?")
|
||||
})
|
||||
|
||||
it("should skip partial messages", () => {
|
||||
callPrivate(host, "handleAskMessage", 123, "command", "ls -la", true)
|
||||
|
||||
// Partial messages should be skipped
|
||||
expect(outputSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("handleAskMessage - interactive mode", () => {
|
||||
let host: ExtensionHost
|
||||
let outputSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
// Default interactive mode
|
||||
host = createTestHost({ nonInteractive: false })
|
||||
// Mock process.stdout.write which is used by output()
|
||||
vi.spyOn(process.stdout, "write").mockImplementation(() => true)
|
||||
outputSpy = spyOnPrivate(host, "output")
|
||||
// Mock readline to prevent actual prompting
|
||||
vi.spyOn(process.stdin, "on").mockImplementation(() => process.stdin)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it("should mark ask as pending in interactive mode", () => {
|
||||
// This will try to prompt, but we're testing the pendingAsks tracking
|
||||
callPrivate(host, "handleAskMessage", 123, "command", "ls -la", false)
|
||||
|
||||
const pendingAsks = getPrivate<Set<number>>(host, "pendingAsks")
|
||||
expect(pendingAsks.has(123)).toBe(true)
|
||||
})
|
||||
|
||||
it("should skip already pending asks", () => {
|
||||
// First call - marks as pending
|
||||
callPrivate(host, "handleAskMessage", 123, "command", "ls -la", false)
|
||||
const callCount1 = outputSpy.mock.calls.length
|
||||
|
||||
// Second call - should skip
|
||||
callPrivate(host, "handleAskMessage", 123, "command", "ls -la", false)
|
||||
const callCount2 = outputSpy.mock.calls.length
|
||||
|
||||
// Should not have logged again
|
||||
expect(callCount2).toBe(callCount1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("handleFollowupQuestion", () => {
|
||||
let host: ExtensionHost
|
||||
let outputSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
host = createTestHost({ nonInteractive: false })
|
||||
// Mock process.stdout.write which is used by output()
|
||||
vi.spyOn(process.stdout, "write").mockImplementation(() => true)
|
||||
outputSpy = spyOnPrivate(host, "output")
|
||||
// Mock readline to prevent actual prompting
|
||||
vi.spyOn(process.stdin, "on").mockImplementation(() => process.stdin)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it("should parse followup question JSON with suggestion objects containing answer and mode", async () => {
|
||||
// This is the format from AskFollowupQuestionTool
|
||||
// { question: "...", suggest: [{ answer: "text", mode: "code" }, ...] }
|
||||
const text = JSON.stringify({
|
||||
question: "What would you like to do?",
|
||||
suggest: [
|
||||
{ answer: "Write code", mode: "code" },
|
||||
{ answer: "Debug issue", mode: "debug" },
|
||||
{ answer: "Just explain", mode: null },
|
||||
],
|
||||
})
|
||||
|
||||
// Call the handler (it will try to prompt but we just want to test parsing)
|
||||
callPrivate(host, "handleFollowupQuestion", 123, text)
|
||||
|
||||
// Should display the question
|
||||
expect(outputSpy).toHaveBeenCalledWith("\n[question]", "What would you like to do?")
|
||||
|
||||
// Should display suggestions with answer text and mode hints
|
||||
expect(outputSpy).toHaveBeenCalledWith("\nSuggested answers:")
|
||||
expect(outputSpy).toHaveBeenCalledWith(" 1. Write code (mode: code)")
|
||||
expect(outputSpy).toHaveBeenCalledWith(" 2. Debug issue (mode: debug)")
|
||||
expect(outputSpy).toHaveBeenCalledWith(" 3. Just explain")
|
||||
})
|
||||
|
||||
it("should handle followup question with suggestions that have no mode", async () => {
|
||||
const text = JSON.stringify({
|
||||
question: "What path?",
|
||||
suggest: [{ answer: "./src/file.ts" }, { answer: "./lib/other.ts" }],
|
||||
})
|
||||
|
||||
callPrivate(host, "handleFollowupQuestion", 123, text)
|
||||
|
||||
expect(outputSpy).toHaveBeenCalledWith("\n[question]", "What path?")
|
||||
expect(outputSpy).toHaveBeenCalledWith(" 1. ./src/file.ts")
|
||||
expect(outputSpy).toHaveBeenCalledWith(" 2. ./lib/other.ts")
|
||||
})
|
||||
|
||||
it("should handle plain text (non-JSON) as the question", async () => {
|
||||
callPrivate(host, "handleFollowupQuestion", 123, "What is your name?")
|
||||
|
||||
expect(outputSpy).toHaveBeenCalledWith("\n[question]", "What is your name?")
|
||||
})
|
||||
|
||||
it("should handle empty suggestions array", async () => {
|
||||
const text = JSON.stringify({
|
||||
question: "Tell me more",
|
||||
suggest: [],
|
||||
})
|
||||
|
||||
callPrivate(host, "handleFollowupQuestion", 123, text)
|
||||
|
||||
expect(outputSpy).toHaveBeenCalledWith("\n[question]", "Tell me more")
|
||||
// Should not show "Suggested answers:" if array is empty
|
||||
expect(outputSpy).not.toHaveBeenCalledWith("\nSuggested answers:")
|
||||
})
|
||||
})
|
||||
|
||||
describe("handleFollowupQuestionWithTimeout", () => {
|
||||
let host: ExtensionHost
|
||||
let outputSpy: ReturnType<typeof vi.spyOn>
|
||||
const originalIsTTY = process.stdin.isTTY
|
||||
|
||||
beforeEach(() => {
|
||||
// Non-interactive mode uses the timeout variant
|
||||
host = createTestHost({ nonInteractive: true })
|
||||
// Mock process.stdout.write which is used by output()
|
||||
vi.spyOn(process.stdout, "write").mockImplementation(() => true)
|
||||
outputSpy = spyOnPrivate(host, "output")
|
||||
// Mock stdin - set isTTY to false so setRawMode is not called
|
||||
Object.defineProperty(process.stdin, "isTTY", { value: false, writable: true })
|
||||
vi.spyOn(process.stdin, "on").mockImplementation(() => process.stdin)
|
||||
vi.spyOn(process.stdin, "resume").mockImplementation(() => process.stdin)
|
||||
vi.spyOn(process.stdin, "pause").mockImplementation(() => process.stdin)
|
||||
vi.spyOn(process.stdin, "removeListener").mockImplementation(() => process.stdin)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
Object.defineProperty(process.stdin, "isTTY", { value: originalIsTTY, writable: true })
|
||||
})
|
||||
|
||||
it("should parse followup question JSON and display question with suggestions", () => {
|
||||
const text = JSON.stringify({
|
||||
question: "What would you like to do?",
|
||||
suggest: [
|
||||
{ answer: "Option A", mode: "code" },
|
||||
{ answer: "Option B", mode: null },
|
||||
],
|
||||
})
|
||||
|
||||
// Call the handler - it will display the question and start the timeout
|
||||
callPrivate(host, "handleFollowupQuestionWithTimeout", 123, text)
|
||||
|
||||
// Should display the question
|
||||
expect(outputSpy).toHaveBeenCalledWith("\n[question]", "What would you like to do?")
|
||||
|
||||
// Should display suggestions
|
||||
expect(outputSpy).toHaveBeenCalledWith("\nSuggested answers:")
|
||||
expect(outputSpy).toHaveBeenCalledWith(" 1. Option A (mode: code)")
|
||||
expect(outputSpy).toHaveBeenCalledWith(" 2. Option B")
|
||||
})
|
||||
|
||||
it("should handle non-JSON text as plain question", () => {
|
||||
callPrivate(host, "handleFollowupQuestionWithTimeout", 123, "Plain question text")
|
||||
|
||||
expect(outputSpy).toHaveBeenCalledWith("\n[question]", "Plain question text")
|
||||
})
|
||||
|
||||
it("should include auto-select hint in prompt when suggestions exist", () => {
|
||||
const stdoutWriteSpy = vi.spyOn(process.stdout, "write")
|
||||
const text = JSON.stringify({
|
||||
question: "Choose one",
|
||||
suggest: [{ answer: "First option" }],
|
||||
})
|
||||
|
||||
callPrivate(host, "handleFollowupQuestionWithTimeout", 123, text)
|
||||
|
||||
// Should show prompt with timeout hint
|
||||
expect(stdoutWriteSpy).toHaveBeenCalledWith(expect.stringContaining("auto-select in 60s"))
|
||||
})
|
||||
})
|
||||
|
||||
describe("handleAskMessageNonInteractive - followup handling", () => {
|
||||
let host: ExtensionHost
|
||||
let _outputSpy: ReturnType<typeof vi.spyOn>
|
||||
let handleFollowupTimeoutSpy: ReturnType<typeof vi.spyOn>
|
||||
const originalIsTTY = process.stdin.isTTY
|
||||
|
||||
beforeEach(() => {
|
||||
host = createTestHost({ nonInteractive: true })
|
||||
vi.spyOn(process.stdout, "write").mockImplementation(() => true)
|
||||
_outputSpy = spyOnPrivate(host, "output")
|
||||
handleFollowupTimeoutSpy = spyOnPrivate(host, "handleFollowupQuestionWithTimeout")
|
||||
// Mock stdin - set isTTY to false so setRawMode is not called
|
||||
Object.defineProperty(process.stdin, "isTTY", { value: false, writable: true })
|
||||
vi.spyOn(process.stdin, "on").mockImplementation(() => process.stdin)
|
||||
vi.spyOn(process.stdin, "resume").mockImplementation(() => process.stdin)
|
||||
vi.spyOn(process.stdin, "pause").mockImplementation(() => process.stdin)
|
||||
vi.spyOn(process.stdin, "removeListener").mockImplementation(() => process.stdin)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
Object.defineProperty(process.stdin, "isTTY", { value: originalIsTTY, writable: true })
|
||||
})
|
||||
|
||||
it("should call handleFollowupQuestionWithTimeout for followup asks in non-interactive mode", () => {
|
||||
const text = JSON.stringify({
|
||||
question: "What to do?",
|
||||
suggest: [{ answer: "Do something" }],
|
||||
})
|
||||
|
||||
callPrivate(host, "handleAskMessageNonInteractive", 123, "followup", text)
|
||||
|
||||
expect(handleFollowupTimeoutSpy).toHaveBeenCalledWith(123, text)
|
||||
})
|
||||
|
||||
it("should add ts to pendingAsks for followup in non-interactive mode", () => {
|
||||
const text = JSON.stringify({
|
||||
question: "What to do?",
|
||||
suggest: [{ answer: "Do something" }],
|
||||
})
|
||||
|
||||
callPrivate(host, "handleAskMessageNonInteractive", 123, "followup", text)
|
||||
|
||||
const pendingAsks = getPrivate<Set<number>>(host, "pendingAsks")
|
||||
expect(pendingAsks.has(123)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("streamContent", () => {
|
||||
let host: ExtensionHost
|
||||
let writeStreamSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
host = createTestHost()
|
||||
// Mock process.stdout.write
|
||||
vi.spyOn(process.stdout, "write").mockImplementation(() => true)
|
||||
writeStreamSpy = spyOnPrivate(host, "writeStream")
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it("should output header and text for new messages", () => {
|
||||
callPrivate(host, "streamContent", 123, "Hello", "[Test]")
|
||||
|
||||
expect(writeStreamSpy).toHaveBeenCalledWith("\n[Test] ")
|
||||
expect(writeStreamSpy).toHaveBeenCalledWith("Hello")
|
||||
})
|
||||
|
||||
it("should compute delta for growing text", () => {
|
||||
// First call - establishes baseline
|
||||
callPrivate(host, "streamContent", 123, "Hello", "[Test]")
|
||||
writeStreamSpy.mockClear()
|
||||
|
||||
// Second call - should only output delta
|
||||
callPrivate(host, "streamContent", 123, "Hello World", "[Test]")
|
||||
|
||||
expect(writeStreamSpy).toHaveBeenCalledWith(" World")
|
||||
})
|
||||
|
||||
it("should skip when text has not grown", () => {
|
||||
callPrivate(host, "streamContent", 123, "Hello", "[Test]")
|
||||
writeStreamSpy.mockClear()
|
||||
|
||||
callPrivate(host, "streamContent", 123, "Hello", "[Test]")
|
||||
|
||||
expect(writeStreamSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should skip when text does not match prefix", () => {
|
||||
callPrivate(host, "streamContent", 123, "Hello", "[Test]")
|
||||
writeStreamSpy.mockClear()
|
||||
|
||||
// Different text entirely
|
||||
callPrivate(host, "streamContent", 123, "Goodbye", "[Test]")
|
||||
|
||||
expect(writeStreamSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should track currently streaming ts", () => {
|
||||
callPrivate(host, "streamContent", 123, "Hello", "[Test]")
|
||||
|
||||
expect(getPrivate(host, "currentlyStreamingTs")).toBe(123)
|
||||
})
|
||||
})
|
||||
|
||||
describe("finishStream", () => {
|
||||
let host: ExtensionHost
|
||||
let writeStreamSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
host = createTestHost()
|
||||
vi.spyOn(process.stdout, "write").mockImplementation(() => true)
|
||||
writeStreamSpy = spyOnPrivate(host, "writeStream")
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it("should add newline when finishing current stream", () => {
|
||||
// Set up streaming state
|
||||
callPrivate(host, "streamContent", 123, "Hello", "[Test]")
|
||||
writeStreamSpy.mockClear()
|
||||
|
||||
callPrivate(host, "finishStream", 123)
|
||||
|
||||
expect(writeStreamSpy).toHaveBeenCalledWith("\n")
|
||||
expect(getPrivate(host, "currentlyStreamingTs")).toBeNull()
|
||||
})
|
||||
|
||||
it("should not add newline for different ts", () => {
|
||||
callPrivate(host, "streamContent", 123, "Hello", "[Test]")
|
||||
writeStreamSpy.mockClear()
|
||||
|
||||
callPrivate(host, "finishStream", 456)
|
||||
|
||||
expect(writeStreamSpy).not.toHaveBeenCalled()
|
||||
expect(askDispatcher).toBeDefined()
|
||||
expect(typeof askDispatcher.handleAsk).toBe("function")
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -975,6 +547,27 @@ describe("ExtensionHost", () => {
|
|||
|
||||
expect(restoreConsoleSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should clear managers", async () => {
|
||||
const outputManager = host.getOutputManager()
|
||||
const askDispatcher = host.getAskDispatcher()
|
||||
const outputClearSpy = vi.spyOn(outputManager, "clear")
|
||||
const askClearSpy = vi.spyOn(askDispatcher, "clear")
|
||||
|
||||
await host.dispose()
|
||||
|
||||
expect(outputClearSpy).toHaveBeenCalled()
|
||||
expect(askClearSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should reset client", async () => {
|
||||
const client = host.getExtensionClient()
|
||||
const resetSpy = vi.spyOn(client, "reset")
|
||||
|
||||
await host.dispose()
|
||||
|
||||
expect(resetSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("waitForCompletion", () => {
|
||||
|
|
@ -1000,7 +593,7 @@ describe("ExtensionHost", () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe("handleStateMessage - mode tracking", () => {
|
||||
describe("mode tracking via handleExtensionMessage", () => {
|
||||
let host: ExtensionHost
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -1020,39 +613,45 @@ describe("ExtensionHost", () => {
|
|||
|
||||
it("should track current mode when state updates with a mode", () => {
|
||||
// Initial state update establishes current mode
|
||||
callPrivate(host, "handleStateMessage", { type: "state", state: { mode: "code", clineMessages: [] } })
|
||||
callPrivate(host, "handleExtensionMessage", { type: "state", state: { mode: "code", clineMessages: [] } })
|
||||
expect(getPrivate(host, "currentMode")).toBe("code")
|
||||
|
||||
// Second state update should update tracked mode
|
||||
callPrivate(host, "handleStateMessage", { type: "state", state: { mode: "architect", clineMessages: [] } })
|
||||
callPrivate(host, "handleExtensionMessage", {
|
||||
type: "state",
|
||||
state: { mode: "architect", clineMessages: [] },
|
||||
})
|
||||
expect(getPrivate(host, "currentMode")).toBe("architect")
|
||||
})
|
||||
|
||||
it("should not change current mode when state has no mode", () => {
|
||||
// Initial state update establishes current mode
|
||||
callPrivate(host, "handleStateMessage", { type: "state", state: { mode: "code", clineMessages: [] } })
|
||||
callPrivate(host, "handleExtensionMessage", { type: "state", state: { mode: "code", clineMessages: [] } })
|
||||
expect(getPrivate(host, "currentMode")).toBe("code")
|
||||
|
||||
// State without mode should not change tracked mode
|
||||
callPrivate(host, "handleStateMessage", { type: "state", state: { clineMessages: [] } })
|
||||
callPrivate(host, "handleExtensionMessage", { type: "state", state: { clineMessages: [] } })
|
||||
expect(getPrivate(host, "currentMode")).toBe("code")
|
||||
})
|
||||
|
||||
it("should track current mode across multiple changes", () => {
|
||||
// Start with code mode
|
||||
callPrivate(host, "handleStateMessage", { type: "state", state: { mode: "code", clineMessages: [] } })
|
||||
callPrivate(host, "handleExtensionMessage", { type: "state", state: { mode: "code", clineMessages: [] } })
|
||||
expect(getPrivate(host, "currentMode")).toBe("code")
|
||||
|
||||
// Change to architect
|
||||
callPrivate(host, "handleStateMessage", { type: "state", state: { mode: "architect", clineMessages: [] } })
|
||||
callPrivate(host, "handleExtensionMessage", {
|
||||
type: "state",
|
||||
state: { mode: "architect", clineMessages: [] },
|
||||
})
|
||||
expect(getPrivate(host, "currentMode")).toBe("architect")
|
||||
|
||||
// Change to debug
|
||||
callPrivate(host, "handleStateMessage", { type: "state", state: { mode: "debug", clineMessages: [] } })
|
||||
callPrivate(host, "handleExtensionMessage", { type: "state", state: { mode: "debug", clineMessages: [] } })
|
||||
expect(getPrivate(host, "currentMode")).toBe("debug")
|
||||
|
||||
// Another state update with debug
|
||||
callPrivate(host, "handleStateMessage", { type: "state", state: { mode: "debug", clineMessages: [] } })
|
||||
callPrivate(host, "handleExtensionMessage", { type: "state", state: { mode: "debug", clineMessages: [] } })
|
||||
expect(getPrivate(host, "currentMode")).toBe("debug")
|
||||
})
|
||||
|
||||
|
|
@ -1063,11 +662,14 @@ describe("ExtensionHost", () => {
|
|||
const sendToExtensionSpy = vi.spyOn(host, "sendToExtension")
|
||||
|
||||
// Initial state
|
||||
callPrivate(host, "handleStateMessage", { type: "state", state: { mode: "code", clineMessages: [] } })
|
||||
callPrivate(host, "handleExtensionMessage", { type: "state", state: { mode: "code", clineMessages: [] } })
|
||||
sendToExtensionSpy.mockClear()
|
||||
|
||||
// Mode change should NOT trigger sendToExtension
|
||||
callPrivate(host, "handleStateMessage", { type: "state", state: { mode: "architect", clineMessages: [] } })
|
||||
callPrivate(host, "handleExtensionMessage", {
|
||||
type: "state",
|
||||
state: { mode: "architect", clineMessages: [] },
|
||||
})
|
||||
expect(sendToExtensionSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
@ -1169,14 +771,14 @@ describe("ExtensionHost", () => {
|
|||
|
||||
it("should preserve mode switch when starting a new task", () => {
|
||||
// Step 1: Initial state from extension (like webviewDidLaunch response)
|
||||
callPrivate(host, "handleStateMessage", {
|
||||
callPrivate(host, "handleExtensionMessage", {
|
||||
type: "state",
|
||||
state: { mode: "code", clineMessages: [] },
|
||||
})
|
||||
expect(getPrivate(host, "currentMode")).toBe("code")
|
||||
|
||||
// Step 2: User presses Ctrl+M to switch mode, extension sends new state
|
||||
callPrivate(host, "handleStateMessage", {
|
||||
callPrivate(host, "handleExtensionMessage", {
|
||||
type: "state",
|
||||
state: { mode: "architect", clineMessages: [] },
|
||||
})
|
||||
|
|
@ -1200,19 +802,19 @@ describe("ExtensionHost", () => {
|
|||
|
||||
it("should track multiple mode switches correctly", () => {
|
||||
// Switch through multiple modes
|
||||
callPrivate(host, "handleStateMessage", {
|
||||
callPrivate(host, "handleExtensionMessage", {
|
||||
type: "state",
|
||||
state: { mode: "code", clineMessages: [] },
|
||||
})
|
||||
callPrivate(host, "handleStateMessage", {
|
||||
callPrivate(host, "handleExtensionMessage", {
|
||||
type: "state",
|
||||
state: { mode: "architect", clineMessages: [] },
|
||||
})
|
||||
callPrivate(host, "handleStateMessage", {
|
||||
callPrivate(host, "handleExtensionMessage", {
|
||||
type: "state",
|
||||
state: { mode: "debug", clineMessages: [] },
|
||||
})
|
||||
callPrivate(host, "handleStateMessage", {
|
||||
callPrivate(host, "handleExtensionMessage", {
|
||||
type: "state",
|
||||
state: { mode: "ask", clineMessages: [] },
|
||||
})
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import fs from "fs"
|
||||
import path from "path"
|
||||
|
||||
import { getApiKeyFromEnv, getDefaultExtensionPath } from "../extensionHostUtils.js"
|
||||
import { getApiKeyFromEnv, getDefaultExtensionPath } from "../utils.js"
|
||||
|
||||
vi.mock("fs")
|
||||
|
||||
671
apps/cli/src/extension-host/ask-dispatcher.ts
Normal file
671
apps/cli/src/extension-host/ask-dispatcher.ts
Normal file
|
|
@ -0,0 +1,671 @@
|
|||
/**
|
||||
* AskDispatcher - Routes ask messages to appropriate handlers
|
||||
*
|
||||
* This dispatcher is responsible for:
|
||||
* - Categorizing ask types using type guards from client module
|
||||
* - Routing to the appropriate handler based on ask category
|
||||
* - Coordinating between OutputManager and PromptManager
|
||||
* - Tracking which asks have been handled (to avoid duplicates)
|
||||
*
|
||||
* Design notes:
|
||||
* - Uses isIdleAsk, isInteractiveAsk, isResumableAsk, isNonBlockingAsk type guards
|
||||
* - Single responsibility: Ask routing and handling only
|
||||
* - Delegates output to OutputManager, input to PromptManager
|
||||
* - Sends responses back through a provided callback
|
||||
*/
|
||||
|
||||
import type { WebviewMessage, ClineMessage, ClineAsk, ClineAskResponse } from "../extension-client/types.js"
|
||||
import { isIdleAsk, isInteractiveAsk, isResumableAsk, isNonBlockingAsk } from "../extension-client/index.js"
|
||||
import type { OutputManager } from "./output-manager.js"
|
||||
import type { PromptManager } from "./prompt-manager.js"
|
||||
import { FOLLOWUP_TIMEOUT_SECONDS } from "../types/constants.js"
|
||||
import { debugLog } from "@roo-code/core/debug-log"
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Configuration for AskDispatcher.
|
||||
*/
|
||||
export interface AskDispatcherOptions {
|
||||
/**
|
||||
* OutputManager for displaying ask-related output.
|
||||
*/
|
||||
outputManager: OutputManager
|
||||
|
||||
/**
|
||||
* PromptManager for collecting user input.
|
||||
*/
|
||||
promptManager: PromptManager
|
||||
|
||||
/**
|
||||
* Callback to send responses to the extension.
|
||||
*/
|
||||
sendMessage: (message: WebviewMessage) => void
|
||||
|
||||
/**
|
||||
* Whether running in non-interactive mode (auto-approve).
|
||||
*/
|
||||
nonInteractive?: boolean
|
||||
|
||||
/**
|
||||
* Whether to disable ask handling (for TUI mode).
|
||||
* In TUI mode, the TUI handles asks directly.
|
||||
*/
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of handling an ask.
|
||||
*/
|
||||
export interface AskHandleResult {
|
||||
/** Whether the ask was handled */
|
||||
handled: boolean
|
||||
/** The response sent (if any) */
|
||||
response?: ClineAskResponse
|
||||
/** Any error that occurred */
|
||||
error?: Error
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// AskDispatcher Class
|
||||
// =============================================================================
|
||||
|
||||
export class AskDispatcher {
|
||||
private outputManager: OutputManager
|
||||
private promptManager: PromptManager
|
||||
private sendMessage: (message: WebviewMessage) => void
|
||||
private nonInteractive: boolean
|
||||
private disabled: boolean
|
||||
|
||||
/**
|
||||
* Track which asks have been handled to avoid duplicates.
|
||||
* Key: message ts
|
||||
*/
|
||||
private handledAsks = new Set<number>()
|
||||
|
||||
constructor(options: AskDispatcherOptions) {
|
||||
this.outputManager = options.outputManager
|
||||
this.promptManager = options.promptManager
|
||||
this.sendMessage = options.sendMessage
|
||||
this.nonInteractive = options.nonInteractive ?? false
|
||||
this.disabled = options.disabled ?? false
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Public API
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Handle an ask message.
|
||||
* Routes to the appropriate handler based on ask type.
|
||||
*
|
||||
* @param message - The ClineMessage with type="ask"
|
||||
* @returns Promise<AskHandleResult>
|
||||
*/
|
||||
async handleAsk(message: ClineMessage): Promise<AskHandleResult> {
|
||||
// Disabled in TUI mode - TUI handles asks directly
|
||||
if (this.disabled) {
|
||||
return { handled: false }
|
||||
}
|
||||
|
||||
const ts = message.ts
|
||||
const ask = message.ask
|
||||
const text = message.text || ""
|
||||
|
||||
// Check if already handled
|
||||
if (this.handledAsks.has(ts)) {
|
||||
return { handled: true }
|
||||
}
|
||||
|
||||
// Must be an ask message
|
||||
if (message.type !== "ask" || !ask) {
|
||||
return { handled: false }
|
||||
}
|
||||
|
||||
// Skip partial messages (wait for complete)
|
||||
if (message.partial) {
|
||||
return { handled: false }
|
||||
}
|
||||
|
||||
// Mark as being handled
|
||||
this.handledAsks.add(ts)
|
||||
|
||||
try {
|
||||
// Route based on ask category
|
||||
if (isNonBlockingAsk(ask)) {
|
||||
return await this.handleNonBlockingAsk(ts, ask, text)
|
||||
}
|
||||
|
||||
if (isIdleAsk(ask)) {
|
||||
return await this.handleIdleAsk(ts, ask, text)
|
||||
}
|
||||
|
||||
if (isResumableAsk(ask)) {
|
||||
return await this.handleResumableAsk(ts, ask, text)
|
||||
}
|
||||
|
||||
if (isInteractiveAsk(ask)) {
|
||||
return await this.handleInteractiveAsk(ts, ask, text)
|
||||
}
|
||||
|
||||
// Unknown ask type - log and handle generically
|
||||
debugLog("[AskDispatcher] Unknown ask type", { ask, ts })
|
||||
return await this.handleUnknownAsk(ts, ask, text)
|
||||
} catch (error) {
|
||||
// Re-allow handling on error
|
||||
this.handledAsks.delete(ts)
|
||||
return {
|
||||
handled: false,
|
||||
error: error instanceof Error ? error : new Error(String(error)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an ask has been handled.
|
||||
*/
|
||||
isHandled(ts: number): boolean {
|
||||
return this.handledAsks.has(ts)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear handled asks (call when starting new task).
|
||||
*/
|
||||
clear(): void {
|
||||
this.handledAsks.clear()
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Category Handlers
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Handle non-blocking asks (command_output).
|
||||
* These don't actually block the agent - just need acknowledgment.
|
||||
*/
|
||||
private async handleNonBlockingAsk(_ts: number, _ask: ClineAsk, _text: string): Promise<AskHandleResult> {
|
||||
// command_output - output is handled by OutputManager
|
||||
// Just send approval to continue
|
||||
this.sendApprovalResponse(true)
|
||||
return { handled: true, response: "yesButtonClicked" }
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle idle asks (completion_result, api_req_failed, etc.).
|
||||
* These indicate the task has stopped.
|
||||
*/
|
||||
private async handleIdleAsk(ts: number, ask: ClineAsk, text: string): Promise<AskHandleResult> {
|
||||
switch (ask) {
|
||||
case "completion_result":
|
||||
// Task complete - nothing to do here, TaskCompleted event handles it
|
||||
return { handled: true }
|
||||
|
||||
case "api_req_failed":
|
||||
return await this.handleApiFailedRetry(ts, text)
|
||||
|
||||
case "mistake_limit_reached":
|
||||
return await this.handleMistakeLimitReached(ts, text)
|
||||
|
||||
case "resume_completed_task":
|
||||
return await this.handleResumeTask(ts, ask, text)
|
||||
|
||||
case "auto_approval_max_req_reached":
|
||||
return await this.handleAutoApprovalMaxReached(ts, text)
|
||||
|
||||
default:
|
||||
return { handled: false }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle resumable asks (resume_task).
|
||||
*/
|
||||
private async handleResumableAsk(ts: number, ask: ClineAsk, text: string): Promise<AskHandleResult> {
|
||||
return await this.handleResumeTask(ts, ask, text)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle interactive asks (followup, command, tool, browser_action_launch, use_mcp_server).
|
||||
* These require user approval or input.
|
||||
*/
|
||||
private async handleInteractiveAsk(ts: number, ask: ClineAsk, text: string): Promise<AskHandleResult> {
|
||||
switch (ask) {
|
||||
case "followup":
|
||||
return await this.handleFollowupQuestion(ts, text)
|
||||
|
||||
case "command":
|
||||
return await this.handleCommandApproval(ts, text)
|
||||
|
||||
case "tool":
|
||||
return await this.handleToolApproval(ts, text)
|
||||
|
||||
case "browser_action_launch":
|
||||
return await this.handleBrowserApproval(ts, text)
|
||||
|
||||
case "use_mcp_server":
|
||||
return await this.handleMcpApproval(ts, text)
|
||||
|
||||
default:
|
||||
return { handled: false }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle unknown ask types.
|
||||
*/
|
||||
private async handleUnknownAsk(ts: number, ask: ClineAsk, text: string): Promise<AskHandleResult> {
|
||||
if (this.nonInteractive) {
|
||||
if (text) {
|
||||
this.outputManager.output(`\n[${ask}]`, text)
|
||||
}
|
||||
return { handled: true }
|
||||
}
|
||||
|
||||
return await this.handleGenericApproval(ts, ask, text)
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Specific Ask Handlers
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Handle followup questions - prompt for text input with suggestions.
|
||||
*/
|
||||
private async handleFollowupQuestion(ts: number, text: string): Promise<AskHandleResult> {
|
||||
let question = text
|
||||
let suggestions: Array<{ answer: string; mode?: string | null }> = []
|
||||
|
||||
try {
|
||||
const data = JSON.parse(text)
|
||||
question = data.question || text
|
||||
suggestions = Array.isArray(data.suggest) ? data.suggest : []
|
||||
} catch {
|
||||
// Use raw text if not JSON
|
||||
}
|
||||
|
||||
this.outputManager.output("\n[question]", question)
|
||||
|
||||
if (suggestions.length > 0) {
|
||||
this.outputManager.output("\nSuggested answers:")
|
||||
suggestions.forEach((suggestion, index) => {
|
||||
const suggestionText = suggestion.answer || String(suggestion)
|
||||
const modeHint = suggestion.mode ? ` (mode: ${suggestion.mode})` : ""
|
||||
this.outputManager.output(` ${index + 1}. ${suggestionText}${modeHint}`)
|
||||
})
|
||||
this.outputManager.output("")
|
||||
}
|
||||
|
||||
const firstSuggestion = suggestions.length > 0 ? suggestions[0] : null
|
||||
const defaultAnswer = firstSuggestion?.answer ?? ""
|
||||
|
||||
if (this.nonInteractive) {
|
||||
// Use timeout prompt in non-interactive mode
|
||||
const timeoutMs = FOLLOWUP_TIMEOUT_SECONDS * 1000
|
||||
const result = await this.promptManager.promptWithTimeout(
|
||||
suggestions.length > 0
|
||||
? `Enter number (1-${suggestions.length}) or type your answer (auto-select in ${Math.round(timeoutMs / 1000)}s): `
|
||||
: `Your answer (auto-select in ${Math.round(timeoutMs / 1000)}s): `,
|
||||
timeoutMs,
|
||||
defaultAnswer,
|
||||
)
|
||||
|
||||
let responseText = result.value.trim()
|
||||
responseText = this.resolveNumberedSuggestion(responseText, suggestions)
|
||||
|
||||
if (result.timedOut || result.cancelled) {
|
||||
this.outputManager.output(`[Using default: ${defaultAnswer || "(empty)"}]`)
|
||||
}
|
||||
|
||||
this.sendFollowupResponse(responseText)
|
||||
return { handled: true, response: "messageResponse" }
|
||||
}
|
||||
|
||||
// Interactive mode
|
||||
try {
|
||||
const answer = await this.promptManager.promptForInput(
|
||||
suggestions.length > 0
|
||||
? `Enter number (1-${suggestions.length}) or type your answer: `
|
||||
: "Your answer: ",
|
||||
)
|
||||
|
||||
let responseText = answer.trim()
|
||||
responseText = this.resolveNumberedSuggestion(responseText, suggestions)
|
||||
|
||||
this.sendFollowupResponse(responseText)
|
||||
return { handled: true, response: "messageResponse" }
|
||||
} catch {
|
||||
this.outputManager.output(`[Using default: ${defaultAnswer || "(empty)"}]`)
|
||||
this.sendFollowupResponse(defaultAnswer)
|
||||
return { handled: true, response: "messageResponse" }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle command execution approval.
|
||||
*/
|
||||
private async handleCommandApproval(ts: number, text: string): Promise<AskHandleResult> {
|
||||
this.outputManager.output("\n[command request]")
|
||||
this.outputManager.output(` Command: ${text || "(no command specified)"}`)
|
||||
this.outputManager.markDisplayed(ts, text || "", false)
|
||||
|
||||
if (this.nonInteractive) {
|
||||
// Auto-approved by extension settings
|
||||
return { handled: true }
|
||||
}
|
||||
|
||||
try {
|
||||
const approved = await this.promptManager.promptForYesNo("Execute this command? (y/n): ")
|
||||
this.sendApprovalResponse(approved)
|
||||
return { handled: true, response: approved ? "yesButtonClicked" : "noButtonClicked" }
|
||||
} catch {
|
||||
this.outputManager.output("[Defaulting to: no]")
|
||||
this.sendApprovalResponse(false)
|
||||
return { handled: true, response: "noButtonClicked" }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle tool execution approval.
|
||||
*/
|
||||
private async handleToolApproval(ts: number, text: string): Promise<AskHandleResult> {
|
||||
let toolName = "unknown"
|
||||
let toolInfo: Record<string, unknown> = {}
|
||||
|
||||
try {
|
||||
toolInfo = JSON.parse(text) as Record<string, unknown>
|
||||
toolName = (toolInfo.tool as string) || "unknown"
|
||||
} catch {
|
||||
// Use raw text if not JSON
|
||||
}
|
||||
|
||||
const isProtected = toolInfo.isProtected === true
|
||||
|
||||
if (isProtected) {
|
||||
this.outputManager.output(`\n[Tool Request] ${toolName} [PROTECTED CONFIGURATION FILE]`)
|
||||
this.outputManager.output(`⚠️ WARNING: This tool wants to modify a protected configuration file.`)
|
||||
this.outputManager.output(
|
||||
` Protected files include .rooignore, .roo/*, and other sensitive config files.`,
|
||||
)
|
||||
} else {
|
||||
this.outputManager.output(`\n[Tool Request] ${toolName}`)
|
||||
}
|
||||
|
||||
// Display tool details
|
||||
for (const [key, value] of Object.entries(toolInfo)) {
|
||||
if (key === "tool" || key === "isProtected") continue
|
||||
|
||||
let displayValue: string
|
||||
if (typeof value === "string") {
|
||||
displayValue = value.length > 200 ? value.substring(0, 200) + "..." : value
|
||||
} else if (typeof value === "object" && value !== null) {
|
||||
const json = JSON.stringify(value)
|
||||
displayValue = json.length > 200 ? json.substring(0, 200) + "..." : json
|
||||
} else {
|
||||
displayValue = String(value)
|
||||
}
|
||||
|
||||
this.outputManager.output(` ${key}: ${displayValue}`)
|
||||
}
|
||||
|
||||
this.outputManager.markDisplayed(ts, text || "", false)
|
||||
|
||||
if (this.nonInteractive) {
|
||||
// Auto-approved by extension settings (unless protected)
|
||||
return { handled: true }
|
||||
}
|
||||
|
||||
try {
|
||||
const approved = await this.promptManager.promptForYesNo("Approve this action? (y/n): ")
|
||||
this.sendApprovalResponse(approved)
|
||||
return { handled: true, response: approved ? "yesButtonClicked" : "noButtonClicked" }
|
||||
} catch {
|
||||
this.outputManager.output("[Defaulting to: no]")
|
||||
this.sendApprovalResponse(false)
|
||||
return { handled: true, response: "noButtonClicked" }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle browser action approval.
|
||||
*/
|
||||
private async handleBrowserApproval(ts: number, text: string): Promise<AskHandleResult> {
|
||||
this.outputManager.output("\n[browser action request]")
|
||||
if (text) {
|
||||
this.outputManager.output(` Action: ${text}`)
|
||||
}
|
||||
this.outputManager.markDisplayed(ts, text || "", false)
|
||||
|
||||
if (this.nonInteractive) {
|
||||
// Auto-approved by extension settings
|
||||
return { handled: true }
|
||||
}
|
||||
|
||||
try {
|
||||
const approved = await this.promptManager.promptForYesNo("Allow browser action? (y/n): ")
|
||||
this.sendApprovalResponse(approved)
|
||||
return { handled: true, response: approved ? "yesButtonClicked" : "noButtonClicked" }
|
||||
} catch {
|
||||
this.outputManager.output("[Defaulting to: no]")
|
||||
this.sendApprovalResponse(false)
|
||||
return { handled: true, response: "noButtonClicked" }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle MCP server access approval.
|
||||
*/
|
||||
private async handleMcpApproval(ts: number, text: string): Promise<AskHandleResult> {
|
||||
let serverName = "unknown"
|
||||
let toolName = ""
|
||||
let resourceUri = ""
|
||||
|
||||
try {
|
||||
const mcpInfo = JSON.parse(text)
|
||||
serverName = mcpInfo.server_name || "unknown"
|
||||
|
||||
if (mcpInfo.type === "use_mcp_tool") {
|
||||
toolName = mcpInfo.tool_name || ""
|
||||
} else if (mcpInfo.type === "access_mcp_resource") {
|
||||
resourceUri = mcpInfo.uri || ""
|
||||
}
|
||||
} catch {
|
||||
// Use raw text if not JSON
|
||||
}
|
||||
|
||||
this.outputManager.output("\n[mcp request]")
|
||||
this.outputManager.output(` Server: ${serverName}`)
|
||||
if (toolName) {
|
||||
this.outputManager.output(` Tool: ${toolName}`)
|
||||
}
|
||||
if (resourceUri) {
|
||||
this.outputManager.output(` Resource: ${resourceUri}`)
|
||||
}
|
||||
this.outputManager.markDisplayed(ts, text || "", false)
|
||||
|
||||
if (this.nonInteractive) {
|
||||
// Auto-approved by extension settings
|
||||
return { handled: true }
|
||||
}
|
||||
|
||||
try {
|
||||
const approved = await this.promptManager.promptForYesNo("Allow MCP access? (y/n): ")
|
||||
this.sendApprovalResponse(approved)
|
||||
return { handled: true, response: approved ? "yesButtonClicked" : "noButtonClicked" }
|
||||
} catch {
|
||||
this.outputManager.output("[Defaulting to: no]")
|
||||
this.sendApprovalResponse(false)
|
||||
return { handled: true, response: "noButtonClicked" }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle API request failed - retry prompt.
|
||||
*/
|
||||
private async handleApiFailedRetry(ts: number, text: string): Promise<AskHandleResult> {
|
||||
this.outputManager.output("\n[api request failed]")
|
||||
this.outputManager.output(` Error: ${text || "Unknown error"}`)
|
||||
this.outputManager.markDisplayed(ts, text || "", false)
|
||||
|
||||
if (this.nonInteractive) {
|
||||
this.outputManager.output("\n[retrying api request]")
|
||||
// Auto-retry in non-interactive mode
|
||||
return { handled: true }
|
||||
}
|
||||
|
||||
try {
|
||||
const retry = await this.promptManager.promptForYesNo("Retry the request? (y/n): ")
|
||||
this.sendApprovalResponse(retry)
|
||||
return { handled: true, response: retry ? "yesButtonClicked" : "noButtonClicked" }
|
||||
} catch {
|
||||
this.outputManager.output("[Defaulting to: no]")
|
||||
this.sendApprovalResponse(false)
|
||||
return { handled: true, response: "noButtonClicked" }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mistake limit reached.
|
||||
*/
|
||||
private async handleMistakeLimitReached(ts: number, text: string): Promise<AskHandleResult> {
|
||||
this.outputManager.output("\n[mistake limit reached]")
|
||||
if (text) {
|
||||
this.outputManager.output(` Details: ${text}`)
|
||||
}
|
||||
this.outputManager.markDisplayed(ts, text || "", false)
|
||||
|
||||
if (this.nonInteractive) {
|
||||
// Auto-proceed in non-interactive mode
|
||||
this.sendApprovalResponse(true)
|
||||
return { handled: true, response: "yesButtonClicked" }
|
||||
}
|
||||
|
||||
try {
|
||||
const proceed = await this.promptManager.promptForYesNo("Continue anyway? (y/n): ")
|
||||
this.sendApprovalResponse(proceed)
|
||||
return { handled: true, response: proceed ? "yesButtonClicked" : "noButtonClicked" }
|
||||
} catch {
|
||||
this.outputManager.output("[Defaulting to: no]")
|
||||
this.sendApprovalResponse(false)
|
||||
return { handled: true, response: "noButtonClicked" }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle auto-approval max reached.
|
||||
*/
|
||||
private async handleAutoApprovalMaxReached(ts: number, text: string): Promise<AskHandleResult> {
|
||||
this.outputManager.output("\n[auto-approval limit reached]")
|
||||
if (text) {
|
||||
this.outputManager.output(` Details: ${text}`)
|
||||
}
|
||||
this.outputManager.markDisplayed(ts, text || "", false)
|
||||
|
||||
if (this.nonInteractive) {
|
||||
// Auto-proceed in non-interactive mode
|
||||
this.sendApprovalResponse(true)
|
||||
return { handled: true, response: "yesButtonClicked" }
|
||||
}
|
||||
|
||||
try {
|
||||
const proceed = await this.promptManager.promptForYesNo("Continue with manual approval? (y/n): ")
|
||||
this.sendApprovalResponse(proceed)
|
||||
return { handled: true, response: proceed ? "yesButtonClicked" : "noButtonClicked" }
|
||||
} catch {
|
||||
this.outputManager.output("[Defaulting to: no]")
|
||||
this.sendApprovalResponse(false)
|
||||
return { handled: true, response: "noButtonClicked" }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle task resume prompt.
|
||||
*/
|
||||
private async handleResumeTask(ts: number, ask: ClineAsk, text: string): Promise<AskHandleResult> {
|
||||
const isCompleted = ask === "resume_completed_task"
|
||||
this.outputManager.output(`\n[Resume ${isCompleted ? "Completed " : ""}Task]`)
|
||||
if (text) {
|
||||
this.outputManager.output(` ${text}`)
|
||||
}
|
||||
this.outputManager.markDisplayed(ts, text || "", false)
|
||||
|
||||
if (this.nonInteractive) {
|
||||
this.outputManager.output("\n[continuing task]")
|
||||
// Auto-resume in non-interactive mode
|
||||
this.sendApprovalResponse(true)
|
||||
return { handled: true, response: "yesButtonClicked" }
|
||||
}
|
||||
|
||||
try {
|
||||
const resume = await this.promptManager.promptForYesNo("Continue with this task? (y/n): ")
|
||||
this.sendApprovalResponse(resume)
|
||||
return { handled: true, response: resume ? "yesButtonClicked" : "noButtonClicked" }
|
||||
} catch {
|
||||
this.outputManager.output("[Defaulting to: no]")
|
||||
this.sendApprovalResponse(false)
|
||||
return { handled: true, response: "noButtonClicked" }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle generic approval prompts for unknown ask types.
|
||||
*/
|
||||
private async handleGenericApproval(ts: number, ask: ClineAsk, text: string): Promise<AskHandleResult> {
|
||||
this.outputManager.output(`\n[${ask}]`)
|
||||
if (text) {
|
||||
this.outputManager.output(` ${text}`)
|
||||
}
|
||||
this.outputManager.markDisplayed(ts, text || "", false)
|
||||
|
||||
try {
|
||||
const approved = await this.promptManager.promptForYesNo("Approve? (y/n): ")
|
||||
this.sendApprovalResponse(approved)
|
||||
return { handled: true, response: approved ? "yesButtonClicked" : "noButtonClicked" }
|
||||
} catch {
|
||||
this.outputManager.output("[Defaulting to: no]")
|
||||
this.sendApprovalResponse(false)
|
||||
return { handled: true, response: "noButtonClicked" }
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Response Helpers
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Send a followup response (text answer) to the extension.
|
||||
*/
|
||||
private sendFollowupResponse(text: string): void {
|
||||
this.sendMessage({ type: "askResponse", askResponse: "messageResponse", text })
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an approval response (yes/no) to the extension.
|
||||
*/
|
||||
private sendApprovalResponse(approved: boolean): void {
|
||||
this.sendMessage({
|
||||
type: "askResponse",
|
||||
askResponse: approved ? "yesButtonClicked" : "noButtonClicked",
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a numbered suggestion selection.
|
||||
*/
|
||||
private resolveNumberedSuggestion(
|
||||
input: string,
|
||||
suggestions: Array<{ answer: string; mode?: string | null }>,
|
||||
): string {
|
||||
const num = parseInt(input, 10)
|
||||
if (!isNaN(num) && num >= 1 && num <= suggestions.length) {
|
||||
const selectedSuggestion = suggestions[num - 1]
|
||||
if (selectedSuggestion) {
|
||||
const selected = selectedSuggestion.answer || String(selectedSuggestion)
|
||||
this.outputManager.output(`Selected: ${selected}`)
|
||||
return selected
|
||||
}
|
||||
}
|
||||
return input
|
||||
}
|
||||
}
|
||||
698
apps/cli/src/extension-host/extension-host.ts
Normal file
698
apps/cli/src/extension-host/extension-host.ts
Normal file
|
|
@ -0,0 +1,698 @@
|
|||
/**
|
||||
* ExtensionHost - Loads and runs the Roo Code extension in CLI mode
|
||||
*
|
||||
* This class is a thin coordination layer responsible for:
|
||||
* 1. Creating the vscode-shim mock
|
||||
* 2. Loading the extension bundle via require()
|
||||
* 3. Activating the extension
|
||||
* 4. Wiring up managers for output, prompting, and ask handling
|
||||
*
|
||||
* Managers handle all the heavy lifting:
|
||||
* - ExtensionClient: Agent state detection (single source of truth)
|
||||
* - OutputManager: CLI output and streaming
|
||||
* - PromptManager: User input collection
|
||||
* - AskDispatcher: Ask routing and handling
|
||||
*/
|
||||
|
||||
import { EventEmitter } from "events"
|
||||
import { createRequire } from "module"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import fs from "fs"
|
||||
import os from "os"
|
||||
|
||||
import { ReasoningEffortExtended, RooCodeSettings, WebviewMessage } from "@roo-code/types"
|
||||
import { createVSCodeAPI, setRuntimeConfigValues } from "@roo-code/vscode-shim"
|
||||
import { DebugLogger } from "@roo-code/core/debug-log"
|
||||
|
||||
import { SupportedProvider } from "../types/types.js"
|
||||
import { User } from "../lib/sdk/types.js"
|
||||
|
||||
// Client module - single source of truth for agent state
|
||||
import {
|
||||
type AgentStateInfo,
|
||||
type AgentStateChangeEvent,
|
||||
type WaitingForInputEvent,
|
||||
type TaskCompletedEvent,
|
||||
type ClineMessage,
|
||||
type ExtensionMessage,
|
||||
ExtensionClient,
|
||||
AgentLoopState,
|
||||
} from "../extension-client/index.js"
|
||||
|
||||
// Managers for output, prompting, and ask handling
|
||||
import { 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
|
||||
const cliLogger = new DebugLogger("CLI")
|
||||
|
||||
// Get the CLI package root directory (for finding node_modules/@vscode/ripgrep)
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const CLI_PACKAGE_ROOT = path.resolve(__dirname, "..")
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
export interface ExtensionHostOptions {
|
||||
mode: string
|
||||
reasoningEffort?: ReasoningEffortExtended | "unspecified" | "disabled"
|
||||
user: User | null
|
||||
provider: SupportedProvider
|
||||
apiKey?: string
|
||||
model: string
|
||||
workspacePath: string
|
||||
extensionPath: string
|
||||
nonInteractive?: boolean
|
||||
debug?: boolean
|
||||
/**
|
||||
* When true, completely disables all direct stdout/stderr output.
|
||||
* Use this when running in TUI mode where Ink controls the terminal.
|
||||
*/
|
||||
disableOutput?: boolean
|
||||
/**
|
||||
* When true, uses a temporary storage directory that is cleaned up on exit.
|
||||
*/
|
||||
ephemeral?: boolean
|
||||
}
|
||||
|
||||
interface ExtensionModule {
|
||||
activate: (context: unknown) => Promise<unknown>
|
||||
deactivate?: () => Promise<void>
|
||||
}
|
||||
|
||||
interface WebviewViewProvider {
|
||||
resolveWebviewView?(webviewView: unknown, context: unknown, token: unknown): void | Promise<void>
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ExtensionHost Class
|
||||
// =============================================================================
|
||||
|
||||
export class ExtensionHost extends EventEmitter {
|
||||
// Extension lifecycle
|
||||
private vscode: ReturnType<typeof createVSCodeAPI> | null = null
|
||||
private extensionModule: ExtensionModule | null = null
|
||||
private extensionAPI: unknown = null
|
||||
private webviewProviders: Map<string, WebviewViewProvider> = new Map()
|
||||
private options: ExtensionHostOptions
|
||||
private isWebviewReady = false
|
||||
private pendingMessages: unknown[] = []
|
||||
private messageListener: ((message: ExtensionMessage) => void) | null = null
|
||||
|
||||
// Console suppression
|
||||
private originalConsole: {
|
||||
log: typeof console.log
|
||||
warn: typeof console.warn
|
||||
error: typeof console.error
|
||||
debug: typeof console.debug
|
||||
info: typeof console.info
|
||||
} | null = null
|
||||
private originalProcessEmitWarning: typeof process.emitWarning | null = null
|
||||
|
||||
// Mode tracking
|
||||
private currentMode: string | null = null
|
||||
|
||||
// Ephemeral storage
|
||||
private ephemeralStorageDir: string | null = null
|
||||
|
||||
// ==========================================================================
|
||||
// Managers - These do all the heavy lifting
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* ExtensionClient: Single source of truth for agent loop state.
|
||||
* Handles message processing and state detection.
|
||||
*/
|
||||
private client: ExtensionClient
|
||||
|
||||
/**
|
||||
* OutputManager: Handles all CLI output and streaming.
|
||||
* Uses Observable pattern internally for stream tracking.
|
||||
*/
|
||||
private outputManager: OutputManager
|
||||
|
||||
/**
|
||||
* PromptManager: Handles all user input collection.
|
||||
* Provides readline, yes/no, and timed prompts.
|
||||
*/
|
||||
private promptManager: PromptManager
|
||||
|
||||
/**
|
||||
* AskDispatcher: Routes asks to appropriate handlers.
|
||||
* Uses type guards (isIdleAsk, isInteractiveAsk, etc.) from client module.
|
||||
*/
|
||||
private askDispatcher: AskDispatcher
|
||||
|
||||
// ==========================================================================
|
||||
// Constructor
|
||||
// ==========================================================================
|
||||
|
||||
constructor(options: ExtensionHostOptions) {
|
||||
super()
|
||||
this.options = options
|
||||
this.currentMode = options.mode || null
|
||||
|
||||
// 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
|
||||
})
|
||||
|
||||
// Initialize output manager
|
||||
this.outputManager = new OutputManager({
|
||||
disabled: options.disableOutput,
|
||||
})
|
||||
|
||||
// Initialize prompt manager with console mode callbacks
|
||||
this.promptManager = new PromptManager({
|
||||
onBeforePrompt: () => this.restoreConsole(),
|
||||
onAfterPrompt: () => this.setupQuietMode(),
|
||||
})
|
||||
|
||||
// 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
|
||||
})
|
||||
|
||||
// Wire up client events
|
||||
this.setupClientEventHandlers()
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Client Event Handlers
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Wire up client events to managers.
|
||||
* The client emits events, managers handle them.
|
||||
*/
|
||||
private setupClientEventHandlers(): void {
|
||||
// Forward state changes for external consumers
|
||||
this.client.on("stateChange", (event: AgentStateChangeEvent) => {
|
||||
this.emit("agentStateChange", event)
|
||||
})
|
||||
|
||||
// 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
|
||||
this.client.on("messageUpdated", (msg: ClineMessage) => {
|
||||
this.logMessageDebug(msg, "updated")
|
||||
this.outputManager.outputMessage(msg)
|
||||
})
|
||||
|
||||
// Handle waiting for input - delegate to AskDispatcher
|
||||
this.client.on("waitingForInput", (event: WaitingForInputEvent) => {
|
||||
this.emit("agentWaitingForInput", event)
|
||||
this.handleWaitingForInput(event)
|
||||
})
|
||||
|
||||
// Handle task completion
|
||||
this.client.on("taskCompleted", (event: TaskCompletedEvent) => {
|
||||
this.emit("agentTaskCompleted", event)
|
||||
this.handleTaskCompleted(event)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug logging for messages (first/last pattern).
|
||||
*/
|
||||
private logMessageDebug(msg: ClineMessage, type: "new" | "updated"): void {
|
||||
if (msg.partial) {
|
||||
if (!this.outputManager.hasLoggedFirstPartial(msg.ts)) {
|
||||
this.outputManager.setLoggedFirstPartial(msg.ts)
|
||||
cliLogger.debug("message:start", { ts: msg.ts, type: msg.say || msg.ask })
|
||||
}
|
||||
} else {
|
||||
cliLogger.debug(`message:${type === "new" ? "new" : "complete"}`, { ts: msg.ts, type: msg.say || msg.ask })
|
||||
this.outputManager.clearLoggedFirstPartial(msg.ts)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
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
|
||||
this.emit("taskComplete")
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Console Suppression
|
||||
// ==========================================================================
|
||||
|
||||
private suppressNodeWarnings(): void {
|
||||
this.originalProcessEmitWarning = process.emitWarning
|
||||
process.emitWarning = () => {}
|
||||
process.on("warning", () => {})
|
||||
}
|
||||
|
||||
private setupQuietMode(): void {
|
||||
this.originalConsole = {
|
||||
log: console.log,
|
||||
warn: console.warn,
|
||||
error: console.error,
|
||||
debug: console.debug,
|
||||
info: console.info,
|
||||
}
|
||||
console.log = () => {}
|
||||
console.warn = () => {}
|
||||
console.debug = () => {}
|
||||
console.info = () => {}
|
||||
}
|
||||
|
||||
private restoreConsole(): void {
|
||||
if (this.originalConsole) {
|
||||
console.log = this.originalConsole.log
|
||||
console.warn = this.originalConsole.warn
|
||||
console.error = this.originalConsole.error
|
||||
console.debug = this.originalConsole.debug
|
||||
console.info = this.originalConsole.info
|
||||
this.originalConsole = null
|
||||
}
|
||||
|
||||
if (this.originalProcessEmitWarning) {
|
||||
process.emitWarning = this.originalProcessEmitWarning
|
||||
this.originalProcessEmitWarning = null
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Extension Lifecycle
|
||||
// ==========================================================================
|
||||
|
||||
private async createEphemeralStorageDir(): Promise<string> {
|
||||
const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`
|
||||
const tmpDir = path.join(os.tmpdir(), `roo-cli-${uniqueId}`)
|
||||
await fs.promises.mkdir(tmpDir, { recursive: true })
|
||||
return tmpDir
|
||||
}
|
||||
|
||||
async activate(): Promise<void> {
|
||||
this.suppressNodeWarnings()
|
||||
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
|
||||
this.vscode = createVSCodeAPI(this.options.extensionPath, this.options.workspacePath, undefined, {
|
||||
appRoot: CLI_PACKAGE_ROOT,
|
||||
storageDir,
|
||||
})
|
||||
;(global as Record<string, unknown>).vscode = this.vscode
|
||||
;(global as Record<string, unknown>).__extensionHost = this
|
||||
|
||||
// Set up module resolution
|
||||
const require = createRequire(import.meta.url)
|
||||
const Module = require("module")
|
||||
const originalResolve = Module._resolveFilename
|
||||
|
||||
Module._resolveFilename = function (request: string, parent: unknown, isMain: boolean, options: unknown) {
|
||||
if (request === "vscode") return "vscode-mock"
|
||||
return originalResolve.call(this, request, parent, isMain, options)
|
||||
}
|
||||
|
||||
require.cache["vscode-mock"] = {
|
||||
id: "vscode-mock",
|
||||
filename: "vscode-mock",
|
||||
loaded: true,
|
||||
exports: this.vscode,
|
||||
children: [],
|
||||
paths: [],
|
||||
path: "",
|
||||
isPreloading: false,
|
||||
parent: null,
|
||||
require: require,
|
||||
} as unknown as NodeJS.Module
|
||||
|
||||
try {
|
||||
this.extensionModule = require(bundlePath) as ExtensionModule
|
||||
} catch (error) {
|
||||
Module._resolveFilename = originalResolve
|
||||
throw new Error(
|
||||
`Failed to load extension bundle: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
}
|
||||
|
||||
Module._resolveFilename = originalResolve
|
||||
|
||||
try {
|
||||
this.extensionAPI = await this.extensionModule.activate(this.vscode.context)
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to activate extension: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
|
||||
// Set up message listener - forward all messages to client
|
||||
this.messageListener = (message: ExtensionMessage) => this.handleExtensionMessage(message)
|
||||
this.on("extensionWebviewMessage", this.messageListener)
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Webview Provider Registration
|
||||
// ==========================================================================
|
||||
|
||||
registerWebviewProvider(viewId: string, provider: WebviewViewProvider): void {
|
||||
this.webviewProviders.set(viewId, provider)
|
||||
}
|
||||
|
||||
unregisterWebviewProvider(viewId: string): void {
|
||||
this.webviewProviders.delete(viewId)
|
||||
}
|
||||
|
||||
isInInitialSetup(): boolean {
|
||||
return !this.isWebviewReady
|
||||
}
|
||||
|
||||
markWebviewReady(): void {
|
||||
this.isWebviewReady = true
|
||||
this.emit("webviewReady")
|
||||
this.flushPendingMessages()
|
||||
}
|
||||
|
||||
private flushPendingMessages(): void {
|
||||
if (this.pendingMessages.length > 0) {
|
||||
for (const message of this.pendingMessages) {
|
||||
this.emit("webviewMessage", message)
|
||||
}
|
||||
this.pendingMessages = []
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Message Handling
|
||||
// ==========================================================================
|
||||
|
||||
sendToExtension(message: WebviewMessage): void {
|
||||
if (!this.isWebviewReady) {
|
||||
this.pendingMessages.push(message)
|
||||
return
|
||||
}
|
||||
this.emit("webviewMessage", message)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming messages from extension.
|
||||
* Forward to client (single source of truth).
|
||||
*/
|
||||
private handleExtensionMessage(msg: ExtensionMessage): void {
|
||||
// Track mode changes
|
||||
if (msg.type === "state" && msg.state?.mode && typeof msg.state.mode === "string") {
|
||||
this.currentMode = msg.state.mode
|
||||
}
|
||||
|
||||
// Forward to client - it's the single source of truth
|
||||
this.client.handleMessage(msg)
|
||||
|
||||
// Handle modes separately
|
||||
if (msg.type === "modes") {
|
||||
this.emit("modesUpdated", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Task Management
|
||||
// ==========================================================================
|
||||
|
||||
private applyRuntimeSettings(settings: RooCodeSettings): void {
|
||||
const activeMode = this.currentMode || this.options.mode
|
||||
if (activeMode) {
|
||||
settings.mode = activeMode
|
||||
}
|
||||
|
||||
if (this.options.reasoningEffort && this.options.reasoningEffort !== "unspecified") {
|
||||
if (this.options.reasoningEffort === "disabled") {
|
||||
settings.enableReasoningEffort = false
|
||||
} else {
|
||||
settings.enableReasoningEffort = true
|
||||
settings.reasoningEffort = this.options.reasoningEffort
|
||||
}
|
||||
}
|
||||
|
||||
setRuntimeConfigValues("roo-cline", settings as Record<string, unknown>)
|
||||
}
|
||||
|
||||
private getApiKeyFromEnv(provider: string): string | undefined {
|
||||
const envVarMap: Record<string, string> = {
|
||||
anthropic: "ANTHROPIC_API_KEY",
|
||||
openai: "OPENAI_API_KEY",
|
||||
"openai-native": "OPENAI_API_KEY",
|
||||
openrouter: "OPENROUTER_API_KEY",
|
||||
google: "GOOGLE_API_KEY",
|
||||
gemini: "GOOGLE_API_KEY",
|
||||
bedrock: "AWS_ACCESS_KEY_ID",
|
||||
ollama: "OLLAMA_API_KEY",
|
||||
mistral: "MISTRAL_API_KEY",
|
||||
deepseek: "DEEPSEEK_API_KEY",
|
||||
xai: "XAI_API_KEY",
|
||||
groq: "GROQ_API_KEY",
|
||||
}
|
||||
const envVar = envVarMap[provider.toLowerCase()] || `${provider.toUpperCase().replace(/-/g, "_")}_API_KEY`
|
||||
return process.env[envVar]
|
||||
}
|
||||
|
||||
private buildApiConfiguration(): RooCodeSettings {
|
||||
const provider = this.options.provider
|
||||
const apiKey = this.options.apiKey || this.getApiKeyFromEnv(provider)
|
||||
const model = this.options.model
|
||||
const config: RooCodeSettings = { apiProvider: provider }
|
||||
|
||||
switch (provider) {
|
||||
case "anthropic":
|
||||
if (apiKey) config.apiKey = apiKey
|
||||
if (model) config.apiModelId = model
|
||||
break
|
||||
case "openai-native":
|
||||
if (apiKey) config.openAiNativeApiKey = apiKey
|
||||
if (model) config.apiModelId = model
|
||||
break
|
||||
case "gemini":
|
||||
if (apiKey) config.geminiApiKey = apiKey
|
||||
if (model) config.apiModelId = model
|
||||
break
|
||||
case "openrouter":
|
||||
if (apiKey) config.openRouterApiKey = apiKey
|
||||
if (model) config.openRouterModelId = model
|
||||
break
|
||||
case "vercel-ai-gateway":
|
||||
if (apiKey) config.vercelAiGatewayApiKey = apiKey
|
||||
if (model) config.vercelAiGatewayModelId = model
|
||||
break
|
||||
case "roo":
|
||||
if (apiKey) config.rooApiKey = apiKey
|
||||
if (model) config.apiModelId = model
|
||||
break
|
||||
default:
|
||||
if (apiKey) config.apiKey = apiKey
|
||||
if (model) config.apiModelId = model
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
async runTask(prompt: string): Promise<void> {
|
||||
cliLogger.debug("runTask:start", { prompt: prompt?.substring(0, 100) })
|
||||
|
||||
if (!this.isWebviewReady) {
|
||||
await new Promise<void>((resolve) => this.once("webviewReady", resolve))
|
||||
}
|
||||
|
||||
const baseSettings: RooCodeSettings = {
|
||||
commandExecutionTimeout: 30,
|
||||
browserToolEnabled: false,
|
||||
enableCheckpoints: false,
|
||||
...this.buildApiConfiguration(),
|
||||
}
|
||||
|
||||
const settings: RooCodeSettings = this.options.nonInteractive
|
||||
? {
|
||||
autoApprovalEnabled: true,
|
||||
alwaysAllowReadOnly: true,
|
||||
alwaysAllowReadOnlyOutsideWorkspace: true,
|
||||
alwaysAllowWrite: true,
|
||||
alwaysAllowWriteOutsideWorkspace: true,
|
||||
alwaysAllowWriteProtected: true,
|
||||
alwaysAllowBrowser: true,
|
||||
alwaysAllowMcp: true,
|
||||
alwaysAllowModeSwitch: true,
|
||||
alwaysAllowSubtasks: true,
|
||||
alwaysAllowExecute: true,
|
||||
allowedCommands: ["*"],
|
||||
...baseSettings,
|
||||
}
|
||||
: {
|
||||
autoApprovalEnabled: false,
|
||||
...baseSettings,
|
||||
}
|
||||
|
||||
this.applyRuntimeSettings(settings)
|
||||
this.sendToExtension({ type: "updateSettings", updatedSettings: settings })
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 100))
|
||||
this.sendToExtension({ type: "newTask", text: prompt })
|
||||
await this.waitForCompletion()
|
||||
}
|
||||
|
||||
private waitForCompletion(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const completeHandler = () => {
|
||||
cleanup()
|
||||
resolve()
|
||||
}
|
||||
const errorHandler = (error: string) => {
|
||||
cleanup()
|
||||
reject(new Error(error))
|
||||
}
|
||||
const cleanup = () => {
|
||||
this.off("taskComplete", completeHandler)
|
||||
this.off("taskError", errorHandler)
|
||||
}
|
||||
|
||||
this.once("taskComplete", completeHandler)
|
||||
this.once("taskError", errorHandler)
|
||||
})
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Public Agent State API (delegated to ExtensionClient)
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get the current agent loop state.
|
||||
*/
|
||||
getAgentState(): AgentStateInfo {
|
||||
return this.client.getAgentState()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the agent is currently waiting for user input.
|
||||
*/
|
||||
isWaitingForInput(): boolean {
|
||||
return this.client.getAgentState().isWaitingForInput
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the agent is currently running.
|
||||
*/
|
||||
isAgentRunning(): boolean {
|
||||
return this.client.getAgentState().isRunning
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current agent loop state enum value.
|
||||
*/
|
||||
getAgentLoopState(): AgentLoopState {
|
||||
return this.client.getAgentState().state
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the underlying ExtensionClient for advanced use cases.
|
||||
*/
|
||||
getExtensionClient(): ExtensionClient {
|
||||
return this.client
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the OutputManager for advanced output control.
|
||||
*/
|
||||
getOutputManager(): OutputManager {
|
||||
return this.outputManager
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the PromptManager for advanced prompting.
|
||||
*/
|
||||
getPromptManager(): PromptManager {
|
||||
return this.promptManager
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the AskDispatcher for advanced ask handling.
|
||||
*/
|
||||
getAskDispatcher(): AskDispatcher {
|
||||
return this.askDispatcher
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Cleanup
|
||||
// ==========================================================================
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
// Clear managers
|
||||
this.outputManager.clear()
|
||||
this.askDispatcher.clear()
|
||||
|
||||
// Remove message listener
|
||||
if (this.messageListener) {
|
||||
this.off("extensionWebviewMessage", this.messageListener)
|
||||
this.messageListener = null
|
||||
}
|
||||
|
||||
// Reset client
|
||||
this.client.reset()
|
||||
|
||||
// Deactivate extension
|
||||
if (this.extensionModule?.deactivate) {
|
||||
try {
|
||||
await this.extensionModule.deactivate()
|
||||
} catch {
|
||||
// NO-OP
|
||||
}
|
||||
}
|
||||
|
||||
// Clear references
|
||||
this.vscode = null
|
||||
this.extensionModule = null
|
||||
this.extensionAPI = null
|
||||
this.webviewProviders.clear()
|
||||
|
||||
// Clear globals
|
||||
delete (global as Record<string, unknown>).vscode
|
||||
delete (global as Record<string, unknown>).__extensionHost
|
||||
|
||||
// Restore console
|
||||
this.restoreConsole()
|
||||
|
||||
// Clean up ephemeral storage
|
||||
if (this.ephemeralStorageDir) {
|
||||
try {
|
||||
await fs.promises.rm(this.ephemeralStorageDir, { recursive: true, force: true })
|
||||
this.ephemeralStorageDir = null
|
||||
} catch {
|
||||
// NO-OP
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1
apps/cli/src/extension-host/index.ts
Normal file
1
apps/cli/src/extension-host/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./extension-host.js"
|
||||
413
apps/cli/src/extension-host/output-manager.ts
Normal file
413
apps/cli/src/extension-host/output-manager.ts
Normal file
|
|
@ -0,0 +1,413 @@
|
|||
/**
|
||||
* OutputManager - Handles all CLI output and streaming
|
||||
*
|
||||
* This manager is responsible for:
|
||||
* - Writing messages to stdout/stderr
|
||||
* - Tracking what's been displayed (to avoid duplicates)
|
||||
* - Managing streaming content with delta computation
|
||||
* - Formatting different message types appropriately
|
||||
*
|
||||
* Design notes:
|
||||
* - Uses the Observable pattern from client/events.ts for internal state
|
||||
* - Single responsibility: CLI output only (no prompting, no state detection)
|
||||
* - 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"
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Tracks what we've displayed for a specific message ts.
|
||||
*/
|
||||
export interface DisplayedMessage {
|
||||
ts: number
|
||||
text: string
|
||||
partial: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks streaming state for a message.
|
||||
*/
|
||||
export interface StreamState {
|
||||
ts: number
|
||||
text: string
|
||||
headerShown: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration options for OutputManager.
|
||||
*/
|
||||
export interface OutputManagerOptions {
|
||||
/**
|
||||
* When true, completely disables all output.
|
||||
* Use for TUI mode where another system controls the terminal.
|
||||
*/
|
||||
disabled?: boolean
|
||||
|
||||
/**
|
||||
* Stream for normal output (default: process.stdout).
|
||||
*/
|
||||
stdout?: NodeJS.WriteStream
|
||||
|
||||
/**
|
||||
* Stream for error output (default: process.stderr).
|
||||
*/
|
||||
stderr?: NodeJS.WriteStream
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// OutputManager Class
|
||||
// =============================================================================
|
||||
|
||||
export class OutputManager {
|
||||
private disabled: boolean
|
||||
private stdout: NodeJS.WriteStream
|
||||
private stderr: NodeJS.WriteStream
|
||||
|
||||
/**
|
||||
* Track displayed messages by ts to avoid duplicate output.
|
||||
* Observable pattern allows external systems to subscribe if needed.
|
||||
*/
|
||||
private displayedMessages = new Map<number, DisplayedMessage>()
|
||||
|
||||
/**
|
||||
* Track streamed content by ts for delta computation.
|
||||
*/
|
||||
private streamedContent = new Map<number, StreamState>()
|
||||
|
||||
/**
|
||||
* Track which ts is currently streaming (for newline management).
|
||||
*/
|
||||
private currentlyStreamingTs: number | null = null
|
||||
|
||||
/**
|
||||
* Track first partial logs (for debugging first/last pattern).
|
||||
*/
|
||||
private loggedFirstPartial = new Set<number>()
|
||||
|
||||
/**
|
||||
* Observable for streaming state changes.
|
||||
* External systems can subscribe to know when streaming starts/ends.
|
||||
*/
|
||||
public readonly streamingState = new Observable<{ ts: number | null; isStreaming: boolean }>({
|
||||
ts: null,
|
||||
isStreaming: false,
|
||||
})
|
||||
|
||||
constructor(options: OutputManagerOptions = {}) {
|
||||
this.disabled = options.disabled ?? false
|
||||
this.stdout = options.stdout ?? process.stdout
|
||||
this.stderr = options.stderr ?? process.stderr
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Public API
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Output a ClineMessage based on its type.
|
||||
* This is the main entry point for message output.
|
||||
*
|
||||
* @param msg - The message to output
|
||||
* @param skipFirstUserMessage - If true, skip the first "text" message (user prompt echo)
|
||||
*/
|
||||
outputMessage(msg: ClineMessage, skipFirstUserMessage = true): void {
|
||||
const ts = msg.ts
|
||||
const text = msg.text || ""
|
||||
const isPartial = msg.partial === true
|
||||
const previousDisplay = this.displayedMessages.get(ts)
|
||||
const alreadyDisplayedComplete = previousDisplay && !previousDisplay.partial
|
||||
|
||||
if (msg.type === "say" && msg.say) {
|
||||
this.outputSayMessage(ts, msg.say, text, isPartial, alreadyDisplayedComplete, skipFirstUserMessage)
|
||||
} else if (msg.type === "ask" && msg.ask) {
|
||||
// For ask messages, we only output command_output here
|
||||
// Other asks are handled by AskDispatcher
|
||||
if (msg.ask === "command_output") {
|
||||
this.outputCommandOutput(ts, text, isPartial, alreadyDisplayedComplete)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Output a simple text line with a label.
|
||||
*/
|
||||
output(label: string, text?: string): void {
|
||||
if (this.disabled) return
|
||||
const message = text ? `${label} ${text}\n` : `${label}\n`
|
||||
this.stdout.write(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* Output an error message.
|
||||
*/
|
||||
outputError(label: string, text?: string): void {
|
||||
if (this.disabled) return
|
||||
const message = text ? `${label} ${text}\n` : `${label}\n`
|
||||
this.stderr.write(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* Write raw text to stdout (for streaming).
|
||||
*/
|
||||
writeRaw(text: string): void {
|
||||
if (this.disabled) return
|
||||
this.stdout.write(text)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a message has already been fully displayed.
|
||||
*/
|
||||
isAlreadyDisplayed(ts: number): boolean {
|
||||
const displayed = this.displayedMessages.get(ts)
|
||||
return displayed !== undefined && !displayed.partial
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we're currently streaming any message.
|
||||
*/
|
||||
isCurrentlyStreaming(): boolean {
|
||||
return this.currentlyStreamingTs !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ts of the currently streaming message.
|
||||
*/
|
||||
getCurrentlyStreamingTs(): number | null {
|
||||
return this.currentlyStreamingTs
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a message as displayed (useful for external coordination).
|
||||
*/
|
||||
markDisplayed(ts: number, text: string, partial: boolean): void {
|
||||
this.displayedMessages.set(ts, { ts, text, partial })
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all tracking state.
|
||||
* Call this when starting a new task.
|
||||
*/
|
||||
clear(): void {
|
||||
this.displayedMessages.clear()
|
||||
this.streamedContent.clear()
|
||||
this.currentlyStreamingTs = null
|
||||
this.loggedFirstPartial.clear()
|
||||
this.streamingState.next({ ts: null, isStreaming: false })
|
||||
}
|
||||
|
||||
/**
|
||||
* Get debugging info about first partial logging.
|
||||
*/
|
||||
hasLoggedFirstPartial(ts: number): boolean {
|
||||
return this.loggedFirstPartial.has(ts)
|
||||
}
|
||||
|
||||
/**
|
||||
* Record that we've logged the first partial for a ts.
|
||||
*/
|
||||
setLoggedFirstPartial(ts: number): void {
|
||||
this.loggedFirstPartial.add(ts)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the first partial record (when complete).
|
||||
*/
|
||||
clearLoggedFirstPartial(ts: number): void {
|
||||
this.loggedFirstPartial.delete(ts)
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Say Message Output
|
||||
// ===========================================================================
|
||||
|
||||
private outputSayMessage(
|
||||
ts: number,
|
||||
say: ClineSay,
|
||||
text: string,
|
||||
isPartial: boolean,
|
||||
alreadyDisplayedComplete: boolean | undefined,
|
||||
skipFirstUserMessage: boolean,
|
||||
): void {
|
||||
switch (say) {
|
||||
case "text":
|
||||
this.outputTextMessage(ts, text, isPartial, alreadyDisplayedComplete, skipFirstUserMessage)
|
||||
break
|
||||
|
||||
// case "thinking": - not a valid ClineSay type
|
||||
case "reasoning":
|
||||
this.outputReasoningMessage(ts, text, isPartial, alreadyDisplayedComplete)
|
||||
break
|
||||
|
||||
case "command_output":
|
||||
this.outputCommandOutput(ts, text, isPartial, alreadyDisplayedComplete)
|
||||
break
|
||||
|
||||
// Note: completion_result is an "ask" type, not a "say" type.
|
||||
// It is handled via the TaskCompleted event in extension-host.ts
|
||||
|
||||
case "error":
|
||||
if (!alreadyDisplayedComplete) {
|
||||
this.outputError("\n[error]", text || "Unknown error")
|
||||
this.displayedMessages.set(ts, { ts, text: text || "", partial: false })
|
||||
}
|
||||
break
|
||||
|
||||
case "api_req_started":
|
||||
// Silent - no output needed
|
||||
break
|
||||
|
||||
default:
|
||||
// NO-OP for unknown say types
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private outputTextMessage(
|
||||
ts: number,
|
||||
text: string,
|
||||
isPartial: boolean,
|
||||
alreadyDisplayedComplete: boolean | undefined,
|
||||
skipFirstUserMessage: boolean,
|
||||
): void {
|
||||
// Skip the initial user prompt echo (first message with no prior messages)
|
||||
if (skipFirstUserMessage && this.displayedMessages.size === 0 && !this.displayedMessages.has(ts)) {
|
||||
this.displayedMessages.set(ts, { ts, text, partial: !!isPartial })
|
||||
return
|
||||
}
|
||||
|
||||
if (isPartial && text) {
|
||||
// Stream partial content
|
||||
this.streamContent(ts, text, "[assistant]")
|
||||
this.displayedMessages.set(ts, { ts, text, partial: true })
|
||||
} else if (!isPartial && text && !alreadyDisplayedComplete) {
|
||||
// Message complete - ensure all content is output
|
||||
const streamed = this.streamedContent.get(ts)
|
||||
|
||||
if (streamed) {
|
||||
// We were streaming - output any remaining delta and finish
|
||||
if (text.length > streamed.text.length && text.startsWith(streamed.text)) {
|
||||
const delta = text.slice(streamed.text.length)
|
||||
this.writeRaw(delta)
|
||||
}
|
||||
this.finishStream(ts)
|
||||
} else {
|
||||
// Not streamed yet - output complete message
|
||||
this.output("\n[assistant]", text)
|
||||
}
|
||||
|
||||
this.displayedMessages.set(ts, { ts, text, partial: false })
|
||||
this.streamedContent.set(ts, { ts, text, headerShown: true })
|
||||
}
|
||||
}
|
||||
|
||||
private outputReasoningMessage(
|
||||
ts: number,
|
||||
text: string,
|
||||
isPartial: boolean,
|
||||
alreadyDisplayedComplete: boolean | undefined,
|
||||
): void {
|
||||
if (isPartial && text) {
|
||||
this.streamContent(ts, text, "[reasoning]")
|
||||
this.displayedMessages.set(ts, { ts, text, partial: true })
|
||||
} else if (!isPartial && text && !alreadyDisplayedComplete) {
|
||||
// Reasoning complete - finish the stream
|
||||
const streamed = this.streamedContent.get(ts)
|
||||
|
||||
if (streamed) {
|
||||
if (text.length > streamed.text.length && text.startsWith(streamed.text)) {
|
||||
const delta = text.slice(streamed.text.length)
|
||||
this.writeRaw(delta)
|
||||
}
|
||||
this.finishStream(ts)
|
||||
} else {
|
||||
this.output("\n[reasoning]", text)
|
||||
}
|
||||
|
||||
this.displayedMessages.set(ts, { ts, text, partial: false })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Output command_output (shared between say and ask types).
|
||||
*/
|
||||
outputCommandOutput(
|
||||
ts: number,
|
||||
text: string,
|
||||
isPartial: boolean,
|
||||
alreadyDisplayedComplete: boolean | undefined,
|
||||
): void {
|
||||
if (isPartial && text) {
|
||||
this.streamContent(ts, text, "[command output]")
|
||||
this.displayedMessages.set(ts, { ts, text, partial: true })
|
||||
} else if (!isPartial && text && !alreadyDisplayedComplete) {
|
||||
const streamed = this.streamedContent.get(ts)
|
||||
|
||||
if (streamed) {
|
||||
if (text.length > streamed.text.length && text.startsWith(streamed.text)) {
|
||||
const delta = text.slice(streamed.text.length)
|
||||
this.writeRaw(delta)
|
||||
}
|
||||
this.finishStream(ts)
|
||||
} else {
|
||||
this.writeRaw("\n[command output] ")
|
||||
this.writeRaw(text)
|
||||
this.writeRaw("\n")
|
||||
}
|
||||
|
||||
this.displayedMessages.set(ts, { ts, text, partial: false })
|
||||
this.streamedContent.set(ts, { ts, text, headerShown: true })
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Streaming Helpers
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Stream content with delta computation - only output new characters.
|
||||
*/
|
||||
streamContent(ts: number, text: string, header: string): void {
|
||||
const previous = this.streamedContent.get(ts)
|
||||
|
||||
if (!previous) {
|
||||
// First time seeing this message - output header and initial text
|
||||
this.writeRaw(`\n${header} `)
|
||||
this.writeRaw(text)
|
||||
this.streamedContent.set(ts, { ts, text, headerShown: true })
|
||||
this.currentlyStreamingTs = ts
|
||||
this.streamingState.next({ ts, isStreaming: true })
|
||||
} else if (text.length > previous.text.length && text.startsWith(previous.text)) {
|
||||
// Text has grown - output delta
|
||||
const delta = text.slice(previous.text.length)
|
||||
this.writeRaw(delta)
|
||||
this.streamedContent.set(ts, { ts, text, headerShown: true })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finish streaming a message (add newline).
|
||||
*/
|
||||
finishStream(ts: number): void {
|
||||
if (this.currentlyStreamingTs === ts) {
|
||||
this.writeRaw("\n")
|
||||
this.currentlyStreamingTs = null
|
||||
this.streamingState.next({ ts: null, isStreaming: false })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Output completion message (called from TaskCompleted handler).
|
||||
*/
|
||||
outputCompletionResult(ts: number, text: string): void {
|
||||
const previousDisplay = this.displayedMessages.get(ts)
|
||||
if (!previousDisplay || previousDisplay.partial) {
|
||||
this.output("\n[task complete]", text || "")
|
||||
this.displayedMessages.set(ts, { ts, text: text || "", partial: false })
|
||||
}
|
||||
}
|
||||
}
|
||||
297
apps/cli/src/extension-host/prompt-manager.ts
Normal file
297
apps/cli/src/extension-host/prompt-manager.ts
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
/**
|
||||
* PromptManager - Handles all user input collection
|
||||
*
|
||||
* This manager is responsible for:
|
||||
* - Collecting user input via readline
|
||||
* - Yes/No prompts with proper defaults
|
||||
* - Timed prompts that auto-select after timeout
|
||||
* - Raw mode input for character-by-character handling
|
||||
*
|
||||
* Design notes:
|
||||
* - Single responsibility: User input only (no output formatting)
|
||||
* - Returns Promises for all input operations
|
||||
* - Handles console mode switching (quiet mode restore)
|
||||
* - Can be disabled for programmatic (non-interactive) use
|
||||
*/
|
||||
|
||||
import readline from "readline"
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Configuration options for PromptManager.
|
||||
*/
|
||||
export interface PromptManagerOptions {
|
||||
/**
|
||||
* Called before prompting to restore console output.
|
||||
* Used to exit quiet mode temporarily.
|
||||
*/
|
||||
onBeforePrompt?: () => void
|
||||
|
||||
/**
|
||||
* Called after prompting to re-enable quiet mode.
|
||||
*/
|
||||
onAfterPrompt?: () => void
|
||||
|
||||
/**
|
||||
* Stream for input (default: process.stdin).
|
||||
*/
|
||||
stdin?: NodeJS.ReadStream
|
||||
|
||||
/**
|
||||
* Stream for prompt output (default: process.stdout).
|
||||
*/
|
||||
stdout?: NodeJS.WriteStream
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a timed prompt.
|
||||
*/
|
||||
export interface TimedPromptResult {
|
||||
/** The user's input, or default if timed out */
|
||||
value: string
|
||||
/** Whether the result came from timeout */
|
||||
timedOut: boolean
|
||||
/** Whether the user cancelled (Ctrl+C) */
|
||||
cancelled: boolean
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PromptManager Class
|
||||
// =============================================================================
|
||||
|
||||
export class PromptManager {
|
||||
private onBeforePrompt?: () => void
|
||||
private onAfterPrompt?: () => void
|
||||
private stdin: NodeJS.ReadStream
|
||||
private stdout: NodeJS.WriteStream
|
||||
|
||||
/**
|
||||
* Track if a prompt is currently active.
|
||||
*/
|
||||
private isPrompting = false
|
||||
|
||||
constructor(options: PromptManagerOptions = {}) {
|
||||
this.onBeforePrompt = options.onBeforePrompt
|
||||
this.onAfterPrompt = options.onAfterPrompt
|
||||
this.stdin = options.stdin ?? (process.stdin as NodeJS.ReadStream)
|
||||
this.stdout = options.stdout ?? process.stdout
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Public API
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Check if a prompt is currently active.
|
||||
*/
|
||||
isActive(): boolean {
|
||||
return this.isPrompting
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt for text input using readline.
|
||||
*
|
||||
* @param prompt - The prompt text to display
|
||||
* @returns The user's input
|
||||
* @throws If input is cancelled or an error occurs
|
||||
*/
|
||||
async promptForInput(prompt: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.beforePrompt()
|
||||
this.isPrompting = true
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: this.stdin,
|
||||
output: this.stdout,
|
||||
})
|
||||
|
||||
rl.question(prompt, (answer) => {
|
||||
rl.close()
|
||||
this.isPrompting = false
|
||||
this.afterPrompt()
|
||||
resolve(answer)
|
||||
})
|
||||
|
||||
rl.on("close", () => {
|
||||
this.isPrompting = false
|
||||
this.afterPrompt()
|
||||
})
|
||||
|
||||
rl.on("error", (err) => {
|
||||
rl.close()
|
||||
this.isPrompting = false
|
||||
this.afterPrompt()
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt for yes/no input.
|
||||
*
|
||||
* @param prompt - The prompt text to display
|
||||
* @param defaultValue - Default value if empty input (default: false)
|
||||
* @returns true for yes, false for no
|
||||
*/
|
||||
async promptForYesNo(prompt: string, defaultValue = false): Promise<boolean> {
|
||||
const answer = await this.promptForInput(prompt)
|
||||
const normalized = answer.trim().toLowerCase()
|
||||
if (normalized === "" && defaultValue !== undefined) {
|
||||
return defaultValue
|
||||
}
|
||||
return normalized === "y" || normalized === "yes"
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt for input with a timeout.
|
||||
* Uses raw mode for character-by-character input handling.
|
||||
*
|
||||
* @param prompt - The prompt text to display
|
||||
* @param timeoutMs - Timeout in milliseconds
|
||||
* @param defaultValue - Value to use if timed out
|
||||
* @returns TimedPromptResult with value, timedOut flag, and cancelled flag
|
||||
*/
|
||||
async promptWithTimeout(prompt: string, timeoutMs: number, defaultValue: string): Promise<TimedPromptResult> {
|
||||
return new Promise((resolve) => {
|
||||
this.beforePrompt()
|
||||
this.isPrompting = true
|
||||
|
||||
// Track the original raw mode state to restore it later
|
||||
const wasRaw = this.stdin.isRaw
|
||||
|
||||
// Enable raw mode for character-by-character input if TTY
|
||||
if (this.stdin.isTTY) {
|
||||
this.stdin.setRawMode(true)
|
||||
}
|
||||
|
||||
this.stdin.resume()
|
||||
|
||||
let inputBuffer = ""
|
||||
let timeoutCancelled = false
|
||||
let resolved = false
|
||||
|
||||
// Set up timeout
|
||||
const timeout = setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolved = true
|
||||
cleanup()
|
||||
this.stdout.write(`\n[Timeout - using default: ${defaultValue || "(empty)"}]\n`)
|
||||
resolve({ value: defaultValue, timedOut: true, cancelled: false })
|
||||
}
|
||||
}, timeoutMs)
|
||||
|
||||
// Display prompt
|
||||
this.stdout.write(prompt)
|
||||
|
||||
// Cleanup function to restore state
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout)
|
||||
this.stdin.removeListener("data", onData)
|
||||
|
||||
if (this.stdin.isTTY && wasRaw !== undefined) {
|
||||
this.stdin.setRawMode(wasRaw)
|
||||
}
|
||||
|
||||
this.stdin.pause()
|
||||
this.isPrompting = false
|
||||
this.afterPrompt()
|
||||
}
|
||||
|
||||
// Handle incoming data
|
||||
const onData = (data: Buffer) => {
|
||||
const char = data.toString()
|
||||
|
||||
// Handle Ctrl+C
|
||||
if (char === "\x03") {
|
||||
cleanup()
|
||||
resolved = true
|
||||
this.stdout.write("\n[cancelled]\n")
|
||||
resolve({ value: defaultValue, timedOut: false, cancelled: true })
|
||||
return
|
||||
}
|
||||
|
||||
// Cancel timeout on first input
|
||||
if (!timeoutCancelled) {
|
||||
timeoutCancelled = true
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
|
||||
// Handle Enter
|
||||
if (char === "\r" || char === "\n") {
|
||||
if (!resolved) {
|
||||
resolved = true
|
||||
cleanup()
|
||||
this.stdout.write("\n")
|
||||
resolve({ value: inputBuffer, timedOut: false, cancelled: false })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle Backspace
|
||||
if (char === "\x7f" || char === "\b") {
|
||||
if (inputBuffer.length > 0) {
|
||||
inputBuffer = inputBuffer.slice(0, -1)
|
||||
this.stdout.write("\b \b")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Normal character - add to buffer and echo
|
||||
inputBuffer += char
|
||||
this.stdout.write(char)
|
||||
}
|
||||
|
||||
this.stdin.on("data", onData)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt for yes/no with timeout.
|
||||
*
|
||||
* @param prompt - The prompt text to display
|
||||
* @param timeoutMs - Timeout in milliseconds
|
||||
* @param defaultValue - Default boolean value if timed out
|
||||
* @returns true for yes, false for no
|
||||
*/
|
||||
async promptForYesNoWithTimeout(prompt: string, timeoutMs: number, defaultValue: boolean): Promise<boolean> {
|
||||
const result = await this.promptWithTimeout(prompt, timeoutMs, defaultValue ? "y" : "n")
|
||||
const normalized = result.value.trim().toLowerCase()
|
||||
if (result.timedOut || result.cancelled || normalized === "") {
|
||||
return defaultValue
|
||||
}
|
||||
return normalized === "y" || normalized === "yes"
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a message on stdout (utility for prompting context).
|
||||
*/
|
||||
write(text: string): void {
|
||||
this.stdout.write(text)
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a message with newline.
|
||||
*/
|
||||
writeLine(text: string): void {
|
||||
this.stdout.write(text + "\n")
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Private Helpers
|
||||
// ===========================================================================
|
||||
|
||||
private beforePrompt(): void {
|
||||
if (this.onBeforePrompt) {
|
||||
this.onBeforePrompt()
|
||||
}
|
||||
}
|
||||
|
||||
private afterPrompt(): void {
|
||||
if (this.onAfterPrompt) {
|
||||
this.onAfterPrompt()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import path from "path"
|
||||
import fs from "fs"
|
||||
|
||||
import type { SupportedProvider } from "../types.js"
|
||||
import type { SupportedProvider } from "../types/types.js"
|
||||
|
||||
const envVarMap: Record<SupportedProvider, string> = {
|
||||
// Frontier Labs
|
||||
|
|
@ -33,6 +33,7 @@ export function getDefaultExtensionPath(dirname: string): string {
|
|||
// Check for environment variable first (set by install script)
|
||||
if (process.env.ROO_EXTENSION_PATH) {
|
||||
const envPath = process.env.ROO_EXTENSION_PATH
|
||||
|
||||
if (fs.existsSync(path.join(envPath, "extension.js"))) {
|
||||
return envPath
|
||||
}
|
||||
|
|
@ -1,35 +1,13 @@
|
|||
import fs from "fs"
|
||||
import { createRequire } from "module"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
import { Command } from "commander"
|
||||
import { createElement } from "react"
|
||||
|
||||
import { isProviderName } from "@roo-code/types"
|
||||
import { setLogger } from "@roo-code/vscode-shim"
|
||||
import { DEFAULT_FLAGS } from "./types/constants.js"
|
||||
|
||||
import { loadToken } from "./storage/credentials.js"
|
||||
|
||||
import { FlagOptions, isSupportedProvider, OnboardingProviderChoice, supportedProviders } from "./types.js"
|
||||
import { ASCII_ROO, DEFAULT_FLAG_OPTIONS, REASONING_EFFORTS, SDK_BASE_URL } from "./constants.js"
|
||||
import { ExtensionHost, ExtensionHostOptions } from "./extension-host.js"
|
||||
import { login, logout, status } from "./commands/index.js"
|
||||
import { getEnvVarName, getApiKeyFromEnv, getDefaultExtensionPath } from "./utils/extensionHostUtils.js"
|
||||
import { runOnboarding } from "./utils/onboarding.js"
|
||||
import { type User, createClient } from "./sdk/index.js"
|
||||
import { hasToken, loadSettings } from "./storage/index.js"
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const require = createRequire(import.meta.url)
|
||||
const packageJson = require("../package.json")
|
||||
import { run, login, logout, status } from "./commands/index.js"
|
||||
import { VERSION } from "./lib/utils/version.js"
|
||||
|
||||
const program = new Command()
|
||||
|
||||
program
|
||||
.name("roo")
|
||||
.description("Roo Code CLI - Run the Roo Code agent from the command line")
|
||||
.version(packageJson.version)
|
||||
program.name("roo").description("Roo Code CLI - Run the Roo Code agent from the command line").version(VERSION)
|
||||
|
||||
program
|
||||
.argument("[workspace]", "Workspace path to operate in", process.cwd())
|
||||
|
|
@ -39,12 +17,12 @@ program
|
|||
.option("-y, --yes", "Auto-approve all prompts (non-interactive mode)", false)
|
||||
.option("-k, --api-key <key>", "API key for the LLM provider (defaults to OPENROUTER_API_KEY env var)")
|
||||
.option("-p, --provider <provider>", "API provider (anthropic, openai, openrouter, etc.)", "openrouter")
|
||||
.option("-m, --model <model>", "Model to use", DEFAULT_FLAG_OPTIONS.model)
|
||||
.option("-M, --mode <mode>", "Mode to start in (code, architect, ask, debug, etc.)", DEFAULT_FLAG_OPTIONS.mode)
|
||||
.option("-m, --model <model>", "Model to use", DEFAULT_FLAGS.model)
|
||||
.option("-M, --mode <mode>", "Mode to start in (code, architect, ask, debug, etc.)", DEFAULT_FLAGS.mode)
|
||||
.option(
|
||||
"-r, --reasoning-effort <effort>",
|
||||
"Reasoning effort level (unspecified, disabled, none, minimal, low, medium, high, xhigh)",
|
||||
DEFAULT_FLAG_OPTIONS.reasoningEffort,
|
||||
DEFAULT_FLAGS.reasoningEffort,
|
||||
)
|
||||
.option("-x, --exit-on-complete", "Exit the process when the task completes (applies to TUI mode only)", false)
|
||||
.option(
|
||||
|
|
@ -54,195 +32,8 @@ program
|
|||
)
|
||||
.option("--ephemeral", "Run without persisting state (uses temporary storage)", false)
|
||||
.option("--no-tui", "Disable TUI, use plain text output")
|
||||
.action(async (workspaceArg: string, options: FlagOptions) => {
|
||||
setLogger({
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
debug: () => {},
|
||||
})
|
||||
.action(run)
|
||||
|
||||
const isTuiSupported = process.stdin.isTTY && process.stdout.isTTY
|
||||
const extensionPath = options.extension || getDefaultExtensionPath(__dirname)
|
||||
const workspacePath = path.resolve(workspaceArg)
|
||||
|
||||
if (!isSupportedProvider(options.provider)) {
|
||||
console.error(
|
||||
`[CLI] Error: Invalid provider: ${options.provider}; must be one of: ${supportedProviders.join(", ")}`,
|
||||
)
|
||||
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
let apiKey = options.apiKey || getApiKeyFromEnv(options.provider)
|
||||
let provider = options.provider
|
||||
let user: User | null = null
|
||||
let useCloudProvider = false
|
||||
|
||||
if (isTuiSupported) {
|
||||
let { onboardingProviderChoice } = await loadSettings()
|
||||
|
||||
if (!onboardingProviderChoice) {
|
||||
const result = await runOnboarding()
|
||||
onboardingProviderChoice = result.choice
|
||||
}
|
||||
|
||||
if (onboardingProviderChoice === OnboardingProviderChoice.Roo) {
|
||||
useCloudProvider = true
|
||||
const authenticated = await hasToken()
|
||||
|
||||
if (authenticated) {
|
||||
const token = await loadToken()
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
const client = createClient({ url: SDK_BASE_URL, authToken: token })
|
||||
const me = await client.auth.me.query()
|
||||
provider = "roo"
|
||||
apiKey = token
|
||||
user = me?.type === "user" ? me.user : null
|
||||
} catch {
|
||||
// Token may be expired or invalid - user will need to re-authenticate
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
if (useCloudProvider) {
|
||||
console.error("[CLI] Error: Authentication with Roo Code Cloud failed or was cancelled.")
|
||||
console.error("[CLI] Please run: roo auth login")
|
||||
console.error("[CLI] Or use --api-key to provide your own API key.")
|
||||
} else {
|
||||
console.error(
|
||||
`[CLI] Error: No API key provided. Use --api-key or set the appropriate environment variable.`,
|
||||
)
|
||||
console.error(`[CLI] For ${provider}, set ${getEnvVarName(provider)}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (!fs.existsSync(workspacePath)) {
|
||||
console.error(`[CLI] Error: Workspace path does not exist: ${workspacePath}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (!isProviderName(options.provider)) {
|
||||
console.error(`[CLI] Error: Invalid provider: ${options.provider}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (options.reasoningEffort && !REASONING_EFFORTS.includes(options.reasoningEffort)) {
|
||||
console.error(
|
||||
`[CLI] Error: Invalid reasoning effort: ${options.reasoningEffort}, must be one of: ${REASONING_EFFORTS.join(", ")}`,
|
||||
)
|
||||
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) {
|
||||
console.error("[CLI] Error: prompt is required in plain text mode")
|
||||
console.error("[CLI] Usage: roo [workspace] -P <prompt> [options]")
|
||||
console.error("[CLI] Use TUI mode (without --no-tui) for interactive input")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (useTui) {
|
||||
try {
|
||||
const { render } = await import("ink")
|
||||
const { App } = await import("./ui/App.js")
|
||||
|
||||
render(
|
||||
createElement(App, {
|
||||
initialPrompt: options.prompt || "",
|
||||
workspacePath: workspacePath,
|
||||
extensionPath: path.resolve(extensionPath),
|
||||
user,
|
||||
provider,
|
||||
apiKey,
|
||||
model: options.model || DEFAULT_FLAG_OPTIONS.model,
|
||||
mode: options.mode || DEFAULT_FLAG_OPTIONS.mode,
|
||||
nonInteractive: options.yes,
|
||||
debug: options.debug,
|
||||
exitOnComplete: options.exitOnComplete,
|
||||
reasoningEffort: options.reasoningEffort,
|
||||
ephemeral: options.ephemeral,
|
||||
version: packageJson.version,
|
||||
// Create extension host factory for dependency injection.
|
||||
createExtensionHost: (opts: ExtensionHostOptions) => new ExtensionHost(opts),
|
||||
}),
|
||||
// Handle Ctrl+C in App component for double-press exit.
|
||||
{ exitOnCtrlC: false },
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("[CLI] Failed to start TUI:", error instanceof Error ? error.message : String(error))
|
||||
|
||||
if (error instanceof Error) {
|
||||
console.error(error.stack)
|
||||
}
|
||||
|
||||
process.exit(1)
|
||||
}
|
||||
} else {
|
||||
console.log(ASCII_ROO)
|
||||
console.log()
|
||||
console.log(
|
||||
`[roo] Running ${options.model || "default"} (${options.reasoningEffort || "default"}) on ${provider} in ${options.mode || "default"} mode in ${workspacePath}`,
|
||||
)
|
||||
|
||||
const host = new ExtensionHost({
|
||||
mode: options.mode || DEFAULT_FLAG_OPTIONS.mode,
|
||||
reasoningEffort: options.reasoningEffort === "unspecified" ? undefined : options.reasoningEffort,
|
||||
user,
|
||||
provider,
|
||||
apiKey,
|
||||
model: options.model || DEFAULT_FLAG_OPTIONS.model,
|
||||
workspacePath,
|
||||
extensionPath: path.resolve(extensionPath),
|
||||
nonInteractive: options.yes,
|
||||
ephemeral: options.ephemeral,
|
||||
})
|
||||
|
||||
process.on("SIGINT", async () => {
|
||||
console.log("\n[CLI] Received SIGINT, shutting down...")
|
||||
await host.dispose()
|
||||
process.exit(130)
|
||||
})
|
||||
|
||||
process.on("SIGTERM", async () => {
|
||||
console.log("\n[CLI] Received SIGTERM, shutting down...")
|
||||
await host.dispose()
|
||||
process.exit(143)
|
||||
})
|
||||
|
||||
try {
|
||||
await host.activate()
|
||||
await host.runTask(options.prompt!)
|
||||
await host.dispose()
|
||||
|
||||
if (!options.waitOnComplete) {
|
||||
process.exit(0)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[CLI] Error:", error instanceof Error ? error.message : String(error))
|
||||
|
||||
if (error instanceof Error) {
|
||||
console.error(error.stack)
|
||||
}
|
||||
|
||||
await host.dispose()
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Auth command group
|
||||
const authCommand = program.command("auth").description("Manage authentication for Roo Code Cloud")
|
||||
|
||||
authCommand
|
||||
|
|
|
|||
1
apps/cli/src/lib/auth/index.ts
Normal file
1
apps/cli/src/lib/auth/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./token.js"
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import * as fs from "fs/promises"
|
||||
import * as path from "path"
|
||||
|
||||
import { getHistoryFilePath, loadHistory, saveHistory, addToHistory, MAX_HISTORY_ENTRIES } from "../historyStorage.js"
|
||||
import { getHistoryFilePath, loadHistory, saveHistory, addToHistory, MAX_HISTORY_ENTRIES } from "../history.js"
|
||||
|
||||
vi.mock("fs/promises")
|
||||
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import * as fs from "fs/promises"
|
||||
import * as path from "path"
|
||||
|
||||
import { ensureConfigDir, getConfigDir } from "../storage/index.js"
|
||||
import { ensureConfigDir, getConfigDir } from "./config-dir.js"
|
||||
|
||||
/** Maximum number of history entries to keep */
|
||||
export const MAX_HISTORY_ENTRIES = 500
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
|
||||
import type { CliSettings } from "../types.js"
|
||||
import type { CliSettings } from "../../types/types.js"
|
||||
|
||||
import { getConfigDir } from "./index.js"
|
||||
|
||||
export function getSettingsPath(): string {
|
||||
|
|
@ -4,7 +4,7 @@ import {
|
|||
GLOBAL_COMMANDS,
|
||||
getGlobalCommand,
|
||||
getGlobalCommandsForAutocomplete,
|
||||
} from "../globalCommands.js"
|
||||
} from "../commands.js"
|
||||
|
||||
describe("globalCommands", () => {
|
||||
describe("GLOBAL_COMMANDS", () => {
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import type { Key } from "ink"
|
||||
|
||||
import { GLOBAL_INPUT_SEQUENCES, isGlobalInputSequence, matchesGlobalSequence } from "../globalInputSequences.js"
|
||||
import { GLOBAL_INPUT_SEQUENCES, isGlobalInputSequence, matchesGlobalSequence } from "../input.js"
|
||||
|
||||
function createKey(overrides: Partial<Key> = {}): Key {
|
||||
return {
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { normalizePath, arePathsEqual } from "./pathUtils.js"
|
||||
import { normalizePath, arePathsEqual } from "../path.js"
|
||||
|
||||
describe("normalizePath", () => {
|
||||
it("should remove trailing slashes", () => {
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
import { createElement } from "react"
|
||||
|
||||
import { type OnboardingResult, OnboardingProviderChoice } from "../types.js"
|
||||
import { type OnboardingResult, OnboardingProviderChoice } from "../../types/types.js"
|
||||
import { login } from "../../commands/index.js"
|
||||
import { saveSettings } from "../storage/settings.js"
|
||||
import { login } from "../commands/auth/login.js"
|
||||
|
||||
export async function runOnboarding(): Promise<OnboardingResult> {
|
||||
const { render } = await import("ink")
|
||||
const { OnboardingScreen } = await import("../components/onboarding/index.js")
|
||||
const { OnboardingScreen } = await import("../../ui/components/onboarding/index.js")
|
||||
|
||||
return new Promise<OnboardingResult>((resolve) => {
|
||||
const onSelect = async (choice: OnboardingProviderChoice) => {
|
||||
6
apps/cli/src/lib/utils/version.ts
Normal file
6
apps/cli/src/lib/utils/version.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { createRequire } from "module"
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const packageJson = require("../package.json")
|
||||
|
||||
export const VERSION = packageJson.version
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { reasoningEffortsExtended } from "@roo-code/types"
|
||||
|
||||
export const DEFAULT_FLAG_OPTIONS = {
|
||||
export const DEFAULT_FLAGS = {
|
||||
mode: "code",
|
||||
reasoningEffort: "medium" as const,
|
||||
model: "anthropic/claude-opus-4.5",
|
||||
|
|
@ -21,7 +21,6 @@ export const ASCII_ROO = ` _,' ___
|
|||
// \\\\
|
||||
,/' \`\\_,`
|
||||
|
||||
export const AUTH_BASE_URL = process.env.NODE_ENV === "production" ? "https://app.roocode.com" : "http://localhost:3000"
|
||||
export const AUTH_BASE_URL = process.env.ROO_AUTH_BASE_URL ?? "https://app.roocode.com"
|
||||
|
||||
export const SDK_BASE_URL =
|
||||
process.env.NODE_ENV === "production" ? "https://cloud-api.roocode.com" : "http://localhost:3001"
|
||||
export const SDK_BASE_URL = process.env.ROO_SDK_BASE_URL ?? "https://cloud-api.roocode.com"
|
||||
2
apps/cli/src/types/index.ts
Normal file
2
apps/cli/src/types/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./types.js"
|
||||
export * from "./constants.js"
|
||||
|
|
@ -3,9 +3,9 @@ import { Select } from "@inkjs/ui"
|
|||
import { useState, useEffect, useCallback, useRef, useMemo } from "react"
|
||||
import type { WebviewMessage } from "@roo-code/types"
|
||||
|
||||
import { getGlobalCommandsForAutocomplete } from "../utils/globalCommands.js"
|
||||
import { arePathsEqual } from "../utils/pathUtils.js"
|
||||
import { getContextWindow } from "../utils/getContextWindow.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"
|
||||
|
|
@ -54,7 +54,7 @@ import {
|
|||
} from "./components/autocomplete/index.js"
|
||||
import { ScrollArea, useScrollToBottom } from "./components/ScrollArea.js"
|
||||
import ScrollIndicator from "./components/ScrollIndicator.js"
|
||||
import { ExtensionHostOptions } from "../extension-host.js"
|
||||
import { ExtensionHostOptions } from "../extension-host/extension-host.js"
|
||||
|
||||
const PICKER_HEIGHT = 10
|
||||
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import { Text, Box } from "ink"
|
|||
|
||||
import type { TokenUsage } from "@roo-code/types"
|
||||
|
||||
import { ASCII_ROO } from "../../constants.js"
|
||||
import { User } from "../../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"
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
import { useState, useEffect, useMemo, useCallback, useRef } from "react"
|
||||
import { Box, Text, useInput, type Key } from "ink"
|
||||
|
||||
import { isGlobalInputSequence } from "../../utils/globalInputSequences.js"
|
||||
import { isGlobalInputSequence } from "../../lib/utils/input.js"
|
||||
|
||||
export interface MultilineTextInputProps {
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { Box, Text } from "ink"
|
|||
import fuzzysort from "fuzzysort"
|
||||
|
||||
import type { AutocompleteTrigger, AutocompleteItem, TriggerDetectionResult } from "../types.js"
|
||||
import { GlobalCommandAction } from "../../../../utils/globalCommands.js"
|
||||
import { GlobalCommandAction } from "../../../../lib/utils/commands.js"
|
||||
|
||||
export interface SlashCommandResult extends AutocompleteItem {
|
||||
name: string
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { Box, Text } from "ink"
|
||||
import { Select } from "@inkjs/ui"
|
||||
|
||||
import { OnboardingProviderChoice } from "../../types.js"
|
||||
import { ASCII_ROO } from "../../constants.js"
|
||||
import { OnboardingProviderChoice } from "../../../types/types.js"
|
||||
import { ASCII_ROO } from "../../../types/constants.js"
|
||||
|
||||
export interface OnboardingScreenProps {
|
||||
onSelect: (choice: OnboardingProviderChoice) => void
|
||||
|
|
@ -3,10 +3,9 @@ import { useApp } from "ink"
|
|||
import { randomUUID } from "crypto"
|
||||
import type { ExtensionMessage, WebviewMessage } from "@roo-code/types"
|
||||
|
||||
import { toolInspectorLog, clearToolInspectorLog } from "../../utils/toolInspectorLogger.js"
|
||||
import { useCLIStore } from "../store.js"
|
||||
|
||||
import { ExtensionHostOptions } from "../../extension-host.js"
|
||||
import { ExtensionHostOptions } from "../../extension-host/extension-host.js"
|
||||
|
||||
interface ExtensionHostInterface {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
|
@ -72,14 +71,6 @@ export function useExtensionHost({
|
|||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
clearToolInspectorLog()
|
||||
|
||||
toolInspectorLog("session:start", {
|
||||
timestamp: new Date().toISOString(),
|
||||
mode,
|
||||
nonInteractive,
|
||||
})
|
||||
|
||||
try {
|
||||
const host = createExtensionHost({
|
||||
mode,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useEffect, useRef } from "react"
|
||||
import { FOLLOWUP_TIMEOUT_SECONDS } from "../../constants.js"
|
||||
import { FOLLOWUP_TIMEOUT_SECONDS } from "../../types/constants.js"
|
||||
import { useUIStateStore } from "../stores/uiStateStore.js"
|
||||
import type { PendingAsk } from "../types.js"
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ import { useEffect, useRef } from "react"
|
|||
import { useInput } from "ink"
|
||||
import type { WebviewMessage } from "@roo-code/types"
|
||||
|
||||
import { matchesGlobalSequence } from "../../utils/globalInputSequences.js"
|
||||
import { matchesGlobalSequence } from "../../lib/utils/input.js"
|
||||
|
||||
import type { ModeResult } from "../components/autocomplete/index.js"
|
||||
import { useUIStateStore } from "../stores/uiStateStore.js"
|
||||
import { useCLIStore } from "../store.js"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState, useEffect, useCallback, useRef } from "react"
|
||||
|
||||
import { loadHistory, addToHistory } from "../../utils/historyStorage.js"
|
||||
import { loadHistory, addToHistory } from "../../lib/storage/history.js"
|
||||
|
||||
export interface UseInputHistoryOptions {
|
||||
isActive?: boolean
|
||||
|
|
|
|||
|
|
@ -2,16 +2,10 @@ import { useCallback, useRef } from "react"
|
|||
import type { ExtensionMessage, ClineMessage, ClineAsk, ClineSay, TodoItem } from "@roo-code/types"
|
||||
import { consolidateTokenUsage, consolidateApiRequests, consolidateCommands } from "@roo-code/core/message-utils"
|
||||
|
||||
import { toolInspectorLog } from "../../utils/toolInspectorLogger.js"
|
||||
import type { TUIMessage, ToolData } from "../types.js"
|
||||
import type { FileResult, SlashCommandResult, ModeResult } from "../components/autocomplete/index.js"
|
||||
import { useCLIStore } from "../store.js"
|
||||
import {
|
||||
extractToolData,
|
||||
formatToolOutput,
|
||||
formatToolAskMessage,
|
||||
parseTodosFromToolInfo,
|
||||
} from "../utils/toolDataUtils.js"
|
||||
import { extractToolData, formatToolOutput, formatToolAskMessage, parseTodosFromToolInfo } from "../utils/tools.js"
|
||||
|
||||
export interface UseMessageHandlersOptions {
|
||||
nonInteractive: boolean
|
||||
|
|
@ -104,7 +98,6 @@ export function useMessageHandlers({ nonInteractive }: UseMessageHandlersOptions
|
|||
toolDisplayName = "bash"
|
||||
toolDisplayOutput = text
|
||||
const trackedCommand = pendingCommandRef.current
|
||||
toolInspectorLog("say:command_output", { ts, trackedCommand, outputLength: text?.length })
|
||||
toolData = { tool: "execute_command", command: trackedCommand || undefined, output: text }
|
||||
pendingCommandRef.current = null
|
||||
} else if (say === "reasoning") {
|
||||
|
|
@ -209,7 +202,6 @@ export function useMessageHandlers({ nonInteractive }: UseMessageHandlersOptions
|
|||
// Track pending command BEFORE nonInteractive handling
|
||||
// This ensures we capture the command text for later injection into command_output toolData
|
||||
if (ask === "command") {
|
||||
toolInspectorLog("ask:command:tracking", { ts, text })
|
||||
pendingCommandRef.current = text
|
||||
}
|
||||
|
||||
|
|
@ -227,15 +219,6 @@ export function useMessageHandlers({ nonInteractive }: UseMessageHandlersOptions
|
|||
|
||||
try {
|
||||
const toolInfo = JSON.parse(text) as Record<string, unknown>
|
||||
|
||||
// Log tool payload for inspection (nonInteractive ask)
|
||||
toolInspectorLog("ask:tool:nonInteractive", {
|
||||
ts,
|
||||
rawText: text,
|
||||
parsedToolInfo: toolInfo,
|
||||
partial,
|
||||
})
|
||||
|
||||
toolName = toolInfo.tool as string
|
||||
toolDisplayName = toolInfo.tool as string
|
||||
toolDisplayOutput = formatToolOutput(toolInfo)
|
||||
|
|
@ -294,15 +277,6 @@ export function useMessageHandlers({ nonInteractive }: UseMessageHandlersOptions
|
|||
} else if (ask === "tool") {
|
||||
try {
|
||||
const toolInfo = JSON.parse(text) as Record<string, unknown>
|
||||
|
||||
// Log tool payload for inspection (interactive ask)
|
||||
toolInspectorLog("ask:tool:interactive", {
|
||||
ts,
|
||||
rawText: text,
|
||||
parsedToolInfo: toolInfo,
|
||||
partial,
|
||||
})
|
||||
|
||||
questionText = formatToolAskMessage(toolInfo)
|
||||
} catch {
|
||||
// Use raw text if not valid JSON
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ import { useCallback } from "react"
|
|||
import { randomUUID } from "crypto"
|
||||
import type { WebviewMessage } from "@roo-code/types"
|
||||
|
||||
import { getGlobalCommand } from "../../utils/globalCommands.js"
|
||||
import { getGlobalCommand } from "../../lib/utils/commands.js"
|
||||
|
||||
import { useCLIStore } from "../store.js"
|
||||
import { useUIStateStore } from "../stores/uiStateStore.js"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,2 @@
|
|||
export {
|
||||
extractToolData,
|
||||
formatToolOutput,
|
||||
formatToolAskMessage,
|
||||
parseTodosFromToolInfo,
|
||||
parseMarkdownChecklist,
|
||||
} from "./toolDataUtils.js"
|
||||
|
||||
export { getView } from "./viewUtils.js"
|
||||
export * from "./tools.js"
|
||||
export * from "./views.js"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { TodoItem } from "@roo-code/types"
|
||||
|
||||
import type { ToolData } from "../types.js"
|
||||
|
||||
/**
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
/**
|
||||
* Tool Inspector Logger
|
||||
*
|
||||
* A dedicated logger for inspecting tool use payloads in the CLI.
|
||||
* This writes to ~/.roo/cli-tool-inspector.log, separate from the general
|
||||
* debug log to avoid noise when specifically investigating tool shapes.
|
||||
*
|
||||
* Usage:
|
||||
* import { toolInspectorLog } from "../utils/toolInspectorLogger.js"
|
||||
*
|
||||
* toolInspectorLog("tool:received", { toolName, payload })
|
||||
*/
|
||||
|
||||
import * as fs from "fs"
|
||||
import * as path from "path"
|
||||
import * as os from "os"
|
||||
|
||||
const TOOL_INSPECTOR_LOG_PATH = path.join(os.homedir(), ".roo", "cli-tool-inspector.log")
|
||||
|
||||
/**
|
||||
* Log a tool inspection entry to the dedicated log file.
|
||||
* Writes timestamped JSON entries to ~/.roo/cli-tool-inspector.log
|
||||
*/
|
||||
export function toolInspectorLog(event: string, data?: unknown): void {
|
||||
try {
|
||||
const logDir = path.dirname(TOOL_INSPECTOR_LOG_PATH)
|
||||
|
||||
if (!fs.existsSync(logDir)) {
|
||||
fs.mkdirSync(logDir, { recursive: true })
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString()
|
||||
|
||||
const entry = {
|
||||
timestamp,
|
||||
event,
|
||||
...(data !== undefined && { data }),
|
||||
}
|
||||
|
||||
// Write as formatted JSON for easier inspection
|
||||
fs.appendFileSync(TOOL_INSPECTOR_LOG_PATH, JSON.stringify(entry, null, 2) + "\n---\n")
|
||||
} catch {
|
||||
// NO-OP - don't let logging errors break functionality
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the tool inspector log file.
|
||||
* Useful for starting a fresh inspection session.
|
||||
*/
|
||||
export function clearToolInspectorLog(): void {
|
||||
try {
|
||||
if (fs.existsSync(TOOL_INSPECTOR_LOG_PATH)) {
|
||||
fs.unlinkSync(TOOL_INSPECTOR_LOG_PATH)
|
||||
}
|
||||
} catch {
|
||||
// NO-OP
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to the tool inspector log file.
|
||||
*/
|
||||
export function getToolInspectorLogPath(): string {
|
||||
return TOOL_INSPECTOR_LOG_PATH
|
||||
}
|
||||
456
docs/AGENT_LOOP_STATE_DETECTION.md
Normal file
456
docs/AGENT_LOOP_STATE_DETECTION.md
Normal file
|
|
@ -0,0 +1,456 @@
|
|||
# Agent Loop State Detection in the Roo Code Webview Client
|
||||
|
||||
This document explains how the webview client detects when the agent loop has stopped and is waiting on the client to resume. This is essential knowledge for implementing an alternative client.
|
||||
|
||||
## Overview
|
||||
|
||||
The Roo Code extension uses a message-based architecture where the extension host (server) communicates with the webview client through typed messages. The agent loop state is determined by analyzing the `clineMessages` array in the extension state, specifically looking at the **last message's type and properties**.
|
||||
|
||||
## Architecture Diagram
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Extension Host (Server) │
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌──────────────────────────────────────────────┐ │
|
||||
│ │ Task.ts │────────▶│ RooCodeEventName events │ │
|
||||
│ └─────────────┘ │ • TaskActive • TaskInteractive │ │
|
||||
│ │ • TaskIdle • TaskResumable │ │
|
||||
│ └──────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ postMessage("state")
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Webview Client │
|
||||
│ │
|
||||
│ ┌──────────────────────┐ ┌─────────────────────┐ │
|
||||
│ │ ExtensionStateContext│─────▶│ ChatView.tsx │ │
|
||||
│ │ clineMessages[] │ │ │ │
|
||||
│ └──────────────────────┘ │ ┌───────────────┐ │ │
|
||||
│ │ │lastMessage │ │ │
|
||||
│ │ │ .type │ │ │
|
||||
│ │ │ .ask / .say │ │ │
|
||||
│ │ │ .partial │ │ │
|
||||
│ │ └───────┬───────┘ │ │
|
||||
│ │ │ │ │
|
||||
│ │ ▼ │ │
|
||||
│ │ ┌───────────────┐ │ │
|
||||
│ │ │ State Detection│ │ │
|
||||
│ │ │ Logic │ │ │
|
||||
│ │ └───────┬───────┘ │ │
|
||||
│ │ │ │ │
|
||||
│ │ ▼ │ │
|
||||
│ │ ┌───────────────┐ │ │
|
||||
│ │ │ UI State │ │ │
|
||||
│ │ │ • clineAsk │ │ │
|
||||
│ │ │ • buttons │ │ │
|
||||
│ │ └───────────────┘ │ │
|
||||
│ └─────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Key Message Types
|
||||
|
||||
### ClineMessage Structure
|
||||
|
||||
Defined in [`packages/types/src/message.ts`](../packages/types/src/message.ts):
|
||||
|
||||
```typescript
|
||||
interface ClineMessage {
|
||||
ts: number // Timestamp identifier
|
||||
type: "ask" | "say" // Message category
|
||||
ask?: ClineAsk // Ask type (when type="ask")
|
||||
say?: ClineSay // Say type (when type="say")
|
||||
text?: string // Message content
|
||||
partial?: boolean // Is streaming incomplete?
|
||||
// ... other fields
|
||||
}
|
||||
```
|
||||
|
||||
## Ask Type Categories
|
||||
|
||||
The `ClineAsk` types are categorized into four groups that determine when the agent is waiting. These are defined in [`packages/types/src/message.ts`](../packages/types/src/message.ts):
|
||||
|
||||
### 1. Idle Asks - Task effectively finished
|
||||
|
||||
These indicate the agent loop has stopped and the task is in a terminal or error state.
|
||||
|
||||
```typescript
|
||||
const idleAsks = [
|
||||
"completion_result", // Task completed successfully
|
||||
"api_req_failed", // API request failed
|
||||
"resume_completed_task", // Resume a completed task
|
||||
"mistake_limit_reached", // Too many errors encountered
|
||||
"auto_approval_max_req_reached", // Auto-approval limit hit
|
||||
] as const
|
||||
```
|
||||
|
||||
**Helper function:** `isIdleAsk(ask: ClineAsk): boolean`
|
||||
|
||||
### 2. Interactive Asks - Approval needed
|
||||
|
||||
These indicate the agent is waiting for user approval or input to proceed.
|
||||
|
||||
```typescript
|
||||
const interactiveAsks = [
|
||||
"followup", // Follow-up question asked
|
||||
"command", // Permission to execute command
|
||||
"tool", // Permission for file operations
|
||||
"browser_action_launch", // Permission to use browser
|
||||
"use_mcp_server", // Permission for MCP server
|
||||
] as const
|
||||
```
|
||||
|
||||
**Helper function:** `isInteractiveAsk(ask: ClineAsk): boolean`
|
||||
|
||||
### 3. Resumable Asks - Task paused
|
||||
|
||||
These indicate the task is paused and can be resumed.
|
||||
|
||||
```typescript
|
||||
const resumableAsks = ["resume_task"] as const
|
||||
```
|
||||
|
||||
**Helper function:** `isResumableAsk(ask: ClineAsk): boolean`
|
||||
|
||||
### 4. Non-Blocking Asks - No actual approval needed
|
||||
|
||||
These are informational and don't block the agent loop.
|
||||
|
||||
```typescript
|
||||
const nonBlockingAsks = ["command_output"] as const
|
||||
```
|
||||
|
||||
**Helper function:** `isNonBlockingAsk(ask: ClineAsk): boolean`
|
||||
|
||||
## Client-Side State Detection
|
||||
|
||||
### ChatView State Management
|
||||
|
||||
The [`ChatView`](../webview-ui/src/components/chat/ChatView.tsx) component maintains several state variables:
|
||||
|
||||
```typescript
|
||||
const [clineAsk, setClineAsk] = useState<ClineAsk | undefined>(undefined)
|
||||
const [enableButtons, setEnableButtons] = useState<boolean>(false)
|
||||
const [primaryButtonText, setPrimaryButtonText] = useState<string | undefined>(undefined)
|
||||
const [secondaryButtonText, setSecondaryButtonText] = useState<string | undefined>(undefined)
|
||||
const [sendingDisabled, setSendingDisabled] = useState(false)
|
||||
```
|
||||
|
||||
### Detection Logic
|
||||
|
||||
The state is determined by a `useDeepCompareEffect` that watches `lastMessage` and `secondLastMessage`:
|
||||
|
||||
```typescript
|
||||
useDeepCompareEffect(() => {
|
||||
if (lastMessage) {
|
||||
switch (lastMessage.type) {
|
||||
case "ask":
|
||||
const isPartial = lastMessage.partial === true
|
||||
switch (lastMessage.ask) {
|
||||
case "api_req_failed":
|
||||
// Agent loop stopped - API failed, needs retry or new task
|
||||
setSendingDisabled(true)
|
||||
setClineAsk("api_req_failed")
|
||||
setEnableButtons(true)
|
||||
break
|
||||
|
||||
case "mistake_limit_reached":
|
||||
// Agent loop stopped - too many errors
|
||||
setSendingDisabled(false)
|
||||
setClineAsk("mistake_limit_reached")
|
||||
setEnableButtons(true)
|
||||
break
|
||||
|
||||
case "followup":
|
||||
// Agent loop stopped - waiting for user answer
|
||||
setSendingDisabled(isPartial)
|
||||
setClineAsk("followup")
|
||||
setEnableButtons(true)
|
||||
break
|
||||
|
||||
case "tool":
|
||||
case "command":
|
||||
case "browser_action_launch":
|
||||
case "use_mcp_server":
|
||||
// Agent loop stopped - waiting for approval
|
||||
setSendingDisabled(isPartial)
|
||||
setClineAsk(lastMessage.ask)
|
||||
setEnableButtons(!isPartial)
|
||||
break
|
||||
|
||||
case "completion_result":
|
||||
// Agent loop stopped - task complete
|
||||
setSendingDisabled(isPartial)
|
||||
setClineAsk("completion_result")
|
||||
setEnableButtons(!isPartial)
|
||||
break
|
||||
|
||||
case "resume_task":
|
||||
case "resume_completed_task":
|
||||
// Agent loop stopped - task paused/completed
|
||||
setSendingDisabled(false)
|
||||
setClineAsk(lastMessage.ask)
|
||||
setEnableButtons(true)
|
||||
break
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}, [lastMessage, secondLastMessage])
|
||||
```
|
||||
|
||||
### Streaming Detection
|
||||
|
||||
To determine if the agent is still streaming a response:
|
||||
|
||||
```typescript
|
||||
const isStreaming = useMemo(() => {
|
||||
// Check if current ask has buttons visible
|
||||
const isLastAsk = !!modifiedMessages.at(-1)?.ask
|
||||
const isToolCurrentlyAsking =
|
||||
isLastAsk && clineAsk !== undefined && enableButtons && primaryButtonText !== undefined
|
||||
|
||||
if (isToolCurrentlyAsking) return false
|
||||
|
||||
// Check if message is partial (still streaming)
|
||||
const isLastMessagePartial = modifiedMessages.at(-1)?.partial === true
|
||||
if (isLastMessagePartial) return true
|
||||
|
||||
// Check if last API request finished (has cost)
|
||||
const lastApiReqStarted = findLast(modifiedMessages, (m) => m.say === "api_req_started")
|
||||
if (lastApiReqStarted?.text) {
|
||||
const cost = JSON.parse(lastApiReqStarted.text).cost
|
||||
if (cost === undefined) return true // Still streaming
|
||||
}
|
||||
|
||||
return false
|
||||
}, [modifiedMessages, clineAsk, enableButtons, primaryButtonText])
|
||||
```
|
||||
|
||||
## Implementing State Detection in an Alternative Client
|
||||
|
||||
### Step 1: Subscribe to State Updates
|
||||
|
||||
```typescript
|
||||
// Listen for state messages from extension
|
||||
window.addEventListener("message", (event) => {
|
||||
const message = event.data
|
||||
if (message.type === "state") {
|
||||
const clineMessages = message.state.clineMessages
|
||||
detectAgentState(clineMessages)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Step 2: Detect Agent State
|
||||
|
||||
```typescript
|
||||
type AgentLoopState =
|
||||
| "running" // Agent is actively processing
|
||||
| "streaming" // Agent is streaming a response
|
||||
| "interactive" // Waiting for tool/command approval
|
||||
| "followup" // Waiting for user to answer a question
|
||||
| "idle" // Task completed or errored out
|
||||
| "resumable" // Task paused, can be resumed
|
||||
|
||||
function detectAgentState(messages: ClineMessage[]): AgentLoopState {
|
||||
const lastMessage = messages.at(-1)
|
||||
if (!lastMessage) return "running"
|
||||
|
||||
// Check if still streaming
|
||||
if (lastMessage.partial === true) {
|
||||
return "streaming"
|
||||
}
|
||||
|
||||
// Check if it's an ask message
|
||||
if (lastMessage.type === "ask" && lastMessage.ask) {
|
||||
const ask = lastMessage.ask
|
||||
|
||||
// Idle states - task effectively stopped
|
||||
if (
|
||||
[
|
||||
"completion_result",
|
||||
"api_req_failed",
|
||||
"resume_completed_task",
|
||||
"mistake_limit_reached",
|
||||
"auto_approval_max_req_reached",
|
||||
].includes(ask)
|
||||
) {
|
||||
return "idle"
|
||||
}
|
||||
|
||||
// Resumable state
|
||||
if (ask === "resume_task") {
|
||||
return "resumable"
|
||||
}
|
||||
|
||||
// Follow-up question
|
||||
if (ask === "followup") {
|
||||
return "followup"
|
||||
}
|
||||
|
||||
// Interactive approval needed
|
||||
if (["command", "tool", "browser_action_launch", "use_mcp_server"].includes(ask)) {
|
||||
return "interactive"
|
||||
}
|
||||
|
||||
// Non-blocking (command_output)
|
||||
if (ask === "command_output") {
|
||||
return "running" // Can proceed or interrupt
|
||||
}
|
||||
}
|
||||
|
||||
// Check for API request in progress
|
||||
const lastApiReq = messages.findLast((m) => m.say === "api_req_started")
|
||||
if (lastApiReq?.text) {
|
||||
try {
|
||||
const data = JSON.parse(lastApiReq.text)
|
||||
if (data.cost === undefined) {
|
||||
return "streaming"
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return "running"
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Respond to Agent State
|
||||
|
||||
```typescript
|
||||
// Send response back to extension
|
||||
function respondToAsk(response: ClineAskResponse, text?: string, images?: string[]) {
|
||||
vscode.postMessage({
|
||||
type: "askResponse",
|
||||
askResponse: response, // "yesButtonClicked" | "noButtonClicked" | "messageResponse"
|
||||
text,
|
||||
images,
|
||||
})
|
||||
}
|
||||
|
||||
// Start a new task
|
||||
function startNewTask(text: string, images?: string[]) {
|
||||
vscode.postMessage({
|
||||
type: "newTask",
|
||||
text,
|
||||
images,
|
||||
})
|
||||
}
|
||||
|
||||
// Clear current task
|
||||
function clearTask() {
|
||||
vscode.postMessage({ type: "clearTask" })
|
||||
}
|
||||
|
||||
// Cancel streaming task
|
||||
function cancelTask() {
|
||||
vscode.postMessage({ type: "cancelTask" })
|
||||
}
|
||||
|
||||
// Terminal operations for command_output
|
||||
function terminalOperation(operation: "continue" | "abort") {
|
||||
vscode.postMessage({ type: "terminalOperation", terminalOperation: operation })
|
||||
}
|
||||
```
|
||||
|
||||
## Response Actions by State
|
||||
|
||||
| State | Primary Action | Secondary Action |
|
||||
| ----------------------- | ---------------------------- | -------------------------- |
|
||||
| `api_req_failed` | Retry (`yesButtonClicked`) | New Task (`clearTask`) |
|
||||
| `mistake_limit_reached` | Proceed (`yesButtonClicked`) | New Task (`clearTask`) |
|
||||
| `followup` | Answer (`messageResponse`) | - |
|
||||
| `tool` | Approve (`yesButtonClicked`) | Reject (`noButtonClicked`) |
|
||||
| `command` | Run (`yesButtonClicked`) | Reject (`noButtonClicked`) |
|
||||
| `browser_action_launch` | Approve (`yesButtonClicked`) | Reject (`noButtonClicked`) |
|
||||
| `use_mcp_server` | Approve (`yesButtonClicked`) | Reject (`noButtonClicked`) |
|
||||
| `completion_result` | New Task (`clearTask`) | - |
|
||||
| `resume_task` | Resume (`yesButtonClicked`) | Terminate (`clearTask`) |
|
||||
| `resume_completed_task` | New Task (`clearTask`) | - |
|
||||
| `command_output` | Proceed (`continue`) | Kill (`abort`) |
|
||||
|
||||
## Extension-Side Event Emission
|
||||
|
||||
The extension emits task state events from [`src/core/task/Task.ts`](../src/core/task/Task.ts):
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ Task Started │
|
||||
└────────┬────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
┌────▶│ TaskActive │◀────┐
|
||||
│ └────────┬────────┘ │
|
||||
│ │ │
|
||||
│ ┌─────────┼─────────┐ │
|
||||
│ │ │ │ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ ┌───┐ ┌───────┐ ┌─────┐ │
|
||||
│ │Idle│ │Interact│ │Resume│ │
|
||||
│ │Ask │ │iveAsk │ │ableAsk│ │
|
||||
│ └─┬──┘ └───┬───┘ └──┬──┘ │
|
||||
│ │ │ │ │
|
||||
│ ▼ │ │ │
|
||||
│ ┌──────┐ │ │ │
|
||||
│ │TaskIdle│ │ │ │
|
||||
│ └──────┘ │ │ │
|
||||
│ ▼ │ │
|
||||
│ ┌───────────────┐ │ │
|
||||
│ │TaskInteractive│ │ │
|
||||
│ └───────┬───────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ User │ │
|
||||
│ │ approves│ │
|
||||
│ │ ▼ │
|
||||
│ │ ┌───────────┐
|
||||
│ │ │TaskResumable│
|
||||
│ │ └─────┬─────┘
|
||||
│ │ │
|
||||
│ │ User │
|
||||
│ │ resumes│
|
||||
│ │ │
|
||||
└──────────────┴────────┘
|
||||
```
|
||||
|
||||
The extension uses helper functions to categorize asks and emit the appropriate events:
|
||||
|
||||
- `isInteractiveAsk()` → emits `TaskInteractive`
|
||||
- `isIdleAsk()` → emits `TaskIdle`
|
||||
- `isResumableAsk()` → emits `TaskResumable`
|
||||
|
||||
## WebviewMessage Types for Responses
|
||||
|
||||
When responding to asks, use the appropriate `WebviewMessage` type (defined in [`packages/types/src/vscode-extension-host.ts`](../packages/types/src/vscode-extension-host.ts)):
|
||||
|
||||
```typescript
|
||||
interface WebviewMessage {
|
||||
type:
|
||||
| "askResponse" // Respond to an ask
|
||||
| "newTask" // Start a new task
|
||||
| "clearTask" // Clear/end current task
|
||||
| "cancelTask" // Cancel running task
|
||||
| "terminalOperation" // Control terminal output
|
||||
// ... many other types
|
||||
|
||||
askResponse?: ClineAskResponse // "yesButtonClicked" | "noButtonClicked" | "messageResponse" | "objectResponse"
|
||||
text?: string
|
||||
images?: string[]
|
||||
terminalOperation?: "continue" | "abort"
|
||||
}
|
||||
```
|
||||
|
||||
## Summary
|
||||
|
||||
To correctly detect when the agent loop has stopped in an alternative client:
|
||||
|
||||
1. **Monitor `clineMessages`** from state updates
|
||||
2. **Check the last message's `type` and `ask`/`say` properties**
|
||||
3. **Check `partial` flag** to detect streaming
|
||||
4. **For API request status**, parse the `api_req_started` message's `text` field and check if `cost` is defined
|
||||
5. **Use the ask category functions** (`isIdleAsk`, `isInteractiveAsk`, etc.) to determine the appropriate UI state
|
||||
6. **Respond with the correct `askResponse` type** based on user action
|
||||
|
||||
The key insight is that the agent loop stops whenever a message with `type: "ask"` arrives, and the specific `ask` value determines what kind of response the agent is waiting for.
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@roo-code/types",
|
||||
"version": "1.99.0",
|
||||
"version": "1.100.0",
|
||||
"description": "TypeScript type definitions for Roo Code.",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue