@kuralle-syrinx/aisdk 4.4.0 → 4.5.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.4.0",
3
+ "version": "4.5.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": [
@@ -22,20 +22,27 @@
22
22
  "url": "https://github.com/kuralle/syrinx/issues"
23
23
  },
24
24
  "type": "module",
25
- "main": "./src/index.ts",
26
- "types": "./src/index.ts",
25
+ "main": "./dist/index.js",
26
+ "types": "./dist/index.d.ts",
27
27
  "dependencies": {
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.4.0"
31
+ "@kuralle-syrinx/core": "4.5.0"
32
32
  },
33
33
  "devDependencies": {
34
+ "@types/node": "^22.0.0",
34
35
  "typescript": "^5.7.0",
35
36
  "vitest": "^3.2.6"
36
37
  },
38
+ "files": [
39
+ "dist",
40
+ "src",
41
+ "README.md"
42
+ ],
37
43
  "scripts": {
38
44
  "typecheck": "tsc --noEmit",
39
- "test": "vitest run"
45
+ "test": "vitest run",
46
+ "build": "tsc -p tsconfig.build.json"
40
47
  }
41
48
  }
@@ -0,0 +1,315 @@
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, modelIdentity, 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
+ // What the finish ReasoningPart now carries, forwarded from totalUsage. Nested
24
+ // detail fields (cache/reasoning) are not surfaced, so only the three top-level counts appear.
25
+ const FINISH_USAGE = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
26
+
27
+ function baseTurn(): ReasonerTurn {
28
+ return {
29
+ userText: "Hi",
30
+ messages: [{ role: "system", content: "test" }],
31
+ signal: new AbortController().signal,
32
+ };
33
+ }
34
+
35
+ function textDelta(text: string): TextStreamPart<ToolSet> {
36
+ return { type: "text-delta", id: "0", text, providerMetadata: undefined } as TextStreamPart<ToolSet>;
37
+ }
38
+
39
+ function finish(finishReason: FinishReason, rawFinishReason?: string): TextStreamPart<ToolSet> {
40
+ return {
41
+ type: "finish",
42
+ finishReason,
43
+ rawFinishReason,
44
+ totalUsage: ZERO_USAGE,
45
+ usage: ZERO_USAGE,
46
+ providerMetadata: undefined,
47
+ response: {},
48
+ } as TextStreamPart<ToolSet>;
49
+ }
50
+
51
+ function finishStep(finishReason: FinishReason, rawFinishReason?: string): TextStreamPart<ToolSet> {
52
+ return {
53
+ type: "finish-step",
54
+ finishReason,
55
+ rawFinishReason,
56
+ usage: ZERO_USAGE,
57
+ providerMetadata: undefined,
58
+ response: {},
59
+ } as TextStreamPart<ToolSet>;
60
+ }
61
+
62
+ function toolCall(toolCallId: string, toolName: string, input: Record<string, unknown>): TextStreamPart<ToolSet> {
63
+ return {
64
+ type: "tool-call",
65
+ toolCallId,
66
+ toolName,
67
+ input,
68
+ } as TextStreamPart<ToolSet>;
69
+ }
70
+
71
+ function toolResult(
72
+ toolCallId: string,
73
+ toolName: string,
74
+ output: unknown,
75
+ ): TextStreamPart<ToolSet> {
76
+ return {
77
+ type: "tool-result",
78
+ toolCallId,
79
+ toolName,
80
+ input: {},
81
+ output,
82
+ } as TextStreamPart<ToolSet>;
83
+ }
84
+
85
+ function errorPart(error: unknown): TextStreamPart<ToolSet> {
86
+ return { type: "error", error } as TextStreamPart<ToolSet>;
87
+ }
88
+
89
+ function toolErrorPart(toolCallId: string, toolName: string, error: unknown): TextStreamPart<ToolSet> {
90
+ return {
91
+ type: "tool-error",
92
+ toolCallId,
93
+ toolName,
94
+ input: {},
95
+ error,
96
+ } as TextStreamPart<ToolSet>;
97
+ }
98
+
99
+ function abortPart(reason: string): TextStreamPart<ToolSet> {
100
+ return { type: "abort", reason } as TextStreamPart<ToolSet>;
101
+ }
102
+
103
+ async function collectParts(reasoner: Reasoner, turn: ReasonerTurn): Promise<ReasoningPart[]> {
104
+ const parts: ReasoningPart[] = [];
105
+ for await (const part of reasoner.stream(turn)) {
106
+ parts.push(part);
107
+ }
108
+ return parts;
109
+ }
110
+
111
+ describe("from-ai-sdk adapters", () => {
112
+ it("maps happy path: deltas, tool-call, tool-result, finish:stop", async () => {
113
+ const reasoner = fromStreamFactory(async function* () {
114
+ yield textDelta("Hello ");
115
+ yield textDelta("world.");
116
+ yield toolCall("tc-1", "get_weather", { city: "NYC" });
117
+ yield toolResult("tc-1", "get_weather", { temp: 72 });
118
+ yield finish("stop");
119
+ });
120
+
121
+ const parts = await collectParts(reasoner, baseTurn());
122
+
123
+ expect(parts).toEqual([
124
+ { type: "text-delta", text: "Hello " },
125
+ { type: "text-delta", text: "world." },
126
+ {
127
+ type: "tool-call",
128
+ toolId: "tc-1",
129
+ toolName: "get_weather",
130
+ args: { city: "NYC" },
131
+ },
132
+ {
133
+ type: "tool-result",
134
+ toolId: "tc-1",
135
+ toolName: "get_weather",
136
+ result: JSON.stringify({ temp: 72 }),
137
+ },
138
+ { type: "finish", reason: "stop", text: "Hello world.", usage: FINISH_USAGE },
139
+ ]);
140
+ });
141
+
142
+ it("maps error part to terminal error", async () => {
143
+ const reasoner = fromStreamFactory(async function* () {
144
+ yield textDelta("partial");
145
+ yield errorPart(new Error("provider failed"));
146
+ });
147
+
148
+ const parts = await collectParts(reasoner, baseTurn());
149
+
150
+ expect(parts).toHaveLength(2);
151
+ expect(parts[0]).toEqual({ type: "text-delta", text: "partial" });
152
+ expect(parts[1]?.type).toBe("error");
153
+ if (parts[1]?.type === "error") {
154
+ expect(parts[1].cause.message).toBe("provider failed");
155
+ expect(parts[1].recoverable).toBe(false);
156
+ }
157
+ });
158
+
159
+ it("maps an abort part to an AbortError-named cause so downstream isAbortError guards swallow it", async () => {
160
+ // Regression: a barge-in aborts the reasoner mid-delegate → the AI SDK emits an `abort`
161
+ // stream part. If the cause isn't named "AbortError", the realtime-bridge's isAbortError
162
+ // guard misses it and surfaces a fatal bridge.error/internal_fault (the native multi-turn crash).
163
+ const reasoner = fromStreamFactory(async function* () {
164
+ yield textDelta("partial");
165
+ yield abortPart("This operation was aborted");
166
+ });
167
+
168
+ const parts = await collectParts(reasoner, baseTurn());
169
+
170
+ expect(parts[1]?.type).toBe("error");
171
+ if (parts[1]?.type === "error") {
172
+ expect(parts[1].cause.name).toBe("AbortError");
173
+ // The web/Node abort convention: err.name === "AbortError".
174
+ const isAbortError = (err: unknown): boolean => err instanceof Error && err.name === "AbortError";
175
+ expect(isAbortError(parts[1].cause)).toBe(true);
176
+ }
177
+ });
178
+
179
+ it("maps tool-error part to terminal error", async () => {
180
+ const reasoner = fromStreamFactory(async function* () {
181
+ yield toolErrorPart("tc-1", "broken_tool", new Error("tool exploded"));
182
+ });
183
+
184
+ const parts = await collectParts(reasoner, baseTurn());
185
+
186
+ expect(parts).toHaveLength(1);
187
+ expect(parts[0]?.type).toBe("error");
188
+ if (parts[0]?.type === "error") {
189
+ expect(parts[0].cause.message).toBe("tool exploded");
190
+ }
191
+ });
192
+
193
+ it("maps finish-step(error) to terminal error", async () => {
194
+ const reasoner = fromStreamFactory(async function* () {
195
+ yield finishStep("error", "MALFORMED_FUNCTION_CALL");
196
+ });
197
+
198
+ const parts = await collectParts(reasoner, baseTurn());
199
+
200
+ expect(parts).toHaveLength(1);
201
+ expect(parts[0]?.type).toBe("error");
202
+ if (parts[0]?.type === "error") {
203
+ expect(parts[0].cause.message).toBe(
204
+ "AI SDK provider step failed: error (MALFORMED_FUNCTION_CALL)",
205
+ );
206
+ expect(parts[0].recoverable).toBe(true);
207
+ }
208
+ });
209
+
210
+ it("extracts provider/model for cost attribution from a model object and a bare id", () => {
211
+ // Regression: the bridge emitted usage with EMPTY provider/model in production while a
212
+ // session-layer test that hand-supplied them stayed green. modelIdentity is the real
213
+ // extraction the bridge threads onto finish usage, so usage counters carry a model tag.
214
+ const objectModel = { provider: "openai", modelId: "gpt-4.1-mini" } as never;
215
+ expect(modelIdentity(objectModel)).toEqual({ provider: "openai", model: "gpt-4.1-mini" });
216
+
217
+ // A bare id string: the id IS the model; provider is unknown, so it's omitted (not "").
218
+ expect(modelIdentity("openai/gpt-4.1-mini" as never)).toEqual({ model: "openai/gpt-4.1-mini" });
219
+ });
220
+
221
+ it("maps finish(length) to finish with accumulated text", async () => {
222
+ const reasoner = fromStreamFactory(async function* () {
223
+ yield textDelta("truncated");
224
+ yield finish("length", "MAX_TOKENS");
225
+ });
226
+
227
+ const parts = await collectParts(reasoner, baseTurn());
228
+
229
+ expect(parts).toEqual([
230
+ { type: "text-delta", text: "truncated" },
231
+ { type: "finish", reason: "length", text: "truncated", usage: FINISH_USAGE },
232
+ ]);
233
+ });
234
+
235
+ it("drops reasoning-delta and tool-input-start parts", async () => {
236
+ const reasoner = fromStreamFactory(async function* () {
237
+ yield {
238
+ type: "reasoning-delta",
239
+ id: "r1",
240
+ text: "thinking...",
241
+ providerMetadata: undefined,
242
+ } as TextStreamPart<ToolSet>;
243
+ yield {
244
+ type: "tool-input-start",
245
+ id: "t1",
246
+ toolName: "search",
247
+ providerMetadata: undefined,
248
+ } as TextStreamPart<ToolSet>;
249
+ yield textDelta("answer");
250
+ yield finish("stop");
251
+ });
252
+
253
+ const parts = await collectParts(reasoner, baseTurn());
254
+
255
+ expect(parts).toEqual([
256
+ { type: "text-delta", text: "answer" },
257
+ { type: "finish", reason: "stop", text: "answer", usage: FINISH_USAGE },
258
+ ]);
259
+ });
260
+
261
+ it("yields first text-delta before source stream completes (no buffering)", async () => {
262
+ let resolveGate: (() => void) | undefined;
263
+ const gate = new Promise<void>((resolve) => {
264
+ resolveGate = resolve;
265
+ });
266
+
267
+ const reasoner = fromStreamFactory(async function* () {
268
+ yield textDelta("immediate");
269
+ await gate;
270
+ });
271
+
272
+ const iterator = reasoner.stream(baseTurn())[Symbol.asyncIterator]();
273
+ const first = await iterator.next();
274
+
275
+ expect(first.done).toBe(false);
276
+ expect(first.value).toEqual({ type: "text-delta", text: "immediate" });
277
+ resolveGate?.();
278
+ });
279
+
280
+ it("maps stream ending without finish to terminal error", async () => {
281
+ const reasoner = fromStreamFactory(async function* () {
282
+ yield textDelta("no finish");
283
+ });
284
+
285
+ const parts = await collectParts(reasoner, baseTurn());
286
+
287
+ expect(parts).toHaveLength(2);
288
+ expect(parts[1]?.type).toBe("error");
289
+ if (parts[1]?.type === "error") {
290
+ expect(parts[1].cause.message).toBe("AI SDK stream ended without a provider finish reason");
291
+ }
292
+ });
293
+
294
+ it("fromAiSdkAgent maps agent fullStream through the same table", async () => {
295
+ const agent: AiSdkAgentLike = {
296
+ async stream() {
297
+ return {
298
+ fullStream: (async function* () {
299
+ yield textDelta("From ");
300
+ yield textDelta("agent");
301
+ yield finish("stop");
302
+ })(),
303
+ };
304
+ },
305
+ };
306
+
307
+ const parts = await collectParts(fromAiSdkAgent(agent), baseTurn());
308
+
309
+ expect(parts).toEqual([
310
+ { type: "text-delta", text: "From " },
311
+ { type: "text-delta", text: "agent" },
312
+ { type: "finish", reason: "stop", text: "From agent", usage: FINISH_USAGE },
313
+ ]);
314
+ });
315
+ });
@@ -0,0 +1,291 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import {
5
+ PipelineBusImpl,
6
+ Route,
7
+ type EndOfSpeechPacket,
8
+ type InMemoryIuLedger,
9
+ type IncrementalUnitId,
10
+ type InterruptLlmPacket,
11
+ type TextToSpeechPlayoutProgressPacket,
12
+ type TextToSpeechTextPacket,
13
+ type TextToSpeechWordTimestampsPacket,
14
+ type TtsWordTimestamp,
15
+ } from "@kuralle-syrinx/core";
16
+ import type { FinishReason, TextStreamPart, ToolSet } from "ai";
17
+ import { fromStreamFactory } from "./from-ai-sdk.js";
18
+ import { ReasoningBridge } from "./index.js";
19
+
20
+ const ZERO_USAGE = {
21
+ inputTokens: 0,
22
+ inputTokenDetails: { noCacheTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 },
23
+ outputTokens: 0,
24
+ outputTokenDetails: { textTokens: 0, reasoningTokens: 0 },
25
+ totalTokens: 0,
26
+ };
27
+
28
+ type BridgeLedgerAccess = {
29
+ iuLedger: InMemoryIuLedger;
30
+ };
31
+
32
+ function bridgeLedger(plugin: ReasoningBridge): BridgeLedgerAccess {
33
+ return plugin as unknown as BridgeLedgerAccess;
34
+ }
35
+
36
+ function assistantId(contextId: string, epoch = 1): IncrementalUnitId {
37
+ return { contextId, iuId: `${contextId}#assistant`, epoch };
38
+ }
39
+
40
+ function userTurnId(contextId: string, epoch = 1): IncrementalUnitId {
41
+ return { contextId, iuId: contextId, epoch };
42
+ }
43
+
44
+ describe("ReasoningBridge heard-prefix commit (S2-01)", () => {
45
+ it("commits assistant IU with word-boundary prefix on barge-in during playback", async () => {
46
+ const packets: Array<{ packet: unknown }> = [];
47
+ const plugin = new ReasoningBridge(
48
+ fromStreamFactory(async function* () {
49
+ yield textDelta("Hello world foo bar.");
50
+ yield finish("stop");
51
+ }),
52
+ );
53
+ const bus = new PipelineBusImpl({ onPacket: (_route, packet) => packets.push({ packet }) });
54
+ const drain = bus.start();
55
+ await plugin.initialize(bus, baseConfig());
56
+ const ledger = bridgeLedger(plugin);
57
+ const ctx = "turn-word";
58
+
59
+ bus.push(Route.Main, turnComplete(ctx, "first question"));
60
+ await waitFor(() => hasPacket(packets, "llm.done", ctx));
61
+
62
+ bus.push(Route.Main, wordTimestamps(ctx, [
63
+ { word: "Hello", startMs: 0, endMs: 200 },
64
+ { word: "world", startMs: 220, endMs: 400 },
65
+ { word: "foo", startMs: 420, endMs: 600 },
66
+ { word: "bar.", startMs: 620, endMs: 800 },
67
+ ]));
68
+ bus.push(Route.Main, playoutProgress(ctx, 450));
69
+ await new Promise((resolve) => setTimeout(resolve, 20));
70
+
71
+ bus.push(Route.Critical, interruptLlm(ctx));
72
+ await waitFor(() => hasMetric(packets, "llm.history_truncated_to_spoken"));
73
+
74
+ const spoken = "Hello world";
75
+ const iu = ledger.iuLedger.get(assistantId(ctx))!;
76
+ expect(iu.state).toBe("committed");
77
+ expect(iu.committedPrefix?.chars).toBe(spoken.length);
78
+ expect(iu.committedPrefix?.ms).toBe(450);
79
+ expect(iu.committedPrefix?.chars).toBeLessThan("Hello world foo bar.".length);
80
+
81
+ bus.stop();
82
+ await drain;
83
+ await plugin.close();
84
+ });
85
+
86
+ it("commits assistant IU with spokenByContext prefix when word timestamps are absent", async () => {
87
+ const packets: Array<{ packet: unknown }> = [];
88
+ const plugin = new ReasoningBridge(
89
+ fromStreamFactory(async function* () {
90
+ yield textDelta("Sentence one. Sentence two.");
91
+ yield finish("stop");
92
+ }),
93
+ );
94
+ const bus = new PipelineBusImpl({ onPacket: (_route, packet) => packets.push({ packet }) });
95
+ const drain = bus.start();
96
+ await plugin.initialize(bus, baseConfig());
97
+ const ledger = bridgeLedger(plugin);
98
+ const ctx = "turn-fallback";
99
+
100
+ bus.push(Route.Main, turnComplete(ctx, "first question"));
101
+ await waitFor(() => hasPacket(packets, "llm.done", ctx));
102
+
103
+ bus.push(Route.Main, ttsText(ctx, "Sentence one."));
104
+ await new Promise((resolve) => setTimeout(resolve, 10));
105
+ bus.push(Route.Critical, interruptLlm(ctx));
106
+ await waitFor(() => hasMetric(packets, "llm.history_truncated_to_spoken"));
107
+
108
+ const spoken = "Sentence one.";
109
+ const iu = ledger.iuLedger.get(assistantId(ctx))!;
110
+ expect(iu.state).toBe("committed");
111
+ expect(iu.committedPrefix?.chars).toBe(spoken.length);
112
+
113
+ bus.stop();
114
+ await drain;
115
+ await plugin.close();
116
+ });
117
+
118
+ it("commits heard prefix on mid-stream interrupt without losing streamed packets", async () => {
119
+ const packets: Array<{ route: Route; packet: unknown }> = [];
120
+ const plugin = new ReasoningBridge(
121
+ fromStreamFactory(async function* ({ signal }) {
122
+ yield textDelta("Hello");
123
+ await new Promise<void>((resolve) => {
124
+ if (signal.aborted) {
125
+ resolve();
126
+ return;
127
+ }
128
+ signal.addEventListener("abort", () => resolve(), { once: true });
129
+ });
130
+ }),
131
+ );
132
+ const bus = new PipelineBusImpl({ onPacket: (route, packet) => packets.push({ route, packet }) });
133
+ const drain = bus.start();
134
+ await plugin.initialize(bus, baseConfig());
135
+ const ledger = bridgeLedger(plugin);
136
+ const ctx = "turn-mid";
137
+
138
+ bus.push(Route.Main, turnComplete(ctx, "first question"));
139
+ await waitFor(() =>
140
+ packets.some(
141
+ ({ packet }) =>
142
+ (packet as { kind?: string }).kind === "llm.delta" &&
143
+ (packet as { text?: string }).text === "Hello",
144
+ ),
145
+ );
146
+
147
+ bus.push(Route.Main, ttsText(ctx, "Hello"));
148
+ await new Promise((resolve) => setTimeout(resolve, 10));
149
+ bus.push(Route.Critical, interruptLlm(ctx));
150
+ await waitFor(() => hasMetric(packets, "llm.history_truncated_to_spoken"));
151
+
152
+ const iu = ledger.iuLedger.get(assistantId(ctx))!;
153
+ expect(iu.state).toBe("committed");
154
+ expect(iu.committedPrefix?.chars).toBe("Hello".length);
155
+
156
+ expect(packets).toContainEqual({
157
+ route: Route.Main,
158
+ packet: expect.objectContaining({ kind: "llm.delta", contextId: ctx, text: "Hello" }),
159
+ });
160
+ expect(packets.some(({ packet }) => (packet as { kind?: string }).kind === "llm.done")).toBe(false);
161
+
162
+ bus.stop();
163
+ await drain;
164
+ await plugin.close();
165
+ });
166
+
167
+ it("commits assistant IU fully on clean completion without a truncated prefix", async () => {
168
+ const packets: Array<{ packet: unknown }> = [];
169
+ const plugin = new ReasoningBridge(
170
+ fromStreamFactory(async function* () {
171
+ yield textDelta("Clean answer.");
172
+ yield finish("stop");
173
+ }),
174
+ );
175
+ const bus = new PipelineBusImpl({ onPacket: (_route, packet) => packets.push({ packet }) });
176
+ const drain = bus.start();
177
+ await plugin.initialize(bus, baseConfig());
178
+ const ledger = bridgeLedger(plugin);
179
+ const ctx = "turn-clean";
180
+
181
+ bus.push(Route.Main, turnComplete(ctx, "question"));
182
+ await waitFor(() => hasPacket(packets, "llm.done", ctx));
183
+
184
+ const iu = ledger.iuLedger.get(assistantId(ctx))!;
185
+ expect(iu.state).toBe("committed");
186
+ expect(iu.committedPrefix).toBeUndefined();
187
+
188
+ bus.stop();
189
+ await drain;
190
+ await plugin.close();
191
+ });
192
+
193
+ it("keeps distinct assistant and user-turn IUs in the ledger for one contextId", async () => {
194
+ const plugin = new ReasoningBridge(
195
+ fromStreamFactory(async function* () {
196
+ yield textDelta("Answer.");
197
+ yield finish("stop");
198
+ }),
199
+ { speculative: true },
200
+ );
201
+ const bus = new PipelineBusImpl({ onPacket: () => {} });
202
+ const drain = bus.start();
203
+ await plugin.initialize(bus, baseConfig());
204
+ const ledger = bridgeLedger(plugin);
205
+ const ctx = "turn-dual";
206
+
207
+ bus.push(Route.Main, eosInterim(ctx, "hello"));
208
+ await new Promise((resolve) => setTimeout(resolve, 30));
209
+
210
+ const userIu = ledger.iuLedger.get(userTurnId(ctx));
211
+ const assistantIu = ledger.iuLedger.get(assistantId(ctx));
212
+ expect(userIu?.kind).toBe("user_turn");
213
+ expect(assistantIu?.kind).toBe("assistant_response");
214
+ expect(userIu?.id.iuId).toBe(ctx);
215
+ expect(assistantIu?.id.iuId).toBe(`${ctx}#assistant`);
216
+ expect(userIu?.id.epoch).toBe(assistantIu?.id.epoch);
217
+
218
+ bus.stop();
219
+ await drain;
220
+ await plugin.close();
221
+ });
222
+ });
223
+
224
+ function baseConfig(): Record<string, unknown> {
225
+ return {
226
+ api_key: "test-key",
227
+ model: "gpt-test",
228
+ system_prompt: "test",
229
+ retry_max_attempts: 1,
230
+ timeout_ms: 1000,
231
+ };
232
+ }
233
+
234
+ function turnComplete(contextId: string, text: string): EndOfSpeechPacket {
235
+ return { kind: "eos.turn_complete", contextId, timestampMs: Date.now(), text, transcripts: [] };
236
+ }
237
+
238
+ function eosInterim(contextId: string, text: string): { kind: "eos.interim"; contextId: string; timestampMs: number; text: string } {
239
+ return { kind: "eos.interim", contextId, timestampMs: Date.now(), text };
240
+ }
241
+
242
+ function ttsText(contextId: string, text: string): TextToSpeechTextPacket {
243
+ return { kind: "tts.text", contextId, timestampMs: Date.now(), text };
244
+ }
245
+
246
+ function wordTimestamps(contextId: string, words: TtsWordTimestamp[]): TextToSpeechWordTimestampsPacket {
247
+ return { kind: "tts.word_timestamps", contextId, timestampMs: Date.now(), words };
248
+ }
249
+
250
+ function playoutProgress(contextId: string, playedOutMs: number): TextToSpeechPlayoutProgressPacket {
251
+ return { kind: "tts.playout_progress", contextId, timestampMs: Date.now(), playedOutMs, complete: false };
252
+ }
253
+
254
+ function interruptLlm(contextId: string): InterruptLlmPacket {
255
+ return { kind: "interrupt.llm", contextId, timestampMs: Date.now() };
256
+ }
257
+
258
+ function textDelta(text: string): TextStreamPart<ToolSet> {
259
+ return { type: "text-delta", id: "0", text, providerMetadata: undefined } as TextStreamPart<ToolSet>;
260
+ }
261
+
262
+ function finish(finishReason: FinishReason): TextStreamPart<ToolSet> {
263
+ return {
264
+ type: "finish",
265
+ finishReason,
266
+ totalUsage: ZERO_USAGE,
267
+ usage: ZERO_USAGE,
268
+ providerMetadata: undefined,
269
+ response: {},
270
+ } as unknown as TextStreamPart<ToolSet>;
271
+ }
272
+
273
+ function hasPacket(packets: Array<{ packet: unknown }>, kind: string, contextId: string): boolean {
274
+ return packets.some(
275
+ ({ packet }) =>
276
+ (packet as { kind?: string }).kind === kind &&
277
+ (packet as { contextId?: string }).contextId === contextId,
278
+ );
279
+ }
280
+
281
+ function hasMetric(packets: Array<{ packet: unknown }>, name: string): boolean {
282
+ return packets.some(({ packet }) => (packet as { name?: string }).name === name);
283
+ }
284
+
285
+ async function waitFor(predicate: () => boolean): Promise<void> {
286
+ const started = Date.now();
287
+ while (!predicate()) {
288
+ if (Date.now() - started > 2000) throw new Error("Timed out waiting for condition");
289
+ await new Promise((resolve) => setTimeout(resolve, 10));
290
+ }
291
+ }