feat: add real-time steering advice for mid-task coaching

Implements MVP for issue #11802: allows users to send steering
advice while the agent is actively working. The advice is queued
and injected into the next API call as additional context, without
interrupting the current task.

Changes:
- Add SteeringQueue (src/core/steering/SteeringQueue.ts) with
  TTL-based expiration and capacity cap
- Add steering_advice ClineSay type for chat display
- Add steeringAdvice WebviewMessage type
- Inject pending advice at finalUserContent assembly in Task.ts
- Handle steeringAdvice in webviewMessageHandler.ts
- Add Compass steering button in ChatTextArea (visible during streaming)
- Add steering handler in ChatView
- Render steering_advice messages with badge in ChatRow
- Add i18n strings for steering UI
- 11 unit tests for SteeringQueue (all passing)
This commit is contained in:
Roo Code 2026-02-28 19:18:58 +00:00
parent 193053110b
commit 7c07d74303
10 changed files with 289 additions and 1 deletions

View file

@ -170,6 +170,7 @@ export const clineSays = [
"user_edit_todos",
"too_many_tools_warning",
"tool",
"steering_advice",
] as const
export const clineSaySchema = z.enum(clineSays)

View file

@ -580,6 +580,7 @@ export interface WebviewMessage {
| "moveSkill"
| "updateSkillModes"
| "openSkillFile"
| "steeringAdvice"
text?: string
taskId?: string
editedMessageContent?: string

View file

@ -0,0 +1,92 @@
/**
* SteeringQueue
*
* A simple queue for reactive steering advice. When the user sends steering
* advice while the agent is actively working, it is enqueued here. At the
* next API call the pending advice is drained, formatted, and injected into
* the prompt so the LLM can naturally incorporate it.
*
* Design constraints:
* - Zero overhead when no advice is pending (no extra tokens).
* - Capped at MAX_PENDING to avoid unbounded growth.
* - TTL-based expiration so stale advice doesn't pollute future calls.
*/
export interface SteeringAdvice {
text: string
timestamp: number
}
const MAX_PENDING = 5
const TTL_MS = 5 * 60 * 1000 // 5 minutes
export class SteeringQueue {
private queue: SteeringAdvice[] = []
/**
* Enqueue a piece of steering advice from the user.
* Drops the oldest item if the queue is at capacity.
*/
enqueue(text: string): void {
if (!text.trim()) {
return
}
if (this.queue.length >= MAX_PENDING) {
this.queue.shift()
}
this.queue.push({ text: text.trim(), timestamp: Date.now() })
}
/**
* Drain all non-expired advice and format it as an injection block.
* Returns `undefined` when the queue is empty (zero-overhead path).
*/
drain(): string | undefined {
if (this.queue.length === 0) {
return undefined
}
const now = Date.now()
const valid = this.queue.filter((a) => now - a.timestamp < TTL_MS)
this.queue = []
if (valid.length === 0) {
return undefined
}
const lines = valid.map((a, i) => `${i + 1}. ${a.text}`).join("\n")
return [
"<steering_advice>",
"The user has sent the following real-time steering advice while you were working.",
"Please incorporate this guidance into your next actions. If any advice conflicts",
"with your current approach, use your judgment to determine the best path forward.",
"",
lines,
"</steering_advice>",
].join("\n")
}
/**
* Whether the queue has any pending advice.
*/
get hasPending(): boolean {
return this.queue.length > 0
}
/**
* Number of items currently in the queue.
*/
get size(): number {
return this.queue.length
}
/**
* Clear all pending advice.
*/
clear(): void {
this.queue = []
}
}

View file

@ -0,0 +1,109 @@
import { SteeringQueue } from "../SteeringQueue"
describe("SteeringQueue", () => {
let queue: SteeringQueue
beforeEach(() => {
queue = new SteeringQueue()
})
describe("enqueue", () => {
it("should add advice to the queue", () => {
queue.enqueue("Use Vitest instead of Jest")
expect(queue.size).toBe(1)
expect(queue.hasPending).toBe(true)
})
it("should ignore empty or whitespace-only advice", () => {
queue.enqueue("")
queue.enqueue(" ")
expect(queue.size).toBe(0)
expect(queue.hasPending).toBe(false)
})
it("should trim advice text", () => {
queue.enqueue(" Use Vitest ")
const result = queue.drain()
expect(result).toContain("Use Vitest")
expect(result).not.toContain(" Use Vitest ")
})
it("should drop oldest item when at capacity (5)", () => {
for (let i = 1; i <= 6; i++) {
queue.enqueue(`Advice ${i}`)
}
expect(queue.size).toBe(5)
const result = queue.drain()!
expect(result).not.toContain("Advice 1")
expect(result).toContain("Advice 2")
expect(result).toContain("Advice 6")
})
})
describe("drain", () => {
it("should return undefined when empty", () => {
expect(queue.drain()).toBeUndefined()
})
it("should return formatted injection text", () => {
queue.enqueue("Use Vitest instead of Jest")
queue.enqueue("Use UTF-8 encoding")
const result = queue.drain()!
expect(result).toContain("<steering_advice>")
expect(result).toContain("</steering_advice>")
expect(result).toContain("1. Use Vitest instead of Jest")
expect(result).toContain("2. Use UTF-8 encoding")
})
it("should clear the queue after draining", () => {
queue.enqueue("Some advice")
queue.drain()
expect(queue.size).toBe(0)
expect(queue.hasPending).toBe(false)
expect(queue.drain()).toBeUndefined()
})
it("should skip expired advice (older than 5 minutes)", () => {
queue.enqueue("Old advice")
// Manually expire the item by backdating its timestamp
const internalQueue = (queue as any).queue as Array<{ text: string; timestamp: number }>
internalQueue[0].timestamp = Date.now() - 6 * 60 * 1000 // 6 min ago
queue.enqueue("Fresh advice")
const result = queue.drain()!
expect(result).not.toContain("Old advice")
expect(result).toContain("Fresh advice")
})
it("should return undefined when all advice is expired", () => {
queue.enqueue("Stale advice")
const internalQueue = (queue as any).queue as Array<{ text: string; timestamp: number }>
internalQueue[0].timestamp = Date.now() - 6 * 60 * 1000
expect(queue.drain()).toBeUndefined()
expect(queue.size).toBe(0)
})
})
describe("clear", () => {
it("should remove all pending advice", () => {
queue.enqueue("Advice 1")
queue.enqueue("Advice 2")
queue.clear()
expect(queue.size).toBe(0)
expect(queue.hasPending).toBe(false)
})
})
describe("zero-overhead path", () => {
it("should have no overhead when empty (drain returns undefined)", () => {
// This verifies the zero-overhead contract: when no advice is pending,
// drain() returns undefined and no injection text is generated.
expect(queue.drain()).toBeUndefined()
expect(queue.hasPending).toBe(false)
})
})
})

View file

@ -128,6 +128,7 @@ import {
import { processUserContentMentions } from "../mentions/processUserContentMentions"
import { getMessagesSinceLastSummary, summarizeConversation, getEffectiveApiHistory } from "../condense"
import { MessageQueueService } from "../message-queue/MessageQueueService"
import { SteeringQueue } from "../steering/SteeringQueue"
import { AutoApprovalHandler, checkAutoApproval } from "../auto-approval"
import { MessageManager } from "../message-manager"
import { validateAndFixToolResultIds } from "./validateToolResultIds"
@ -335,6 +336,9 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
public readonly messageQueueService: MessageQueueService
private messageQueueStateChangedHandler: (() => void) | undefined
// Steering Queue - allows users to send real-time advice during task execution
public readonly steeringQueue: SteeringQueue = new SteeringQueue()
// Streaming
isWaitingForFirstChunk = false
isStreaming = false
@ -2639,6 +2643,15 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// Add environment details as its own text block, separate from tool
// results.
let finalUserContent = [...contentWithoutEnvDetails, { type: "text" as const, text: environmentDetails }]
// Inject any pending steering advice into the prompt.
// This is the zero-overhead path: when no advice is pending, drain()
// returns undefined and nothing is added.
const steeringInjection = this.steeringQueue.drain()
if (steeringInjection) {
finalUserContent.push({ type: "text" as const, text: steeringInjection })
}
// Only add user message to conversation history if:
// 1. This is the first attempt (retryAttempt === 0), AND
// 2. The original userContent was not empty (empty signals delegation resume where

View file

@ -3164,6 +3164,16 @@ export const webviewMessageHandler = async (
* Chat Message Queue
*/
case "steeringAdvice": {
const task = provider.getCurrentTask()
if (task && message.text) {
task.steeringQueue.enqueue(message.text)
// Show the advice in chat as a steering_advice message
await task.say("steering_advice", message.text)
}
break
}
case "queueMessage": {
const resolved = await resolveIncomingImages({ text: message.text, images: message.images })
provider.getCurrentTask()?.messageQueueService.addMessage(resolved.text, resolved.images)

View file

@ -71,6 +71,7 @@ import {
Split,
ArrowRight,
Check,
Compass,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { PathTooltip } from "../ui/PathTooltip"
@ -1274,6 +1275,23 @@ export const ChatRowContent = ({
</div>
</div>
)
case "steering_advice":
return (
<div className="group">
<div style={headerStyle}>
<Compass className="w-4 shrink-0" aria-label="Steering icon" />
<span style={{ fontWeight: "bold" }}>{t("chat:steeringAdvice.youAdvised")}</span>
<VSCodeBadge>{t("chat:steeringAdvice.label")}</VSCodeBadge>
</div>
<div
className={cn(
"ml-6 border rounded-sm overflow-hidden whitespace-pre-wrap",
"cursor-text p-1 bg-vscode-editor-foreground/70 text-vscode-editor-background",
)}>
<div className="flex-grow px-2 py-1 wrap-anywhere rounded-lg">{message.text}</div>
</div>
</div>
)
case "user_feedback_diff":
const tool = safeJsonParse<ClineSayTool>(message.text)
return (

View file

@ -1,7 +1,7 @@
import React, { forwardRef, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"
import { useEvent } from "react-use"
import DynamicTextArea from "react-textarea-autosize"
import { VolumeX, Image, WandSparkles, SendHorizontal, X, ListEnd, Square } from "lucide-react"
import { VolumeX, Image, WandSparkles, SendHorizontal, X, ListEnd, Square, Compass } from "lucide-react"
import type { ExtensionMessage } from "@roo-code/types"
@ -56,6 +56,7 @@ interface ChatTextAreaProps {
isStreaming?: boolean
onStop?: () => void
onEnqueueMessage?: () => void
onSendSteeringAdvice?: () => void
}
export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
@ -79,6 +80,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
isStreaming = false,
onStop,
onEnqueueMessage,
onSendSteeringAdvice,
},
ref,
) => {
@ -1196,6 +1198,29 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
</button>
</StandardTooltip>
)}
{/* Steering advice button - shown when streaming and user has typed content */}
{!isEditMode && isStreaming && hasInputContent && onSendSteeringAdvice && (
<StandardTooltip content={t("chat:sendSteeringAdvice")}>
<button
aria-label={t("chat:sendSteeringAdvice")}
disabled={false}
onClick={onSendSteeringAdvice}
className={cn(
"relative inline-flex items-center justify-center",
"bg-transparent border-none p-1.5",
"rounded-md min-w-[28px] min-h-[28px]",
"text-vscode-descriptionForeground hover:text-vscode-foreground",
"transition-all duration-200",
"opacity-100 hover:opacity-100 pointer-events-auto",
"hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)]",
"focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder",
"active:bg-[rgba(255,255,255,0.1)]",
"cursor-pointer",
)}>
<Compass className="w-4 h-4" />
</button>
</StandardTooltip>
)}
{/* Queue button - shown when streaming and user has typed content */}
{!isEditMode && isStreaming && hasInputContent && onEnqueueMessage && (
<StandardTooltip content={t("chat:enqueueMessage")}>

View file

@ -722,6 +722,19 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
}
}, [inputValue, selectedImages])
// Handle steering advice button click from textarea
const handleSendSteeringAdvice = useCallback(() => {
const text = inputValue.trim()
if (text) {
vscode.postMessage({
type: "steeringAdvice",
text,
})
setInputValue("")
setSelectedImages([])
}
}, [inputValue])
// This logic depends on the useEffect[messages] above to set clineAsk,
// after which buttons are shown and we then send an askResponse to the
// extension.
@ -1761,6 +1774,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
isStreaming={isStreaming}
onStop={handleStopTask}
onEnqueueMessage={handleEnqueueCurrentMessage}
onSendSteeringAdvice={handleSendSteeringAdvice}
/>
{isProfileDisabled && (

View file

@ -120,6 +120,11 @@
"tooltip": "Stop the current task"
},
"enqueueMessage": "Add message to queue (will be sent after current task completes)",
"sendSteeringAdvice": "Send steering advice (will be included in the next API call)",
"steeringAdvice": {
"label": "Steering",
"youAdvised": "You advised"
},
"editMessage": {
"placeholder": "Edit your message..."
},