From 4ecb5b04deade8465747cd6ef40f18603464f714 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Mon, 19 Jan 2026 20:22:53 -0700 Subject: [PATCH 1/5] perf(webview): avoid resending taskHistory in state updates --- packages/types/src/vscode-extension-host.ts | 11 +- src/core/task/Task.ts | 12 +- src/core/webview/ClineProvider.ts | 57 +- .../ClineProvider.taskHistory.spec.ts | 593 ++++++++++++++++++ .../src/context/ExtensionStateContext.tsx | 46 +- 5 files changed, 706 insertions(+), 13 deletions(-) create mode 100644 src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index b4e63f8775b..01610ab9b3a 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -29,6 +29,8 @@ export interface ExtensionMessage { type: | "action" | "state" + | "taskHistoryUpdated" + | "taskHistoryItemUpdated" | "selectedImages" | "theme" | "workspaceUpdated" @@ -114,7 +116,11 @@ export interface ExtensionMessage { | "switchTab" | "toggleAutoApprove" invoke?: "newChat" | "sendMessage" | "primaryButtonClick" | "secondaryButtonClick" | "setChatBoxMessage" - state?: ExtensionState + /** + * Partial state updates are allowed to reduce message size (e.g. omit large fields like taskHistory). + * The webview is responsible for merging. + */ + state?: Partial images?: string[] filePaths?: string[] openedTabs?: Array<{ @@ -194,6 +200,9 @@ export interface ExtensionMessage { childrenCost: number } historyItem?: HistoryItem + taskHistory?: HistoryItem[] // For taskHistoryUpdated: full sorted task history + /** For taskHistoryItemUpdated: single updated/added history item */ + taskHistoryItem?: HistoryItem } export interface OpenAiCodexRateLimitsMessage { diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 2ad2ca10b3c..38d8c02b6c7 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -590,7 +590,7 @@ export class Task extends EventEmitter implements TaskLike { this.messageQueueStateChangedHandler = () => { this.emit(RooCodeEventName.TaskUserMessage, this.taskId) - this.providerRef.deref()?.postStateToWebview() + this.providerRef.deref()?.postStateToWebviewWithoutTaskHistory() } this.messageQueueService.on("stateChanged", this.messageQueueStateChangedHandler) @@ -1137,7 +1137,9 @@ export class Task extends EventEmitter implements TaskLike { private async addToClineMessages(message: ClineMessage) { this.clineMessages.push(message) const provider = this.providerRef.deref() - await provider?.postStateToWebview() + // Avoid resending large, mostly-static fields (notably taskHistory) on every chat message update. + // taskHistory is maintained in-memory in the webview and updated via taskHistoryItemUpdated. + await provider?.postStateToWebviewWithoutTaskHistory() this.emit(RooCodeEventName.Message, { action: "created", message }) await this.saveClineMessages() @@ -1888,7 +1890,7 @@ export class Task extends EventEmitter implements TaskLike { // The todo list is already set in the constructor if initialTodos were provided // No need to add any messages - the todoList property is already set - await this.providerRef.deref()?.postStateToWebview() + await this.providerRef.deref()?.postStateToWebviewWithoutTaskHistory() await this.say("text", task, images) @@ -2678,7 +2680,7 @@ export class Task extends EventEmitter implements TaskLike { } satisfies ClineApiReqInfo) await this.saveClineMessages() - await this.providerRef.deref()?.postStateToWebview() + await this.providerRef.deref()?.postStateToWebviewWithoutTaskHistory() try { let cacheWriteTokens = 0 @@ -3446,7 +3448,7 @@ export class Task extends EventEmitter implements TaskLike { } await this.saveClineMessages() - await this.providerRef.deref()?.postStateToWebview() + await this.providerRef.deref()?.postStateToWebviewWithoutTaskHistory() // Reset parser after each complete conversation round (XML protocol only) this.assistantMessageParser?.reset() diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 33fa12ca78c..c12c4d3bffb 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1822,6 +1822,25 @@ export class ClineProvider } } + /** + * Like postStateToWebview but intentionally omits taskHistory. + * + * Rationale: + * - taskHistory can be large and was being resent on every chat message update. + * - The webview maintains taskHistory in-memory and receives updates via + * `taskHistoryUpdated` / `taskHistoryItemUpdated`. + */ + async postStateToWebviewWithoutTaskHistory(): Promise { + const state = await this.getStateToPostToWebview() + const { taskHistory: _omit, ...rest } = state + this.postMessageToWebview({ type: "state", state: rest }) + + // Preserve existing MDM redirect behavior + if (this.mdmService?.requiresCloudAuth() && !this.checkMdmCompliance()) { + await this.postMessageToWebview({ type: "action", action: "cloudButtonClicked" }) + } + } + /** * Fetches marketplace data on demand to avoid blocking main state updates */ @@ -2474,11 +2493,19 @@ export class ClineProvider } } - async updateTaskHistory(item: HistoryItem): Promise { + /** + * Updates a task in the task history and optionally broadcasts the updated history to the webview. + * @param item The history item to update or add + * @param options.broadcast Whether to broadcast the updated history to the webview (default: true) + * @returns The updated task history array + */ + async updateTaskHistory(item: HistoryItem, options: { broadcast?: boolean } = {}): Promise { + const { broadcast = true } = options const history = (this.getGlobalState("taskHistory") as HistoryItem[] | undefined) || [] const existingItemIndex = history.findIndex((h) => h.id === item.id) + const wasExisting = existingItemIndex !== -1 - if (existingItemIndex !== -1) { + if (wasExisting) { // Preserve existing metadata (e.g., delegation fields) unless explicitly overwritten. // This prevents loss of status/awaitingChildId/delegatedToId when tasks are reopened, // terminated, or when routine message persistence occurs. @@ -2493,9 +2520,35 @@ export class ClineProvider await this.updateGlobalState("taskHistory", history) this.recentTasksCache = undefined + // Broadcast the updated history to the webview if requested. + // Prefer per-item updates to avoid repeatedly cloning/sending the full history. + if (broadcast && this.isViewLaunched) { + const updatedItem = wasExisting ? history[existingItemIndex] : item + await this.postMessageToWebview({ type: "taskHistoryItemUpdated", taskHistoryItem: updatedItem }) + } + return history } + /** + * Broadcasts a task history update to the webview. + * This sends a lightweight message with just the task history, rather than the full state. + * @param history The task history to broadcast (if not provided, reads from global state) + */ + async broadcastTaskHistoryUpdate(history?: HistoryItem[]): Promise { + const taskHistory = history ?? (this.getGlobalState("taskHistory") as HistoryItem[] | undefined) ?? [] + + // Sort and filter the history the same way as getStateToPostToWebview + const sortedHistory = taskHistory + .filter((item: HistoryItem) => item.ts && item.task) + .sort((a: HistoryItem, b: HistoryItem) => b.ts - a.ts) + + await this.postMessageToWebview({ + type: "taskHistoryUpdated", + taskHistory: sortedHistory, + }) + } + // ContextProxy // @deprecated - Use `ContextProxy#setValue` instead. diff --git a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts new file mode 100644 index 00000000000..386ac0cd0e7 --- /dev/null +++ b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts @@ -0,0 +1,593 @@ +// pnpm --filter roo-cline test core/webview/__tests__/ClineProvider.taskHistory.spec.ts + +import * as vscode from "vscode" +import type { HistoryItem, ExtensionMessage } from "@roo-code/types" +import { TelemetryService } from "@roo-code/telemetry" + +import { ContextProxy } from "../../config/ContextProxy" +import { ClineProvider } from "../ClineProvider" + +// Mock setup +vi.mock("p-wait-for", () => ({ + __esModule: true, + default: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock("fs/promises", () => ({ + mkdir: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined), + readFile: vi.fn().mockResolvedValue(""), + unlink: vi.fn().mockResolvedValue(undefined), + rmdir: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock("axios", () => ({ + default: { + get: vi.fn().mockResolvedValue({ data: { data: [] } }), + post: vi.fn(), + }, + get: vi.fn().mockResolvedValue({ data: { data: [] } }), + post: vi.fn(), +})) + +vi.mock("delay", () => { + const delayFn = (_ms: number) => Promise.resolve() + delayFn.createDelay = () => delayFn + delayFn.reject = () => Promise.reject(new Error("Delay rejected")) + delayFn.range = () => Promise.resolve() + return { default: delayFn } +}) + +vi.mock("../../prompts/sections/custom-instructions") + +vi.mock("../../../utils/storage", () => ({ + getSettingsDirectoryPath: vi.fn().mockResolvedValue("/test/settings/path"), + getTaskDirectoryPath: vi.fn().mockResolvedValue("/test/task/path"), + getGlobalStoragePath: vi.fn().mockResolvedValue("/test/storage/path"), +})) + +vi.mock("@modelcontextprotocol/sdk/types.js", () => ({ + CallToolResultSchema: {}, + ListResourcesResultSchema: {}, + ListResourceTemplatesResultSchema: {}, + ListToolsResultSchema: {}, + ReadResourceResultSchema: {}, + ErrorCode: { + InvalidRequest: "InvalidRequest", + MethodNotFound: "MethodNotFound", + InternalError: "InternalError", + }, + McpError: class McpError extends Error { + code: string + constructor(code: string, message: string) { + super(message) + this.code = code + this.name = "McpError" + } + }, +})) + +vi.mock("../../../services/browser/BrowserSession", () => ({ + BrowserSession: vi.fn().mockImplementation(() => ({ + testConnection: vi.fn().mockResolvedValue({ success: false }), + })), +})) + +vi.mock("../../../services/browser/browserDiscovery", () => ({ + discoverChromeHostUrl: vi.fn().mockResolvedValue("http://localhost:9222"), + tryChromeHostUrl: vi.fn().mockResolvedValue(false), + testBrowserConnection: vi.fn(), +})) + +vi.mock("@modelcontextprotocol/sdk/client/index.js", () => ({ + Client: vi.fn().mockImplementation(() => ({ + connect: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + listTools: vi.fn().mockResolvedValue({ tools: [] }), + callTool: vi.fn().mockResolvedValue({ content: [] }), + })), +})) + +vi.mock("@modelcontextprotocol/sdk/client/stdio.js", () => ({ + StdioClientTransport: vi.fn().mockImplementation(() => ({ + connect: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + })), +})) + +vi.mock("vscode", () => ({ + ExtensionContext: vi.fn(), + OutputChannel: vi.fn(), + WebviewView: vi.fn(), + Uri: { + joinPath: vi.fn(), + file: vi.fn(), + }, + CodeActionKind: { + QuickFix: { value: "quickfix" }, + RefactorRewrite: { value: "refactor.rewrite" }, + }, + commands: { + executeCommand: vi.fn().mockResolvedValue(undefined), + }, + window: { + showInformationMessage: vi.fn(), + showWarningMessage: vi.fn(), + showErrorMessage: vi.fn(), + onDidChangeActiveTextEditor: vi.fn(() => ({ dispose: vi.fn() })), + }, + workspace: { + getConfiguration: vi.fn().mockReturnValue({ + get: vi.fn().mockReturnValue([]), + update: vi.fn(), + }), + onDidChangeConfiguration: vi.fn().mockImplementation(() => ({ + dispose: vi.fn(), + })), + onDidSaveTextDocument: vi.fn(() => ({ dispose: vi.fn() })), + onDidChangeTextDocument: vi.fn(() => ({ dispose: vi.fn() })), + onDidOpenTextDocument: vi.fn(() => ({ dispose: vi.fn() })), + onDidCloseTextDocument: vi.fn(() => ({ dispose: vi.fn() })), + }, + env: { + uriScheme: "vscode", + language: "en", + appName: "Visual Studio Code", + }, + ExtensionMode: { + Production: 1, + Development: 2, + Test: 3, + }, + version: "1.85.0", +})) + +vi.mock("../../../utils/tts", () => ({ + setTtsEnabled: vi.fn(), + setTtsSpeed: vi.fn(), +})) + +vi.mock("../../../api", () => ({ + buildApiHandler: vi.fn().mockReturnValue({ + getModel: vi.fn().mockReturnValue({ + id: "claude-3-sonnet", + }), + }), +})) + +vi.mock("../../prompts/system", () => ({ + SYSTEM_PROMPT: vi.fn().mockImplementation(async () => "mocked system prompt"), + codeMode: "code", +})) + +vi.mock("../../../integrations/workspace/WorkspaceTracker", () => { + return { + default: vi.fn().mockImplementation(() => ({ + initializeFilePaths: vi.fn(), + dispose: vi.fn(), + })), + } +}) + +vi.mock("../../task/Task", () => ({ + Task: vi.fn().mockImplementation((options: any) => ({ + api: undefined, + abortTask: vi.fn(), + handleWebviewAskResponse: vi.fn(), + clineMessages: [], + apiConversationHistory: [], + overwriteClineMessages: vi.fn(), + overwriteApiConversationHistory: vi.fn(), + getTaskNumber: vi.fn().mockReturnValue(0), + setTaskNumber: vi.fn(), + setParentTask: vi.fn(), + setRootTask: vi.fn(), + taskId: options?.historyItem?.id || "test-task-id", + emit: vi.fn(), + })), +})) + +vi.mock("../../../integrations/misc/extract-text", () => ({ + extractTextFromFile: vi.fn().mockResolvedValue("file content"), +})) + +vi.mock("../../../api/providers/fetchers/modelCache", () => ({ + getModels: vi.fn().mockResolvedValue({}), + flushModels: vi.fn(), + getModelsFromCache: vi.fn().mockReturnValue(undefined), +})) + +vi.mock("../../../shared/modes", () => ({ + modes: [{ slug: "code", name: "Code Mode", roleDefinition: "You are a code assistant", groups: ["read", "edit"] }], + getModeBySlug: vi.fn().mockReturnValue({ + slug: "code", + name: "Code Mode", + roleDefinition: "You are a code assistant", + groups: ["read", "edit"], + }), + getGroupName: vi.fn().mockReturnValue("General Tools"), + defaultModeSlug: "code", +})) + +vi.mock("../diff/strategies/multi-search-replace", () => ({ + MultiSearchReplaceDiffStrategy: vi.fn().mockImplementation(() => ({ + getToolDescription: () => "test", + getName: () => "test-strategy", + applyDiff: vi.fn(), + })), +})) + +vi.mock("@roo-code/cloud", () => ({ + CloudService: { + hasInstance: vi.fn().mockReturnValue(true), + get instance() { + return { + isAuthenticated: vi.fn().mockReturnValue(false), + getAllowList: vi.fn().mockResolvedValue("*"), + getUserInfo: vi.fn().mockReturnValue(null), + canShareTask: vi.fn().mockResolvedValue(false), + canSharePublicly: vi.fn().mockResolvedValue(false), + getOrganizationSettings: vi.fn().mockReturnValue(null), + getOrganizationMemberships: vi.fn().mockResolvedValue([]), + getUserSettings: vi.fn().mockReturnValue(null), + isTaskSyncEnabled: vi.fn().mockReturnValue(false), + } + }, + }, + BridgeOrchestrator: { + isEnabled: vi.fn().mockReturnValue(false), + }, + getRooCodeApiUrl: vi.fn().mockReturnValue("https://app.roocode.com"), +})) + +afterAll(() => { + vi.restoreAllMocks() +}) + +describe("ClineProvider Task History Synchronization", () => { + let provider: ClineProvider + let mockContext: vscode.ExtensionContext + let mockOutputChannel: vscode.OutputChannel + let mockWebviewView: vscode.WebviewView + let mockPostMessage: ReturnType + let taskHistoryState: HistoryItem[] + + beforeEach(() => { + vi.clearAllMocks() + + if (!TelemetryService.hasInstance()) { + TelemetryService.createInstance([]) + } + + // Initialize task history state + taskHistoryState = [] + + const globalState: Record = { + mode: "code", + currentApiConfigName: "current-config", + taskHistory: taskHistoryState, + } + + const secrets: Record = {} + + mockContext = { + extensionPath: "/test/path", + extensionUri: {} as vscode.Uri, + globalState: { + get: vi.fn().mockImplementation((key: string) => globalState[key]), + update: vi.fn().mockImplementation((key: string, value: any) => { + globalState[key] = value + if (key === "taskHistory") { + taskHistoryState = value + } + }), + keys: vi.fn().mockImplementation(() => Object.keys(globalState)), + }, + secrets: { + get: vi.fn().mockImplementation((key: string) => secrets[key]), + store: vi.fn().mockImplementation((key: string, value: string | undefined) => (secrets[key] = value)), + delete: vi.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: vi.fn(), + clear: vi.fn(), + dispose: vi.fn(), + } as unknown as vscode.OutputChannel + + mockPostMessage = vi.fn() + + mockWebviewView = { + webview: { + postMessage: mockPostMessage, + html: "", + options: {}, + onDidReceiveMessage: vi.fn(), + asWebviewUri: vi.fn(), + cspSource: "vscode-webview://test-csp-source", + }, + visible: true, + onDidDispose: vi.fn().mockImplementation((callback) => { + callback() + return { dispose: vi.fn() } + }), + onDidChangeVisibility: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })), + } as unknown as vscode.WebviewView + + provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + // Mock the custom modes manager + ;(provider as any).customModesManager = { + updateCustomMode: vi.fn().mockResolvedValue(undefined), + getCustomModes: vi.fn().mockResolvedValue([]), + dispose: vi.fn(), + } + + // Mock getMcpHub + provider.getMcpHub = vi.fn().mockReturnValue({ + listTools: vi.fn().mockResolvedValue([]), + callTool: vi.fn().mockResolvedValue({ content: [] }), + listResources: vi.fn().mockResolvedValue([]), + readResource: vi.fn().mockResolvedValue({ contents: [] }), + getAllServers: vi.fn().mockReturnValue([]), + }) + }) + + // Helper to create valid HistoryItem with required fields + const createHistoryItem = (overrides: Partial & { id: string; task: string }): HistoryItem => ({ + number: 1, + ts: Date.now(), + tokensIn: 100, + tokensOut: 50, + totalCost: 0.01, + ...overrides, + }) + + // Helper to find calls by message type + const findCallsByType = (calls: any[][], type: string) => { + return calls.filter((call) => call[0]?.type === type) + } + + describe("updateTaskHistory", () => { + it("broadcasts task history update by default", async () => { + await provider.resolveWebviewView(mockWebviewView) + provider.isViewLaunched = true + + const historyItem = createHistoryItem({ + id: "task-1", + task: "Test task", + }) + + await provider.updateTaskHistory(historyItem) + + // Should have called postMessage with taskHistoryItemUpdated + const taskHistoryItemUpdatedCalls = findCallsByType(mockPostMessage.mock.calls, "taskHistoryItemUpdated") + + expect(taskHistoryItemUpdatedCalls.length).toBeGreaterThanOrEqual(1) + + const lastCall = taskHistoryItemUpdatedCalls[taskHistoryItemUpdatedCalls.length - 1] + expect(lastCall[0].type).toBe("taskHistoryItemUpdated") + expect(lastCall[0].taskHistoryItem).toBeDefined() + expect(lastCall[0].taskHistoryItem.id).toBe("task-1") + }) + + it("does not broadcast when broadcast option is false", async () => { + await provider.resolveWebviewView(mockWebviewView) + provider.isViewLaunched = true + + // Clear previous calls + mockPostMessage.mockClear() + + const historyItem = createHistoryItem({ + id: "task-2", + task: "Test task 2", + }) + + await provider.updateTaskHistory(historyItem, { broadcast: false }) + + // Should NOT have called postMessage with taskHistoryItemUpdated + const taskHistoryItemUpdatedCalls = findCallsByType(mockPostMessage.mock.calls, "taskHistoryItemUpdated") + + expect(taskHistoryItemUpdatedCalls.length).toBe(0) + }) + + it("does not broadcast when view is not launched", async () => { + // Do not resolve webview and keep isViewLaunched false + provider.isViewLaunched = false + + const historyItem = createHistoryItem({ + id: "task-3", + task: "Test task 3", + }) + + await provider.updateTaskHistory(historyItem) + + // Should NOT have called postMessage with taskHistoryItemUpdated + const taskHistoryItemUpdatedCalls = findCallsByType(mockPostMessage.mock.calls, "taskHistoryItemUpdated") + + expect(taskHistoryItemUpdatedCalls.length).toBe(0) + }) + + it("updates existing task in history", async () => { + await provider.resolveWebviewView(mockWebviewView) + provider.isViewLaunched = true + + const historyItem = createHistoryItem({ + id: "task-update", + task: "Original task", + }) + + await provider.updateTaskHistory(historyItem) + + // Update the same task + const updatedItem: HistoryItem = { + ...historyItem, + task: "Updated task", + tokensIn: 200, + } + + await provider.updateTaskHistory(updatedItem) + + // Verify the update was persisted + expect(mockContext.globalState.update).toHaveBeenCalledWith( + "taskHistory", + expect.arrayContaining([expect.objectContaining({ id: "task-update", task: "Updated task" })]), + ) + + // Should not have duplicates + const allCalls = (mockContext.globalState.update as ReturnType).mock.calls + const lastUpdateCall = allCalls.find((call: any[]) => call[0] === "taskHistory") + const historyArray = lastUpdateCall?.[1] as HistoryItem[] + const matchingItems = historyArray?.filter((item: HistoryItem) => item.id === "task-update") + expect(matchingItems?.length).toBe(1) + }) + + it("returns the updated task history array", async () => { + await provider.resolveWebviewView(mockWebviewView) + provider.isViewLaunched = true + + const historyItem = createHistoryItem({ + id: "task-return", + task: "Return test task", + }) + + const result = await provider.updateTaskHistory(historyItem) + + expect(Array.isArray(result)).toBe(true) + expect(result.some((item) => item.id === "task-return")).toBe(true) + }) + }) + + describe("broadcastTaskHistoryUpdate", () => { + it("sends taskHistoryUpdated message with sorted history", async () => { + await provider.resolveWebviewView(mockWebviewView) + + const now = Date.now() + const items: HistoryItem[] = [ + createHistoryItem({ id: "old", ts: now - 10000, task: "Old task" }), + createHistoryItem({ id: "new", ts: now, task: "New task", number: 2 }), + ] + + // Clear previous calls + mockPostMessage.mockClear() + + await provider.broadcastTaskHistoryUpdate(items) + + expect(mockPostMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "taskHistoryUpdated", + taskHistory: expect.any(Array), + }), + ) + + // Verify the history is sorted (newest first) + const calls = mockPostMessage.mock.calls as any[][] + const call = calls.find((c) => c[0]?.type === "taskHistoryUpdated") + const sentHistory = call?.[0]?.taskHistory as HistoryItem[] + expect(sentHistory[0].id).toBe("new") // Newest should be first + expect(sentHistory[1].id).toBe("old") // Oldest should be second + }) + + it("filters out invalid history items", async () => { + await provider.resolveWebviewView(mockWebviewView) + + const now = Date.now() + const items: HistoryItem[] = [ + createHistoryItem({ id: "valid", ts: now, task: "Valid task" }), + createHistoryItem({ id: "no-ts", ts: 0, task: "No timestamp", number: 2 }), // Invalid: ts is 0/falsy + createHistoryItem({ id: "no-task", ts: now, task: "", number: 3 }), // Invalid: empty task + ] + + // Clear previous calls + mockPostMessage.mockClear() + + await provider.broadcastTaskHistoryUpdate(items) + + const calls = mockPostMessage.mock.calls as any[][] + const call = calls.find((c) => c[0]?.type === "taskHistoryUpdated") + const sentHistory = call?.[0]?.taskHistory as HistoryItem[] + + // Only valid item should be included + expect(sentHistory.length).toBe(1) + expect(sentHistory[0].id).toBe("valid") + }) + + it("reads from global state when no history is provided", async () => { + await provider.resolveWebviewView(mockWebviewView) + + // Set up task history in global state + const now = Date.now() + const stateHistory: HistoryItem[] = [createHistoryItem({ id: "from-state", ts: now, task: "State task" })] + + // Update the mock to return our history + ;(mockContext.globalState.get as ReturnType).mockImplementation((key: string) => { + if (key === "taskHistory") return stateHistory + return undefined + }) + + // Clear previous calls + mockPostMessage.mockClear() + + await provider.broadcastTaskHistoryUpdate() + + const calls = mockPostMessage.mock.calls as any[][] + const call = calls.find((c) => c[0]?.type === "taskHistoryUpdated") + const sentHistory = call?.[0]?.taskHistory as HistoryItem[] + + expect(sentHistory.length).toBe(1) + expect(sentHistory[0].id).toBe("from-state") + }) + }) + + describe("task history includes all workspaces", () => { + it("getStateToPostToWebview returns tasks from all workspaces", async () => { + await provider.resolveWebviewView(mockWebviewView) + + const now = Date.now() + const multiWorkspaceHistory: HistoryItem[] = [ + createHistoryItem({ + id: "ws1-task", + ts: now, + task: "Workspace 1 task", + workspace: "/path/to/workspace1", + }), + createHistoryItem({ + id: "ws2-task", + ts: now - 1000, + task: "Workspace 2 task", + workspace: "/path/to/workspace2", + number: 2, + }), + createHistoryItem({ + id: "ws3-task", + ts: now - 2000, + task: "Workspace 3 task", + workspace: "/different/workspace", + number: 3, + }), + ] + + // Update the mock to return multi-workspace history + ;(mockContext.globalState.get as ReturnType).mockImplementation((key: string) => { + if (key === "taskHistory") return multiWorkspaceHistory + return undefined + }) + + const state = await provider.getStateToPostToWebview() + + // All tasks from all workspaces should be included + expect(state.taskHistory.length).toBe(3) + expect(state.taskHistory.some((item: HistoryItem) => item.workspace === "/path/to/workspace1")).toBe(true) + expect(state.taskHistory.some((item: HistoryItem) => item.workspace === "/path/to/workspace2")).toBe(true) + expect(state.taskHistory.some((item: HistoryItem) => item.workspace === "/different/workspace")).toBe(true) + }) + }) +}) diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index d2ff79a8e02..6f1105bb8ce 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -171,7 +171,7 @@ export interface ExtensionStateContextType extends ExtensionState { export const ExtensionStateContext = createContext(undefined) -export const mergeExtensionState = (prevState: ExtensionState, newState: ExtensionState) => { +export const mergeExtensionState = (prevState: ExtensionState, newState: Partial) => { const { customModePrompts: prevCustomModePrompts, experiments: prevExperiments, ...prevRest } = prevState const { @@ -182,13 +182,19 @@ export const mergeExtensionState = (prevState: ExtensionState, newState: Extensi ...newRest } = newState - const customModePrompts = { ...prevCustomModePrompts, ...newCustomModePrompts } - const experiments = { ...prevExperiments, ...newExperiments } + const customModePrompts = { ...prevCustomModePrompts, ...(newCustomModePrompts ?? {}) } + const experiments = { ...prevExperiments, ...(newExperiments ?? {}) } const rest = { ...prevRest, ...newRest } // Note that we completely replace the previous apiConfiguration and customSupportPrompts objects // with new ones since the state that is broadcast is the entire objects so merging is not necessary. - return { ...rest, apiConfiguration, customModePrompts, customSupportPrompts, experiments } + return { + ...rest, + apiConfiguration: apiConfiguration ?? prevState.apiConfiguration, + customModePrompts, + customSupportPrompts: customSupportPrompts ?? prevState.customSupportPrompts, + experiments, + } } export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { @@ -322,7 +328,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode const message: ExtensionMessage = event.data switch (message.type) { case "state": { - const newState = message.state! + const newState = message.state ?? {} setState((prevState) => mergeExtensionState(prevState, newState)) setShowWelcome(!checkExistKey(newState.apiConfiguration)) setDidHydrateState(true) @@ -424,6 +430,36 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode } break } + case "taskHistoryUpdated": { + // Efficiently update just the task history without replacing entire state + if (message.taskHistory !== undefined) { + setState((prevState) => ({ + ...prevState, + taskHistory: message.taskHistory!, + })) + } + break + } + case "taskHistoryItemUpdated": { + const item = message.taskHistoryItem + if (!item) { + break + } + setState((prevState) => { + const existingIndex = prevState.taskHistory.findIndex((h) => h.id === item.id) + let nextHistory: typeof prevState.taskHistory + if (existingIndex === -1) { + nextHistory = [item, ...prevState.taskHistory] + } else { + nextHistory = [...prevState.taskHistory] + nextHistory[existingIndex] = item + } + // Keep UI semantics consistent with extension: newest-first ordering. + nextHistory.sort((a, b) => b.ts - a.ts) + return { ...prevState, taskHistory: nextHistory } + }) + break + } } }, [setListApiConfigMeta], From 641365bc5be6d67496409c572244dbcf680b0f1a Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Mon, 19 Jan 2026 20:29:52 -0700 Subject: [PATCH 2/5] test: update provider mocks for taskHistory-omitting state posts --- .../config/__tests__/importExport.spec.ts | 1 + src/core/task/Task.ts | 112 ++++++++++-------- src/core/task/__tests__/Task.spec.ts | 4 + .../Task.sticky-profile-race.spec.ts | 1 + src/core/task/__tests__/Task.throttle.test.ts | 1 + .../flushPendingToolResultsToHistory.spec.ts | 1 + .../task/__tests__/grace-retry-errors.spec.ts | 1 + .../task/__tests__/grounding-sources.test.ts | 1 + .../__tests__/reasoning-preservation.test.ts | 1 + .../ClineProvider.flicker-free-cancel.spec.ts | 1 + 10 files changed, 72 insertions(+), 52 deletions(-) diff --git a/src/core/config/__tests__/importExport.spec.ts b/src/core/config/__tests__/importExport.spec.ts index 3d5329f377b..7a1247efe80 100644 --- a/src/core/config/__tests__/importExport.spec.ts +++ b/src/core/config/__tests__/importExport.spec.ts @@ -458,6 +458,7 @@ describe("importExport", () => { const mockProvider = { settingsImportedAt: 0, postStateToWebview: vi.fn().mockResolvedValue(undefined), + postStateToWebviewWithoutTaskHistory: vi.fn().mockResolvedValue(undefined), } // Mock the showErrorMessage to capture the error diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 38d8c02b6c7..86ae5eeeaaf 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1868,69 +1868,77 @@ export class Task extends EventEmitter implements TaskLike { } private async startTask(task?: string, images?: string[]): Promise { - if (this.enableBridge) { - try { - await BridgeOrchestrator.subscribeToTask(this) - } catch (error) { - console.error( - `[Task#startTask] BridgeOrchestrator.subscribeToTask() failed: ${error instanceof Error ? error.message : String(error)}`, - ) + try { + if (this.enableBridge) { + try { + await BridgeOrchestrator.subscribeToTask(this) + } catch (error) { + console.error( + `[Task#startTask] BridgeOrchestrator.subscribeToTask() failed: ${error instanceof Error ? error.message : String(error)}`, + ) + } } - } - // `conversationHistory` (for API) and `clineMessages` (for webview) - // need to be in sync. - // If the extension process were killed, then on restart the - // `clineMessages` might not be empty, so we need to set it to [] when - // we create a new Cline client (otherwise webview would show stale - // messages from previous session). - this.clineMessages = [] - this.apiConversationHistory = [] + // `conversationHistory` (for API) and `clineMessages` (for webview) + // need to be in sync. + // If the extension process were killed, then on restart the + // `clineMessages` might not be empty, so we need to set it to [] when + // we create a new Cline client (otherwise webview would show stale + // messages from previous session). + this.clineMessages = [] + this.apiConversationHistory = [] - // The todo list is already set in the constructor if initialTodos were provided - // No need to add any messages - the todoList property is already set + // The todo list is already set in the constructor if initialTodos were provided + // No need to add any messages - the todoList property is already set - await this.providerRef.deref()?.postStateToWebviewWithoutTaskHistory() + await this.providerRef.deref()?.postStateToWebviewWithoutTaskHistory() - await this.say("text", task, images) + await this.say("text", task, images) - // Check for too many MCP tools and warn the user - const { enabledToolCount, enabledServerCount } = await this.getEnabledMcpToolsCount() - if (enabledToolCount > MAX_MCP_TOOLS_THRESHOLD) { - await this.say( - "too_many_tools_warning", - JSON.stringify({ - toolCount: enabledToolCount, - serverCount: enabledServerCount, - threshold: MAX_MCP_TOOLS_THRESHOLD, - }), - undefined, - undefined, - undefined, - undefined, - { isNonInteractive: true }, - ) - } - this.isInitialized = true - - let imageBlocks: Anthropic.ImageBlockParam[] = formatResponse.imageBlocks(images) + // Check for too many MCP tools and warn the user + const { enabledToolCount, enabledServerCount } = await this.getEnabledMcpToolsCount() + if (enabledToolCount > MAX_MCP_TOOLS_THRESHOLD) { + await this.say( + "too_many_tools_warning", + JSON.stringify({ + toolCount: enabledToolCount, + serverCount: enabledServerCount, + threshold: MAX_MCP_TOOLS_THRESHOLD, + }), + undefined, + undefined, + undefined, + undefined, + { isNonInteractive: true }, + ) + } + this.isInitialized = true - // Task starting + const imageBlocks: Anthropic.ImageBlockParam[] = formatResponse.imageBlocks(images) - await this.initiateTaskLoop([ - { - type: "text", - text: `\n${task}\n`, - }, - ...imageBlocks, - ]).catch((error) => { - // Swallow loop rejection when the task was intentionally abandoned/aborted - // during delegation or user cancellation to prevent unhandled rejections. - if (this.abandoned === true || this.abortReason === "user_cancelled") { + // Task starting + await this.initiateTaskLoop([ + { + type: "text", + text: `\n${task}\n`, + }, + ...imageBlocks, + ]).catch((error) => { + // Swallow loop rejection when the task was intentionally abandoned/aborted + // during delegation or user cancellation to prevent unhandled rejections. + if (this.abandoned === true || this.abortReason === "user_cancelled") { + return + } + throw error + }) + } catch (error) { + // In tests and some UX flows, tasks can be aborted while `startTask` is still + // initializing. Treat abort/abandon as expected and avoid unhandled rejections. + if (this.abandoned === true || this.abort === true || this.abortReason === "user_cancelled") { return } throw error - }) + } } private async resumeTaskFromHistory() { diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 6064ed965e4..c69050b22a5 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -282,6 +282,7 @@ describe("Cline", () => { // Mock provider methods mockProvider.postMessageToWebview = vi.fn().mockResolvedValue(undefined) mockProvider.postStateToWebview = vi.fn().mockResolvedValue(undefined) + mockProvider.postStateToWebviewWithoutTaskHistory = vi.fn().mockResolvedValue(undefined) mockProvider.getTaskWithId = vi.fn().mockImplementation(async (id) => ({ historyItem: { id, @@ -987,6 +988,7 @@ describe("Cline", () => { getSkillsManager: vi.fn().mockReturnValue(undefined), say: vi.fn(), postStateToWebview: vi.fn().mockResolvedValue(undefined), + postStateToWebviewWithoutTaskHistory: vi.fn().mockResolvedValue(undefined), postMessageToWebview: vi.fn().mockResolvedValue(undefined), updateTaskHistory: vi.fn().mockResolvedValue(undefined), } @@ -1901,6 +1903,7 @@ describe("Queued message processing after condense", () => { const provider = new ClineProvider(ctx, output as any, "sidebar", new ContextProxy(ctx)) as any provider.postMessageToWebview = vi.fn().mockResolvedValue(undefined) provider.postStateToWebview = vi.fn().mockResolvedValue(undefined) + provider.postStateToWebviewWithoutTaskHistory = vi.fn().mockResolvedValue(undefined) provider.getState = vi.fn().mockResolvedValue({}) return provider } @@ -2039,6 +2042,7 @@ describe("pushToolResultToUserContent", () => { mockProvider.postMessageToWebview = vi.fn().mockResolvedValue(undefined) mockProvider.postStateToWebview = vi.fn().mockResolvedValue(undefined) + mockProvider.postStateToWebviewWithoutTaskHistory = vi.fn().mockResolvedValue(undefined) }) it("should add tool_result when not a duplicate", () => { diff --git a/src/core/task/__tests__/Task.sticky-profile-race.spec.ts b/src/core/task/__tests__/Task.sticky-profile-race.spec.ts index e78301541df..38a3098b041 100644 --- a/src/core/task/__tests__/Task.sticky-profile-race.spec.ts +++ b/src/core/task/__tests__/Task.sticky-profile-race.spec.ts @@ -121,6 +121,7 @@ describe("Task - sticky provider profile init race", () => { on: vi.fn(), off: vi.fn(), postStateToWebview: vi.fn().mockResolvedValue(undefined), + postStateToWebviewWithoutTaskHistory: vi.fn().mockResolvedValue(undefined), updateTaskHistory: vi.fn().mockResolvedValue(undefined), } as unknown as ClineProvider diff --git a/src/core/task/__tests__/Task.throttle.test.ts b/src/core/task/__tests__/Task.throttle.test.ts index 1d5911be9f9..904bc46b55e 100644 --- a/src/core/task/__tests__/Task.throttle.test.ts +++ b/src/core/task/__tests__/Task.throttle.test.ts @@ -79,6 +79,7 @@ describe("Task token usage throttling", () => { getState: vi.fn().mockResolvedValue({ mode: "code" }), log: vi.fn(), postStateToWebview: vi.fn().mockResolvedValue(undefined), + postStateToWebviewWithoutTaskHistory: vi.fn().mockResolvedValue(undefined), updateTaskHistory: vi.fn().mockResolvedValue(undefined), } diff --git a/src/core/task/__tests__/flushPendingToolResultsToHistory.spec.ts b/src/core/task/__tests__/flushPendingToolResultsToHistory.spec.ts index 453fd1cad3a..4f6f79970e5 100644 --- a/src/core/task/__tests__/flushPendingToolResultsToHistory.spec.ts +++ b/src/core/task/__tests__/flushPendingToolResultsToHistory.spec.ts @@ -210,6 +210,7 @@ describe("flushPendingToolResultsToHistory", () => { mockProvider.postMessageToWebview = vi.fn().mockResolvedValue(undefined) mockProvider.postStateToWebview = vi.fn().mockResolvedValue(undefined) + mockProvider.postStateToWebviewWithoutTaskHistory = vi.fn().mockResolvedValue(undefined) mockProvider.updateTaskHistory = vi.fn().mockResolvedValue(undefined) }) diff --git a/src/core/task/__tests__/grace-retry-errors.spec.ts b/src/core/task/__tests__/grace-retry-errors.spec.ts index 5ea0e1ddb36..3c3e40b98c3 100644 --- a/src/core/task/__tests__/grace-retry-errors.spec.ts +++ b/src/core/task/__tests__/grace-retry-errors.spec.ts @@ -206,6 +206,7 @@ describe("Grace Retry Error Handling", () => { mockProvider.postMessageToWebview = vi.fn().mockResolvedValue(undefined) mockProvider.postStateToWebview = vi.fn().mockResolvedValue(undefined) + mockProvider.postStateToWebviewWithoutTaskHistory = vi.fn().mockResolvedValue(undefined) mockProvider.getState = vi.fn().mockResolvedValue({}) }) diff --git a/src/core/task/__tests__/grounding-sources.test.ts b/src/core/task/__tests__/grounding-sources.test.ts index a33e4fd5d2c..dc1212ead51 100644 --- a/src/core/task/__tests__/grounding-sources.test.ts +++ b/src/core/task/__tests__/grounding-sources.test.ts @@ -166,6 +166,7 @@ describe("Task grounding sources handling", () => { // Mock provider with necessary methods mockProvider = { postStateToWebview: vi.fn().mockResolvedValue(undefined), + postStateToWebviewWithoutTaskHistory: vi.fn().mockResolvedValue(undefined), getState: vi.fn().mockResolvedValue({ mode: "code", experiments: {}, diff --git a/src/core/task/__tests__/reasoning-preservation.test.ts b/src/core/task/__tests__/reasoning-preservation.test.ts index 3b0f773956c..45fb602f665 100644 --- a/src/core/task/__tests__/reasoning-preservation.test.ts +++ b/src/core/task/__tests__/reasoning-preservation.test.ts @@ -166,6 +166,7 @@ describe("Task reasoning preservation", () => { // Mock provider with necessary methods mockProvider = { postStateToWebview: vi.fn().mockResolvedValue(undefined), + postStateToWebviewWithoutTaskHistory: vi.fn().mockResolvedValue(undefined), getState: vi.fn().mockResolvedValue({ mode: "code", experiments: {}, diff --git a/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts b/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts index 36c23512e77..8533865031e 100644 --- a/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts @@ -150,6 +150,7 @@ describe("ClineProvider flicker-free cancel", () => { }) provider.postStateToWebview = vi.fn().mockResolvedValue(undefined) + provider.postStateToWebviewWithoutTaskHistory = vi.fn().mockResolvedValue(undefined) // Mock private method using any cast ;(provider as any).updateGlobalState = vi.fn().mockResolvedValue(undefined) provider.activateProviderProfile = vi.fn().mockResolvedValue(undefined) From 79aec3205d1a827c5f3b8d58b0b17d069fdcde46 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Mon, 19 Jan 2026 20:58:54 -0700 Subject: [PATCH 3/5] Update src/core/webview/ClineProvider.ts Co-authored-by: roomote[bot] <219738659+roomote[bot]@users.noreply.github.com> --- src/core/webview/ClineProvider.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index c12c4d3bffb..68a72697084 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2535,7 +2535,9 @@ export class ClineProvider * This sends a lightweight message with just the task history, rather than the full state. * @param history The task history to broadcast (if not provided, reads from global state) */ - async broadcastTaskHistoryUpdate(history?: HistoryItem[]): Promise { + if (!this.isViewLaunched) { + return + } const taskHistory = history ?? (this.getGlobalState("taskHistory") as HistoryItem[] | undefined) ?? [] // Sort and filter the history the same way as getStateToPostToWebview From e42e7abe73c93b40d7fb867d1129ea4b56963c13 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Mon, 19 Jan 2026 20:59:02 -0700 Subject: [PATCH 4/5] Update webview-ui/src/context/ExtensionStateContext.tsx Co-authored-by: roomote[bot] <219738659+roomote[bot]@users.noreply.github.com> --- webview-ui/src/context/ExtensionStateContext.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 6f1105bb8ce..fa0befd321f 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -456,7 +456,11 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode } // Keep UI semantics consistent with extension: newest-first ordering. nextHistory.sort((a, b) => b.ts - a.ts) - return { ...prevState, taskHistory: nextHistory } + return { + ...prevState, + taskHistory: nextHistory, + currentTaskItem: prevState.currentTaskItem?.id === item.id ? item : prevState.currentTaskItem, + } }) break } From 395893aca1755bdb4d795a310515214da7932a66 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Mon, 19 Jan 2026 21:10:37 -0700 Subject: [PATCH 5/5] fix(webview): restore task history broadcast method --- src/core/webview/ClineProvider.ts | 2 ++ src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts | 3 +++ 2 files changed, 5 insertions(+) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 68a72697084..52845543edf 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2535,9 +2535,11 @@ export class ClineProvider * This sends a lightweight message with just the task history, rather than the full state. * @param history The task history to broadcast (if not provided, reads from global state) */ + public async broadcastTaskHistoryUpdate(history?: HistoryItem[]): Promise { if (!this.isViewLaunched) { return } + const taskHistory = history ?? (this.getGlobalState("taskHistory") as HistoryItem[] | undefined) ?? [] // Sort and filter the history the same way as getStateToPostToWebview diff --git a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts index 386ac0cd0e7..dd87ba5c6f1 100644 --- a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts @@ -469,6 +469,7 @@ describe("ClineProvider Task History Synchronization", () => { describe("broadcastTaskHistoryUpdate", () => { it("sends taskHistoryUpdated message with sorted history", async () => { await provider.resolveWebviewView(mockWebviewView) + provider.isViewLaunched = true const now = Date.now() const items: HistoryItem[] = [ @@ -498,6 +499,7 @@ describe("ClineProvider Task History Synchronization", () => { it("filters out invalid history items", async () => { await provider.resolveWebviewView(mockWebviewView) + provider.isViewLaunched = true const now = Date.now() const items: HistoryItem[] = [ @@ -522,6 +524,7 @@ describe("ClineProvider Task History Synchronization", () => { it("reads from global state when no history is provided", async () => { await provider.resolveWebviewView(mockWebviewView) + provider.isViewLaunched = true // Set up task history in global state const now = Date.now()