assistant-stream 0.3.26 → 0.3.27
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/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.js +2 -2
- 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/chunk-types.d.ts +2 -2
- package/dist/core/serialization/data-stream/chunk-types.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 +11 -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/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/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/assistant-message-accumulator.test.ts +374 -1
- package/src/core/accumulators/assistant-message-accumulator.ts +75 -26
- 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 +182 -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.test.ts +168 -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 +3 -3
- 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/chunk-types.ts +2 -2
- package/src/core/serialization/ui-message-stream/UIMessageStream.test.ts +111 -0
- package/src/core/serialization/ui-message-stream/UIMessageStream.ts +20 -1
- package/src/core/tool/ToolExecutionStream.ts +10 -0
- package/src/core/tool/toolResultStream.test.ts +50 -0
- package/src/core/tool/toolResultStream.ts +7 -2
- package/src/core/utils/stream/merge.ts +31 -9
- package/src/gorp.test.ts +42 -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/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 { PipeableTransformStream } from "../../utils/stream/PipeableTransformStream";
|
|
3
4
|
import {
|
|
@@ -6,6 +7,70 @@ import {
|
|
|
6
7
|
} from "../../utils/stream/SSEEventDecoderStream";
|
|
7
8
|
import type { AssistantStreamEncoder } from "../../AssistantStream";
|
|
8
9
|
|
|
10
|
+
type ChunkFields = Record<string, unknown>;
|
|
11
|
+
|
|
12
|
+
type ChunkRule = {
|
|
13
|
+
kind: "message" | "part-addressed";
|
|
14
|
+
valid: (chunk: ChunkFields) => boolean;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const noFields = () => true;
|
|
18
|
+
const requiredObject = (key: string) => (c: ChunkFields) => {
|
|
19
|
+
const value = c[key];
|
|
20
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
21
|
+
};
|
|
22
|
+
const requiredString = (key: string) => (c: ChunkFields) =>
|
|
23
|
+
typeof c[key] === "string";
|
|
24
|
+
const requiredArray = (key: string) => (c: ChunkFields) =>
|
|
25
|
+
Array.isArray(c[key]);
|
|
26
|
+
const optionalBoolean = (key: string) => (c: ChunkFields) =>
|
|
27
|
+
c[key] === undefined || typeof c[key] === "boolean";
|
|
28
|
+
|
|
29
|
+
const KNOWN_CHUNK_TYPES: Record<AssistantStreamChunk["type"], ChunkRule> = {
|
|
30
|
+
"part-start": { kind: "message", valid: requiredObject("part") },
|
|
31
|
+
"part-finish": { kind: "part-addressed", valid: noFields },
|
|
32
|
+
"tool-call-args-text-finish": { kind: "part-addressed", valid: noFields },
|
|
33
|
+
"text-delta": { kind: "part-addressed", valid: requiredString("textDelta") },
|
|
34
|
+
annotations: { kind: "message", valid: requiredArray("annotations") },
|
|
35
|
+
data: { kind: "message", valid: requiredArray("data") },
|
|
36
|
+
"step-start": { kind: "message", valid: noFields },
|
|
37
|
+
"step-finish": { kind: "message", valid: requiredString("finishReason") },
|
|
38
|
+
"message-finish": { kind: "message", valid: requiredString("finishReason") },
|
|
39
|
+
result: { kind: "part-addressed", valid: optionalBoolean("isError") },
|
|
40
|
+
error: { kind: "message", valid: noFields },
|
|
41
|
+
"update-state": { kind: "message", valid: requiredArray("operations") },
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const parseChunk = (data: string): AssistantStreamChunk | string => {
|
|
45
|
+
let value: unknown;
|
|
46
|
+
try {
|
|
47
|
+
value = sjson.parse(data);
|
|
48
|
+
} catch {
|
|
49
|
+
return "unparseable";
|
|
50
|
+
}
|
|
51
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
52
|
+
return "not-an-object";
|
|
53
|
+
const { type, path } = value as { type?: unknown; path?: unknown };
|
|
54
|
+
if (
|
|
55
|
+
typeof type !== "string" ||
|
|
56
|
+
!Object.prototype.hasOwnProperty.call(KNOWN_CHUNK_TYPES, type)
|
|
57
|
+
)
|
|
58
|
+
return "unknown-type";
|
|
59
|
+
const rule = KNOWN_CHUNK_TYPES[type as AssistantStreamChunk["type"]];
|
|
60
|
+
if (!rule.valid(value as Record<string, unknown>))
|
|
61
|
+
return `invalid-fields:${type}`;
|
|
62
|
+
if (path === undefined) {
|
|
63
|
+
if (rule.kind !== "message") return `missing-path:${type}`;
|
|
64
|
+
return { ...value, path: [] } as unknown as AssistantStreamChunk;
|
|
65
|
+
}
|
|
66
|
+
if (
|
|
67
|
+
!Array.isArray(path) ||
|
|
68
|
+
!path.every((entry) => Number.isInteger(entry) && entry >= 0)
|
|
69
|
+
)
|
|
70
|
+
return "invalid-path";
|
|
71
|
+
return value as AssistantStreamChunk;
|
|
72
|
+
};
|
|
73
|
+
|
|
9
74
|
/**
|
|
10
75
|
* AssistantTransportEncoder encodes AssistantStreamChunks into SSE format
|
|
11
76
|
* and emits [DONE] when the stream completes.
|
|
@@ -49,6 +114,7 @@ export class AssistantTransportDecoder extends PipeableTransformStream<
|
|
|
49
114
|
constructor() {
|
|
50
115
|
super((readable) => {
|
|
51
116
|
let receivedDone = false;
|
|
117
|
+
const warnedReasons = new Set<string>();
|
|
52
118
|
|
|
53
119
|
return readable
|
|
54
120
|
.pipeThrough(new TextDecoderStream())
|
|
@@ -64,7 +130,17 @@ export class AssistantTransportDecoder extends PipeableTransformStream<
|
|
|
64
130
|
// Stop processing when we encounter [DONE]
|
|
65
131
|
controller.terminate();
|
|
66
132
|
} else {
|
|
67
|
-
|
|
133
|
+
const chunk = parseChunk(event.data);
|
|
134
|
+
if (typeof chunk === "string") {
|
|
135
|
+
if (!warnedReasons.has(chunk)) {
|
|
136
|
+
warnedReasons.add(chunk);
|
|
137
|
+
console.warn(
|
|
138
|
+
`Dropped invalid assistant-transport chunk (${chunk}): ${event.data.slice(0, 200)}`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
} else {
|
|
142
|
+
controller.enqueue(chunk);
|
|
143
|
+
}
|
|
68
144
|
}
|
|
69
145
|
break;
|
|
70
146
|
default:
|
|
@@ -2,7 +2,7 @@ import type {
|
|
|
2
2
|
ReadonlyJSONObject,
|
|
3
3
|
ReadonlyJSONValue,
|
|
4
4
|
} from "../../../utils/json/json-value";
|
|
5
|
-
import type {
|
|
5
|
+
import type { GorpStreamOperation } from "../../gorp/types";
|
|
6
6
|
|
|
7
7
|
export type DataStreamChunk = {
|
|
8
8
|
[K in DataStreamStreamChunkType]: {
|
|
@@ -100,7 +100,7 @@ type DataStreamStreamChunkValue = {
|
|
|
100
100
|
[DataStreamStreamChunkType.File]: { data: string; mimeType: string };
|
|
101
101
|
|
|
102
102
|
// aui-extensions
|
|
103
|
-
[DataStreamStreamChunkType.AuiUpdateStateOperations]:
|
|
103
|
+
[DataStreamStreamChunkType.AuiUpdateStateOperations]: GorpStreamOperation[];
|
|
104
104
|
[DataStreamStreamChunkType.AuiTextDelta]: {
|
|
105
105
|
textDelta: string;
|
|
106
106
|
parentId: string;
|
|
@@ -575,4 +575,115 @@ describe("UIMessageStreamDecoder", () => {
|
|
|
575
575
|
// Should not throw, should complete successfully
|
|
576
576
|
expect(chunks.some((c) => c.type === "message-finish")).toBe(true);
|
|
577
577
|
});
|
|
578
|
+
|
|
579
|
+
it("drops frames that are not objects with a string type", async () => {
|
|
580
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
581
|
+
const invalidFrames = [
|
|
582
|
+
"null",
|
|
583
|
+
"5",
|
|
584
|
+
'"text"',
|
|
585
|
+
"[1,2]",
|
|
586
|
+
'{"foo":1}',
|
|
587
|
+
'{"type":5}',
|
|
588
|
+
];
|
|
589
|
+
const events = [
|
|
590
|
+
...invalidFrames,
|
|
591
|
+
JSON.stringify({ type: "start", messageId: "msg_123" }),
|
|
592
|
+
JSON.stringify({ type: "text-delta", id: "t", delta: "ok" }),
|
|
593
|
+
"[DONE]",
|
|
594
|
+
];
|
|
595
|
+
|
|
596
|
+
const chunks = await collectChunks(
|
|
597
|
+
createUIMessageStream(events).pipeThrough(new UIMessageStreamDecoder()),
|
|
598
|
+
);
|
|
599
|
+
|
|
600
|
+
expect(warn.mock.calls.map((c) => c[0])).toEqual(
|
|
601
|
+
invalidFrames.map((f) => `Dropped invalid UIMessageStream chunk: ${f}`),
|
|
602
|
+
);
|
|
603
|
+
expect(chunks.some((c) => c.type === "text-delta")).toBe(true);
|
|
604
|
+
warn.mockRestore();
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
it("drops frames that are not valid JSON", async () => {
|
|
608
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
609
|
+
const invalidFrames = ['{"type":"te', "not json"];
|
|
610
|
+
const events = [
|
|
611
|
+
...invalidFrames,
|
|
612
|
+
JSON.stringify({ type: "start", messageId: "msg_123" }),
|
|
613
|
+
JSON.stringify({ type: "text-delta", id: "t", delta: "ok" }),
|
|
614
|
+
"[DONE]",
|
|
615
|
+
];
|
|
616
|
+
|
|
617
|
+
const chunks = await collectChunks(
|
|
618
|
+
createUIMessageStream(events).pipeThrough(new UIMessageStreamDecoder()),
|
|
619
|
+
);
|
|
620
|
+
|
|
621
|
+
expect(warn.mock.calls.map((c) => c[0])).toEqual(
|
|
622
|
+
invalidFrames.map((f) => `Dropped invalid UIMessageStream chunk: ${f}`),
|
|
623
|
+
);
|
|
624
|
+
expect(chunks.some((c) => c.type === "text-delta")).toBe(true);
|
|
625
|
+
warn.mockRestore();
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
it("ignores a tool-call-delta arriving after tool-result", async () => {
|
|
629
|
+
const events = [
|
|
630
|
+
JSON.stringify({ type: "start", messageId: "msg_123" }),
|
|
631
|
+
JSON.stringify({
|
|
632
|
+
type: "tool-call-start",
|
|
633
|
+
toolCallId: "call_abc",
|
|
634
|
+
toolName: "weather",
|
|
635
|
+
}),
|
|
636
|
+
JSON.stringify({ type: "tool-call-delta", argsText: '{"city":"NYC"}' }),
|
|
637
|
+
JSON.stringify({
|
|
638
|
+
type: "tool-result",
|
|
639
|
+
toolCallId: "call_abc",
|
|
640
|
+
result: { temp: 72 },
|
|
641
|
+
}),
|
|
642
|
+
JSON.stringify({ type: "tool-call-delta", argsText: '{"late":true}' }),
|
|
643
|
+
"[DONE]",
|
|
644
|
+
];
|
|
645
|
+
|
|
646
|
+
const chunks = await collectChunks(
|
|
647
|
+
createUIMessageStream(events).pipeThrough(new UIMessageStreamDecoder()),
|
|
648
|
+
);
|
|
649
|
+
|
|
650
|
+
const argDeltas = chunks.filter((c) => c.type === "text-delta");
|
|
651
|
+
expect(argDeltas).toHaveLength(1);
|
|
652
|
+
expect(chunks.some((c) => c.type === "result")).toBe(true);
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
it("keeps the active tool call writable when another call receives its result", async () => {
|
|
656
|
+
const events = [
|
|
657
|
+
JSON.stringify({
|
|
658
|
+
type: "tool-call-start",
|
|
659
|
+
toolCallId: "call_a",
|
|
660
|
+
toolName: "first",
|
|
661
|
+
}),
|
|
662
|
+
JSON.stringify({
|
|
663
|
+
type: "tool-call-start",
|
|
664
|
+
toolCallId: "call_b",
|
|
665
|
+
toolName: "second",
|
|
666
|
+
}),
|
|
667
|
+
JSON.stringify({
|
|
668
|
+
type: "tool-result",
|
|
669
|
+
toolCallId: "call_a",
|
|
670
|
+
result: { ok: true },
|
|
671
|
+
}),
|
|
672
|
+
JSON.stringify({ type: "tool-call-delta", argsText: '{"x":1}' }),
|
|
673
|
+
"[DONE]",
|
|
674
|
+
];
|
|
675
|
+
|
|
676
|
+
const chunks = await collectChunks(
|
|
677
|
+
createUIMessageStream(events).pipeThrough(new UIMessageStreamDecoder()),
|
|
678
|
+
);
|
|
679
|
+
|
|
680
|
+
const argDeltas = chunks.filter(
|
|
681
|
+
(c): c is AssistantStreamChunk & { type: "text-delta" } =>
|
|
682
|
+
c.type === "text-delta",
|
|
683
|
+
);
|
|
684
|
+
expect(argDeltas).toHaveLength(2);
|
|
685
|
+
const byPath = new Map(argDeltas.map((c) => [c.path[0], c.textDelta]));
|
|
686
|
+
expect(byPath.get(0)).toBe("{}");
|
|
687
|
+
expect(byPath.get(1)).toBe('{"x":1}');
|
|
688
|
+
});
|
|
578
689
|
});
|
|
@@ -149,6 +149,9 @@ export class UIMessageStreamDecoder extends PipeableTransformStream<
|
|
|
149
149
|
`Encountered tool result with unknown id: ${chunk.toolCallId}`,
|
|
150
150
|
);
|
|
151
151
|
}
|
|
152
|
+
if (toolCallController.argsText === activeToolCallArgsText) {
|
|
153
|
+
activeToolCallArgsText = undefined;
|
|
154
|
+
}
|
|
152
155
|
toolCallController.setResponse({
|
|
153
156
|
result: chunk.result,
|
|
154
157
|
isError: chunk.isError ?? false,
|
|
@@ -222,7 +225,23 @@ export class UIMessageStreamDecoder extends PipeableTransformStream<
|
|
|
222
225
|
return;
|
|
223
226
|
}
|
|
224
227
|
|
|
225
|
-
|
|
228
|
+
let chunk;
|
|
229
|
+
try {
|
|
230
|
+
chunk = JSON.parse(event.data);
|
|
231
|
+
} catch {
|
|
232
|
+
chunk = undefined;
|
|
233
|
+
}
|
|
234
|
+
if (
|
|
235
|
+
typeof chunk !== "object" ||
|
|
236
|
+
chunk === null ||
|
|
237
|
+
Array.isArray(chunk) ||
|
|
238
|
+
typeof chunk.type !== "string"
|
|
239
|
+
) {
|
|
240
|
+
console.warn(
|
|
241
|
+
`Dropped invalid UIMessageStream chunk: ${event.data.slice(0, 200)}`,
|
|
242
|
+
);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
226
245
|
if (
|
|
227
246
|
chunk.type === "text-delta" &&
|
|
228
247
|
chunk.textDelta === undefined
|
|
@@ -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
|
}
|
|
@@ -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);
|
|
@@ -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,42 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import * as entry from "./index";
|
|
3
|
+
|
|
4
|
+
describe("assistant-transport state exports", () => {
|
|
5
|
+
it("exposes the assistant-transport surface and no gorp-shaped names", () => {
|
|
6
|
+
expect(typeof entry.AssistantTransportDeltaTracker).toBe("function");
|
|
7
|
+
expect(typeof entry.createObjectStream).toBe("function");
|
|
8
|
+
expect(typeof entry.ObjectStreamResponse).toBe("function");
|
|
9
|
+
expect(typeof entry.fromObjectStreamResponse).toBe("function");
|
|
10
|
+
for (const name of Object.keys(entry)) {
|
|
11
|
+
expect(name.toLowerCase()).not.toContain("gorp");
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("streams operations end to end through the SSE wire", async () => {
|
|
16
|
+
const stream = entry.createObjectStream({
|
|
17
|
+
execute: (controller) => {
|
|
18
|
+
controller.enqueue([
|
|
19
|
+
{ type: "set", path: ["message"], value: "Hello" },
|
|
20
|
+
]);
|
|
21
|
+
controller.enqueue([
|
|
22
|
+
{ type: "append-text", path: ["message"], value: " World" },
|
|
23
|
+
]);
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const response = new entry.ObjectStreamResponse(stream);
|
|
28
|
+
const decoded = entry.fromObjectStreamResponse(response);
|
|
29
|
+
|
|
30
|
+
const tracker = new entry.AssistantTransportDeltaTracker();
|
|
31
|
+
const reader = decoded.getReader();
|
|
32
|
+
while (true) {
|
|
33
|
+
const { done, value } = await reader.read();
|
|
34
|
+
if (done) break;
|
|
35
|
+
tracker.append(value.operations);
|
|
36
|
+
expect(tracker.state).toEqual(value.snapshot);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
expect(tracker.state).toEqual({ message: "Hello World" });
|
|
40
|
+
expect(tracker.isChangedAt(["message"])).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
});
|
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
|
});
|