@kuralle-syrinx/aisdk 4.2.0 → 4.3.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 +3 -3
- package/src/from-ai-sdk.ts +40 -1
- package/src/index.ts +144 -1
- package/src/from-ai-sdk.test.ts +0 -276
- package/src/heard-prefix-commit.test.ts +0 -291
- package/src/index.test.ts +0 -989
- package/src/speculative-on-ledger.test.ts +0 -211
- package/src/speculative-post-promotion.test.ts +0 -86
- package/tsconfig.json +0 -13
|
@@ -1,291 +0,0 @@
|
|
|
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
|
-
}
|