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

Co-authored-by: roomote[bot] <219738659+roomote[bot]@users.noreply.github.com>
This commit is contained in:
Hannes Rudolph 2026-01-19 21:22:32 -07:00 committed by GitHub
parent 1a1827d806
commit 06039400cd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 792 additions and 68 deletions

View file

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

View file

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

View file

@ -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
await this.providerRef.deref()?.postStateToWebviewWithoutTaskHistory()
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
// `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 = []
const imageBlocks: Anthropic.ImageBlockParam[] = formatResponse.imageBlocks(images)
// 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.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)
// 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") {
// 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()

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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: {},

View file

@ -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: {},

View file

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

View file

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

View file

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

View file

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