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,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
2
|
import { AssistantMessageAccumulator } from "./assistant-message-accumulator";
|
|
3
3
|
import type { AssistantStreamChunk } from "../AssistantStreamChunk";
|
|
4
4
|
import type { AssistantMessage } from "../utils/types";
|
|
@@ -224,6 +224,88 @@ describe("AssistantMessageAccumulator timing", () => {
|
|
|
224
224
|
expect(last.metadata.timing).toBeDefined();
|
|
225
225
|
expect(last.metadata.timing!.firstTokenTime).toBeTypeOf("number");
|
|
226
226
|
});
|
|
227
|
+
|
|
228
|
+
it("does not record firstTokenTime when a text-delta is dropped for an out-of-range path", async () => {
|
|
229
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
230
|
+
const messages = await collectStream([
|
|
231
|
+
{ type: "part-start", path: [0], part: { type: "text" } },
|
|
232
|
+
{ type: "text-delta", path: [5], textDelta: "x" },
|
|
233
|
+
]);
|
|
234
|
+
const last = messages.at(-1)!;
|
|
235
|
+
|
|
236
|
+
expect(last.metadata.timing).toBeDefined();
|
|
237
|
+
expect(last.metadata.timing!.firstTokenTime).toBeUndefined();
|
|
238
|
+
warn.mockRestore();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("does not record firstTokenTime when a text-delta is dropped for a nested path", async () => {
|
|
242
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
243
|
+
const messages = await collectStream([
|
|
244
|
+
{ type: "part-start", path: [0], part: { type: "text" } },
|
|
245
|
+
{ type: "text-delta", path: [0, 1], textDelta: "x" },
|
|
246
|
+
]);
|
|
247
|
+
const last = messages.at(-1)!;
|
|
248
|
+
|
|
249
|
+
expect(last.metadata.timing).toBeDefined();
|
|
250
|
+
expect(last.metadata.timing!.firstTokenTime).toBeUndefined();
|
|
251
|
+
warn.mockRestore();
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("does not record firstTokenTime when a text-delta is dropped for a wrong part type", async () => {
|
|
255
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
256
|
+
const messages = await collectStream([
|
|
257
|
+
{
|
|
258
|
+
type: "part-start",
|
|
259
|
+
path: [0],
|
|
260
|
+
part: {
|
|
261
|
+
type: "source",
|
|
262
|
+
sourceType: "url",
|
|
263
|
+
id: "s1",
|
|
264
|
+
url: "https://example.com",
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
{ type: "text-delta", path: [0], textDelta: "x" },
|
|
268
|
+
]);
|
|
269
|
+
const last = messages.at(-1)!;
|
|
270
|
+
|
|
271
|
+
expect(last.metadata.timing).toBeDefined();
|
|
272
|
+
expect(last.metadata.timing!.firstTokenTime).toBeUndefined();
|
|
273
|
+
warn.mockRestore();
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("records firstTokenTime on the first applied text-delta, not a preceding dropped one", async () => {
|
|
277
|
+
vi.useFakeTimers();
|
|
278
|
+
vi.setSystemTime(1000);
|
|
279
|
+
|
|
280
|
+
const accumulator = new AssistantMessageAccumulator();
|
|
281
|
+
const writer = accumulator.writable.getWriter();
|
|
282
|
+
const messages: AssistantMessage[] = [];
|
|
283
|
+
const reading = (async () => {
|
|
284
|
+
const reader = accumulator.readable.getReader();
|
|
285
|
+
for (;;) {
|
|
286
|
+
const { done, value } = await reader.read();
|
|
287
|
+
if (done) break;
|
|
288
|
+
messages.push(value);
|
|
289
|
+
}
|
|
290
|
+
})();
|
|
291
|
+
|
|
292
|
+
await writer.write({
|
|
293
|
+
type: "part-start",
|
|
294
|
+
path: [0],
|
|
295
|
+
part: { type: "text" },
|
|
296
|
+
});
|
|
297
|
+
vi.setSystemTime(2000);
|
|
298
|
+
await writer.write({ type: "text-delta", path: [5], textDelta: "x" });
|
|
299
|
+
vi.setSystemTime(3000);
|
|
300
|
+
await writer.write({ type: "text-delta", path: [0], textDelta: "hi" });
|
|
301
|
+
await writer.close();
|
|
302
|
+
await reading;
|
|
303
|
+
vi.useRealTimers();
|
|
304
|
+
|
|
305
|
+
const last = messages.at(-1)!;
|
|
306
|
+
expect(last.metadata.timing).toBeDefined();
|
|
307
|
+
expect(last.metadata.timing!.firstTokenTime).toBe(2000);
|
|
308
|
+
});
|
|
227
309
|
});
|
|
228
310
|
|
|
229
311
|
describe("AssistantMessageAccumulator error chunks", () => {
|
|
@@ -240,6 +322,19 @@ describe("AssistantMessageAccumulator error chunks", () => {
|
|
|
240
322
|
});
|
|
241
323
|
});
|
|
242
324
|
|
|
325
|
+
it("defaults a missing error message", async () => {
|
|
326
|
+
const messages = await collectStream([
|
|
327
|
+
{ type: "error", path: [] } as unknown as AssistantStreamChunk,
|
|
328
|
+
]);
|
|
329
|
+
const last = messages.at(-1)!;
|
|
330
|
+
|
|
331
|
+
expect(last.status).toEqual({
|
|
332
|
+
type: "incomplete",
|
|
333
|
+
reason: "error",
|
|
334
|
+
error: { code: "unknown", message: "unknown error" },
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
|
|
243
338
|
it("carries code and severity through unchanged", async () => {
|
|
244
339
|
const messages = await collectStream([
|
|
245
340
|
{
|
|
@@ -288,3 +383,281 @@ describe("AssistantMessageAccumulator error chunks", () => {
|
|
|
288
383
|
});
|
|
289
384
|
});
|
|
290
385
|
});
|
|
386
|
+
|
|
387
|
+
describe("AssistantMessageAccumulator part path bounds", () => {
|
|
388
|
+
it("drops a text-delta whose path is out of range with a warning", async () => {
|
|
389
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
390
|
+
const messages = await collectStream([
|
|
391
|
+
{ type: "part-start", path: [0], part: { type: "text" } },
|
|
392
|
+
{ type: "text-delta", path: [5], textDelta: "x" },
|
|
393
|
+
{ type: "text-delta", path: [0], textDelta: "hi" },
|
|
394
|
+
]);
|
|
395
|
+
const last = messages.at(-1)!;
|
|
396
|
+
|
|
397
|
+
expect(last.parts).toHaveLength(1);
|
|
398
|
+
expect(last.parts[0]).toMatchObject({ type: "text", text: "hi" });
|
|
399
|
+
expect(warn).toHaveBeenCalledWith(
|
|
400
|
+
"Dropped text-delta chunk: no part at path [5]",
|
|
401
|
+
);
|
|
402
|
+
warn.mockRestore();
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it("drops an out-of-range part-finish instead of fabricating a part", async () => {
|
|
406
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
407
|
+
const messages = await collectStream([
|
|
408
|
+
{ type: "part-start", path: [0], part: { type: "text" } },
|
|
409
|
+
{ type: "part-finish", path: [3] },
|
|
410
|
+
]);
|
|
411
|
+
const last = messages.at(-1)!;
|
|
412
|
+
|
|
413
|
+
expect(last.parts).toHaveLength(1);
|
|
414
|
+
expect(last.parts[0]!.type).toBe("text");
|
|
415
|
+
warn.mockRestore();
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it("drops part chunks when no parts exist", async () => {
|
|
419
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
420
|
+
const messages = await collectStream([
|
|
421
|
+
{ type: "text-delta", path: [0], textDelta: "x" },
|
|
422
|
+
{ type: "part-start", path: [0], part: { type: "text" } },
|
|
423
|
+
{ type: "text-delta", path: [0], textDelta: "ok" },
|
|
424
|
+
]);
|
|
425
|
+
const last = messages.at(-1)!;
|
|
426
|
+
|
|
427
|
+
expect(last.parts).toHaveLength(1);
|
|
428
|
+
expect(last.parts[0]).toMatchObject({ type: "text", text: "ok" });
|
|
429
|
+
warn.mockRestore();
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it("drops chunks with nested paths", async () => {
|
|
433
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
434
|
+
const messages = await collectStream([
|
|
435
|
+
{ type: "part-start", path: [0], part: { type: "text" } },
|
|
436
|
+
{ type: "text-delta", path: [0, 1], textDelta: "x" },
|
|
437
|
+
{ type: "text-delta", path: [0], textDelta: "ok" },
|
|
438
|
+
]);
|
|
439
|
+
const last = messages.at(-1)!;
|
|
440
|
+
|
|
441
|
+
expect(last.parts).toHaveLength(1);
|
|
442
|
+
expect(last.parts[0]).toMatchObject({ type: "text", text: "ok" });
|
|
443
|
+
warn.mockRestore();
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it("drops an out-of-range result without touching existing tool calls", async () => {
|
|
447
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
448
|
+
const messages = await collectStream([
|
|
449
|
+
{
|
|
450
|
+
type: "part-start",
|
|
451
|
+
path: [0],
|
|
452
|
+
part: { type: "tool-call", toolCallId: "t1", toolName: "f" },
|
|
453
|
+
},
|
|
454
|
+
{ type: "text-delta", path: [0], textDelta: "{}" },
|
|
455
|
+
{ type: "result", path: [7], result: { ok: true }, isError: false },
|
|
456
|
+
{ type: "result", path: [0], result: { ok: true }, isError: false },
|
|
457
|
+
]);
|
|
458
|
+
const last = messages.at(-1)!;
|
|
459
|
+
|
|
460
|
+
expect(last.parts).toHaveLength(1);
|
|
461
|
+
expect(last.parts[0]).toMatchObject({
|
|
462
|
+
type: "tool-call",
|
|
463
|
+
state: "result",
|
|
464
|
+
result: { ok: true },
|
|
465
|
+
});
|
|
466
|
+
warn.mockRestore();
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
describe("AssistantMessageAccumulator wrong part type chunks", () => {
|
|
471
|
+
const sourcePart = {
|
|
472
|
+
type: "source",
|
|
473
|
+
sourceType: "url",
|
|
474
|
+
id: "s1",
|
|
475
|
+
url: "https://example.com",
|
|
476
|
+
} as const;
|
|
477
|
+
|
|
478
|
+
it("drops a text-delta addressed to a source part", async () => {
|
|
479
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
480
|
+
const messages = await collectStream([
|
|
481
|
+
{ type: "part-start", path: [0], part: sourcePart },
|
|
482
|
+
{ type: "text-delta", path: [0], textDelta: "x" },
|
|
483
|
+
]);
|
|
484
|
+
const last = messages.at(-1)!;
|
|
485
|
+
|
|
486
|
+
expect(last.parts[0]).toMatchObject({ type: "source", id: "s1" });
|
|
487
|
+
expect(warn).toHaveBeenCalledWith(
|
|
488
|
+
"Dropped text-delta chunk: part is neither text nor tool-call",
|
|
489
|
+
);
|
|
490
|
+
warn.mockRestore();
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it("drops a result addressed to a text part", async () => {
|
|
494
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
495
|
+
const messages = await collectStream([
|
|
496
|
+
{ type: "part-start", path: [0], part: { type: "text" } },
|
|
497
|
+
{ type: "text-delta", path: [0], textDelta: "hi" },
|
|
498
|
+
{ type: "result", path: [0], result: { ok: true }, isError: false },
|
|
499
|
+
]);
|
|
500
|
+
const last = messages.at(-1)!;
|
|
501
|
+
|
|
502
|
+
expect(last.parts[0]).toMatchObject({ type: "text", text: "hi" });
|
|
503
|
+
expect(warn).toHaveBeenCalledWith(
|
|
504
|
+
"Dropped result chunk: part is not a tool-call",
|
|
505
|
+
);
|
|
506
|
+
warn.mockRestore();
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it("drops an args-text-finish addressed to a text part", async () => {
|
|
510
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
511
|
+
const messages = await collectStream([
|
|
512
|
+
{ type: "part-start", path: [0], part: { type: "text" } },
|
|
513
|
+
{ type: "tool-call-args-text-finish", path: [0] },
|
|
514
|
+
]);
|
|
515
|
+
const last = messages.at(-1)!;
|
|
516
|
+
|
|
517
|
+
expect(last.parts[0]).toMatchObject({ type: "text" });
|
|
518
|
+
expect(warn).toHaveBeenCalledWith(
|
|
519
|
+
"Dropped tool-call-args-text-finish chunk: part is not a tool-call",
|
|
520
|
+
);
|
|
521
|
+
warn.mockRestore();
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
it("inserts a placeholder for an unsupported part-start so later part indices stay aligned", async () => {
|
|
525
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
526
|
+
const messages = await collectStream([
|
|
527
|
+
{
|
|
528
|
+
type: "part-start",
|
|
529
|
+
path: [0],
|
|
530
|
+
part: { type: "bogus" },
|
|
531
|
+
} as unknown as AssistantStreamChunk,
|
|
532
|
+
{ type: "part-start", path: [1], part: { type: "text" } },
|
|
533
|
+
{ type: "text-delta", path: [1], textDelta: "ok" },
|
|
534
|
+
{ type: "part-finish", path: [0] },
|
|
535
|
+
]);
|
|
536
|
+
const last = messages.at(-1)!;
|
|
537
|
+
|
|
538
|
+
expect(last.parts).toHaveLength(2);
|
|
539
|
+
expect(last.parts[0]).toMatchObject({
|
|
540
|
+
type: "reasoning",
|
|
541
|
+
text: "",
|
|
542
|
+
status: { type: "complete", reason: "unknown" },
|
|
543
|
+
});
|
|
544
|
+
expect(last.parts[1]).toMatchObject({ type: "text", text: "ok" });
|
|
545
|
+
expect(warn).toHaveBeenCalledWith(
|
|
546
|
+
"Unsupported part-start type bogus: inserting an empty reasoning part to preserve part indices",
|
|
547
|
+
);
|
|
548
|
+
warn.mockRestore();
|
|
549
|
+
});
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
describe("AssistantMessageAccumulator warn dedup", () => {
|
|
553
|
+
it("warns once per drop class per instance", async () => {
|
|
554
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
555
|
+
await collectStream([
|
|
556
|
+
{ type: "part-start", path: [0], part: { type: "text" } },
|
|
557
|
+
{ type: "text-delta", path: [5], textDelta: "a" },
|
|
558
|
+
{ type: "text-delta", path: [5], textDelta: "b" },
|
|
559
|
+
{ type: "text-delta", path: [6], textDelta: "c" },
|
|
560
|
+
{ type: "part-finish", path: [9] },
|
|
561
|
+
]);
|
|
562
|
+
|
|
563
|
+
expect(warn).toHaveBeenCalledTimes(2);
|
|
564
|
+
expect(warn).toHaveBeenCalledWith(
|
|
565
|
+
"Dropped text-delta chunk: no part at path [5]",
|
|
566
|
+
);
|
|
567
|
+
expect(warn).toHaveBeenCalledWith(
|
|
568
|
+
"Dropped part-finish chunk: no part at path [9]",
|
|
569
|
+
);
|
|
570
|
+
warn.mockRestore();
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
it("resets dedup per accumulator instance", async () => {
|
|
574
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
575
|
+
const stream = [
|
|
576
|
+
{ type: "part-start", path: [0], part: { type: "text" } },
|
|
577
|
+
{ type: "text-delta", path: [5], textDelta: "x" },
|
|
578
|
+
] satisfies AssistantStreamChunk[];
|
|
579
|
+
await collectStream(stream);
|
|
580
|
+
await collectStream(stream);
|
|
581
|
+
|
|
582
|
+
expect(warn).toHaveBeenCalledTimes(2);
|
|
583
|
+
warn.mockRestore();
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
it("dedupes repeated wrong-part-type drops", async () => {
|
|
587
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
588
|
+
await collectStream([
|
|
589
|
+
{
|
|
590
|
+
type: "part-start",
|
|
591
|
+
path: [0],
|
|
592
|
+
part: { type: "source", sourceType: "url", id: "s", url: "https://x" },
|
|
593
|
+
},
|
|
594
|
+
{ type: "text-delta", path: [0], textDelta: "a" },
|
|
595
|
+
{ type: "text-delta", path: [0], textDelta: "b" },
|
|
596
|
+
]);
|
|
597
|
+
|
|
598
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
599
|
+
warn.mockRestore();
|
|
600
|
+
});
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
describe("AssistantMessageAccumulator warn dedup key independence", () => {
|
|
604
|
+
it("keeps drop classes as independent keys within one stream", async () => {
|
|
605
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
606
|
+
await collectStream([
|
|
607
|
+
{
|
|
608
|
+
type: "part-start",
|
|
609
|
+
path: [0],
|
|
610
|
+
part: { type: "source", sourceType: "url", id: "s", url: "https://x" },
|
|
611
|
+
},
|
|
612
|
+
{ type: "text-delta", path: [0], textDelta: "wrong-part" },
|
|
613
|
+
{ type: "text-delta", path: [7], textDelta: "no-part" },
|
|
614
|
+
]);
|
|
615
|
+
|
|
616
|
+
expect(warn).toHaveBeenCalledTimes(2);
|
|
617
|
+
warn.mockRestore();
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
it("keys unsupported part-starts per type", async () => {
|
|
621
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
622
|
+
const messages = await collectStream([
|
|
623
|
+
{
|
|
624
|
+
type: "part-start",
|
|
625
|
+
path: [0],
|
|
626
|
+
part: { type: "bogus" },
|
|
627
|
+
} as unknown as AssistantStreamChunk,
|
|
628
|
+
{
|
|
629
|
+
type: "part-start",
|
|
630
|
+
path: [1],
|
|
631
|
+
part: { type: "bogus" },
|
|
632
|
+
} as unknown as AssistantStreamChunk,
|
|
633
|
+
{
|
|
634
|
+
type: "part-start",
|
|
635
|
+
path: [2],
|
|
636
|
+
part: { type: "video" },
|
|
637
|
+
} as unknown as AssistantStreamChunk,
|
|
638
|
+
]);
|
|
639
|
+
const last = messages.at(-1)!;
|
|
640
|
+
|
|
641
|
+
expect(last.parts).toHaveLength(3);
|
|
642
|
+
expect(warn).toHaveBeenCalledTimes(2);
|
|
643
|
+
warn.mockRestore();
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
it("caps the number of warned keys per instance", async () => {
|
|
647
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
648
|
+
await collectStream(
|
|
649
|
+
Array.from(
|
|
650
|
+
{ length: 20 },
|
|
651
|
+
(_, i) =>
|
|
652
|
+
({
|
|
653
|
+
type: "part-start",
|
|
654
|
+
path: [i],
|
|
655
|
+
part: { type: `bogus-${i}` },
|
|
656
|
+
}) as unknown as AssistantStreamChunk,
|
|
657
|
+
),
|
|
658
|
+
);
|
|
659
|
+
|
|
660
|
+
expect(warn).toHaveBeenCalledTimes(16);
|
|
661
|
+
warn.mockRestore();
|
|
662
|
+
});
|
|
663
|
+
});
|
|
@@ -13,7 +13,7 @@ import type {
|
|
|
13
13
|
FilePart,
|
|
14
14
|
DataPart,
|
|
15
15
|
} from "../utils/types";
|
|
16
|
-
import {
|
|
16
|
+
import { GorpStreamAccumulator } from "../gorp/GorpStreamAccumulator";
|
|
17
17
|
import type { ReadonlyJSONValue } from "../../utils";
|
|
18
18
|
import { TimingTracker } from "./TimingTracker";
|
|
19
19
|
|
|
@@ -37,20 +37,29 @@ export const createInitialMessage = ({
|
|
|
37
37
|
},
|
|
38
38
|
});
|
|
39
39
|
|
|
40
|
+
type WarnOnce = (key: string, message: string) => void;
|
|
41
|
+
|
|
42
|
+
const MAX_WARNED_KEYS = 16;
|
|
43
|
+
|
|
40
44
|
const updatePartForPath = (
|
|
41
45
|
message: AssistantMessage,
|
|
42
46
|
chunk: AssistantStreamChunk,
|
|
47
|
+
warnOnce: WarnOnce,
|
|
43
48
|
updater: (part: AssistantMessagePart) => AssistantMessagePart,
|
|
44
49
|
): AssistantMessage => {
|
|
45
|
-
|
|
46
|
-
|
|
50
|
+
const part =
|
|
51
|
+
chunk.path.length === 1 ? message.parts[chunk.path[0]!] : undefined;
|
|
52
|
+
if (part === undefined) {
|
|
53
|
+
warnOnce(
|
|
54
|
+
`no-part:${chunk.type}`,
|
|
55
|
+
`Dropped ${chunk.type} chunk: no part at path [${chunk.path.join(", ")}]`,
|
|
56
|
+
);
|
|
57
|
+
return message;
|
|
47
58
|
}
|
|
48
59
|
|
|
49
|
-
if (chunk.path.length !== 1)
|
|
50
|
-
throw new Error("Nested paths are not supported yet.");
|
|
51
|
-
|
|
52
60
|
const partIndex = chunk.path[0]!;
|
|
53
|
-
const updatedPart = updater(
|
|
61
|
+
const updatedPart = updater(part);
|
|
62
|
+
if (updatedPart === part) return message;
|
|
54
63
|
return {
|
|
55
64
|
...message,
|
|
56
65
|
parts: [
|
|
@@ -67,6 +76,7 @@ const updatePartForPath = (
|
|
|
67
76
|
const handlePartStart = (
|
|
68
77
|
message: AssistantMessage,
|
|
69
78
|
chunk: AssistantStreamChunk & { readonly type: "part-start" },
|
|
79
|
+
warnOnce: WarnOnce,
|
|
70
80
|
): AssistantMessage => {
|
|
71
81
|
const partInit = chunk.part;
|
|
72
82
|
if (partInit.type === "text" || partInit.type === "reasoning") {
|
|
@@ -147,7 +157,24 @@ const handlePartStart = (
|
|
|
147
157
|
},
|
|
148
158
|
};
|
|
149
159
|
} else {
|
|
150
|
-
|
|
160
|
+
const unsupportedType = String((partInit as { type?: unknown }).type);
|
|
161
|
+
warnOnce(
|
|
162
|
+
`unsupported-part-start:${unsupportedType}`,
|
|
163
|
+
`Unsupported part-start type ${unsupportedType}: inserting an empty reasoning part to preserve part indices`,
|
|
164
|
+
);
|
|
165
|
+
// reasoning rather than a data sentinel: auiV0Encode rejects data parts, so a data placeholder would fail cloud persistence
|
|
166
|
+
const placeholderPart: ReasoningPart = {
|
|
167
|
+
type: "reasoning",
|
|
168
|
+
text: "",
|
|
169
|
+
status: { type: "running" },
|
|
170
|
+
};
|
|
171
|
+
return {
|
|
172
|
+
...message,
|
|
173
|
+
parts: [...message.parts, placeholderPart],
|
|
174
|
+
get content() {
|
|
175
|
+
return this.parts;
|
|
176
|
+
},
|
|
177
|
+
};
|
|
151
178
|
}
|
|
152
179
|
};
|
|
153
180
|
|
|
@@ -156,14 +183,19 @@ const handleToolCallArgsTextFinish = (
|
|
|
156
183
|
chunk: AssistantStreamChunk & {
|
|
157
184
|
readonly type: "tool-call-args-text-finish";
|
|
158
185
|
},
|
|
186
|
+
warnOnce: WarnOnce,
|
|
159
187
|
): AssistantMessage => {
|
|
160
|
-
return updatePartForPath(message, chunk, (part) => {
|
|
188
|
+
return updatePartForPath(message, chunk, warnOnce, (part) => {
|
|
161
189
|
if (part.type !== "tool-call") {
|
|
162
|
-
|
|
190
|
+
warnOnce(
|
|
191
|
+
"wrong-part:tool-call-args-text-finish",
|
|
192
|
+
"Dropped tool-call-args-text-finish chunk: part is not a tool-call",
|
|
193
|
+
);
|
|
194
|
+
return part;
|
|
163
195
|
}
|
|
164
196
|
|
|
165
197
|
// TODO this should never be hit; this happens if args-text-finish is emitted after result
|
|
166
|
-
if (part.state !== "partial-call") return part;
|
|
198
|
+
if (part.state !== "partial-call") return { ...part };
|
|
167
199
|
// throw new Error("Last is not a partial call");
|
|
168
200
|
|
|
169
201
|
return {
|
|
@@ -176,8 +208,9 @@ const handleToolCallArgsTextFinish = (
|
|
|
176
208
|
const handlePartFinish = (
|
|
177
209
|
message: AssistantMessage,
|
|
178
210
|
chunk: AssistantStreamChunk & { readonly type: "part-finish" },
|
|
211
|
+
warnOnce: WarnOnce,
|
|
179
212
|
): AssistantMessage => {
|
|
180
|
-
return updatePartForPath(message, chunk, (part) => ({
|
|
213
|
+
return updatePartForPath(message, chunk, warnOnce, (part) => ({
|
|
181
214
|
...part,
|
|
182
215
|
status: { type: "complete", reason: "unknown" },
|
|
183
216
|
}));
|
|
@@ -186,8 +219,9 @@ const handlePartFinish = (
|
|
|
186
219
|
const handleTextDelta = (
|
|
187
220
|
message: AssistantMessage,
|
|
188
221
|
chunk: AssistantStreamChunk & { type: "text-delta" },
|
|
222
|
+
warnOnce: WarnOnce,
|
|
189
223
|
): AssistantMessage => {
|
|
190
|
-
return updatePartForPath(message, chunk, (part) => {
|
|
224
|
+
return updatePartForPath(message, chunk, warnOnce, (part) => {
|
|
191
225
|
if (part.type === "text" || part.type === "reasoning") {
|
|
192
226
|
return { ...part, text: part.text + chunk.textDelta };
|
|
193
227
|
} else if (part.type === "tool-call") {
|
|
@@ -198,9 +232,11 @@ const handleTextDelta = (
|
|
|
198
232
|
|
|
199
233
|
return { ...part, argsText: newArgsText, args: newArgs };
|
|
200
234
|
} else {
|
|
201
|
-
|
|
202
|
-
"
|
|
235
|
+
warnOnce(
|
|
236
|
+
"wrong-part:text-delta",
|
|
237
|
+
"Dropped text-delta chunk: part is neither text nor tool-call",
|
|
203
238
|
);
|
|
239
|
+
return part;
|
|
204
240
|
}
|
|
205
241
|
});
|
|
206
242
|
};
|
|
@@ -208,8 +244,9 @@ const handleTextDelta = (
|
|
|
208
244
|
const handleResult = (
|
|
209
245
|
message: AssistantMessage,
|
|
210
246
|
chunk: AssistantStreamChunk & { type: "result" },
|
|
247
|
+
warnOnce: WarnOnce,
|
|
211
248
|
): AssistantMessage => {
|
|
212
|
-
return updatePartForPath(message, chunk, (part) => {
|
|
249
|
+
return updatePartForPath(message, chunk, warnOnce, (part) => {
|
|
213
250
|
if (part.type === "tool-call") {
|
|
214
251
|
return {
|
|
215
252
|
...part,
|
|
@@ -232,7 +269,11 @@ const handleResult = (
|
|
|
232
269
|
status: { type: "complete", reason: "stop" },
|
|
233
270
|
};
|
|
234
271
|
} else {
|
|
235
|
-
|
|
272
|
+
warnOnce(
|
|
273
|
+
"wrong-part:result",
|
|
274
|
+
"Dropped result chunk: part is not a tool-call",
|
|
275
|
+
);
|
|
276
|
+
return part;
|
|
236
277
|
}
|
|
237
278
|
});
|
|
238
279
|
};
|
|
@@ -377,7 +418,7 @@ const handleErrorChunk = (
|
|
|
377
418
|
reason: "error",
|
|
378
419
|
error: {
|
|
379
420
|
code: chunk.code ?? "unknown",
|
|
380
|
-
message: chunk.error,
|
|
421
|
+
message: chunk.error ?? "unknown error",
|
|
381
422
|
...(severity !== undefined && { severity }),
|
|
382
423
|
},
|
|
383
424
|
},
|
|
@@ -388,7 +429,7 @@ const handleUpdateState = (
|
|
|
388
429
|
message: AssistantMessage,
|
|
389
430
|
chunk: AssistantStreamChunk & { type: "update-state" },
|
|
390
431
|
): AssistantMessage => {
|
|
391
|
-
const acc = new
|
|
432
|
+
const acc = new GorpStreamAccumulator(message.metadata.unstable_state);
|
|
392
433
|
acc.append(chunk.operations);
|
|
393
434
|
|
|
394
435
|
return {
|
|
@@ -451,6 +492,12 @@ export class AssistantMessageAccumulator extends TransformStream<
|
|
|
451
492
|
} = {}) {
|
|
452
493
|
let message = initialMessage ?? createInitialMessage();
|
|
453
494
|
const tracker = new TimingTracker();
|
|
495
|
+
const warnedKeys = new Set<string>();
|
|
496
|
+
const warnOnce: WarnOnce = (key, warning) => {
|
|
497
|
+
if (warnedKeys.has(key) || warnedKeys.size >= MAX_WARNED_KEYS) return;
|
|
498
|
+
warnedKeys.add(key);
|
|
499
|
+
console.warn(warning);
|
|
500
|
+
};
|
|
454
501
|
let controller:
|
|
455
502
|
| TransformStreamDefaultController<AssistantMessage>
|
|
456
503
|
| undefined;
|
|
@@ -470,26 +517,28 @@ export class AssistantMessageAccumulator extends TransformStream<
|
|
|
470
517
|
const type = chunk.type;
|
|
471
518
|
switch (type) {
|
|
472
519
|
case "part-start":
|
|
473
|
-
message = handlePartStart(message, chunk);
|
|
520
|
+
message = handlePartStart(message, chunk, warnOnce);
|
|
474
521
|
if (chunk.part.type === "tool-call") {
|
|
475
522
|
tracker.recordToolCallStart(chunk.part.toolCallId);
|
|
476
523
|
}
|
|
477
524
|
break;
|
|
478
525
|
|
|
479
526
|
case "tool-call-args-text-finish":
|
|
480
|
-
message = handleToolCallArgsTextFinish(message, chunk);
|
|
527
|
+
message = handleToolCallArgsTextFinish(message, chunk, warnOnce);
|
|
481
528
|
break;
|
|
482
529
|
|
|
483
530
|
case "part-finish":
|
|
484
|
-
message = handlePartFinish(message, chunk);
|
|
531
|
+
message = handlePartFinish(message, chunk, warnOnce);
|
|
485
532
|
break;
|
|
486
533
|
|
|
487
|
-
case "text-delta":
|
|
488
|
-
|
|
489
|
-
tracker.recordFirstToken();
|
|
534
|
+
case "text-delta": {
|
|
535
|
+
const next = handleTextDelta(message, chunk, warnOnce);
|
|
536
|
+
if (next !== message) tracker.recordFirstToken();
|
|
537
|
+
message = next;
|
|
490
538
|
break;
|
|
539
|
+
}
|
|
491
540
|
case "result":
|
|
492
|
-
message = handleResult(message, chunk);
|
|
541
|
+
message = handleResult(message, chunk, warnOnce);
|
|
493
542
|
break;
|
|
494
543
|
case "message-finish":
|
|
495
544
|
message = handleMessageFinish(message, chunk);
|
|
@@ -563,66 +563,30 @@ describe("toGenericMessages", () => {
|
|
|
563
563
|
expect(content[0]!.mediaType).toBe(expectedType);
|
|
564
564
|
};
|
|
565
565
|
|
|
566
|
-
it("
|
|
566
|
+
it("maps known extensions to their media types", () => {
|
|
567
567
|
testMediaType("https://example.com/photo.jpg", "image/jpeg");
|
|
568
|
-
});
|
|
569
|
-
|
|
570
|
-
it("handles jpeg extension", () => {
|
|
571
568
|
testMediaType("https://example.com/photo.jpeg", "image/jpeg");
|
|
572
|
-
});
|
|
573
|
-
|
|
574
|
-
it("handles png extension", () => {
|
|
575
569
|
testMediaType("https://example.com/photo.png", "image/png");
|
|
576
|
-
});
|
|
577
|
-
|
|
578
|
-
it("handles gif extension", () => {
|
|
579
570
|
testMediaType("https://example.com/photo.gif", "image/gif");
|
|
580
|
-
});
|
|
581
|
-
|
|
582
|
-
it("handles webp extension", () => {
|
|
583
571
|
testMediaType("https://example.com/photo.webp", "image/webp");
|
|
584
|
-
});
|
|
585
|
-
|
|
586
|
-
it("handles svg extension", () => {
|
|
587
572
|
testMediaType("https://example.com/icon.svg", "image/svg+xml");
|
|
588
|
-
});
|
|
589
|
-
|
|
590
|
-
it("handles avif extension", () => {
|
|
591
573
|
testMediaType("https://example.com/photo.avif", "image/avif");
|
|
592
|
-
});
|
|
593
|
-
|
|
594
|
-
it("handles bmp extension", () => {
|
|
595
574
|
testMediaType("https://example.com/photo.bmp", "image/bmp");
|
|
596
|
-
});
|
|
597
|
-
|
|
598
|
-
it("handles ico extension", () => {
|
|
599
575
|
testMediaType("https://example.com/favicon.ico", "image/x-icon");
|
|
600
|
-
});
|
|
601
|
-
|
|
602
|
-
it("handles tiff extension", () => {
|
|
603
576
|
testMediaType("https://example.com/photo.tiff", "image/tiff");
|
|
604
|
-
});
|
|
605
|
-
|
|
606
|
-
it("handles tif extension", () => {
|
|
607
577
|
testMediaType("https://example.com/photo.tif", "image/tiff");
|
|
608
578
|
});
|
|
609
579
|
|
|
610
|
-
it("defaults to image/png for unknown
|
|
580
|
+
it("defaults to image/png for unknown or missing extensions", () => {
|
|
611
581
|
testMediaType("https://example.com/photo.unknown", "image/png");
|
|
612
|
-
});
|
|
613
|
-
|
|
614
|
-
it("defaults to image/png for no extension", () => {
|
|
615
582
|
testMediaType("https://example.com/photo", "image/png");
|
|
616
583
|
});
|
|
617
584
|
|
|
618
|
-
it("
|
|
585
|
+
it("ignores query params and casing in the extension", () => {
|
|
619
586
|
testMediaType(
|
|
620
587
|
"https://example.com/photo.jpg?size=large&quality=high",
|
|
621
588
|
"image/jpeg",
|
|
622
589
|
);
|
|
623
|
-
});
|
|
624
|
-
|
|
625
|
-
it("handles uppercase extensions", () => {
|
|
626
590
|
testMediaType("https://example.com/photo.JPG", "image/jpeg");
|
|
627
591
|
});
|
|
628
592
|
});
|