Skip to content

Commit 00dce9a

Browse files
committed
fix(sdk,webapp): stop chat losing a message sent right after an action
1 parent a3dca98 commit 00dce9a

7 files changed

Lines changed: 181 additions & 38 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: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -591,13 +591,17 @@ body: ""
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
@@ -730,11 +730,10 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
730730
// and the server-side dedupe sees one logical append.
731731
const partId = crypto.randomUUID();
732732
const serializedBody = this.serializeInputChunk({ kind: "message", payload: wirePayload });
733-
const sendChatMessage = async (token: string) => {
734-
await this.appendInputChunk(chatId, token, serializedBody, partId);
735-
};
733+
const sendChatMessage = (token: string) =>
734+
this.appendInputChunk(chatId, token, serializedBody, partId);
736735

737-
await this.sendWithEvents(
736+
const inSeq = await this.sendWithEvents(
738737
chatId,
739738
trigger,
740739
{
@@ -755,7 +754,7 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
755754
state.isStreaming = true;
756755
this.notifySessionChange(chatId, state);
757756

758-
return this.subscribeToSessionStream(state, abortSignal, chatId);
757+
return this.subscribeToSessionStream(state, abortSignal, chatId, { sinceInSeq: inSeq });
759758
};
760759

761760
/**
@@ -1124,12 +1123,13 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
11241123

11251124
const body = this.serializeInputChunk({ kind: "message", payload: wirePayload });
11261125
const partId = crypto.randomUUID();
1127-
const send = async (token: string) => {
1128-
await this.appendInputChunk(chatId, token, body, partId);
1129-
};
1126+
const send = (token: string) => this.appendInputChunk(chatId, token, body, partId);
11301127

1131-
await this.sendWithEvents(chatId, "action", { partId, bodyBytes: byteLength(body) }, () =>
1132-
this.callWithAuthRetry(chatId, state, send)
1128+
const inSeq = await this.sendWithEvents(
1129+
chatId,
1130+
"action",
1131+
{ partId, bodyBytes: byteLength(body) },
1132+
() => this.callWithAuthRetry(chatId, state, send)
11331133
);
11341134

11351135
// Supersede any in-flight reader before subscribing — same as
@@ -1147,7 +1147,7 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
11471147
state.isStreaming = true;
11481148
this.notifySessionChange(chatId, state);
11491149

1150-
return this.subscribeToSessionStream(state, undefined, chatId);
1150+
return this.subscribeToSessionStream(state, undefined, chatId, { sinceInSeq: inSeq });
11511151
};
11521152

11531153
// -------------------------------------------------------------------------
@@ -1191,15 +1191,15 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
11911191
}
11921192

11931193
/** Run a send op, emitting the terminal message-sent / message-send-failed event. */
1194-
private async sendWithEvents(
1194+
private async sendWithEvents<T>(
11951195
chatId: string,
11961196
source: ChatTransportSendSource,
11971197
extras: { messageId?: string; partId?: string; bodyBytes?: number },
1198-
op: () => Promise<void>
1199-
): Promise<void> {
1198+
op: () => Promise<T>
1199+
): Promise<T> {
12001200
const startedAt = Date.now();
12011201
try {
1202-
await op();
1202+
const result = await op();
12031203
const turnProducing =
12041204
source === "submit-message" || source === "regenerate-message" || source === "head-start";
12051205
if (turnProducing) {
@@ -1213,6 +1213,7 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
12131213
durationMs: Date.now() - startedAt,
12141214
...extras,
12151215
});
1216+
return result;
12161217
} catch (error) {
12171218
const status = (error as { status?: unknown }).status;
12181219
this.emitEvent({
@@ -1384,7 +1385,7 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
13841385
token: string,
13851386
body: string,
13861387
partId?: string
1387-
): Promise<void> {
1388+
): Promise<number | undefined> {
13881389
const ctx: ChatTransportEndpointContext = { endpoint: "in", chatId };
13891390
const url = `${this.resolveBaseURL(ctx)}/realtime/v1/sessions/${encodeURIComponent(chatId)}/in/append`;
13901391
// extraHeaders first so the fixed headers below win — a transport-wide
@@ -1407,24 +1408,26 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
14071408
err.status = response.status;
14081409
throw err;
14091410
}
1411+
// The appended record's `.in` seq, for correlating the response stream to
1412+
// this send. Omitted by older webapps / a lost idempotency claim.
1413+
const data = (await response.json().catch(() => undefined)) as { seq?: unknown } | undefined;
1414+
return typeof data?.seq === "number" ? data.seq : undefined;
14101415
}
14111416

1412-
private async callWithAuthRetry(
1417+
private async callWithAuthRetry<T>(
14131418
chatId: string,
14141419
state: ChatSessionState,
1415-
op: (token: string) => Promise<void>
1416-
): Promise<void> {
1420+
op: (token: string) => Promise<T>
1421+
): Promise<T> {
14171422
// 1) Try with the current PAT.
14181423
try {
1419-
await op(state.publicAccessToken);
1420-
return;
1424+
return await op(state.publicAccessToken);
14211425
} catch (err) {
14221426
if (isSessionNotFoundError(err)) {
14231427
// The cached PAT authenticated but the session doesn't exist here —
14241428
// recreate it and retry.
14251429
await this.recreateSession(chatId, state);
1426-
await op(state.publicAccessToken);
1427-
return;
1430+
return await op(state.publicAccessToken);
14281431
}
14291432
if (!isAuthError(err)) throw err;
14301433
}
@@ -1435,8 +1438,7 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
14351438
state.publicAccessToken = fresh;
14361439
this.notifySessionChange(chatId, state);
14371440
try {
1438-
await op(fresh);
1439-
return;
1441+
return await op(fresh);
14401442
} catch (err) {
14411443
if (!isSessionNotFoundError(err)) throw err;
14421444
}
@@ -1445,7 +1447,7 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
14451447
// state is stale (created in a different environment, or before the
14461448
// sessions upgrade). Recreate the session and retry once.
14471449
await this.recreateSession(chatId, state);
1448-
await op(state.publicAccessToken);
1450+
return await op(state.publicAccessToken);
14491451
}
14501452

14511453
/**
@@ -1493,6 +1495,8 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
14931495
sendStopOnAbort?: boolean;
14941496
peekSettled?: boolean;
14951497
resumed?: boolean;
1498+
/** `.in` seq of the send that opened this stream; skip turn-completes below it (earlier turns). */
1499+
sinceInSeq?: number;
14961500
}
14971501
): ReadableStream<UIMessageChunk> {
14981502
const internalAbort = new AbortController();
@@ -1750,6 +1754,15 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
17501754
}
17511755

17521756
if (controlValue === TRIGGER_CONTROL_SUBTYPE.TURN_COMPLETE) {
1757+
// Skip a turn-complete from an earlier turn (committed `.in` cursor
1758+
// below this send's seq), e.g. an undo action that raced this send.
1759+
if (options?.sinceInSeq !== undefined) {
1760+
const cursorRaw = headerValue(value.headers, SESSION_IN_EVENT_ID_HEADER);
1761+
const cursor = cursorRaw !== undefined ? Number.parseInt(cursorRaw, 10) : NaN;
1762+
if (!Number.isNaN(cursor) && cursor < options.sinceInSeq) {
1763+
continue;
1764+
}
1765+
}
17531766
const refreshedToken =
17541767
headerValue(value.headers, PUBLIC_ACCESS_TOKEN_HEADER) ??
17551768
legacyChunk?.publicAccessToken;
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
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([
94+
turnComplete(10, 4),
95+
textDelta(11, "56"),
96+
turnComplete(12, 5),
97+
]);
98+
const deltas = await submit(makeTransport(out, 5));
99+
expect(deltas).toEqual(["56"]);
100+
});
101+
102+
it("does not skip when the turn-complete is at the send's own seq", async () => {
103+
const out = batchResponse([textDelta(10, "56"), turnComplete(11, 5)]);
104+
const deltas = await submit(makeTransport(out, 5));
105+
expect(deltas).toEqual(["56"]);
106+
});
107+
108+
it("without an append seq, closes on the first turn-complete (legacy webapp)", async () => {
109+
// No seq => no baseline => old behavior: close on the first turn-complete.
110+
const out = batchResponse([
111+
turnComplete(10, 4),
112+
textDelta(11, "56"),
113+
turnComplete(12, 5),
114+
]);
115+
const deltas = await submit(makeTransport(out, undefined));
116+
expect(deltas).toEqual([]);
117+
});
118+
});

0 commit comments

Comments
 (0)