Skip to content

Commit a83bfbd

Browse files
committed
test(sdk): cover pipeAndCapture status/partial-capture and writeTurnComplete lastEventId
Adds unit tests for the new chat.pipeAndCapture result shape (complete / aborted-with-partial / error) and for chat.writeTurnComplete returning a resume cursor. Extends the in-memory session test harness so its .out writeControl projects a synthetic seq_num and its pipe propagates source-stream errors (mirroring production) instead of swallowing them.
1 parent 335c087 commit a83bfbd

2 files changed

Lines changed: 230 additions & 5 deletions

File tree

packages/trigger-sdk/src/v3/test/test-session-handle.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ export class TestSessionOutputChannel extends SessionOutputChannel {
124124
): PipeStreamResult<T> {
125125
const state = this.state;
126126
const readChunks: T[] = [];
127+
let pipeError: unknown;
127128
let resolveDone!: () => void;
128129
const done = new Promise<void>((resolve) => {
129130
resolveDone = resolve;
@@ -135,10 +136,15 @@ export class TestSessionOutputChannel extends SessionOutputChannel {
135136
try {
136137
while (true) {
137138
const { done: d, value: v } = await reader.read();
138-
if (d) return;
139+
if (d) break;
139140
readChunks.push(v as T);
140141
notify(state, v);
141142
}
143+
} catch (err) {
144+
// Mirror production: a source-stream error rejects waitUntilComplete
145+
// instead of being silently swallowed, so callers (e.g.
146+
// chat.pipeAndCapture) can observe the failure.
147+
pipeError = err;
142148
} finally {
143149
try {
144150
reader.releaseLock();
@@ -147,9 +153,7 @@ export class TestSessionOutputChannel extends SessionOutputChannel {
147153
}
148154
resolveDone();
149155
}
150-
})().catch(() => {
151-
resolveDone();
152-
});
156+
})();
153157

154158
const replayStream = new ReadableStream<T>({
155159
async start(controller) {
@@ -167,6 +171,7 @@ export class TestSessionOutputChannel extends SessionOutputChannel {
167171
},
168172
waitUntilComplete: async () => {
169173
await done;
174+
if (pipeError) throw pipeError;
170175
return emptyResult;
171176
},
172177
};
@@ -262,7 +267,11 @@ export class TestSessionOutputChannel extends SessionOutputChannel {
262267
}
263268
}
264269
notify(this.state, synthetic);
265-
return {};
270+
// Project a synthetic monotonic seq_num as the ack's `lastEventId`,
271+
// mirroring what S2 returns in production (there it's the control
272+
// record's seq_num). Using the running `.out` record count lets
273+
// `chat.writeTurnComplete()` surface a real resume cursor in tests.
274+
return { lastEventId: String(this.state.chunks.length) };
266275
}
267276

268277
/**
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
// Import the test harness FIRST — this installs the resource catalog so
2+
// `chat.customAgent()` calls below register their task functions correctly.
3+
import { mockChatAgent } from "../src/v3/test/index.js";
4+
5+
import { describe, expect, it } from "vitest";
6+
import type { UIMessage } from "ai";
7+
import { simulateReadableStream, streamText } from "ai";
8+
import { MockLanguageModelV3 } from "ai/test";
9+
import type { LanguageModelV3StreamPart } from "@ai-sdk/provider";
10+
import { chat } from "../src/v3/ai.js";
11+
import type { PipeAndCaptureResult } from "../src/v3/ai.js";
12+
13+
// ── Helpers ────────────────────────────────────────────────────────────
14+
15+
function userMessage(text: string, id: string): UIMessage {
16+
return { id, role: "user", parts: [{ type: "text", text }] };
17+
}
18+
19+
function textChunks(text: string, opts?: { split?: boolean }): LanguageModelV3StreamPart[] {
20+
const deltas = opts?.split ? text.split(" ").map((w, i) => (i === 0 ? w : ` ${w}`)) : [text];
21+
return [
22+
{ type: "text-start", id: "t1" },
23+
...deltas.map((delta) => ({ type: "text-delta" as const, id: "t1", delta })),
24+
{ type: "text-end", id: "t1" },
25+
{
26+
type: "finish",
27+
finishReason: { unified: "stop", raw: "stop" },
28+
usage: {
29+
inputTokens: { total: 5, noCache: 5, cacheRead: undefined, cacheWrite: undefined },
30+
outputTokens: { total: 5, text: 5, reasoning: undefined },
31+
},
32+
},
33+
];
34+
}
35+
36+
/** Model that streams `text` in one fast pass with a `stop` finish. */
37+
function fastModel(text: string) {
38+
return new MockLanguageModelV3({
39+
doStream: async () => ({ stream: simulateReadableStream({ chunks: textChunks(text) }) }),
40+
});
41+
}
42+
43+
/** Model that streams `text` word-by-word with a wide gap before the final
44+
* chunk, leaving a window to abort mid-stream after the first delta. */
45+
function slowModel(text: string) {
46+
return new MockLanguageModelV3({
47+
doStream: async () => ({
48+
stream: simulateReadableStream({
49+
chunks: textChunks(text, { split: true }),
50+
initialDelayInMs: 0,
51+
chunkDelayInMs: 500,
52+
}),
53+
}),
54+
});
55+
}
56+
57+
function extractText(message: UIMessage | undefined): string {
58+
if (!message) return "";
59+
return (message.parts as Array<{ type: string; text?: string }>)
60+
.filter((p) => p.type === "text")
61+
.map((p) => p.text ?? "")
62+
.join("");
63+
}
64+
65+
async function waitFor(check: () => boolean, timeoutMs = 5_000) {
66+
const start = Date.now();
67+
while (Date.now() - start < timeoutMs) {
68+
if (check()) return;
69+
await new Promise((r) => setTimeout(r, 20));
70+
}
71+
throw new Error("waitFor timed out");
72+
}
73+
74+
function deltaCount(harness: { allChunks: unknown[] }): number {
75+
return (harness.allChunks as { type?: string }[]).filter((c) => c.type === "text-delta").length;
76+
}
77+
78+
// ── Tests ──────────────────────────────────────────────────────────────
79+
80+
describe("chat.pipeAndCapture", () => {
81+
it("returns status 'complete' with the message and finish reason on a normal turn", async () => {
82+
const captures: PipeAndCaptureResult[] = [];
83+
const lastEventIds: Array<string | undefined> = [];
84+
85+
const agent = chat.customAgent({
86+
id: "pipe-capture.complete",
87+
run: async () => {
88+
const conversation = new chat.MessageAccumulator();
89+
const next = await chat.messages.waitWithIdleTimeout({
90+
idleTimeoutInSeconds: 60,
91+
timeout: "1h",
92+
});
93+
if (!next.ok) return;
94+
const wire = next.output as { message?: UIMessage; trigger: string };
95+
const incoming = wire.message ? [wire.message] : [];
96+
const messages = await conversation.addIncoming(incoming, wire.trigger, 0);
97+
const result = streamText({ model: fastModel("hello world"), messages });
98+
const captured = await chat.pipeAndCapture(result);
99+
captures.push(captured);
100+
if (captured.message) await conversation.addResponse(captured.message);
101+
const { lastEventId } = await chat.writeTurnComplete();
102+
lastEventIds.push(lastEventId);
103+
},
104+
});
105+
106+
const harness = mockChatAgent(agent, { chatId: "pc-complete" });
107+
try {
108+
await harness.sendMessage(userMessage("hi", "u-1"));
109+
await waitFor(() => captures.length >= 1 && lastEventIds.length >= 1);
110+
111+
expect(captures[0]!.status).toBe("complete");
112+
expect(extractText(captures[0]!.message)).toBe("hello world");
113+
expect(captures[0]!.finishReason).toBe("stop");
114+
expect(captures[0]!.error).toBeUndefined();
115+
// chat.writeTurnComplete() surfaces the resume cursor for the next turn.
116+
expect(typeof lastEventIds[0]).toBe("string");
117+
expect(lastEventIds[0]!.length).toBeGreaterThan(0);
118+
} finally {
119+
await harness.close();
120+
}
121+
});
122+
123+
it("returns status 'error' with the thrown error and does not throw when the stream fails", async () => {
124+
const captures: PipeAndCaptureResult[] = [];
125+
let runThrew = false;
126+
127+
// Synthetic source whose UI stream errors after emitting a partial. This
128+
// deterministically drives the pipe-failure path without depending on the
129+
// AI SDK's model-error handling.
130+
const erroringSource = {
131+
toUIMessageStream() {
132+
return new ReadableStream({
133+
start(controller) {
134+
controller.enqueue({ type: "start", messageId: "a-err" });
135+
controller.enqueue({ type: "text-start", id: "t1" });
136+
controller.enqueue({ type: "text-delta", id: "t1", delta: "partial" });
137+
controller.error(new Error("boom"));
138+
},
139+
});
140+
},
141+
};
142+
143+
const agent = chat.customAgent({
144+
id: "pipe-capture.error",
145+
run: async () => {
146+
const next = await chat.messages.waitWithIdleTimeout({
147+
idleTimeoutInSeconds: 60,
148+
timeout: "1h",
149+
});
150+
if (!next.ok) return;
151+
try {
152+
captures.push(await chat.pipeAndCapture(erroringSource as never));
153+
} catch {
154+
runThrew = true;
155+
}
156+
await chat.writeTurnComplete();
157+
},
158+
});
159+
160+
const harness = mockChatAgent(agent, { chatId: "pc-error" });
161+
try {
162+
await harness.sendMessage(userMessage("go", "u-1"));
163+
await waitFor(() => captures.length >= 1);
164+
165+
expect(runThrew).toBe(false);
166+
expect(captures[0]!.status).toBe("error");
167+
expect(captures[0]!.error).toBeInstanceOf(Error);
168+
expect((captures[0]!.error as Error).message).toBe("boom");
169+
} finally {
170+
await harness.close();
171+
}
172+
});
173+
174+
it("returns status 'aborted' and preserves the partial message on a mid-stream stop", async () => {
175+
const captures: PipeAndCaptureResult[] = [];
176+
177+
const agent = chat.customAgent({
178+
id: "pipe-capture.aborted",
179+
run: async () => {
180+
const stop = chat.createStopSignal();
181+
const conversation = new chat.MessageAccumulator();
182+
const next = await chat.messages.waitWithIdleTimeout({
183+
idleTimeoutInSeconds: 60,
184+
timeout: "1h",
185+
});
186+
if (!next.ok) return;
187+
const wire = next.output as { message?: UIMessage; trigger: string };
188+
const incoming = wire.message ? [wire.message] : [];
189+
const messages = await conversation.addIncoming(incoming, wire.trigger, 0);
190+
const result = streamText({
191+
model: slowModel("one two three four"),
192+
messages,
193+
abortSignal: stop.signal,
194+
});
195+
captures.push(await chat.pipeAndCapture(result, { signal: stop.signal }));
196+
await chat.writeTurnComplete();
197+
stop.cleanup();
198+
},
199+
});
200+
201+
const harness = mockChatAgent(agent, { chatId: "pc-aborted" });
202+
try {
203+
void harness.sendMessage(userMessage("hi", "u-1"));
204+
// Stop once the first delta has streamed but before the turn finishes.
205+
await waitFor(() => deltaCount(harness) >= 1);
206+
await harness.sendStop();
207+
await waitFor(() => captures.length >= 1);
208+
209+
expect(captures[0]!.status).toBe("aborted");
210+
// The partial that streamed before the stop is preserved.
211+
expect(extractText(captures[0]!.message).startsWith("one")).toBe(true);
212+
} finally {
213+
await harness.close();
214+
}
215+
});
216+
});

0 commit comments

Comments
 (0)