diff --git a/evals/packages/types/src/roo-code.ts b/evals/packages/types/src/roo-code.ts index 0363c888b6..15488f9b88 100644 --- a/evals/packages/types/src/roo-code.ts +++ b/evals/packages/types/src/roo-code.ts @@ -143,6 +143,8 @@ export const historyItemSchema = z.object({ totalCost: z.number(), size: z.number().optional(), workspace: z.string().optional(), + lastActiveModeSlug: z.string().optional(), + }) export type HistoryItem = z.infer diff --git a/packages/types/src/history.ts b/packages/types/src/history.ts index 8c75024879..fae05283e8 100644 --- a/packages/types/src/history.ts +++ b/packages/types/src/history.ts @@ -16,6 +16,7 @@ export const historyItemSchema = z.object({ totalCost: z.number(), size: z.number().optional(), workspace: z.string().optional(), + lastActiveModeSlug: z.string().optional(), }) export type HistoryItem = z.infer diff --git a/src/core/task-persistence/taskMetadata.ts b/src/core/task-persistence/taskMetadata.ts index 8044acd8ba..75d60c4276 100644 --- a/src/core/task-persistence/taskMetadata.ts +++ b/src/core/task-persistence/taskMetadata.ts @@ -17,6 +17,7 @@ export type TaskMetadataOptions = { taskNumber: number globalStoragePath: string workspace: string + lastActiveModeSlug?: string } export async function taskMetadata({ @@ -25,6 +26,7 @@ export async function taskMetadata({ taskNumber, globalStoragePath, workspace, + lastActiveModeSlug, }: TaskMetadataOptions) { const taskDir = await getTaskDirectoryPath(globalStoragePath, taskId) const taskMessage = messages[0] // First message is always the task say. @@ -57,6 +59,7 @@ export async function taskMetadata({ totalCost: tokenUsage.totalCost, size: taskDirSize, workspace, + lastActiveModeSlug, } return { historyItem, tokenUsage } diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index ac3b1cb7d8..38913d001f 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -113,6 +113,7 @@ export type TaskOptions = { parentTask?: Task taskNumber?: number onCreated?: (cline: Task) => void + currentModeSlug: string } export class Task extends EventEmitter { @@ -123,7 +124,8 @@ export class Task extends EventEmitter { readonly parentTask: Task | undefined = undefined readonly taskNumber: number readonly workspacePath: string - + currentModeSlug: string + modeIsFrozen: boolean = false providerRef: WeakRef private readonly globalStoragePath: string abort: boolean = false @@ -205,6 +207,7 @@ export class Task extends EventEmitter { parentTask, taskNumber = -1, onCreated, + currentModeSlug, }: TaskOptions) { super() @@ -239,6 +242,7 @@ export class Task extends EventEmitter { this.globalStoragePath = provider.context.globalStorageUri.fsPath this.diffViewProvider = new DiffViewProvider(this.cwd) this.enableCheckpoints = enableCheckpoints + this.currentModeSlug = currentModeSlug this.rootTask = rootTask this.parentTask = parentTask @@ -294,6 +298,17 @@ export class Task extends EventEmitter { await this.saveApiConversationHistory() } + public async updateCurrentModeSlug(newModeSlug: string) { + if (this.modeIsFrozen) { + // If the mode is frozen, ignore any attempts to change it. + return // Crucial: Exit without updating the mode + } + // Only update if the new mode is actually different to avoid unnecessary assignments + if (this.currentModeSlug !== newModeSlug) { + this.currentModeSlug = newModeSlug + } + } + async overwriteApiConversationHistory(newHistory: ApiMessage[]) { this.apiConversationHistory = newHistory await this.saveApiConversationHistory() @@ -367,6 +382,7 @@ export class Task extends EventEmitter { taskNumber: this.taskNumber, globalStoragePath: this.globalStoragePath, workspace: this.cwd, + lastActiveModeSlug: this.currentModeSlug, }) this.emit("taskTokenUsageUpdated", this.taskId, tokenUsage) @@ -472,6 +488,14 @@ export class Task extends EventEmitter { await this.addToClineMessages({ ts: askTs, type: "ask", ask: type, text }) } + // Set modeIsFrozen if the task just completed + if ( + (type === "completion_result" || type === "resume_completed_task") && + (partial === false || partial === undefined) + ) { + this.modeIsFrozen = true + } + await pWaitFor(() => this.askResponse !== undefined || this.lastMessageTs !== askTs, { interval: 100 }) if (this.lastMessageTs !== askTs) { @@ -1069,6 +1093,20 @@ export class Task extends EventEmitter { throw new Error(`[RooCode#recursivelyMakeRooRequests] task ${this.taskId}.${this.instanceId} aborted`) } + // Reset modeIsFrozen when a new message is sent + if (this.modeIsFrozen) { + this.modeIsFrozen = false + // Get the actual current mode from the provider and update the task's mode + const provider = this.providerRef.deref() + if (provider) { + const providerState = await provider.getState() + if (providerState.mode !== this.currentModeSlug) { + this.currentModeSlug = providerState.mode + } + } + await this.saveClineMessages() // Save messages now reflects the unfreezing and potential mode update + } + if (this.consecutiveMistakeCount >= this.consecutiveMistakeLimit) { const { response, text, images } = await this.ask( "mistake_limit_reached", diff --git a/src/core/task/__tests__/Task.test.ts b/src/core/task/__tests__/Task.test.ts index 8ed57ffcb3..201444c3ca 100644 --- a/src/core/task/__tests__/Task.test.ts +++ b/src/core/task/__tests__/Task.test.ts @@ -277,6 +277,7 @@ describe("Cline", () => { const cline = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, + currentModeSlug: "code", fuzzyMatchThreshold: 0.95, task: "test task", startTask: false, @@ -289,6 +290,7 @@ describe("Cline", () => { const cline = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, + currentModeSlug: "code", enableDiff: true, fuzzyMatchThreshold: 0.95, task: "test task", @@ -303,7 +305,7 @@ describe("Cline", () => { it("should require either task or historyItem", () => { expect(() => { - new Task({ provider: mockProvider, apiConfiguration: mockApiConfig }) + new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, currentModeSlug: "code" }) }).toThrow("Either historyItem or task/images must be provided") }) }) @@ -315,6 +317,7 @@ describe("Cline", () => { const [cline, task] = Task.create({ provider: mockProvider, apiConfiguration: mockApiConfig, + currentModeSlug: "code", // Added for test task: "test task", }) @@ -423,6 +426,7 @@ describe("Cline", () => { const [clineWithImages, taskWithImages] = Task.create({ provider: mockProvider, apiConfiguration: configWithImages, + currentModeSlug: "code", // Added for test task: "test task", }) @@ -446,6 +450,7 @@ describe("Cline", () => { const [clineWithoutImages, taskWithoutImages] = Task.create({ provider: mockProvider, apiConfiguration: configWithoutImages, + currentModeSlug: "code", // Added for test task: "test task", }) @@ -537,6 +542,7 @@ describe("Cline", () => { const [cline, task] = Task.create({ provider: mockProvider, apiConfiguration: mockApiConfig, + currentModeSlug: "code", // Added for test task: "test task", }) @@ -662,6 +668,7 @@ describe("Cline", () => { const [cline, task] = Task.create({ provider: mockProvider, apiConfiguration: mockApiConfig, + currentModeSlug: "code", // Added for test task: "test task", }) @@ -787,6 +794,7 @@ describe("Cline", () => { const [cline, task] = Task.create({ provider: mockProvider, apiConfiguration: mockApiConfig, + currentModeSlug: "code", // Added for test task: "test task", }) diff --git a/src/core/tools/switchModeTool.ts b/src/core/tools/switchModeTool.ts index 8ce906b41f..772a1d4c4e 100644 --- a/src/core/tools/switchModeTool.ts +++ b/src/core/tools/switchModeTool.ts @@ -62,7 +62,17 @@ export async function switchModeTool( } // Switch the mode using shared handler - await cline.providerRef.deref()?.handleModeSwitch(mode_slug) + const provider = cline.providerRef.deref() + if (provider) { + await provider.handleModeSwitch(mode_slug) + // After provider's global mode is switched, update this Cline instance's currentModeSlug + await cline.updateCurrentModeSlug(mode_slug) + } else { + // Should not happen, but handle gracefully + cline.recordToolError("switch_mode") + pushToolResult(formatResponse.toolError("Failed to get provider reference for mode switch.")) + return + } pushToolResult( `Successfully switched from ${getModeBySlug(currentMode)?.name ?? currentMode} mode to ${ diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 539c6114c0..0483099f2f 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -192,6 +192,15 @@ export class ClineProvider } } + public async clearStack() { + this.log("Clearing entire cline stack") + while (this.clineStack.length > 0) { + // removeClineFromStack already logs the removal of each task + await this.removeClineFromStack() + } + this.log("Cline stack cleared") + } + // returns the current cline object in the stack (the top one) // if the stack is empty, returns undefined getCurrentCline(): Task | undefined { @@ -217,8 +226,17 @@ export class ClineProvider console.log(`[subtasks] finishing subtask ${lastMessage}`) // remove the last cline instance from the stack (this is the finished sub task) await this.removeClineFromStack() - // resume the last cline instance in the stack (if it exists - this is the 'parent' calling task) - await this.getCurrentCline()?.resumePausedTask(lastMessage) + + const parentCline = this.getCurrentCline() + if (parentCline) { + const parentLastActiveMode = parentCline.currentModeSlug // Changed from initialModeSlug + const currentActiveMode = (await this.getState()).mode + + if (parentLastActiveMode && parentLastActiveMode !== currentActiveMode) { + await this.handleModeSwitch(parentLastActiveMode) + } + await parentCline.resumePausedTask(lastMessage) + } } /* @@ -517,6 +535,7 @@ export class ClineProvider diffEnabled: enableDiff, enableCheckpoints, fuzzyMatchThreshold, + mode, experiments, } = await this.getState() @@ -537,6 +556,7 @@ export class ClineProvider parentTask, taskNumber: this.clineStack.length + 1, onCreated: (cline) => this.emit("clineCreated", cline), + currentModeSlug: mode, ...options, }) @@ -552,6 +572,17 @@ export class ClineProvider public async initClineWithHistoryItem(historyItem: HistoryItem & { rootTask?: Task; parentTask?: Task }) { await this.removeClineFromStack() + // Get current global mode first + let currentGlobalMode = (await this.getState()).mode + + const targetModeSlug = historyItem.lastActiveModeSlug ?? currentGlobalMode + + if (targetModeSlug !== currentGlobalMode) { + await this.handleModeSwitch(targetModeSlug) + // After switching, getState() will return the new active mode and its associated configs + } + + // Re-fetch state after potential mode switch to get correct apiConfig, prompts, etc. const { apiConfiguration, diffEnabled: enableDiff, @@ -571,6 +602,7 @@ export class ClineProvider rootTask: historyItem.rootTask, parentTask: historyItem.parentTask, taskNumber: historyItem.number, + currentModeSlug: targetModeSlug, // Pass the determined target mode slug onCreated: (cline) => this.emit("clineCreated", cline), }) @@ -773,10 +805,13 @@ export class ClineProvider * @param newMode The mode to switch to */ public async handleModeSwitch(newMode: Mode) { - const cline = this.getCurrentCline() + // Telemetry for mode switch (provider level) + const cline = this.getCurrentCline() // Get current cline for task ID if available + TelemetryService.instance.captureModeSwitch(cline?.taskId || "global", newMode) if (cline) { TelemetryService.instance.captureModeSwitch(cline.taskId, newMode) + await cline.updateCurrentModeSlug(newMode) cline.emit("taskModeSwitched", cline.taskId, newMode) } diff --git a/src/core/webview/__tests__/ClineProvider.test.ts b/src/core/webview/__tests__/ClineProvider.test.ts index bca291a48e..346ed1acb4 100644 --- a/src/core/webview/__tests__/ClineProvider.test.ts +++ b/src/core/webview/__tests__/ClineProvider.test.ts @@ -4,7 +4,7 @@ import Anthropic from "@anthropic-ai/sdk" import * as vscode from "vscode" import axios from "axios" -import { type ProviderSettingsEntry, type ClineMessage, ORGANIZATION_ALLOW_ALL } from "@roo-code/types" +import { type ProviderSettingsEntry, type ClineMessage, ORGANIZATION_ALLOW_ALL, HistoryItem } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" import { ExtensionMessage, ExtensionState } from "../../../shared/ExtensionMessage" @@ -195,11 +195,11 @@ jest.mock("../../../integrations/workspace/WorkspaceTracker", () => { })) }) -jest.mock("../../task/Task", () => ({ - Task: jest - .fn() - .mockImplementation( - (_provider, _apiConfiguration, _customInstructions, _diffEnabled, _fuzzyMatchThreshold, _task, taskId) => ({ +jest.mock("../../task/Task", () => { + return { + Task: jest.fn().mockImplementation((options: TaskOptions) => { + const generatedTaskId = `mock-task-${Date.now()}-${Math.random().toString(36).substring(7)}` + return { api: undefined, abortTask: jest.fn(), handleWebviewAskResponse: jest.fn(), @@ -207,14 +207,24 @@ jest.mock("../../task/Task", () => ({ apiConversationHistory: [], overwriteClineMessages: jest.fn(), overwriteApiConversationHistory: jest.fn(), - getTaskNumber: jest.fn().mockReturnValue(0), + getTaskNumber: jest.fn().mockReturnValue(options.taskNumber || 0), setTaskNumber: jest.fn(), setParentTask: jest.fn(), setRootTask: jest.fn(), - taskId: taskId || "test-task-id", - }), - ), -})) + taskId: options.historyItem?.id || generatedTaskId, // Use historyItem.id if available + currentModeSlug: options.currentModeSlug, + updateCurrentModeSlug: jest.fn(), + saveMessages: jest.fn().mockResolvedValue(options.historyItem?.id || "mock-history-item-id"), + getEditorState: jest.fn().mockReturnValue({ selections: [], visibleRanges: [] }), + emitContextMentions: jest.fn(), + initialPrompt: options.task, // This is the task string + historyItem: options.historyItem, // Keep track of historyItem if passed + emit: jest.fn(), // Add mock for emit + resumePausedTask: jest.fn().mockResolvedValue(undefined), // Add mock for resumePausedTask + } + }), + } +}) jest.mock("../../../integrations/misc/extract-text", () => ({ extractTextFromFile: jest.fn().mockImplementation(async (_filePath: string) => { @@ -316,6 +326,7 @@ describe("ClineProvider", () => { apiConfiguration: { apiProvider: "openrouter", }, + currentModeSlug: "code", // Added for test } // @ts-ignore - Access private property for testing @@ -1920,6 +1931,441 @@ describe("ClineProvider", () => { }) }) +describe("Task Mode Preservation on Resume", () => { + let provider: ClineProvider // Declare provider here + let mockContext: vscode.ExtensionContext // Declare mockContext here + let mockOutputChannel: vscode.OutputChannel + let mockWebviewView: vscode.WebviewView + let mockPostMessage: jest.Mock + + let mockGetGlobalState: jest.SpyInstance + let handleModeSwitchSpy: jest.SpyInstance, [newMode: string]> // Corrected spy type + + beforeEach(() => { + // Reset mocks + jest.clearAllMocks() + + // Mock context + const globalState: Record = { + mode: "architect", + currentApiConfigName: "current-config", + } + + const secrets: Record = {} + + mockContext = { + extensionPath: "/test/path", + extensionUri: {} as vscode.Uri, + globalState: { + get: jest.fn().mockImplementation((key: string) => globalState[key]), + update: jest + .fn() + .mockImplementation((key: string, value: string | undefined) => (globalState[key] = value)), + keys: jest.fn().mockImplementation(() => Object.keys(globalState)), + }, + secrets: { + get: jest.fn().mockImplementation((key: string) => secrets[key]), + store: jest.fn().mockImplementation((key: string, value: string | undefined) => (secrets[key] = value)), + delete: jest.fn().mockImplementation((key: string) => delete secrets[key]), + }, + subscriptions: [], + extension: { + packageJSON: { version: "1.0.0" }, + }, + globalStorageUri: { + fsPath: "/test/storage/path", + }, + } as unknown as vscode.ExtensionContext + + mockOutputChannel = { + appendLine: jest.fn(), + clear: jest.fn(), + dispose: jest.fn(), + } as unknown as vscode.OutputChannel + + mockPostMessage = jest.fn() + mockWebviewView = { + webview: { + postMessage: mockPostMessage, + html: "", + options: {}, + onDidReceiveMessage: jest.fn(), + asWebviewUri: jest.fn(), + }, + visible: true, + onDidDispose: jest.fn().mockImplementation((callback) => { + callback() + return { dispose: jest.fn() } + }), + onDidChangeVisibility: jest.fn().mockImplementation(() => ({ dispose: jest.fn() })), + } as unknown as vscode.WebviewView + + provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + // @ts-ignore - Accessing private property for testing. + provider.customModesManager = { + updateCustomMode: jest.fn().mockResolvedValue(undefined), + getCustomModes: jest.fn().mockResolvedValue([]), + dispose: jest.fn(), + } + // Ensure webview is resolved for postMessage to work if needed by underlying calls + provider.resolveWebviewView(mockWebviewView) + + mockGetGlobalState = jest.spyOn(mockContext.globalState, "get") + handleModeSwitchSpy = jest.spyOn(provider, "handleModeSwitch") + ;(Task as unknown as jest.Mock).mockClear() + }) + + afterEach(() => { + mockGetGlobalState.mockRestore() + handleModeSwitchSpy.mockRestore() + }) + + test("initClineWithHistoryItem should use lastActiveModeSlug from history item if present and different from current mode", async () => { + const initialProviderMode = "initial-provider-mode" + const historyItemMode = "history-item-mode" + mockGetGlobalState.mockImplementation((key: string) => { + // This mock will be called by provider.getState() which is used by handleModeSwitch and initClineWithHistoryItem + if (key === "mode") return initialProviderMode // Initial global mode before any switch + if (key === "currentApiConfigName") return "initial-config-name" + if (key === "listApiConfigMeta") + return [{ id: "initial-config-id", name: "initial-config-name", apiProvider: "test-initial" }] + if (key === `apiConfiguration_initial-config-id`) + return { apiProvider: "test-initial", id: "initial-config-id", name: "initial-config-name" } + // For the target historyItemMode + if (key === `apiConfigId_${historyItemMode}`) return "history-item-config-id" + if (key === `apiConfiguration_history-item-config-id`) + return { apiProvider: "test-history", id: "history-item-config-id", name: "history-item-config" } + return undefined + }) + // Set the provider to the initial state. + await provider.handleModeSwitch(initialProviderMode) + let stateBeforeInit = await provider.getState() + expect(stateBeforeInit.mode).toBe(initialProviderMode) + + const mockHistoryItem: HistoryItem = { + id: "hist-1", + number: 1, + ts: Date.now(), + task: "Test task", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + lastActiveModeSlug: historyItemMode, + } + + await provider.initClineWithHistoryItem(mockHistoryItem) + + expect(handleModeSwitchSpy).toHaveBeenCalledWith(historyItemMode) // Corrected: no second argument + + expect(Task).toHaveBeenCalledTimes(1) + const taskConstructorOptions = (Task as unknown as jest.Mock).mock.calls[0][0] as TaskOptions + expect(taskConstructorOptions.currentModeSlug).toBe(historyItemMode) + + const state = await provider.getState() + expect(state.mode).toBe(historyItemMode) + }) + + test("initClineWithHistoryItem should use provider's current mode if lastActiveModeSlug is missing", async () => { + const currentProviderMode = "current-provider-mode" + mockGetGlobalState.mockImplementation((key: string) => { + if (key === "mode") return currentProviderMode + if (key === "currentApiConfigName") return "provider-mode-config-name" + if (key === "listApiConfigMeta") + return [ + { id: "provider-mode-config-id", name: "provider-mode-config-name", apiProvider: "test-provider" }, + ] + if (key === `apiConfiguration_provider-mode-config-id`) + return { + apiProvider: "test-provider", + id: "provider-mode-config-id", + name: "provider-mode-config-name", + } + return undefined + }) + // Set the provider to the currentProviderMode + await provider.handleModeSwitch(currentProviderMode) + let stateBeforeInit = await provider.getState() + expect(stateBeforeInit.mode).toBe(currentProviderMode) + + const mockHistoryItem: HistoryItem = { + id: "hist-2", + number: 2, + ts: Date.now(), + task: "Test task without mode", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + } + + await provider.initClineWithHistoryItem(mockHistoryItem) + + const callsToHandleModeSwitch = handleModeSwitchSpy.mock.calls + const otherCalls = callsToHandleModeSwitch.filter((call) => call[0] !== currentProviderMode) + expect(otherCalls.length).toBe(0) + + expect(Task).toHaveBeenCalledTimes(1) + const taskConstructorOptions = (Task as unknown as jest.Mock).mock.calls[0][0] as TaskOptions + expect(taskConstructorOptions.currentModeSlug).toBe(currentProviderMode) + + const state = await provider.getState() + expect(state.mode).toBe(currentProviderMode) + }) + + test("initClineWithHistoryItem should use lastActiveModeSlug (no handleModeSwitch if same as current)", async () => { + const sameMode = "same-mode-for-all" + mockGetGlobalState.mockImplementation((key: string) => { + if (key === "mode") return sameMode + if (key === "currentApiConfigName") return "same-mode-config-name" + if (key === "listApiConfigMeta") + return [{ id: "same-mode-config-id", name: "same-mode-config-name", apiProvider: "test-same" }] + if (key === `apiConfiguration_same-mode-config-id`) + return { apiProvider: "test-same", id: "same-mode-config-id", name: "same-mode-config-name" } + return undefined + }) + // Set the provider to the sameMode + await provider.handleModeSwitch(sameMode) + let stateBeforeInit = await provider.getState() + expect(stateBeforeInit.mode).toBe(sameMode) + + const mockHistoryItem: HistoryItem = { + id: "hist-3", + number: 3, + ts: Date.now(), + task: "Test task with same mode", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + lastActiveModeSlug: sameMode, + } + + await provider.initClineWithHistoryItem(mockHistoryItem) + // If targetModeSlug is same as current, handleModeSwitch for mode *change* is not called. + // It might be called for config sync, but not with a *different* mode. + handleModeSwitchSpy.mock.calls.forEach((call) => { + expect(call[0]).toBe(sameMode) + }) + // More specific: it should not be called to *change* the mode. + // The plan: "If targetModeSlug differs from current active mode, calls this.handleModeSwitch(targetModeSlug)." + // This implies it's *not* called if they don't differ for the purpose of changing mode. + // If handleModeSwitch has side effects beyond mode string change (like API config update), it might still be called. + // For this test, we primarily care that the mode passed to Task is correct and provider state is correct. + + expect(Task).toHaveBeenCalledTimes(1) + const taskConstructorOptions = (Task as unknown as jest.Mock).mock.calls[0][0] as TaskOptions + expect(taskConstructorOptions.currentModeSlug).toBe(sameMode) + + const state = await provider.getState() + expect(state.mode).toBe(sameMode) + }) +}) + +describe("Task Mode Preservation with Subtasks", () => { + let provider: ClineProvider // Declare provider here + let mockContext: vscode.ExtensionContext // Declare mockContext here + let mockOutputChannel: vscode.OutputChannel + let mockWebviewView: vscode.WebviewView + let mockPostMessage: jest.Mock + + let handleModeSwitchSpy: jest.SpyInstance, [newMode: string]> // Corrected spy type + let parentTaskMock: any + let subTaskMock: any + + beforeEach(async () => { + // Reset mocks + jest.clearAllMocks() + + // Mock context + const globalState: Record = { + mode: "architect", // Initial global mode + currentApiConfigName: "architect-config", // Default config for architect + apiConfigId_architect: "architect-config-id", + "apiConfiguration_architect-config-id": JSON.stringify({ + apiProvider: "test-architect", + id: "architect-config-id", + name: "architect-config", + }), + "apiConfigId_parent-mode": "parent-config-id", + "apiConfiguration_parent-config-id": JSON.stringify({ + apiProvider: "test-parent", + id: "parent-config-id", + name: "parent-config", + }), + "apiConfigId_subtask-mode": "subtask-config-id", + "apiConfiguration_subtask-config-id": JSON.stringify({ + apiProvider: "test-subtask", + id: "subtask-config-id", + name: "subtask-config", + }), + "apiConfigId_same-mode-for-all": "same-config-id", + "apiConfiguration_same-config-id": JSON.stringify({ + apiProvider: "test-same", + id: "same-config-id", + name: "same-config", + }), + listApiConfigMeta: JSON.stringify([ + // Mock listApiConfigMeta for handleModeSwitch + { id: "architect-config-id", name: "architect-config", apiProvider: "test-architect" }, + { id: "parent-config-id", name: "parent-config", apiProvider: "test-parent" }, + { id: "subtask-config-id", name: "subtask-config", apiProvider: "test-subtask" }, + { id: "same-config-id", name: "same-config", apiProvider: "test-same" }, + ]), + } + const secrets: Record = {} + mockContext = { + extensionPath: "/test/path", + extensionUri: {} as vscode.Uri, + globalState: { + get: jest.fn().mockImplementation((key: string) => { + const value = globalState[key] + // Parse JSON for specific keys if they are expected to be objects/arrays + if (key.startsWith("apiConfiguration_") || key === "listApiConfigMeta") { + return value ? JSON.parse(value) : undefined + } + return value + }), + update: jest.fn().mockImplementation((key: string, value: any) => { + // Store objects/arrays as JSON strings + if (typeof value === "object") { + globalState[key] = JSON.stringify(value) + } else { + globalState[key] = value + } + }), + keys: jest.fn().mockImplementation(() => Object.keys(globalState)), + }, + secrets: { + get: jest.fn().mockImplementation((key: string) => secrets[key]), + store: jest.fn().mockImplementation((key: string, value: string | undefined) => (secrets[key] = value)), + delete: jest.fn().mockImplementation((key: string) => delete secrets[key]), + }, + subscriptions: [], + extension: { + packageJSON: { version: "1.0.0" }, + }, + globalStorageUri: { + fsPath: "/test/storage/path", + }, + } as unknown as vscode.ExtensionContext + + mockOutputChannel = { + appendLine: jest.fn(), + clear: jest.fn(), + dispose: jest.fn(), + } as unknown as vscode.OutputChannel + + mockPostMessage = jest.fn() + mockWebviewView = { + webview: { + postMessage: mockPostMessage, + html: "", + options: {}, + onDidReceiveMessage: jest.fn(), + asWebviewUri: jest.fn(), + }, + visible: true, + onDidDispose: jest.fn().mockImplementation((callback) => { + callback() + return { dispose: jest.fn() } + }), + onDidChangeVisibility: jest.fn().mockImplementation(() => ({ dispose: jest.fn() })), + } as unknown as vscode.WebviewView + + provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + // @ts-ignore - Accessing private property for testing. + provider.customModesManager = { + updateCustomMode: jest.fn().mockResolvedValue(undefined), + getCustomModes: jest.fn().mockResolvedValue([]), + dispose: jest.fn(), + } + await provider.resolveWebviewView(mockWebviewView) // Ensure webview is resolved + + handleModeSwitchSpy = jest.spyOn(provider, "handleModeSwitch") + ;(Task as unknown as jest.Mock).mockClear() + + const parentMode = "parent-mode" + // Set provider's initial state for the parent task by calling handleModeSwitch + await provider.handleModeSwitch(parentMode) + // Initialize parent task. initClineWithTask will use the provider's current state. + await provider.initClineWithTask("Parent task") // Corrected: Pass only task string + parentTaskMock = provider.getCurrentCline() + // The Task mock should have captured the currentModeSlug from the options passed by initClineWithTask + // (which internally gets it from provider.getState().mode) + expect(parentTaskMock.currentModeSlug).toBe(parentMode) + + const subTaskMode = "subtask-mode" + // Simulate provider mode changing for the subtask + await provider.handleModeSwitch(subTaskMode) // Corrected: Only one argument + let subTaskState = await provider.getState() // Get state after mode switch + expect(subTaskState.mode).toBe(subTaskMode) + + // Initialize subtask, passing the parentTaskMock + await provider.initClineWithTask("Sub task", undefined, parentTaskMock) // Corrected: Pass task string, undefined for images, then parentTask + subTaskMock = provider.getCurrentCline() + expect(subTaskMock.currentModeSlug).toBe(subTaskMode) + expect(provider.getClineStackSize()).toBe(2) + }) + + afterEach(() => { + handleModeSwitchSpy.mockRestore() + }) + + test("finishSubTask should restore parent task's mode if different from current provider mode", async () => { + let providerStateBeforeFinish = await provider.getState() + expect(providerStateBeforeFinish.mode).toBe("subtask-mode") + expect(parentTaskMock.currentModeSlug).toBe("parent-mode") + + await provider.finishSubTask("subtask-finished-message") + + expect(provider.getClineStackSize()).toBe(1) + expect(provider.getCurrentCline()).toBe(parentTaskMock) + // Check if handleModeSwitch was called to revert to parent's mode + expect(handleModeSwitchSpy).toHaveBeenCalledWith("parent-mode") + + const stateAfterFinish = await provider.getState() + expect(stateAfterFinish.mode).toBe("parent-mode") + }) + + test("finishSubTask should not call handleModeSwitch to change mode if parent's mode is same", async () => { + ;(Task as unknown as jest.Mock).mockClear() + handleModeSwitchSpy.mockClear() + + // Clear stack from tasks potentially created in the describe's beforeEach + await provider.clearStack() + expect(provider.getClineStackSize()).toBe(0) + + const sameMode = "same-mode-for-all" + await provider.handleModeSwitch(sameMode) // Set provider to sameMode + + // Initialize parent task in sameMode + await provider.initClineWithTask("Parent task same") + parentTaskMock = provider.getCurrentCline() + expect(provider.getClineStackSize()).toBe(1) // After parent task + + // Initialize subtask, provider is already in sameMode + await provider.initClineWithTask("Sub task same", undefined, parentTaskMock) + subTaskMock = provider.getCurrentCline() + expect(provider.getClineStackSize()).toBe(2) // After subtask + + let providerStateBeforeFinish = await provider.getState() + expect(providerStateBeforeFinish.mode).toBe(sameMode) + expect(parentTaskMock.currentModeSlug).toBe(sameMode) + + await provider.finishSubTask("subtask-finished-same-mode") + + expect(provider.getClineStackSize()).toBe(1) + expect(provider.getCurrentCline()).toBe(parentTaskMock) + + // handleModeSwitch should not be called to *change* mode from sameMode + const callsToChangeMode = handleModeSwitchSpy.mock.calls.filter( + (call) => call[0] !== sameMode && call[0] !== undefined, + ) + expect(callsToChangeMode.length).toBe(0) + + const stateAfterFinish = await provider.getState() + expect(stateAfterFinish.mode).toBe(sameMode) + }) +}) + describe("Project MCP Settings", () => { let provider: ClineProvider let mockContext: vscode.ExtensionContext @@ -2133,6 +2579,7 @@ describe("getTelemetryProperties", () => { apiConfiguration: { apiProvider: "openrouter", }, + currentModeSlug: "code", // Added for test } // Setup Task instance with mocked getModel method diff --git a/webview-ui/src/components/history/HistoryPreview.tsx b/webview-ui/src/components/history/HistoryPreview.tsx index 080cbff3e2..061fa9371f 100644 --- a/webview-ui/src/components/history/HistoryPreview.tsx +++ b/webview-ui/src/components/history/HistoryPreview.tsx @@ -2,6 +2,9 @@ import { memo } from "react" import { vscode } from "@/utils/vscode" import { formatLargeNumber, formatDate } from "@/utils/format" +import { useExtensionState } from "@/context/ExtensionStateContext" +import { getModeBySlug } from "../../../../src/shared/modes" +import { ModeConfig } from "@roo-code/types" import { CopyButton } from "./CopyButton" import { useTaskSearch } from "./useTaskSearch" @@ -10,54 +13,74 @@ import { Coins } from "lucide-react" const HistoryPreview = () => { const { tasks, showAllWorkspaces } = useTaskSearch() + const { customModes } = useExtensionState() return ( <>
{tasks.length !== 0 && ( <> - {tasks.slice(0, 3).map((item) => ( -
vscode.postMessage({ type: "showTaskWithId", text: item.id })}> -
-
- - {formatDate(item.ts)} - - -
-
- {item.task} -
-
- ↑ {formatLargeNumber(item.tokensIn || 0)} - ↓ {formatLargeNumber(item.tokensOut || 0)} - {!!item.totalCost && ( - - {" "} - {"$" + item.totalCost?.toFixed(2)} + {tasks.slice(0, 3).map((item) => { + const taskTitle = item.task + return ( +
vscode.postMessage({ type: "showTaskWithId", text: item.id })}> +
+
+ + {formatDate(item.ts)} + {/* CopyButton should copy the original task text, not the prefixed one */} + +
+
+ {taskTitle} +
+
+ {item.lastActiveModeSlug && + (() => { + const mode = getModeBySlug(item.lastActiveModeSlug, customModes) as + | ModeConfig + | undefined + if (mode?.name) { + return ( + + {mode.name} + + ) + } + return null + })()} + +
+ ↑ {formatLargeNumber(item.tokensIn || 0)} + ↓ {formatLargeNumber(item.tokensOut || 0)} + {!!item.totalCost && ( + + {" "} + {"$" + item.totalCost?.toFixed(2)} + + )} +
+
+ {showAllWorkspaces && item.workspace && ( +
+ + {item.workspace} +
)}
- {showAllWorkspaces && item.workspace && ( -
- - {item.workspace} -
- )}
-
- ))} + ) + })} )}
diff --git a/webview-ui/src/components/history/HistoryView.tsx b/webview-ui/src/components/history/HistoryView.tsx index bcb86a8d13..6f6e9482ac 100644 --- a/webview-ui/src/components/history/HistoryView.tsx +++ b/webview-ui/src/components/history/HistoryView.tsx @@ -8,6 +8,10 @@ import { VSCodeTextField, VSCodeRadioGroup, VSCodeRadio } from "@vscode/webview- import { vscode } from "@/utils/vscode" import { formatLargeNumber, formatDate } from "@/utils/format" +import { useExtensionState } from "@/context/ExtensionStateContext" +import { getModeBySlug } from "../../../../src/shared/modes" +import { ModeConfig } from "@roo-code/types" + import { cn } from "@/lib/utils" import { Button, Checkbox } from "@/components/ui" import { useAppTranslation } from "@/i18n/TranslationContext" @@ -35,6 +39,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => { setShowAllWorkspaces, } = useTaskSearch() const { t } = useAppTranslation() + const { customModes } = useExtensionState() // Added const [deleteTaskId, setDeleteTaskId] = useState(null) const [isSelectionMode, setIsSelectionMode] = useState(false) @@ -285,10 +290,25 @@ const HistoryView = ({ onDone }: HistoryViewProps) => { wordBreak: "break-word", overflowWrap: "anywhere", }} - data-testid="task-content" - dangerouslySetInnerHTML={{ __html: item.task }} - /> -
+ data-testid="task-content"> + +
+
+ {item.lastActiveModeSlug && + (() => { + const mode = getModeBySlug(item.lastActiveModeSlug, customModes) as + | ModeConfig + | undefined + if (mode?.name) { + return ( +
+ {mode.name} +
+ ) + } + return null + })()} +