Skip to content

Commit 82eae9c

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 992585f commit 82eae9c

7 files changed

Lines changed: 547 additions & 36 deletions

File tree

src/core/task/Task.ts

Lines changed: 11 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,13 @@ 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+
// Unanswered asks must reach the webview before Message listeners can respond against its state.
1048+
const requiresImmediateState =
1049+
message.partial === true || (message.type === "ask" && message.isAnswered !== true)
1050+
await provider?.postStateToWebviewThrottled()
1051+
if (requiresImmediateState) {
1052+
await provider?.flushPostStateToWebviewThrottled()
1053+
}
10531054
this.emit(RooCodeEventName.Message, { action: "created", message })
10541055
await this.saveClineMessages()
10551056

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

2254+
await this.providerRef.deref()?.flushPostStateToWebviewThrottled()
2255+
22532256
this.emit(RooCodeEventName.TaskAborted)
22542257

22552258
try {

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

Lines changed: 220 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { Anthropic } from "@anthropic-ai/sdk"
88

99
import {
1010
providerIdentifiers,
11+
RooCodeEventName,
1112
type GlobalState,
1213
type ProviderSettings,
1314
type ModelInfo,
@@ -27,9 +28,12 @@ import type { ApiMessage } from "../../task-persistence"
2728

2829
type TaskTestAccess = {
2930
getSystemPrompt: () => Promise<string>
31+
getEnabledMcpToolsCount: () => Promise<{ enabledToolCount: number; enabledServerCount: number }>
32+
initiateTaskLoop: (userContent: Anthropic.Messages.ContentBlockParam[]) => Promise<void>
3033
startTask: (task?: string, images?: string[]) => Promise<void>
3134
resumeTaskFromHistory: () => Promise<void>
3235
presentAssistantMessageSafe: () => void
36+
addToClineMessages: (message: import("@roo-code/types").ClineMessage) => Promise<void>
3337
updateClineMessage: (message: import("@roo-code/types").ClineMessage) => Promise<void>
3438
saveClineMessages: () => Promise<boolean>
3539
safeEnsureModelFetched: () => Promise<void>
@@ -338,6 +342,8 @@ describe("Cline", () => {
338342
mockProvider.postMessageToWebview = vi.fn().mockResolvedValue(undefined)
339343
mockProvider.postStateToWebview = vi.fn().mockResolvedValue(undefined)
340344
mockProvider.postStateToWebviewWithoutTaskHistory = vi.fn().mockResolvedValue(undefined)
345+
mockProvider.postStateToWebviewThrottled = vi.fn().mockResolvedValue(undefined)
346+
mockProvider.flushPostStateToWebviewThrottled = vi.fn().mockResolvedValue(undefined)
341347
mockProvider.getTaskWithId = vi.fn().mockImplementation(async (id) => ({
342348
historyItem: {
343349
id,
@@ -1029,6 +1035,8 @@ describe("Cline", () => {
10291035
say: vi.fn(),
10301036
postStateToWebview: vi.fn().mockResolvedValue(undefined),
10311037
postStateToWebviewWithoutTaskHistory: vi.fn().mockResolvedValue(undefined),
1038+
postStateToWebviewThrottled: vi.fn().mockResolvedValue(undefined),
1039+
flushPostStateToWebviewThrottled: vi.fn().mockResolvedValue(undefined),
10321040
postMessageToWebview: vi.fn().mockResolvedValue(undefined),
10331041
updateTaskHistory: vi.fn().mockResolvedValue(undefined),
10341042
}
@@ -1663,6 +1671,137 @@ describe("Cline", () => {
16631671
})
16641672
})
16651673

1674+
describe("webview state throttling", () => {
1675+
it("schedules a complete new message without forcing an immediate state push", async () => {
1676+
const task = new Task({
1677+
provider: mockProvider,
1678+
apiConfiguration: mockApiConfig,
1679+
task: "test task",
1680+
startTask: false,
1681+
})
1682+
vi.spyOn(getTaskTestAccess(task), "saveClineMessages").mockResolvedValue(true)
1683+
const message = {
1684+
ts: Date.now(),
1685+
type: "say" as const,
1686+
say: "text" as const,
1687+
text: "message",
1688+
}
1689+
1690+
await getTaskTestAccess(task).addToClineMessages(message)
1691+
1692+
expect(mockProvider.postStateToWebviewThrottled).toHaveBeenCalledOnce()
1693+
expect(mockProvider.postStateToWebviewThrottled).toHaveBeenCalledWith()
1694+
expect(mockProvider.flushPostStateToWebviewThrottled).not.toHaveBeenCalled()
1695+
expect(mockProvider.postStateToWebviewWithoutTaskHistory).not.toHaveBeenCalled()
1696+
})
1697+
1698+
it("waits for an unanswered ask flush before emitting the message", async () => {
1699+
const task = new Task({
1700+
provider: mockProvider,
1701+
apiConfiguration: mockApiConfig,
1702+
task: "test task",
1703+
startTask: false,
1704+
})
1705+
const taskAccess = getTaskTestAccess(task)
1706+
vi.spyOn(taskAccess, "saveClineMessages").mockResolvedValue(true)
1707+
let releaseFlush!: () => void
1708+
const pendingFlush = new Promise<void>((resolve) => {
1709+
releaseFlush = resolve
1710+
})
1711+
const flushSpy = vi.mocked(mockProvider.flushPostStateToWebviewThrottled).mockReturnValueOnce(pendingFlush)
1712+
const messageListener = vi.fn()
1713+
task.on(RooCodeEventName.Message, messageListener)
1714+
const message = {
1715+
ts: 1,
1716+
type: "ask" as const,
1717+
ask: "resume_task" as const,
1718+
}
1719+
1720+
const addPromise = taskAccess.addToClineMessages(message)
1721+
1722+
await Promise.resolve()
1723+
expect(mockProvider.postStateToWebviewThrottled).toHaveBeenCalledOnce()
1724+
expect(mockProvider.postStateToWebviewThrottled).toHaveBeenCalledWith()
1725+
expect(flushSpy).toHaveBeenCalledOnce()
1726+
expect(flushSpy).toHaveBeenCalledWith()
1727+
expect(messageListener).not.toHaveBeenCalled()
1728+
1729+
releaseFlush()
1730+
await addPromise
1731+
1732+
expect(flushSpy.mock.invocationCallOrder[0]).toBeLessThan(messageListener.mock.invocationCallOrder[0])
1733+
expect(messageListener).toHaveBeenCalledWith({ action: "created", message })
1734+
})
1735+
1736+
it("keeps an already answered ask on the throttled path", async () => {
1737+
const task = new Task({
1738+
provider: mockProvider,
1739+
apiConfiguration: mockApiConfig,
1740+
task: "test task",
1741+
startTask: false,
1742+
})
1743+
vi.spyOn(getTaskTestAccess(task), "saveClineMessages").mockResolvedValue(true)
1744+
1745+
await getTaskTestAccess(task).addToClineMessages({
1746+
ts: 1,
1747+
type: "ask",
1748+
ask: "tool",
1749+
isAnswered: true,
1750+
})
1751+
1752+
expect(mockProvider.postStateToWebviewThrottled).toHaveBeenCalledOnce()
1753+
expect(mockProvider.postStateToWebviewThrottled).toHaveBeenCalledWith()
1754+
expect(mockProvider.flushPostStateToWebviewThrottled).not.toHaveBeenCalled()
1755+
})
1756+
1757+
it("waits for a new partial message flush before a following message update", async () => {
1758+
const task = new Task({
1759+
provider: mockProvider,
1760+
apiConfiguration: mockApiConfig,
1761+
task: "test task",
1762+
startTask: false,
1763+
})
1764+
const taskAccess = getTaskTestAccess(task)
1765+
vi.spyOn(taskAccess, "saveClineMessages").mockResolvedValue(true)
1766+
let releaseFlush!: () => void
1767+
const pendingFlush = new Promise<void>((resolve) => {
1768+
releaseFlush = resolve
1769+
})
1770+
const flushSpy = vi.mocked(mockProvider.flushPostStateToWebviewThrottled).mockReturnValueOnce(pendingFlush)
1771+
const updatePostSpy = vi.mocked(mockProvider.postMessageToWebview)
1772+
const partialMessage = {
1773+
ts: 1,
1774+
type: "say" as const,
1775+
say: "text" as const,
1776+
text: "partial message",
1777+
partial: true,
1778+
}
1779+
let partialAddSettled = false
1780+
const addThenUpdate = taskAccess.addToClineMessages(partialMessage).then(async () => {
1781+
partialAddSettled = true
1782+
await taskAccess.updateClineMessage({ ...partialMessage, text: "updated partial" })
1783+
})
1784+
1785+
await Promise.resolve()
1786+
expect(mockProvider.postStateToWebviewThrottled).toHaveBeenCalledWith()
1787+
expect(flushSpy).toHaveBeenCalledWith()
1788+
expect(partialAddSettled).toBe(false)
1789+
expect(updatePostSpy).not.toHaveBeenCalled()
1790+
1791+
releaseFlush()
1792+
await addThenUpdate
1793+
1794+
expect(flushSpy.mock.invocationCallOrder[0]).toBeLessThan(updatePostSpy.mock.invocationCallOrder[0])
1795+
expect(updatePostSpy).toHaveBeenCalledWith({
1796+
type: "messageUpdated",
1797+
clineMessage: {
1798+
...partialMessage,
1799+
text: "updated partial",
1800+
},
1801+
})
1802+
})
1803+
})
1804+
16661805
describe("abortTask", () => {
16671806
it("should set abort flag and emit TaskAborted event", async () => {
16681807
const task = new Task({
@@ -1707,6 +1846,37 @@ describe("Cline", () => {
17071846
expect(disposeSpy).toHaveBeenCalled()
17081847
})
17091848

1849+
it("flushes pending state before TaskAborted and disposal while queue state is intact", async () => {
1850+
const task = new Task({
1851+
provider: mockProvider,
1852+
apiConfiguration: mockApiConfig,
1853+
task: "test task",
1854+
startTask: false,
1855+
})
1856+
let queuedMessagesAtFlush = -1
1857+
const flushSpy = vi.mocked(mockProvider.flushPostStateToWebviewThrottled).mockImplementation(async () => {
1858+
queuedMessagesAtFlush = task.messageQueueService.messages.length
1859+
})
1860+
const emitSpy = vi.spyOn(task, "emit")
1861+
const disposeSpy = vi.spyOn(task, "dispose").mockImplementation(() => {})
1862+
1863+
task.messageQueueService.addMessage("queued text")
1864+
await task.abortTask()
1865+
1866+
const taskAbortedCallIndex = (emitSpy.mock.calls as unknown[][]).findIndex(
1867+
([event]) => event === "taskAborted",
1868+
)
1869+
expect(taskAbortedCallIndex).toBeGreaterThanOrEqual(0)
1870+
expect(queuedMessagesAtFlush).toBe(1)
1871+
expect(flushSpy).toHaveBeenCalledWith()
1872+
expect(flushSpy.mock.invocationCallOrder[0]).toBeLessThan(
1873+
emitSpy.mock.invocationCallOrder[taskAbortedCallIndex],
1874+
)
1875+
expect(emitSpy.mock.invocationCallOrder[taskAbortedCallIndex]).toBeLessThan(
1876+
disposeSpy.mock.invocationCallOrder[0],
1877+
)
1878+
})
1879+
17101880
it("should work with TaskLike interface", async () => {
17111881
const task = new Task({
17121882
provider: mockProvider,
@@ -2741,6 +2911,50 @@ describe("Cline", () => {
27412911
})
27422912
})
27432913

2914+
describe("startTask", () => {
2915+
it("posts a clean state immediately before adding the first task message", async () => {
2916+
const task = new Task({
2917+
provider: mockProvider,
2918+
apiConfiguration: mockApiConfig,
2919+
task: "new task",
2920+
startTask: false,
2921+
})
2922+
const taskAccess = getTaskTestAccess(task)
2923+
2924+
task.clineMessages = [{ ts: 1, type: "say", say: "text", text: "stale message" }]
2925+
2926+
let resolvePostState: (() => void) | undefined
2927+
const pendingPostState = new Promise<void>((resolve) => {
2928+
resolvePostState = resolve
2929+
})
2930+
const postStateSpy = vi
2931+
.mocked(mockProvider.postStateToWebviewWithoutTaskHistory)
2932+
.mockImplementationOnce(async () => {
2933+
expect(task.clineMessages).toEqual([])
2934+
await pendingPostState
2935+
})
2936+
const saySpy = vi.spyOn(task, "say").mockResolvedValue(undefined)
2937+
vi.spyOn(taskAccess, "getEnabledMcpToolsCount").mockResolvedValue({
2938+
enabledToolCount: 0,
2939+
enabledServerCount: 0,
2940+
})
2941+
const initiateTaskLoopSpy = vi.spyOn(taskAccess, "initiateTaskLoop").mockResolvedValue(undefined)
2942+
2943+
const startPromise = taskAccess.startTask("new task")
2944+
2945+
expect(postStateSpy).toHaveBeenCalledTimes(1)
2946+
expect(mockProvider.postStateToWebviewThrottled).not.toHaveBeenCalled()
2947+
expect(saySpy).not.toHaveBeenCalled()
2948+
2949+
resolvePostState?.()
2950+
await startPromise
2951+
2952+
expect(saySpy).toHaveBeenCalledOnce()
2953+
expect(saySpy).toHaveBeenCalledWith("text", "new task", undefined)
2954+
expect(initiateTaskLoopSpy).toHaveBeenCalledOnce()
2955+
})
2956+
})
2957+
27442958
describe("start()", () => {
27452959
it("should be a no-op if the task was already started in the constructor", () => {
27462960
const task = new Task({
@@ -2859,9 +3073,9 @@ describe("Cline", () => {
28593073
resumeSpy.mockRestore()
28603074
})
28613075

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

28663080
const task = new Task({
28673081
provider: mockProvider,
@@ -2870,13 +3084,14 @@ describe("Cline", () => {
28703084
startTask: false,
28713085
})
28723086

2873-
// Triggers messageQueueStateChangedHandler -> void postStateToWebviewWithoutTaskHistory()
3087+
// Triggers messageQueueStateChangedHandler -> void postStateToWebviewThrottled()
28743088
task.messageQueueService.addMessage("queued text")
28753089
await flushMicrotasks()
28763090

2877-
expect(mockProvider.postStateToWebviewWithoutTaskHistory).toHaveBeenCalled()
3091+
expect(mockProvider.postStateToWebviewThrottled).toHaveBeenCalledWith()
3092+
expect(mockProvider.postStateToWebviewWithoutTaskHistory).not.toHaveBeenCalled()
28783093
expect(consoleErrorSpy).toHaveBeenCalledWith(
2879-
"[Task#messageQueueStateChangedHandler] postStateToWebviewWithoutTaskHistory failed:",
3094+
"[Task#messageQueueStateChangedHandler] postStateToWebviewThrottled failed:",
28803095
boom,
28813096
)
28823097
})

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

0 commit comments

Comments
 (0)