|
12 | 12 | */ |
13 | 13 | import { randomBytes } from "crypto"; |
14 | 14 | import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; |
| 15 | +import { SessionStreamInstance } from "@trigger.dev/core/v3"; |
15 | 16 | import type { SessionStreamTestServer } from "@internal/testcontainers/webapp"; |
16 | 17 | import { startSessionStreamTestServer } from "@internal/testcontainers/webapp"; |
17 | 18 | import { seedTestEnvironment } from "./helpers/seedTestEnvironment"; |
@@ -65,7 +66,16 @@ async function setupSession() { |
65 | 66 | basin: server.s2.basin, |
66 | 67 | streamName, |
67 | 68 | }); |
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 | + }); |
69 | 79 | } |
70 | 80 |
|
71 | 81 | describe("session stream e2e", () => { |
@@ -225,6 +235,55 @@ describe("session stream e2e", () => { |
225 | 235 | expect(result!.closedMs).toBeLessThan(8_000); |
226 | 236 | }); |
227 | 237 |
|
| 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 | + |
228 | 287 | it("E11 in/append delivers the record on the .in channel", async () => { |
229 | 288 | const { addressingKey, token, baseUrl } = await setupSession(); |
230 | 289 |
|
|
0 commit comments