Skip to content

Commit 243289c

Browse files
committed
test(webapp): drain the session .out collector across reconnects
The e2e .out collector subscribed once and stopped at the first graceful SSE close. The proxy serves a bounded wait window and then closes; real clients reconnect with Last-Event-ID to keep draining. A pre-populated stream fits one window, so the wire legs passed, but a streaming agent turn spans several windows and a slower runner reliably lands the content past the first close, leaving the collector with empty output. collectSessionOut now re-subscribes from the last seq it saw (deduping) until its predicate holds or the deadline passes, matching how the session manager consumes the channel.
1 parent de4c3c7 commit 243289c

1 file changed

Lines changed: 47 additions & 22 deletions

File tree

apps/webapp/test/helpers/sessionStream.ts

Lines changed: 47 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -219,37 +219,62 @@ export function subscribeSessionOut(opts: SubscribeOptions): SSEStreamSubscripti
219219
}
220220

221221
/**
222-
* Subscribe + drain parts into an array, stopping when `until(parts)` is true,
223-
* the stream closes, or `maxMs` elapses. Cancels the reader on exit. Returns
224-
* the parts plus how many distinct SSE connections/opens the subscription made
225-
* (for asserting the round-trip count on a reconnect).
222+
* Subscribe + drain parts into an array, stopping when `until(parts)` is true
223+
* or `maxMs` elapses. Cancels the reader on exit.
224+
*
225+
* The `.out` proxy serves a bounded `wait=N` window and then closes the SSE;
226+
* real clients (and the run-engine session manager) reconnect with
227+
* `Last-Event-ID` to keep draining a turn whose output streams across that
228+
* boundary. A streaming agent routinely spans several windows, so when an
229+
* `until` predicate is supplied this re-subscribes past each graceful close
230+
* (deduping by seq, resuming from the last one seen) until the predicate holds
231+
* or the deadline passes. Without `until` it reads a single window, matching a
232+
* one-shot "read what's there" call.
226233
*/
227234
export async function collectSessionOut(
228235
opts: SubscribeOptions & { until?: (parts: CollectedPart[]) => boolean; maxMs?: number }
229236
): Promise<{ parts: CollectedPart[]; durationMs: number; subscription: SSEStreamSubscription }> {
230-
const subscription = subscribeSessionOut(opts);
231-
const stream = await subscription.subscribe();
232-
const reader = stream.getReader();
233237
const parts: CollectedPart[] = [];
238+
const seen = new Set<string>();
234239
const started = performance.now();
235240
const deadline = started + (opts.maxMs ?? 30_000);
241+
let lastEventId = opts.lastEventId;
242+
let subscription: SSEStreamSubscription;
236243

237-
try {
238-
while (true) {
239-
if (opts.until && opts.until(parts)) break;
240-
const remaining = deadline - performance.now();
241-
if (remaining <= 0) break;
242-
const next = await Promise.race([
243-
reader.read(),
244-
new Promise<"timeout">((r) => setTimeout(() => r("timeout"), remaining)),
245-
]);
246-
if (next === "timeout") break;
247-
if (next.done) break;
248-
parts.push(next.value as CollectedPart);
244+
do {
245+
subscription = subscribeSessionOut({ ...opts, lastEventId });
246+
const stream = await subscription.subscribe();
247+
const reader = stream.getReader();
248+
let gotNew = false;
249+
try {
250+
while (true) {
251+
if (opts.until && opts.until(parts)) break;
252+
const remaining = deadline - performance.now();
253+
if (remaining <= 0) break;
254+
const next = await Promise.race([
255+
reader.read(),
256+
new Promise<"timeout">((r) => setTimeout(() => r("timeout"), remaining)),
257+
]);
258+
if (next === "timeout") break;
259+
if (next.done) break;
260+
const part = next.value as CollectedPart;
261+
if (part.id) {
262+
if (seen.has(part.id)) continue;
263+
seen.add(part.id);
264+
lastEventId = part.id;
265+
}
266+
parts.push(part);
267+
gotNew = true;
268+
}
269+
} finally {
270+
await reader.cancel().catch(() => {});
249271
}
250-
} finally {
251-
await reader.cancel().catch(() => {});
252-
}
272+
273+
if (!opts.until || opts.until(parts) || performance.now() >= deadline) break;
274+
if (!gotNew) {
275+
await new Promise((r) => setTimeout(r, 100));
276+
}
277+
} while (true);
253278

254279
return { parts, durationMs: performance.now() - started, subscription };
255280
}

0 commit comments

Comments
 (0)