@kuralle-syrinx/aisdk 4.2.0 → 4.4.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kuralle-syrinx/aisdk",
3
- "version": "4.2.0",
3
+ "version": "4.4.0",
4
4
  "private": false,
5
5
  "description": "Vercel AI SDK bridge for Syrinx — drive any ai@6 model or agent as the voice pipeline's reasoner, with opt-in speculative generation",
6
6
  "keywords": [
@@ -28,11 +28,11 @@
28
28
  "@ai-sdk/openai": "^3.0.67",
29
29
  "ai": "^6.0.0",
30
30
  "zod": "^4.1.8",
31
- "@kuralle-syrinx/core": "4.2.0"
31
+ "@kuralle-syrinx/core": "4.4.0"
32
32
  },
33
33
  "devDependencies": {
34
34
  "typescript": "^5.7.0",
35
- "vitest": "^2.1.0"
35
+ "vitest": "^3.2.6"
36
36
  },
37
37
  "scripts": {
38
38
  "typecheck": "tsc --noEmit",
@@ -8,6 +8,7 @@
8
8
  import {
9
9
  streamText,
10
10
  type FinishReason,
11
+ type LanguageModelUsage,
11
12
  type ModelMessage,
12
13
  type TextStreamPart,
13
14
  type ToolChoice,
@@ -19,6 +20,7 @@ import {
19
20
  type Reasoner,
20
21
  type ReasonerMessage,
21
22
  type ReasonerTurn,
23
+ type ReasonerUsage,
22
24
  type ReasoningPart,
23
25
  } from "@kuralle-syrinx/core";
24
26
  import type { AISDKStreamFactory } from "./index.js";
@@ -79,7 +81,7 @@ async function* streamFromStreamText(config: StreamTextConfig, turn: ReasonerTur
79
81
  messages,
80
82
  abortSignal: turn.signal,
81
83
  });
82
- yield* mapTextStreamParts(result.fullStream);
84
+ yield* mapTextStreamParts(result.fullStream, modelIdentity(config.model));
83
85
  }
84
86
 
85
87
  async function* streamFromFactory(factory: AISDKStreamFactory, turn: ReasonerTurn): AsyncGenerator<ReasoningPart> {
@@ -112,6 +114,7 @@ function mapMessages(messages: readonly ReasonerMessage[]): ModelMessage[] {
112
114
 
113
115
  async function* mapTextStreamParts(
114
116
  source: AsyncIterable<TextStreamPart<ToolSet>>,
117
+ identity: { provider?: string; model?: string } = {},
115
118
  ): AsyncGenerator<ReasoningPart> {
116
119
  let accumulatedText = "";
117
120
  let sawFinish = false;
@@ -150,7 +153,12 @@ async function* mapTextStreamParts(
150
153
  return;
151
154
  }
152
155
  case "abort": {
156
+ // An abort is a benign cancellation (barge-in aborted the reasoner), NOT an error.
157
+ // Carry the web/Node standard `name === "AbortError"` so downstream `isAbortError`
158
+ // guards (realtime-bridge runDelegate, ReasoningBridge) swallow it instead of
159
+ // surfacing a fatal `bridge.error/internal_fault`.
153
160
  const cause = new Error(part.reason ?? "AI SDK stream aborted");
161
+ cause.name = "AbortError";
154
162
  yield toErrorPart(cause);
155
163
  return;
156
164
  }
@@ -171,6 +179,11 @@ async function* mapTextStreamParts(
171
179
  type: "finish",
172
180
  reason: mapFinishReason(part.finishReason),
173
181
  text: accumulatedText,
182
+ // The AI SDK finish part carries totalUsage; forward it so the bridge can
183
+ // record cost. Omit entirely when the provider reported nothing.
184
+ ...(part.totalUsage
185
+ ? { usage: { ...identity, ...toReasonerUsage(part.totalUsage) } }
186
+ : {}),
174
187
  };
175
188
  return;
176
189
  }
@@ -198,6 +211,32 @@ async function* mapTextStreamParts(
198
211
  }
199
212
  }
200
213
 
214
+ /**
215
+ * Extract provider/model for cost attribution. The AI SDK model is either a bare id
216
+ * string (`"openai/gpt-4.1-mini"`) or a model object exposing `.provider` / `.modelId`.
217
+ * Without this, usage counters are tagged with empty provider/model and spend cannot be
218
+ * attributed to a model — the whole point of the low-cardinality tags.
219
+ */
220
+ export function modelIdentity(model: StreamTextConfig["model"]): { provider?: string; model?: string } {
221
+ if (typeof model === "string") return { model };
222
+ const m = model as { provider?: unknown; modelId?: unknown };
223
+ return {
224
+ ...(typeof m.provider === "string" ? { provider: m.provider } : {}),
225
+ ...(typeof m.modelId === "string" ? { model: m.modelId } : {}),
226
+ };
227
+ }
228
+
229
+ /** Copy only the token fields the SDK actually populated (all are `number | undefined`). */
230
+ function toReasonerUsage(u: LanguageModelUsage): ReasonerUsage {
231
+ return {
232
+ ...(u.inputTokens !== undefined ? { inputTokens: u.inputTokens } : {}),
233
+ ...(u.outputTokens !== undefined ? { outputTokens: u.outputTokens } : {}),
234
+ ...(u.totalTokens !== undefined ? { totalTokens: u.totalTokens } : {}),
235
+ ...(u.cachedInputTokens !== undefined ? { cachedInputTokens: u.cachedInputTokens } : {}),
236
+ ...(u.reasoningTokens !== undefined ? { reasoningTokens: u.reasoningTokens } : {}),
237
+ };
238
+ }
239
+
201
240
  function mapFinishReason(finishReason: FinishReason): "stop" | "tool" | "length" {
202
241
  if (finishReason === "tool-calls") return "tool";
203
242
  if (finishReason === "length") return "length";
package/src/index.ts CHANGED
@@ -70,6 +70,10 @@ export class ReasoningBridge implements VoicePlugin {
70
70
  private timeoutMs: number = 30_000;
71
71
  private maxHistoryTurns: number = 12;
72
72
  private history: Array<{ role: "system" | "user" | "assistant" | "tool"; content: string; toolCallId?: string }> = [];
73
+ private readonly transientContextMessages = new Set<{
74
+ role: "system";
75
+ content: string;
76
+ }>();
73
77
  private activeGeneration: { contextId: string; controller: AbortController } | null = null;
74
78
  // At most one speculative draft at a time; `hold` gates its side effects.
75
79
  private speculativeDraft: {
@@ -127,6 +131,21 @@ export class ReasoningBridge implements VoicePlugin {
127
131
  * unconfirmed endpoints cost extra LLM calls (Deepgram measures +50–70% at
128
132
  * eager thresholds 0.3–0.5). Drafts never consume a suspended-run pointer —
129
133
  * `runStore` resume stays confirmed-turn-only.
134
+ *
135
+ * **Only enable this with a confidence-gated eager endpointer (Deepgram Flux).**
136
+ * Promotion requires `draft.userText === eos.text` — exact equality. Flux
137
+ * guarantees the EndOfTurn transcript matches the preceding EagerEndOfTurn when
138
+ * no TurnResumed intervened, so drafts promote. `PipecatEOSPlugin` instead pushes
139
+ * `eos.interim` on EVERY non-empty STT interim, and each interim discards the prior
140
+ * draft and starts a new call, so the surviving draft is built on an interim
141
+ * transcript that rarely matches the final one.
142
+ *
143
+ * Measured live on smart-turn, one turn, university fixture:
144
+ * ON — started 13, discarded 13, promoted 0; ttfa 1724ms, llmTtft 1269ms
145
+ * OFF — started 0, discarded 0, promoted 0; ttfa 1302ms, llmTtft 1025ms
146
+ *
147
+ * Thirteen wasted LLM calls, zero promotions, and latency got *worse*. The lever
148
+ * is Flux-specific; on a per-interim endpointer it is pure cost.
130
149
  */
131
150
  speculative?: boolean;
132
151
  } = {},
@@ -136,6 +155,12 @@ export class ReasoningBridge implements VoicePlugin {
136
155
  }
137
156
  }
138
157
 
158
+ injectContext(text: string): void {
159
+ const message = { role: "system" as const, content: text };
160
+ this.history.push(message);
161
+ this.transientContextMessages.add(message);
162
+ }
163
+
139
164
  async initialize(bus: PipelineBus, config: PluginConfig): Promise<void> {
140
165
  this.bus = bus;
141
166
  this.iuLedger = new InMemoryIuLedger((a) => {
@@ -188,6 +213,7 @@ export class ReasoningBridge implements VoicePlugin {
188
213
  ) {
189
214
  this.iuLedger.commit(draft.id);
190
215
  this.speculativeDraft = null;
216
+ this.metric(eos.contextId, "speculative.draft_promoted");
191
217
  for (const flush of draft.hold.buffered.splice(0)) flush();
192
218
  return;
193
219
  }
@@ -259,6 +285,7 @@ export class ReasoningBridge implements VoicePlugin {
259
285
  /** Start (or restart, if a newer eager endpoint supersedes) the speculative draft. */
260
286
  private async runDraft(userText: string, contextId: string): Promise<void> {
261
287
  this.discardDraft();
288
+ this.metric(contextId, "speculative.draft_started");
262
289
  const id = this.iuIdFor(contextId);
263
290
  this.iuLedger.add({ id, kind: "user_turn", state: "hypothesized" });
264
291
  const controller = new AbortController();
@@ -291,11 +318,22 @@ export class ReasoningBridge implements VoicePlugin {
291
318
  this.speculativeDraft = null;
292
319
  const committed = this.iuLedger.get(draft.id)?.state === "committed";
293
320
  if (!committed) {
321
+ this.metric(draft.contextId, "speculative.draft_discarded");
294
322
  this.iuLedger.revoke(draft.id);
295
323
  draft.controller.abort();
296
324
  }
297
325
  }
298
326
 
327
+ private metric(contextId: string, name: string, value = "1"): void {
328
+ this.bus?.push(Route.Background, {
329
+ kind: "metric.conversation",
330
+ contextId,
331
+ timestampMs: Date.now(),
332
+ name,
333
+ value,
334
+ });
335
+ }
336
+
299
337
  private async processTurn(
300
338
  userText: string,
301
339
  contextId: string,
@@ -340,6 +378,20 @@ export class ReasoningBridge implements VoicePlugin {
340
378
  let emittedDelta = false;
341
379
  let committed = false;
342
380
  let grounded = false;
381
+ let passStartedMs = 0;
382
+ let passTtftRecorded = false;
383
+ const recordPassTtft = (): void => {
384
+ if (passTtftRecorded) return;
385
+ passTtftRecorded = true;
386
+ const firstOutputMs = Date.now();
387
+ push(Route.Main, {
388
+ kind: "metric.conversation",
389
+ contextId,
390
+ timestampMs: firstOutputMs,
391
+ name: "llm.pass_ttft_ms",
392
+ value: String(firstOutputMs - passStartedMs),
393
+ });
394
+ };
343
395
 
344
396
  // G2 observability: the turn's query is on its way to the reasoner (Background
345
397
  // route, droppable — RFC bimodel-delegate-seam R4). Cascade turns have no
@@ -355,6 +407,15 @@ export class ReasoningBridge implements VoicePlugin {
355
407
  try {
356
408
  for (let attempt = 1; attempt <= this.retryConfig.maxAttempts; attempt += 1) {
357
409
  grounded = false;
410
+ passStartedMs = Date.now();
411
+ passTtftRecorded = false;
412
+ push(Route.Main, {
413
+ kind: "metric.conversation",
414
+ contextId,
415
+ timestampMs: passStartedMs,
416
+ name: "llm.call_started",
417
+ value: "1",
418
+ });
358
419
  try {
359
420
  // Drafts never consume a suspended-run pointer: takePending mutates the
360
421
  // store, and a retracted draft would silently lose the resume.
@@ -374,6 +435,7 @@ export class ReasoningBridge implements VoicePlugin {
374
435
  case "text-delta":
375
436
  reply += part.text;
376
437
  emittedDelta = true;
438
+ recordPassTtft();
377
439
  push(Route.Main, {
378
440
  kind: "llm.delta",
379
441
  contextId,
@@ -382,6 +444,7 @@ export class ReasoningBridge implements VoicePlugin {
382
444
  });
383
445
  break;
384
446
  case "tool-call":
447
+ recordPassTtft();
385
448
  push(Route.Main, {
386
449
  kind: "llm.tool_call",
387
450
  contextId,
@@ -401,7 +464,64 @@ export class ReasoningBridge implements VoicePlugin {
401
464
  toolName: part.toolName,
402
465
  result: part.result,
403
466
  });
467
+ passStartedMs = Date.now();
468
+ passTtftRecorded = false;
469
+ push(Route.Main, {
470
+ kind: "metric.conversation",
471
+ contextId,
472
+ timestampMs: passStartedMs,
473
+ name: "llm.call_started",
474
+ value: "1",
475
+ });
476
+ break;
477
+ case "control":
478
+ push(Route.Background, {
479
+ kind: "delegate.result",
480
+ contextId,
481
+ timestampMs: Date.now(),
482
+ query: userText,
483
+ answer: reply,
484
+ durationMs: Date.now() - queryStartedMs,
485
+ grounded,
486
+ control: {
487
+ name: part.name,
488
+ payload: part.payload,
489
+ },
490
+ });
404
491
  break;
492
+ case "blocked": {
493
+ if (signal.aborted) return;
494
+ const safeMessage = part.userFacingMessage;
495
+ const blockedMs = Date.now();
496
+ push(Route.Main, {
497
+ kind: "llm.delta",
498
+ contextId,
499
+ timestampMs: blockedMs,
500
+ text: safeMessage,
501
+ });
502
+ push(Route.Main, {
503
+ kind: "llm.done",
504
+ contextId,
505
+ timestampMs: blockedMs,
506
+ text: safeMessage,
507
+ });
508
+ push(Route.Background, {
509
+ kind: "delegate.result",
510
+ contextId,
511
+ timestampMs: blockedMs,
512
+ query: userText,
513
+ answer: safeMessage,
514
+ durationMs: blockedMs - queryStartedMs,
515
+ grounded,
516
+ blocked: {
517
+ userFacingMessage: safeMessage,
518
+ payload: part.payload,
519
+ },
520
+ });
521
+ defer(() => this.rememberTurn(userText, safeMessage, contextId));
522
+ committed = true;
523
+ return;
524
+ }
405
525
  case "error":
406
526
  throw part.cause;
407
527
  case "finish":
@@ -412,6 +532,18 @@ export class ReasoningBridge implements VoicePlugin {
412
532
  name: "llm.finish_reason",
413
533
  value: part.reason,
414
534
  });
535
+ // Record billable token usage — the field the bridge used to drop.
536
+ // A turn with tool calls produces several finish parts; the session
537
+ // accumulator sums them, so emit per-finish rather than once per turn.
538
+ if (part.usage) {
539
+ push(Route.Background, {
540
+ kind: "usage.recorded",
541
+ contextId,
542
+ timestampMs: Date.now(),
543
+ stage: "llm",
544
+ ...part.usage,
545
+ });
546
+ }
415
547
  finishReason = part.reason;
416
548
  break;
417
549
  case "suspended": {
@@ -556,6 +688,7 @@ export class ReasoningBridge implements VoicePlugin {
556
688
  this.assistantMsgByContext.clear();
557
689
  this.wordTimestampsByContext.clear();
558
690
  this.playedOutMsByContext.clear();
691
+ this.transientContextMessages.clear();
559
692
  this.bus = null;
560
693
  }
561
694
 
@@ -574,7 +707,14 @@ export class ReasoningBridge implements VoicePlugin {
574
707
  const sessionId = this.opts.sessionId;
575
708
  if (!store || !sessionId) return;
576
709
  try {
577
- void Promise.resolve(store.save(sessionId, this.history.map((message) => ({ ...message })))).catch(
710
+ void Promise.resolve(
711
+ store.save(
712
+ sessionId,
713
+ this.history
714
+ .filter((message) => !this.transientContextMessages.has(message as { role: "system"; content: string }))
715
+ .map((message) => ({ ...message })),
716
+ ),
717
+ ).catch(
578
718
  () => undefined,
579
719
  );
580
720
  } catch {
@@ -654,6 +794,9 @@ export class ReasoningBridge implements VoicePlugin {
654
794
  for (const [ctx, msg] of this.assistantMsgByContext) {
655
795
  if (!this.history.includes(msg)) this.clearTurnState(ctx);
656
796
  }
797
+ for (const message of this.transientContextMessages) {
798
+ if (!this.history.includes(message)) this.transientContextMessages.delete(message);
799
+ }
657
800
  }
658
801
 
659
802
  private clearTurnState(contextId: string): void {
@@ -1,276 +0,0 @@
1
- // SPDX-License-Identifier: MIT
2
-
3
- import { describe, expect, it } from "vitest";
4
- import type { FinishReason, TextStreamPart, ToolSet } from "ai";
5
- import type { Reasoner, ReasonerTurn, ReasoningPart } from "@kuralle-syrinx/core";
6
- import { fromAiSdkAgent, fromStreamFactory, type AiSdkAgentLike } from "./from-ai-sdk.js";
7
-
8
- const ZERO_USAGE = {
9
- inputTokens: 0,
10
- inputTokenDetails: {
11
- noCacheTokens: 0,
12
- cacheReadTokens: 0,
13
- cacheWriteTokens: 0,
14
- },
15
- outputTokens: 0,
16
- outputTokenDetails: {
17
- textTokens: 0,
18
- reasoningTokens: 0,
19
- },
20
- totalTokens: 0,
21
- };
22
-
23
- function baseTurn(): ReasonerTurn {
24
- return {
25
- userText: "Hi",
26
- messages: [{ role: "system", content: "test" }],
27
- signal: new AbortController().signal,
28
- };
29
- }
30
-
31
- function textDelta(text: string): TextStreamPart<ToolSet> {
32
- return { type: "text-delta", id: "0", text, providerMetadata: undefined } as TextStreamPart<ToolSet>;
33
- }
34
-
35
- function finish(finishReason: FinishReason, rawFinishReason?: string): TextStreamPart<ToolSet> {
36
- return {
37
- type: "finish",
38
- finishReason,
39
- rawFinishReason,
40
- totalUsage: ZERO_USAGE,
41
- usage: ZERO_USAGE,
42
- providerMetadata: undefined,
43
- response: {},
44
- } as TextStreamPart<ToolSet>;
45
- }
46
-
47
- function finishStep(finishReason: FinishReason, rawFinishReason?: string): TextStreamPart<ToolSet> {
48
- return {
49
- type: "finish-step",
50
- finishReason,
51
- rawFinishReason,
52
- usage: ZERO_USAGE,
53
- providerMetadata: undefined,
54
- response: {},
55
- } as TextStreamPart<ToolSet>;
56
- }
57
-
58
- function toolCall(toolCallId: string, toolName: string, input: Record<string, unknown>): TextStreamPart<ToolSet> {
59
- return {
60
- type: "tool-call",
61
- toolCallId,
62
- toolName,
63
- input,
64
- } as TextStreamPart<ToolSet>;
65
- }
66
-
67
- function toolResult(
68
- toolCallId: string,
69
- toolName: string,
70
- output: unknown,
71
- ): TextStreamPart<ToolSet> {
72
- return {
73
- type: "tool-result",
74
- toolCallId,
75
- toolName,
76
- input: {},
77
- output,
78
- } as TextStreamPart<ToolSet>;
79
- }
80
-
81
- function errorPart(error: unknown): TextStreamPart<ToolSet> {
82
- return { type: "error", error } as TextStreamPart<ToolSet>;
83
- }
84
-
85
- function toolErrorPart(toolCallId: string, toolName: string, error: unknown): TextStreamPart<ToolSet> {
86
- return {
87
- type: "tool-error",
88
- toolCallId,
89
- toolName,
90
- input: {},
91
- error,
92
- } as TextStreamPart<ToolSet>;
93
- }
94
-
95
- async function collectParts(reasoner: Reasoner, turn: ReasonerTurn): Promise<ReasoningPart[]> {
96
- const parts: ReasoningPart[] = [];
97
- for await (const part of reasoner.stream(turn)) {
98
- parts.push(part);
99
- }
100
- return parts;
101
- }
102
-
103
- describe("from-ai-sdk adapters", () => {
104
- it("maps happy path: deltas, tool-call, tool-result, finish:stop", async () => {
105
- const reasoner = fromStreamFactory(async function* () {
106
- yield textDelta("Hello ");
107
- yield textDelta("world.");
108
- yield toolCall("tc-1", "get_weather", { city: "NYC" });
109
- yield toolResult("tc-1", "get_weather", { temp: 72 });
110
- yield finish("stop");
111
- });
112
-
113
- const parts = await collectParts(reasoner, baseTurn());
114
-
115
- expect(parts).toEqual([
116
- { type: "text-delta", text: "Hello " },
117
- { type: "text-delta", text: "world." },
118
- {
119
- type: "tool-call",
120
- toolId: "tc-1",
121
- toolName: "get_weather",
122
- args: { city: "NYC" },
123
- },
124
- {
125
- type: "tool-result",
126
- toolId: "tc-1",
127
- toolName: "get_weather",
128
- result: JSON.stringify({ temp: 72 }),
129
- },
130
- { type: "finish", reason: "stop", text: "Hello world." },
131
- ]);
132
- });
133
-
134
- it("maps error part to terminal error", async () => {
135
- const reasoner = fromStreamFactory(async function* () {
136
- yield textDelta("partial");
137
- yield errorPart(new Error("provider failed"));
138
- });
139
-
140
- const parts = await collectParts(reasoner, baseTurn());
141
-
142
- expect(parts).toHaveLength(2);
143
- expect(parts[0]).toEqual({ type: "text-delta", text: "partial" });
144
- expect(parts[1]?.type).toBe("error");
145
- if (parts[1]?.type === "error") {
146
- expect(parts[1].cause.message).toBe("provider failed");
147
- expect(parts[1].recoverable).toBe(false);
148
- }
149
- });
150
-
151
- it("maps tool-error part to terminal error", async () => {
152
- const reasoner = fromStreamFactory(async function* () {
153
- yield toolErrorPart("tc-1", "broken_tool", new Error("tool exploded"));
154
- });
155
-
156
- const parts = await collectParts(reasoner, baseTurn());
157
-
158
- expect(parts).toHaveLength(1);
159
- expect(parts[0]?.type).toBe("error");
160
- if (parts[0]?.type === "error") {
161
- expect(parts[0].cause.message).toBe("tool exploded");
162
- }
163
- });
164
-
165
- it("maps finish-step(error) to terminal error", async () => {
166
- const reasoner = fromStreamFactory(async function* () {
167
- yield finishStep("error", "MALFORMED_FUNCTION_CALL");
168
- });
169
-
170
- const parts = await collectParts(reasoner, baseTurn());
171
-
172
- expect(parts).toHaveLength(1);
173
- expect(parts[0]?.type).toBe("error");
174
- if (parts[0]?.type === "error") {
175
- expect(parts[0].cause.message).toBe(
176
- "AI SDK provider step failed: error (MALFORMED_FUNCTION_CALL)",
177
- );
178
- expect(parts[0].recoverable).toBe(true);
179
- }
180
- });
181
-
182
- it("maps finish(length) to finish with accumulated text", async () => {
183
- const reasoner = fromStreamFactory(async function* () {
184
- yield textDelta("truncated");
185
- yield finish("length", "MAX_TOKENS");
186
- });
187
-
188
- const parts = await collectParts(reasoner, baseTurn());
189
-
190
- expect(parts).toEqual([
191
- { type: "text-delta", text: "truncated" },
192
- { type: "finish", reason: "length", text: "truncated" },
193
- ]);
194
- });
195
-
196
- it("drops reasoning-delta and tool-input-start parts", async () => {
197
- const reasoner = fromStreamFactory(async function* () {
198
- yield {
199
- type: "reasoning-delta",
200
- id: "r1",
201
- text: "thinking...",
202
- providerMetadata: undefined,
203
- } as TextStreamPart<ToolSet>;
204
- yield {
205
- type: "tool-input-start",
206
- id: "t1",
207
- toolName: "search",
208
- providerMetadata: undefined,
209
- } as TextStreamPart<ToolSet>;
210
- yield textDelta("answer");
211
- yield finish("stop");
212
- });
213
-
214
- const parts = await collectParts(reasoner, baseTurn());
215
-
216
- expect(parts).toEqual([
217
- { type: "text-delta", text: "answer" },
218
- { type: "finish", reason: "stop", text: "answer" },
219
- ]);
220
- });
221
-
222
- it("yields first text-delta before source stream completes (no buffering)", async () => {
223
- let resolveGate: (() => void) | undefined;
224
- const gate = new Promise<void>((resolve) => {
225
- resolveGate = resolve;
226
- });
227
-
228
- const reasoner = fromStreamFactory(async function* () {
229
- yield textDelta("immediate");
230
- await gate;
231
- });
232
-
233
- const iterator = reasoner.stream(baseTurn())[Symbol.asyncIterator]();
234
- const first = await iterator.next();
235
-
236
- expect(first.done).toBe(false);
237
- expect(first.value).toEqual({ type: "text-delta", text: "immediate" });
238
- resolveGate?.();
239
- });
240
-
241
- it("maps stream ending without finish to terminal error", async () => {
242
- const reasoner = fromStreamFactory(async function* () {
243
- yield textDelta("no finish");
244
- });
245
-
246
- const parts = await collectParts(reasoner, baseTurn());
247
-
248
- expect(parts).toHaveLength(2);
249
- expect(parts[1]?.type).toBe("error");
250
- if (parts[1]?.type === "error") {
251
- expect(parts[1].cause.message).toBe("AI SDK stream ended without a provider finish reason");
252
- }
253
- });
254
-
255
- it("fromAiSdkAgent maps agent fullStream through the same table", async () => {
256
- const agent: AiSdkAgentLike = {
257
- async stream() {
258
- return {
259
- fullStream: (async function* () {
260
- yield textDelta("From ");
261
- yield textDelta("agent");
262
- yield finish("stop");
263
- })(),
264
- };
265
- },
266
- };
267
-
268
- const parts = await collectParts(fromAiSdkAgent(agent), baseTurn());
269
-
270
- expect(parts).toEqual([
271
- { type: "text-delta", text: "From " },
272
- { type: "text-delta", text: "agent" },
273
- { type: "finish", reason: "stop", text: "From agent" },
274
- ]);
275
- });
276
- });