Move message queue to the extension host (#7604)

Co-authored-by: roomote[bot] <219738659+roomote[bot]@users.noreply.github.com>
This commit is contained in:
Chris Estreich 2025-09-02 19:12:18 -07:00 committed by GitHub
parent 7e24600877
commit 5196c75017
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 385 additions and 221 deletions

View file

@ -175,6 +175,7 @@ export class ExtensionChannel extends BaseChannel<
{ from: RooCodeEventName.TaskInteractive, to: ExtensionBridgeEventName.TaskInteractive },
{ from: RooCodeEventName.TaskResumable, to: ExtensionBridgeEventName.TaskResumable },
{ from: RooCodeEventName.TaskIdle, to: ExtensionBridgeEventName.TaskIdle },
{ from: RooCodeEventName.TaskUserMessage, to: ExtensionBridgeEventName.TaskUserMessage },
] as const
eventMapping.forEach(({ from, to }) => {
@ -220,6 +221,8 @@ export class ExtensionChannel extends BaseChannel<
? {
taskId: task.taskId,
taskStatus: task.taskStatus,
taskAsk: task?.taskAsk,
queuedMessages: task.queuedMessages,
...task.metadata,
}
: { taskId: "", taskStatus: TaskStatus.None },

View file

@ -101,6 +101,7 @@ describe("ExtensionChannel", () => {
RooCodeEventName.TaskInteractive,
RooCodeEventName.TaskResumable,
RooCodeEventName.TaskIdle,
RooCodeEventName.TaskUserMessage,
]
// Check that on() was called for each event
@ -230,7 +231,7 @@ describe("ExtensionChannel", () => {
}
// Listeners should still be the same count (not accumulated)
const expectedEventCount = 10 // Number of events we listen to
const expectedEventCount = 11 // Number of events we listen to (including TaskUserMessage)
expect(eventListeners.size).toBe(expectedEventCount)
// Each event should have exactly 1 listener

View file

@ -1,6 +1,6 @@
{
"name": "@roo-code/types",
"version": "1.66.0",
"version": "1.67.0",
"description": "TypeScript type definitions for Roo Code.",
"publishConfig": {
"access": "public",

View file

@ -7,7 +7,7 @@ import { TaskStatus, taskMetadataSchema } from "./task.js"
import { globalSettingsSchema } from "./global-settings.js"
import { providerSettingsWithIdSchema } from "./provider-settings.js"
import { mcpMarketplaceItemSchema } from "./marketplace.js"
import { clineMessageSchema } from "./message.js"
import { clineMessageSchema, queuedMessageSchema } from "./message.js"
import { staticAppPropertiesSchema, gitPropertiesSchema } from "./telemetry.js"
/**
@ -359,6 +359,8 @@ export const INSTANCE_TTL_SECONDS = 60
const extensionTaskSchema = z.object({
taskId: z.string(),
taskStatus: z.nativeEnum(TaskStatus),
taskAsk: clineMessageSchema.optional(),
queuedMessages: z.array(queuedMessageSchema).optional(),
...taskMetadataSchema.shape,
})
@ -402,6 +404,8 @@ export enum ExtensionBridgeEventName {
TaskResumable = RooCodeEventName.TaskResumable,
TaskIdle = RooCodeEventName.TaskIdle,
TaskUserMessage = RooCodeEventName.TaskUserMessage,
ModeChanged = RooCodeEventName.ModeChanged,
ProviderProfileChanged = RooCodeEventName.ProviderProfileChanged,
@ -461,6 +465,26 @@ export const extensionBridgeEventSchema = z.discriminatedUnion("type", [
instance: extensionInstanceSchema,
timestamp: z.number(),
}),
z.object({
type: z.literal(ExtensionBridgeEventName.TaskUserMessage),
instance: extensionInstanceSchema,
timestamp: z.number(),
}),
z.object({
type: z.literal(ExtensionBridgeEventName.ModeChanged),
instance: extensionInstanceSchema,
mode: z.string(),
timestamp: z.number(),
}),
z.object({
type: z.literal(ExtensionBridgeEventName.ProviderProfileChanged),
instance: extensionInstanceSchema,
providerProfile: z.object({ name: z.string(), provider: z.string().optional() }),
timestamp: z.number(),
}),
z.object({
type: z.literal(ExtensionBridgeEventName.InstanceRegistered),
instance: extensionInstanceSchema,
@ -476,18 +500,6 @@ export const extensionBridgeEventSchema = z.discriminatedUnion("type", [
instance: extensionInstanceSchema,
timestamp: z.number(),
}),
z.object({
type: z.literal(ExtensionBridgeEventName.ModeChanged),
instance: extensionInstanceSchema,
mode: z.string(),
timestamp: z.number(),
}),
z.object({
type: z.literal(ExtensionBridgeEventName.ProviderProfileChanged),
instance: extensionInstanceSchema,
providerProfile: z.object({ name: z.string(), provider: z.string().optional() }),
timestamp: z.number(),
}),
])
export type ExtensionBridgeEvent = z.infer<typeof extensionBridgeEventSchema>

View file

@ -31,6 +31,7 @@ export enum RooCodeEventName {
Message = "message",
TaskModeSwitched = "taskModeSwitched",
TaskAskResponded = "taskAskResponded",
TaskUserMessage = "taskUserMessage",
// Task Analytics
TaskTokenUsageUpdated = "taskTokenUsageUpdated",
@ -82,6 +83,7 @@ export const rooCodeEventsSchema = z.object({
]),
[RooCodeEventName.TaskModeSwitched]: z.tuple([z.string(), z.string()]),
[RooCodeEventName.TaskAskResponded]: z.tuple([z.string()]),
[RooCodeEventName.TaskUserMessage]: z.tuple([z.string()]),
[RooCodeEventName.TaskToolFailed]: z.tuple([z.string(), toolNamesSchema, z.string()]),
[RooCodeEventName.TaskTokenUsageUpdated]: z.tuple([z.string(), tokenUsageSchema]),

View file

@ -248,14 +248,11 @@ export type TokenUsage = z.infer<typeof tokenUsageSchema>
* QueuedMessage
*/
/**
* Represents a message that is queued to be sent when sending is enabled
*/
export interface QueuedMessage {
/** Unique identifier for the queued message */
id: string
/** The text content of the message */
text: string
/** Array of image data URLs attached to the message */
images: string[]
}
export const queuedMessageSchema = z.object({
timestamp: z.number(),
id: z.string(),
text: z.string(),
images: z.array(z.string()).optional(),
})
export type QueuedMessage = z.infer<typeof queuedMessageSchema>

View file

@ -2,7 +2,7 @@ import { z } from "zod"
import { RooCodeEventName } from "./events.js"
import type { RooCodeSettings } from "./global-settings.js"
import type { ClineMessage, TokenUsage } from "./message.js"
import type { ClineMessage, QueuedMessage, TokenUsage } from "./message.js"
import type { ToolUsage, ToolName } from "./tool.js"
import type { StaticAppProperties, GitProperties, TelemetryProperties } from "./telemetry.js"
import type { TodoItem } from "./todo.js"
@ -59,8 +59,6 @@ export interface TaskProviderLike {
export type TaskProviderEvents = {
[RooCodeEventName.TaskCreated]: [task: TaskLike]
// Proxied from the Task EventEmitter.
[RooCodeEventName.TaskStarted]: [taskId: string]
[RooCodeEventName.TaskCompleted]: [taskId: string, tokenUsage: TokenUsage, toolUsage: ToolUsage]
[RooCodeEventName.TaskAborted]: [taskId: string]
@ -71,6 +69,9 @@ export type TaskProviderEvents = {
[RooCodeEventName.TaskResumable]: [taskId: string]
[RooCodeEventName.TaskIdle]: [taskId: string]
[RooCodeEventName.TaskSpawned]: [taskId: string]
[RooCodeEventName.TaskUserMessage]: [taskId: string]
[RooCodeEventName.ModeChanged]: [mode: string]
[RooCodeEventName.ProviderProfileChanged]: [config: { name: string; provider?: string }]
}
@ -105,11 +106,12 @@ export type TaskMetadata = z.infer<typeof taskMetadataSchema>
export interface TaskLike {
readonly taskId: string
readonly taskStatus: TaskStatus
readonly taskAsk: ClineMessage | undefined
readonly rootTask?: TaskLike
readonly metadata: TaskMetadata
readonly rootTask?: TaskLike
readonly taskStatus: TaskStatus
readonly taskAsk: ClineMessage | undefined
readonly queuedMessages: QueuedMessage[]
on<K extends keyof TaskEvents>(event: K, listener: (...args: TaskEvents[K]) => void | Promise<void>): this
off<K extends keyof TaskEvents>(event: K, listener: (...args: TaskEvents[K]) => void | Promise<void>): this
@ -141,6 +143,7 @@ export type TaskEvents = {
[RooCodeEventName.Message]: [{ action: "created" | "updated"; message: ClineMessage }]
[RooCodeEventName.TaskModeSwitched]: [taskId: string, mode: string]
[RooCodeEventName.TaskAskResponded]: []
[RooCodeEventName.TaskUserMessage]: [taskId: string]
// Task Analytics
[RooCodeEventName.TaskToolFailed]: [taskId: string, tool: ToolName, error: string]

View file

@ -0,0 +1,98 @@
import { EventEmitter } from "events"
import { v4 as uuidv4 } from "uuid"
import { QueuedMessage } from "@roo-code/types"
export interface MessageQueueState {
messages: QueuedMessage[]
isProcessing: boolean
isPaused: boolean
}
export interface QueueEvents {
stateChanged: [messages: QueuedMessage[]]
}
export class MessageQueueService extends EventEmitter<QueueEvents> {
private _messages: QueuedMessage[]
constructor() {
super()
this._messages = []
}
private findMessage(id: string) {
const index = this._messages.findIndex((msg) => msg.id === id)
if (index === -1) {
return { index, message: undefined }
}
return { index, message: this._messages[index] }
}
public addMessage(text: string, images?: string[]): QueuedMessage | undefined {
if (!text && !images?.length) {
return undefined
}
const message: QueuedMessage = {
timestamp: Date.now(),
id: uuidv4(),
text,
images,
}
this._messages.push(message)
this.emit("stateChanged", this._messages)
return message
}
public removeMessage(id: string): boolean {
const { index, message } = this.findMessage(id)
if (!message) {
return false
}
this._messages.splice(index, 1)
this.emit("stateChanged", this._messages)
return true
}
public updateMessage(id: string, text: string, images?: string[]): boolean {
const { message } = this.findMessage(id)
if (!message) {
return false
}
message.timestamp = Date.now()
message.text = text
message.images = images
this.emit("stateChanged", this._messages)
return true
}
public dequeueMessage(): QueuedMessage | undefined {
const message = this._messages.shift()
this.emit("stateChanged", this._messages)
return message
}
public get messages(): QueuedMessage[] {
return this._messages
}
public isEmpty(): boolean {
return this._messages.length === 0
}
public dispose(): void {
this._messages = []
this.removeAllListeners()
}
}

View file

@ -1,3 +1,3 @@
export { readApiMessages, saveApiMessages } from "./apiMessages"
export { type ApiMessage, readApiMessages, saveApiMessages } from "./apiMessages"
export { readTaskMessages, saveTaskMessages } from "./taskMessages"
export { taskMetadata } from "./taskMetadata"

View file

@ -10,7 +10,6 @@ import pWaitFor from "p-wait-for"
import { serializeError } from "serialize-error"
import {
type RooCodeSettings,
type TaskLike,
type TaskMetadata,
type TaskEvents,
@ -35,6 +34,7 @@ import {
isIdleAsk,
isInteractiveAsk,
isResumableAsk,
QueuedMessage,
} from "@roo-code/types"
import { TelemetryService } from "@roo-code/telemetry"
import { CloudService, BridgeOrchestrator } from "@roo-code/cloud"
@ -42,6 +42,7 @@ import { CloudService, BridgeOrchestrator } from "@roo-code/cloud"
// api
import { ApiHandler, ApiHandlerCreateMessageMetadata, buildApiHandler } from "../../api"
import { ApiStream } from "../../api/transform/stream"
import { maybeRemoveImageBlocks } from "../../api/transform/image-cleaning"
// shared
import { findLastIndex } from "../../shared/array"
@ -79,6 +80,7 @@ import { SYSTEM_PROMPT } from "../prompts/system"
// core modules
import { ToolRepetitionDetector } from "../tools/ToolRepetitionDetector"
import { restoreTodoListForTask } from "../tools/updateTodoListTool"
import { FileContextTracker } from "../context-tracking/FileContextTracker"
import { RooIgnoreController } from "../ignore/RooIgnoreController"
import { RooProtectedController } from "../protect/RooProtectedController"
@ -88,7 +90,14 @@ import { truncateConversationIfNeeded } from "../sliding-window"
import { ClineProvider } from "../webview/ClineProvider"
import { MultiSearchReplaceDiffStrategy } from "../diff/strategies/multi-search-replace"
import { MultiFileSearchReplaceDiffStrategy } from "../diff/strategies/multi-file-search-replace"
import { readApiMessages, saveApiMessages, readTaskMessages, saveTaskMessages, taskMetadata } from "../task-persistence"
import {
type ApiMessage,
readApiMessages,
saveApiMessages,
readTaskMessages,
saveTaskMessages,
taskMetadata,
} from "../task-persistence"
import { getEnvironmentDetails } from "../environment/getEnvironmentDetails"
import { checkContextWindowExceededError } from "../context/context-management/context-error-handling"
import {
@ -100,12 +109,11 @@ import {
checkpointDiff,
} from "../checkpoints"
import { processUserContentMentions } from "../mentions/processUserContentMentions"
import { ApiMessage } from "../task-persistence/apiMessages"
import { getMessagesSinceLastSummary, summarizeConversation } from "../condense"
import { maybeRemoveImageBlocks } from "../../api/transform/image-cleaning"
import { restoreTodoListForTask } from "../tools/updateTodoListTool"
import { AutoApprovalHandler } from "./AutoApprovalHandler"
import { Gpt5Metadata, ClineMessageWithMetadata } from "./types"
import { MessageQueueService } from "../message-queue/MessageQueueService"
import { AutoApprovalHandler } from "./AutoApprovalHandler"
const MAX_EXPONENTIAL_BACKOFF_SECONDS = 600 // 10 minutes
const DEFAULT_USAGE_COLLECTION_TIMEOUT_MS = 5000 // 5 seconds
@ -259,6 +267,10 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// Task Bridge
enableBridge: boolean
// Message Queue Service
public readonly messageQueueService: MessageQueueService
private messageQueueStateChangedHandler: (() => void) | undefined
// Streaming
isWaitingForFirstChunk = false
isStreaming = false
@ -356,9 +368,18 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
TelemetryService.instance.captureTaskCreated(this.taskId)
}
// Initialize the assistant message parser
// Initialize the assistant message parser.
this.assistantMessageParser = new AssistantMessageParser()
this.messageQueueService = new MessageQueueService()
this.messageQueueStateChangedHandler = () => {
this.emit(RooCodeEventName.TaskUserMessage, this.taskId)
this.providerRef.deref()?.postStateToWebview()
}
this.messageQueueService.on("stateChanged", this.messageQueueStateChangedHandler)
// Only set up diff strategy if diff is enabled.
if (this.diffEnabled) {
// Default to old strategy, will be updated if experiment is enabled.
@ -759,10 +780,13 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// The state is mutable if the message is complete and the task will
// block (via the `pWaitFor`).
const isBlocking = !(this.askResponse !== undefined || this.lastMessageTs !== askTs)
const isStatusMutable = !partial && isBlocking
const isMessageQueued = !this.messageQueueService.isEmpty()
const isStatusMutable = !partial && isBlocking && !isMessageQueued
let statusMutationTimeouts: NodeJS.Timeout[] = []
if (isStatusMutable) {
console.log(`Task#ask will block -> type: ${type}`)
if (isInteractiveAsk(type)) {
statusMutationTimeouts.push(
setTimeout(() => {
@ -797,9 +821,19 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
}, 1_000),
)
}
} else if (isMessageQueued) {
console.log("Task#ask will process message queue")
const message = this.messageQueueService.dequeueMessage()
if (message) {
setTimeout(async () => {
await this.submitUserMessage(message.text, message.images)
}, 0)
}
}
// Wait for askResponse to be set
// Wait for askResponse to be set.
await pWaitFor(() => this.askResponse !== undefined || this.lastMessageTs !== askTs, { interval: 100 })
if (this.lastMessageTs !== askTs) {
@ -872,6 +906,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
await provider.setProviderProfile(providerProfile)
}
this.emit(RooCodeEventName.TaskUserMessage, this.taskId)
provider.postMessageToWebview({ type: "invoke", invoke: "sendMessage", text, images })
} else {
console.error("[Task#submitUserMessage] Provider reference lost")
@ -1406,8 +1442,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
newUserContent.push(...formatResponse.imageBlocks(responseImages))
}
// Ensure we have at least some content to send to the API
// If newUserContent is empty, add a minimal resumption message
// Ensure we have at least some content to send to the API.
// If newUserContent is empty, add a minimal resumption message.
if (newUserContent.length === 0) {
newUserContent.push({
type: "text",
@ -1417,14 +1453,25 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
await this.overwriteApiConversationHistory(modifiedApiConversationHistory)
// Task resuming from history item
// Task resuming from history item.
await this.initiateTaskLoop(newUserContent)
}
public dispose(): void {
console.log(`[Task#dispose] disposing task ${this.taskId}.${this.instanceId}`)
// Dispose message queue and remove event listeners.
try {
if (this.messageQueueStateChangedHandler) {
this.messageQueueService.removeListener("stateChanged", this.messageQueueStateChangedHandler)
this.messageQueueStateChangedHandler = undefined
}
this.messageQueueService.dispose()
} catch (error) {
console.error("Error disposing message queue:", error)
}
// Remove all event listeners to prevent memory leaks.
try {
this.removeAllListeners()
@ -2719,10 +2766,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// Getters
public get cwd() {
return this.workspacePath
}
public get taskStatus(): TaskStatus {
if (this.interactiveAsk) {
return TaskStatus.Interactive
@ -2742,4 +2785,12 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
public get taskAsk(): ClineMessage | undefined {
return this.idleAsk || this.resumableAsk || this.interactiveAsk
}
public get queuedMessages(): QueuedMessage[] {
return this.messageQueueService.messages
}
public get cwd() {
return this.workspacePath
}
}

View file

@ -47,7 +47,7 @@ import { Package } from "../../shared/package"
import { findLast } from "../../shared/array"
import { supportPrompt } from "../../shared/support-prompt"
import { GlobalFileNames } from "../../shared/globalFileNames"
import { ExtensionMessage, MarketplaceInstalledMetadata } from "../../shared/ExtensionMessage"
import type { ExtensionMessage, ExtensionState, MarketplaceInstalledMetadata } from "../../shared/ExtensionMessage"
import { Mode, defaultModeSlug, getModeBySlug } from "../../shared/modes"
import { experimentDefault } from "../../shared/experiments"
import { formatLanguage } from "../../shared/language"
@ -184,6 +184,7 @@ export class ClineProvider
const onTaskInteractive = (taskId: string) => this.emit(RooCodeEventName.TaskInteractive, taskId)
const onTaskResumable = (taskId: string) => this.emit(RooCodeEventName.TaskResumable, taskId)
const onTaskIdle = (taskId: string) => this.emit(RooCodeEventName.TaskIdle, taskId)
const onTaskUserMessage = (taskId: string) => this.emit(RooCodeEventName.TaskUserMessage, taskId)
// Attach the listeners.
instance.on(RooCodeEventName.TaskStarted, onTaskStarted)
@ -195,6 +196,7 @@ export class ClineProvider
instance.on(RooCodeEventName.TaskInteractive, onTaskInteractive)
instance.on(RooCodeEventName.TaskResumable, onTaskResumable)
instance.on(RooCodeEventName.TaskIdle, onTaskIdle)
instance.on(RooCodeEventName.TaskUserMessage, onTaskUserMessage)
// Store the cleanup functions for later removal.
this.taskEventListeners.set(instance, [
@ -207,6 +209,7 @@ export class ClineProvider
() => instance.off(RooCodeEventName.TaskInteractive, onTaskInteractive),
() => instance.off(RooCodeEventName.TaskResumable, onTaskResumable),
() => instance.off(RooCodeEventName.TaskIdle, onTaskIdle),
() => instance.off(RooCodeEventName.TaskUserMessage, onTaskUserMessage),
])
}
@ -1210,14 +1213,16 @@ export class ClineProvider
// OpenRouter
async handleOpenRouterCallback(code: string) {
let { apiConfiguration, currentApiConfigName } = await this.getState()
let { apiConfiguration, currentApiConfigName = "default" } = await this.getState()
let apiKey: string
try {
const baseUrl = apiConfiguration.openRouterBaseUrl || "https://openrouter.ai/api/v1"
// Extract the base domain for the auth endpoint
// Extract the base domain for the auth endpoint.
const baseUrlDomain = baseUrl.match(/^(https?:\/\/[^\/]+)/)?.[1] || "https://openrouter.ai"
const response = await axios.post(`${baseUrlDomain}/api/v1/auth/keys`, { code })
if (response.data && response.data.key) {
apiKey = response.data.key
} else {
@ -1227,6 +1232,7 @@ export class ClineProvider
this.log(
`Error exchanging code for API key: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
)
throw error
}
@ -1244,8 +1250,10 @@ export class ClineProvider
async handleGlamaCallback(code: string) {
let apiKey: string
try {
const response = await axios.post("https://glama.ai/api/gateway/v1/auth/exchange-code", { code })
if (response.data && response.data.apiKey) {
apiKey = response.data.apiKey
} else {
@ -1255,10 +1263,11 @@ export class ClineProvider
this.log(
`Error exchanging code for API key: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
)
throw error
}
const { apiConfiguration, currentApiConfigName } = await this.getState()
const { apiConfiguration, currentApiConfigName = "default" } = await this.getState()
const newConfiguration: ProviderSettings = {
...apiConfiguration,
@ -1273,7 +1282,7 @@ export class ClineProvider
// Requesty
async handleRequestyCallback(code: string) {
let { apiConfiguration, currentApiConfigName } = await this.getState()
let { apiConfiguration, currentApiConfigName = "default" } = await this.getState()
const newConfiguration: ProviderSettings = {
...apiConfiguration,
@ -1531,7 +1540,7 @@ export class ClineProvider
}
}
async getStateToPostToWebview() {
async getStateToPostToWebview(): Promise<ExtensionState> {
const {
apiConfiguration,
lastShownAnnouncementId,
@ -1621,6 +1630,7 @@ export class ClineProvider
remoteControlEnabled,
openRouterImageApiKey,
openRouterImageGenerationSelectedModel,
openRouterUseMiddleOutTransform,
} = await this.getState()
const telemetryKey = process.env.POSTHOG_API_KEY
@ -1658,6 +1668,7 @@ export class ClineProvider
: undefined,
clineMessages: this.getCurrentTask()?.clineMessages || [],
currentTaskTodos: this.getCurrentTask()?.todoList || [],
messageQueue: this.getCurrentTask()?.messageQueueService?.messages,
taskHistory: (taskHistory || [])
.filter((item: HistoryItem) => item.ts && item.task)
.sort((a: HistoryItem, b: HistoryItem) => b.ts - a.ts),
@ -1754,6 +1765,7 @@ export class ClineProvider
remoteControlEnabled,
openRouterImageApiKey,
openRouterImageGenerationSelectedModel,
openRouterUseMiddleOutTransform,
}
}
@ -1763,7 +1775,17 @@ export class ClineProvider
* https://www.eliostruyf.com/devhack-code-extension-storage-options/
*/
async getState() {
async getState(): Promise<
Omit<
ExtensionState,
| "clineMessages"
| "renderContext"
| "hasOpenedModeSelector"
| "version"
| "shouldShowAnnouncement"
| "hasSystemPromptOverride"
>
> {
const stateValues = this.contextProxy.getValues()
const customModes = await this.customModesManager.getCustomModes()
@ -1831,7 +1853,7 @@ export class ClineProvider
)
}
// Return the same structure as before
// Return the same structure as before.
return {
apiConfiguration: providerSettings,
lastShownAnnouncementId: stateValues.lastShownAnnouncementId,
@ -1855,7 +1877,7 @@ export class ClineProvider
allowedMaxCost: stateValues.allowedMaxCost,
autoCondenseContext: stateValues.autoCondenseContext ?? true,
autoCondenseContextPercent: stateValues.autoCondenseContextPercent ?? 100,
taskHistory: stateValues.taskHistory,
taskHistory: stateValues.taskHistory ?? [],
allowedCommands: stateValues.allowedCommands,
deniedCommands: stateValues.deniedCommands,
soundEnabled: stateValues.soundEnabled ?? false,
@ -1902,7 +1924,7 @@ export class ClineProvider
customModes,
maxOpenTabsContext: stateValues.maxOpenTabsContext ?? 20,
maxWorkspaceFiles: stateValues.maxWorkspaceFiles ?? 200,
openRouterUseMiddleOutTransform: stateValues.openRouterUseMiddleOutTransform ?? true,
openRouterUseMiddleOutTransform: stateValues.openRouterUseMiddleOutTransform,
browserToolEnabled: stateValues.browserToolEnabled ?? true,
telemetrySetting: stateValues.telemetrySetting || "unset",
showRooIgnoredFiles: stateValues.showRooIgnoredFiles ?? false,
@ -1916,7 +1938,6 @@ export class ClineProvider
sharingEnabled,
organizationAllowList,
organizationSettingsVersion,
// Explicitly add condensing settings
condensingApiConfigId: stateValues.condensingApiConfigId,
customCondensingPrompt: stateValues.customCondensingPrompt,
codebaseIndexModels: stateValues.codebaseIndexModels ?? EMBEDDING_MODEL_PROFILES,
@ -1936,12 +1957,9 @@ export class ClineProvider
codebaseIndexSearchMinScore: stateValues.codebaseIndexConfig?.codebaseIndexSearchMinScore,
},
profileThresholds: stateValues.profileThresholds ?? {},
// Add diagnostic message settings
includeDiagnosticMessages: stateValues.includeDiagnosticMessages ?? true,
maxDiagnosticMessages: stateValues.maxDiagnosticMessages ?? 50,
// Add includeTaskHistoryInEnhance setting
includeTaskHistoryInEnhance: stateValues.includeTaskHistoryInEnhance ?? true,
// Add remoteControlEnabled setting - get from cloud settings
remoteControlEnabled: (() => {
try {
const cloudSettings = CloudService.instance.getUserSettings()
@ -1953,7 +1971,6 @@ export class ClineProvider
return false
}
})(),
// Add image generation settings
openRouterImageApiKey: stateValues.openRouterImageApiKey,
openRouterImageGenerationSelectedModel: stateValues.openRouterImageGenerationSelectedModel,
}
@ -2390,12 +2407,12 @@ export class ClineProvider
// Provider Profiles
public async getProviderProfiles(): Promise<{ name: string; provider?: string }[]> {
const { listApiConfigMeta } = await this.getState()
const { listApiConfigMeta = [] } = await this.getState()
return listApiConfigMeta.map((profile) => ({ name: profile.name, provider: profile.apiProvider }))
}
public async getProviderProfile(): Promise<string> {
const { currentApiConfigName } = await this.getState()
const { currentApiConfigName = "default" } = await this.getState()
return currentApiConfigName
}
@ -2446,7 +2463,7 @@ export class ClineProvider
}
private async getTaskProperties(): Promise<DynamicAppProperties & TaskProperties> {
const { language, mode, apiConfiguration } = await this.getState()
const { language = "en", mode, apiConfiguration } = await this.getState()
const task = this.getCurrentTask()
const todoList = task?.todoList

View file

@ -23,7 +23,12 @@ import { Package } from "../../shared/package"
import { RouterName, toRouterName, ModelRecord } from "../../shared/api"
import { MessageEnhancer } from "./messageEnhancer"
import { checkoutDiffPayloadSchema, checkoutRestorePayloadSchema, WebviewMessage } from "../../shared/WebviewMessage"
import {
type WebviewMessage,
type EditQueuedMessagePayload,
checkoutDiffPayloadSchema,
checkoutRestorePayloadSchema,
} from "../../shared/WebviewMessage"
import { checkExistKey } from "../../shared/checkExistApiConfig"
import { experimentDefault } from "../../shared/experiments"
import { Terminal } from "../../integrations/terminal/Terminal"
@ -1412,7 +1417,7 @@ export const webviewMessageHandler = async (
const {
apiConfiguration,
customSupportPrompts,
listApiConfigMeta,
listApiConfigMeta = [],
enhancementApiConfigId,
includeTaskHistoryInEnhance,
} = state
@ -2671,5 +2676,26 @@ export const webviewMessageHandler = async (
vscode.window.showWarningMessage(t("common:mdm.info.organization_requires_auth"))
break
}
/**
* Chat Message Queue
*/
case "queueMessage": {
provider.getCurrentTask()?.messageQueueService.addMessage(message.text ?? "", message.images)
break
}
case "removeQueuedMessage": {
provider.getCurrentTask()?.messageQueueService.removeMessage(message.text ?? "")
break
}
case "editQueuedMessage": {
if (message.payload) {
const { id, text, images } = message.payload as EditQueuedMessagePayload
provider.getCurrentTask()?.messageQueueService.updateMessage(id, text, images)
}
break
}
}
}

View file

@ -12,6 +12,7 @@ import type {
CloudUserInfo,
OrganizationAllowList,
ShareVisibility,
QueuedMessage,
} from "@roo-code/types"
import { GitCommit } from "../utils/git"
@ -196,6 +197,7 @@ export interface ExtensionMessage {
messageTs?: number
context?: string
commands?: Command[]
queuedMessages?: QueuedMessage[]
}
export type ExtensionState = Pick<
@ -219,8 +221,10 @@ export type ExtensionState = Pick<
| "alwaysAllowMcp"
| "alwaysAllowModeSwitch"
| "alwaysAllowSubtasks"
| "alwaysAllowFollowupQuestions"
| "alwaysAllowExecute"
| "alwaysAllowUpdateTodoList"
| "followupAutoApproveTimeoutMs"
| "allowedCommands"
| "deniedCommands"
| "allowedMaxRequests"
@ -229,6 +233,7 @@ export type ExtensionState = Pick<
| "browserViewportSize"
| "screenshotQuality"
| "remoteBrowserEnabled"
| "cachedChromeHostUrl"
| "remoteBrowserHost"
// | "enableCheckpoints" // Optional in GlobalSettings, required here.
| "ttsEnabled"
@ -274,12 +279,13 @@ export type ExtensionState = Pick<
| "maxDiagnosticMessages"
| "remoteControlEnabled"
| "openRouterImageGenerationSelectedModel"
| "includeTaskHistoryInEnhance"
> & {
version: string
clineMessages: ClineMessage[]
currentTaskItem?: HistoryItem
currentTaskTodos?: TodoItem[] // Initial todos for the current task
apiConfiguration?: ProviderSettings
apiConfiguration: ProviderSettings
uriScheme?: string
shouldShowAnnouncement: boolean
@ -328,6 +334,13 @@ export type ExtensionState = Pick<
profileThresholds: Record<string, number>
hasOpenedModeSelector: boolean
openRouterImageApiKey?: string
openRouterUseMiddleOutTransform?: boolean
messageQueue?: QueuedMessage[]
lastShownAnnouncementId?: string
apiModelId?: string
mcpServers?: McpServer[]
hasSystemPromptOverride?: boolean
mdmCompliant?: boolean
}
export interface ClineSayTool {

View file

@ -7,6 +7,7 @@ import {
type InstallMarketplaceItemOptions,
type MarketplaceItem,
type ShareVisibility,
type QueuedMessage,
marketplaceItemSchema,
} from "@roo-code/types"
@ -22,6 +23,8 @@ export interface UpdateTodoListPayload {
todos: any[]
}
export type EditQueuedMessagePayload = Pick<QueuedMessage, "id" | "text" | "images">
export interface WebviewMessage {
type:
| "updateTodoList"
@ -215,6 +218,9 @@ export interface WebviewMessage {
| "imageGenerationSettings"
| "openRouterImageApiKey"
| "openRouterImageGenerationSelectedModel"
| "queueMessage"
| "removeQueuedMessage"
| "editQueuedMessage"
text?: string
editedMessageContent?: string
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud"
@ -330,3 +336,4 @@ export type WebViewMessagePayload =
| IndexClearedPayload
| InstallMarketplaceItemWithParametersPayload
| UpdateTodoListPayload
| EditQueuedMessagePayload

View file

@ -23,6 +23,7 @@ import { getApiMetrics } from "@roo/getApiMetrics"
import { AudioType } from "@roo/WebviewMessage"
import { getAllModes } from "@roo/modes"
import { ProfileValidator } from "@roo/ProfileValidator"
import { getLatestTodo } from "@roo/todo"
import { vscode } from "@src/utils/vscode"
import {
@ -54,9 +55,7 @@ import AutoApproveMenu from "./AutoApproveMenu"
import SystemPromptWarning from "./SystemPromptWarning"
import ProfileViolationWarning from "./ProfileViolationWarning"
import { CheckpointWarning } from "./CheckpointWarning"
import QueuedMessages from "./QueuedMessages"
import { getLatestTodo } from "@roo/todo"
import { QueuedMessage } from "@roo-code/types"
import { QueuedMessages } from "./QueuedMessages"
export interface ChatViewProps {
isHidden: boolean
@ -121,6 +120,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
soundEnabled,
soundVolume,
cloudIsAuthenticated,
messageQueue = [],
} = useExtensionState()
const messagesRef = useRef(messages)
@ -174,10 +174,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
const textAreaRef = useRef<HTMLTextAreaElement>(null)
const [sendingDisabled, setSendingDisabled] = useState(false)
const [selectedImages, setSelectedImages] = useState<string[]>([])
const [messageQueue, setMessageQueue] = useState<QueuedMessage[]>([])
const isProcessingQueueRef = useRef(false)
const retryCountRef = useRef<Map<string, number>>(new Map())
const MAX_RETRY_ATTEMPTS = 3
// we need to hold on to the ask because useEffect > lastMessage will always let us know when an ask comes in and handle it, but by the time handleMessage is called, the last message might not be the ask anymore (it could be a say that followed)
const [clineAsk, setClineAsk] = useState<ClineAsk | undefined>(undefined)
@ -470,11 +466,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
}
// Reset user response flag for new task
userRespondedRef.current = false
// Clear message queue when starting a new task
setMessageQueue([])
// Clear retry counts
retryCountRef.current.clear()
}, [task?.ts])
useEffect(() => {
@ -586,129 +577,71 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
* Handles sending messages to the extension
* @param text - The message text to send
* @param images - Array of image data URLs to send with the message
* @param fromQueue - Internal flag indicating if this message is being sent from the queue (prevents re-queueing)
*/
const handleSendMessage = useCallback(
(text: string, images: string[], fromQueue = false) => {
try {
text = text.trim()
(text: string, images: string[]) => {
text = text.trim()
if (text || images.length > 0) {
if (sendingDisabled && !fromQueue) {
// Generate a more unique ID using timestamp + random component
const messageId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
setMessageQueue((prev: QueuedMessage[]) => [...prev, { id: messageId, text, images }])
if (text || images.length > 0) {
if (sendingDisabled) {
try {
console.log("queueMessage", text, images)
vscode.postMessage({ type: "queueMessage", text, images })
setInputValue("")
setSelectedImages([])
return
}
// Mark that user has responded - this prevents any pending auto-approvals
userRespondedRef.current = true
if (messagesRef.current.length === 0) {
vscode.postMessage({ type: "newTask", text, images })
} else if (clineAskRef.current) {
if (clineAskRef.current === "followup") {
markFollowUpAsAnswered()
}
// Use clineAskRef.current
switch (
clineAskRef.current // Use clineAskRef.current
) {
case "followup":
case "tool":
case "browser_action_launch":
case "command": // User can provide feedback to a tool or command use.
case "command_output": // User can send input to command stdin.
case "use_mcp_server":
case "completion_result": // If this happens then the user has feedback for the completion result.
case "resume_task":
case "resume_completed_task":
case "mistake_limit_reached":
vscode.postMessage({
type: "askResponse",
askResponse: "messageResponse",
text,
images,
})
break
// There is no other case that a textfield should be enabled.
}
} else {
// This is a new message in an ongoing task.
vscode.postMessage({ type: "askResponse", askResponse: "messageResponse", text, images })
} catch (error) {
console.error(
`Failed to queue message: ${error instanceof Error ? error.message : String(error)}`,
)
}
handleChatReset()
return
}
} catch (error) {
console.error("Error in handleSendMessage:", error)
// If this was a queued message, we should handle it differently
if (fromQueue) {
throw error // Re-throw to be caught by the queue processor
// Mark that user has responded - this prevents any pending auto-approvals.
userRespondedRef.current = true
if (messagesRef.current.length === 0) {
vscode.postMessage({ type: "newTask", text, images })
} else if (clineAskRef.current) {
if (clineAskRef.current === "followup") {
markFollowUpAsAnswered()
}
// Use clineAskRef.current
switch (
clineAskRef.current // Use clineAskRef.current
) {
case "followup":
case "tool":
case "browser_action_launch":
case "command": // User can provide feedback to a tool or command use.
case "command_output": // User can send input to command stdin.
case "use_mcp_server":
case "completion_result": // If this happens then the user has feedback for the completion result.
case "resume_task":
case "resume_completed_task":
case "mistake_limit_reached":
vscode.postMessage({
type: "askResponse",
askResponse: "messageResponse",
text,
images,
})
break
// There is no other case that a textfield should be enabled.
}
} else {
// This is a new message in an ongoing task.
vscode.postMessage({ type: "askResponse", askResponse: "messageResponse", text, images })
}
// For direct sends, we could show an error to the user
// but for now we'll just log it
handleChatReset()
}
},
[handleChatReset, markFollowUpAsAnswered, sendingDisabled], // messagesRef and clineAskRef are stable
)
useEffect(() => {
// Early return if conditions aren't met
// Also don't process queue if there's an API error (clineAsk === "api_req_failed")
if (
sendingDisabled ||
messageQueue.length === 0 ||
isProcessingQueueRef.current ||
clineAsk === "api_req_failed"
) {
return
}
// Mark as processing immediately to prevent race conditions
isProcessingQueueRef.current = true
// Process the first message in the queue
const [nextMessage, ...remaining] = messageQueue
// Update queue immediately to prevent duplicate processing
setMessageQueue(remaining)
// Process the message
Promise.resolve()
.then(() => {
handleSendMessage(nextMessage.text, nextMessage.images, true)
// Clear retry count on success
retryCountRef.current.delete(nextMessage.id)
})
.catch((error) => {
console.error("Failed to send queued message:", error)
// Get current retry count
const retryCount = retryCountRef.current.get(nextMessage.id) || 0
// Only re-add if under retry limit
if (retryCount < MAX_RETRY_ATTEMPTS) {
retryCountRef.current.set(nextMessage.id, retryCount + 1)
// Re-add the message to the end of the queue
setMessageQueue((current: QueuedMessage[]) => [...current, nextMessage])
} else {
console.error(`Message ${nextMessage.id} failed after ${MAX_RETRY_ATTEMPTS} attempts, discarding`)
retryCountRef.current.delete(nextMessage.id)
}
})
.finally(() => {
isProcessingQueueRef.current = false
})
// Cleanup function to handle component unmount
return () => {
isProcessingQueueRef.current = false
}
}, [sendingDisabled, messageQueue, handleSendMessage, clineAsk])
const handleSetChatBoxMessage = useCallback(
(text: string, images: string[]) => {
// Avoid nested template literals by breaking down the logic
@ -724,18 +657,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
[inputValue, selectedImages],
)
// Cleanup retry count map on unmount
useEffect(() => {
// Store refs in variables to avoid stale closure issues
const retryCountMap = retryCountRef.current
const isProcessingRef = isProcessingQueueRef
return () => {
retryCountMap.clear()
isProcessingRef.current = false
}
}, [])
const startNewTask = useCallback(() => vscode.postMessage({ type: "clearTask" }), [])
// This logic depends on the useEffect[messages] above to set clineAsk,
@ -2018,9 +1939,18 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
<QueuedMessages
queue={messageQueue}
onRemove={(index) => setMessageQueue((prev) => prev.filter((_, i) => i !== index))}
onRemove={(index) => {
if (messageQueue[index]) {
vscode.postMessage({ type: "removeQueuedMessage", text: messageQueue[index].id })
}
}}
onUpdate={(index, newText) => {
setMessageQueue((prev) => prev.map((msg, i) => (i === index ? { ...msg, text: newText } : msg)))
if (messageQueue[index]) {
vscode.postMessage({
type: "editQueuedMessage",
payload: { id: messageQueue[index].id, text: newText, images: messageQueue[index].images },
})
}
}}
/>
<ChatTextArea

View file

@ -1,17 +1,21 @@
import React, { useState } from "react"
import { useState } from "react"
import { useTranslation } from "react-i18next"
import Thumbnails from "../common/Thumbnails"
import { QueuedMessage } from "@roo-code/types"
import { Mention } from "./Mention"
import { Button } from "@src/components/ui"
import Thumbnails from "../common/Thumbnails"
import { Mention } from "./Mention"
interface QueuedMessagesProps {
queue: QueuedMessage[]
onRemove: (index: number) => void
onUpdate: (index: number, newText: string) => void
}
const QueuedMessages: React.FC<QueuedMessagesProps> = ({ queue, onRemove, onUpdate }) => {
export const QueuedMessages = ({ queue, onRemove, onUpdate }: QueuedMessagesProps) => {
const { t } = useTranslation("chat")
const [editingStates, setEditingStates] = useState<Record<string, { isEditing: boolean; value: string }>>({})
@ -108,5 +112,3 @@ const QueuedMessages: React.FC<QueuedMessagesProps> = ({ queue, onRemove, onUpda
</div>
)
}
export default QueuedMessages

View file

@ -97,22 +97,23 @@ vi.mock("@src/components/welcome/RooCloudCTA", () => ({
// Mock QueuedMessages component
vi.mock("../QueuedMessages", () => ({
default: function MockQueuedMessages({
messages = [],
onRemoveMessage,
QueuedMessages: function MockQueuedMessages({
queue = [],
onRemove,
}: {
messages?: Array<{ id: string; text: string; images?: string[] }>
onRemoveMessage?: (id: string) => void
queue?: Array<{ id: string; text: string; images?: string[] }>
onRemove?: (index: number) => void
onUpdate?: (index: number, newText: string) => void
}) {
if (!messages || messages.length === 0) {
if (!queue || queue.length === 0) {
return null
}
return (
<div data-testid="queued-messages">
{messages.map((msg) => (
{queue.map((msg, index) => (
<div key={msg.id}>
<span>{msg.text}</span>
<button aria-label="Remove message" onClick={() => onRemoveMessage?.(msg.id)}>
<button aria-label="Remove message" onClick={() => onRemove?.(index)}>
Remove
</button>
</div>

View file

@ -176,7 +176,8 @@ export const mergeExtensionState = (prevState: ExtensionState, newState: Extensi
}
export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [state, setState] = useState<ExtensionState & { organizationAllowList?: OrganizationAllowList }>({
const [state, setState] = useState<ExtensionState>({
apiConfiguration: {},
version: "",
clineMessages: [],
taskHistory: [],