assistant-stream 0.3.26 → 0.3.28
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/dist/core/AssistantStream.d.ts.map +1 -1
- package/dist/core/AssistantStreamChunk.d.ts +3 -3
- package/dist/core/accumulators/AssistantMessageStream.d.ts.map +1 -1
- package/dist/core/accumulators/AssistantMessageStream.js +0 -1
- package/dist/core/accumulators/AssistantMessageStream.js.map +1 -1
- package/dist/core/accumulators/assistant-message-accumulator.d.ts.map +1 -1
- package/dist/core/accumulators/assistant-message-accumulator.js +65 -28
- package/dist/core/accumulators/assistant-message-accumulator.js.map +1 -1
- package/dist/core/converters/toGenericMessages.js.map +1 -1
- package/dist/core/gorp/GorpStreamAccumulator.d.ts +15 -0
- package/dist/core/gorp/GorpStreamAccumulator.d.ts.map +1 -0
- package/dist/core/{object/ObjectStreamAccumulator.js → gorp/GorpStreamAccumulator.js} +11 -9
- package/dist/core/gorp/GorpStreamAccumulator.js.map +1 -0
- package/dist/core/gorp/GorpStreamDeltaTracker.d.ts +17 -0
- package/dist/core/gorp/GorpStreamDeltaTracker.d.ts.map +1 -0
- package/dist/core/gorp/GorpStreamDeltaTracker.js +38 -0
- package/dist/core/gorp/GorpStreamDeltaTracker.js.map +1 -0
- package/dist/core/gorp/GorpStreamResponse.d.ts +16 -0
- package/dist/core/gorp/GorpStreamResponse.d.ts.map +1 -0
- package/dist/core/gorp/GorpStreamResponse.js +51 -0
- package/dist/core/gorp/GorpStreamResponse.js.map +1 -0
- package/dist/core/gorp/changeTree.d.ts +16 -0
- package/dist/core/gorp/changeTree.d.ts.map +1 -0
- package/dist/core/gorp/changeTree.js +77 -0
- package/dist/core/gorp/changeTree.js.map +1 -0
- package/dist/core/gorp/createGorpStream.d.ts +16 -0
- package/dist/core/gorp/createGorpStream.d.ts.map +1 -0
- package/dist/core/{object/createObjectStream.js → gorp/createGorpStream.js} +13 -8
- package/dist/core/gorp/createGorpStream.js.map +1 -0
- package/dist/core/{object → gorp}/types.d.ts +6 -5
- package/dist/core/gorp/types.d.ts.map +1 -0
- package/dist/core/modules/assistant-stream.d.ts +5 -4
- package/dist/core/modules/assistant-stream.d.ts.map +1 -1
- package/dist/core/modules/assistant-stream.js +10 -6
- package/dist/core/modules/assistant-stream.js.map +1 -1
- package/dist/core/modules/tool-call.d.ts.map +1 -1
- package/dist/core/modules/tool-call.js +3 -3
- package/dist/core/modules/tool-call.js.map +1 -1
- package/dist/core/serialization/assistant-transport/AssistantTransport.d.ts.map +1 -1
- package/dist/core/serialization/assistant-transport/AssistantTransport.js +91 -1
- package/dist/core/serialization/assistant-transport/AssistantTransport.js.map +1 -1
- package/dist/core/serialization/data-stream/DataStream.js +48 -48
- package/dist/core/serialization/data-stream/DataStream.js.map +1 -1
- package/dist/core/serialization/data-stream/chunk-types.d.ts +25 -24
- package/dist/core/serialization/data-stream/chunk-types.d.ts.map +1 -1
- package/dist/core/serialization/data-stream/chunk-types.js +22 -23
- package/dist/core/serialization/data-stream/chunk-types.js.map +1 -1
- package/dist/core/serialization/data-stream/serialization.d.ts.map +1 -1
- package/dist/core/serialization/data-stream/serialization.js +14 -2
- package/dist/core/serialization/data-stream/serialization.js.map +1 -1
- package/dist/core/serialization/ui-message-stream/UIMessageStream.d.ts.map +1 -1
- package/dist/core/serialization/ui-message-stream/UIMessageStream.js +12 -1
- package/dist/core/serialization/ui-message-stream/UIMessageStream.js.map +1 -1
- package/dist/core/tool/ToolCallReader.d.ts.map +1 -1
- package/dist/core/tool/ToolCallReader.js.map +1 -1
- package/dist/core/tool/ToolExecutionStream.d.ts.map +1 -1
- package/dist/core/tool/ToolExecutionStream.js +9 -1
- package/dist/core/tool/ToolExecutionStream.js.map +1 -1
- package/dist/core/tool/ToolResponse.d.ts.map +1 -1
- package/dist/core/tool/tool-types.d.ts +6 -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 +7 -2
- package/dist/core/tool/toolResultStream.js.map +1 -1
- package/dist/core/utils/stream/AssistantTransformStream.d.ts.map +1 -1
- package/dist/core/utils/stream/PipeableTransformStream.d.ts.map +1 -1
- package/dist/core/utils/stream/SSE.d.ts.map +1 -1
- package/dist/core/utils/stream/SSE.js +11 -2
- package/dist/core/utils/stream/SSE.js.map +1 -1
- package/dist/core/utils/stream/SSEEventDecoder.d.ts.map +1 -1
- package/dist/core/utils/stream/merge.d.ts +2 -0
- package/dist/core/utils/stream/merge.d.ts.map +1 -1
- package/dist/core/utils/stream/merge.js +27 -8
- package/dist/core/utils/stream/merge.js.map +1 -1
- package/dist/core/utils/stream/path-utils.d.ts.map +1 -1
- package/dist/index.d.ts +25 -4
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -3
- package/dist/index.js.map +1 -0
- package/dist/resumable/ResumableStreamContext.d.ts.map +1 -1
- package/dist/resumable/ResumableStreamContext.js +12 -5
- package/dist/resumable/ResumableStreamContext.js.map +1 -1
- package/dist/resumable/errors.d.ts.map +1 -1
- package/dist/resumable/stores/redis-impl.d.ts.map +1 -1
- package/dist/utils/AsyncIterableStream.d.ts.map +1 -1
- package/dist/utils/AsyncIterableStream.js +18 -3
- package/dist/utils/AsyncIterableStream.js.map +1 -1
- package/dist/utils/promiseWithResolvers.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/core/AssistantStreamChunk.ts +3 -3
- package/src/core/accumulators/AssistantMessageStream.ts +3 -1
- package/src/core/accumulators/assistant-message-accumulator.test.ts +374 -1
- package/src/core/accumulators/assistant-message-accumulator.ts +75 -26
- package/src/core/converters/toGenericMessages.test.ts +3 -39
- package/src/core/{object/ObjectStream.test.ts → gorp/GorpStream.test.ts} +86 -25
- package/src/core/gorp/GorpStreamAccumulator.test.ts +28 -0
- package/src/core/{object/ObjectStreamAccumulator.ts → gorp/GorpStreamAccumulator.ts} +13 -15
- package/src/core/gorp/GorpStreamDeltaTracker.test.ts +212 -0
- package/src/core/gorp/GorpStreamDeltaTracker.ts +55 -0
- package/src/core/{object/ObjectStreamResponse.test.ts → gorp/GorpStreamResponse.test.ts} +6 -6
- package/src/core/gorp/GorpStreamResponse.ts +88 -0
- package/src/core/gorp/changeTree.ts +96 -0
- package/src/core/{object/createObjectStream.ts → gorp/createGorpStream.ts} +23 -17
- package/src/core/{object → gorp}/types.ts +5 -3
- package/src/core/modules/assistant-stream.test.ts +154 -2
- package/src/core/modules/assistant-stream.ts +16 -6
- package/src/core/modules/tool-call.test.ts +67 -0
- package/src/core/modules/tool-call.ts +7 -4
- package/src/core/serialization/assistant-transport/AssistantTransport.test.ts +174 -1
- package/src/core/serialization/assistant-transport/AssistantTransport.ts +77 -1
- package/src/core/serialization/data-stream/DataStream.ts +1 -1
- package/src/core/serialization/data-stream/chunk-types.ts +26 -24
- package/src/core/serialization/data-stream/serialization.test.ts +133 -0
- package/src/core/serialization/data-stream/serialization.ts +20 -2
- package/src/core/serialization/ui-message-stream/UIMessageStream.test.ts +135 -0
- package/src/core/serialization/ui-message-stream/UIMessageStream.ts +21 -1
- package/src/core/tool/ToolCallReader.ts +5 -1
- package/src/core/tool/ToolExecutionStream.ts +10 -0
- package/src/core/tool/tool-types.ts +7 -0
- package/src/core/tool/toolResultStream.test.ts +50 -0
- package/src/core/tool/toolResultStream.ts +7 -2
- package/src/core/utils/stream/SSE.test.ts +97 -0
- package/src/core/utils/stream/SSE.ts +13 -2
- package/src/core/utils/stream/merge.ts +31 -9
- package/src/gorp.test.ts +32 -0
- package/src/index.ts +21 -6
- package/src/resumable/ResumableStreamContext.test.ts +116 -1
- package/src/resumable/ResumableStreamContext.ts +32 -5
- package/src/resumable/createResumableAssistantStreamResponse.test.ts +1 -25
- package/src/utils/AsyncIterableStream.test.ts +61 -0
- package/src/utils/AsyncIterableStream.ts +18 -3
- package/dist/core/object/ObjectStreamAccumulator.d.ts +0 -15
- package/dist/core/object/ObjectStreamAccumulator.d.ts.map +0 -1
- package/dist/core/object/ObjectStreamAccumulator.js.map +0 -1
- package/dist/core/object/ObjectStreamResponse.d.ts +0 -16
- package/dist/core/object/ObjectStreamResponse.d.ts.map +0 -1
- package/dist/core/object/ObjectStreamResponse.js +0 -57
- package/dist/core/object/ObjectStreamResponse.js.map +0 -1
- package/dist/core/object/createObjectStream.d.ts +0 -16
- package/dist/core/object/createObjectStream.d.ts.map +0 -1
- package/dist/core/object/createObjectStream.js.map +0 -1
- package/dist/core/object/types.d.ts.map +0 -1
- package/src/core/accumulators/TimingTracker.test.ts +0 -100
- package/src/core/object/ObjectStreamResponse.ts +0 -114
- /package/dist/core/{object → gorp}/types.js +0 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import sjson from "secure-json-parse";
|
|
1
2
|
import type { AssistantStreamChunk } from "../../AssistantStreamChunk";
|
|
2
3
|
import type { ToolCallStreamController } from "../../modules/tool-call";
|
|
3
4
|
import type { TextStreamController } from "../../modules/text";
|
|
@@ -149,6 +150,9 @@ export class UIMessageStreamDecoder extends PipeableTransformStream<
|
|
|
149
150
|
`Encountered tool result with unknown id: ${chunk.toolCallId}`,
|
|
150
151
|
);
|
|
151
152
|
}
|
|
153
|
+
if (toolCallController.argsText === activeToolCallArgsText) {
|
|
154
|
+
activeToolCallArgsText = undefined;
|
|
155
|
+
}
|
|
152
156
|
toolCallController.setResponse({
|
|
153
157
|
result: chunk.result,
|
|
154
158
|
isError: chunk.isError ?? false,
|
|
@@ -222,7 +226,23 @@ export class UIMessageStreamDecoder extends PipeableTransformStream<
|
|
|
222
226
|
return;
|
|
223
227
|
}
|
|
224
228
|
|
|
225
|
-
|
|
229
|
+
let chunk;
|
|
230
|
+
try {
|
|
231
|
+
chunk = sjson.parse(event.data);
|
|
232
|
+
} catch {
|
|
233
|
+
chunk = undefined;
|
|
234
|
+
}
|
|
235
|
+
if (
|
|
236
|
+
typeof chunk !== "object" ||
|
|
237
|
+
chunk === null ||
|
|
238
|
+
Array.isArray(chunk) ||
|
|
239
|
+
typeof chunk.type !== "string"
|
|
240
|
+
) {
|
|
241
|
+
console.warn(
|
|
242
|
+
`Dropped invalid UIMessageStream chunk: ${event.data.slice(0, 200)}`,
|
|
243
|
+
);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
226
246
|
if (
|
|
227
247
|
chunk.type === "text-delta" &&
|
|
228
248
|
chunk.textDelta === undefined
|
|
@@ -441,7 +441,11 @@ export class ToolCallArgsReaderImpl<
|
|
|
441
441
|
export class ToolCallResponseReaderImpl<
|
|
442
442
|
TResult extends ReadonlyJSONValue,
|
|
443
443
|
> implements ToolCallResponseReader<TResult> {
|
|
444
|
-
|
|
444
|
+
private readonly promise: Promise<ToolResponse<TResult>>;
|
|
445
|
+
|
|
446
|
+
constructor(promise: Promise<ToolResponse<TResult>>) {
|
|
447
|
+
this.promise = promise;
|
|
448
|
+
}
|
|
445
449
|
|
|
446
450
|
public get() {
|
|
447
451
|
return this.promise;
|
|
@@ -51,6 +51,7 @@ export class ToolExecutionStream extends PipeableTransformStream<
|
|
|
51
51
|
string,
|
|
52
52
|
ToolCallReaderImpl<ReadonlyJSONObject, ReadonlyJSONValue>
|
|
53
53
|
>();
|
|
54
|
+
const toolCallIdsWithBackendResult = new Set<string>();
|
|
54
55
|
|
|
55
56
|
super((readable) => {
|
|
56
57
|
const transform = new TransformStream<
|
|
@@ -109,6 +110,7 @@ export class ToolExecutionStream extends PipeableTransformStream<
|
|
|
109
110
|
modelContent: chunk.modelContent,
|
|
110
111
|
}),
|
|
111
112
|
);
|
|
113
|
+
toolCallIdsWithBackendResult.add(toolCallId);
|
|
112
114
|
break;
|
|
113
115
|
}
|
|
114
116
|
case "tool-call-args-text-finish": {
|
|
@@ -123,6 +125,11 @@ export class ToolExecutionStream extends PipeableTransformStream<
|
|
|
123
125
|
// resolve. Awaited so the close settles before the writer is reused.
|
|
124
126
|
await streamController.finishArgsText();
|
|
125
127
|
|
|
128
|
+
// A backend result is authoritative. Closing the args stream still
|
|
129
|
+
// emits this finish chunk, but must not parse stale/incomplete args,
|
|
130
|
+
// execute the frontend tool, or enqueue a second result.
|
|
131
|
+
if (toolCallIdsWithBackendResult.has(toolCallId)) break;
|
|
132
|
+
|
|
126
133
|
let isExecuting = false;
|
|
127
134
|
const promise = withPromiseOrValue(
|
|
128
135
|
() => {
|
|
@@ -206,10 +213,13 @@ export class ToolExecutionStream extends PipeableTransformStream<
|
|
|
206
213
|
toolCallPromise.then(() => {
|
|
207
214
|
toolCallPromises.delete(toolCallId);
|
|
208
215
|
toolCallControllers.delete(toolCallId);
|
|
216
|
+
toolCallIdsWithBackendResult.delete(toolCallId);
|
|
209
217
|
|
|
210
218
|
controller.enqueue(chunk);
|
|
211
219
|
});
|
|
212
220
|
} else {
|
|
221
|
+
toolCallControllers.delete(toolCallId);
|
|
222
|
+
toolCallIdsWithBackendResult.delete(toolCallId);
|
|
213
223
|
controller.enqueue(chunk);
|
|
214
224
|
}
|
|
215
225
|
}
|
|
@@ -226,6 +226,13 @@ type ToolBase<
|
|
|
226
226
|
unstable_backendDefault?: {
|
|
227
227
|
parameters?: boolean;
|
|
228
228
|
};
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Replaces an already-registered same-priority tool with the same name
|
|
232
|
+
* instead of throwing. Discouraged escape hatch — overwriting a same-priority
|
|
233
|
+
* tool is usually a design smell; prefer distinct names or priorities.
|
|
234
|
+
*/
|
|
235
|
+
overwrite?: boolean;
|
|
229
236
|
};
|
|
230
237
|
|
|
231
238
|
type BackendTool<
|
|
@@ -17,6 +17,56 @@ const createDelayedTool = (delay: number, result?: string): Tool => ({
|
|
|
17
17
|
});
|
|
18
18
|
|
|
19
19
|
describe("unstable_runPendingTools", () => {
|
|
20
|
+
it("removes the abort listener after tool execution settles", async () => {
|
|
21
|
+
const abortController = new AbortController();
|
|
22
|
+
const addEventListener = vi.spyOn(
|
|
23
|
+
abortController.signal,
|
|
24
|
+
"addEventListener",
|
|
25
|
+
);
|
|
26
|
+
const removeEventListener = vi.spyOn(
|
|
27
|
+
abortController.signal,
|
|
28
|
+
"removeEventListener",
|
|
29
|
+
);
|
|
30
|
+
const message: AssistantMessage = {
|
|
31
|
+
role: "assistant",
|
|
32
|
+
status: { type: "requires-action", reason: "tool-calls" },
|
|
33
|
+
parts: [
|
|
34
|
+
{
|
|
35
|
+
type: "tool-call",
|
|
36
|
+
toolCallId: "1",
|
|
37
|
+
toolName: "tool",
|
|
38
|
+
args: {},
|
|
39
|
+
} as ToolCallPart,
|
|
40
|
+
],
|
|
41
|
+
content: [],
|
|
42
|
+
metadata: {
|
|
43
|
+
unstable_state: {},
|
|
44
|
+
unstable_data: [],
|
|
45
|
+
unstable_annotations: [],
|
|
46
|
+
steps: [],
|
|
47
|
+
custom: {},
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
await unstable_runPendingTools(
|
|
52
|
+
message,
|
|
53
|
+
{
|
|
54
|
+
tool: {
|
|
55
|
+
parameters: { type: "object", properties: {} },
|
|
56
|
+
execute: async () => "done",
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
abortController.signal,
|
|
60
|
+
async () => {},
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
expect(addEventListener).toHaveBeenCalledOnce();
|
|
64
|
+
expect(removeEventListener).toHaveBeenCalledWith(
|
|
65
|
+
"abort",
|
|
66
|
+
addEventListener.mock.calls[0]![1],
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
20
70
|
describe("parallel execution", () => {
|
|
21
71
|
it("should run tool calls in parallel", async () => {
|
|
22
72
|
const tool1 = createDelayedTool(100, "Tool 1");
|
|
@@ -59,9 +59,10 @@ function getToolResponse(
|
|
|
59
59
|
|
|
60
60
|
// Create abort promise that resolves after 2 microtasks
|
|
61
61
|
// This gives tools that handle abort a chance to win the race
|
|
62
|
+
let onAbort!: () => void;
|
|
62
63
|
const abortPromise = new Promise<ToolResponse<ReadonlyJSONValue>>(
|
|
63
64
|
(resolve) => {
|
|
64
|
-
|
|
65
|
+
onAbort = () => {
|
|
65
66
|
queueMicrotask(() => {
|
|
66
67
|
queueMicrotask(() => {
|
|
67
68
|
resolve(
|
|
@@ -116,7 +117,11 @@ function getToolResponse(
|
|
|
116
117
|
return response;
|
|
117
118
|
})();
|
|
118
119
|
|
|
119
|
-
|
|
120
|
+
try {
|
|
121
|
+
return await Promise.race([executePromise, abortPromise]);
|
|
122
|
+
} finally {
|
|
123
|
+
abortSignal.removeEventListener("abort", onAbort);
|
|
124
|
+
}
|
|
120
125
|
};
|
|
121
126
|
|
|
122
127
|
return getResult(tool.execute);
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { SSEDecoder, SSEEncoder } from "./SSE";
|
|
3
|
+
|
|
4
|
+
async function collectChunks<T>(stream: ReadableStream<T>): Promise<T[]> {
|
|
5
|
+
const reader = stream.getReader();
|
|
6
|
+
const chunks: T[] = [];
|
|
7
|
+
try {
|
|
8
|
+
while (true) {
|
|
9
|
+
const { done, value } = await reader.read();
|
|
10
|
+
if (done) break;
|
|
11
|
+
chunks.push(value);
|
|
12
|
+
}
|
|
13
|
+
} finally {
|
|
14
|
+
reader.releaseLock();
|
|
15
|
+
}
|
|
16
|
+
return chunks;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function createSSEStream(messages: string[]): ReadableStream<Uint8Array> {
|
|
20
|
+
const encoder = new TextEncoder();
|
|
21
|
+
const sseText = messages.map((m) => `data: ${m}\n\n`).join("");
|
|
22
|
+
return new ReadableStream<Uint8Array>({
|
|
23
|
+
start(controller) {
|
|
24
|
+
controller.enqueue(encoder.encode(sseText));
|
|
25
|
+
controller.close();
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("SSEDecoder", () => {
|
|
31
|
+
it("decodes message events as JSON", async () => {
|
|
32
|
+
const chunks = await collectChunks(
|
|
33
|
+
createSSEStream([
|
|
34
|
+
JSON.stringify({ hello: "world" }),
|
|
35
|
+
JSON.stringify([1, 2, 3]),
|
|
36
|
+
]).pipeThrough(new SSEDecoder()),
|
|
37
|
+
);
|
|
38
|
+
expect(chunks).toEqual([{ hello: "world" }, [1, 2, 3]]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("round-trips values through SSEEncoder and SSEDecoder", async () => {
|
|
42
|
+
type V = { hello: string } | number[];
|
|
43
|
+
const values: V[] = [{ hello: "world" }, [1, 2, 3]];
|
|
44
|
+
const source = new ReadableStream<V>({
|
|
45
|
+
start(controller) {
|
|
46
|
+
for (const v of values) controller.enqueue(v);
|
|
47
|
+
controller.close();
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
const decoded = await collectChunks(
|
|
51
|
+
source.pipeThrough(new SSEEncoder<V>()).pipeThrough(new SSEDecoder<V>()),
|
|
52
|
+
);
|
|
53
|
+
expect(decoded).toEqual(values);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("drops frames carrying prototype-pollution keys", async () => {
|
|
57
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
58
|
+
const chunks = await collectChunks(
|
|
59
|
+
createSSEStream([
|
|
60
|
+
'{"__proto__":{"polluted":true},"ok":1}',
|
|
61
|
+
'{"constructor":{"prototype":{"polluted":true}},"ok":2}',
|
|
62
|
+
JSON.stringify({ ok: 3 }),
|
|
63
|
+
]).pipeThrough(new SSEDecoder()),
|
|
64
|
+
);
|
|
65
|
+
expect(warn).toHaveBeenCalledTimes(2);
|
|
66
|
+
expect(chunks).toEqual([{ ok: 3 }]);
|
|
67
|
+
warn.mockRestore();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("drops frames that are not valid JSON", async () => {
|
|
71
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
72
|
+
const invalid = ["{not json", "not json"];
|
|
73
|
+
const chunks = await collectChunks(
|
|
74
|
+
createSSEStream([...invalid, JSON.stringify({ ok: 1 })]).pipeThrough(
|
|
75
|
+
new SSEDecoder(),
|
|
76
|
+
),
|
|
77
|
+
);
|
|
78
|
+
expect(warn.mock.calls.map((c) => c[0])).toEqual(
|
|
79
|
+
invalid.map((m) => `Dropped invalid SSE message: ${m}`),
|
|
80
|
+
);
|
|
81
|
+
expect(chunks).toEqual([{ ok: 1 }]);
|
|
82
|
+
warn.mockRestore();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("errors the stream on an unknown event type", async () => {
|
|
86
|
+
const encoder = new TextEncoder();
|
|
87
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
88
|
+
start(controller) {
|
|
89
|
+
controller.enqueue(encoder.encode("event: custom\ndata: x\n\n"));
|
|
90
|
+
controller.close();
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
await expect(
|
|
94
|
+
collectChunks(stream.pipeThrough(new SSEDecoder())),
|
|
95
|
+
).rejects.toThrow("Unknown SSE event type: custom");
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import sjson from "secure-json-parse";
|
|
1
2
|
import { PipeableTransformStream } from "./PipeableTransformStream";
|
|
2
3
|
import {
|
|
3
4
|
SSEEventDecoderStream,
|
|
@@ -44,9 +45,19 @@ export class SSEDecoder<T> extends PipeableTransformStream<
|
|
|
44
45
|
new TransformStream<PipelineSSEEvent, T>({
|
|
45
46
|
transform(event, controller) {
|
|
46
47
|
switch (event.event) {
|
|
47
|
-
case "message":
|
|
48
|
-
|
|
48
|
+
case "message": {
|
|
49
|
+
let value;
|
|
50
|
+
try {
|
|
51
|
+
value = sjson.parse(event.data);
|
|
52
|
+
} catch {
|
|
53
|
+
console.warn(
|
|
54
|
+
`Dropped invalid SSE message: ${event.data.slice(0, 200)}`,
|
|
55
|
+
);
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
controller.enqueue(value);
|
|
49
59
|
break;
|
|
60
|
+
}
|
|
50
61
|
default:
|
|
51
62
|
throw new Error(`Unknown SSE event type: ${event.event}`);
|
|
52
63
|
}
|
|
@@ -9,9 +9,18 @@ type MergeStreamItem = {
|
|
|
9
9
|
export const createMergeStream = () => {
|
|
10
10
|
const list: MergeStreamItem[] = [];
|
|
11
11
|
let sealed = false;
|
|
12
|
+
let cancelled = false;
|
|
13
|
+
let errored = false;
|
|
12
14
|
let controller: ReadableStreamDefaultController<AssistantStreamChunk>;
|
|
13
15
|
let currentPull: ReturnType<typeof promiseWithResolvers<void>> | undefined;
|
|
14
16
|
|
|
17
|
+
const cancelAllReaders = () => {
|
|
18
|
+
list.forEach((item) => {
|
|
19
|
+
void item.reader.cancel().catch(() => undefined);
|
|
20
|
+
});
|
|
21
|
+
list.length = 0;
|
|
22
|
+
};
|
|
23
|
+
|
|
15
24
|
const handlePull = (item: MergeStreamItem) => {
|
|
16
25
|
if (!item.promise) {
|
|
17
26
|
// TODO for most streams, we can directly pipeTo to avoid the microTask queue
|
|
@@ -23,6 +32,8 @@ export const createMergeStream = () => {
|
|
|
23
32
|
.read()
|
|
24
33
|
.then(({ done, value }) => {
|
|
25
34
|
item.promise = undefined;
|
|
35
|
+
if (cancelled || errored) return;
|
|
36
|
+
|
|
26
37
|
if (done) {
|
|
27
38
|
list.splice(list.indexOf(item), 1);
|
|
28
39
|
if (sealed && list.length === 0) {
|
|
@@ -36,12 +47,11 @@ export const createMergeStream = () => {
|
|
|
36
47
|
currentPull = undefined;
|
|
37
48
|
})
|
|
38
49
|
.catch((e) => {
|
|
39
|
-
|
|
50
|
+
if (cancelled || errored) return;
|
|
40
51
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
list.length = 0;
|
|
52
|
+
errored = true;
|
|
53
|
+
console.error(e);
|
|
54
|
+
cancelAllReaders();
|
|
45
55
|
|
|
46
56
|
controller.error(e);
|
|
47
57
|
|
|
@@ -64,10 +74,10 @@ export const createMergeStream = () => {
|
|
|
64
74
|
return currentPull.promise;
|
|
65
75
|
},
|
|
66
76
|
cancel() {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
77
|
+
cancelled = true;
|
|
78
|
+
cancelAllReaders();
|
|
79
|
+
currentPull?.resolve();
|
|
80
|
+
currentPull = undefined;
|
|
71
81
|
},
|
|
72
82
|
});
|
|
73
83
|
|
|
@@ -76,11 +86,23 @@ export const createMergeStream = () => {
|
|
|
76
86
|
isSealed() {
|
|
77
87
|
return sealed;
|
|
78
88
|
},
|
|
89
|
+
isCancelled() {
|
|
90
|
+
return cancelled;
|
|
91
|
+
},
|
|
92
|
+
isErrored() {
|
|
93
|
+
return errored;
|
|
94
|
+
},
|
|
79
95
|
seal() {
|
|
96
|
+
if (cancelled || errored) return;
|
|
80
97
|
sealed = true;
|
|
81
98
|
if (list.length === 0) controller.close();
|
|
82
99
|
},
|
|
83
100
|
addStream(stream: ReadableStream<AssistantStreamChunk>) {
|
|
101
|
+
if (cancelled || errored) {
|
|
102
|
+
void stream.cancel().catch(() => undefined);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
84
106
|
if (sealed)
|
|
85
107
|
throw new Error(
|
|
86
108
|
"Cannot add streams after the run callback has settled.",
|
package/src/gorp.test.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import * as entry from "./index";
|
|
3
|
+
|
|
4
|
+
describe("assistant-transport state exports", () => {
|
|
5
|
+
it("streams operations end to end through the SSE wire", async () => {
|
|
6
|
+
const stream = entry.createObjectStream({
|
|
7
|
+
execute: (controller) => {
|
|
8
|
+
controller.enqueue([
|
|
9
|
+
{ type: "set", path: ["message"], value: "Hello" },
|
|
10
|
+
]);
|
|
11
|
+
controller.enqueue([
|
|
12
|
+
{ type: "append-text", path: ["message"], value: " World" },
|
|
13
|
+
]);
|
|
14
|
+
},
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const response = new entry.ObjectStreamResponse(stream);
|
|
18
|
+
const decoded = entry.fromObjectStreamResponse(response);
|
|
19
|
+
|
|
20
|
+
const tracker = new entry.AssistantTransportDeltaTracker();
|
|
21
|
+
const reader = decoded.getReader();
|
|
22
|
+
while (true) {
|
|
23
|
+
const { done, value } = await reader.read();
|
|
24
|
+
if (done) break;
|
|
25
|
+
tracker.append(value.operations);
|
|
26
|
+
expect(tracker.state).toEqual(value.snapshot);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
expect(tracker.state).toEqual({ message: "Hello World" });
|
|
30
|
+
expect(tracker.isChangedAt(["message"])).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -62,12 +62,27 @@ export {
|
|
|
62
62
|
export type { TextStreamController } from "./core/modules/text";
|
|
63
63
|
export type { ToolCallStreamController } from "./core/modules/tool-call";
|
|
64
64
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
} from "./core/
|
|
70
|
-
|
|
65
|
+
import { createGorpStream } from "./core/gorp/createGorpStream";
|
|
66
|
+
import {
|
|
67
|
+
GorpStreamResponse,
|
|
68
|
+
fromGorpStreamResponse,
|
|
69
|
+
} from "./core/gorp/GorpStreamResponse";
|
|
70
|
+
import type { GorpStreamChunk } from "./core/gorp/types";
|
|
71
|
+
import { GorpStreamDeltaTracker } from "./core/gorp/GorpStreamDeltaTracker";
|
|
72
|
+
|
|
73
|
+
export { GorpStreamDeltaTracker as AssistantTransportDeltaTracker };
|
|
74
|
+
export type { AssistantTransportStateOperation } from "./core/gorp/types";
|
|
75
|
+
|
|
76
|
+
/** @deprecated Use the assistant-transport surface instead. */
|
|
77
|
+
export const createObjectStream = createGorpStream;
|
|
78
|
+
/** @deprecated Use the assistant-transport surface instead. */
|
|
79
|
+
export const ObjectStreamResponse = GorpStreamResponse;
|
|
80
|
+
/** @deprecated Use the assistant-transport surface instead. */
|
|
81
|
+
export type ObjectStreamResponse = GorpStreamResponse;
|
|
82
|
+
/** @deprecated Use the assistant-transport surface instead. */
|
|
83
|
+
export const fromObjectStreamResponse = fromGorpStreamResponse;
|
|
84
|
+
/** @deprecated Use the assistant-transport surface instead. */
|
|
85
|
+
export type ObjectStreamChunk = GorpStreamChunk;
|
|
71
86
|
|
|
72
87
|
export {
|
|
73
88
|
toGenericMessages,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { createResumableStreamContext } from "./ResumableStreamContext";
|
|
3
3
|
import { ResumableStreamError } from "./errors";
|
|
4
4
|
import { createInMemoryResumableStreamStore } from "./stores/InMemoryResumableStreamStore";
|
|
@@ -8,6 +8,10 @@ const dec = new TextDecoder();
|
|
|
8
8
|
|
|
9
9
|
const bytes = (s: string): Uint8Array => enc.encode(s);
|
|
10
10
|
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
vi.restoreAllMocks();
|
|
13
|
+
});
|
|
14
|
+
|
|
11
15
|
async function collect(stream: ReadableStream<Uint8Array>): Promise<string> {
|
|
12
16
|
const reader = stream.getReader();
|
|
13
17
|
let out = "";
|
|
@@ -271,4 +275,115 @@ describe("createResumableStreamContext", () => {
|
|
|
271
275
|
expect(errors[0]!.id).toBe("a");
|
|
272
276
|
expect((errors[0]!.error as Error).message).toBe("boom");
|
|
273
277
|
});
|
|
278
|
+
|
|
279
|
+
it("continues acquisition when onAcquire throws", async () => {
|
|
280
|
+
const consoleError = vi
|
|
281
|
+
.spyOn(console, "error")
|
|
282
|
+
.mockImplementation(() => {});
|
|
283
|
+
const hookError = new Error("acquire observer failed");
|
|
284
|
+
const store = createInMemoryResumableStreamStore();
|
|
285
|
+
const ctx = createResumableStreamContext({
|
|
286
|
+
store,
|
|
287
|
+
onAcquire: () => {
|
|
288
|
+
throw hookError;
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
const stream = await ctx.run("a", () => makeStringStream(["hello"]));
|
|
293
|
+
|
|
294
|
+
expect(await collect(stream)).toBe("hello");
|
|
295
|
+
expect(await ctx.status("a")).toBe("done");
|
|
296
|
+
expect(consoleError).toHaveBeenCalledWith(
|
|
297
|
+
"resumable stream onAcquire hook failed:",
|
|
298
|
+
hookError,
|
|
299
|
+
);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("continues streaming when onAppend throws", async () => {
|
|
303
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
304
|
+
const store = createInMemoryResumableStreamStore();
|
|
305
|
+
const ctx = createResumableStreamContext({
|
|
306
|
+
store,
|
|
307
|
+
onAppend: () => {
|
|
308
|
+
throw new Error("append observer failed");
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
const stream = await ctx.run("a", () =>
|
|
313
|
+
makeStringStream(["hello ", "world"]),
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
expect(await collect(stream)).toBe("hello world");
|
|
317
|
+
expect(await ctx.status("a")).toBe("done");
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("continues streaming when async onAppend rejects", async () => {
|
|
321
|
+
const consoleError = vi
|
|
322
|
+
.spyOn(console, "error")
|
|
323
|
+
.mockImplementation(() => {});
|
|
324
|
+
const hookError = new Error("async append observer failed");
|
|
325
|
+
const store = createInMemoryResumableStreamStore();
|
|
326
|
+
const ctx = createResumableStreamContext({
|
|
327
|
+
store,
|
|
328
|
+
onAppend: async () => {
|
|
329
|
+
throw hookError;
|
|
330
|
+
},
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
const stream = await ctx.run("a", () => makeStringStream(["hello"]));
|
|
334
|
+
|
|
335
|
+
expect(await collect(stream)).toBe("hello");
|
|
336
|
+
expect(await ctx.status("a")).toBe("done");
|
|
337
|
+
await vi.waitFor(() => {
|
|
338
|
+
expect(consoleError).toHaveBeenCalledWith(
|
|
339
|
+
"resumable stream onAppend hook failed:",
|
|
340
|
+
hookError,
|
|
341
|
+
);
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("keeps successful completion when onFinalize throws", async () => {
|
|
346
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
347
|
+
const onError = vi.fn();
|
|
348
|
+
const onFinalize = vi.fn(() => {
|
|
349
|
+
throw new Error("finalize observer failed");
|
|
350
|
+
});
|
|
351
|
+
const store = createInMemoryResumableStreamStore();
|
|
352
|
+
const ctx = createResumableStreamContext({ store, onError, onFinalize });
|
|
353
|
+
|
|
354
|
+
const stream = await ctx.run("a", () => makeStringStream(["hello"]));
|
|
355
|
+
|
|
356
|
+
expect(await collect(stream)).toBe("hello");
|
|
357
|
+
expect(await ctx.status("a")).toBe("done");
|
|
358
|
+
expect(onFinalize).toHaveBeenCalledOnce();
|
|
359
|
+
expect(onError).not.toHaveBeenCalled();
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("finalizes producer errors when onError throws", async () => {
|
|
363
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
364
|
+
const tasks: Promise<unknown>[] = [];
|
|
365
|
+
const store = createInMemoryResumableStreamStore();
|
|
366
|
+
const ctx = createResumableStreamContext({
|
|
367
|
+
store,
|
|
368
|
+
waitUntil: (task) => tasks.push(task),
|
|
369
|
+
onError: () => {
|
|
370
|
+
throw new Error("error observer failed");
|
|
371
|
+
},
|
|
372
|
+
});
|
|
373
|
+
const producerError = new Error("producer failed");
|
|
374
|
+
|
|
375
|
+
const stream = await ctx.run(
|
|
376
|
+
"a",
|
|
377
|
+
() =>
|
|
378
|
+
new ReadableStream<Uint8Array>({
|
|
379
|
+
start(controller) {
|
|
380
|
+
controller.error(producerError);
|
|
381
|
+
},
|
|
382
|
+
}),
|
|
383
|
+
);
|
|
384
|
+
await Promise.allSettled(tasks);
|
|
385
|
+
|
|
386
|
+
expect(await ctx.status("a")).toBe("error");
|
|
387
|
+
await expect(collect(stream)).rejects.toThrow("producer failed");
|
|
388
|
+
});
|
|
274
389
|
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ResumableStreamError } from "./errors";
|
|
2
|
+
import { withPromiseOrValue } from "../core/utils/withPromiseOrValue";
|
|
2
3
|
import type {
|
|
3
4
|
ResumableStreamRole,
|
|
4
5
|
ResumableStreamStatus,
|
|
@@ -42,6 +43,21 @@ export interface ResumableStreamContext {
|
|
|
42
43
|
delete(streamId: string): Promise<void>;
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
const invokeObservabilityHook = <TArgs extends unknown[]>(
|
|
47
|
+
name: string,
|
|
48
|
+
hook: ((...args: TArgs) => void) | undefined,
|
|
49
|
+
...args: TArgs
|
|
50
|
+
): void => {
|
|
51
|
+
if (!hook) return;
|
|
52
|
+
void withPromiseOrValue(
|
|
53
|
+
() => hook(...args),
|
|
54
|
+
() => {},
|
|
55
|
+
(error) => {
|
|
56
|
+
console.error(`resumable stream ${name} hook failed:`, error);
|
|
57
|
+
},
|
|
58
|
+
);
|
|
59
|
+
};
|
|
60
|
+
|
|
45
61
|
export function createResumableStreamContext(
|
|
46
62
|
options: ResumableStreamContextOptions,
|
|
47
63
|
): ResumableStreamContext {
|
|
@@ -53,7 +69,7 @@ export function createResumableStreamContext(
|
|
|
53
69
|
return {
|
|
54
70
|
async run(streamId, makeStream) {
|
|
55
71
|
const role = await store.acquire(streamId, acquireOptions);
|
|
56
|
-
onAcquire
|
|
72
|
+
invokeObservabilityHook("onAcquire", onAcquire, streamId, role);
|
|
57
73
|
if (role === "producer") {
|
|
58
74
|
startProducerTask(store, streamId, makeStream, {
|
|
59
75
|
waitUntil,
|
|
@@ -119,13 +135,18 @@ function startProducerTask(
|
|
|
119
135
|
const { done, value } = await reader.read();
|
|
120
136
|
if (done) break;
|
|
121
137
|
await store.append(streamId, value);
|
|
122
|
-
|
|
138
|
+
invokeObservabilityHook(
|
|
139
|
+
"onAppend",
|
|
140
|
+
onAppend,
|
|
141
|
+
streamId,
|
|
142
|
+
value.byteLength,
|
|
143
|
+
);
|
|
123
144
|
}
|
|
124
145
|
await store.finalize(streamId, "done");
|
|
125
|
-
onFinalize
|
|
146
|
+
invokeObservabilityHook("onFinalize", onFinalize, streamId, "done");
|
|
126
147
|
} catch (err) {
|
|
127
148
|
cancelled = true;
|
|
128
|
-
onError
|
|
149
|
+
invokeObservabilityHook("onError", onError, streamId, err);
|
|
129
150
|
const message = err instanceof Error ? err.message : String(err);
|
|
130
151
|
try {
|
|
131
152
|
await reader?.cancel(err);
|
|
@@ -134,7 +155,13 @@ function startProducerTask(
|
|
|
134
155
|
}
|
|
135
156
|
try {
|
|
136
157
|
await store.finalize(streamId, "error", message);
|
|
137
|
-
|
|
158
|
+
invokeObservabilityHook(
|
|
159
|
+
"onFinalize",
|
|
160
|
+
onFinalize,
|
|
161
|
+
streamId,
|
|
162
|
+
"error",
|
|
163
|
+
message,
|
|
164
|
+
);
|
|
138
165
|
} catch (finalizeErr) {
|
|
139
166
|
console.error("resumable stream finalize failed:", finalizeErr);
|
|
140
167
|
}
|