From 7c07d7430330741cee7a353a7121c0863c76a6af Mon Sep 17 00:00:00 2001 From: Roo Code Date: Sat, 28 Feb 2026 19:18:58 +0000 Subject: [PATCH] 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) --- packages/types/src/message.ts | 1 + packages/types/src/vscode-extension-host.ts | 1 + src/core/steering/SteeringQueue.ts | 92 +++++++++++++++ .../steering/__tests__/SteeringQueue.test.ts | 109 ++++++++++++++++++ src/core/task/Task.ts | 13 +++ src/core/webview/webviewMessageHandler.ts | 10 ++ webview-ui/src/components/chat/ChatRow.tsx | 18 +++ .../src/components/chat/ChatTextArea.tsx | 27 ++++- webview-ui/src/components/chat/ChatView.tsx | 14 +++ webview-ui/src/i18n/locales/en/chat.json | 5 + 10 files changed, 289 insertions(+), 1 deletion(-) create mode 100644 src/core/steering/SteeringQueue.ts create mode 100644 src/core/steering/__tests__/SteeringQueue.test.ts diff --git a/packages/types/src/message.ts b/packages/types/src/message.ts index e518972a1c..8d9f19c726 100644 --- a/packages/types/src/message.ts +++ b/packages/types/src/message.ts @@ -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) diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index ab06f0dabb..fb5ea11e1e 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -580,6 +580,7 @@ export interface WebviewMessage { | "moveSkill" | "updateSkillModes" | "openSkillFile" + | "steeringAdvice" text?: string taskId?: string editedMessageContent?: string diff --git a/src/core/steering/SteeringQueue.ts b/src/core/steering/SteeringQueue.ts new file mode 100644 index 0000000000..e82a2e916c --- /dev/null +++ b/src/core/steering/SteeringQueue.ts @@ -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 [ + "", + "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, + "", + ].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 = [] + } +} diff --git a/src/core/steering/__tests__/SteeringQueue.test.ts b/src/core/steering/__tests__/SteeringQueue.test.ts new file mode 100644 index 0000000000..5aae7844b2 --- /dev/null +++ b/src/core/steering/__tests__/SteeringQueue.test.ts @@ -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("") + expect(result).toContain("") + 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) + }) + }) +}) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 1d4320493a..6e2bbfda82 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -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 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 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 diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 89f29ae2e1..a4fa46d601 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -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) diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 1f3f8f5a4f..702c88cedb 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -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 = ({ ) + case "steering_advice": + return ( +
+
+ + {t("chat:steeringAdvice.youAdvised")} + {t("chat:steeringAdvice.label")} +
+
+
{message.text}
+
+
+ ) case "user_feedback_diff": const tool = safeJsonParse(message.text) return ( diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index e72c1726f3..8a6d81cb42 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -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( @@ -79,6 +80,7 @@ export const ChatTextArea = forwardRef( isStreaming = false, onStop, onEnqueueMessage, + onSendSteeringAdvice, }, ref, ) => { @@ -1196,6 +1198,29 @@ export const ChatTextArea = forwardRef( )} + {/* Steering advice button - shown when streaming and user has typed content */} + {!isEditMode && isStreaming && hasInputContent && onSendSteeringAdvice && ( + + + + )} {/* Queue button - shown when streaming and user has typed content */} {!isEditMode && isStreaming && hasInputContent && onEnqueueMessage && ( diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index fd0aca66cb..1b4aded7d3 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -722,6 +722,19 @@ const ChatViewComponent: React.ForwardRefRenderFunction { + 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 {isProfileDisabled && ( diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index 4899859e3a..9c046b373d 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -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..." },