Skip to content

Commit b3f3347

Browse files
committed
test(e2e): drain delayed mock stream deterministically in subtask suite
The API-hang subtask fixture used aimock's flat latency, which applies per SSE chunk and is never interrupted by client disconnects, so a cancelled delayed stream stayed pending server-side for chunks x latency and could flush into the next test's traffic. - delay only the first chunk via streamingProfile.ttft so the pending window is exactly the shared SUBTASK_API_HANG_RESPONSE_LATENCY_MS - anchor the post-test drain to the request's aimock journal timestamp and wait out only the remainder of that bounded window
1 parent 5971caa commit b3f3347

2 files changed

Lines changed: 75 additions & 16 deletions

File tree

apps/vscode-e2e/src/fixtures/subtasks.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ export const SUBTASK_API_HANG_RESUME_MESSAGE = "Continue after provider hang."
3333
export const SUBTASK_API_HANG_CHILD_RESULT = "Hung child completed"
3434
export const SUBTASK_API_HANG_PARENT_RESULT = "API hang parent resumed"
3535

36+
// How long the API-hang child's first mocked response stays pending before its first SSE
37+
// chunk. Shared with the subtask suite so its post-test drain waits exactly one window.
38+
export const SUBTASK_API_HANG_RESPONSE_LATENCY_MS = 15_000
39+
3640
// Abandon-subtask scenario (#559) — separate markers to avoid sequenceIndex collisions with the
3741
// interrupted-child-resumes tests above, which exhaust the sequence count for INTERRUPT markers.
3842
const SUBTASK_ABANDON_PARENT_MARKER = "SUBTASK_PARENT_ABANDON_SEVER"
@@ -261,8 +265,15 @@ export function addSubtaskFixtures(mock: InstanceType<typeof LLMock>) {
261265
userMessage: apiHangChildMatch,
262266
sequenceIndex: 0,
263267
},
264-
// Keep the first child response pending long enough for the e2e test to cancel an in-flight API request.
265-
latency: 15_000,
268+
// Keep the first child response pending long enough for the e2e test to cancel an in-flight
269+
// API request. Delay only the first chunk (ttft) rather than using flat `latency`: aimock
270+
// applies `latency` to EVERY chunk and never observes client disconnects, so after the test
271+
// cancels, a flat-latency stream would stay pending server-side for chunks × latency before
272+
// flushing to the dead socket. With ttft the pending window is exactly
273+
// SUBTASK_API_HANG_RESPONSE_LATENCY_MS, which is what the suite's post-test drain waits out.
274+
// This relies on no flat `latency` (fixture or LLMock default) being set — a flat latency
275+
// would still apply to every chunk after the first.
276+
streamingProfile: { ttft: SUBTASK_API_HANG_RESPONSE_LATENCY_MS },
266277
response: {
267278
toolCalls: [
268279
{

apps/vscode-e2e/src/suite/subtasks.test.ts

Lines changed: 62 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -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 {
3334
type AimockMessageContent = string | Array<{ type?: string; text?: string }>
3435

3536
type 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

72113
suite("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

Comments
 (0)