Bladeren bron

perf(webview): avoid resending taskHistory in state updates (#10842)

Co-authored-by: roomote[bot] <219738659+roomote[bot]@users.noreply.github.com>
Hannes Rudolph 2 dagen geleden
bovenliggende
commit
06039400cd

+ 10 - 1
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<ExtensionState>
 	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 {

+ 1 - 0
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

+ 66 - 56
src/core/task/Task.ts

@@ -590,7 +590,7 @@ export class Task extends EventEmitter<TaskEvents> 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<TaskEvents> 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()
 
@@ -1866,69 +1868,77 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
 	}
 
 	private async startTask(task?: string, images?: string[]): Promise<void> {
-		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 = []
 
-		// 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
+			// `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 = []
 
-		await this.providerRef.deref()?.postStateToWebview()
+			// 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.say("text", task, images)
+			await this.providerRef.deref()?.postStateToWebviewWithoutTaskHistory()
 
-		// 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
+			await this.say("text", task, images)
 
-		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: `<user_message>\n${task}\n</user_message>`,
-			},
-			...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: `<user_message>\n${task}\n</user_message>`,
+				},
+				...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() {
@@ -2678,7 +2688,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
 			} satisfies ClineApiReqInfo)
 
 			await this.saveClineMessages()
-			await this.providerRef.deref()?.postStateToWebview()
+			await this.providerRef.deref()?.postStateToWebviewWithoutTaskHistory()
 
 			try {
 				let cacheWriteTokens = 0
@@ -3446,7 +3456,7 @@ export class Task extends EventEmitter<TaskEvents> 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()

+ 4 - 0
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", () => {

+ 1 - 0
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
 

+ 1 - 0
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),
 		}
 

+ 1 - 0
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)
 	})
 

+ 1 - 0
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({})
 	})
 

+ 1 - 0
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: {},

+ 1 - 0
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: {},

+ 59 - 2
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<void> {
+		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<HistoryItem[]> {
+	/**
+	 * 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<HistoryItem[]> {
+		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,39 @@ 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)
+	 */
+	public async broadcastTaskHistoryUpdate(history?: HistoryItem[]): Promise<void> {
+		if (!this.isViewLaunched) {
+			return
+		}
+
+		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.

+ 1 - 0
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)

+ 596 - 0
src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts

@@ -0,0 +1,596 @@
+// 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<typeof vi.fn>
+	let taskHistoryState: HistoryItem[]
+
+	beforeEach(() => {
+		vi.clearAllMocks()
+
+		if (!TelemetryService.hasInstance()) {
+			TelemetryService.createInstance([])
+		}
+
+		// Initialize task history state
+		taskHistoryState = []
+
+		const globalState: Record<string, any> = {
+			mode: "code",
+			currentApiConfigName: "current-config",
+			taskHistory: taskHistoryState,
+		}
+
+		const secrets: Record<string, string | undefined> = {}
+
+		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<HistoryItem> & { 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<typeof vi.fn>).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)
+			provider.isViewLaunched = true
+
+			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)
+			provider.isViewLaunched = true
+
+			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)
+			provider.isViewLaunched = true
+
+			// 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<typeof vi.fn>).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<typeof vi.fn>).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)
+		})
+	})
+})

+ 45 - 5
webview-ui/src/context/ExtensionStateContext.tsx

@@ -171,7 +171,7 @@ export interface ExtensionStateContextType extends ExtensionState {
 
 export const ExtensionStateContext = createContext<ExtensionStateContextType | undefined>(undefined)
 
-export const mergeExtensionState = (prevState: ExtensionState, newState: ExtensionState) => {
+export const mergeExtensionState = (prevState: ExtensionState, newState: Partial<ExtensionState>) => {
 	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,40 @@ 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,
+								currentTaskItem: prevState.currentTaskItem?.id === item.id ? item : prevState.currentTaskItem,
+							}
+					})
+					break
+				}
 			}
 		},
 		[setListApiConfigMeta],