Skip to content

Commit c7f78e4

Browse files
wuweiweiwudevin-ai-integration[bot]nicktrn
authored
fix(sdk): reset skipToTurnComplete when a new chat turn starts (#4744)
## ✅ Checklist - [x] I have followed every step in the [contributing guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md) - [x] The PR title follows the convention. - [x] I ran and tested the code works --- ## Testing Reproduced with `useTriggerChatTransport` + `useChat` and the stop pattern from the ai-chat frontend docs: 1. Send a message so a turn is streaming. 2. Call `transport.stopGeneration(chatId)`, then `useChat`'s `stop()`. 3. Send another message. Before this change the second turn never renders: no parts arrive, `status` stays `streaming`, and the session stays `isStreaming: true`, so a stop button stays on screen until the page is reloaded. The run itself is fine and everything persists, so a reload shows the full response. Cause: `stopGeneration` sets `state.skipToTurnComplete = true`, and the read loop only clears that when it sees a `TURN_COMPLETE` record. The abort closes the reader before that record arrives, so the flag survives into the next turn and every record of that turn is skipped, including its own `TURN_COMPLETE`. After this change the same sequence streams the second turn normally. Verified against 4.5.11 and 4.5.12 (both affected) with the equivalent patch applied to the built SDK. --- ## Changelog Reset `skipToTurnComplete` when a new chat turn or action is sent, so a message sent after `stopGeneration` streams normally instead of leaving the chat stuck in a streaming state. --------- Co-authored-by: Devin AI <devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: nicktrn <55853254+nicktrn@users.noreply.github.com>
1 parent 920892b commit c7f78e4

3 files changed

Lines changed: 77 additions & 0 deletions

File tree

.changeset/fluffy-pans-argue.md

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 chat transport discarding the next turn after stopping generation. `skipToTurnComplete` is now reset when a new message or action is sent, so a message sent after `stopGeneration` streams normally instead of leaving the chat stuck in a streaming state.

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -870,6 +870,10 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
870870
this.activeStreams.delete(chatId);
871871
}
872872

873+
// A stop that never saw its TURN_COMPLETE leaves the flag set, and the new
874+
// turn would be skipped record by record.
875+
state.skipToTurnComplete = false;
876+
873877
state.isStreaming = true;
874878
this.notifySessionChange(chatId, state);
875879

@@ -1281,6 +1285,10 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
12811285
this.activeStreams.delete(chatId);
12821286
}
12831287

1288+
// A stop that never saw its TURN_COMPLETE leaves the flag set, and the new
1289+
// turn would be skipped record by record.
1290+
state.skipToTurnComplete = false;
1291+
12841292
// Mark streaming + persist so a reload mid-action resumes (reconnectToStream
12851293
// no-ops when the persisted session says isStreaming: false).
12861294
state.isStreaming = true;

packages/trigger-sdk/test/chat-transport-events.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,70 @@ describe("transport send events", () => {
174174
});
175175
});
176176

177+
describe("stopped turn followed by a new turn", () => {
178+
/**
179+
* `.out` stub that honours the `Last-Event-ID` cursor like the server does, so
180+
* a resubscribe cannot replay records the reader already consumed. A stop that
181+
* never saw its turn-complete is therefore unrecoverable unless the new send
182+
* clears the skip state.
183+
*/
184+
function cursoredOneTurnTransport() {
185+
const frames = [
186+
{ id: "1", data: `{"type":"text-delta","id":"t1","delta":"hello"}` },
187+
{ id: "2", data: `{"type":"trigger:turn-complete"}` },
188+
];
189+
190+
return makeTransport({
191+
fetch: async (_url, init, ctx) => {
192+
if (ctx.endpoint === "in") return jsonOk();
193+
194+
const cursor = new Headers(init.headers).get("Last-Event-ID");
195+
const from = cursor ? frames.findIndex((f) => f.id === cursor) + 1 : 0;
196+
const remaining = frames.slice(from);
197+
const response = sseResponse(
198+
remaining.map((f) => `id: ${f.id}\ndata: ${f.data}\n\n`).join("")
199+
);
200+
// Nothing left to send: the session is settled, so the reader stops
201+
// instead of resubscribing.
202+
if (remaining.length === 0) response.headers.set("X-Session-Settled", "true");
203+
return response;
204+
},
205+
});
206+
}
207+
208+
it("streams a sendMessages turn after a stop that never saw turn-complete", async () => {
209+
const { transport, events } = cursoredOneTurnTransport();
210+
211+
expect(await transport.stopGeneration("c1")).toBe(true);
212+
events.length = 0;
213+
214+
const stream = await transport.sendMessages({
215+
trigger: "submit-message",
216+
chatId: "c1",
217+
messageId: undefined,
218+
messages: [user("after stop", "u-2")],
219+
abortSignal: undefined,
220+
});
221+
const chunks = await readAll(stream);
222+
223+
expect(chunks).toEqual([{ type: "text-delta", id: "t1", delta: "hello" }]);
224+
expect(events.some((e) => e.type === "turn-completed")).toBe(true);
225+
});
226+
227+
it("streams a sendAction turn after a stop that never saw turn-complete", async () => {
228+
const { transport, events } = cursoredOneTurnTransport();
229+
230+
expect(await transport.stopGeneration("c1")).toBe(true);
231+
events.length = 0;
232+
233+
const stream = await transport.sendAction("c1", { type: "undo" });
234+
const chunks = await readAll(stream);
235+
236+
expect(chunks).toEqual([{ type: "text-delta", id: "t1", delta: "hello" }]);
237+
expect(events.some((e) => e.type === "turn-completed")).toBe(true);
238+
});
239+
});
240+
177241
describe("transport stream events", () => {
178242
it("marks reconnectToStream subscriptions as resumed", async () => {
179243
const { transport, events } = makeTransport({

0 commit comments

Comments
 (0)