Skip to content

Commit a361659

Browse files
committed
fix(chat): cover head-start and watch reconnects
1 parent fdc0f2f commit a361659

6 files changed

Lines changed: 194 additions & 25 deletions

File tree

packages/core/src/v3/schemas/api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2181,6 +2181,7 @@ export type CreateStreamResponseBody = z.infer<typeof CreateStreamResponseBody>;
21812181
export const AppendToStreamResponseBody = z.object({
21822182
ok: z.boolean(),
21832183
message: z.string().optional(),
2184+
seq: z.number().optional(),
21842185
});
21852186
export type AppendToStreamResponseBody = z.infer<typeof AppendToStreamResponseBody>;
21862187

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -119,8 +119,8 @@ function createSessionResponse(externalId: string): Response {
119119
);
120120
}
121121

122-
function appendOkResponse(): Response {
123-
return new Response(JSON.stringify({ ok: true }), {
122+
function appendOkResponse(seq?: number): Response {
123+
return new Response(JSON.stringify(seq === undefined ? { ok: true } : { ok: true, seq }), {
124124
status: 200,
125125
headers: { "content-type": "application/json" },
126126
});
@@ -289,7 +289,7 @@ describe("chat.headStart (route handler)", () => {
289289
return createSessionResponse("chat-final");
290290
}
291291
if (urlStr.includes("/realtime/v1/sessions/") && urlStr.endsWith("/in/append")) {
292-
return appendOkResponse();
292+
return appendOkResponse(17);
293293
}
294294
// Stitched response subscribes to `.out` after handover.
295295
if (/\/realtime\/v1\/sessions\/[^/]+\/out$/.test(urlStr)) {
@@ -336,6 +336,9 @@ describe("chat.headStart (route handler)", () => {
336336
// Drain the SSE body so handoverWhenDone observes finishReason.
337337
const chunks = await readSSEBodyToChunks(res);
338338
expect(chunks.some((c) => c.type === "text-delta")).toBe(true);
339+
expect(chunks).toContainEqual(
340+
expect.objectContaining({ type: "trigger:session-state", activeInputSeq: 17 })
341+
);
339342

340343
// Give the deferred handoverWhenDone a tick to dispatch.
341344
await new Promise((r) => setTimeout(r, 30));

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

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -662,7 +662,7 @@ async function openHandoverSession(opts: {
662662
}
663663
};
664664

665-
const handover = async (args: {
665+
const dispatchHandover = async (args: {
666666
partialAssistantMessage: ModelMessage[];
667667
messageId?: string;
668668
isFinal: boolean;
@@ -673,7 +673,16 @@ async function openHandoverSession(opts: {
673673
messageId: args.messageId,
674674
isFinal: args.isFinal,
675675
};
676-
await apiClient.appendToSessionStream(chatId, "in", JSON.stringify(chunk));
676+
const result = await apiClient.appendToSessionStream(chatId, "in", JSON.stringify(chunk));
677+
return result.seq;
678+
};
679+
680+
const handover = async (args: {
681+
partialAssistantMessage: ModelMessage[];
682+
messageId?: string;
683+
isFinal: boolean;
684+
}) => {
685+
await dispatchHandover(args);
677686
};
678687

679688
/**
@@ -707,7 +716,7 @@ async function openHandoverSession(opts: {
707716
// and dispatches the handover decision. The stitched response stream
708717
// awaits this to know whether to close (skip) or pull more chunks
709718
// from session.out (handover).
710-
type HandoverDecision = { kind: "handover" | "handover-skip" };
719+
type HandoverDecision = { kind: "handover"; activeInputSeq?: number } | { kind: "handover-skip" };
711720
let resolveDecision!: (decision: HandoverDecision) => void;
712721
const decisionPromise = new Promise<HandoverDecision>((resolve) => {
713722
resolveDecision = resolve;
@@ -737,25 +746,26 @@ async function openHandoverSession(opts: {
737746
// so the agent's `streamText` resumes by executing them
738747
// before the step-2 LLM call.
739748
const reshaped = reshapeForHandoverResume(responseMessages);
740-
await handover({
749+
const activeInputSeq = await dispatchHandover({
741750
partialAssistantMessage: reshaped,
742751
messageId: turnMessageId,
743752
isFinal: false,
744753
});
754+
resolveDecision({ kind: "handover", activeInputSeq });
745755
} else {
746756
// Pure-text (or any non-tool-calls) finish — customer's step 1
747757
// IS the final response. The agent runs the turn-loop hooks
748758
// (`onChatStart`, `onTurnStart`, `onTurnComplete`, etc.) using
749759
// this partial as the response, but skips the LLM call. That
750760
// way persistence (`onTurnComplete` writing to DB), self-
751761
// review, and any post-turn work all fire normally.
752-
await handover({
762+
const activeInputSeq = await dispatchHandover({
753763
partialAssistantMessage: responseMessages,
754764
messageId: turnMessageId,
755765
isFinal: true,
756766
});
767+
resolveDecision({ kind: "handover", activeInputSeq });
757768
}
758-
resolveDecision({ kind: "handover" });
759769
} catch (err) {
760770
// Dispatch failed before we could send the handover signal.
761771
// Tell the agent to exit clean (no hooks fire) and close the
@@ -812,6 +822,18 @@ async function openHandoverSession(opts: {
812822
return;
813823
}
814824

825+
// The handover append happens after the HTTP response has started,
826+
// so its `.in` sequence cannot be returned in a response header.
827+
// Send it as transport-only state before any agent output so a
828+
// reload during the remainder of this turn can reject stale
829+
// turn-complete records.
830+
if (decision.activeInputSeq !== undefined) {
831+
controller.enqueue({
832+
type: "trigger:session-state",
833+
activeInputSeq: decision.activeInputSeq,
834+
} as unknown as UIMessageChunk);
835+
}
836+
815837
// Phase 2b: agent is taking over. Resume from session.out
816838
// starting AFTER the customer tee's last write, so we don't
817839
// re-emit chunks the browser already saw.

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

Lines changed: 76 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ function sseEncode(chunks: (UIMessageChunk | Record<string, unknown>)[]): string
3535
const headers: Array<[string, string]> = [["trigger-control", "turn-complete"]];
3636
const token = (chunk as { publicAccessToken?: string }).publicAccessToken;
3737
if (token) headers.push(["public-access-token", token]);
38+
const sessionInEventId = (chunk as { sessionInEventId?: string | number }).sessionInEventId;
39+
if (sessionInEventId !== undefined) {
40+
headers.push(["session-in-event-id", String(sessionInEventId)]);
41+
}
3842
return {
3943
body: "",
4044
seq_num: nextSeq++,
@@ -1762,24 +1766,28 @@ describe("TriggerChatTransport", () => {
17621766
* does: `data: <JSON>\n\n` per chunk. The transport's
17631767
* `parseUIMessageSseTransform` parses this back into chunk objects.
17641768
*/
1765-
function handoverSseBody(chunks: UIMessageChunk[]): ReadableStream<Uint8Array> {
1769+
function handoverSseBody(
1770+
chunks: (UIMessageChunk | Record<string, unknown>)[],
1771+
close = true
1772+
): ReadableStream<Uint8Array> {
17661773
const encoder = new TextEncoder();
17671774
return new ReadableStream({
17681775
start(controller) {
17691776
for (const chunk of chunks) {
17701777
controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
17711778
}
1772-
controller.close();
1779+
if (close) controller.close();
17731780
},
17741781
});
17751782
}
17761783

17771784
function handoverResponse(args: {
17781785
chatId: string;
17791786
accessToken: string;
1780-
chunks: UIMessageChunk[];
1787+
chunks: (UIMessageChunk | Record<string, unknown>)[];
1788+
close?: boolean;
17811789
}): Response {
1782-
return new Response(handoverSseBody(args.chunks), {
1790+
return new Response(handoverSseBody(args.chunks, args.close), {
17831791
status: 200,
17841792
headers: {
17851793
"content-type": "text/event-stream",
@@ -1914,6 +1922,70 @@ describe("TriggerChatTransport", () => {
19141922
expect(subscribe).toBeDefined();
19151923
});
19161924

1925+
it("persists the handover input sequence for a reload while the first turn is active", async () => {
1926+
global.fetch = vi.fn().mockImplementation(async (url: string | URL) => {
1927+
const urlStr = typeof url === "string" ? url : url.toString();
1928+
if (urlStr === "https://my-app.example/api/chat") {
1929+
return handoverResponse({
1930+
chatId: "chat-handover-reload",
1931+
accessToken: "handover-pat-reload",
1932+
chunks: [
1933+
{ type: "trigger:session-state", activeInputSeq: 7 },
1934+
{ type: "text-delta", id: "part-1", delta: "working" },
1935+
],
1936+
close: false,
1937+
});
1938+
}
1939+
if (isSessionOutSubscribeUrl(urlStr)) {
1940+
return defaultSseResponse([
1941+
{ type: "trigger:turn-complete", sessionInEventId: 6 },
1942+
{ type: "text-delta", id: "part-2", delta: "current" },
1943+
{ type: "trigger:turn-complete", sessionInEventId: 7 },
1944+
]);
1945+
}
1946+
throw new Error(`Unexpected URL: ${urlStr}`);
1947+
});
1948+
1949+
const transport = new TriggerChatTransport({
1950+
task: "my-chat-task",
1951+
accessToken: () => "pat",
1952+
headStart: "https://my-app.example/api/chat",
1953+
});
1954+
1955+
const firstTurn = await transport.sendMessages({
1956+
trigger: "submit-message",
1957+
chatId: "chat-handover-reload",
1958+
messageId: "m1",
1959+
messages: [createUserMessage("first")],
1960+
abortSignal: undefined,
1961+
});
1962+
const firstTurnReader = firstTurn.getReader();
1963+
await expect(firstTurnReader.read()).resolves.toMatchObject({
1964+
value: { type: "text-delta", delta: "working" },
1965+
});
1966+
1967+
const persisted = transport.getSession("chat-handover-reload");
1968+
expect(persisted).toMatchObject({
1969+
publicAccessToken: "handover-pat-reload",
1970+
activeInputSeq: 7,
1971+
isStreaming: true,
1972+
});
1973+
await firstTurnReader.cancel();
1974+
1975+
const rehydrated = new TriggerChatTransport({
1976+
task: "my-chat-task",
1977+
accessToken: () => "pat",
1978+
sessions: { "chat-handover-reload": persisted! },
1979+
});
1980+
const resumed = await rehydrated.reconnectToStream({ chatId: "chat-handover-reload" });
1981+
1982+
expect(resumed).not.toBeNull();
1983+
await expect(drainChunks(resumed!)).resolves.toEqual([
1984+
{ type: "text-delta", id: "part-2", delta: "current" },
1985+
]);
1986+
expect(rehydrated.getSession("chat-handover-reload")?.activeInputSeq).toBeUndefined();
1987+
});
1988+
19171989
it("bypasses endpoint when a session is already hydrated (page reload after first turn)", async () => {
19181990
const requests: Array<{ url: string; init?: RequestInit }> = [];
19191991
global.fetch = vi.fn().mockImplementation(async (url: string | URL, init?: RequestInit) => {

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

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -981,10 +981,10 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
981981
// useChat resume / reconnectToStream path doesn't open a
982982
// second `session.out` subscription on top of our stitched
983983
// response.
984-
// - On `trigger:session-state`, hydrate `state.lastEventId`
985-
// with the agent's final S2 event id. Without this, turn 2's
986-
// `session.out` subscribe reads from the start and replays
987-
// turn 1's chunks back into the UI.
984+
// - On `trigger:session-state`, hydrate `state.activeInputSeq`
985+
// as soon as the handover append commits and `state.lastEventId`
986+
// when the agent stream finishes. These keep reloads correlated
987+
// to the active turn and keep turn 2 from replaying turn 1.
988988
// - On stream end (handover-skip case — no
989989
// `trigger:turn-complete` arrives, customer's stream just
990990
// ends), also clear `isStreaming` for the same reason.
@@ -993,9 +993,17 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
993993
this.notifySessionChange(id, state);
994994
const TRIGGER_TURN_COMPLETE = "trigger:turn-complete";
995995
const TRIGGER_SESSION_STATE = "trigger:session-state";
996+
const clearActiveTurn = () => {
997+
const state = sessions.get(chatId);
998+
if (state && (state.isStreaming || state.activeInputSeq !== undefined)) {
999+
state.activeInputSeq = undefined;
1000+
state.isStreaming = false;
1001+
notifyChange(chatId, state);
1002+
}
1003+
};
9961004
const clearStreaming = () => {
9971005
const state = sessions.get(chatId);
998-
if (state && state.isStreaming) {
1006+
if (state?.isStreaming) {
9991007
state.isStreaming = false;
10001008
notifyChange(chatId, state);
10011009
}
@@ -1007,6 +1015,13 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
10071015
notifyChange(chatId, state);
10081016
}
10091017
};
1018+
const setActiveInputSeq = (activeInputSeq: number) => {
1019+
const state = sessions.get(chatId);
1020+
if (state) {
1021+
state.activeInputSeq = activeInputSeq;
1022+
notifyChange(chatId, state);
1023+
}
1024+
};
10101025
const emit = (event: ChatTransportEvent) => this.emitEvent(event);
10111026
const attribution = () => this.turnAttribution(chatId);
10121027
let sawFirstChunk = false;
@@ -1028,7 +1043,7 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
10281043
if (chunk && typeof chunk === "object") {
10291044
const type = (chunk as { type?: unknown }).type;
10301045
if (type === TRIGGER_TURN_COMPLETE) {
1031-
clearStreaming();
1046+
clearActiveTurn();
10321047
emit({
10331048
type: "turn-completed",
10341049
chatId,
@@ -1039,10 +1054,17 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
10391054
return; // drop — not a real UIMessageChunk
10401055
}
10411056
if (type === TRIGGER_SESSION_STATE) {
1042-
const lastEventId = (chunk as { lastEventId?: unknown }).lastEventId;
1057+
const sessionState = chunk as {
1058+
lastEventId?: unknown;
1059+
activeInputSeq?: unknown;
1060+
};
1061+
const lastEventId = sessionState.lastEventId;
10431062
if (typeof lastEventId === "string") {
10441063
setLastEventId(lastEventId);
10451064
}
1065+
if (typeof sessionState.activeInputSeq === "number") {
1066+
setActiveInputSeq(sessionState.activeInputSeq);
1067+
}
10461068
return; // drop
10471069
}
10481070
}
@@ -1760,6 +1782,7 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
17601782
}) as typeof fetch)
17611783
: undefined;
17621784
let sawFirstChunk = false;
1785+
let sinceInSeq = options?.sinceInSeq;
17631786

17641787
const connectSseOnce = async (token: string) => {
17651788
const subscription = new SSEStreamSubscription(streamUrl, {
@@ -1993,10 +2016,10 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
19932016
if (controlValue === TRIGGER_CONTROL_SUBTYPE.TURN_COMPLETE) {
19942017
// Skip a turn-complete from an earlier turn (committed `.in` cursor
19952018
// below this send's seq), e.g. an undo action that raced this send.
1996-
if (options?.sinceInSeq !== undefined) {
2019+
if (sinceInSeq !== undefined) {
19972020
const cursorRaw = headerValue(value.headers, SESSION_IN_EVENT_ID_HEADER);
19982021
const cursor = cursorRaw !== undefined ? Number.parseInt(cursorRaw, 10) : NaN;
1999-
if (!Number.isNaN(cursor) && cursor < options.sinceInSeq) {
2022+
if (!Number.isNaN(cursor) && cursor < sinceInSeq) {
20002023
continue;
20012024
}
20022025
}
@@ -2014,6 +2037,8 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
20142037
sessionInEventId: headerValue(value.headers, SESSION_IN_EVENT_ID_HEADER),
20152038
...this.turnAttribution(chatId),
20162039
});
2040+
state.activeInputSeq = undefined;
2041+
sinceInSeq = undefined;
20172042
state.isStreaming = false;
20182043
this.notifySessionChange(chatId, state);
20192044
this.coordinator?.release(chatId);

0 commit comments

Comments
 (0)