Browse Source

Mode and provider profile selector (#7545)

Chris Estreich 4 months ago
parent
commit
20929b0f16

+ 1 - 1
packages/cloud/src/bridge/BaseChannel.ts

@@ -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.

+ 30 - 14
packages/cloud/src/bridge/ExtensionChannel.ts

@@ -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

+ 10 - 2
packages/cloud/src/bridge/TaskChannel.ts

@@ -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
 

+ 11 - 1
packages/cloud/src/bridge/__tests__/ExtensionChannel.test.ts

@@ -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

+ 6 - 1
packages/cloud/src/bridge/__tests__/TaskChannel.test.ts

@@ -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", () => {

+ 1 - 1
packages/types/npm/package.metadata.json

@@ -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",

+ 24 - 3
packages/types/src/cloud.ts

@@ -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(),
 	}),

+ 7 - 0
packages/types/src/events.ts

@@ -36,6 +36,10 @@ export enum RooCodeEventName {
 	TaskTokenUsageUpdated = "taskTokenUsageUpdated",
 	TaskToolFailed = "taskToolFailed",
 
+	// Configuration Changes
+	ModeChanged = "modeChanged",
+	ProviderProfileChanged = "providerProfileChanged",
+
 	// Evals
 	EvalPass = "evalPass",
 	EvalFail = "evalFail",
@@ -81,6 +85,9 @@ export const rooCodeEventsSchema = z.object({
 
 	[RooCodeEventName.TaskToolFailed]: z.tuple([z.string(), toolNamesSchema, z.string()]),
 	[RooCodeEventName.TaskTokenUsageUpdated]: z.tuple([z.string(), tokenUsageSchema]),
+
+	[RooCodeEventName.ModeChanged]: z.tuple([z.string()]),
+	[RooCodeEventName.ProviderProfileChanged]: z.tuple([z.object({ name: z.string(), provider: z.string() })]),
 })
 
 export type RooCodeEvents = z.infer<typeof rooCodeEventsSchema>

+ 3 - 1
packages/types/src/provider-settings.ts

@@ -414,9 +414,11 @@ export const providerSettingsSchema = z.object({
 export type ProviderSettings = z.infer<typeof providerSettingsSchema>
 
 export const providerSettingsWithIdSchema = providerSettingsSchema.extend({ id: z.string().optional() })
+
 export const discriminatedProviderSettingsWithIdSchema = providerSettingsSchemaDiscriminated.and(
 	z.object({ id: z.string().optional() }),
 )
+
 export type ProviderSettingsWithId = z.infer<typeof providerSettingsWithIdSchema>
 
 export const PROVIDER_SETTINGS_KEYS = providerSettingsSchema.keyof().options
@@ -454,7 +456,7 @@ export const getApiProtocol = (provider: ProviderName | undefined, modelId?: str
 		return "anthropic"
 	}
 
-	// Vercel AI Gateway uses anthropic protocol for anthropic models
+	// Vercel AI Gateway uses anthropic protocol for anthropic models.
 	if (provider && provider === "vercel-ai-gateway" && modelId && modelId.toLowerCase().startsWith("anthropic/")) {
 		return "anthropic"
 	}

+ 41 - 19
packages/types/src/task.ts

@@ -1,38 +1,48 @@
 import { z } from "zod"
 
 import { RooCodeEventName } from "./events.js"
-import { type ClineMessage, type TokenUsage } from "./message.js"
-import { type ToolUsage, type ToolName } from "./tool.js"
+import type { RooCodeSettings } from "./global-settings.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"
 
 /**
  * TaskProviderLike
  */
 
-export interface TaskProviderState {
-	mode?: string
-}
-
 export interface TaskProviderLike {
-	readonly cwd: string
-	readonly appProperties: StaticAppProperties
-	readonly gitProperties: GitProperties | undefined
-
+	// Tasks
 	getCurrentTask(): TaskLike | undefined
-	getCurrentTaskStack(): string[]
 	getRecentTasks(): string[]
-
-	createTask(text?: string, images?: string[], parentTask?: TaskLike): Promise<TaskLike>
+	createTask(
+		text?: string,
+		images?: string[],
+		parentTask?: TaskLike,
+		options?: CreateTaskOptions,
+		configuration?: RooCodeSettings,
+	): Promise<TaskLike>
 	cancelTask(): Promise<void>
 	clearTask(): Promise<void>
 	resumeTask(taskId: string): void
 
-	getState(): Promise<TaskProviderState>
-	postStateToWebview(): Promise<void>
-	postMessageToWebview(message: unknown): Promise<void>
+	// Modes
+	getModes(): Promise<{ slug: string; name: string }[]>
+	getMode(): Promise<string>
+	setMode(mode: string): Promise<void>
+
+	// Provider Profiles
+	getProviderProfiles(): Promise<{ name: string; provider?: string }[]>
+	getProviderProfile(): Promise<string>
+	setProviderProfile(providerProfile: string): Promise<void>
 
+	// Telemetry
+	readonly appProperties: StaticAppProperties
+	readonly gitProperties: GitProperties | undefined
 	getTelemetryProperties(): Promise<TelemetryProperties>
+	readonly cwd: string
 
+	// Event Emitter
 	on<K extends keyof TaskProviderEvents>(
 		event: K,
 		listener: (...args: TaskProviderEvents[K]) => void | Promise<void>,
@@ -42,6 +52,9 @@ export interface TaskProviderLike {
 		event: K,
 		listener: (...args: TaskProviderEvents[K]) => void | Promise<void>,
 	): this
+
+	// @TODO: Find a better way to do this.
+	postStateToWebview(): Promise<void>
 }
 
 export type TaskProviderEvents = {
@@ -57,15 +70,24 @@ export type TaskProviderEvents = {
 	[RooCodeEventName.TaskInteractive]: [taskId: string]
 	[RooCodeEventName.TaskResumable]: [taskId: string]
 	[RooCodeEventName.TaskIdle]: [taskId: string]
-
-	// Subtask Lifecycle
 	[RooCodeEventName.TaskSpawned]: [taskId: string]
+	[RooCodeEventName.ModeChanged]: [mode: string]
+	[RooCodeEventName.ProviderProfileChanged]: [config: { name: string; provider?: string }]
 }
 
 /**
  * TaskLike
  */
 
+export interface CreateTaskOptions {
+	enableDiff?: boolean
+	enableCheckpoints?: boolean
+	fuzzyMatchThreshold?: number
+	consecutiveMistakeLimit?: number
+	experiments?: Record<string, boolean>
+	initialTodos?: TodoItem[]
+}
+
 export enum TaskStatus {
 	Running = "running",
 	Interactive = "interactive",
@@ -94,7 +116,7 @@ export interface TaskLike {
 
 	approveAsk(options?: { text?: string; images?: string[] }): void
 	denyAsk(options?: { text?: string; images?: string[] }): void
-	submitUserMessage(text: string, images?: string[]): void
+	submitUserMessage(text: string, images?: string[], mode?: string, providerProfile?: string): Promise<void>
 	abortTask(): void
 }
 

+ 17 - 2
src/core/task/Task.ts

@@ -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,
@@ -110,7 +112,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
@@ -845,7 +847,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 ?? []
@@ -857,6 +864,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")

+ 323 - 269
src/core/webview/ClineProvider.ts

@@ -30,6 +30,7 @@ import {
 	type TerminalActionPromptType,
 	type HistoryItem,
 	type CloudUserInfo,
+	type CreateTaskOptions,
 	RooCodeEventName,
 	requestyDefaultModelId,
 	openRouterDefaultModelId,
@@ -37,6 +38,7 @@ 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"
@@ -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"
@@ -264,27 +267,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,
 					})
@@ -374,17 +379,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
 	}
@@ -407,58 +401,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/
@@ -737,82 +679,17 @@ 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(historyItem: HistoryItem & { rootTask?: Task; parentTask?: Task }) {
 		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}'.`,
 				)
@@ -821,14 +698,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)
 
@@ -836,13 +713,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.
 					}
 				}
 			}
@@ -1081,39 +958,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.
@@ -1256,60 +1133,9 @@ export class ClineProvider
 		}
 
 		await this.postStateToWebview()
-	}
-
-	// Task Management
-
-	async cancelTask() {
-		const cline = this.getCurrentTask()
 
-		if (!cline) {
-			return
-		}
-
-		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
-
-		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 })
-	}
-
-	// 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()
+		if (providerSettings.apiProvider) {
+			this.emit(RooCodeEventName.ProviderProfileChanged, { name, provider: providerSettings.apiProvider })
 		}
 	}
 
@@ -2146,12 +1972,6 @@ export class ClineProvider
 		await this.contextProxy.setValues(values)
 	}
 
-	// cwd
-
-	get cwd() {
-		return getWorkspacePath()
-	}
-
 	// dev
 
 	async resetState() {
@@ -2262,7 +2082,300 @@ 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(),
+			})
+		}
+	}
+
+	/**
+	 * 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: 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 cancelTask(): Promise<void> {
+		const cline = this.getCurrentTask()
+
+		if (!cline) {
+			return
+		}
+
+		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
+
+		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 })
+	}
+
+	// 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()
+		}
+	}
+
+	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) {
@@ -2329,8 +2442,6 @@ export class ClineProvider
 		}
 	}
 
-	private _gitProperties?: GitProperties
-
 	private async getGitProperties(): Promise<GitProperties> {
 		if (!this._gitProperties) {
 			this._gitProperties = await getWorkspaceGitInfo()
@@ -2352,64 +2463,7 @@ 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(),
-			})
-		}
-	}
-}
-
-class OrganizationAllowListViolationError extends Error {
-	constructor(message: string) {
-		super(message)
+	public get cwd() {
+		return getWorkspacePath()
 	}
 }

+ 7 - 30
src/extension/api.ts

@@ -11,6 +11,7 @@ import {
 	type ProviderSettings,
 	type ProviderSettingsEntry,
 	type TaskEvent,
+	type CreateTaskOptions,
 	RooCodeEventName,
 	TaskCommandName,
 	isSecretStateKey,
@@ -128,46 +129,22 @@ export class API extends EventEmitter<RooCodeEvents> implements RooCodeAPI {
 			provider = this.sidebarProvider
 		}
 
-		if (configuration) {
-			await provider.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,
-					)
-			}
-		}
-
 		await provider.removeClineFromStack()
 		await provider.postStateToWebview()
 		await provider.postMessageToWebview({ type: "action", action: "chatButtonClicked" })
 		await provider.postMessageToWebview({ type: "invoke", invoke: "newChat", text, images })
 
-		const cline = await provider.createTask(text, images, undefined, {
+		const options: CreateTaskOptions = {
 			consecutiveMistakeLimit: Number.MAX_SAFE_INTEGER,
-		})
+		}
+
+		const task = await provider.createTask(text, images, undefined, options, configuration)
 
-		if (!cline) {
+		if (!task) {
 			throw new Error("Failed to create task due to policy restrictions")
 		}
 
-		return cline.taskId
+		return task.taskId
 	}
 
 	public async resumeTask(taskId: string): Promise<void> {

+ 5 - 0
src/utils/errors.ts

@@ -0,0 +1,5 @@
+export class OrganizationAllowListViolationError extends Error {
+	constructor(message: string) {
+		super(message)
+	}
+}