Skip to content

Commit 0b02f7d

Browse files
fix(webview): throttle large state updates
Coalesce repeated full-state webview updates with a leading and trailing debounce and a one-second maximum wait. Keep task-start, API-boundary, and stream-completion updates immediate, and flush pending state during partial-message initialization and task abort. Reset aggregated task costs when switching tasks and ignore delayed responses for inactive tasks so stale per-task data is not retained or displayed. Add regression coverage for debounce timing, flush and disposal behavior, partial-message ordering, queue failures, and task-switch cost cleanup. Signed-off-by: JunyongParkDev <jun94.park@samsung.com>
1 parent ca9b60f commit 0b02f7d

7 files changed

Lines changed: 428 additions & 36 deletions

File tree

src/core/task/Task.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -551,12 +551,9 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
551551
this.emit(RooCodeEventName.QueuedMessagesUpdated, this.taskId, this.messageQueueService.messages)
552552
void this.providerRef
553553
.deref()
554-
?.postStateToWebviewWithoutTaskHistory()
554+
?.postStateToWebviewThrottled()
555555
.catch((error) => {
556-
console.error(
557-
"[Task#messageQueueStateChangedHandler] postStateToWebviewWithoutTaskHistory failed:",
558-
error,
559-
)
556+
console.error("[Task#messageQueueStateChangedHandler] postStateToWebviewThrottled failed:", error)
560557
})
561558
}
562559

@@ -1047,9 +1044,10 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
10471044
private async addToClineMessages(message: ClineMessage) {
10481045
this.clineMessages.push(message)
10491046
const provider = this.providerRef.deref()
1050-
// Avoid resending large, mostly-static fields (notably taskHistory) on every chat message update.
1051-
// taskHistory is maintained in-memory in the webview and updated via taskHistoryItemUpdated.
1052-
await provider?.postStateToWebviewWithoutTaskHistory()
1047+
await provider?.postStateToWebviewThrottled()
1048+
if (message.partial === true) {
1049+
await provider?.flushPostStateToWebviewThrottled()
1050+
}
10531051
this.emit(RooCodeEventName.Message, { action: "created", message })
10541052
await this.saveClineMessages()
10551053

@@ -2250,6 +2248,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
22502248
// Force final token usage update before abort event
22512249
this.emitFinalTokenUsageUpdate()
22522250

2251+
await this.providerRef.deref()?.flushPostStateToWebviewThrottled()
2252+
22532253
this.emit(RooCodeEventName.TaskAborted)
22542254

22552255
try {

src/core/task/__tests__/Task.spec.ts

Lines changed: 114 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ type TaskTestAccess = {
3030
startTask: (task?: string, images?: string[]) => Promise<void>
3131
resumeTaskFromHistory: () => Promise<void>
3232
presentAssistantMessageSafe: () => void
33+
addToClineMessages: (message: import("@roo-code/types").ClineMessage) => Promise<void>
3334
updateClineMessage: (message: import("@roo-code/types").ClineMessage) => Promise<void>
3435
saveClineMessages: () => Promise<boolean>
3536
safeEnsureModelFetched: () => Promise<void>
@@ -338,6 +339,8 @@ describe("Cline", () => {
338339
mockProvider.postMessageToWebview = vi.fn().mockResolvedValue(undefined)
339340
mockProvider.postStateToWebview = vi.fn().mockResolvedValue(undefined)
340341
mockProvider.postStateToWebviewWithoutTaskHistory = vi.fn().mockResolvedValue(undefined)
342+
mockProvider.postStateToWebviewThrottled = vi.fn().mockResolvedValue(undefined)
343+
mockProvider.flushPostStateToWebviewThrottled = vi.fn().mockResolvedValue(undefined)
341344
mockProvider.getTaskWithId = vi.fn().mockImplementation(async (id) => ({
342345
historyItem: {
343346
id,
@@ -1029,6 +1032,8 @@ describe("Cline", () => {
10291032
say: vi.fn(),
10301033
postStateToWebview: vi.fn().mockResolvedValue(undefined),
10311034
postStateToWebviewWithoutTaskHistory: vi.fn().mockResolvedValue(undefined),
1035+
postStateToWebviewThrottled: vi.fn().mockResolvedValue(undefined),
1036+
flushPostStateToWebviewThrottled: vi.fn().mockResolvedValue(undefined),
10321037
postMessageToWebview: vi.fn().mockResolvedValue(undefined),
10331038
updateTaskHistory: vi.fn().mockResolvedValue(undefined),
10341039
}
@@ -1663,6 +1668,78 @@ describe("Cline", () => {
16631668
})
16641669
})
16651670

1671+
describe("webview state throttling", () => {
1672+
it("schedules a complete new message without forcing an immediate state push", async () => {
1673+
const task = new Task({
1674+
provider: mockProvider,
1675+
apiConfiguration: mockApiConfig,
1676+
task: "test task",
1677+
startTask: false,
1678+
})
1679+
vi.spyOn(getTaskTestAccess(task), "saveClineMessages").mockResolvedValue(true)
1680+
const message = {
1681+
ts: Date.now(),
1682+
type: "say" as const,
1683+
say: "text" as const,
1684+
text: "message",
1685+
}
1686+
1687+
await getTaskTestAccess(task).addToClineMessages(message)
1688+
1689+
expect(mockProvider.postStateToWebviewThrottled).toHaveBeenCalledOnce()
1690+
expect(mockProvider.postStateToWebviewThrottled).toHaveBeenCalledWith()
1691+
expect(mockProvider.flushPostStateToWebviewThrottled).not.toHaveBeenCalled()
1692+
expect(mockProvider.postStateToWebviewWithoutTaskHistory).not.toHaveBeenCalled()
1693+
})
1694+
1695+
it("waits for a new partial message flush before a following message update", async () => {
1696+
const task = new Task({
1697+
provider: mockProvider,
1698+
apiConfiguration: mockApiConfig,
1699+
task: "test task",
1700+
startTask: false,
1701+
})
1702+
const taskAccess = getTaskTestAccess(task)
1703+
vi.spyOn(taskAccess, "saveClineMessages").mockResolvedValue(true)
1704+
let releaseFlush!: () => void
1705+
const pendingFlush = new Promise<void>((resolve) => {
1706+
releaseFlush = resolve
1707+
})
1708+
const flushSpy = vi.mocked(mockProvider.flushPostStateToWebviewThrottled).mockReturnValueOnce(pendingFlush)
1709+
const updatePostSpy = vi.mocked(mockProvider.postMessageToWebview)
1710+
const partialMessage = {
1711+
ts: 1,
1712+
type: "say" as const,
1713+
say: "text" as const,
1714+
text: "partial message",
1715+
partial: true,
1716+
}
1717+
let partialAddSettled = false
1718+
const addThenUpdate = taskAccess.addToClineMessages(partialMessage).then(async () => {
1719+
partialAddSettled = true
1720+
await taskAccess.updateClineMessage({ ...partialMessage, text: "updated partial" })
1721+
})
1722+
1723+
await Promise.resolve()
1724+
expect(mockProvider.postStateToWebviewThrottled).toHaveBeenCalledWith()
1725+
expect(flushSpy).toHaveBeenCalledWith()
1726+
expect(partialAddSettled).toBe(false)
1727+
expect(updatePostSpy).not.toHaveBeenCalled()
1728+
1729+
releaseFlush()
1730+
await addThenUpdate
1731+
1732+
expect(flushSpy.mock.invocationCallOrder[0]).toBeLessThan(updatePostSpy.mock.invocationCallOrder[0])
1733+
expect(updatePostSpy).toHaveBeenCalledWith({
1734+
type: "messageUpdated",
1735+
clineMessage: {
1736+
...partialMessage,
1737+
text: "updated partial",
1738+
},
1739+
})
1740+
})
1741+
})
1742+
16661743
describe("abortTask", () => {
16671744
it("should set abort flag and emit TaskAborted event", async () => {
16681745
const task = new Task({
@@ -1707,6 +1784,37 @@ describe("Cline", () => {
17071784
expect(disposeSpy).toHaveBeenCalled()
17081785
})
17091786

1787+
it("flushes pending state before TaskAborted and disposal while queue state is intact", async () => {
1788+
const task = new Task({
1789+
provider: mockProvider,
1790+
apiConfiguration: mockApiConfig,
1791+
task: "test task",
1792+
startTask: false,
1793+
})
1794+
let queuedMessagesAtFlush = -1
1795+
const flushSpy = vi.mocked(mockProvider.flushPostStateToWebviewThrottled).mockImplementation(async () => {
1796+
queuedMessagesAtFlush = task.messageQueueService.messages.length
1797+
})
1798+
const emitSpy = vi.spyOn(task, "emit")
1799+
const disposeSpy = vi.spyOn(task, "dispose").mockImplementation(() => {})
1800+
1801+
task.messageQueueService.addMessage("queued text")
1802+
await task.abortTask()
1803+
1804+
const taskAbortedCallIndex = (emitSpy.mock.calls as unknown[][]).findIndex(
1805+
([event]) => event === "taskAborted",
1806+
)
1807+
expect(taskAbortedCallIndex).toBeGreaterThanOrEqual(0)
1808+
expect(queuedMessagesAtFlush).toBe(1)
1809+
expect(flushSpy).toHaveBeenCalledWith()
1810+
expect(flushSpy.mock.invocationCallOrder[0]).toBeLessThan(
1811+
emitSpy.mock.invocationCallOrder[taskAbortedCallIndex],
1812+
)
1813+
expect(emitSpy.mock.invocationCallOrder[taskAbortedCallIndex]).toBeLessThan(
1814+
disposeSpy.mock.invocationCallOrder[0],
1815+
)
1816+
})
1817+
17101818
it("should work with TaskLike interface", async () => {
17111819
const task = new Task({
17121820
provider: mockProvider,
@@ -2859,9 +2967,9 @@ describe("Cline", () => {
28592967
resumeSpy.mockRestore()
28602968
})
28612969

2862-
it("logs (instead of crashing) when postStateToWebviewWithoutTaskHistory rejects from the queue handler", async () => {
2970+
it("logs (instead of crashing) when postStateToWebviewThrottled rejects from the queue handler", async () => {
28632971
const boom = new Error("postState boom")
2864-
mockProvider.postStateToWebviewWithoutTaskHistory = vi.fn().mockRejectedValue(boom)
2972+
mockProvider.postStateToWebviewThrottled = vi.fn().mockRejectedValue(boom)
28652973

28662974
const task = new Task({
28672975
provider: mockProvider,
@@ -2870,13 +2978,14 @@ describe("Cline", () => {
28702978
startTask: false,
28712979
})
28722980

2873-
// Triggers messageQueueStateChangedHandler -> void postStateToWebviewWithoutTaskHistory()
2981+
// Triggers messageQueueStateChangedHandler -> void postStateToWebviewThrottled()
28742982
task.messageQueueService.addMessage("queued text")
28752983
await flushMicrotasks()
28762984

2877-
expect(mockProvider.postStateToWebviewWithoutTaskHistory).toHaveBeenCalled()
2985+
expect(mockProvider.postStateToWebviewThrottled).toHaveBeenCalledWith()
2986+
expect(mockProvider.postStateToWebviewWithoutTaskHistory).not.toHaveBeenCalled()
28782987
expect(consoleErrorSpy).toHaveBeenCalledWith(
2879-
"[Task#messageQueueStateChangedHandler] postStateToWebviewWithoutTaskHistory failed:",
2988+
"[Task#messageQueueStateChangedHandler] postStateToWebviewThrottled failed:",
28802989
boom,
28812990
)
28822991
})

src/core/task/__tests__/Task.throttle.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ describe("Task token usage throttling", () => {
7979
log: vi.fn(),
8080
postStateToWebview: vi.fn().mockResolvedValue(undefined),
8181
postStateToWebviewWithoutTaskHistory: vi.fn().mockResolvedValue(undefined),
82+
postStateToWebviewThrottled: vi.fn().mockResolvedValue(undefined),
83+
flushPostStateToWebviewThrottled: vi.fn().mockResolvedValue(undefined),
8284
updateTaskHistory: vi.fn().mockResolvedValue(undefined),
8385
}
8486

src/core/webview/ClineProvider.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import EventEmitter from "events"
66
import { Anthropic } from "@anthropic-ai/sdk"
77
import delay from "delay"
88
import axios from "axios"
9+
import debounce from "lodash.debounce"
910
import pWaitFor from "p-wait-for"
1011
import * as vscode from "vscode"
1112

@@ -188,6 +189,21 @@ export class ClineProvider
188189
private taskEventListeners: WeakMap<Task, Array<() => void>> = new WeakMap()
189190
private currentWorkspacePath: string | undefined
190191
private _disposed = false
192+
private readonly _postStateToWebviewThrottled = debounce(
193+
async () => {
194+
try {
195+
await this.postStateToWebviewWithoutTaskHistory()
196+
} catch (error) {
197+
this.log(
198+
`[ClineProvider#postStateToWebviewThrottled] Failed to post state: ${
199+
error instanceof Error ? error.message : String(error)
200+
}`,
201+
)
202+
}
203+
},
204+
500,
205+
{ leading: true, trailing: true, maxWait: 1000 },
206+
)
191207
private readonly rateLimitClock: RateLimitClock = createRateLimitClock()
192208

193209
private recentTasksCache?: string[]
@@ -689,6 +705,7 @@ export class ClineProvider
689705
}
690706

691707
this._disposed = true
708+
this._postStateToWebviewThrottled.cancel()
692709
this.log("Disposing ClineProvider...")
693710

694711
// Reject any tasks still waiting for a scheduler permit so they don't
@@ -2178,6 +2195,22 @@ export class ClineProvider
21782195
await this.postMessageToWebview({ type: "state", state: rest })
21792196
}
21802197

2198+
async postStateToWebviewThrottled(): Promise<void> {
2199+
if (this._disposed) {
2200+
return
2201+
}
2202+
2203+
await this._postStateToWebviewThrottled()
2204+
}
2205+
2206+
async flushPostStateToWebviewThrottled(): Promise<void> {
2207+
if (this._disposed) {
2208+
return
2209+
}
2210+
2211+
await this._postStateToWebviewThrottled.flush()
2212+
}
2213+
21812214
/**
21822215
* Like postStateToWebview but intentionally omits both clineMessages and taskHistory.
21832216
*

0 commit comments

Comments
 (0)