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.
@@ -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;EAEV,YAAY,UAAU,iBAAiB;EACvC;;cA8FW,uBACX,UAAU,mBAAmB,8BAC5B;cAcU,gDAA8B,iBAAA"}
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._argsTextController.close();
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._argsTextController.close();\n await Promise.resolve(); // flush microtask queue\n // TODO switch argsTextController to be something that doesn'#t require this\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":";;AAcA,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,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,KAAK,oBAAoB,MAAM;EAC/B,MAAM,QAAQ,QAAQ;CAExB;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"}
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "assistant-stream",
3
- "version": "0.3.29",
3
+ "version": "0.3.30",
4
4
  "description": "Streaming utilities for AI assistants",
5
5
  "keywords": [
6
6
  "ai",
@@ -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._argsTextController.close();
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({