mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-10 22:41:14 +00:00
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:
parent
193053110b
commit
7c07d74303
10 changed files with 289 additions and 1 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -580,6 +580,7 @@ export interface WebviewMessage {
|
|||
| "moveSkill"
|
||||
| "updateSkillModes"
|
||||
| "openSkillFile"
|
||||
| "steeringAdvice"
|
||||
text?: string
|
||||
taskId?: string
|
||||
editedMessageContent?: string
|
||||
|
|
|
|||
92
src/core/steering/SteeringQueue.ts
Normal file
92
src/core/steering/SteeringQueue.ts
Normal 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 = []
|
||||
}
|
||||
}
|
||||
109
src/core/steering/__tests__/SteeringQueue.test.ts
Normal file
109
src/core/steering/__tests__/SteeringQueue.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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")}>
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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..."
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue