Skip to content

Commit 206e7f5

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

6 files changed

Lines changed: 175 additions & 36 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: sending a message immediately after an action (for example an undo) could close the response stream on the earlier turn's completion, so the follow-up's answer never appeared on screen (it came back on refresh). The transport now correlates each send to the turn that consumes it, so the stream only closes on its own turn.

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> {

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)