18 KiB
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:
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:
1. Idle Asks - Task effectively finished
These indicate the agent loop has stopped and the task is in a terminal or error state.
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.
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.
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.
const nonBlockingAsks = ["command_output"] as const
Helper function: isNonBlockingAsk(ask: ClineAsk): boolean
Client-Side State Detection
ChatView State Management
The ChatView component maintains several state variables:
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:
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:
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
// 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
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
// 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:
┌─────────────────┐
│ 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()→ emitsTaskInteractiveisIdleAsk()→ emitsTaskIdleisResumableAsk()→ emitsTaskResumable
WebviewMessage Types for Responses
When responding to asks, use the appropriate WebviewMessage type (defined in packages/types/src/vscode-extension-host.ts):
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:
- Monitor
clineMessagesfrom state updates - Check the last message's
typeandask/sayproperties - Check
partialflag to detect streaming - For API request status, parse the
api_req_startedmessage'stextfield and check ifcostis defined - Use the ask category functions (
isIdleAsk,isInteractiveAsk, etc.) to determine the appropriate UI state - Respond with the correct
askResponsetype 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.