Skip to content

Commit d43512f

Browse files
committed
refactor(core,sdk): drop the client-side caught-up close, rely on the server fast-close
1 parent cab3b03 commit d43512f

9 files changed

Lines changed: 20 additions & 424 deletions

File tree

.changeset/chat-session-caught-up-resume.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@
33
"@trigger.dev/sdk": patch
44
---
55

6-
Chat sessions now close a resumed stream as soon as it has caught up to the latest output, instead of holding the connection open for the full long-poll window. Reloading or reconnecting to an idle chat settles faster. This applies to the server-to-server `AgentChat` client's `reconnect()` too, not just the browser transport.
6+
Reconnecting to an idle chat now closes promptly instead of holding the connection open for the full long-poll window. When a resumed `.out` stream is already settled (the agent finished its turn), the server drains any residual records and closes right away, so a reload or reconnect settles in about a second instead of tens of seconds. `AgentChat.reconnect()` opts into the same fast-close.

apps/webapp/test/helpers/sessionStream.ts

Lines changed: 0 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -279,53 +279,3 @@ export async function collectSessionOut(
279279
return { parts, durationMs: performance.now() - started, subscription };
280280
}
281281

282-
/**
283-
* Subscribe + drain while awaiting the client's `caughtUp()`. Resolves once the
284-
* client reports it has drained to the live tail (or `maxMs` elapses). Used by
285-
* the GREEN legs: the drain is what lets the wrapper mark the tail boundary, so
286-
* `parts` holds everything delivered up to the moment caught-up fires — the
287-
* data-loss guard.
288-
*/
289-
export async function collectUntilCaughtUp(opts: SubscribeOptions & { maxMs?: number }): Promise<{
290-
parts: CollectedPart[];
291-
caughtUp: boolean;
292-
tailSeqNum?: number;
293-
settleMs: number;
294-
}> {
295-
const subscription = subscribeSessionOut(opts);
296-
const stream = await subscription.subscribe();
297-
const reader = stream.getReader();
298-
const parts: CollectedPart[] = [];
299-
const started = performance.now();
300-
301-
let caughtUp = false;
302-
let tailSeqNum: number | undefined;
303-
let settleMs = 0;
304-
subscription
305-
.caughtUp()
306-
.then((tail) => {
307-
caughtUp = true;
308-
tailSeqNum = tail.seqNum;
309-
settleMs = performance.now() - started;
310-
})
311-
.catch(() => {});
312-
313-
const deadline = started + (opts.maxMs ?? 30_000);
314-
try {
315-
while (!caughtUp) {
316-
const remaining = deadline - performance.now();
317-
if (remaining <= 0) break;
318-
const next = await Promise.race([
319-
reader.read(),
320-
new Promise<"tick">((r) => setTimeout(() => r("tick"), Math.min(remaining, 250))),
321-
]);
322-
if (next === "tick") continue;
323-
if (next.done) break;
324-
parts.push(next.value as CollectedPart);
325-
}
326-
} finally {
327-
await reader.cancel().catch(() => {});
328-
}
329-
330-
return { parts, caughtUp, tailSeqNum, settleMs: settleMs || performance.now() - started };
331-
}

apps/webapp/test/session-stream.e2e.test.ts

Lines changed: 3 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ import { seedTestEnvironment } from "./helpers/seedTestEnvironment";
1818
import {
1919
appendInput,
2020
collectSessionOut,
21-
collectUntilCaughtUp,
2221
isTurnComplete,
2322
mintSessionToken,
2423
openChannelRaw,
@@ -163,72 +162,27 @@ describe("session stream e2e", () => {
163162
expect(allData).toEqual([0, 1, 2, 3, 4]);
164163
});
165164

166-
it("E7 trim-at-tail: trim command record is filtered; caught-up still fires", async () => {
165+
it("E7 trim-at-tail: trim command record is filtered from the stream", async () => {
167166
const { addressingKey, token, producer, baseUrl } = await setupSession();
168167

169168
await producer.appendData({ n: 0 }, "p0");
170169
const keep = await producer.appendData({ n: 1 }, "p1");
171170
await producer.appendTurnComplete();
172171
await producer.trim(keep);
173172

174-
const { parts, caughtUp } = await collectUntilCaughtUp({
173+
const { parts } = await collectSessionOut({
175174
baseUrl,
176175
addressingKey,
177176
token,
177+
until: (p) => p.some(isTurnComplete),
178178
maxMs: 15_000,
179179
});
180180

181-
expect(caughtUp).toBe(true);
182181
const commandRecords = parts.filter((p) => (p.headers ?? []).some(([k]) => k === ""));
183182
expect(commandRecords).toHaveLength(0);
184183
expect(parts.filter((p) => p.chunk != null).length).toBeGreaterThanOrEqual(1);
185184
});
186185

187-
it("E3 quiescent reconnect (GREEN): client caught-up close settles at the tail", async () => {
188-
const { addressingKey, token, producer, baseUrl } = await setupSession();
189-
190-
await producer.appendData({ n: 0 }, "p0");
191-
await producer.appendData({ n: 1 }, "p1");
192-
const tc = await producer.appendTurnComplete();
193-
194-
const { parts, caughtUp, settleMs } = await collectUntilCaughtUp({
195-
baseUrl,
196-
addressingKey,
197-
token,
198-
lastEventId: String(tc),
199-
timeoutInSeconds: 30,
200-
maxMs: 15_000,
201-
});
202-
203-
expect(caughtUp).toBe(true);
204-
expect(settleMs).toBeLessThan(5_000);
205-
expect(parts.filter((p) => p.chunk != null)).toHaveLength(0);
206-
});
207-
208-
it("E4 backlog reaches tail (GREEN): every record is delivered before caught-up", async () => {
209-
const { addressingKey, token, producer, baseUrl } = await setupSession();
210-
211-
await producer.appendData({ n: 0 }, "p0");
212-
await producer.appendData({ n: 1 }, "p1");
213-
await producer.appendData({ n: 2 }, "p2");
214-
const tc = await producer.appendTurnComplete();
215-
216-
const { parts, caughtUp, tailSeqNum } = await collectUntilCaughtUp({
217-
baseUrl,
218-
addressingKey,
219-
token,
220-
maxMs: 15_000,
221-
});
222-
223-
expect(caughtUp).toBe(true);
224-
expect(tailSeqNum).toBe(tc + 1);
225-
const dataChunks = parts
226-
.filter((p) => !isTurnComplete(p) && p.chunk != null)
227-
.map((p) => (p.chunk as { n: number }).n);
228-
expect(dataChunks).toEqual([0, 1, 2]);
229-
expect(parts.some(isTurnComplete)).toBe(true);
230-
});
231-
232186
it("E8 in/append 413 for an oversized body still carries CORS headers", async () => {
233187
const { addressingKey, token, baseUrl } = await setupSession();
234188

docs/ai-chat/client-protocol.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -652,7 +652,7 @@ On **reconnect-on-reload** paths (resuming a chat where nothing may be streaming
652652
653653
**Do not send `X-Peek-Settled` on the active-send response-stream path.** The peek would race the newly-triggered turn's first chunk — if the agent hasn't written the new turn's first record yet, the peek sees the prior turn's `turn-complete` and closes the SSE before the response lands on S2. The built-in `TriggerChatTransport.reconnectToStream` sets the header; `sendMessages → subscribeToStream` does not.
654654
655-
If you use `TriggerChatTransport` (or `useChat`, which builds on it), the transport settles the stream for you: on the reconnect path it watches the `tail` on batch and ping events via `SSEStreamSubscription.caughtUp()` and closes the resumed stream as soon as it reaches the live edge, so a settled idle reconnect closes promptly without waiting out the long poll. `SSEStreamSubscription` on its own only exposes the caught-up signal, it does not close itself: consuming it directly, call `caughtUp()` (or compare your last received `seq_num` to the ping/batch `tail`) and close your own stream. On older self-hosted backends whose `ping` carries no `tail`, this falls back to the `X-Peek-Settled` behavior above.
655+
If you use `TriggerChatTransport` (or `useChat`, which builds on it), this is handled for you: the reconnect path sends `X-Peek-Settled`, so a settled idle reconnect closes promptly on the server's `wait=0` fast-close instead of waiting out the long poll. Consuming `.out` directly, send the same header on your reconnect reads and treat an `X-Session-Settled: true` response as a terminal close.
656656
657657
```ts
658658
// Reconnect path (page reload)

packages/core/src/v3/apiClient/index.ts

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1453,13 +1453,6 @@ export class ApiClient {
14531453
* enqueued into the consumer stream — handle the event here.
14541454
*/
14551455
onControl?: (event: ControlEvent) => void;
1456-
/**
1457-
* Fires once when the session reaches the live tail (backlog drained),
1458-
* with the observed tail position. No-op when the backend omits the
1459-
* tail-carrying heartbeat. Mirrors the browser transport's caught-up
1460-
* signal on the worker / apiClient read path.
1461-
*/
1462-
onCaughtUp?: (tail: { seqNum: number; timestamp: Date }) => void;
14631456
}
14641457
): Promise<AsyncIterableStream<T>> {
14651458
const url = `${options?.baseUrl ?? this.baseUrl}/realtime/v1/sessions/${encodeURIComponent(sessionIdOrExternalId)}/${io}`;
@@ -1474,12 +1467,6 @@ export class ApiClient {
14741467
});
14751468

14761469
const stream = await subscription.subscribe();
1477-
if (options?.onCaughtUp) {
1478-
subscription
1479-
.caughtUp()
1480-
.then(options.onCaughtUp)
1481-
.catch(() => {});
1482-
}
14831470
const onPart = options?.onPart;
14841471
const onControl = options?.onControl;
14851472

packages/core/src/v3/apiClient/runStream.test.ts

Lines changed: 0 additions & 203 deletions
Original file line numberDiff line numberDiff line change
@@ -640,206 +640,3 @@ describe("SSEStreamSubscription v2 batch parsing — record kinds", () => {
640640
expect((parts[1]!.chunk as any).delta).toBe("x");
641641
});
642642
});
643-
644-
describe("SSEStreamSubscription caught-up tracking", () => {
645-
const originalFetch = globalThis.fetch;
646-
647-
afterEach(() => {
648-
globalThis.fetch = originalFetch;
649-
vi.restoreAllMocks();
650-
});
651-
652-
type Rec = {
653-
body: string;
654-
seq_num: number;
655-
timestamp: number;
656-
headers?: Array<[string, string]>;
657-
};
658-
type Tail = { seq_num: number; timestamp: number };
659-
660-
function dataRec(seq: number): Rec {
661-
return {
662-
body: JSON.stringify({ data: { type: "text-delta", delta: "x" }, id: `p${seq}` }),
663-
seq_num: seq,
664-
timestamp: 1,
665-
headers: [],
666-
};
667-
}
668-
669-
function batchEvent(records: Rec[], tail?: Tail): string {
670-
const data = tail ? { records, tail } : { records };
671-
return `event: batch\ndata: ${JSON.stringify(data)}\n\n`;
672-
}
673-
674-
function pingEvent(tail?: Tail): string {
675-
const data = tail ? { timestamp: 1, tail } : { timestamp: 1 };
676-
return `event: ping\ndata: ${JSON.stringify(data)}\n\n`;
677-
}
678-
679-
function makeEventsResponse(events: string[]) {
680-
const body = new ReadableStream<Uint8Array>({
681-
start(controller) {
682-
for (const e of events) controller.enqueue(new TextEncoder().encode(e));
683-
controller.close();
684-
},
685-
});
686-
return new Response(body, {
687-
status: 200,
688-
headers: { "Content-Type": "text/event-stream", "X-Stream-Version": "v2" },
689-
});
690-
}
691-
692-
async function drain(stream: ReadableStream<{ id: string; chunk: unknown }>) {
693-
const reader = stream.getReader();
694-
const parts: Array<{ id: string; chunk: unknown }> = [];
695-
while (true) {
696-
const { done, value } = await reader.read();
697-
if (done) {
698-
reader.releaseLock();
699-
return parts;
700-
}
701-
parts.push(value);
702-
}
703-
}
704-
705-
it("resolves caughtUp() when a batch reaches the reported tail", async () => {
706-
globalThis.fetch = vi
707-
.fn()
708-
.mockResolvedValue(
709-
makeEventsResponse([
710-
batchEvent([dataRec(0), dataRec(1), dataRec(2)], { seq_num: 3, timestamp: 1 }),
711-
])
712-
);
713-
const sub = new SSEStreamSubscription("http://x", { maxRetries: 0 });
714-
const stream = await sub.subscribe();
715-
const cu = sub.caughtUp();
716-
await drain(stream);
717-
const tail = await cu;
718-
expect(tail.seqNum).toBe(3);
719-
expect(sub.isCaughtUp()).toBe(true);
720-
});
721-
722-
it("stays behind when the batch does not reach the tail", async () => {
723-
globalThis.fetch = vi
724-
.fn()
725-
.mockResolvedValue(
726-
makeEventsResponse([batchEvent([dataRec(0)], { seq_num: 3, timestamp: 1 })])
727-
);
728-
const sub = new SSEStreamSubscription("http://x", { maxRetries: 0 });
729-
const stream = await sub.subscribe();
730-
await drain(stream);
731-
expect(sub.isCaughtUp()).toBe(false);
732-
});
733-
734-
it("resolves caughtUp() from a ping tail with an empty backlog (open-at-tail)", async () => {
735-
globalThis.fetch = vi
736-
.fn()
737-
.mockResolvedValue(makeEventsResponse([pingEvent({ seq_num: 3, timestamp: 1 })]));
738-
const sub = new SSEStreamSubscription("http://x", { maxRetries: 0 });
739-
const stream = await sub.subscribe();
740-
const cu = sub.caughtUp();
741-
await drain(stream);
742-
const tail = await cu;
743-
expect(tail.seqNum).toBe(3);
744-
expect(sub.isCaughtUp()).toBe(true);
745-
});
746-
747-
it("reaches caught-up when the tail is a trim command record (raw counts include it)", async () => {
748-
globalThis.fetch = vi.fn().mockResolvedValue(
749-
makeEventsResponse([
750-
batchEvent(
751-
[
752-
dataRec(0),
753-
{
754-
body: "",
755-
seq_num: 1,
756-
timestamp: 1,
757-
headers: [["trigger-control", "turn-complete"]],
758-
},
759-
{ body: "AAAAAAAAAAQ=", seq_num: 2, timestamp: 1, headers: [["", "trim"]] },
760-
],
761-
{ seq_num: 3, timestamp: 1 }
762-
),
763-
])
764-
);
765-
const sub = new SSEStreamSubscription("http://x", { maxRetries: 0 });
766-
const stream = await sub.subscribe();
767-
const cu = sub.caughtUp();
768-
const parts = await drain(stream);
769-
const tail = await cu;
770-
expect(tail.seqNum).toBe(3);
771-
expect(sub.isCaughtUp()).toBe(true);
772-
expect(parts).toHaveLength(2);
773-
});
774-
775-
it("never reaches caught-up when the wire carries no tail (feature-detect fallback)", async () => {
776-
globalThis.fetch = vi
777-
.fn()
778-
.mockResolvedValue(
779-
makeEventsResponse([batchEvent([dataRec(0), dataRec(1), dataRec(2)]), pingEvent()])
780-
);
781-
const sub = new SSEStreamSubscription("http://x", { maxRetries: 0 });
782-
const stream = await sub.subscribe();
783-
await drain(stream);
784-
expect(sub.isCaughtUp()).toBe(false);
785-
});
786-
787-
it("does not resolve caughtUp() until the consumer drains the tail", async () => {
788-
globalThis.fetch = vi
789-
.fn()
790-
.mockResolvedValue(
791-
makeEventsResponse([
792-
batchEvent([dataRec(0), dataRec(1), dataRec(2)], { seq_num: 3, timestamp: 1 }),
793-
])
794-
);
795-
const sub = new SSEStreamSubscription("http://x", { maxRetries: 0 });
796-
const stream = await sub.subscribe();
797-
const cu = sub.caughtUp();
798-
let resolved = false;
799-
cu.then(() => {
800-
resolved = true;
801-
}).catch(() => {});
802-
803-
await new Promise((r) => setTimeout(r, 20));
804-
expect(resolved).toBe(false);
805-
expect(sub.isCaughtUp()).toBe(false);
806-
807-
const parts = await drain(stream);
808-
const tail = await cu;
809-
expect(tail.seqNum).toBe(3);
810-
expect(parts).toHaveLength(3);
811-
expect(sub.isCaughtUp()).toBe(true);
812-
});
813-
814-
it("holds caughtUp() until the final record before the tail is consumed", async () => {
815-
globalThis.fetch = vi
816-
.fn()
817-
.mockResolvedValue(
818-
makeEventsResponse([
819-
batchEvent([dataRec(0), dataRec(1), dataRec(2)], { seq_num: 3, timestamp: 1 }),
820-
])
821-
);
822-
const sub = new SSEStreamSubscription("http://x", { maxRetries: 0 });
823-
const stream = await sub.subscribe();
824-
const cu = sub.caughtUp();
825-
let resolved = false;
826-
cu.then(() => {
827-
resolved = true;
828-
}).catch(() => {});
829-
const reader = stream.getReader();
830-
831-
const first = await reader.read();
832-
expect(first.value?.id).toBe("0");
833-
await reader.read();
834-
await reader.read();
835-
await new Promise((r) => setTimeout(r, 20));
836-
expect(resolved).toBe(false);
837-
838-
const done = await reader.read();
839-
expect(done.done).toBe(true);
840-
await new Promise((r) => setTimeout(r, 20));
841-
expect(resolved).toBe(true);
842-
expect(sub.isCaughtUp()).toBe(true);
843-
reader.releaseLock();
844-
});
845-
});

0 commit comments

Comments
 (0)