diff --git a/packages/cloud/src/bridge/BaseChannel.ts b/packages/cloud/src/bridge/BaseChannel.ts index 45b9b525f6c..95db835d1f9 100644 --- a/packages/cloud/src/bridge/BaseChannel.ts +++ b/packages/cloud/src/bridge/BaseChannel.ts @@ -83,7 +83,7 @@ export abstract class BaseChannel /** * Handle connection-specific logic. diff --git a/packages/cloud/src/bridge/ExtensionChannel.ts b/packages/cloud/src/bridge/ExtensionChannel.ts index 99649f76f4c..b38e3b9a8b9 100644 --- a/packages/cloud/src/bridge/ExtensionChannel.ts +++ b/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 { 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 { - const instance = this.updateInstance() + const instance = await this.updateInstance() await this.publish(ExtensionSocketEvents.REGISTER, instance) } private async unregisterInstance(_socket: Socket): Promise { - 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 { 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 diff --git a/packages/cloud/src/bridge/TaskChannel.ts b/packages/cloud/src/bridge/TaskChannel.ts index cf2a4a25161..f974a3e559b 100644 --- a/packages/cloud/src/bridge/TaskChannel.ts +++ b/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 { 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 diff --git a/packages/cloud/src/bridge/__tests__/ExtensionChannel.test.ts b/packages/cloud/src/bridge/__tests__/ExtensionChannel.test.ts index 89979c9a66e..7d25891840c 100644 --- a/packages/cloud/src/bridge/__tests__/ExtensionChannel.test.ts +++ b/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 diff --git a/packages/cloud/src/bridge/__tests__/TaskChannel.test.ts b/packages/cloud/src/bridge/__tests__/TaskChannel.test.ts index e69cb0ce3e8..4a6aa724684 100644 --- a/packages/cloud/src/bridge/__tests__/TaskChannel.test.ts +++ b/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", () => { diff --git a/packages/types/npm/package.metadata.json b/packages/types/npm/package.metadata.json index 1005327120e..f5ccde8888a 100644 --- a/packages/types/npm/package.metadata.json +++ b/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", diff --git a/packages/types/src/cloud.ts b/packages/types/src/cloud.ts index dbf79b6bfac..44dec96271e 100644 --- a/packages/types/src/cloud.ts +++ b/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 @@ -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 @@ -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(), }), diff --git a/packages/types/src/events.ts b/packages/types/src/events.ts index 2b6b810c810..590c2bdc885 100644 --- a/packages/types/src/events.ts +++ b/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 diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index 15833e00c4d..7e5ee3cc7e1 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -421,9 +421,11 @@ export const providerSettingsSchema = z.object({ export type ProviderSettings = z.infer 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 export const PROVIDER_SETTINGS_KEYS = providerSettingsSchema.keyof().options @@ -461,7 +463,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" } diff --git a/packages/types/src/task.ts b/packages/types/src/task.ts index c32ca0ceec5..b0058521195 100644 --- a/packages/types/src/task.ts +++ b/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 + createTask( + text?: string, + images?: string[], + parentTask?: TaskLike, + options?: CreateTaskOptions, + configuration?: RooCodeSettings, + ): Promise cancelTask(): Promise clearTask(): Promise resumeTask(taskId: string): void - getState(): Promise - postStateToWebview(): Promise - postMessageToWebview(message: unknown): Promise + // Modes + getModes(): Promise<{ slug: string; name: string }[]> + getMode(): Promise + setMode(mode: string): Promise + + // Provider Profiles + getProviderProfiles(): Promise<{ name: string; provider?: string }[]> + getProviderProfile(): Promise + setProviderProfile(providerProfile: string): Promise + // Telemetry + readonly appProperties: StaticAppProperties + readonly gitProperties: GitProperties | undefined getTelemetryProperties(): Promise + readonly cwd: string + // Event Emitter on( event: K, listener: (...args: TaskProviderEvents[K]) => void | Promise, @@ -42,6 +52,9 @@ export interface TaskProviderLike { event: K, listener: (...args: TaskProviderEvents[K]) => void | Promise, ): this + + // @TODO: Find a better way to do this. + postStateToWebview(): Promise } 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 + 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 abortTask(): void } diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 1a5092a375f..655983db20b 100644 --- a/src/core/task/Task.ts +++ b/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 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 { try { text = (text ?? "").trim() images = images ?? [] @@ -857,6 +864,14 @@ export class Task extends EventEmitter 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") diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index a7c59560e49..725a1d87b04 100644 --- a/src/core/webview/ClineProvider.ts +++ b/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 }) } } @@ -2129,12 +1955,6 @@ export class ClineProvider await this.contextProxy.setValues(values) } - // cwd - - get cwd() { - return getWorkspacePath() - } - // dev async resetState() { @@ -2245,7 +2065,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 { + 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 { + 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 { + 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 { + const { mode } = await this.getState() + return mode + } + + public async setMode(mode: string): Promise { + 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 { + const { currentApiConfigName } = await this.getState() + return currentApiConfigName + } + + public async setProviderProfile(name: string): Promise { + await this.activateProviderProfile({ name }) + } + + // Telemetry + private _appProperties?: StaticAppProperties + private _gitProperties?: GitProperties private getAppProperties(): StaticAppProperties { if (!this._appProperties) { @@ -2312,8 +2425,6 @@ export class ClineProvider } } - private _gitProperties?: GitProperties - private async getGitProperties(): Promise { if (!this._gitProperties) { this._gitProperties = await getWorkspaceGitInfo() @@ -2335,64 +2446,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() } } diff --git a/src/extension/api.ts b/src/extension/api.ts index 9c38aabfdbf..8af6d99052a 100644 --- a/src/extension/api.ts +++ b/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 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 { diff --git a/src/utils/errors.ts b/src/utils/errors.ts new file mode 100644 index 00000000000..546bc8311ce --- /dev/null +++ b/src/utils/errors.ts @@ -0,0 +1,5 @@ +export class OrganizationAllowListViolationError extends Error { + constructor(message: string) { + super(message) + } +}