Skip to content

Commit 9b3a7bd

Browse files
authored
fix(sdk,webapp): stop chat losing a message sent right after an action (#4234)
## Summary Sending a chat message immediately after an action (for example an undo) could make the message's response vanish from the UI. The transport opened a response stream that closed on the *earlier* turn's completion instead of waiting for the send's own turn. The agent still produced and persisted the answer, so it reappeared on refresh. Same "disappearing message" class as [#4176](#4176), different cause. ## Fix A send's response stream had no way to tell whether a `turn-complete` belonged to its turn. `POST /realtime/v1/sessions/:id/in/append` now returns the appended record's sequence number, and the transport skips any turn-complete whose `session-in-event-id` (the agent's committed `.in` cursor) is below that seq, closing only on its own turn. Older webapps omit the seq, in which case the transport falls back to the previous behavior, so the SDK and server can ship independently. Because the fix spans the SDK and the server, both a webapp deploy and an SDK release are needed for the full effect. Verified end to end with the ai-chat reference app: undo-then-immediate-send loses the follow-up's answer before the fix and streams it inline after, with a revert-the-guard run reproducing the loss on the same script. Unit tests cover the skip and the no-seq fallback.
1 parent 5d0e9d9 commit 9b3a7bd

7 files changed

Lines changed: 174 additions & 39 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
---
4+
5+
Fix a `chat.agent` message-loss race where sending a message right after an action (such as an undo) could drop the follow-up's response from the UI until a refresh.

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,10 +156,12 @@ const { action, loader } = createActionApiRoute(
156156
)
157157
: true;
158158

159+
let appendSeq: number | undefined;
159160
if (wonClaim) {
160-
const [appendError] = await tryCatch(
161+
const [appendError, seq] = await tryCatch(
161162
realtimeStream.appendPartToSessionStream(part, partId, addressingKey, params.io)
162163
);
164+
appendSeq = seq ?? undefined;
163165

164166
if (appendError) {
165167
if (clientPartId) {
@@ -228,7 +230,8 @@ const { action, loader } = createActionApiRoute(
228230
);
229231
}
230232

231-
return json({ ok: true }, { status: 200 });
233+
// `seq` lets the client correlate this send to the turn that consumes it.
234+
return json({ ok: true, seq: appendSeq }, { status: 200 });
232235
}
233236
);
234237

apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground.realtime.v1.sessions.$session.$io.append.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
101101
const part = await request.text();
102102
const partId = request.headers.get("X-Part-Id") ?? nanoid(7);
103103

104-
const [appendError] = await tryCatch(
104+
const [appendError, appendSeq] = await tryCatch(
105105
realtimeStream.appendPartToSessionStream(part, partId, addressingKey, io)
106106
);
107107

@@ -148,5 +148,5 @@ export async function action({ request, params }: ActionFunctionArgs) {
148148
);
149149
}
150150

151-
return json({ ok: true }, { status: 200 });
151+
return json({ ok: true, seq: appendSeq ?? undefined }, { status: 200 });
152152
}

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -194,22 +194,20 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor {
194194
}
195195

196196
async appendPart(part: string, partId: string, runId: string, streamId: string): Promise<void> {
197-
return this.#appendPartByName(part, partId, this.toStreamName(runId, streamId));
197+
await this.#appendPartByName(part, partId, this.toStreamName(runId, streamId));
198198
}
199199

200-
/**
201-
* Append a single record to a `Session`-primitive channel.
202-
*/
200+
/** Append one record to a `Session` channel; returns its seq (same space as `session-in-event-id`). */
203201
async appendPartToSessionStream(
204202
part: string,
205203
partId: string,
206204
friendlyId: string,
207205
io: "out" | "in"
208-
): Promise<void> {
206+
): Promise<number> {
209207
return this.#appendPartByName(part, partId, this.toSessionStreamName(friendlyId, io));
210208
}
211209

212-
async #appendPartByName(part: string, partId: string, s2Stream: string): Promise<void> {
210+
async #appendPartByName(part: string, partId: string, s2Stream: string): Promise<number> {
213211
this.logger.debug(`S2 appending to stream`, { part, stream: s2Stream });
214212

215213
const recordBody = JSON.stringify({ data: part, id: partId });
@@ -223,6 +221,8 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor {
223221
});
224222

225223
this.logger.debug(`S2 append result`, { result });
224+
225+
return result.start.seq_num;
226226
}
227227

228228
getLastChunkIndex(runId: string, streamId: string, clientId: string): Promise<number> {

docs/ai-chat/client-protocol.mdx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -583,21 +583,25 @@ Signals that the agent's turn is finished — stop reading and wait for user inp
583583
headers:
584584
["trigger-control", "turn-complete"]
585585
["public-access-token", "eyJ..."] // optional, refreshed JWT
586-
["session-in-event-id", "42"] // optional, agent-internal resume cursor
586+
["session-in-event-id", "42"] // optional, send-correlation + resume cursor
587587
body: ""
588588
```
589589
590590
| Header | Description |
591591
| --- | --- |
592592
| `trigger-control: turn-complete` | Always present on this record. |
593593
| `public-access-token: <jwt>` (optional) | A refreshed JWT with the same session + run scopes. If present, replace your stored token. |
594-
| `session-in-event-id: <seq>` (optional) | Internal cursor used by the agent to resume `.in` across worker boots without replaying already-processed user messages. Custom transports should ignore this header — it carries no client-side meaning. |
594+
| `session-in-event-id: <seq>` (optional) | The agent's committed `.in` cursor for this turn: the seq of the last user record it had consumed when the turn finished. Compare it to the `seq` from your `.in/append` to tell whether this turn-complete is *yours* (see the warning below). Also used internally to resume `.in` across worker boots without replaying processed messages. |
595595
596596
When you receive this record:
597597
1. Update `publicAccessToken` if one is included on the headers.
598598
2. Close the stream reader (unless you want to keep it open across turns — see [Resuming a stream](#resuming-a-stream)).
599599
3. Wait for the next user message before sending on `.in`.
600600
601+
<Warning>
602+
**Correlate turn-completes to your send.** A `turn-complete` on `.out` can belong to an *earlier* turn than the message you just sent, for example a concurrent action (an undo) whose completion lands on the stream first. Only treat a `turn-complete` as terminal for your send if its `session-in-event-id` is greater than or equal to the `seq` returned by that send's `.in/append`; skip any lower one and keep reading. Closing on the wrong turn drops your response until the next reload. The built-in `TriggerChatTransport` does this correlation for you.
603+
</Warning>
604+
601605
### `upgrade-required` control record
602606
603607
Signals that the agent cannot handle this message on its current version and a new run has been started. Emitted when the agent calls [`chat.requestUpgrade()`](/ai-chat/patterns/version-upgrades).
@@ -681,7 +685,7 @@ Content-Type: application/json
681685
682686
`{sessionId}` accepts the same friendly-or-external forms as `.out`. The `publicAccessToken` from session-create authorizes both.
683687
684-
The body is a JSON-serialized [`ChatInputChunk`](#chatinputchunk)a tagged union covering messages, stops, and actions. Send them as raw JSON strings (not wrapped in a `data` field). On success the response is `200 OK` with body `{ "ok": true }`; on failure it's `4xx`/`5xx` with `{ "ok": false, "error": "<message>" }`. Common failures:
688+
The body is a JSON-serialized [`ChatInputChunk`](#chatinputchunk), a tagged union covering messages, stops, and actions. Send them as raw JSON strings (not wrapped in a `data` field). On success the response is `200 OK` with body `{ "ok": true, "seq": <number> }`, where `seq` is the appended record's `.in` sequence number. Use it to correlate this send to the turn that consumes it (see [`turn-complete` control record](#turn-complete-control-record)). On failure it's `4xx`/`5xx` with `{ "ok": false, "error": "<message>" }`. Common failures:
685689
686690
| Status | When |
687691
| --- | --- |

packages/trigger-sdk/src/v3/chat.ts

Lines changed: 39 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -849,11 +849,10 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
849849
// and the server-side dedupe sees one logical append.
850850
const partId = crypto.randomUUID();
851851
const serializedBody = this.serializeInputChunk({ kind: "message", payload: wirePayload });
852-
const sendChatMessage = async (token: string) => {
853-
await this.appendInputChunk(chatId, token, serializedBody, partId);
854-
};
852+
const sendChatMessage = (token: string) =>
853+
this.appendInputChunk(chatId, token, serializedBody, partId);
855854

856-
await this.sendWithEvents(
855+
const inSeq = await this.sendWithEvents(
857856
chatId,
858857
trigger,
859858
{
@@ -874,7 +873,7 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
874873
state.isStreaming = true;
875874
this.notifySessionChange(chatId, state);
876875

877-
return this.subscribeToSessionStream(state, abortSignal, chatId);
876+
return this.subscribeToSessionStream(state, abortSignal, chatId, { sinceInSeq: inSeq });
878877
};
879878

880879
/**
@@ -1243,12 +1242,13 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
12431242

12441243
const body = this.serializeInputChunk({ kind: "message", payload: wirePayload });
12451244
const partId = crypto.randomUUID();
1246-
const send = async (token: string) => {
1247-
await this.appendInputChunk(chatId, token, body, partId);
1248-
};
1245+
const send = (token: string) => this.appendInputChunk(chatId, token, body, partId);
12491246

1250-
await this.sendWithEvents(chatId, "action", { partId, bodyBytes: byteLength(body) }, () =>
1251-
this.callWithAuthRetry(chatId, state, send)
1247+
const inSeq = await this.sendWithEvents(
1248+
chatId,
1249+
"action",
1250+
{ partId, bodyBytes: byteLength(body) },
1251+
() => this.callWithAuthRetry(chatId, state, send)
12521252
);
12531253

12541254
// Supersede any in-flight reader before subscribing — same as
@@ -1266,7 +1266,7 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
12661266
state.isStreaming = true;
12671267
this.notifySessionChange(chatId, state);
12681268

1269-
return this.subscribeToSessionStream(state, undefined, chatId);
1269+
return this.subscribeToSessionStream(state, undefined, chatId, { sinceInSeq: inSeq });
12701270
};
12711271

12721272
// -------------------------------------------------------------------------
@@ -1310,15 +1310,15 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
13101310
}
13111311

13121312
/** Run a send op, emitting the terminal message-sent / message-send-failed event. */
1313-
private async sendWithEvents(
1313+
private async sendWithEvents<T>(
13141314
chatId: string,
13151315
source: ChatTransportSendSource,
13161316
extras: { messageId?: string; partId?: string; bodyBytes?: number },
1317-
op: () => Promise<void>
1318-
): Promise<void> {
1317+
op: () => Promise<T>
1318+
): Promise<T> {
13191319
const startedAt = Date.now();
13201320
try {
1321-
await op();
1321+
const result = await op();
13221322
const turnProducing =
13231323
source === "submit-message" || source === "regenerate-message" || source === "head-start";
13241324
if (turnProducing) {
@@ -1332,6 +1332,7 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
13321332
durationMs: Date.now() - startedAt,
13331333
...extras,
13341334
});
1335+
return result;
13351336
} catch (error) {
13361337
const status = (error as { status?: unknown }).status;
13371338
this.emitEvent({
@@ -1503,7 +1504,7 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
15031504
token: string,
15041505
body: string,
15051506
partId?: string
1506-
): Promise<void> {
1507+
): Promise<number | undefined> {
15071508
const ctx: ChatTransportEndpointContext = { endpoint: "in", chatId };
15081509
const url = `${this.resolveBaseURL(ctx)}/realtime/v1/sessions/${encodeURIComponent(chatId)}/in/append`;
15091510
// extraHeaders first so the fixed headers below win — a transport-wide
@@ -1526,24 +1527,26 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
15261527
err.status = response.status;
15271528
throw err;
15281529
}
1530+
// The appended record's `.in` seq, for correlating the response stream to
1531+
// this send. Omitted by older webapps / a lost idempotency claim.
1532+
const data = (await response.json().catch(() => undefined)) as { seq?: unknown } | undefined;
1533+
return typeof data?.seq === "number" ? data.seq : undefined;
15291534
}
15301535

1531-
private async callWithAuthRetry(
1536+
private async callWithAuthRetry<T>(
15321537
chatId: string,
15331538
state: ChatSessionState,
1534-
op: (token: string) => Promise<void>
1535-
): Promise<void> {
1539+
op: (token: string) => Promise<T>
1540+
): Promise<T> {
15361541
// 1) Try with the current PAT.
15371542
try {
1538-
await op(state.publicAccessToken);
1539-
return;
1543+
return await op(state.publicAccessToken);
15401544
} catch (err) {
15411545
if (isSessionNotFoundError(err)) {
15421546
// The cached PAT authenticated but the session doesn't exist here —
15431547
// recreate it and retry.
15441548
await this.recreateSession(chatId, state);
1545-
await op(state.publicAccessToken);
1546-
return;
1549+
return await op(state.publicAccessToken);
15471550
}
15481551
if (!isAuthError(err)) throw err;
15491552
}
@@ -1554,8 +1557,7 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
15541557
state.publicAccessToken = fresh;
15551558
this.notifySessionChange(chatId, state);
15561559
try {
1557-
await op(fresh);
1558-
return;
1560+
return await op(fresh);
15591561
} catch (err) {
15601562
if (!isSessionNotFoundError(err)) throw err;
15611563
}
@@ -1564,7 +1566,7 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
15641566
// state is stale (created in a different environment, or before the
15651567
// sessions upgrade). Recreate the session and retry once.
15661568
await this.recreateSession(chatId, state);
1567-
await op(state.publicAccessToken);
1569+
return await op(state.publicAccessToken);
15681570
}
15691571

15701572
/**
@@ -1612,6 +1614,8 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
16121614
sendStopOnAbort?: boolean;
16131615
peekSettled?: boolean;
16141616
resumed?: boolean;
1617+
/** `.in` seq of the send that opened this stream; skip turn-completes below it (earlier turns). */
1618+
sinceInSeq?: number;
16151619
}
16161620
): ReadableStream<UIMessageChunk> {
16171621
const internalAbort = new AbortController();
@@ -1869,6 +1873,15 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
18691873
}
18701874

18711875
if (controlValue === TRIGGER_CONTROL_SUBTYPE.TURN_COMPLETE) {
1876+
// Skip a turn-complete from an earlier turn (committed `.in` cursor
1877+
// below this send's seq), e.g. an undo action that raced this send.
1878+
if (options?.sinceInSeq !== undefined) {
1879+
const cursorRaw = headerValue(value.headers, SESSION_IN_EVENT_ID_HEADER);
1880+
const cursor = cursorRaw !== undefined ? Number.parseInt(cursorRaw, 10) : NaN;
1881+
if (!Number.isNaN(cursor) && cursor < options.sinceInSeq) {
1882+
continue;
1883+
}
1884+
}
18721885
const refreshedToken =
18731886
headerValue(value.headers, PUBLIC_ACCESS_TOKEN_HEADER) ??
18741887
legacyChunk?.publicAccessToken;
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { describe, expect, it } from "vitest";
2+
import type { UIMessage } from "ai";
3+
import { TriggerChatTransport, type TriggerChatTransportOptions } from "../src/v3/chat.js";
4+
5+
// A send's `.out` stream must close on the turn that consumed its own appended
6+
// record, not an earlier turn-complete (e.g. a racing undo action). The seq
7+
// comes back from `/in/append`; correlation headers ride the v2 batch wire.
8+
9+
function user(text: string, id: string): UIMessage {
10+
return { id, role: "user", parts: [{ type: "text", text }] };
11+
}
12+
13+
type BatchRecord = {
14+
body: string;
15+
seq_num: number;
16+
timestamp: number;
17+
headers?: Array<[string, string]>;
18+
};
19+
20+
function batchResponse(records: BatchRecord[]): Response {
21+
const frames = records
22+
.map((r) => `event: batch\ndata: ${JSON.stringify({ records: [r] })}\n\n`)
23+
.join("");
24+
return new Response(frames, {
25+
status: 200,
26+
headers: { "Content-Type": "text/event-stream", "X-Stream-Version": "v2" },
27+
});
28+
}
29+
30+
/** A turn-complete control record whose committed `.in` cursor is `inCursor`. */
31+
function turnComplete(seqNum: number, inCursor: number): BatchRecord {
32+
return {
33+
body: "",
34+
seq_num: seqNum,
35+
timestamp: seqNum,
36+
headers: [
37+
["trigger-control", "turn-complete"],
38+
["session-in-event-id", String(inCursor)],
39+
],
40+
};
41+
}
42+
43+
function textDelta(seqNum: number, text: string): BatchRecord {
44+
return {
45+
body: JSON.stringify({ data: { type: "text-delta", id: "t1", delta: text }, id: "m1" }),
46+
seq_num: seqNum,
47+
timestamp: seqNum,
48+
headers: [],
49+
};
50+
}
51+
52+
function inResponse(seq?: number): Response {
53+
return new Response(JSON.stringify(seq === undefined ? { ok: true } : { ok: true, seq }), {
54+
status: 200,
55+
});
56+
}
57+
58+
async function readDeltas(stream: ReadableStream<unknown>): Promise<string[]> {
59+
const out: string[] = [];
60+
const reader = stream.getReader();
61+
while (true) {
62+
const next = await reader.read();
63+
if (next.done) return out;
64+
const chunk = next.value as { type?: string; delta?: string };
65+
if (chunk?.type === "text-delta" && typeof chunk.delta === "string") out.push(chunk.delta);
66+
}
67+
}
68+
69+
function makeTransport(out: Response, inSeq: number | undefined) {
70+
const options: TriggerChatTransportOptions = {
71+
task: "test-task",
72+
accessToken: async () => "tok_test",
73+
sessions: { c1: { publicAccessToken: "tok_test", isStreaming: false } },
74+
fetch: async (_url, _init, ctx) => (ctx.endpoint === "in" ? inResponse(inSeq) : out),
75+
};
76+
return new TriggerChatTransport(options);
77+
}
78+
79+
async function submit(transport: TriggerChatTransport): Promise<string[]> {
80+
const stream = await transport.sendMessages({
81+
trigger: "submit-message",
82+
chatId: "c1",
83+
messageId: undefined,
84+
messages: [user("hi", "u-1")],
85+
abortSignal: undefined,
86+
});
87+
return readDeltas(stream);
88+
}
89+
90+
describe("transport turn correlation", () => {
91+
it("skips an earlier turn's turn-complete and closes on its own", async () => {
92+
// Append seq 5; the undo turn's complete (cursor 4) must be skipped.
93+
const out = batchResponse([turnComplete(10, 4), textDelta(11, "56"), turnComplete(12, 5)]);
94+
const deltas = await submit(makeTransport(out, 5));
95+
expect(deltas).toEqual(["56"]);
96+
});
97+
98+
it("does not skip when the turn-complete is at the send's own seq", async () => {
99+
const out = batchResponse([textDelta(10, "56"), turnComplete(11, 5)]);
100+
const deltas = await submit(makeTransport(out, 5));
101+
expect(deltas).toEqual(["56"]);
102+
});
103+
104+
it("without an append seq, closes on the first turn-complete (legacy webapp)", async () => {
105+
// No seq => no baseline => old behavior: close on the first turn-complete.
106+
const out = batchResponse([turnComplete(10, 4), textDelta(11, "56"), turnComplete(12, 5)]);
107+
const deltas = await submit(makeTransport(out, undefined));
108+
expect(deltas).toEqual([]);
109+
});
110+
});

0 commit comments

Comments
 (0)