Mode and provider profile selector (#7545)

This commit is contained in:
Chris Estreich 2025-08-29 16:48:06 -07:00 committed by Hannes Rudolph
parent 0126507e9b
commit 47855ed69f
10 changed files with 442 additions and 363 deletions

View file

@ -83,7 +83,7 @@ export abstract class BaseChannel<TCommand = unknown, TEventName extends string
/**
* Handle incoming commands - must be implemented by subclasses.
*/
public abstract handleCommand(command: TCommand): void
public abstract handleCommand(command: TCommand): Promise<void>
/**
* Handle connection-specific logic.

View file

@ -53,10 +53,7 @@ export class ExtensionChannel extends BaseChannel<
this.setupListeners()
}
/**
* Handle extension-specific commands from the web app
*/
public handleCommand(command: ExtensionBridgeCommand): void {
public async handleCommand(command: ExtensionBridgeCommand): Promise<void> {
if (command.instanceId !== this.instanceId) {
console.log(`[ExtensionChannel] command -> instance id mismatch | ${this.instanceId}`, {
messageInstanceId: command.instanceId,
@ -69,13 +66,22 @@ export class ExtensionChannel extends BaseChannel<
console.log(`[ExtensionChannel] command -> createTask() | ${command.instanceId}`, {
text: command.payload.text?.substring(0, 100) + "...",
hasImages: !!command.payload.images,
mode: command.payload.mode,
providerProfile: command.payload.providerProfile,
})
this.provider.createTask(command.payload.text, command.payload.images)
this.provider.createTask(
command.payload.text,
command.payload.images,
undefined, // parentTask
undefined, // options
{ mode: command.payload.mode, currentApiConfigName: command.payload.providerProfile },
)
break
}
case ExtensionBridgeCommandName.StopTask: {
const instance = this.updateInstance()
const instance = await this.updateInstance()
if (instance.task.taskStatus === TaskStatus.Running) {
console.log(`[ExtensionChannel] command -> cancelTask() | ${command.instanceId}`)
@ -86,6 +92,7 @@ export class ExtensionChannel extends BaseChannel<
this.provider.clearTask()
this.provider.postStateToWebview()
}
break
}
case ExtensionBridgeCommandName.ResumeTask: {
@ -93,7 +100,6 @@ export class ExtensionChannel extends BaseChannel<
taskId: command.payload.taskId,
})
// Resume the task from history by taskId
this.provider.resumeTask(command.payload.taskId)
this.provider.postStateToWebview()
break
@ -122,12 +128,12 @@ export class ExtensionChannel extends BaseChannel<
}
private async registerInstance(_socket: Socket): Promise<void> {
const instance = this.updateInstance()
const instance = await this.updateInstance()
await this.publish(ExtensionSocketEvents.REGISTER, instance)
}
private async unregisterInstance(_socket: Socket): Promise<void> {
const instance = this.updateInstance()
const instance = await this.updateInstance()
await this.publish(ExtensionSocketEvents.UNREGISTER, instance)
}
@ -135,7 +141,7 @@ export class ExtensionChannel extends BaseChannel<
this.stopHeartbeat()
this.heartbeatInterval = setInterval(async () => {
const instance = this.updateInstance()
const instance = await this.updateInstance()
try {
socket.emit(ExtensionSocketEvents.HEARTBEAT, instance)
@ -172,11 +178,11 @@ export class ExtensionChannel extends BaseChannel<
] as const
eventMapping.forEach(({ from, to }) => {
// Create and store the listener function for cleanup/
const listener = (..._args: unknown[]) => {
// Create and store the listener function for cleanup.
const listener = async (..._args: unknown[]) => {
this.publish(ExtensionSocketEvents.EVENT, {
type: to,
instance: this.updateInstance(),
instance: await this.updateInstance(),
timestamp: Date.now(),
})
}
@ -195,10 +201,16 @@ export class ExtensionChannel extends BaseChannel<
this.eventListeners.clear()
}
private updateInstance(): ExtensionInstance {
private async updateInstance(): Promise<ExtensionInstance> {
const task = this.provider?.getCurrentTask()
const taskHistory = this.provider?.getRecentTasks() ?? []
const mode = await this.provider?.getMode()
const modes = (await this.provider?.getModes()) ?? []
const providerProfile = await this.provider?.getProviderProfile()
const providerProfiles = (await this.provider?.getProviderProfiles()) ?? []
this.extensionInstance = {
...this.extensionInstance,
appProperties: this.extensionInstance.appProperties ?? this.provider.appProperties,
@ -213,6 +225,10 @@ export class ExtensionChannel extends BaseChannel<
: { taskId: "", taskStatus: TaskStatus.None },
taskAsk: task?.taskAsk,
taskHistory,
mode,
providerProfile,
modes,
providerProfiles,
}
return this.extensionInstance

View file

@ -73,7 +73,7 @@ export class TaskChannel extends BaseChannel<
super(instanceId)
}
public handleCommand(command: TaskBridgeCommand): void {
public async handleCommand(command: TaskBridgeCommand): Promise<void> {
const task = this.subscribedTasks.get(command.taskId)
if (!task) {
@ -87,7 +87,14 @@ export class TaskChannel extends BaseChannel<
`[TaskChannel] ${TaskBridgeCommandName.Message} ${command.taskId} -> submitUserMessage()`,
command,
)
task.submitUserMessage(command.payload.text, command.payload.images)
await task.submitUserMessage(
command.payload.text,
command.payload.images,
command.payload.mode,
command.payload.providerProfile,
)
break
case TaskBridgeCommandName.ApproveAsk:
@ -95,6 +102,7 @@ export class TaskChannel extends BaseChannel<
`[TaskChannel] ${TaskBridgeCommandName.ApproveAsk} ${command.taskId} -> approveAsk()`,
command,
)
task.approveAsk(command.payload)
break

View file

@ -53,6 +53,13 @@ describe("ExtensionChannel", () => {
postStateToWebview: vi.fn(),
postMessageToWebview: vi.fn(),
getTelemetryProperties: vi.fn(),
getMode: vi.fn().mockResolvedValue("code"),
getModes: vi.fn().mockResolvedValue([
{ slug: "code", name: "Code", description: "Code mode" },
{ slug: "architect", name: "Architect", description: "Architect mode" },
]),
getProviderProfile: vi.fn().mockResolvedValue("default"),
getProviderProfiles: vi.fn().mockResolvedValue([{ name: "default", description: "Default profile" }]),
on: vi.fn((event: keyof TaskProviderEvents, listener: (...args: unknown[]) => unknown) => {
if (!eventListeners.has(event)) {
eventListeners.set(event, new Set())
@ -184,6 +191,9 @@ describe("ExtensionChannel", () => {
// Connect the socket to enable publishing
await extensionChannel.onConnect(mockSocket)
// Clear the mock calls from the connection (which emits a register event)
;(mockSocket.emit as any).mockClear()
// Get a listener that was registered for TaskStarted
const taskStartedListeners = eventListeners.get(RooCodeEventName.TaskStarted)
expect(taskStartedListeners).toBeDefined()
@ -192,7 +202,7 @@ describe("ExtensionChannel", () => {
// Trigger the listener
const listener = Array.from(taskStartedListeners!)[0]
if (listener) {
listener("test-task-id")
await listener("test-task-id")
}
// Verify the event was published to the socket

View file

@ -333,7 +333,12 @@ describe("TaskChannel", () => {
taskChannel.handleCommand(command)
expect(mockTask.submitUserMessage).toHaveBeenCalledWith(command.payload.text, command.payload.images)
expect(mockTask.submitUserMessage).toHaveBeenCalledWith(
command.payload.text,
command.payload.images,
undefined,
undefined,
)
})
it("should handle ApproveAsk command", () => {

View file

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

View file

@ -378,6 +378,10 @@ export const extensionInstanceSchema = z.object({
task: extensionTaskSchema,
taskAsk: clineMessageSchema.optional(),
taskHistory: z.array(z.string()),
mode: z.string().optional(),
modes: z.array(z.object({ slug: z.string(), name: z.string() })).optional(),
providerProfile: z.string().optional(),
providerProfiles: z.array(z.object({ name: z.string(), provider: z.string().optional() })).optional(),
})
export type ExtensionInstance = z.infer<typeof extensionInstanceSchema>
@ -398,6 +402,9 @@ export enum ExtensionBridgeEventName {
TaskResumable = RooCodeEventName.TaskResumable,
TaskIdle = RooCodeEventName.TaskIdle,
ModeChanged = RooCodeEventName.ModeChanged,
ProviderProfileChanged = RooCodeEventName.ProviderProfileChanged,
InstanceRegistered = "instance_registered",
InstanceUnregistered = "instance_unregistered",
HeartbeatUpdated = "heartbeat_updated",
@ -469,6 +476,18 @@ 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>
@ -490,6 +509,8 @@ export const extensionBridgeCommandSchema = z.discriminatedUnion("type", [
payload: z.object({
text: z.string(),
images: z.array(z.string()).optional(),
mode: z.string().optional(),
providerProfile: z.string().optional(),
}),
timestamp: z.number(),
}),
@ -502,9 +523,7 @@ export const extensionBridgeCommandSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal(ExtensionBridgeCommandName.ResumeTask),
instanceId: z.string(),
payload: z.object({
taskId: z.string(),
}),
payload: z.object({ taskId: z.string() }),
timestamp: z.number(),
}),
])
@ -558,6 +577,8 @@ export const taskBridgeCommandSchema = z.discriminatedUnion("type", [
payload: z.object({
text: z.string(),
images: z.array(z.string()).optional(),
mode: z.string().optional(),
providerProfile: z.string().optional(),
}),
timestamp: z.number(),
}),

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, QueuedMessage, TokenUsage } from "./message.js"
import type { ClineMessage, 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,6 +59,8 @@ 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]
@ -68,21 +70,14 @@ export type TaskProviderEvents = {
[RooCodeEventName.TaskInteractive]: [taskId: string]
[RooCodeEventName.TaskResumable]: [taskId: string]
[RooCodeEventName.TaskIdle]: [taskId: string]
[RooCodeEventName.TaskPaused]: [taskId: string]
[RooCodeEventName.TaskUnpaused]: [taskId: string]
[RooCodeEventName.TaskSpawned]: [taskId: string]
[RooCodeEventName.TaskUserMessage]: [taskId: string]
[RooCodeEventName.TaskTokenUsageUpdated]: [taskId: string, tokenUsage: TokenUsage]
[RooCodeEventName.ModeChanged]: [mode: string]
[RooCodeEventName.ProviderProfileChanged]: [config: { name: string; provider?: string }]
}
/**
* TaskLike
*/
* TaskLike
*/
export interface CreateTaskOptions {
enableDiff?: boolean
@ -110,14 +105,11 @@ export type TaskMetadata = z.infer<typeof taskMetadataSchema>
export interface TaskLike {
readonly taskId: string
readonly rootTaskId?: string
readonly parentTaskId?: string
readonly childTaskId?: string
readonly metadata: TaskMetadata
readonly taskStatus: TaskStatus
readonly taskAsk: ClineMessage | undefined
readonly queuedMessages: QueuedMessage[]
readonly tokenUsage: TokenUsage | undefined
readonly metadata: TaskMetadata
readonly rootTask?: TaskLike
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,15 +133,14 @@ export type TaskEvents = {
[RooCodeEventName.TaskIdle]: [taskId: string]
// Subtask Lifecycle
[RooCodeEventName.TaskPaused]: [taskId: string]
[RooCodeEventName.TaskUnpaused]: [taskId: string]
[RooCodeEventName.TaskPaused]: []
[RooCodeEventName.TaskUnpaused]: []
[RooCodeEventName.TaskSpawned]: [taskId: string]
// Task Execution
[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

@ -10,6 +10,7 @@ import pWaitFor from "p-wait-for"
import { serializeError } from "serialize-error"
import {
type RooCodeSettings,
type TaskLike,
type TaskMetadata,
type TaskEvents,
@ -23,6 +24,7 @@ import {
type ClineAsk,
type ToolProgressStatus,
type HistoryItem,
type CreateTaskOptions,
RooCodeEventName,
TelemetryEventName,
TaskStatus,
@ -111,7 +113,7 @@ const DEFAULT_USAGE_COLLECTION_TIMEOUT_MS = 5000 // 5 seconds
const FORCED_CONTEXT_REDUCTION_PERCENT = 75 // Keep 75% of context (remove 25%) on context window errors
const MAX_CONTEXT_WINDOW_RETRIES = 3 // Maximum retries for context window errors
export type TaskOptions = {
export interface TaskOptions extends CreateTaskOptions {
provider: ClineProvider
apiConfiguration: ProviderSettings
enableDiff?: boolean
@ -847,7 +849,12 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
this.handleWebviewAskResponse("noButtonClicked", text, images)
}
public submitUserMessage(text: string, images?: string[]): void {
public async submitUserMessage(
text: string,
images?: string[],
mode?: string,
providerProfile?: string,
): Promise<void> {
try {
text = (text ?? "").trim()
images = images ?? []
@ -859,6 +866,14 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
const provider = this.providerRef.deref()
if (provider) {
if (mode) {
await provider.setMode(mode)
}
if (providerProfile) {
await provider.setProviderProfile(providerProfile)
}
provider.postMessageToWebview({ type: "invoke", invoke: "sendMessage", text, images })
} else {
console.error("[Task#submitUserMessage] Provider reference lost")

View file

@ -30,6 +30,7 @@ import {
type TerminalActionPromptType,
type HistoryItem,
type CloudUserInfo,
type CreateTaskOptions,
RooCodeEventName,
requestyDefaultModelId,
openRouterDefaultModelId,
@ -37,9 +38,10 @@ import {
DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT,
DEFAULT_WRITE_DELAY_MS,
ORGANIZATION_ALLOW_ALL,
DEFAULT_MODES,
} from "@roo-code/types"
import { TelemetryService } from "@roo-code/telemetry"
import { CloudService, BridgeOrchestrator, getRooCodeApiUrl } from "@roo-code/cloud"
import { CloudService, getRooCodeApiUrl } from "@roo-code/cloud"
import { Package } from "../../shared/package"
import { findLast } from "../../shared/array"
@ -70,6 +72,7 @@ import { fileExistsAtPath } from "../../utils/fs"
import { setTtsEnabled, setTtsSpeed } from "../../utils/tts"
import { getWorkspaceGitInfo } from "../../utils/git"
import { getWorkspacePath } from "../../utils/path"
import { OrganizationAllowListViolationError } from "../../utils/errors"
import { setPanel } from "../../activate/registerCommands"
@ -81,7 +84,7 @@ import { forceFullModelDetailsLoad, hasLoadedFullDetails } from "../../api/provi
import { ContextProxy } from "../config/ContextProxy"
import { ProviderSettingsManager } from "../config/ProviderSettingsManager"
import { CustomModesManager } from "../config/CustomModesManager"
import { Task, TaskOptions } from "../task/Task"
import { Task } from "../task/Task"
import { getSystemPromptFilePath } from "../prompts/sections/custom-system-prompt"
import { webviewMessageHandler } from "./webviewMessageHandler"
@ -266,27 +269,29 @@ export class ClineProvider
}
/**
* Synchronize cloud profiles with local profiles
* Synchronize cloud profiles with local profiles.
*/
private async syncCloudProfiles() {
try {
const settings = CloudService.instance.getOrganizationSettings()
if (!settings?.providerProfiles) {
return
}
const currentApiConfigName = this.getGlobalState("currentApiConfigName")
const result = await this.providerSettingsManager.syncCloudProfiles(
settings.providerProfiles,
currentApiConfigName,
)
if (result.hasChanges) {
// Update list
// Update list.
await this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig())
if (result.activeProfileChanged && result.activeProfileId) {
// Reload full settings for new active profile
// Reload full settings for new active profile.
const profile = await this.providerSettingsManager.getProfile({
id: result.activeProfileId,
})
@ -376,17 +381,6 @@ export class ClineProvider
}
}
// returns the current cline object in the stack (the top one)
// if the stack is empty, returns undefined
getCurrentTask(): Task | undefined {
if (this.clineStack.length === 0) {
return undefined
}
return this.clineStack[this.clineStack.length - 1]
}
// returns the current clineStack length (how many cline objects are in the stack)
getTaskStackSize(): number {
return this.clineStack.length
}
@ -409,58 +403,6 @@ export class ClineProvider
await this.getCurrentTask()?.resumePausedTask(lastMessage)
}
resumeTask(taskId: string): void {
// Use the existing showTaskWithId method which handles both current and historical tasks
this.showTaskWithId(taskId).catch((error) => {
this.log(`Failed to resume task ${taskId}: ${error.message}`)
})
}
getRecentTasks(): string[] {
if (this.recentTasksCache) {
return this.recentTasksCache
}
const history = this.getGlobalState("taskHistory") ?? []
const workspaceTasks: HistoryItem[] = []
for (const item of history) {
if (!item.ts || !item.task || item.workspace !== this.cwd) {
continue
}
workspaceTasks.push(item)
}
if (workspaceTasks.length === 0) {
this.recentTasksCache = []
return this.recentTasksCache
}
workspaceTasks.sort((a, b) => b.ts - a.ts)
let recentTaskIds: string[] = []
if (workspaceTasks.length >= 100) {
// If we have at least 100 tasks, return tasks from the last 7 days.
const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000
for (const item of workspaceTasks) {
// Stop when we hit tasks older than 7 days.
if (item.ts < sevenDaysAgo) {
break
}
recentTaskIds.push(item.id)
}
} else {
// Otherwise, return the most recent 100 tasks (or all if less than 100).
recentTaskIds = workspaceTasks.slice(0, Math.min(100, workspaceTasks.length)).map((item) => item.id)
}
this.recentTasksCache = recentTaskIds
return this.recentTasksCache
}
/*
VSCode extensions use the disposable pattern to clean up resources when the sidebar/editor tab is closed by the user or system. This applies to event listening, commands, interacting with the UI, etc.
- https://vscode-docs.readthedocs.io/en/stable/extensions/patterns-and-principles/
@ -741,84 +683,19 @@ export class ClineProvider
await this.removeClineFromStack()
}
// When initializing a new task, (not from history but from a tool command
// new_task) there is no need to remove the previous task since the new
// task is a subtask of the previous one, and when it finishes it is removed
// from the stack and the caller is resumed in this way we can have a chain
// of tasks, each one being a sub task of the previous one until the main
// task is finished.
public async createTask(
text?: string,
images?: string[],
parentTask?: Task,
options: Partial<
Pick<
TaskOptions,
| "enableDiff"
| "enableCheckpoints"
| "fuzzyMatchThreshold"
| "consecutiveMistakeLimit"
| "experiments"
| "initialTodos"
>
> = {},
) {
const {
apiConfiguration,
organizationAllowList,
diffEnabled: enableDiff,
enableCheckpoints,
fuzzyMatchThreshold,
experiments,
cloudUserInfo,
remoteControlEnabled,
} = await this.getState()
if (!ProfileValidator.isProfileAllowed(apiConfiguration, organizationAllowList)) {
throw new OrganizationAllowListViolationError(t("common:errors.violated_organization_allowlist"))
}
const task = new Task({
provider: this,
apiConfiguration,
enableDiff,
enableCheckpoints,
fuzzyMatchThreshold,
consecutiveMistakeLimit: apiConfiguration.consecutiveMistakeLimit,
task: text,
images,
experiments,
rootTask: this.clineStack.length > 0 ? this.clineStack[0] : undefined,
parentTask,
taskNumber: this.clineStack.length + 1,
onCreated: this.taskCreationCallback,
enableBridge: BridgeOrchestrator.isEnabled(cloudUserInfo, remoteControlEnabled),
initialTodos: options.initialTodos,
...options,
})
await this.addClineToStack(task)
this.log(
`[createTask] ${task.parentTask ? "child" : "parent"} task ${task.taskId}.${task.instanceId} instantiated`,
)
return task
}
public async createTaskWithHistoryItem(
public async createTaskWithHistoryItem(
historyItem: HistoryItem & { rootTask?: Task; parentTask?: Task; preservedFCOState?: any },
) {
await this.removeClineFromStack()
// If the history item has a saved mode, restore it and its associated API configuration
// If the history item has a saved mode, restore it and its associated API configuration.
if (historyItem.mode) {
// Validate that the mode still exists
const customModes = await this.customModesManager.getCustomModes()
const modeExists = getModeBySlug(historyItem.mode, customModes) !== undefined
if (!modeExists) {
// Mode no longer exists, fall back to default mode
// Mode no longer exists, fall back to default mode.
this.log(
`Mode '${historyItem.mode}' from history no longer exists. Falling back to default mode '${defaultModeSlug}'.`,
)
@ -827,14 +704,14 @@ export class ClineProvider
await this.updateGlobalState("mode", historyItem.mode)
// Load the saved API config for the restored mode if it exists
// Load the saved API config for the restored mode if it exists.
const savedConfigId = await this.providerSettingsManager.getModeConfigId(historyItem.mode)
const listApiConfig = await this.providerSettingsManager.listConfig()
// Update listApiConfigMeta first to ensure UI has latest data
// Update listApiConfigMeta first to ensure UI has latest data.
await this.updateGlobalState("listApiConfigMeta", listApiConfig)
// If this mode has a saved config, use it
// If this mode has a saved config, use it.
if (savedConfigId) {
const profile = listApiConfig.find(({ id }) => id === savedConfigId)
@ -842,13 +719,13 @@ export class ClineProvider
try {
await this.activateProviderProfile({ name: profile.name })
} catch (error) {
// Log the error but continue with task restoration
// Log the error but continue with task restoration.
this.log(
`Failed to restore API configuration for mode '${historyItem.mode}': ${
error instanceof Error ? error.message : String(error)
}. Continuing with default configuration.`,
)
// The task will continue with the current/default configuration
// The task will continue with the current/default configuration.
}
}
}
@ -877,7 +754,7 @@ export class ClineProvider
parentTask: historyItem.parentTask,
taskNumber: historyItem.number,
onCreated: this.taskCreationCallback,
enableBridge: BridgeOrchestrator.isEnabled(cloudUserInfo, remoteControlEnabled),
enableBridge: false, // BridgeOrchestrator removed in main
})
await this.addClineToStack(task)
@ -1127,39 +1004,39 @@ export class ClineProvider
TelemetryService.instance.captureModeSwitch(cline.taskId, newMode)
cline.emit(RooCodeEventName.TaskModeSwitched, cline.taskId, newMode)
// Store the current mode in case we need to rollback
const previousMode = (cline as any)._taskMode
try {
// Update the task history with the new mode first
// Update the task history with the new mode first.
const history = this.getGlobalState("taskHistory") ?? []
const taskHistoryItem = history.find((item) => item.id === cline.taskId)
if (taskHistoryItem) {
taskHistoryItem.mode = newMode
await this.updateTaskHistory(taskHistoryItem)
}
// Only update the task's mode after successful persistence
// Only update the task's mode after successful persistence.
;(cline as any)._taskMode = newMode
} catch (error) {
// If persistence fails, log the error but don't update the in-memory state
// If persistence fails, log the error but don't update the in-memory state.
this.log(
`Failed to persist mode switch for task ${cline.taskId}: ${error instanceof Error ? error.message : String(error)}`,
)
// Optionally, we could emit an event to notify about the failure
// This ensures the in-memory state remains consistent with persisted state
// Optionally, we could emit an event to notify about the failure.
// This ensures the in-memory state remains consistent with persisted state.
throw error
}
}
await this.updateGlobalState("mode", newMode)
// Load the saved API config for the new mode if it exists
this.emit(RooCodeEventName.ModeChanged, newMode)
// Load the saved API config for the new mode if it exists.
const savedConfigId = await this.providerSettingsManager.getModeConfigId(newMode)
const listApiConfig = await this.providerSettingsManager.listConfig()
// Update listApiConfigMeta first to ensure UI has latest data
// Update listApiConfigMeta first to ensure UI has latest data.
await this.updateGlobalState("listApiConfigMeta", listApiConfig)
// If this mode has a saved config, use it.
@ -1302,73 +1179,75 @@ export class ClineProvider
}
await this.postStateToWebview()
if (providerSettings.apiProvider) {
this.emit(RooCodeEventName.ProviderProfileChanged, { name, provider: providerSettings.apiProvider })
}
}
public async cancelTask(): Promise<void> {
const cline = this.getCurrentTask()
if (!cline) {
return
}
// Task Management
console.log(`[cancelTask] cancelling task ${cline.taskId}.${cline.instanceId}`)
async cancelTask() {
const cline = this.getCurrentTask()
const { historyItem } = await this.getTaskWithId(cline.taskId)
// Preserve parent and root task information for history item.
const rootTask = cline.rootTask
const parentTask = cline.parentTask
if (!cline) {
return
// Preserve FCO state before aborting task to prevent FCO from disappearing
let preservedFCOState: any = undefined
try {
const fileChangeManager = this.getFileChangeManager()
if (fileChangeManager) {
preservedFCOState = fileChangeManager.getChanges()
this.log(`[cancelTask] Preserved FCO state with ${preservedFCOState.files.length} files`)
}
console.log(`[cancelTask] cancelling task ${cline.taskId}.${cline.instanceId}`)
const { historyItem } = await this.getTaskWithId(cline.taskId)
// Preserve parent and root task information for history item.
const rootTask = cline.rootTask
const parentTask = cline.parentTask
// Preserve FCO state before aborting task to prevent FCO from disappearing
let preservedFCOState: any = undefined
try {
const fileChangeManager = this.getFileChangeManager()
if (fileChangeManager) {
preservedFCOState = fileChangeManager.getChanges()
this.log(`[cancelTask] Preserved FCO state with ${preservedFCOState.files.length} files`)
}
} catch (error) {
this.log(`[cancelTask] Failed to preserve FCO state: ${error}`)
}
cline.abortTask()
await pWaitFor(
() =>
this.getCurrentTask()! === undefined ||
this.getCurrentTask()!.isStreaming === false ||
this.getCurrentTask()!.didFinishAbortingStream ||
// If only the first chunk is processed, then there's no
// need to wait for graceful abort (closes edits, browser,
// etc).
this.getCurrentTask()!.isWaitingForFirstChunk,
{
timeout: 3_000,
},
).catch(() => {
console.error("Failed to abort task")
})
if (this.getCurrentTask()) {
// 'abandoned' will prevent this Cline instance from affecting
// future Cline instances. This may happen if its hanging on a
// streaming request.
this.getCurrentTask()!.abandoned = true
}
// Clears task again, so we need to abortTask manually above.
await this.createTaskWithHistoryItem({ ...historyItem, rootTask, parentTask, preservedFCOState })
} catch (error) {
this.log(`[cancelTask] Failed to preserve FCO state: ${error}`)
}
// Clear the current task without treating it as a subtask.
// This is used when the user cancels a task that is not a subtask.
async clearTask() {
if (this.clineStack.length > 0) {
const task = this.clineStack[this.clineStack.length - 1]
console.log(`[clearTask] clearing task ${task.taskId}.${task.instanceId}`)
await this.removeClineFromStack()
}
cline.abortTask()
await pWaitFor(
() =>
this.getCurrentTask()! === undefined ||
this.getCurrentTask()!.isStreaming === false ||
this.getCurrentTask()!.didFinishAbortingStream ||
// If only the first chunk is processed, then there's no
// need to wait for graceful abort (closes edits, browser,
// etc).
this.getCurrentTask()!.isWaitingForFirstChunk,
{
timeout: 3_000,
},
).catch(() => {
console.error("Failed to abort task")
})
if (this.getCurrentTask()) {
// 'abandoned' will prevent this Cline instance from affecting
// future Cline instances. This may happen if its hanging on a
// streaming request.
this.getCurrentTask()!.abandoned = true
}
// Clears task again, so we need to abortTask manually above.
await this.createTaskWithHistoryItem({ ...historyItem, rootTask, parentTask, preservedFCOState })
}
// Clear the current task without treating it as a subtask.
// This is used when the user cancels a task that is not a subtask.
public async clearTask(): Promise<void> {
if (this.clineStack.length > 0) {
const task = this.clineStack[this.clineStack.length - 1]
console.log(`[clearTask] clearing task ${task.taskId}.${task.instanceId}`)
await this.removeClineFromStack()
}
}
async updateCustomInstructions(instructions?: string) {
@ -2143,18 +2022,8 @@ export class ClineProvider
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()
return cloudSettings?.settings?.extensionBridgeEnabled ?? false
} catch (error) {
console.error(
`[getState] failed to get remote control setting from cloud: ${error instanceof Error ? error.message : String(error)}`,
)
return false
}
})(),
// Remote control functionality removed in main branch
remoteControlEnabled: false,
// Add image generation settings
openRouterImageApiKey: stateValues.openRouterImageApiKey,
openRouterImageGenerationSelectedModel: stateValues.openRouterImageGenerationSelectedModel,
@ -2205,12 +2074,6 @@ export class ClineProvider
await this.contextProxy.setValues(values)
}
// cwd
get cwd() {
return getWorkspacePath()
}
// dev
async resetState() {
@ -2285,43 +2148,252 @@ export class ClineProvider
return
}
await BridgeOrchestrator.connectOrDisconnect(userInfo, enabled, {
...config,
provider: this,
sessionId: vscode.env.sessionId,
})
// BridgeOrchestrator functionality removed in main branch
this.log(`[ClineProvider#remoteControlEnabled] Remote control ${enabled ? 'enabled' : 'disabled'}`)
}
const bridge = BridgeOrchestrator.getInstance()
/**
* Gets the CodeIndexManager for the current active workspace
* @returns CodeIndexManager instance for the current workspace or the default one
*/
public getCurrentWorkspaceCodeIndexManager(): CodeIndexManager | undefined {
return CodeIndexManager.getInstance(this.context)
}
if (bridge) {
const currentTask = this.getCurrentTask()
/**
* Updates the code index status subscription to listen to the current workspace manager
*/
private updateCodeIndexStatusSubscription(): void {
// Get the current workspace manager
const currentManager = this.getCurrentWorkspaceCodeIndexManager()
if (currentTask && !currentTask.enableBridge) {
try {
currentTask.enableBridge = true
await BridgeOrchestrator.subscribeToTask(currentTask)
} catch (error) {
const message = `[ClineProvider#remoteControlEnabled] BridgeOrchestrator.subscribeToTask() failed: ${error instanceof Error ? error.message : String(error)}`
this.log(message)
console.error(message)
}
}
} else {
for (const task of this.clineStack) {
if (task.enableBridge) {
try {
await BridgeOrchestrator.getInstance()?.unsubscribeFromTask(task.taskId)
} catch (error) {
const message = `[ClineProvider#remoteControlEnabled] BridgeOrchestrator#unsubscribeFromTask() failed: ${error instanceof Error ? error.message : String(error)}`
this.log(message)
console.error(message)
}
// If the manager hasn't changed, no need to update subscription
if (currentManager === this.currentWorkspaceManager) {
return
}
// Dispose the old subscription if it exists
if (this.codeIndexStatusSubscription) {
this.codeIndexStatusSubscription.dispose()
this.codeIndexStatusSubscription = undefined
}
// Update the current workspace manager reference
this.currentWorkspaceManager = currentManager
// Subscribe to the new manager's progress updates if it exists
if (currentManager) {
this.codeIndexStatusSubscription = currentManager.onProgressUpdate((update: IndexProgressUpdate) => {
// Only send updates if this manager is still the current one
if (currentManager === this.getCurrentWorkspaceCodeIndexManager()) {
// Get the full status from the manager to ensure we have all fields correctly formatted
const fullStatus = currentManager.getCurrentStatus()
this.postMessageToWebview({
type: "indexingStatusUpdate",
values: fullStatus,
})
}
})
if (this.view) {
this.webviewDisposables.push(this.codeIndexStatusSubscription)
}
// Send initial status for the current workspace
this.postMessageToWebview({
type: "indexingStatusUpdate",
values: currentManager.getCurrentStatus(),
})
}
}
/**
* TaskProviderLike, TelemetryPropertiesProvider
*/
public getCurrentTask(): Task | undefined {
if (this.clineStack.length === 0) {
return undefined
}
return this.clineStack[this.clineStack.length - 1]
}
public getRecentTasks(): string[] {
if (this.recentTasksCache) {
return this.recentTasksCache
}
const history = this.getGlobalState("taskHistory") ?? []
const workspaceTasks: HistoryItem[] = []
for (const item of history) {
if (!item.ts || !item.task || item.workspace !== this.cwd) {
continue
}
workspaceTasks.push(item)
}
if (workspaceTasks.length === 0) {
this.recentTasksCache = []
return this.recentTasksCache
}
workspaceTasks.sort((a, b) => b.ts - a.ts)
let recentTaskIds: string[] = []
if (workspaceTasks.length >= 100) {
// If we have at least 100 tasks, return tasks from the last 7 days.
const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000
for (const item of workspaceTasks) {
// Stop when we hit tasks older than 7 days.
if (item.ts < sevenDaysAgo) {
break
}
recentTaskIds.push(item.id)
}
} else {
// Otherwise, return the most recent 100 tasks (or all if less than 100).
recentTaskIds = workspaceTasks.slice(0, Math.min(100, workspaceTasks.length)).map((item) => item.id)
}
this.recentTasksCache = recentTaskIds
return this.recentTasksCache
}
// When initializing a new task, (not from history but from a tool command
// new_task) there is no need to remove the previous task since the new
// task is a subtask of the previous one, and when it finishes it is removed
// from the stack and the caller is resumed in this way we can have a chain
// of tasks, each one being a sub task of the previous one until the main
// task is finished.
public async createTask(
text?: string,
images?: string[],
parentTask?: Task,
options: CreateTaskOptions = {},
configuration: RooCodeSettings = {},
): Promise<Task> {
if (configuration) {
await this.setValues(configuration)
if (configuration.allowedCommands) {
await vscode.workspace
.getConfiguration(Package.name)
.update("allowedCommands", configuration.allowedCommands, vscode.ConfigurationTarget.Global)
}
if (configuration.deniedCommands) {
await vscode.workspace
.getConfiguration(Package.name)
.update("deniedCommands", configuration.deniedCommands, vscode.ConfigurationTarget.Global)
}
if (configuration.commandExecutionTimeout !== undefined) {
await vscode.workspace
.getConfiguration(Package.name)
.update(
"commandExecutionTimeout",
configuration.commandExecutionTimeout,
vscode.ConfigurationTarget.Global,
)
}
if (configuration.currentApiConfigName) {
await this.setProviderProfile(configuration.currentApiConfigName)
}
}
const {
apiConfiguration,
organizationAllowList,
diffEnabled: enableDiff,
enableCheckpoints,
fuzzyMatchThreshold,
experiments,
cloudUserInfo,
remoteControlEnabled,
} = await this.getState()
if (!ProfileValidator.isProfileAllowed(apiConfiguration, organizationAllowList)) {
throw new OrganizationAllowListViolationError(t("common:errors.violated_organization_allowlist"))
}
const task = new Task({
provider: this,
apiConfiguration,
enableDiff,
enableCheckpoints,
fuzzyMatchThreshold,
consecutiveMistakeLimit: apiConfiguration.consecutiveMistakeLimit,
task: text,
images,
experiments,
rootTask: this.clineStack.length > 0 ? this.clineStack[0] : undefined,
parentTask,
taskNumber: this.clineStack.length + 1,
onCreated: this.taskCreationCallback,
enableBridge: false, // BridgeOrchestrator removed in main
initialTodos: options.initialTodos,
...options,
})
await this.addClineToStack(task)
this.log(
`[createTask] ${task.parentTask ? "child" : "parent"} task ${task.taskId}.${task.instanceId} instantiated`,
)
return task
}
public resumeTask(taskId: string): void {
// Use the existing showTaskWithId method which handles both current and
// historical tasks.
this.showTaskWithId(taskId).catch((error) => {
this.log(`Failed to resume task ${taskId}: ${error.message}`)
})
}
// Modes
public async getModes(): Promise<{ slug: string; name: string }[]> {
return DEFAULT_MODES.map((mode) => ({ slug: mode.slug, name: mode.name }))
}
public async getMode(): Promise<string> {
const { mode } = await this.getState()
return mode
}
public async setMode(mode: string): Promise<void> {
await this.setValues({ mode })
}
// Provider Profiles
public async getProviderProfiles(): Promise<{ name: string; provider?: string }[]> {
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()
return currentApiConfigName
}
public async setProviderProfile(name: string): Promise<void> {
await this.activateProviderProfile({ name })
}
// Telemetry
private _appProperties?: StaticAppProperties
private _gitProperties?: GitProperties
private getAppProperties(): StaticAppProperties {
if (!this._appProperties) {
@ -2388,8 +2460,6 @@ export class ClineProvider
}
}
private _gitProperties?: GitProperties
private async getGitProperties(): Promise<GitProperties> {
if (!this._gitProperties) {
this._gitProperties = await getWorkspaceGitInfo()
@ -2411,61 +2481,6 @@ export class ClineProvider
}
}
/**
* Gets the CodeIndexManager for the current active workspace
* @returns CodeIndexManager instance for the current workspace or the default one
*/
public getCurrentWorkspaceCodeIndexManager(): CodeIndexManager | undefined {
return CodeIndexManager.getInstance(this.context)
}
/**
* Updates the code index status subscription to listen to the current workspace manager
*/
private updateCodeIndexStatusSubscription(): void {
// Get the current workspace manager
const currentManager = this.getCurrentWorkspaceCodeIndexManager()
// If the manager hasn't changed, no need to update subscription
if (currentManager === this.currentWorkspaceManager) {
return
}
// Dispose the old subscription if it exists
if (this.codeIndexStatusSubscription) {
this.codeIndexStatusSubscription.dispose()
this.codeIndexStatusSubscription = undefined
}
// Update the current workspace manager reference
this.currentWorkspaceManager = currentManager
// Subscribe to the new manager's progress updates if it exists
if (currentManager) {
this.codeIndexStatusSubscription = currentManager.onProgressUpdate((update: IndexProgressUpdate) => {
// Only send updates if this manager is still the current one
if (currentManager === this.getCurrentWorkspaceCodeIndexManager()) {
// Get the full status from the manager to ensure we have all fields correctly formatted
const fullStatus = currentManager.getCurrentStatus()
this.postMessageToWebview({
type: "indexingStatusUpdate",
values: fullStatus,
})
}
})
if (this.view) {
this.webviewDisposables.push(this.codeIndexStatusSubscription)
}
// Send initial status for the current workspace
this.postMessageToWebview({
type: "indexingStatusUpdate",
values: currentManager.getCurrentStatus(),
})
}
}
public getFileChangeManager():
| import("../../services/file-changes/FileChangeManager").FileChangeManager
| undefined {
@ -2481,10 +2496,8 @@ export class ClineProvider
}
return this.globalFileChangeManager
}
}
class OrganizationAllowListViolationError extends Error {
constructor(message: string) {
super(message)
public get cwd() {
return getWorkspacePath()
}
}