Skip to content

Commit 71f02ce

Browse files
committed
x-peek-settled header
1 parent 4453a45 commit 71f02ce

4 files changed

Lines changed: 34 additions & 4 deletions

File tree

.server-changes/session-out-settled-signal.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ area: webapp
33
type: improvement
44
---
55

6-
`/realtime/v1/sessions/:session/out` now peeks the tail record in S2 at connection time. If the last chunk is `trigger:turn-complete` (agent finished a turn and is either idle-waiting on `.in` or has exited), the downstream S2 read uses `wait=0` so the SSE drains and closes immediately instead of holding the connection open for 60s. The response also carries `X-Session-Settled: true` so the client can tell the close is terminal rather than a normal long-poll cycle.
6+
`/realtime/v1/sessions/:session/out` accepts an opt-in `X-Peek-Settled: 1` request header. When set, the route peeks the tail record in S2 before proxying; if the last chunk is `trigger:turn-complete`, it switches the downstream read to `wait=0` and returns `X-Session-Settled: true` so the SSE drains-and-closes in ~1s instead of long-polling for 60s.
77

8-
Lets `TriggerChatTransport.reconnectToStream` return quickly on page reloads of settled chats without requiring callers to persist an `isStreaming` flag — the server decides from the stream's own tail. Mid-turn tails still take the 60s long-poll path unchanged.
8+
Without the header, the route behaves exactly as before the settled work — unconditional `wait=60`. This matters because the peek races a newly-triggered turn's first chunk: the active `sendMessages → subscribeToSessionStream` path would otherwise see the previous turn's `trigger:turn-complete` at the tail and close the SSE before the new turn's chunks land on S2. The smoke test confirmed this race was failing every turn-2 response.
9+
10+
`TriggerChatTransport.reconnectToStream` opts in via the header (that's the reload-on-a-settled-chat case where the fast close is a real UX win). Active send paths don't set the header and keep long-poll semantics.

apps/webapp/app/routes/realtime.v1.sessions.$session.$io.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,12 +124,20 @@ const loader = createLoaderApiRoute(
124124
timeoutInSeconds = parsed;
125125
}
126126

127+
// Opt-in: only consider the settled-peek shortcut when the client
128+
// asks for it via `X-Peek-Settled: 1`. Reconnect-on-reload paths
129+
// (`TriggerChatTransport.reconnectToStream`) set this; the active
130+
// send-a-message path (`sendMessages → subscribeToSessionStream`)
131+
// does not — otherwise the peek races with the newly-triggered
132+
// turn's first chunk and the SSE closes before records land.
133+
const peekSettled = request.headers.get("X-Peek-Settled") === "1";
134+
127135
return realtimeStream.streamResponseFromSessionStream(
128136
request,
129137
session.friendlyId,
130138
params.io,
131139
getRequestAbortSignal(),
132-
{ lastEventId, timeoutInSeconds }
140+
{ lastEventId, timeoutInSeconds, peekSettled }
133141
);
134142
}
135143
);

apps/webapp/app/services/realtime/s2realtimeStreams.server.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -318,8 +318,17 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor {
318318
let waitSeconds = options?.timeoutInSeconds ?? this.s2WaitSeconds;
319319
let settled = false;
320320

321-
if (io === "out") {
321+
// Only peek + settle when the client opts in via `options.peekSettled`.
322+
// Reconnect-on-reload paths (`TriggerChatTransport.reconnectToStream`)
323+
// set it; active send-a-message paths don't — otherwise the peek
324+
// races the newly-triggered turn's first chunk and the SSE closes
325+
// before records land.
326+
if (io === "out" && options?.peekSettled) {
322327
const lastChunk = await this.#peekLastChunkBody(s2Stream);
328+
const lastChunkType =
329+
lastChunk != null && typeof lastChunk === "object"
330+
? (lastChunk as { type?: unknown }).type
331+
: null;
323332
if (
324333
lastChunk != null &&
325334
typeof lastChunk === "object" &&

apps/webapp/app/services/realtime/types.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,17 @@ export interface StreamIngestor {
3333
export type StreamResponseOptions = {
3434
timeoutInSeconds?: number;
3535
lastEventId?: string;
36+
/**
37+
* Session-stream-only. When `true`, the responder MAY peek the tail
38+
* of `.out` and short-circuit to `wait=0` + `X-Session-Settled: true`
39+
* if the last chunk is a terminal marker (e.g. `trigger:turn-complete`).
40+
* Used by `TriggerChatTransport.reconnectToStream` on page reload.
41+
*
42+
* When absent/false, the responder keeps the unconditional long-poll
43+
* behavior — required on the active send-a-message path where the
44+
* peek would race the newly-triggered turn's first chunk.
45+
*/
46+
peekSettled?: boolean;
3647
};
3748

3849
// Interface for stream response

0 commit comments

Comments
 (0)