@@ -8,6 +8,7 @@ import { Anthropic } from "@anthropic-ai/sdk"
88
99import {
1010 providerIdentifiers ,
11+ RooCodeEventName ,
1112 type GlobalState ,
1213 type ProviderSettings ,
1314 type ModelInfo ,
@@ -27,9 +28,12 @@ import type { ApiMessage } from "../../task-persistence"
2728
2829type 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 } )
0 commit comments