assistant-stream 0.3.13 → 0.3.15

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.
Files changed (103) hide show
  1. package/README.md +39 -0
  2. package/dist/core/AssistantStream.d.ts +37 -0
  3. package/dist/core/AssistantStream.d.ts.map +1 -1
  4. package/dist/core/AssistantStream.js +22 -0
  5. package/dist/core/AssistantStream.js.map +1 -1
  6. package/dist/core/AssistantStreamChunk.d.ts +32 -0
  7. package/dist/core/AssistantStreamChunk.d.ts.map +1 -1
  8. package/dist/core/accumulators/assistant-message-accumulator.d.ts.map +1 -1
  9. package/dist/core/accumulators/assistant-message-accumulator.js +3 -0
  10. package/dist/core/accumulators/assistant-message-accumulator.js.map +1 -1
  11. package/dist/core/modules/assistant-stream.d.ts +68 -0
  12. package/dist/core/modules/assistant-stream.d.ts.map +1 -1
  13. package/dist/core/modules/assistant-stream.js +23 -0
  14. package/dist/core/modules/assistant-stream.js.map +1 -1
  15. package/dist/core/modules/tool-call.d.ts.map +1 -1
  16. package/dist/core/modules/tool-call.js +3 -0
  17. package/dist/core/modules/tool-call.js.map +1 -1
  18. package/dist/core/tool/ToolExecutionStream.d.ts.map +1 -1
  19. package/dist/core/tool/ToolExecutionStream.js +3 -0
  20. package/dist/core/tool/ToolExecutionStream.js.map +1 -1
  21. package/dist/core/tool/ToolResponse.d.ts +44 -0
  22. package/dist/core/tool/ToolResponse.d.ts.map +1 -1
  23. package/dist/core/tool/ToolResponse.js +27 -0
  24. package/dist/core/tool/ToolResponse.js.map +1 -1
  25. package/dist/core/tool/tool-types.d.ts +119 -2
  26. package/dist/core/tool/tool-types.d.ts.map +1 -1
  27. package/dist/core/tool/toolResultStream.d.ts +15 -0
  28. package/dist/core/tool/toolResultStream.d.ts.map +1 -1
  29. package/dist/core/tool/toolResultStream.js +39 -1
  30. package/dist/core/tool/toolResultStream.js.map +1 -1
  31. package/dist/core/utils/types.d.ts +4 -0
  32. package/dist/core/utils/types.d.ts.map +1 -1
  33. package/dist/index.d.ts +1 -1
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js.map +1 -1
  36. package/dist/resumable/ResumableStreamContext.d.ts +27 -0
  37. package/dist/resumable/ResumableStreamContext.d.ts.map +1 -0
  38. package/dist/resumable/ResumableStreamContext.js +121 -0
  39. package/dist/resumable/ResumableStreamContext.js.map +1 -0
  40. package/dist/resumable/constants.d.ts +2 -0
  41. package/dist/resumable/constants.d.ts.map +1 -0
  42. package/dist/resumable/constants.js +2 -0
  43. package/dist/resumable/constants.js.map +1 -0
  44. package/dist/resumable/createResumableAssistantStreamResponse.d.ts +24 -0
  45. package/dist/resumable/createResumableAssistantStreamResponse.d.ts.map +1 -0
  46. package/dist/resumable/createResumableAssistantStreamResponse.js +40 -0
  47. package/dist/resumable/createResumableAssistantStreamResponse.js.map +1 -0
  48. package/dist/resumable/errors.d.ts +7 -0
  49. package/dist/resumable/errors.d.ts.map +1 -0
  50. package/dist/resumable/errors.js +15 -0
  51. package/dist/resumable/errors.js.map +1 -0
  52. package/dist/resumable/index.d.ts +7 -0
  53. package/dist/resumable/index.d.ts.map +1 -0
  54. package/dist/resumable/index.js +5 -0
  55. package/dist/resumable/index.js.map +1 -0
  56. package/dist/resumable/stores/InMemoryResumableStreamStore.d.ts +13 -0
  57. package/dist/resumable/stores/InMemoryResumableStreamStore.d.ts.map +1 -0
  58. package/dist/resumable/stores/InMemoryResumableStreamStore.js +199 -0
  59. package/dist/resumable/stores/InMemoryResumableStreamStore.js.map +1 -0
  60. package/dist/resumable/stores/ioredis.d.ts +10 -0
  61. package/dist/resumable/stores/ioredis.d.ts.map +1 -0
  62. package/dist/resumable/stores/ioredis.js +95 -0
  63. package/dist/resumable/stores/ioredis.js.map +1 -0
  64. package/dist/resumable/stores/redis-impl.d.ts +60 -0
  65. package/dist/resumable/stores/redis-impl.d.ts.map +1 -0
  66. package/dist/resumable/stores/redis-impl.js +198 -0
  67. package/dist/resumable/stores/redis-impl.js.map +1 -0
  68. package/dist/resumable/stores/redis.d.ts +39 -0
  69. package/dist/resumable/stores/redis.d.ts.map +1 -0
  70. package/dist/resumable/stores/redis.js +113 -0
  71. package/dist/resumable/stores/redis.js.map +1 -0
  72. package/dist/resumable/types.d.ts +30 -0
  73. package/dist/resumable/types.d.ts.map +1 -0
  74. package/dist/resumable/types.js +2 -0
  75. package/dist/resumable/types.js.map +1 -0
  76. package/package.json +28 -2
  77. package/src/core/AssistantStream.ts +37 -0
  78. package/src/core/AssistantStreamChunk.ts +32 -0
  79. package/src/core/accumulators/assistant-message-accumulator.ts +3 -0
  80. package/src/core/modules/assistant-stream.ts +68 -0
  81. package/src/core/modules/tool-call.ts +3 -0
  82. package/src/core/tool/ToolExecutionStream.ts +3 -0
  83. package/src/core/tool/ToolResponse.ts +50 -0
  84. package/src/core/tool/tool-types.ts +125 -2
  85. package/src/core/tool/toolResultStream.test.ts +360 -2
  86. package/src/core/tool/toolResultStream.ts +45 -1
  87. package/src/core/utils/types.ts +4 -0
  88. package/src/index.ts +5 -1
  89. package/src/resumable/ResumableStreamContext.test.ts +274 -0
  90. package/src/resumable/ResumableStreamContext.ts +187 -0
  91. package/src/resumable/__tests__/integration.test.ts +159 -0
  92. package/src/resumable/constants.ts +1 -0
  93. package/src/resumable/createResumableAssistantStreamResponse.test.ts +243 -0
  94. package/src/resumable/createResumableAssistantStreamResponse.ts +80 -0
  95. package/src/resumable/errors.ts +26 -0
  96. package/src/resumable/index.ts +36 -0
  97. package/src/resumable/stores/InMemoryResumableStreamStore.test.ts +285 -0
  98. package/src/resumable/stores/InMemoryResumableStreamStore.ts +237 -0
  99. package/src/resumable/stores/ioredis.ts +123 -0
  100. package/src/resumable/stores/redis-impl.ts +304 -0
  101. package/src/resumable/stores/redis.test.ts +265 -0
  102. package/src/resumable/stores/redis.ts +171 -0
  103. package/src/resumable/types.ts +49 -0
@@ -0,0 +1,243 @@
1
+ import { afterEach, beforeEach, 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 { ResumableStreamError } from "./errors";
9
+ import { createInMemoryResumableStreamStore } from "./stores/InMemoryResumableStreamStore";
10
+ import { DataStreamDecoder } from "../core/serialization/data-stream/DataStream";
11
+ import { AssistantTransportEncoder } from "../core/serialization/assistant-transport/AssistantTransport";
12
+ import { AssistantMessageAccumulator } from "../core/accumulators/assistant-message-accumulator";
13
+ import type { AssistantStreamChunk } from "../core/AssistantStreamChunk";
14
+
15
+ async function decodeAssistantText(response: Response): Promise<string> {
16
+ const chunks: AssistantStreamChunk[] = [];
17
+ const stream = response.body!.pipeThrough(new DataStreamDecoder());
18
+ for await (const chunk of stream) chunks.push(chunk);
19
+
20
+ const acc = new AssistantMessageAccumulator();
21
+ const messageStream = new ReadableStream<AssistantStreamChunk>({
22
+ start(c) {
23
+ for (const ch of chunks) c.enqueue(ch);
24
+ c.close();
25
+ },
26
+ }).pipeThrough(acc);
27
+
28
+ const reader = messageStream.getReader();
29
+ let last:
30
+ | { parts: ReadonlyArray<{ type: string; text?: string }> }
31
+ | undefined;
32
+ while (true) {
33
+ const { done, value } = await reader.read();
34
+ if (done) break;
35
+ last = value;
36
+ }
37
+ return (
38
+ last?.parts
39
+ .filter((p): p is { type: "text"; text: string } => p.type === "text")
40
+ .map((p) => p.text)
41
+ .join("") ?? ""
42
+ );
43
+ }
44
+
45
+ describe("createResumableAssistantStreamResponse", () => {
46
+ it("produces a response that decodes through DataStreamDecoder", async () => {
47
+ const ctx = createResumableStreamContext({
48
+ store: createInMemoryResumableStreamStore(),
49
+ });
50
+ const response = await createResumableAssistantStreamResponse({
51
+ context: ctx,
52
+ streamId: "s1",
53
+ callback: (controller) => {
54
+ controller.appendText("hello ");
55
+ controller.appendText("world");
56
+ },
57
+ });
58
+
59
+ expect(response.headers.get(RESUMABLE_STREAM_ID_HEADER)).toBe("s1");
60
+ expect(response.headers.get("content-type")).toContain("text/plain");
61
+ expect(await decodeAssistantText(response)).toBe("hello world");
62
+ });
63
+
64
+ it("resume endpoint replays the same payload byte-for-byte", async () => {
65
+ const store = createInMemoryResumableStreamStore();
66
+ const ctx = createResumableStreamContext({ store });
67
+
68
+ const first = await createResumableAssistantStreamResponse({
69
+ context: ctx,
70
+ streamId: "s1",
71
+ callback: (controller) => {
72
+ controller.appendText("alpha");
73
+ const tool = controller.addToolCallPart({
74
+ toolName: "echo",
75
+ toolCallId: "t1",
76
+ args: { v: 1 },
77
+ response: { result: { ok: true } },
78
+ });
79
+ tool.close();
80
+ },
81
+ });
82
+ const firstBytes = await new Response(first.body).arrayBuffer();
83
+
84
+ const second = await createResumeAssistantStreamResponse({
85
+ context: ctx,
86
+ streamId: "s1",
87
+ });
88
+ const secondBytes = await new Response(second.body).arrayBuffer();
89
+
90
+ expect(new Uint8Array(secondBytes)).toEqual(new Uint8Array(firstBytes));
91
+ expect(second.headers.get(RESUMABLE_STREAM_ID_HEADER)).toBe("s1");
92
+ });
93
+
94
+ it("resume returns 404 when streamId is unknown", async () => {
95
+ const ctx = createResumableStreamContext({
96
+ store: createInMemoryResumableStreamStore(),
97
+ });
98
+ const response = await createResumeAssistantStreamResponse({
99
+ context: ctx,
100
+ streamId: "missing",
101
+ });
102
+ expect(response.status).toBe(404);
103
+ const body = await response.json();
104
+ expect(body).toEqual({ error: "stream not found" });
105
+ });
106
+
107
+ it("custom encoder factory survives across producer + consumer", async () => {
108
+ const ctx = createResumableStreamContext({
109
+ store: createInMemoryResumableStreamStore(),
110
+ });
111
+ const response = await createResumableAssistantStreamResponse({
112
+ context: ctx,
113
+ streamId: "s1",
114
+ encoder: () => new AssistantTransportEncoder(),
115
+ callback: (controller) => controller.appendText("hi"),
116
+ });
117
+
118
+ expect(response.headers.get("content-type")).toBe("text/event-stream");
119
+ const text = await new Response(response.body).text();
120
+ expect(text).toContain("text-delta");
121
+ expect(text).toContain("[DONE]");
122
+ });
123
+
124
+ it("user headers override encoder defaults but not the stream id header", async () => {
125
+ const ctx = createResumableStreamContext({
126
+ store: createInMemoryResumableStreamStore(),
127
+ });
128
+ const response = await createResumableAssistantStreamResponse({
129
+ context: ctx,
130
+ streamId: "s1",
131
+ headers: {
132
+ "Cache-Control": "private, max-age=0",
133
+ [RESUMABLE_STREAM_ID_HEADER]: "spoofed",
134
+ },
135
+ callback: (controller) => controller.appendText("hi"),
136
+ });
137
+ expect(response.headers.get("cache-control")).toBe("private, max-age=0");
138
+ expect(response.headers.get(RESUMABLE_STREAM_ID_HEADER)).toBe("s1");
139
+ await response.body!.cancel();
140
+ });
141
+
142
+ describe("producer crash", () => {
143
+ let suppressed: unknown[];
144
+ let original: NodeJS.UnhandledRejectionListener[];
145
+ const swallow: NodeJS.UnhandledRejectionListener = (reason) => {
146
+ suppressed.push(reason);
147
+ };
148
+
149
+ beforeEach(() => {
150
+ suppressed = [];
151
+ original = process.listeners("unhandledRejection");
152
+ for (const l of original) process.off("unhandledRejection", l);
153
+ process.on("unhandledRejection", swallow);
154
+ });
155
+
156
+ afterEach(async () => {
157
+ await new Promise((r) => setTimeout(r, 20));
158
+ process.off("unhandledRejection", swallow);
159
+ for (const l of original) process.on("unhandledRejection", l);
160
+ });
161
+
162
+ it("synchronous callback throw is encoded into the body and finalizes the stream", async () => {
163
+ const ctx = createResumableStreamContext({
164
+ store: createInMemoryResumableStreamStore(),
165
+ });
166
+ const response = await createResumableAssistantStreamResponse({
167
+ context: ctx,
168
+ streamId: "s1",
169
+ callback: () => {
170
+ throw new Error("boom");
171
+ },
172
+ });
173
+ expect(response.status).toBe(200);
174
+
175
+ const firstBytes = await new Response(response.body).arrayBuffer();
176
+ expect(new TextDecoder().decode(firstBytes)).toContain("Error: boom");
177
+
178
+ while ((await ctx.status("s1")) === "streaming") {
179
+ await new Promise((r) => setTimeout(r, 5));
180
+ }
181
+ expect(await ctx.status("s1")).toBe("done");
182
+
183
+ const replay = await ctx.resume("s1");
184
+ expect(replay).not.toBeNull();
185
+ const replayBytes = await new Response(replay).arrayBuffer();
186
+ expect(new Uint8Array(replayBytes)).toEqual(new Uint8Array(firstBytes));
187
+
188
+ while (suppressed.length === 0) {
189
+ await new Promise((r) => setTimeout(r, 5));
190
+ }
191
+ expect(suppressed.map((e) => (e as Error).message)).toContain("boom");
192
+ });
193
+ });
194
+
195
+ it("requireResume rejects with ResumableStreamError(missing) for unknown ids", async () => {
196
+ const ctx = createResumableStreamContext({
197
+ store: createInMemoryResumableStreamStore(),
198
+ });
199
+ let captured: unknown;
200
+ try {
201
+ await ctx.requireResume("nonexistent");
202
+ } catch (e) {
203
+ captured = e;
204
+ }
205
+ expect(captured).toBeInstanceOf(ResumableStreamError);
206
+ expect((captured as ResumableStreamError).code).toBe("missing");
207
+ });
208
+
209
+ it("mid-stream consumer joins active production and receives every chunk in order", async () => {
210
+ const ctx = createResumableStreamContext({
211
+ store: createInMemoryResumableStreamStore(),
212
+ });
213
+
214
+ let emitted = 0;
215
+ const producerDone = createResumableAssistantStreamResponse({
216
+ context: ctx,
217
+ streamId: "s1",
218
+ callback: async (controller) => {
219
+ for (let i = 0; i < 5; i++) {
220
+ await new Promise((r) => setTimeout(r, 5));
221
+ controller.appendText(`chunk${i};`);
222
+ emitted += 1;
223
+ }
224
+ },
225
+ });
226
+
227
+ const producer = await producerDone;
228
+
229
+ while (emitted < 2) {
230
+ await new Promise((r) => setTimeout(r, 1));
231
+ }
232
+
233
+ const replay = await ctx.resume("s1");
234
+ expect(replay).not.toBeNull();
235
+
236
+ const [producerBody, replayText] = await Promise.all([
237
+ new Response(producer.body).arrayBuffer(),
238
+ decodeAssistantText(new Response(replay)),
239
+ ]);
240
+ expect(producerBody.byteLength).toBeGreaterThan(0);
241
+ expect(replayText).toBe("chunk0;chunk1;chunk2;chunk3;chunk4;");
242
+ });
243
+ });
@@ -0,0 +1,80 @@
1
+ import type { AssistantStreamEncoder } from "../core/AssistantStream";
2
+ import {
3
+ createAssistantStream,
4
+ type AssistantStreamController,
5
+ } from "../core/modules/assistant-stream";
6
+ import { DataStreamEncoder } from "../core/serialization/data-stream/DataStream";
7
+ import type { ResumableStreamContext } from "./ResumableStreamContext";
8
+
9
+ export const RESUMABLE_STREAM_ID_HEADER = "x-resumable-stream-id";
10
+
11
+ export type CreateResumableAssistantStreamResponseOptions = {
12
+ readonly context: ResumableStreamContext;
13
+ readonly streamId: string;
14
+ readonly callback: (
15
+ controller: AssistantStreamController,
16
+ ) => PromiseLike<void> | void;
17
+ /** Defaults to `DataStreamEncoder`. Also consulted for response headers. */
18
+ readonly encoder?: () => AssistantStreamEncoder;
19
+ readonly headers?: HeadersInit;
20
+ };
21
+
22
+ export async function createResumableAssistantStreamResponse(
23
+ options: CreateResumableAssistantStreamResponseOptions,
24
+ ): Promise<Response> {
25
+ const encoder = (options.encoder ?? (() => new DataStreamEncoder()))();
26
+
27
+ const stream = await options.context.run(options.streamId, () => {
28
+ const aStream = createAssistantStream(options.callback);
29
+ return aStream.pipeThrough(encoder);
30
+ });
31
+
32
+ return new Response(stream, {
33
+ headers: mergeHeaders(encoder.headers, options.headers, options.streamId),
34
+ });
35
+ }
36
+
37
+ export type CreateResumeAssistantStreamResponseOptions = {
38
+ readonly context: ResumableStreamContext;
39
+ readonly streamId: string;
40
+ /** Read for `headers` only; the encoder transform is not invoked on resume. */
41
+ readonly encoder?: () => AssistantStreamEncoder;
42
+ readonly headers?: HeadersInit;
43
+ /** Defaults to a 404 JSON response. */
44
+ readonly missingResponse?: () => Response;
45
+ };
46
+
47
+ export async function createResumeAssistantStreamResponse(
48
+ options: CreateResumeAssistantStreamResponseOptions,
49
+ ): Promise<Response> {
50
+ const stream = await options.context.resume(options.streamId);
51
+ if (!stream) {
52
+ return options.missingResponse?.() ?? defaultMissingResponse();
53
+ }
54
+ const encoder = (options.encoder ?? (() => new DataStreamEncoder()))();
55
+ return new Response(stream, {
56
+ headers: mergeHeaders(encoder.headers, options.headers, options.streamId),
57
+ });
58
+ }
59
+
60
+ function defaultMissingResponse(): Response {
61
+ return new Response(JSON.stringify({ error: "stream not found" }), {
62
+ status: 404,
63
+ headers: { "Content-Type": "application/json" },
64
+ });
65
+ }
66
+
67
+ function mergeHeaders(
68
+ encoderHeaders: Headers | undefined,
69
+ extra: HeadersInit | undefined,
70
+ streamId: string,
71
+ ): Headers {
72
+ const merged = new Headers(encoderHeaders ?? {});
73
+ if (extra) {
74
+ for (const [key, value] of new Headers(extra)) {
75
+ merged.set(key, value);
76
+ }
77
+ }
78
+ merged.set(RESUMABLE_STREAM_ID_HEADER, streamId);
79
+ return merged;
80
+ }
@@ -0,0 +1,26 @@
1
+ export type ResumableStreamErrorCode =
2
+ | "missing"
3
+ | "exists"
4
+ | "finalized"
5
+ | "invalid-id";
6
+
7
+ export class ResumableStreamError extends Error {
8
+ readonly code: ResumableStreamErrorCode;
9
+
10
+ constructor(code: ResumableStreamErrorCode, message: string) {
11
+ super(message);
12
+ this.name = "ResumableStreamError";
13
+ this.code = code;
14
+ }
15
+ }
16
+
17
+ const STREAM_ID_PATTERN = /^[A-Za-z0-9_.:-]{1,256}$/;
18
+
19
+ export function validateStreamId(streamId: string): void {
20
+ if (!STREAM_ID_PATTERN.test(streamId)) {
21
+ throw new ResumableStreamError(
22
+ "invalid-id",
23
+ `Invalid streamId: ${streamId} (must match ${STREAM_ID_PATTERN})`,
24
+ );
25
+ }
26
+ }
@@ -0,0 +1,36 @@
1
+ export type {
2
+ ResumableStreamStore,
3
+ ResumableStreamRole,
4
+ ResumableStreamStatus,
5
+ ResumableStreamEntry,
6
+ ResumableStreamAcquireOptions,
7
+ } from "./types";
8
+
9
+ export {
10
+ ResumableStreamError,
11
+ type ResumableStreamErrorCode,
12
+ } from "./errors";
13
+
14
+ export {
15
+ createResumableStreamContext,
16
+ type ResumableStreamContext,
17
+ type ResumableStreamContextOptions,
18
+ } from "./ResumableStreamContext";
19
+
20
+ export {
21
+ createResumableAssistantStreamResponse,
22
+ createResumeAssistantStreamResponse,
23
+ RESUMABLE_STREAM_ID_HEADER,
24
+ type CreateResumableAssistantStreamResponseOptions,
25
+ type CreateResumeAssistantStreamResponseOptions,
26
+ } from "./createResumableAssistantStreamResponse";
27
+
28
+ export {
29
+ createInMemoryResumableStreamStore,
30
+ type InMemoryResumableStreamStoreOptions,
31
+ } from "./stores/InMemoryResumableStreamStore";
32
+
33
+ export type {
34
+ RedisLikeClient,
35
+ RedisResumableStreamStoreOptions,
36
+ } from "./stores/redis-impl";
@@ -0,0 +1,285 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { createInMemoryResumableStreamStore } from "./InMemoryResumableStreamStore";
3
+ import type { ResumableStreamEntry } from "../types";
4
+
5
+ const bytes = (s: string): Uint8Array => new TextEncoder().encode(s);
6
+
7
+ const decode = (chunk: Uint8Array): string => new TextDecoder().decode(chunk);
8
+
9
+ async function drain(
10
+ iter: AsyncIterable<ResumableStreamEntry>,
11
+ ): Promise<string[]> {
12
+ const out: string[] = [];
13
+ for await (const entry of iter) out.push(decode(entry.chunk));
14
+ return out;
15
+ }
16
+
17
+ describe("InMemoryResumableStreamStore", () => {
18
+ it("elects exactly one producer per stream id", async () => {
19
+ const store = createInMemoryResumableStreamStore();
20
+ const first = await store.acquire("a");
21
+ const second = await store.acquire("a");
22
+ const third = await store.acquire("a");
23
+ expect(first).toBe("producer");
24
+ expect(second).toBe("consumer");
25
+ expect(third).toBe("consumer");
26
+ });
27
+
28
+ it("isolates streams by id", async () => {
29
+ const store = createInMemoryResumableStreamStore();
30
+ expect(await store.acquire("a")).toBe("producer");
31
+ expect(await store.acquire("b")).toBe("producer");
32
+ });
33
+
34
+ it("replays buffered entries and tails new ones until finalize", async () => {
35
+ const store = createInMemoryResumableStreamStore();
36
+ await store.acquire("a");
37
+ await store.append("a", bytes("hello "));
38
+ await store.append("a", bytes("world"));
39
+
40
+ const ac = new AbortController();
41
+ const collected: string[] = [];
42
+ const reading = (async () => {
43
+ for await (const entry of store.read("a", "", ac.signal)) {
44
+ collected.push(decode(entry.chunk));
45
+ if (collected.length === 3) {
46
+ await store.finalize("a", "done");
47
+ }
48
+ }
49
+ })();
50
+
51
+ await store.append("a", bytes("!"));
52
+ await reading;
53
+ expect(collected).toEqual(["hello ", "world", "!"]);
54
+ });
55
+
56
+ it("status transitions: missing → streaming → done", async () => {
57
+ const store = createInMemoryResumableStreamStore();
58
+ expect(await store.status("a")).toBe("missing");
59
+ await store.acquire("a");
60
+ expect(await store.status("a")).toBe("streaming");
61
+ await store.finalize("a", "done");
62
+ expect(await store.status("a")).toBe("done");
63
+ });
64
+
65
+ it("status reports error after error finalize", async () => {
66
+ const store = createInMemoryResumableStreamStore();
67
+ await store.acquire("a");
68
+ await store.finalize("a", "error", "boom");
69
+ expect(await store.status("a")).toBe("error");
70
+ });
71
+
72
+ it("read throws on the next iteration after error finalize", async () => {
73
+ const store = createInMemoryResumableStreamStore();
74
+ await store.acquire("a");
75
+ await store.append("a", bytes("partial"));
76
+ await store.finalize("a", "error", "boom");
77
+
78
+ const ac = new AbortController();
79
+ const seen: string[] = [];
80
+ await expect(async () => {
81
+ for await (const entry of store.read("a", "", ac.signal)) {
82
+ seen.push(decode(entry.chunk));
83
+ }
84
+ }).rejects.toThrow("boom");
85
+ expect(seen).toEqual(["partial"]);
86
+ });
87
+
88
+ it("a consumer joining after done replays everything", async () => {
89
+ const store = createInMemoryResumableStreamStore();
90
+ await store.acquire("a");
91
+ await store.append("a", bytes("a"));
92
+ await store.append("a", bytes("b"));
93
+ await store.append("a", bytes("c"));
94
+ await store.finalize("a", "done");
95
+
96
+ const ac = new AbortController();
97
+ expect(await drain(store.read("a", "", ac.signal))).toEqual([
98
+ "a",
99
+ "b",
100
+ "c",
101
+ ]);
102
+ });
103
+
104
+ it("cursor advances and skips already-seen entries", async () => {
105
+ const store = createInMemoryResumableStreamStore();
106
+ await store.acquire("a");
107
+ await store.append("a", bytes("1"));
108
+ await store.append("a", bytes("2"));
109
+ await store.append("a", bytes("3"));
110
+ await store.finalize("a", "done");
111
+
112
+ const ac = new AbortController();
113
+ const seen: { cursor: string; text: string }[] = [];
114
+ for await (const entry of store.read("a", "", ac.signal)) {
115
+ seen.push({ cursor: entry.cursor, text: decode(entry.chunk) });
116
+ }
117
+ expect(seen.map((s) => s.text)).toEqual(["1", "2", "3"]);
118
+
119
+ const afterFirst = seen[0]!.cursor;
120
+ expect(await drain(store.read("a", afterFirst, ac.signal))).toEqual([
121
+ "2",
122
+ "3",
123
+ ]);
124
+ });
125
+
126
+ it("aborting the read signal terminates without throwing", async () => {
127
+ const store = createInMemoryResumableStreamStore();
128
+ await store.acquire("a");
129
+ const ac = new AbortController();
130
+ const collected: string[] = [];
131
+ const reading = (async () => {
132
+ for await (const entry of store.read("a", "", ac.signal)) {
133
+ collected.push(decode(entry.chunk));
134
+ }
135
+ })();
136
+ await store.append("a", bytes("x"));
137
+ await new Promise((r) => setTimeout(r, 5));
138
+ ac.abort();
139
+ await reading;
140
+ expect(collected).toEqual(["x"]);
141
+ });
142
+
143
+ it("multiple consumers can read concurrently", async () => {
144
+ const store = createInMemoryResumableStreamStore();
145
+ await store.acquire("a");
146
+ const ac = new AbortController();
147
+ const a = drain(store.read("a", "", ac.signal));
148
+ const b = drain(store.read("a", "", ac.signal));
149
+ await store.append("a", bytes("x"));
150
+ await store.append("a", bytes("y"));
151
+ await store.finalize("a", "done");
152
+ expect(await a).toEqual(["x", "y"]);
153
+ expect(await b).toEqual(["x", "y"]);
154
+ });
155
+
156
+ it("delete prevents further reads and ends in-flight reads", async () => {
157
+ const store = createInMemoryResumableStreamStore();
158
+ await store.acquire("a");
159
+ const ac = new AbortController();
160
+ const reading = drain(store.read("a", "", ac.signal));
161
+ await store.append("a", bytes("x"));
162
+ await new Promise((r) => setTimeout(r, 0));
163
+ await store.delete("a");
164
+ expect(await reading).toEqual(["x"]);
165
+ expect(await store.status("a")).toBe("missing");
166
+ });
167
+
168
+ it("expired streams are evicted on next access", async () => {
169
+ let now = 1_000;
170
+ const store = createInMemoryResumableStreamStore({
171
+ defaultTtlMs: 100,
172
+ now: () => now,
173
+ });
174
+ await store.acquire("a");
175
+ await store.append("a", bytes("hi"));
176
+ expect(await store.status("a")).toBe("streaming");
177
+ now += 200;
178
+ expect(await store.status("a")).toBe("missing");
179
+ });
180
+
181
+ it("appending refreshes TTL", async () => {
182
+ let now = 1_000;
183
+ const store = createInMemoryResumableStreamStore({
184
+ defaultTtlMs: 100,
185
+ now: () => now,
186
+ });
187
+ await store.acquire("a");
188
+ now += 80;
189
+ await store.append("a", bytes("x"));
190
+ now += 80;
191
+ expect(await store.status("a")).toBe("streaming");
192
+ });
193
+
194
+ it("rejects append on finalized stream", async () => {
195
+ const store = createInMemoryResumableStreamStore();
196
+ await store.acquire("a");
197
+ await store.finalize("a", "done");
198
+ await expect(store.append("a", bytes("late"))).rejects.toThrow(
199
+ /already finalized/,
200
+ );
201
+ });
202
+
203
+ it("rejects append on missing stream", async () => {
204
+ const store = createInMemoryResumableStreamStore();
205
+ await expect(store.append("a", bytes("x"))).rejects.toThrow(
206
+ /Stream not found/,
207
+ );
208
+ });
209
+
210
+ it("finalize is idempotent", async () => {
211
+ const store = createInMemoryResumableStreamStore();
212
+ await store.acquire("a");
213
+ await store.finalize("a", "done");
214
+ await store.finalize("a", "done");
215
+ expect(await store.status("a")).toBe("done");
216
+ });
217
+
218
+ it("rejects append when chunk exceeds maxChunkBytes", async () => {
219
+ const store = createInMemoryResumableStreamStore({ maxChunkBytes: 4 });
220
+ await store.acquire("a");
221
+ await expect(store.append("a", bytes("hello"))).rejects.toThrow(
222
+ /Chunk exceeds maxChunkBytes: 5/,
223
+ );
224
+ await store.append("a", bytes("ok"));
225
+ expect(await store.status("a")).toBe("streaming");
226
+ });
227
+
228
+ it("rejects append when stream reaches maxEntriesPerStream", async () => {
229
+ const store = createInMemoryResumableStreamStore({
230
+ maxEntriesPerStream: 2,
231
+ });
232
+ await store.acquire("a");
233
+ await store.append("a", bytes("1"));
234
+ await store.append("a", bytes("2"));
235
+ await expect(store.append("a", bytes("3"))).rejects.toThrow(
236
+ /Stream exceeded maxEntriesPerStream: a/,
237
+ );
238
+ });
239
+
240
+ it("rejects acquire when active stream count exceeds maxStreams", async () => {
241
+ const store = createInMemoryResumableStreamStore({ maxStreams: 2 });
242
+ await store.acquire("a");
243
+ await store.acquire("b");
244
+ await expect(store.acquire("c")).rejects.toThrow(/maxStreams exceeded/);
245
+ expect(await store.acquire("a")).toBe("consumer");
246
+ });
247
+
248
+ it("gc sweeper evicts expired streams without explicit access", async () => {
249
+ vi.useFakeTimers();
250
+ try {
251
+ let now = 1_000;
252
+ const store = createInMemoryResumableStreamStore({
253
+ defaultTtlMs: 100,
254
+ gcIntervalMs: 50,
255
+ now: () => now,
256
+ });
257
+ await store.acquire("a");
258
+ await store.append("a", bytes("hi"));
259
+ now += 200;
260
+ vi.advanceTimersByTime(50);
261
+ expect(await store.status("a")).toBe("missing");
262
+ store.dispose();
263
+ } finally {
264
+ vi.useRealTimers();
265
+ }
266
+ });
267
+
268
+ it("dispose clears the gc interval", async () => {
269
+ vi.useFakeTimers();
270
+ try {
271
+ const clearSpy = vi.spyOn(globalThis, "clearInterval");
272
+ const store = createInMemoryResumableStreamStore({ gcIntervalMs: 50 });
273
+ store.dispose();
274
+ expect(clearSpy).toHaveBeenCalledTimes(1);
275
+ clearSpy.mockRestore();
276
+ } finally {
277
+ vi.useRealTimers();
278
+ }
279
+ });
280
+
281
+ it("dispose is a no-op when gcIntervalMs is undefined", () => {
282
+ const store = createInMemoryResumableStreamStore();
283
+ expect(() => store.dispose()).not.toThrow();
284
+ });
285
+ });