assistant-stream 0.3.29 → 0.3.30
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/modules/tool-call.d.ts +4 -0
- package/dist/core/modules/tool-call.d.ts.map +1 -1
- package/dist/core/modules/tool-call.js +2 -2
- package/dist/core/modules/tool-call.js.map +1 -1
- package/package.json +1 -1
- package/src/core/modules/assistant-stream.test.ts +49 -0
- package/src/core/modules/tool-call.test.ts +50 -0
- package/src/core/modules/tool-call.ts +7 -3
- package/src/core/serialization/assistant-transport/AssistantTransport.interop.test.ts +179 -0
- package/src/core/serialization/assistant-transport/__fixtures__/python-encoder.sse +52 -0
- package/src/core/serialization/ui-message-stream/UIMessageStream.test.ts +41 -0
|
@@ -6,6 +6,10 @@ import { AssistantStream } from "../AssistantStream.js";
|
|
|
6
6
|
//#region src/core/modules/tool-call.d.ts
|
|
7
7
|
type ToolCallStreamController = {
|
|
8
8
|
argsText: TextStreamController;
|
|
9
|
+
/**
|
|
10
|
+
* Sets the tool response and settles the part. The part closes automatically
|
|
11
|
+
* and subsequent calls are ignored.
|
|
12
|
+
*/
|
|
9
13
|
setResponse(response: ToolResponseLike<ReadonlyJSONValue>): void;
|
|
10
14
|
close(): void;
|
|
11
15
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tool-call.d.ts","names":[],"sources":["../../../src/core/modules/tool-call.ts"],"mappings":";;;;;;KAOY;EACV,UAAU
|
|
1
|
+
{"version":3,"file":"tool-call.d.ts","names":[],"sources":["../../../src/core/modules/tool-call.ts"],"mappings":";;;;;;KAOY;EACV,UAAU;;;;;EAMV,YAAY,UAAU,iBAAiB;EACvC;;cA8FW,uBACX,UAAU,mBAAmB,8BAC5B;cAcU,gDAA8B,iBAAA"}
|
|
@@ -36,6 +36,7 @@ var ToolCallStreamControllerImpl = class {
|
|
|
36
36
|
}
|
|
37
37
|
_argsTextController;
|
|
38
38
|
async setResponse(response) {
|
|
39
|
+
if (this._isClosed) return;
|
|
39
40
|
this._controller.enqueue({
|
|
40
41
|
type: "result",
|
|
41
42
|
path: [],
|
|
@@ -45,8 +46,7 @@ var ToolCallStreamControllerImpl = class {
|
|
|
45
46
|
...response.modelContent !== void 0 ? { modelContent: response.modelContent } : {},
|
|
46
47
|
...response.messages !== void 0 ? { messages: response.messages } : {}
|
|
47
48
|
});
|
|
48
|
-
this.
|
|
49
|
-
await Promise.resolve();
|
|
49
|
+
await this.close();
|
|
50
50
|
}
|
|
51
51
|
async close() {
|
|
52
52
|
if (this._isClosed) return;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tool-call.js","names":[],"sources":["../../../src/core/modules/tool-call.ts"],"sourcesContent":["import type { AssistantStream } from \"../AssistantStream\";\nimport type { AssistantStreamChunk } from \"../AssistantStreamChunk\";\nimport type { ToolResponseLike } from \"../tool/ToolResponse\";\nimport type { ReadonlyJSONValue } from \"../../utils/json/json-value\";\nimport type { UnderlyingReadable } from \"../utils/stream/UnderlyingReadable\";\nimport { createTextStream, type TextStreamController } from \"./text\";\n\nexport type ToolCallStreamController = {\n argsText: TextStreamController;\n\n setResponse(response: ToolResponseLike<ReadonlyJSONValue>): void;\n close(): void;\n};\n\nclass ToolCallStreamControllerImpl implements ToolCallStreamController {\n private _isClosed = false;\n\n private _mergeTask: Promise<void>;\n private _controller: ReadableStreamDefaultController<AssistantStreamChunk>;\n\n constructor(\n _controller: ReadableStreamDefaultController<AssistantStreamChunk>,\n ) {\n this._controller = _controller;\n const stream = createTextStream({\n start: (c) => {\n this._argsTextController = c;\n },\n });\n\n let hasArgsText = false;\n this._mergeTask = stream.pipeTo(\n new WritableStream({\n write: (chunk) => {\n switch (chunk.type) {\n case \"text-delta\":\n hasArgsText = true;\n this._controller.enqueue(chunk);\n break;\n\n case \"part-finish\":\n if (!hasArgsText) {\n // if no argsText was provided, assume empty object\n this._controller.enqueue({\n type: \"text-delta\",\n textDelta: \"{}\",\n path: [],\n });\n }\n this._controller.enqueue({\n type: \"tool-call-args-text-finish\",\n path: [],\n });\n break;\n\n default:\n throw new Error(`Unexpected chunk type: ${chunk.type}`);\n }\n },\n }),\n );\n }\n\n get argsText() {\n return this._argsTextController;\n }\n\n private _argsTextController!: TextStreamController;\n\n async setResponse(response: ToolResponseLike<ReadonlyJSONValue>) {\n this._controller.enqueue({\n type: \"result\",\n path: [],\n ...(response.artifact !== undefined\n ? { artifact: response.artifact }\n : {}),\n result: response.result,\n isError: response.isError ?? false,\n ...(response.modelContent !== undefined\n ? { modelContent: response.modelContent }\n : {}),\n ...(response.messages !== undefined\n ? { messages: response.messages }\n : {}),\n });\n this.
|
|
1
|
+
{"version":3,"file":"tool-call.js","names":[],"sources":["../../../src/core/modules/tool-call.ts"],"sourcesContent":["import type { AssistantStream } from \"../AssistantStream\";\nimport type { AssistantStreamChunk } from \"../AssistantStreamChunk\";\nimport type { ToolResponseLike } from \"../tool/ToolResponse\";\nimport type { ReadonlyJSONValue } from \"../../utils/json/json-value\";\nimport type { UnderlyingReadable } from \"../utils/stream/UnderlyingReadable\";\nimport { createTextStream, type TextStreamController } from \"./text\";\n\nexport type ToolCallStreamController = {\n argsText: TextStreamController;\n\n /**\n * Sets the tool response and settles the part. The part closes automatically\n * and subsequent calls are ignored.\n */\n setResponse(response: ToolResponseLike<ReadonlyJSONValue>): void;\n close(): void;\n};\n\nclass ToolCallStreamControllerImpl implements ToolCallStreamController {\n private _isClosed = false;\n\n private _mergeTask: Promise<void>;\n private _controller: ReadableStreamDefaultController<AssistantStreamChunk>;\n\n constructor(\n _controller: ReadableStreamDefaultController<AssistantStreamChunk>,\n ) {\n this._controller = _controller;\n const stream = createTextStream({\n start: (c) => {\n this._argsTextController = c;\n },\n });\n\n let hasArgsText = false;\n this._mergeTask = stream.pipeTo(\n new WritableStream({\n write: (chunk) => {\n switch (chunk.type) {\n case \"text-delta\":\n hasArgsText = true;\n this._controller.enqueue(chunk);\n break;\n\n case \"part-finish\":\n if (!hasArgsText) {\n // if no argsText was provided, assume empty object\n this._controller.enqueue({\n type: \"text-delta\",\n textDelta: \"{}\",\n path: [],\n });\n }\n this._controller.enqueue({\n type: \"tool-call-args-text-finish\",\n path: [],\n });\n break;\n\n default:\n throw new Error(`Unexpected chunk type: ${chunk.type}`);\n }\n },\n }),\n );\n }\n\n get argsText() {\n return this._argsTextController;\n }\n\n private _argsTextController!: TextStreamController;\n\n async setResponse(response: ToolResponseLike<ReadonlyJSONValue>) {\n if (this._isClosed) return;\n\n this._controller.enqueue({\n type: \"result\",\n path: [],\n ...(response.artifact !== undefined\n ? { artifact: response.artifact }\n : {}),\n result: response.result,\n isError: response.isError ?? false,\n ...(response.modelContent !== undefined\n ? { modelContent: response.modelContent }\n : {}),\n ...(response.messages !== undefined\n ? { messages: response.messages }\n : {}),\n });\n await this.close();\n }\n\n async close() {\n if (this._isClosed) return;\n\n this._isClosed = true;\n this._argsTextController.close();\n await this._mergeTask;\n\n this._controller.enqueue({\n type: \"part-finish\",\n path: [],\n });\n this._controller.close();\n }\n}\n\nexport const createToolCallStream = (\n readable: UnderlyingReadable<ToolCallStreamController>,\n): AssistantStream => {\n return new ReadableStream({\n start(c) {\n return readable.start?.(new ToolCallStreamControllerImpl(c));\n },\n pull(c) {\n return readable.pull?.(new ToolCallStreamControllerImpl(c));\n },\n cancel(c) {\n return readable.cancel?.(c);\n },\n });\n};\n\nexport const createToolCallStreamController = () => {\n let controller!: ToolCallStreamController;\n const stream = createToolCallStream({\n start(c) {\n controller = c;\n },\n });\n return [stream, controller] as const;\n};\n"],"mappings":";;AAkBA,IAAM,+BAAN,MAAuE;CACrE,YAAoB;CAEpB;CACA;CAEA,YACE,aACA;EACA,KAAK,cAAc;EACnB,MAAM,SAAS,iBAAiB,EAC9B,QAAQ,MAAM;GACZ,KAAK,sBAAsB;EAC7B,EACF,CAAC;EAED,IAAI,cAAc;EAClB,KAAK,aAAa,OAAO,OACvB,IAAI,eAAe,EACjB,QAAQ,UAAU;GAChB,QAAQ,MAAM,MAAd;IACE,KAAK;KACH,cAAc;KACd,KAAK,YAAY,QAAQ,KAAK;KAC9B;IAEF,KAAK;KACH,IAAI,CAAC,aAEH,KAAK,YAAY,QAAQ;MACvB,MAAM;MACN,WAAW;MACX,MAAM,CAAC;KACT,CAAC;KAEH,KAAK,YAAY,QAAQ;MACvB,MAAM;MACN,MAAM,CAAC;KACT,CAAC;KACD;IAEF,SACE,MAAM,IAAI,MAAM,0BAA0B,MAAM,MAAM;GAC1D;EACF,EACF,CAAC,CACH;CACF;CAEA,IAAI,WAAW;EACb,OAAO,KAAK;CACd;CAEA;CAEA,MAAM,YAAY,UAA+C;EAC/D,IAAI,KAAK,WAAW;EAEpB,KAAK,YAAY,QAAQ;GACvB,MAAM;GACN,MAAM,CAAC;GACP,GAAI,SAAS,aAAa,KAAA,IACtB,EAAE,UAAU,SAAS,SAAS,IAC9B,CAAC;GACL,QAAQ,SAAS;GACjB,SAAS,SAAS,WAAW;GAC7B,GAAI,SAAS,iBAAiB,KAAA,IAC1B,EAAE,cAAc,SAAS,aAAa,IACtC,CAAC;GACL,GAAI,SAAS,aAAa,KAAA,IACtB,EAAE,UAAU,SAAS,SAAS,IAC9B,CAAC;EACP,CAAC;EACD,MAAM,KAAK,MAAM;CACnB;CAEA,MAAM,QAAQ;EACZ,IAAI,KAAK,WAAW;EAEpB,KAAK,YAAY;EACjB,KAAK,oBAAoB,MAAM;EAC/B,MAAM,KAAK;EAEX,KAAK,YAAY,QAAQ;GACvB,MAAM;GACN,MAAM,CAAC;EACT,CAAC;EACD,KAAK,YAAY,MAAM;CACzB;AACF;AAEA,MAAa,wBACX,aACoB;CACpB,OAAO,IAAI,eAAe;EACxB,MAAM,GAAG;GACP,OAAO,SAAS,QAAQ,IAAI,6BAA6B,CAAC,CAAC;EAC7D;EACA,KAAK,GAAG;GACN,OAAO,SAAS,OAAO,IAAI,6BAA6B,CAAC,CAAC;EAC5D;EACA,OAAO,GAAG;GACR,OAAO,SAAS,SAAS,CAAC;EAC5B;CACF,CAAC;AACH;AAEA,MAAa,uCAAuC;CAClD,IAAI;CAMJ,OAAO,CALQ,qBAAqB,EAClC,MAAM,GAAG;EACP,aAAa;CACf,EACF,CACa,GAAG,UAAU;AAC5B"}
|
package/package.json
CHANGED
|
@@ -173,6 +173,55 @@ describe("createAssistantStream task settlement", () => {
|
|
|
173
173
|
});
|
|
174
174
|
});
|
|
175
175
|
|
|
176
|
+
describe("addToolCallPart with an immediate response", () => {
|
|
177
|
+
it("completes the stream without an explicit tool close", async () => {
|
|
178
|
+
const chunks = await collectChunks(
|
|
179
|
+
createAssistantStream((controller) => {
|
|
180
|
+
controller.addToolCallPart({
|
|
181
|
+
toolName: "search",
|
|
182
|
+
response: { result: "done" },
|
|
183
|
+
});
|
|
184
|
+
}),
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
expect(chunks.map((c) => c.type)).toContain("result");
|
|
188
|
+
expect(chunks.at(-1)?.type).toBe("part-finish");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("completes the stream when args accompany the response", async () => {
|
|
192
|
+
const chunks = await collectChunks(
|
|
193
|
+
createAssistantStream((controller) => {
|
|
194
|
+
controller.addToolCallPart({
|
|
195
|
+
toolName: "search",
|
|
196
|
+
args: { query: "x" },
|
|
197
|
+
response: { result: "done" },
|
|
198
|
+
});
|
|
199
|
+
}),
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
const deltas = chunks.filter((c) => c.type === "text-delta");
|
|
203
|
+
expect(deltas.map((c) => c.textDelta).join("")).toBe('{"query":"x"}');
|
|
204
|
+
expect(chunks.filter((c) => c.type === "result")).toHaveLength(1);
|
|
205
|
+
expect(chunks.at(-1)?.type).toBe("part-finish");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("keeps working when the caller also closes explicitly", async () => {
|
|
209
|
+
const chunks = await collectChunks(
|
|
210
|
+
createAssistantStream((controller) => {
|
|
211
|
+
const tool = controller.addToolCallPart({
|
|
212
|
+
toolName: "search",
|
|
213
|
+
response: { result: "done" },
|
|
214
|
+
});
|
|
215
|
+
tool.close();
|
|
216
|
+
}),
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
expect(chunks.filter((c) => c.type === "result")).toHaveLength(1);
|
|
220
|
+
expect(chunks.filter((c) => c.type === "part-finish")).toHaveLength(1);
|
|
221
|
+
expect(chunks.at(-1)?.type).toBe("part-finish");
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
176
225
|
describe("AssistantStreamController withParentId", () => {
|
|
177
226
|
it("attaches parentId to text parts across a data-stream round trip", async () => {
|
|
178
227
|
const response = createAssistantStreamResponse((controller) => {
|
|
@@ -1,12 +1,28 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { createAssistantStreamController } from "./assistant-stream";
|
|
3
|
+
import { createToolCallStreamController } from "./tool-call";
|
|
3
4
|
import { ToolResponse } from "../tool/ToolResponse";
|
|
4
5
|
import { toolResultStream } from "../tool/toolResultStream";
|
|
5
6
|
import type { ToolCallReader } from "../tool/tool-types";
|
|
7
|
+
import type { AssistantStream } from "../AssistantStream";
|
|
6
8
|
import type { AssistantStreamChunk } from "../AssistantStreamChunk";
|
|
7
9
|
|
|
8
10
|
type Reader = ToolCallReader<Record<string, unknown>, unknown>;
|
|
9
11
|
|
|
12
|
+
const collectChunks = async (
|
|
13
|
+
stream: AssistantStream,
|
|
14
|
+
): Promise<AssistantStreamChunk[]> => {
|
|
15
|
+
const chunks: AssistantStreamChunk[] = [];
|
|
16
|
+
await stream.pipeTo(
|
|
17
|
+
new WritableStream({
|
|
18
|
+
write(chunk) {
|
|
19
|
+
chunks.push(chunk);
|
|
20
|
+
},
|
|
21
|
+
}),
|
|
22
|
+
);
|
|
23
|
+
return chunks;
|
|
24
|
+
};
|
|
25
|
+
|
|
10
26
|
describe("ToolCallStreamController", () => {
|
|
11
27
|
it("delivers a backend response before an args parse failure", async () => {
|
|
12
28
|
const [stream, controller] = createAssistantStreamController();
|
|
@@ -64,4 +80,38 @@ describe("ToolCallStreamController", () => {
|
|
|
64
80
|
isError: false,
|
|
65
81
|
});
|
|
66
82
|
});
|
|
83
|
+
|
|
84
|
+
it("setResponse settles the part without an explicit close", async () => {
|
|
85
|
+
const [stream, controller] = createToolCallStreamController();
|
|
86
|
+
controller.setResponse({ result: "done" });
|
|
87
|
+
|
|
88
|
+
const chunks = await collectChunks(stream);
|
|
89
|
+
|
|
90
|
+
expect(chunks.map((c) => c.type)).toContain("result");
|
|
91
|
+
expect(chunks.at(-1)?.type).toBe("part-finish");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("ignores a second setResponse after the part is settled", async () => {
|
|
95
|
+
const [stream, controller] = createToolCallStreamController();
|
|
96
|
+
controller.setResponse({ result: "first" });
|
|
97
|
+
controller.setResponse({ result: "second" });
|
|
98
|
+
|
|
99
|
+
const chunks = await collectChunks(stream);
|
|
100
|
+
|
|
101
|
+
const results = chunks.filter((c) => c.type === "result");
|
|
102
|
+
expect(results).toEqual([expect.objectContaining({ result: "first" })]);
|
|
103
|
+
expect(chunks.filter((c) => c.type === "part-finish")).toHaveLength(1);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("ignores setResponse after an explicit close", async () => {
|
|
107
|
+
const [stream, controller] = createToolCallStreamController();
|
|
108
|
+
controller.argsText.append('{"query":"x"}');
|
|
109
|
+
controller.close();
|
|
110
|
+
controller.setResponse({ result: "late" });
|
|
111
|
+
|
|
112
|
+
const chunks = await collectChunks(stream);
|
|
113
|
+
|
|
114
|
+
expect(chunks.filter((c) => c.type === "result")).toHaveLength(0);
|
|
115
|
+
expect(chunks.at(-1)?.type).toBe("part-finish");
|
|
116
|
+
});
|
|
67
117
|
});
|
|
@@ -8,6 +8,10 @@ import { createTextStream, type TextStreamController } from "./text";
|
|
|
8
8
|
export type ToolCallStreamController = {
|
|
9
9
|
argsText: TextStreamController;
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Sets the tool response and settles the part. The part closes automatically
|
|
13
|
+
* and subsequent calls are ignored.
|
|
14
|
+
*/
|
|
11
15
|
setResponse(response: ToolResponseLike<ReadonlyJSONValue>): void;
|
|
12
16
|
close(): void;
|
|
13
17
|
};
|
|
@@ -68,6 +72,8 @@ class ToolCallStreamControllerImpl implements ToolCallStreamController {
|
|
|
68
72
|
private _argsTextController!: TextStreamController;
|
|
69
73
|
|
|
70
74
|
async setResponse(response: ToolResponseLike<ReadonlyJSONValue>) {
|
|
75
|
+
if (this._isClosed) return;
|
|
76
|
+
|
|
71
77
|
this._controller.enqueue({
|
|
72
78
|
type: "result",
|
|
73
79
|
path: [],
|
|
@@ -83,9 +89,7 @@ class ToolCallStreamControllerImpl implements ToolCallStreamController {
|
|
|
83
89
|
? { messages: response.messages }
|
|
84
90
|
: {}),
|
|
85
91
|
});
|
|
86
|
-
this.
|
|
87
|
-
await Promise.resolve(); // flush microtask queue
|
|
88
|
-
// TODO switch argsTextController to be something that doesn'#t require this
|
|
92
|
+
await this.close();
|
|
89
93
|
}
|
|
90
94
|
|
|
91
95
|
async close() {
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { AssistantTransportDecoder } from "./AssistantTransport";
|
|
4
|
+
import { AssistantMessageAccumulator } from "../../accumulators/assistant-message-accumulator";
|
|
5
|
+
import type { AssistantStreamChunk } from "../../AssistantStreamChunk";
|
|
6
|
+
import type { AssistantMessage } from "../../utils/types";
|
|
7
|
+
|
|
8
|
+
// Fixture produced by the Python encoder; regenerate with
|
|
9
|
+
// `uv run python tests/generate_interop_fixture.py` in python/assistant-stream.
|
|
10
|
+
const fixture = readFileSync(
|
|
11
|
+
new URL("./__fixtures__/python-encoder.sse", import.meta.url),
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
const EXPECTED_CHUNKS: AssistantStreamChunk[] = [
|
|
15
|
+
{ type: "step-start", messageId: "msg_1", path: [] },
|
|
16
|
+
{ type: "part-start", part: { type: "reasoning" }, path: [] },
|
|
17
|
+
{ type: "text-delta", textDelta: "Let me check the weather.", path: [0] },
|
|
18
|
+
{ type: "part-finish", path: [0] },
|
|
19
|
+
{ type: "part-start", part: { type: "text" }, path: [] },
|
|
20
|
+
{ type: "text-delta", textDelta: "Checking", path: [1] },
|
|
21
|
+
{ type: "text-delta", textDelta: " now.", path: [1] },
|
|
22
|
+
{ type: "part-finish", path: [1] },
|
|
23
|
+
{
|
|
24
|
+
type: "part-start",
|
|
25
|
+
part: { type: "tool-call", toolCallId: "call_1", toolName: "get_weather" },
|
|
26
|
+
path: [],
|
|
27
|
+
},
|
|
28
|
+
{ type: "text-delta", textDelta: '{"city": ', path: [2] },
|
|
29
|
+
{ type: "text-delta", textDelta: '"NYC"}', path: [2] },
|
|
30
|
+
{
|
|
31
|
+
type: "update-state",
|
|
32
|
+
operations: [{ type: "set", path: ["status"], value: "running" }],
|
|
33
|
+
path: [],
|
|
34
|
+
},
|
|
35
|
+
{ type: "result", result: { temp: 70 }, isError: false, path: [2] },
|
|
36
|
+
{ type: "tool-call-args-text-finish", path: [2] },
|
|
37
|
+
{ type: "part-finish", path: [2] },
|
|
38
|
+
{ type: "data", data: [{ progress: 1 }], path: [] },
|
|
39
|
+
{
|
|
40
|
+
type: "annotations",
|
|
41
|
+
annotations: [{ type: "citation", id: "a1" }],
|
|
42
|
+
path: [],
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
type: "part-start",
|
|
46
|
+
part: {
|
|
47
|
+
type: "source",
|
|
48
|
+
sourceType: "url",
|
|
49
|
+
id: "s1",
|
|
50
|
+
url: "https://example.com",
|
|
51
|
+
title: "Example",
|
|
52
|
+
},
|
|
53
|
+
path: [],
|
|
54
|
+
},
|
|
55
|
+
{ type: "part-finish", path: [3] },
|
|
56
|
+
{ type: "part-start", part: { type: "text" }, path: [] },
|
|
57
|
+
{ type: "text-delta", textDelta: "It is sunny.", path: [4] },
|
|
58
|
+
{ type: "part-finish", path: [4] },
|
|
59
|
+
{
|
|
60
|
+
type: "part-start",
|
|
61
|
+
part: { type: "file", data: "aGVsbG8=", mimeType: "image/png" },
|
|
62
|
+
path: [],
|
|
63
|
+
},
|
|
64
|
+
{ type: "part-finish", path: [5] },
|
|
65
|
+
{
|
|
66
|
+
type: "step-finish",
|
|
67
|
+
finishReason: "stop",
|
|
68
|
+
usage: { inputTokens: 12, outputTokens: 34 },
|
|
69
|
+
isContinued: false,
|
|
70
|
+
path: [],
|
|
71
|
+
},
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
const decodeFixture = async (chunkSize: number) => {
|
|
75
|
+
const bytes = new Uint8Array(fixture);
|
|
76
|
+
const stream = new ReadableStream<Uint8Array<ArrayBuffer>>({
|
|
77
|
+
start(controller) {
|
|
78
|
+
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
|
|
79
|
+
controller.enqueue(
|
|
80
|
+
new Uint8Array(bytes.slice(offset, offset + chunkSize)),
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
controller.close();
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const chunks: AssistantStreamChunk[] = [];
|
|
88
|
+
const reader = stream
|
|
89
|
+
.pipeThrough(new AssistantTransportDecoder())
|
|
90
|
+
.getReader();
|
|
91
|
+
while (true) {
|
|
92
|
+
const { done, value } = await reader.read();
|
|
93
|
+
if (done) break;
|
|
94
|
+
chunks.push(value);
|
|
95
|
+
}
|
|
96
|
+
return chunks;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
describe("Python encoder interop", () => {
|
|
100
|
+
it("decodes the captured Python encoder output into canonical chunks", async () => {
|
|
101
|
+
expect(await decodeFixture(fixture.length)).toEqual(EXPECTED_CHUNKS);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("decodes the fixture identically under network fragmentation", async () => {
|
|
105
|
+
expect(await decodeFixture(5)).toEqual(EXPECTED_CHUNKS);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("accumulates the decoded fixture into a canonical message", async () => {
|
|
109
|
+
const decoded = await decodeFixture(fixture.length);
|
|
110
|
+
const source = new ReadableStream<AssistantStreamChunk>({
|
|
111
|
+
start(controller) {
|
|
112
|
+
for (const chunk of decoded) {
|
|
113
|
+
controller.enqueue(chunk);
|
|
114
|
+
}
|
|
115
|
+
controller.close();
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const messages: AssistantMessage[] = [];
|
|
120
|
+
await source.pipeThrough(new AssistantMessageAccumulator()).pipeTo(
|
|
121
|
+
new WritableStream({
|
|
122
|
+
write(message) {
|
|
123
|
+
messages.push(message);
|
|
124
|
+
},
|
|
125
|
+
}),
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
const message = messages.at(-1)!;
|
|
129
|
+
expect(message.parts).toHaveLength(6);
|
|
130
|
+
expect(message.parts[0]).toMatchObject({
|
|
131
|
+
type: "reasoning",
|
|
132
|
+
text: "Let me check the weather.",
|
|
133
|
+
});
|
|
134
|
+
expect(message.parts[1]).toMatchObject({
|
|
135
|
+
type: "text",
|
|
136
|
+
text: "Checking now.",
|
|
137
|
+
});
|
|
138
|
+
expect(message.parts[2]).toMatchObject({
|
|
139
|
+
type: "tool-call",
|
|
140
|
+
toolCallId: "call_1",
|
|
141
|
+
toolName: "get_weather",
|
|
142
|
+
state: "result",
|
|
143
|
+
argsText: '{"city": "NYC"}',
|
|
144
|
+
args: { city: "NYC" },
|
|
145
|
+
result: { temp: 70 },
|
|
146
|
+
isError: false,
|
|
147
|
+
});
|
|
148
|
+
expect(message.parts[3]).toMatchObject({
|
|
149
|
+
type: "source",
|
|
150
|
+
sourceType: "url",
|
|
151
|
+
id: "s1",
|
|
152
|
+
url: "https://example.com",
|
|
153
|
+
title: "Example",
|
|
154
|
+
});
|
|
155
|
+
expect(message.parts[4]).toMatchObject({
|
|
156
|
+
type: "text",
|
|
157
|
+
text: "It is sunny.",
|
|
158
|
+
});
|
|
159
|
+
expect(message.parts[5]).toMatchObject({
|
|
160
|
+
type: "file",
|
|
161
|
+
mimeType: "image/png",
|
|
162
|
+
data: "aGVsbG8=",
|
|
163
|
+
});
|
|
164
|
+
expect(message.metadata.unstable_state).toEqual({ status: "running" });
|
|
165
|
+
expect(message.metadata.unstable_data).toEqual([{ progress: 1 }]);
|
|
166
|
+
expect(message.metadata.unstable_annotations).toEqual([
|
|
167
|
+
{ type: "citation", id: "a1" },
|
|
168
|
+
]);
|
|
169
|
+
expect(message.metadata.steps).toEqual([
|
|
170
|
+
{
|
|
171
|
+
state: "finished",
|
|
172
|
+
messageId: "msg_1",
|
|
173
|
+
finishReason: "stop",
|
|
174
|
+
usage: { inputTokens: 12, outputTokens: 34 },
|
|
175
|
+
isContinued: false,
|
|
176
|
+
},
|
|
177
|
+
]);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
data: {"type": "step-start", "messageId": "msg_1"}
|
|
2
|
+
|
|
3
|
+
data: {"type": "part-start", "part": {"type": "reasoning"}, "path": []}
|
|
4
|
+
|
|
5
|
+
data: {"type": "text-delta", "textDelta": "Let me check the weather.", "path": [0]}
|
|
6
|
+
|
|
7
|
+
data: {"type": "part-finish", "path": [0]}
|
|
8
|
+
|
|
9
|
+
data: {"type": "part-start", "part": {"type": "text"}, "path": []}
|
|
10
|
+
|
|
11
|
+
data: {"type": "text-delta", "textDelta": "Checking", "path": [1]}
|
|
12
|
+
|
|
13
|
+
data: {"type": "text-delta", "textDelta": " now.", "path": [1]}
|
|
14
|
+
|
|
15
|
+
data: {"type": "part-finish", "path": [1]}
|
|
16
|
+
|
|
17
|
+
data: {"type": "part-start", "part": {"type": "tool-call", "toolCallId": "call_1", "toolName": "get_weather"}, "path": []}
|
|
18
|
+
|
|
19
|
+
data: {"type": "text-delta", "textDelta": "{\"city\": ", "path": [2]}
|
|
20
|
+
|
|
21
|
+
data: {"type": "text-delta", "textDelta": "\"NYC\"}", "path": [2]}
|
|
22
|
+
|
|
23
|
+
data: {"type": "update-state", "operations": [{"type": "set", "path": ["status"], "value": "running"}]}
|
|
24
|
+
|
|
25
|
+
data: {"type": "result", "result": {"temp": 70}, "isError": false, "path": [2]}
|
|
26
|
+
|
|
27
|
+
data: {"type": "tool-call-args-text-finish", "path": [2]}
|
|
28
|
+
|
|
29
|
+
data: {"type": "part-finish", "path": [2]}
|
|
30
|
+
|
|
31
|
+
data: {"type": "data", "data": [{"progress": 1}]}
|
|
32
|
+
|
|
33
|
+
data: {"type": "annotations", "annotations": [{"type": "citation", "id": "a1"}]}
|
|
34
|
+
|
|
35
|
+
data: {"type": "part-start", "part": {"type": "source", "sourceType": "url", "id": "s1", "url": "https://example.com", "title": "Example"}, "path": []}
|
|
36
|
+
|
|
37
|
+
data: {"type": "part-finish", "path": [3]}
|
|
38
|
+
|
|
39
|
+
data: {"type": "part-start", "part": {"type": "text"}, "path": []}
|
|
40
|
+
|
|
41
|
+
data: {"type": "text-delta", "textDelta": "It is sunny.", "path": [4]}
|
|
42
|
+
|
|
43
|
+
data: {"type": "part-finish", "path": [4]}
|
|
44
|
+
|
|
45
|
+
data: {"type": "part-start", "part": {"type": "file", "data": "aGVsbG8=", "mimeType": "image/png"}, "path": []}
|
|
46
|
+
|
|
47
|
+
data: {"type": "part-finish", "path": [5]}
|
|
48
|
+
|
|
49
|
+
data: {"type": "step-finish", "finishReason": "stop", "usage": {"inputTokens": 12, "outputTokens": 34}, "isContinued": false}
|
|
50
|
+
|
|
51
|
+
data: [DONE]
|
|
52
|
+
|
|
@@ -676,6 +676,47 @@ describe("UIMessageStreamDecoder", () => {
|
|
|
676
676
|
expect(chunks.some((c) => c.type === "result")).toBe(true);
|
|
677
677
|
});
|
|
678
678
|
|
|
679
|
+
it("settles the tool part at result time instead of decoder flush", async () => {
|
|
680
|
+
const events = [
|
|
681
|
+
JSON.stringify({ type: "start", messageId: "msg_123" }),
|
|
682
|
+
JSON.stringify({
|
|
683
|
+
type: "tool-call-start",
|
|
684
|
+
toolCallId: "call_abc",
|
|
685
|
+
toolName: "weather",
|
|
686
|
+
}),
|
|
687
|
+
JSON.stringify({ type: "tool-call-delta", argsText: '{"city":"NYC"}' }),
|
|
688
|
+
JSON.stringify({ type: "tool-call-end" }),
|
|
689
|
+
JSON.stringify({
|
|
690
|
+
type: "tool-result",
|
|
691
|
+
toolCallId: "call_abc",
|
|
692
|
+
result: { temp: 72 },
|
|
693
|
+
}),
|
|
694
|
+
JSON.stringify({ type: "text-start", id: "text_1" }),
|
|
695
|
+
JSON.stringify({ type: "text-delta", id: "text_1", delta: "Hello" }),
|
|
696
|
+
JSON.stringify({ type: "text-end" }),
|
|
697
|
+
JSON.stringify({
|
|
698
|
+
type: "finish",
|
|
699
|
+
finishReason: "stop",
|
|
700
|
+
usage: { inputTokens: 10, outputTokens: 5 },
|
|
701
|
+
}),
|
|
702
|
+
"[DONE]",
|
|
703
|
+
];
|
|
704
|
+
|
|
705
|
+
const chunks = await collectChunks(
|
|
706
|
+
createUIMessageStream(events).pipeThrough(new UIMessageStreamDecoder()),
|
|
707
|
+
);
|
|
708
|
+
|
|
709
|
+
const toolPartFinish = chunks.findIndex(
|
|
710
|
+
(c) => c.type === "part-finish" && c.path[0] === 0,
|
|
711
|
+
);
|
|
712
|
+
const result = chunks.findIndex((c) => c.type === "result");
|
|
713
|
+
const messageFinish = chunks.findIndex((c) => c.type === "message-finish");
|
|
714
|
+
expect(result).toBeGreaterThan(-1);
|
|
715
|
+
expect(messageFinish).toBeGreaterThan(-1);
|
|
716
|
+
expect(toolPartFinish).toBeGreaterThan(result);
|
|
717
|
+
expect(toolPartFinish).toBeLessThan(messageFinish);
|
|
718
|
+
});
|
|
719
|
+
|
|
679
720
|
it("keeps the active tool call writable when another call receives its result", async () => {
|
|
680
721
|
const events = [
|
|
681
722
|
JSON.stringify({
|