Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion packages/types/src/vscode-extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export interface ExtensionMessage {
type:
| "action"
| "state"
| "taskHistoryUpdated"
| "taskHistoryItemUpdated"
| "selectedImages"
| "theme"
| "workspaceUpdated"
Expand Down Expand Up @@ -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<{
Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions src/core/config/__tests__/importExport.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
122 changes: 66 additions & 56 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
4 changes: 4 additions & 0 deletions src/core/task/__tests__/Task.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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", () => {
Expand Down
1 change: 1 addition & 0 deletions src/core/task/__tests__/Task.sticky-profile-race.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions src/core/task/__tests__/Task.throttle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})

Expand Down
1 change: 1 addition & 0 deletions src/core/task/__tests__/grace-retry-errors.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({})
})

Expand Down
1 change: 1 addition & 0 deletions src/core/task/__tests__/grounding-sources.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {},
Expand Down
1 change: 1 addition & 0 deletions src/core/task/__tests__/reasoning-preservation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {},
Expand Down
61 changes: 59 additions & 2 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading