@@ -16,6 +16,7 @@ import {
1616 SUBTASK_API_HANG_PARENT_MARKER ,
1717 SUBTASK_API_HANG_PARENT_PROMPT ,
1818 SUBTASK_API_HANG_PARENT_RESULT ,
19+ SUBTASK_API_HANG_RESPONSE_LATENCY_MS ,
1920 SUBTASK_API_HANG_RESUME_MESSAGE ,
2021 SUBTASK_CHILD_FOLLOWUP_ANSWER ,
2122 SUBTASK_FAST_CHILD_RESULT ,
@@ -33,6 +34,7 @@ import {
3334type AimockMessageContent = string | Array < { type ?: string ; text ?: string } >
3435
3536type AimockJournalEntry = {
37+ timestamp ?: number
3638 body ?: {
3739 messages ?: Array < {
3840 role ?: string
@@ -49,24 +51,63 @@ const messageContentText = (content?: AimockMessageContent) => {
4951 return content ?. map ( ( part ) => part . text ?? "" ) . join ( "" ) ?? ""
5052}
5153
52- const waitForAimockRequestContaining = async ( expectedText : string , excludeText ?: string ) => {
54+ const fetchAimockJournal = async ( ) => {
5355 const aimockUrl = process . env . AIMOCK_URL
5456 assert . ok ( aimockUrl , "AIMOCK_URL must be set for aimock journal assertions" )
5557
58+ const response = await fetch ( `${ aimockUrl } /__aimock/journal` )
59+ return ( await response . json ( ) ) as AimockJournalEntry [ ]
60+ }
61+
62+ const findAimockRequest = ( entries : AimockJournalEntry [ ] , expectedText : string , excludeText ?: string ) =>
63+ entries . find ( ( entry ) => {
64+ const messages = entry . body ?. messages
65+ if ( ! messages ) return false
66+ const entryText = messages . map ( ( m ) => messageContentText ( m . content ) ) . join ( "" )
67+ if ( excludeText && entryText . includes ( excludeText ) ) return false
68+ return messages . some (
69+ ( message ) => message . role === "user" && messageContentText ( message . content ) . includes ( expectedText ) ,
70+ )
71+ } )
72+
73+ // Returns the journal timestamp of the matching request so callers can anchor
74+ // post-test drains to the exact request this test created.
75+ const waitForAimockRequestContaining = async ( expectedText : string , excludeText ?: string ) => {
76+ let matchedAt : number | undefined
77+
5678 await waitFor ( async ( ) => {
57- const response = await fetch ( `${ aimockUrl } /__aimock/journal` )
58- const entries = ( await response . json ( ) ) as AimockJournalEntry [ ]
59-
60- return entries . some ( ( entry ) => {
61- const messages = entry . body ?. messages
62- if ( ! messages ) return false
63- const entryText = messages . map ( ( m ) => messageContentText ( m . content ) ) . join ( "" )
64- if ( excludeText && entryText . includes ( excludeText ) ) return false
65- return messages . some (
66- ( message ) => message . role === "user" && messageContentText ( message . content ) . includes ( expectedText ) ,
67- )
68- } )
79+ matchedAt = findAimockRequest ( await fetchAimockJournal ( ) , expectedText , excludeText ) ?. timestamp
80+ return matchedAt !== undefined
6981 } )
82+
83+ return matchedAt
84+ }
85+
86+ // Grace period after the delayed window for aimock to flush the stream's remaining
87+ // chunks to the dead socket.
88+ const SUBTASK_API_HANG_DRAIN_GRACE_MS = 500
89+
90+ // aimock does not observe client disconnects: after the API-hang child request is cancelled,
91+ // the mock keeps the delayed stream pending server-side until the fixture's ttft has fully
92+ // elapsed, then flushes the remaining chunks to the dead socket. A streamed request opened by
93+ // the next test can interleave with that late flush, so wait out the remainder of the delayed
94+ // window before the next test runs. The deadline is anchored to the journal timestamp of the
95+ // request this test created (never earlier traffic), and bounded by one latency window plus
96+ // grace, so it cannot hide a genuine hang.
97+ const waitForDelayedSubtaskStreamDrain = async ( delayedRequestStartedAt : number | undefined ) => {
98+ if ( delayedRequestStartedAt === undefined ) {
99+ // The delayed request never reached the mock (the test failed before cancelling
100+ // an in-flight request), so there is no delayed stream to drain.
101+ return
102+ }
103+
104+ const drainDeadlineMs =
105+ delayedRequestStartedAt + SUBTASK_API_HANG_RESPONSE_LATENCY_MS + SUBTASK_API_HANG_DRAIN_GRACE_MS
106+ const remainingMs = drainDeadlineMs - Date . now ( )
107+
108+ if ( remainingMs > 0 ) {
109+ await sleep ( remainingMs )
110+ }
70111}
71112
72113suite ( "Roo Code Subtasks" , function ( ) {
@@ -482,6 +523,7 @@ suite("Roo Code Subtasks", function () {
482523 const api = globalThis . api
483524 const asks : Record < string , ClineMessage [ ] > = { }
484525 const says : Record < string , ClineMessage [ ] > = { }
526+ let delayedChildRequestStartedAt : number | undefined
485527
486528 const messageHandler = ( { taskId, message } : { taskId : string ; message : ClineMessage } ) => {
487529 if ( message . type === "ask" ) {
@@ -519,7 +561,10 @@ suite("Roo Code Subtasks", function () {
519561 return false
520562 } )
521563
522- await waitForAimockRequestContaining ( SUBTASK_API_HANG_CHILD_MARKER , SUBTASK_API_HANG_PARENT_MARKER )
564+ delayedChildRequestStartedAt = await waitForAimockRequestContaining (
565+ SUBTASK_API_HANG_CHILD_MARKER ,
566+ SUBTASK_API_HANG_PARENT_MARKER ,
567+ )
523568
524569 await api . cancelCurrentTask ( )
525570
@@ -580,6 +625,9 @@ suite("Roo Code Subtasks", function () {
580625 await api . clearCurrentTask ( )
581626 }
582627 await waitFor ( ( ) => api . getCurrentTaskStack ( ) . length === 0 ) . catch ( ( ) => { } )
628+ // Drain the cancelled delayed stream before the next test can open another
629+ // streamed request against the mock.
630+ await waitForDelayedSubtaskStreamDrain ( delayedChildRequestStartedAt )
583631 }
584632 } )
585633
0 commit comments