@@ -2,7 +2,8 @@ import * as assert from "assert"
22
33import { isSecretStateKey , RooCodeEventName , type ClineMessage , type GlobalState } from "@roo-code/types"
44
5- import { waitUntilCompleted } from "./utils"
5+ import { getFollowupModeIsolationPlan } from "../fixtures/view-state"
6+ import { sleep , waitFor , waitUntilCompleted } from "./utils"
67import { setDefaultSuiteTimeout } from "./test-utils"
78
89const findSecretStatePath = ( value : unknown , path : string [ ] = [ ] ) : string | undefined => {
@@ -112,4 +113,157 @@ suite("Roo Code View State", function () {
112113 globalThis . api . off ( RooCodeEventName . Message , completionHandler )
113114 }
114115 } )
116+ test ( "three panels keep follow-up option mode switches isolated across ten staggered rounds" , async ( ) => {
117+ const plan = getFollowupModeIsolationPlan ( )
118+ const modeEvents : Array < { taskId : string ; mode : string } > = [ ]
119+ const taskIds = new Map < string , string > ( )
120+ const taskNamesById = new Map < string , string > ( )
121+ const pendingSuggestions = new Map < string , { answer : string ; mode ?: string } > ( )
122+ const answeredSuggestions = new Set < string > ( )
123+ const suggestionKey = ( taskId : string , answer : string ) => `${ taskId } :${ answer } `
124+ let releasedRounds = 0
125+ let roundInFlight = false
126+
127+ const taskIdsInPlanOrder = ( ) =>
128+ plan . map ( ( taskPlan ) => taskIds . get ( taskPlan . taskName ) ) . filter ( ( taskId ) : taskId is string => ! ! taskId )
129+ const modeCountForTask = ( taskId : string ) => modeEvents . filter ( ( event ) => event . taskId === taskId ) . length
130+
131+ const maybeReleaseRound = ( ) => {
132+ if ( roundInFlight || taskIds . size !== plan . length ) {
133+ return
134+ }
135+
136+ const taskIdsInOrder = taskIdsInPlanOrder ( )
137+ if (
138+ taskIdsInOrder . length !== plan . length ||
139+ ! taskIdsInOrder . every ( ( taskId ) => pendingSuggestions . has ( taskId ) )
140+ ) {
141+ return
142+ }
143+
144+ roundInFlight = true
145+ releasedRounds ++
146+
147+ for ( const taskId of taskIdsInOrder ) {
148+ const suggestion = pendingSuggestions . get ( taskId )
149+ assert . ok ( suggestion , `Expected pending suggestion for task ${ taskId } ` )
150+ pendingSuggestions . delete ( taskId )
151+ answeredSuggestions . add ( suggestionKey ( taskId , suggestion . answer ) )
152+ void globalThis . api . selectTaskFollowupSuggestion ( { taskId, ...suggestion } )
153+ }
154+ }
155+
156+ const messageHandler = ( { taskId, message } : { taskId : string ; message : ClineMessage } ) => {
157+ if ( message . type === "ask" && message . ask === "followup" && message . text ) {
158+ try {
159+ const parsed = JSON . parse ( message . text ) as { suggest ?: Array < { answer : string ; mode ?: string } > }
160+ const suggestion = parsed . suggest ?. [ 0 ]
161+
162+ if ( suggestion && ! answeredSuggestions . has ( suggestionKey ( taskId , suggestion . answer ) ) ) {
163+ pendingSuggestions . set ( taskId , suggestion )
164+ maybeReleaseRound ( )
165+ }
166+ } catch {
167+ // Ignore partial or malformed follow-up payloads.
168+ }
169+ }
170+
171+ if ( message . type === "ask" && message . ask === "completion_result" ) {
172+ void globalThis . api . approveTaskAsk ( taskId )
173+ }
174+ }
175+ const modeHandler = ( taskId : string , mode : string ) => {
176+ modeEvents . push ( { taskId, mode } )
177+
178+ if ( roundInFlight && taskIdsInPlanOrder ( ) . every ( ( id ) => modeCountForTask ( id ) >= releasedRounds ) ) {
179+ roundInFlight = false
180+ maybeReleaseRound ( )
181+ }
182+ }
183+
184+ globalThis . api . on ( RooCodeEventName . Message , messageHandler )
185+ globalThis . api . on ( RooCodeEventName . TaskModeSwitched , modeHandler )
186+
187+ try {
188+ for ( const [ index , taskPlan ] of plan . entries ( ) ) {
189+ if ( index > 0 ) {
190+ await sleep ( 1_000 )
191+ }
192+
193+ const taskId = await globalThis . api . startNewTask ( {
194+ configuration : {
195+ mode : "code" ,
196+ alwaysAllowModeSwitch : true ,
197+ autoApprovalEnabled : true ,
198+ apiKey : `followup-secret-${ taskPlan . taskName } -must-not-persist` ,
199+ } ,
200+ text : taskPlan . marker ,
201+ newTab : true ,
202+ preserveOpenTabs : index > 0 ,
203+ } )
204+ taskIds . set ( taskPlan . taskName , taskId )
205+ taskNamesById . set ( taskId , taskPlan . taskName )
206+ maybeReleaseRound ( )
207+ }
208+
209+ await waitFor (
210+ ( ) => {
211+ const expectedSwitches = plan . length * 10
212+ return modeEvents . length >= expectedSwitches
213+ } ,
214+ { timeout : 30_000 } ,
215+ ) . catch ( ( error ) => {
216+ const counts = plan . map ( ( taskPlan ) => {
217+ const taskId = taskIds . get ( taskPlan . taskName )
218+ return `${ taskPlan . taskName } :${ taskId ? modeCountForTask ( taskId ) : 0 } `
219+ } )
220+ throw new Error (
221+ `Timed out after ${ releasedRounds } coordinated rounds; mode event counts: ${ counts . join ( ", " ) } ; pending suggestions: ${ pendingSuggestions . size } . ${ error instanceof Error ? error . message : String ( error ) } ` ,
222+ )
223+ } )
224+
225+ for ( let roundIndex = 0 ; roundIndex < 10 ; roundIndex ++ ) {
226+ const actualRoundModes = plan . map ( ( taskPlan ) => {
227+ const taskId = taskIds . get ( taskPlan . taskName )
228+ assert . ok ( taskId , `Expected task id for task ${ taskPlan . taskName } ` )
229+ return modeEvents . filter ( ( event ) => event . taskId === taskId ) . map ( ( event ) => event . mode ) [ roundIndex ]
230+ } )
231+ const expectedRoundModes = plan . map ( ( taskPlan ) => {
232+ const round = taskPlan . rounds [ roundIndex ]
233+ assert . ok ( round , `Expected round ${ roundIndex + 1 } for task ${ taskPlan . taskName } ` )
234+ return round . mode
235+ } )
236+
237+ assert . deepStrictEqual (
238+ actualRoundModes ,
239+ expectedRoundModes ,
240+ `Round ${ roundIndex + 1 } should count only after all three tasks switch once` ,
241+ )
242+ }
243+
244+ for ( const taskPlan of plan ) {
245+ const taskId = taskIds . get ( taskPlan . taskName )
246+ assert . ok ( taskId , `Expected task id for task ${ taskPlan . taskName } ` )
247+ assert . deepStrictEqual (
248+ modeEvents . filter ( ( event ) => event . taskId === taskId ) . map ( ( event ) => event . mode ) ,
249+ taskPlan . rounds . map ( ( round ) => round . mode ) ,
250+ )
251+ }
252+
253+ const viewStates = globalThis . api . getGlobalState ( "viewStates" ) as GlobalState [ "viewStates" ]
254+ assert . ok ( viewStates , "Expected persisted viewStates to exist" )
255+
256+ for ( const [ viewStateId , entry ] of Object . entries ( viewStates ) ) {
257+ const secretStatePath = findSecretStatePath ( entry )
258+ assert . strictEqual (
259+ secretStatePath ,
260+ undefined ,
261+ `Persisted viewStates.${ viewStateId } leaked secret state at ${ secretStatePath } ` ,
262+ )
263+ }
264+ } finally {
265+ globalThis . api . off ( RooCodeEventName . Message , messageHandler )
266+ globalThis . api . off ( RooCodeEventName . TaskModeSwitched , modeHandler )
267+ }
268+ } )
115269} )
0 commit comments