Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 52 additions & 6 deletions apps/cli/docs/AGENT_LOOP.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,57 @@ interface ClineMessage {
| **say** | Informational - agent is telling you something | No |
| **ask** | Interactive - agent needs something from you | Usually yes |

## The Key Insight
## The Key Insight: How the CLI Knows When to Prompt

> **The agent loop stops whenever the last message is an `ask` type (with `partial: false`).**
The CLI doesn't receive any special "waiting" signal from the extension. Instead, it simply **looks at the last message** and asks three questions:

The specific `ask` value tells you exactly what the agent needs.
### Is the agent waiting for user input?

```
isWaitingForInput = true when ALL of these are true:

1. Last message type is "ask" (not "say")
2. Last message is NOT partial: true (streaming is complete)
3. The ask type is "blocking" (not "command_output")
```

That's it. No timing. No special signals. Just look at what the last message is.

### Why this works

When the extension needs user input:

- It sends an `ask` message and **blocks** (waits for response)
- The ask stays as the last message until the CLI responds
- CLI sees the ask → prompts user → sends response → extension continues

When auto-approval is enabled:

- Extension sends an `ask` message
- Extension **immediately auto-responds** to its own ask (doesn't wait)
- New messages quickly follow the ask
- CLI sees the ask but it's quickly superseded by newer messages
- State never "settles" at waiting because the extension kept going

### The Simple Logic

```typescript
function isWaitingForInput(messages) {
const lastMessage = messages.at(-1)

// Still streaming? Not waiting.
if (lastMessage?.partial === true) return false

// Not an ask? Not waiting.
if (lastMessage?.type !== "ask") return false

// Non-blocking ask? Not waiting.
if (lastMessage?.ask === "command_output") return false

// It's a blocking ask that's done streaming → waiting!
return true
}
```

## Ask Categories

Expand Down Expand Up @@ -348,8 +394,8 @@ Example output:
## 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**
2. **State detection is simple: look at the last message**
3. **Waiting = last message is a non-partial, blocking `ask`**
4. **Auto-approval works by the extension auto-responding to its own asks**
5. **`partial: true` or `api_req_started` without cost = streaming**
6. **`ExtensionClient` is the single source of truth**
7 changes: 3 additions & 4 deletions apps/cli/src/agent/__tests__/extension-host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,12 +98,11 @@ describe("ExtensionHost", () => {

const host = new ExtensionHost(options)

// Options are stored but integrationTest is set to true
// Options are stored as provided
const storedOptions = getPrivate<ExtensionHostOptions>(host, "options")
expect(storedOptions.mode).toBe(options.mode)
expect(storedOptions.workspacePath).toBe(options.workspacePath)
expect(storedOptions.extensionPath).toBe(options.extensionPath)
expect(storedOptions.integrationTest).toBe(true) // Always set to true in constructor
})

it("should be an EventEmitter instance", () => {
Expand Down Expand Up @@ -281,8 +280,8 @@ describe("ExtensionHost", () => {
describe("quiet mode", () => {
describe("setupQuietMode", () => {
it("should not modify console when integrationTest is true", () => {
// By default, constructor sets integrationTest = true
const host = createTestHost()
// Explicitly set integrationTest = true
const host = createTestHost({ integrationTest: true })
const originalLog = console.log

callPrivate(host, "setupQuietMode")
Expand Down
81 changes: 44 additions & 37 deletions apps/cli/src/agent/ask-dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ export class AskDispatcher {
}

// Skip partial messages (wait for complete)
// Note: Streaming output for partial tool/command messages is handled by OutputManager
if (message.partial) {
return { handled: false }
}
Expand Down Expand Up @@ -356,9 +357,12 @@ export class AskDispatcher {
* 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)
// Skip output if we already streamed this command via partial messages
if (!this.outputManager.isAlreadyDisplayed(ts)) {
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
Expand All @@ -380,46 +384,49 @@ export class AskDispatcher {
* 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}`)
}
// Skip output if we already streamed this tool request via partial messages
if (!this.outputManager.isAlreadyDisplayed(ts)) {
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
}

// Display tool details
for (const [key, value] of Object.entries(toolInfo)) {
if (key === "tool" || key === "isProtected") continue
const isProtected = toolInfo.isProtected === true

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
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 {
displayValue = String(value)
this.outputManager.output(`\n[Tool Request] ${toolName}`)
}

this.outputManager.output(` ${key}: ${displayValue}`)
}
// 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)
this.outputManager.markDisplayed(ts, text || "", false)
}

if (this.nonInteractive) {
// Auto-approved by extension settings (unless protected)
Expand Down
15 changes: 15 additions & 0 deletions apps/cli/src/agent/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@ export interface ClientEventMap {
*/
modeChanged: ModeChangedEvent

/**
* Emitted when command execution output is received (streaming terminal output).
*/
commandExecutionOutput: CommandExecutionOutputEvent

/**
* Emitted on any error during message processing.
*/
Expand Down Expand Up @@ -128,6 +133,16 @@ export interface ModeChangedEvent {
currentMode: string
}

/**
* Event payload for command execution output (streaming terminal output).
*/
export interface CommandExecutionOutputEvent {
/** Unique execution ID */
executionId: string
/** The terminal output received so far */
output: string
}

// =============================================================================
// Typed Event Emitter
// =============================================================================
Expand Down
51 changes: 33 additions & 18 deletions apps/cli/src/agent/extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import type { User } from "@/lib/sdk/index.js"
import { getProviderSettings } from "@/lib/utils/provider.js"
import { createEphemeralStorageDir } from "@/lib/storage/index.js"

import type { WaitingForInputEvent, TaskCompletedEvent } from "./events.js"
import type { WaitingForInputEvent, TaskCompletedEvent, CommandExecutionOutputEvent } from "./events.js"
import type { AgentStateInfo } from "./agent-state.js"
import { ExtensionClient } from "./extension-client.js"
import { OutputManager } from "./output-manager.js"
Expand Down Expand Up @@ -152,7 +152,7 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac
super()

this.options = options
this.options.integrationTest = true
// this.options.integrationTest = true

// Initialize client - single source of truth for agent state (including mode).
this.client = new ExtensionClient({
Expand Down Expand Up @@ -189,6 +189,18 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac
commandExecutionTimeout: 30,
browserToolEnabled: false,
enableCheckpoints: false,
// Disable preventFocusDisruption experiment for CLI - it's only
// relevant for VSCode diff views and preventing it causes tool
// messages to not stream during LLM generation.
experiments: {
multiFileApplyDiff: false,
powerSteering: false,
preventFocusDisruption: false,
imageGeneration: false,
runSlashCommand: false,
multipleNativeToolCalls: false,
customTools: false,
},
...getProviderSettings(this.options.provider, this.options.apiKey, this.options.model),
}

Expand Down Expand Up @@ -237,12 +249,26 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac
// Handle new messages - delegate to OutputManager.
this.client.on("message", (msg: ClineMessage) => {
this.logMessageDebug(msg, "new")
// DEBUG: Log all incoming messages with timestamp (only when -d flag is set)
if (this.options.debug) {
const ts = new Date().toISOString()
const msgType = msg.type === "say" ? `say:${msg.say}` : `ask:${msg.ask}`
const partial = msg.partial ? "PARTIAL" : "COMPLETE"
process.stdout.write(`\n[DEBUG ${ts}] NEW ${msgType} ${partial} ts=${msg.ts}\n`)
}
this.outputManager.outputMessage(msg)
})

// Handle message updates - delegate to OutputManager.
this.client.on("messageUpdated", (msg: ClineMessage) => {
this.logMessageDebug(msg, "updated")
// DEBUG: Log all message updates with timestamp (only when -d flag is set)
if (this.options.debug) {
const ts = new Date().toISOString()
const msgType = msg.type === "say" ? `say:${msg.say}` : `ask:${msg.ask}`
const partial = msg.partial ? "PARTIAL" : "COMPLETE"
process.stdout.write(`\n[DEBUG ${ts}] UPDATED ${msgType} ${partial} ts=${msg.ts}\n`)
}
this.outputManager.outputMessage(msg)
})

Expand All @@ -259,6 +285,11 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac
this.outputManager.outputCompletionResult(event.message.ts, event.message.text || "")
}
})

// Handle streaming terminal output from commandExecutionStatus messages.
this.client.on("commandExecutionOutput", (event: CommandExecutionOutputEvent) => {
this.outputManager.outputStreamingTerminalOutput(event.executionId, event.output)
})
}

// ==========================================================================
Expand Down Expand Up @@ -436,9 +467,6 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac
this.sendToExtension({ type: "newTask", text: prompt })

return new Promise((resolve, reject) => {
let timeoutId: NodeJS.Timeout | null = null
const timeoutMs: number = 110_000

const completeHandler = () => {
cleanup()
resolve()
Expand All @@ -450,23 +478,10 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac
}

const cleanup = () => {
if (timeoutId) {
clearTimeout(timeoutId)
timeoutId = null
}

this.client.off("taskCompleted", completeHandler)
this.client.off("error", errorHandler)
}

// Set timeout to prevent indefinite hanging.
timeoutId = setTimeout(() => {
cleanup()
reject(
new Error(`Task completion timeout after ${timeoutMs}ms - no completion or error event received`),
)
}, timeoutMs)

this.client.once("taskCompleted", completeHandler)
this.client.once("error", errorHandler)
})
Expand Down
2 changes: 2 additions & 0 deletions apps/cli/src/agent/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export * from "./extension-host.js"
export { ExtensionClient } from "./extension-client.js"
export type { WaitingForInputEvent, TaskCompletedEvent, CommandExecutionOutputEvent } from "./events.js"
Loading
Loading