Skip to content

Commit 410f28d

Browse files
committed
fix(core): stop dropping a chunk on the chat.agent warm-turn handover
1 parent e15f826 commit 410f28d

3 files changed

Lines changed: 75 additions & 5 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/core": patch
3+
---
4+
5+
Fix a chunk occasionally dropped when a chat.agent run takes over from the warm first turn. The realtime stream writer now reports the inclusive last-written position as the resume cursor, so the agent's first record after the handover is no longer skipped.

apps/webapp/test/session-stream.e2e.test.ts

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
*/
1313
import { randomBytes } from "crypto";
1414
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
15+
import { SessionStreamInstance } from "@trigger.dev/core/v3";
1516
import type { SessionStreamTestServer } from "@internal/testcontainers/webapp";
1617
import { startSessionStreamTestServer } from "@internal/testcontainers/webapp";
1718
import { seedTestEnvironment } from "./helpers/seedTestEnvironment";
@@ -65,7 +66,16 @@ async function setupSession() {
6566
basin: server.s2.basin,
6667
streamName,
6768
});
68-
return { addressingKey, token, producer, baseUrl: server.webapp.baseUrl };
69+
return { addressingKey, token, producer, streamName, baseUrl: server.webapp.baseUrl };
70+
}
71+
72+
function readableFrom<T>(chunks: T[]): ReadableStream<T> {
73+
return new ReadableStream<T>({
74+
start(controller) {
75+
for (const chunk of chunks) controller.enqueue(chunk);
76+
controller.close();
77+
},
78+
});
6979
}
7080

7181
describe("session stream e2e", () => {
@@ -225,6 +235,55 @@ describe("session stream e2e", () => {
225235
expect(result!.closedMs).toBeLessThan(8_000);
226236
});
227237

238+
/**
239+
* E10 head-start handover: the resume cursor the S2 stream writer reports
240+
* from `wait()` must point AT the last record it wrote, not one past it.
241+
* `chat.ln`'s warm process drains step 1 to `session.out`, hands that
242+
* cursor to the agent's resume subscribe, and the read proxy resumes from
243+
* `cursor + 1`. S2's append `end` is exclusive (last seq + 1), so if the
244+
* writer reports `end` the agent's first post-handover record is skipped.
245+
*/
246+
it("E10 head-start handover cursor does not skip the first post-handover record", async () => {
247+
const { addressingKey, token, producer, streamName, baseUrl } = await setupSession();
248+
249+
const writer = new SessionStreamInstance<{ n: number }>({
250+
apiClient: undefined as never,
251+
baseUrl,
252+
sessionId: addressingKey,
253+
io: "out",
254+
source: readableFrom([{ n: 0 }, { n: 1 }]),
255+
initializeSession: async () => ({
256+
headers: {
257+
"x-s2-access-token": "ignored",
258+
"x-s2-basin": server.s2.basin,
259+
"x-s2-stream-name": streamName,
260+
"x-s2-endpoint": server.s2.endpoint,
261+
},
262+
}),
263+
});
264+
265+
const { lastEventId } = await writer.wait();
266+
expect(lastEventId).toBeDefined();
267+
268+
const agentSeq = await producer.appendData({ n: 2 }, "agent-0");
269+
270+
const { parts } = await collectSessionOut({
271+
baseUrl,
272+
addressingKey,
273+
token,
274+
lastEventId,
275+
until: (p) => p.some((x) => x.chunk != null),
276+
maxMs: 15_000,
277+
});
278+
279+
const dataChunks = parts
280+
.filter((p) => p.chunk != null)
281+
.map((p) => (p.chunk as { n: number }).n);
282+
283+
expect(dataChunks).toEqual([2]);
284+
expect(parts.map((p) => Number(p.id))).toContain(agentSeq);
285+
});
286+
228287
it("E11 in/append delivers the record on the .in channel", async () => {
229288
const { addressingKey, token, baseUrl } = await setupSession();
230289

packages/core/src/v3/realtimeStreams/streamsWriterV2.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -183,10 +183,16 @@ export class StreamsWriterV2<T = any> implements StreamsWriter {
183183
const lastAcked = session.lastAckedPosition();
184184

185185
if (lastAcked?.end) {
186-
this.lastSeqNum = lastAcked.end.seqNum;
187-
this.log(
188-
`[S2MetadataStream] Written ${this.lastSeqNum} records, ending at seqNum=${this.lastSeqNum}`
189-
);
186+
/**
187+
* S2's ack `end.seqNum` is exclusive: the seq AFTER the last record
188+
* written (equal to the tail). Report the last record's own seq
189+
* (`end - 1`) as `lastEventId` so a resume subscribe, which reads
190+
* from `lastEventId + 1`, lands on the next record instead of
191+
* skipping one. Matches the inclusive seq the one-shot control
192+
* writer returns.
193+
*/
194+
this.lastSeqNum = lastAcked.end.seqNum - 1;
195+
this.log(`[S2MetadataStream] Wrote through seqNum=${this.lastSeqNum}`);
190196
}
191197
} catch (error) {
192198
if (this.aborted) {

0 commit comments

Comments
 (0)