assistant-stream 0.3.13 → 0.3.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -0
- package/dist/core/AssistantStreamChunk.d.ts +2 -0
- package/dist/core/AssistantStreamChunk.d.ts.map +1 -1
- package/dist/core/accumulators/assistant-message-accumulator.d.ts.map +1 -1
- package/dist/core/accumulators/assistant-message-accumulator.js +3 -0
- package/dist/core/accumulators/assistant-message-accumulator.js.map +1 -1
- package/dist/core/modules/tool-call.d.ts.map +1 -1
- package/dist/core/modules/tool-call.js +3 -0
- package/dist/core/modules/tool-call.js.map +1 -1
- package/dist/core/tool/ToolExecutionStream.d.ts.map +1 -1
- package/dist/core/tool/ToolExecutionStream.js +3 -0
- package/dist/core/tool/ToolExecutionStream.js.map +1 -1
- package/dist/core/tool/ToolResponse.d.ts +3 -0
- package/dist/core/tool/ToolResponse.d.ts.map +1 -1
- package/dist/core/tool/ToolResponse.js +4 -0
- package/dist/core/tool/ToolResponse.js.map +1 -1
- package/dist/core/tool/tool-types.d.ts +17 -0
- package/dist/core/tool/tool-types.d.ts.map +1 -1
- package/dist/core/tool/toolResultStream.d.ts.map +1 -1
- package/dist/core/tool/toolResultStream.js +26 -1
- package/dist/core/tool/toolResultStream.js.map +1 -1
- package/dist/core/utils/types.d.ts +4 -0
- package/dist/core/utils/types.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/resumable/ResumableStreamContext.d.ts +27 -0
- package/dist/resumable/ResumableStreamContext.d.ts.map +1 -0
- package/dist/resumable/ResumableStreamContext.js +121 -0
- package/dist/resumable/ResumableStreamContext.js.map +1 -0
- package/dist/resumable/constants.d.ts +2 -0
- package/dist/resumable/constants.d.ts.map +1 -0
- package/dist/resumable/constants.js +2 -0
- package/dist/resumable/constants.js.map +1 -0
- package/dist/resumable/createResumableAssistantStreamResponse.d.ts +24 -0
- package/dist/resumable/createResumableAssistantStreamResponse.d.ts.map +1 -0
- package/dist/resumable/createResumableAssistantStreamResponse.js +40 -0
- package/dist/resumable/createResumableAssistantStreamResponse.js.map +1 -0
- package/dist/resumable/errors.d.ts +7 -0
- package/dist/resumable/errors.d.ts.map +1 -0
- package/dist/resumable/errors.js +15 -0
- package/dist/resumable/errors.js.map +1 -0
- package/dist/resumable/index.d.ts +7 -0
- package/dist/resumable/index.d.ts.map +1 -0
- package/dist/resumable/index.js +5 -0
- package/dist/resumable/index.js.map +1 -0
- package/dist/resumable/stores/InMemoryResumableStreamStore.d.ts +13 -0
- package/dist/resumable/stores/InMemoryResumableStreamStore.d.ts.map +1 -0
- package/dist/resumable/stores/InMemoryResumableStreamStore.js +199 -0
- package/dist/resumable/stores/InMemoryResumableStreamStore.js.map +1 -0
- package/dist/resumable/stores/ioredis.d.ts +10 -0
- package/dist/resumable/stores/ioredis.d.ts.map +1 -0
- package/dist/resumable/stores/ioredis.js +95 -0
- package/dist/resumable/stores/ioredis.js.map +1 -0
- package/dist/resumable/stores/redis-impl.d.ts +60 -0
- package/dist/resumable/stores/redis-impl.d.ts.map +1 -0
- package/dist/resumable/stores/redis-impl.js +198 -0
- package/dist/resumable/stores/redis-impl.js.map +1 -0
- package/dist/resumable/stores/redis.d.ts +39 -0
- package/dist/resumable/stores/redis.d.ts.map +1 -0
- package/dist/resumable/stores/redis.js +113 -0
- package/dist/resumable/stores/redis.js.map +1 -0
- package/dist/resumable/types.d.ts +30 -0
- package/dist/resumable/types.d.ts.map +1 -0
- package/dist/resumable/types.js +2 -0
- package/dist/resumable/types.js.map +1 -0
- package/package.json +28 -3
- package/src/core/AssistantStreamChunk.ts +2 -0
- package/src/core/accumulators/assistant-message-accumulator.ts +3 -0
- package/src/core/modules/tool-call.ts +3 -0
- package/src/core/tool/ToolExecutionStream.ts +3 -0
- package/src/core/tool/ToolResponse.ts +6 -0
- package/src/core/tool/tool-types.ts +23 -0
- package/src/core/tool/toolResultStream.test.ts +360 -2
- package/src/core/tool/toolResultStream.ts +30 -1
- package/src/core/utils/types.ts +4 -0
- package/src/index.ts +5 -1
- package/src/resumable/ResumableStreamContext.test.ts +274 -0
- package/src/resumable/ResumableStreamContext.ts +187 -0
- package/src/resumable/__tests__/integration.test.ts +159 -0
- package/src/resumable/constants.ts +1 -0
- package/src/resumable/createResumableAssistantStreamResponse.test.ts +243 -0
- package/src/resumable/createResumableAssistantStreamResponse.ts +80 -0
- package/src/resumable/errors.ts +26 -0
- package/src/resumable/index.ts +36 -0
- package/src/resumable/stores/InMemoryResumableStreamStore.test.ts +285 -0
- package/src/resumable/stores/InMemoryResumableStreamStore.ts +237 -0
- package/src/resumable/stores/ioredis.ts +123 -0
- package/src/resumable/stores/redis-impl.ts +304 -0
- package/src/resumable/stores/redis.test.ts +265 -0
- package/src/resumable/stores/redis.ts +171 -0
- package/src/resumable/types.ts +49 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createResumableStreamContext } from "./ResumableStreamContext";
|
|
3
|
+
import { ResumableStreamError } from "./errors";
|
|
4
|
+
import { createInMemoryResumableStreamStore } from "./stores/InMemoryResumableStreamStore";
|
|
5
|
+
|
|
6
|
+
const enc = new TextEncoder();
|
|
7
|
+
const dec = new TextDecoder();
|
|
8
|
+
|
|
9
|
+
const bytes = (s: string): Uint8Array => enc.encode(s);
|
|
10
|
+
|
|
11
|
+
async function collect(stream: ReadableStream<Uint8Array>): Promise<string> {
|
|
12
|
+
const reader = stream.getReader();
|
|
13
|
+
let out = "";
|
|
14
|
+
try {
|
|
15
|
+
while (true) {
|
|
16
|
+
const { done, value } = await reader.read();
|
|
17
|
+
if (done) return out;
|
|
18
|
+
out += dec.decode(value, { stream: true });
|
|
19
|
+
}
|
|
20
|
+
} finally {
|
|
21
|
+
reader.releaseLock();
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function makeStringStream(parts: string[]): ReadableStream<Uint8Array> {
|
|
26
|
+
return new ReadableStream<Uint8Array>({
|
|
27
|
+
async start(controller) {
|
|
28
|
+
for (const part of parts) {
|
|
29
|
+
controller.enqueue(bytes(part));
|
|
30
|
+
await Promise.resolve();
|
|
31
|
+
}
|
|
32
|
+
controller.close();
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("createResumableStreamContext", () => {
|
|
38
|
+
it("producer caller receives full byte stream", async () => {
|
|
39
|
+
const ctx = createResumableStreamContext({
|
|
40
|
+
store: createInMemoryResumableStreamStore(),
|
|
41
|
+
});
|
|
42
|
+
const stream = await ctx.run("a", () =>
|
|
43
|
+
makeStringStream(["hello ", "world"]),
|
|
44
|
+
);
|
|
45
|
+
expect(await collect(stream)).toBe("hello world");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("second caller becomes consumer and receives identical bytes", async () => {
|
|
49
|
+
const store = createInMemoryResumableStreamStore();
|
|
50
|
+
const ctx = createResumableStreamContext({ store });
|
|
51
|
+
|
|
52
|
+
const producerStream = await ctx.run("a", () =>
|
|
53
|
+
makeStringStream(["one ", "two ", "three"]),
|
|
54
|
+
);
|
|
55
|
+
const consumerStream = await ctx.run("a", () =>
|
|
56
|
+
makeStringStream(["should-not-run"]),
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const [a, b] = await Promise.all([
|
|
60
|
+
collect(producerStream),
|
|
61
|
+
collect(consumerStream),
|
|
62
|
+
]);
|
|
63
|
+
expect(a).toBe("one two three");
|
|
64
|
+
expect(b).toBe("one two three");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("late consumer after done replays via resume", async () => {
|
|
68
|
+
const ctx = createResumableStreamContext({
|
|
69
|
+
store: createInMemoryResumableStreamStore(),
|
|
70
|
+
});
|
|
71
|
+
const producer = await ctx.run("a", () =>
|
|
72
|
+
makeStringStream(["alpha", "beta", "gamma"]),
|
|
73
|
+
);
|
|
74
|
+
expect(await collect(producer)).toBe("alphabetagamma");
|
|
75
|
+
|
|
76
|
+
const replay = await ctx.resume("a");
|
|
77
|
+
expect(replay).not.toBeNull();
|
|
78
|
+
expect(await collect(replay!)).toBe("alphabetagamma");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("resume returns null for missing streams", async () => {
|
|
82
|
+
const ctx = createResumableStreamContext({
|
|
83
|
+
store: createInMemoryResumableStreamStore(),
|
|
84
|
+
});
|
|
85
|
+
expect(await ctx.resume("nope")).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("requireResume throws ResumableStreamError for missing streams", async () => {
|
|
89
|
+
const ctx = createResumableStreamContext({
|
|
90
|
+
store: createInMemoryResumableStreamStore(),
|
|
91
|
+
});
|
|
92
|
+
await expect(ctx.requireResume("nope")).rejects.toBeInstanceOf(
|
|
93
|
+
ResumableStreamError,
|
|
94
|
+
);
|
|
95
|
+
await expect(ctx.requireResume("nope")).rejects.toMatchObject({
|
|
96
|
+
code: "missing",
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("requireResume returns the replay stream when it exists", async () => {
|
|
101
|
+
const ctx = createResumableStreamContext({
|
|
102
|
+
store: createInMemoryResumableStreamStore(),
|
|
103
|
+
});
|
|
104
|
+
const producer = await ctx.run("a", () => makeStringStream(["hi"]));
|
|
105
|
+
expect(await collect(producer)).toBe("hi");
|
|
106
|
+
|
|
107
|
+
const replay = await ctx.requireResume("a");
|
|
108
|
+
expect(await collect(replay)).toBe("hi");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("status tracks lifecycle", async () => {
|
|
112
|
+
const ctx = createResumableStreamContext({
|
|
113
|
+
store: createInMemoryResumableStreamStore(),
|
|
114
|
+
});
|
|
115
|
+
expect(await ctx.status("a")).toBe("missing");
|
|
116
|
+
const stream = await ctx.run("a", () => makeStringStream(["x"]));
|
|
117
|
+
await collect(stream);
|
|
118
|
+
expect(await ctx.status("a")).toBe("done");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("delete removes stream state", async () => {
|
|
122
|
+
const ctx = createResumableStreamContext({
|
|
123
|
+
store: createInMemoryResumableStreamStore(),
|
|
124
|
+
});
|
|
125
|
+
const stream = await ctx.run("a", () => makeStringStream(["x"]));
|
|
126
|
+
await collect(stream);
|
|
127
|
+
expect(await ctx.status("a")).toBe("done");
|
|
128
|
+
await ctx.delete("a");
|
|
129
|
+
expect(await ctx.status("a")).toBe("missing");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("producer keeps writing after the local consumer cancels", async () => {
|
|
133
|
+
const store = createInMemoryResumableStreamStore();
|
|
134
|
+
const ctx = createResumableStreamContext({ store });
|
|
135
|
+
|
|
136
|
+
let producerEmitted = 0;
|
|
137
|
+
const slowStream = new ReadableStream<Uint8Array>({
|
|
138
|
+
async start(controller) {
|
|
139
|
+
for (let i = 0; i < 5; i++) {
|
|
140
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
141
|
+
controller.enqueue(bytes(`chunk${i};`));
|
|
142
|
+
producerEmitted += 1;
|
|
143
|
+
}
|
|
144
|
+
controller.close();
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const stream = await ctx.run("a", () => slowStream);
|
|
149
|
+
const reader = stream.getReader();
|
|
150
|
+
const first = await reader.read();
|
|
151
|
+
expect(first.done).toBe(false);
|
|
152
|
+
await reader.cancel();
|
|
153
|
+
|
|
154
|
+
while (producerEmitted < 5) {
|
|
155
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
156
|
+
}
|
|
157
|
+
while ((await ctx.status("a")) === "streaming") {
|
|
158
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const replay = await ctx.resume("a");
|
|
162
|
+
expect(replay).not.toBeNull();
|
|
163
|
+
expect(await collect(replay!)).toBe("chunk0;chunk1;chunk2;chunk3;chunk4;");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("propagates producer errors to consumers", async () => {
|
|
167
|
+
const ctx = createResumableStreamContext({
|
|
168
|
+
store: createInMemoryResumableStreamStore(),
|
|
169
|
+
});
|
|
170
|
+
const failing = new ReadableStream<Uint8Array>({
|
|
171
|
+
start(controller) {
|
|
172
|
+
controller.enqueue(bytes("partial;"));
|
|
173
|
+
controller.error(new Error("oops"));
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
const stream = await ctx.run("a", () => failing);
|
|
177
|
+
await expect(collect(stream)).rejects.toThrow("oops");
|
|
178
|
+
expect(await ctx.status("a")).toBe("error");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("waitUntil receives the producer task promise", async () => {
|
|
182
|
+
const promises: Promise<unknown>[] = [];
|
|
183
|
+
const ctx = createResumableStreamContext({
|
|
184
|
+
store: createInMemoryResumableStreamStore(),
|
|
185
|
+
waitUntil: (p) => promises.push(p),
|
|
186
|
+
});
|
|
187
|
+
const stream = await ctx.run("a", () => makeStringStream(["x"]));
|
|
188
|
+
expect(await collect(stream)).toBe("x");
|
|
189
|
+
expect(promises.length).toBe(1);
|
|
190
|
+
await Promise.all(promises);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("onAcquire fires for both producer and consumer roles", async () => {
|
|
194
|
+
const calls: Array<{ id: string; role: string }> = [];
|
|
195
|
+
const ctx = createResumableStreamContext({
|
|
196
|
+
store: createInMemoryResumableStreamStore(),
|
|
197
|
+
onAcquire: (id, role) => calls.push({ id, role }),
|
|
198
|
+
});
|
|
199
|
+
const producer = await ctx.run("a", () => makeStringStream(["x"]));
|
|
200
|
+
const consumer = await ctx.run("a", () => makeStringStream(["unused"]));
|
|
201
|
+
await Promise.all([collect(producer), collect(consumer)]);
|
|
202
|
+
expect(calls).toEqual([
|
|
203
|
+
{ id: "a", role: "producer" },
|
|
204
|
+
{ id: "a", role: "consumer" },
|
|
205
|
+
]);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("onAppend fires per appended chunk with byteLength", async () => {
|
|
209
|
+
const calls: Array<{ id: string; byteLength: number }> = [];
|
|
210
|
+
const ctx = createResumableStreamContext({
|
|
211
|
+
store: createInMemoryResumableStreamStore(),
|
|
212
|
+
onAppend: (id, byteLength) => calls.push({ id, byteLength }),
|
|
213
|
+
});
|
|
214
|
+
const stream = await ctx.run("a", () => makeStringStream(["ab", "cde"]));
|
|
215
|
+
expect(await collect(stream)).toBe("abcde");
|
|
216
|
+
expect(calls).toEqual([
|
|
217
|
+
{ id: "a", byteLength: 2 },
|
|
218
|
+
{ id: "a", byteLength: 3 },
|
|
219
|
+
]);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("onFinalize fires on successful completion", async () => {
|
|
223
|
+
const calls: Array<{
|
|
224
|
+
id: string;
|
|
225
|
+
status: "done" | "error";
|
|
226
|
+
error: string | undefined;
|
|
227
|
+
}> = [];
|
|
228
|
+
const ctx = createResumableStreamContext({
|
|
229
|
+
store: createInMemoryResumableStreamStore(),
|
|
230
|
+
onFinalize: (id, status, error) => calls.push({ id, status, error }),
|
|
231
|
+
});
|
|
232
|
+
const stream = await ctx.run("a", () => makeStringStream(["x"]));
|
|
233
|
+
await collect(stream);
|
|
234
|
+
expect(calls).toEqual([{ id: "a", status: "done", error: undefined }]);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("onFinalize fires with error status when producer fails", async () => {
|
|
238
|
+
const calls: Array<{
|
|
239
|
+
id: string;
|
|
240
|
+
status: "done" | "error";
|
|
241
|
+
error: string | undefined;
|
|
242
|
+
}> = [];
|
|
243
|
+
const ctx = createResumableStreamContext({
|
|
244
|
+
store: createInMemoryResumableStreamStore(),
|
|
245
|
+
onFinalize: (id, status, error) => calls.push({ id, status, error }),
|
|
246
|
+
});
|
|
247
|
+
const failing = new ReadableStream<Uint8Array>({
|
|
248
|
+
start(controller) {
|
|
249
|
+
controller.error(new Error("boom"));
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
const stream = await ctx.run("a", () => failing);
|
|
253
|
+
await expect(collect(stream)).rejects.toThrow("boom");
|
|
254
|
+
expect(calls).toEqual([{ id: "a", status: "error", error: "boom" }]);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("onError fires when the producer task throws", async () => {
|
|
258
|
+
const errors: Array<{ id: string; error: unknown }> = [];
|
|
259
|
+
const ctx = createResumableStreamContext({
|
|
260
|
+
store: createInMemoryResumableStreamStore(),
|
|
261
|
+
onError: (id, error) => errors.push({ id, error }),
|
|
262
|
+
});
|
|
263
|
+
const failing = new ReadableStream<Uint8Array>({
|
|
264
|
+
start(controller) {
|
|
265
|
+
controller.error(new Error("boom"));
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
const stream = await ctx.run("a", () => failing);
|
|
269
|
+
await expect(collect(stream)).rejects.toThrow("boom");
|
|
270
|
+
expect(errors).toHaveLength(1);
|
|
271
|
+
expect(errors[0]!.id).toBe("a");
|
|
272
|
+
expect((errors[0]!.error as Error).message).toBe("boom");
|
|
273
|
+
});
|
|
274
|
+
});
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { ResumableStreamError } from "./errors";
|
|
2
|
+
import type {
|
|
3
|
+
ResumableStreamRole,
|
|
4
|
+
ResumableStreamStatus,
|
|
5
|
+
ResumableStreamStore,
|
|
6
|
+
} from "./types";
|
|
7
|
+
|
|
8
|
+
export type ResumableStreamContextOptions = {
|
|
9
|
+
readonly store: ResumableStreamStore;
|
|
10
|
+
readonly ttlMs?: number;
|
|
11
|
+
/**
|
|
12
|
+
* Required on serverless runtimes that terminate background work when the
|
|
13
|
+
* request handler returns (Vercel, Cloudflare). Pass `after` from
|
|
14
|
+
* `next/server` so the producer task outlives the response.
|
|
15
|
+
*/
|
|
16
|
+
readonly waitUntil?: (promise: Promise<unknown>) => void;
|
|
17
|
+
readonly onAcquire?: (streamId: string, role: ResumableStreamRole) => void;
|
|
18
|
+
readonly onAppend?: (streamId: string, byteLength: number) => void;
|
|
19
|
+
readonly onFinalize?: (
|
|
20
|
+
streamId: string,
|
|
21
|
+
status: "done" | "error",
|
|
22
|
+
error?: string,
|
|
23
|
+
) => void;
|
|
24
|
+
readonly onError?: (streamId: string, error: unknown) => void;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export interface ResumableStreamContext {
|
|
28
|
+
/** Producer or consumer entrypoint. Atomically elects the role. */
|
|
29
|
+
run(
|
|
30
|
+
streamId: string,
|
|
31
|
+
makeStream: () => ReadableStream<Uint8Array>,
|
|
32
|
+
): Promise<ReadableStream<Uint8Array>>;
|
|
33
|
+
|
|
34
|
+
/** Returns `null` when the stream does not exist. */
|
|
35
|
+
resume(streamId: string): Promise<ReadableStream<Uint8Array> | null>;
|
|
36
|
+
|
|
37
|
+
/** Throws `ResumableStreamError("missing")` when the stream does not exist. */
|
|
38
|
+
requireResume(streamId: string): Promise<ReadableStream<Uint8Array>>;
|
|
39
|
+
|
|
40
|
+
status(streamId: string): Promise<ResumableStreamStatus>;
|
|
41
|
+
|
|
42
|
+
delete(streamId: string): Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function createResumableStreamContext(
|
|
46
|
+
options: ResumableStreamContextOptions,
|
|
47
|
+
): ResumableStreamContext {
|
|
48
|
+
const { store, waitUntil, onAcquire, onAppend, onFinalize, onError } =
|
|
49
|
+
options;
|
|
50
|
+
const acquireOptions =
|
|
51
|
+
options.ttlMs !== undefined ? { ttlMs: options.ttlMs } : undefined;
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
async run(streamId, makeStream) {
|
|
55
|
+
const role = await store.acquire(streamId, acquireOptions);
|
|
56
|
+
onAcquire?.(streamId, role);
|
|
57
|
+
if (role === "producer") {
|
|
58
|
+
startProducerTask(store, streamId, makeStream, {
|
|
59
|
+
waitUntil,
|
|
60
|
+
onAppend,
|
|
61
|
+
onFinalize,
|
|
62
|
+
onError,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return readFromStore(store, streamId);
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
async resume(streamId) {
|
|
69
|
+
const status = await store.status(streamId);
|
|
70
|
+
if (status === "missing") return null;
|
|
71
|
+
return readFromStore(store, streamId);
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
async requireResume(streamId) {
|
|
75
|
+
const status = await store.status(streamId);
|
|
76
|
+
if (status === "missing") {
|
|
77
|
+
throw new ResumableStreamError(
|
|
78
|
+
"missing",
|
|
79
|
+
`resumable stream not found: ${streamId}`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
return readFromStore(store, streamId);
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
async status(streamId) {
|
|
86
|
+
return store.status(streamId);
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
async delete(streamId) {
|
|
90
|
+
await store.delete(streamId);
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
type ProducerHooks = {
|
|
96
|
+
readonly waitUntil: ((promise: Promise<unknown>) => void) | undefined;
|
|
97
|
+
readonly onAppend:
|
|
98
|
+
| ((streamId: string, byteLength: number) => void)
|
|
99
|
+
| undefined;
|
|
100
|
+
readonly onFinalize:
|
|
101
|
+
| ((streamId: string, status: "done" | "error", error?: string) => void)
|
|
102
|
+
| undefined;
|
|
103
|
+
readonly onError: ((streamId: string, error: unknown) => void) | undefined;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
function startProducerTask(
|
|
107
|
+
store: ResumableStreamStore,
|
|
108
|
+
streamId: string,
|
|
109
|
+
makeStream: () => ReadableStream<Uint8Array>,
|
|
110
|
+
hooks: ProducerHooks,
|
|
111
|
+
): void {
|
|
112
|
+
const { waitUntil, onAppend, onFinalize, onError } = hooks;
|
|
113
|
+
const task = (async () => {
|
|
114
|
+
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
|
|
115
|
+
let cancelled = false;
|
|
116
|
+
try {
|
|
117
|
+
reader = makeStream().getReader();
|
|
118
|
+
while (true) {
|
|
119
|
+
const { done, value } = await reader.read();
|
|
120
|
+
if (done) break;
|
|
121
|
+
await store.append(streamId, value);
|
|
122
|
+
onAppend?.(streamId, value.byteLength);
|
|
123
|
+
}
|
|
124
|
+
await store.finalize(streamId, "done");
|
|
125
|
+
onFinalize?.(streamId, "done");
|
|
126
|
+
} catch (err) {
|
|
127
|
+
cancelled = true;
|
|
128
|
+
onError?.(streamId, err);
|
|
129
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
130
|
+
try {
|
|
131
|
+
await reader?.cancel(err);
|
|
132
|
+
} catch (cancelErr) {
|
|
133
|
+
console.error("resumable stream reader cancel failed:", cancelErr);
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
await store.finalize(streamId, "error", message);
|
|
137
|
+
onFinalize?.(streamId, "error", message);
|
|
138
|
+
} catch (finalizeErr) {
|
|
139
|
+
console.error("resumable stream finalize failed:", finalizeErr);
|
|
140
|
+
}
|
|
141
|
+
} finally {
|
|
142
|
+
if (!cancelled) reader?.releaseLock();
|
|
143
|
+
}
|
|
144
|
+
})();
|
|
145
|
+
|
|
146
|
+
if (waitUntil) waitUntil(task);
|
|
147
|
+
task.catch((err) => {
|
|
148
|
+
console.error("resumable producer task failed:", err);
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function readFromStore(
|
|
153
|
+
store: ResumableStreamStore,
|
|
154
|
+
streamId: string,
|
|
155
|
+
): ReadableStream<Uint8Array> {
|
|
156
|
+
const ac = new AbortController();
|
|
157
|
+
let iterator: AsyncIterator<{ chunk: Uint8Array }> | undefined;
|
|
158
|
+
|
|
159
|
+
return new ReadableStream<Uint8Array>({
|
|
160
|
+
start() {
|
|
161
|
+
iterator = store.read(streamId, "", ac.signal)[Symbol.asyncIterator]();
|
|
162
|
+
},
|
|
163
|
+
async pull(controller) {
|
|
164
|
+
try {
|
|
165
|
+
if (!iterator) return;
|
|
166
|
+
const { done, value } = await iterator.next();
|
|
167
|
+
if (done) {
|
|
168
|
+
controller.close();
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
controller.enqueue(value.chunk);
|
|
172
|
+
} catch (err) {
|
|
173
|
+
// the platform never calls cancel() on errored streams, so unwind
|
|
174
|
+
// the store iterator and abort the signal explicitly.
|
|
175
|
+
ac.abort();
|
|
176
|
+
try {
|
|
177
|
+
await iterator?.return?.();
|
|
178
|
+
} catch {}
|
|
179
|
+
controller.error(err);
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
cancel() {
|
|
183
|
+
ac.abort();
|
|
184
|
+
iterator?.return?.().catch(() => {});
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
RESUMABLE_STREAM_ID_HEADER,
|
|
4
|
+
createResumableAssistantStreamResponse,
|
|
5
|
+
createResumeAssistantStreamResponse,
|
|
6
|
+
} from "../createResumableAssistantStreamResponse";
|
|
7
|
+
import { createResumableStreamContext } from "../ResumableStreamContext";
|
|
8
|
+
import { createInMemoryResumableStreamStore } from "../stores/InMemoryResumableStreamStore";
|
|
9
|
+
import {
|
|
10
|
+
AssistantTransportDecoder,
|
|
11
|
+
AssistantTransportEncoder,
|
|
12
|
+
} from "../../core/serialization/assistant-transport/AssistantTransport";
|
|
13
|
+
import { DataStreamDecoder } from "../../core/serialization/data-stream/DataStream";
|
|
14
|
+
import { AssistantMessageAccumulator } from "../../core/accumulators/assistant-message-accumulator";
|
|
15
|
+
import type { AssistantStreamChunk } from "../../core/AssistantStreamChunk";
|
|
16
|
+
import type { AssistantMessage } from "../../core/utils/types";
|
|
17
|
+
|
|
18
|
+
async function decodeViaDataStream(body: ReadableStream<Uint8Array>) {
|
|
19
|
+
const stream = body.pipeThrough(new DataStreamDecoder());
|
|
20
|
+
const out: string[] = [];
|
|
21
|
+
for await (const chunk of stream) {
|
|
22
|
+
if (chunk.type === "text-delta") out.push(chunk.textDelta);
|
|
23
|
+
}
|
|
24
|
+
return out.join("");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function accumulate(
|
|
28
|
+
body: ReadableStream<Uint8Array>,
|
|
29
|
+
decoder: TransformStream<Uint8Array, AssistantStreamChunk>,
|
|
30
|
+
): Promise<AssistantMessage> {
|
|
31
|
+
const messages = body
|
|
32
|
+
.pipeThrough(decoder)
|
|
33
|
+
.pipeThrough(new AssistantMessageAccumulator());
|
|
34
|
+
let last: AssistantMessage | undefined;
|
|
35
|
+
for await (const msg of messages) last = msg;
|
|
36
|
+
if (!last) throw new Error("accumulator yielded no messages");
|
|
37
|
+
return last;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("resumable integration", () => {
|
|
41
|
+
it("ten concurrent consumers see identical bytes for a 50 chunk stream", async () => {
|
|
42
|
+
const ctx = createResumableStreamContext({
|
|
43
|
+
store: createInMemoryResumableStreamStore(),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const producer = await createResumableAssistantStreamResponse({
|
|
47
|
+
context: ctx,
|
|
48
|
+
streamId: "fanout",
|
|
49
|
+
callback: async (controller) => {
|
|
50
|
+
for (let i = 0; i < 50; i++) {
|
|
51
|
+
controller.appendText(`chunk-${i}`);
|
|
52
|
+
await Promise.resolve();
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const consumers = await Promise.all(
|
|
58
|
+
Array.from({ length: 10 }, async () => {
|
|
59
|
+
const r = await ctx.resume("fanout");
|
|
60
|
+
if (!r) throw new Error("resume returned null");
|
|
61
|
+
return new Response(r).arrayBuffer();
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
const producerBytes = await new Response(producer.body).arrayBuffer();
|
|
65
|
+
|
|
66
|
+
const expected = new Uint8Array(producerBytes);
|
|
67
|
+
for (const c of consumers) {
|
|
68
|
+
expect(new Uint8Array(c)).toEqual(expected);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const text = await decodeViaDataStream(
|
|
72
|
+
new Response(new Uint8Array(producerBytes)).body!,
|
|
73
|
+
);
|
|
74
|
+
expect(text).toBe(
|
|
75
|
+
Array.from({ length: 50 }, (_, i) => `chunk-${i}`).join(""),
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("non-ASCII payload round-trips byte-for-byte through resume", async () => {
|
|
80
|
+
const allCodePoints = Array.from({ length: 256 }, (_, i) =>
|
|
81
|
+
String.fromCodePoint(i),
|
|
82
|
+
).join("");
|
|
83
|
+
const emoji = "hello 🌍 🚀 ✨";
|
|
84
|
+
const payload = allCodePoints + emoji;
|
|
85
|
+
|
|
86
|
+
const ctx = createResumableStreamContext({
|
|
87
|
+
store: createInMemoryResumableStreamStore(),
|
|
88
|
+
});
|
|
89
|
+
const producer = await createResumableAssistantStreamResponse({
|
|
90
|
+
context: ctx,
|
|
91
|
+
streamId: "binary",
|
|
92
|
+
callback: (controller) => controller.appendText(payload),
|
|
93
|
+
});
|
|
94
|
+
const producerBytes = new Uint8Array(
|
|
95
|
+
await new Response(producer.body).arrayBuffer(),
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const replay = await createResumeAssistantStreamResponse({
|
|
99
|
+
context: ctx,
|
|
100
|
+
streamId: "binary",
|
|
101
|
+
});
|
|
102
|
+
const replayBytes = new Uint8Array(
|
|
103
|
+
await new Response(replay.body).arrayBuffer(),
|
|
104
|
+
);
|
|
105
|
+
expect(replayBytes).toEqual(producerBytes);
|
|
106
|
+
expect(replay.headers.get(RESUMABLE_STREAM_ID_HEADER)).toBe("binary");
|
|
107
|
+
|
|
108
|
+
const decoded = await decodeViaDataStream(new Response(replayBytes).body!);
|
|
109
|
+
expect(decoded).toBe(payload);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("AssistantTransportEncoder round-trips through resume into an identical message", async () => {
|
|
113
|
+
const ctx = createResumableStreamContext({
|
|
114
|
+
store: createInMemoryResumableStreamStore(),
|
|
115
|
+
});
|
|
116
|
+
const producer = await createResumableAssistantStreamResponse({
|
|
117
|
+
context: ctx,
|
|
118
|
+
streamId: "transport",
|
|
119
|
+
encoder: () => new AssistantTransportEncoder(),
|
|
120
|
+
callback: (controller) => {
|
|
121
|
+
controller.appendText("processing... ");
|
|
122
|
+
const tool = controller.addToolCallPart({
|
|
123
|
+
toolName: "lookup",
|
|
124
|
+
toolCallId: "tool-1",
|
|
125
|
+
args: { query: "weather" },
|
|
126
|
+
response: { result: { temperature: 72 } },
|
|
127
|
+
});
|
|
128
|
+
tool.close();
|
|
129
|
+
controller.appendText("done");
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
const producerMessage = await accumulate(
|
|
133
|
+
producer.body!,
|
|
134
|
+
new AssistantTransportDecoder(),
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const replay = await createResumeAssistantStreamResponse({
|
|
138
|
+
context: ctx,
|
|
139
|
+
streamId: "transport",
|
|
140
|
+
encoder: () => new AssistantTransportEncoder(),
|
|
141
|
+
});
|
|
142
|
+
expect(replay.headers.get("content-type")).toBe("text/event-stream");
|
|
143
|
+
const replayMessage = await accumulate(
|
|
144
|
+
replay.body!,
|
|
145
|
+
new AssistantTransportDecoder(),
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
expect(replayMessage.parts).toEqual(producerMessage.parts);
|
|
149
|
+
expect(replayMessage.status).toEqual(producerMessage.status);
|
|
150
|
+
const toolPart = replayMessage.parts.find((p) => p.type === "tool-call");
|
|
151
|
+
expect(toolPart).toBeDefined();
|
|
152
|
+
expect(toolPart).toMatchObject({
|
|
153
|
+
toolName: "lookup",
|
|
154
|
+
toolCallId: "tool-1",
|
|
155
|
+
args: { query: "weather" },
|
|
156
|
+
result: { temperature: 72 },
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
|