@kuralle-syrinx/cf-agents 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/dist/build-session.d.ts +105 -0
- package/dist/build-session.d.ts.map +1 -0
- package/dist/build-session.js +74 -0
- package/dist/build-session.js.map +1 -0
- package/dist/connection-socket.d.ts +41 -0
- package/dist/connection-socket.d.ts.map +1 -0
- package/dist/connection-socket.js +90 -0
- package/dist/connection-socket.js.map +1 -0
- package/dist/durable-history.d.ts +17 -0
- package/dist/durable-history.d.ts.map +1 -0
- package/dist/durable-history.js +58 -0
- package/dist/durable-history.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/r2-recorder.d.ts +28 -0
- package/dist/r2-recorder.d.ts.map +1 -0
- package/dist/r2-recorder.js +303 -0
- package/dist/r2-recorder.js.map +1 -0
- package/dist/real-agent.compile-check.d.ts +12 -0
- package/dist/real-agent.compile-check.d.ts.map +1 -0
- package/dist/real-agent.compile-check.js +35 -0
- package/dist/real-agent.compile-check.js.map +1 -0
- package/dist/with-voice.d.ts +150 -0
- package/dist/with-voice.d.ts.map +1 -0
- package/dist/with-voice.js +359 -0
- package/dist/with-voice.js.map +1 -0
- package/package.json +26 -13
- package/src/build-session.test.ts +125 -0
- package/src/connection-socket.test.ts +114 -0
- package/src/r2-recorder.test.ts +185 -0
- package/src/with-voice.test.ts +798 -0
|
@@ -0,0 +1,798 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
//
|
|
3
|
+
// Integration: drives the real `runVoiceEdgeWebSocketConnection` + real
|
|
4
|
+
// `VoiceAgentSession` through the mixin, using a faithful fake Agent base (the
|
|
5
|
+
// capture-and-patch lifecycle the agents SDK applies) and a fake Connection.
|
|
6
|
+
// Only the leaf providers (stt/tts plugins, reasoner) are stubbed.
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, vi } from "vitest";
|
|
9
|
+
import type { PipelineBus, Reasoner, UserAudioReceivedPacket, VoicePlugin } from "@kuralle-syrinx/core";
|
|
10
|
+
import { encodePcm16ToMuLaw } from "@kuralle-syrinx/core/audio";
|
|
11
|
+
import type { RealtimeAdapter, RealtimeEvent } from "@kuralle-syrinx/realtime";
|
|
12
|
+
import {
|
|
13
|
+
withVoice,
|
|
14
|
+
type DelegateQueryContext,
|
|
15
|
+
type DelegateResultContext,
|
|
16
|
+
type ToolCallStartContext,
|
|
17
|
+
} from "./with-voice.js";
|
|
18
|
+
import type { VoicePipeline, VoicePipelineContext } from "./build-session.js";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* In-memory emulation of the DO-SQLite statements SqliteReasonerSessionStore issues,
|
|
22
|
+
* shared across agent instances to simulate one Durable Object across evictions.
|
|
23
|
+
*/
|
|
24
|
+
function sqliteFake() {
|
|
25
|
+
const history = new Map<string, Array<{ seq: number; role: string; content: string; tool_call_id: string | null }>>();
|
|
26
|
+
const handles = new Map<string, string>();
|
|
27
|
+
const sql = (strings: TemplateStringsArray, ...values: unknown[]): unknown[] => {
|
|
28
|
+
const query = strings.join("?").replace(/\s+/g, " ").trim();
|
|
29
|
+
if (query.startsWith("CREATE TABLE")) return [];
|
|
30
|
+
if (query.includes("SELECT role, content, tool_call_id FROM syrinx_reasoner_history")) {
|
|
31
|
+
return [...(history.get(String(values[0])) ?? [])].sort((a, b) => a.seq - b.seq);
|
|
32
|
+
}
|
|
33
|
+
if (query.includes("DELETE FROM syrinx_reasoner_history")) {
|
|
34
|
+
history.delete(String(values[0]));
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
if (query.includes("INSERT INTO syrinx_reasoner_history")) {
|
|
38
|
+
const [sid, seq, role, content, toolCallId] = values;
|
|
39
|
+
const rows = history.get(String(sid)) ?? [];
|
|
40
|
+
rows.push({
|
|
41
|
+
seq: Number(seq),
|
|
42
|
+
role: String(role),
|
|
43
|
+
content: String(content),
|
|
44
|
+
tool_call_id: toolCallId === null ? null : String(toolCallId),
|
|
45
|
+
});
|
|
46
|
+
history.set(String(sid), rows);
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
if (query.includes("SELECT handle FROM syrinx_resume_handle")) {
|
|
50
|
+
const handle = handles.get(String(values[0]));
|
|
51
|
+
return handle ? [{ handle }] : [];
|
|
52
|
+
}
|
|
53
|
+
if (query.includes("DELETE FROM syrinx_resume_handle")) {
|
|
54
|
+
handles.delete(String(values[0]));
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
if (query.includes("INSERT INTO syrinx_resume_handle")) {
|
|
58
|
+
handles.set(String(values[0]), String(values[1]));
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
return [];
|
|
62
|
+
};
|
|
63
|
+
return { sql, history, handles };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Controllable fake realtime front — `emit()` pushes provider events into the bridge. */
|
|
67
|
+
class FakeFront implements RealtimeAdapter {
|
|
68
|
+
readonly caps = {
|
|
69
|
+
inputSampleRateHz: 24_000,
|
|
70
|
+
outputSampleRateHz: 24_000,
|
|
71
|
+
supportsConcurrentToolAudio: true,
|
|
72
|
+
supportsTruncate: true,
|
|
73
|
+
emitsServerSpeechStarted: true,
|
|
74
|
+
} as const;
|
|
75
|
+
#queued: RealtimeEvent[] = [];
|
|
76
|
+
#waiters: Array<(e: RealtimeEvent | null) => void> = [];
|
|
77
|
+
#closed = false;
|
|
78
|
+
readonly events: AsyncIterable<RealtimeEvent> = {
|
|
79
|
+
[Symbol.asyncIterator]: () => ({
|
|
80
|
+
next: async (): Promise<IteratorResult<RealtimeEvent>> => {
|
|
81
|
+
if (this.#queued.length) return { value: this.#queued.shift()!, done: false };
|
|
82
|
+
if (this.#closed) return { value: undefined, done: true };
|
|
83
|
+
const e = await new Promise<RealtimeEvent | null>((r) => this.#waiters.push(r));
|
|
84
|
+
return e === null ? { value: undefined, done: true } : { value: e, done: false };
|
|
85
|
+
},
|
|
86
|
+
}),
|
|
87
|
+
};
|
|
88
|
+
async open(): Promise<void> {}
|
|
89
|
+
sendAudio(): void {}
|
|
90
|
+
cancelResponse(): void {}
|
|
91
|
+
readonly injected: Array<{ toolId: string; text: string }> = [];
|
|
92
|
+
injectToolResult(toolId: string, text: string): void {
|
|
93
|
+
this.injected.push({ toolId, text });
|
|
94
|
+
}
|
|
95
|
+
async close(): Promise<void> {
|
|
96
|
+
this.#closed = true;
|
|
97
|
+
for (const w of this.#waiters.splice(0)) w(null);
|
|
98
|
+
}
|
|
99
|
+
emit(e: RealtimeEvent): void {
|
|
100
|
+
const w = this.#waiters.shift();
|
|
101
|
+
if (w) w(e);
|
|
102
|
+
else this.#queued.push(e);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// --- Fakes ---------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
class FakeAgentBase {
|
|
109
|
+
name = "inst-1";
|
|
110
|
+
env: Record<string, unknown>;
|
|
111
|
+
runtime?: unknown;
|
|
112
|
+
keepAliveCalls = 0;
|
|
113
|
+
disposeCalls = 0;
|
|
114
|
+
onConnectCalls = 0;
|
|
115
|
+
onCloseCalls = 0;
|
|
116
|
+
|
|
117
|
+
constructor(env: Record<string, unknown> = {}) {
|
|
118
|
+
this.env = env;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async keepAlive(): Promise<() => void> {
|
|
122
|
+
this.keepAliveCalls += 1;
|
|
123
|
+
return () => {
|
|
124
|
+
this.disposeCalls += 1;
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
getConnections(): Iterable<unknown> {
|
|
129
|
+
return [];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Tagged-template stand-in; the durable-history path issues real statements
|
|
133
|
+
// against it (subclasses can back it with an in-memory emulation).
|
|
134
|
+
sql(_strings?: TemplateStringsArray, ..._values: unknown[]): unknown[] {
|
|
135
|
+
return [];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
onConnect(_connection: unknown, _ctx: unknown): void {
|
|
139
|
+
this.onConnectCalls += 1;
|
|
140
|
+
}
|
|
141
|
+
onMessage(_connection: unknown, _message: unknown): void {}
|
|
142
|
+
onClose(_connection: unknown, _code: number, _reason: string, _wasClean: boolean): void {
|
|
143
|
+
this.onCloseCalls += 1;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
interface FakeConnection {
|
|
148
|
+
id: string;
|
|
149
|
+
readyState: number;
|
|
150
|
+
send(data: string | ArrayBuffer | ArrayBufferView): void;
|
|
151
|
+
close(code?: number, reason?: string): void;
|
|
152
|
+
frames: Array<string | ArrayBuffer | ArrayBufferView>;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function fakeConnection(id = "conn-1"): FakeConnection {
|
|
156
|
+
const frames: Array<string | ArrayBuffer | ArrayBufferView> = [];
|
|
157
|
+
let open = true;
|
|
158
|
+
return {
|
|
159
|
+
id,
|
|
160
|
+
get readyState() {
|
|
161
|
+
return open ? 1 : 3;
|
|
162
|
+
},
|
|
163
|
+
send(data) {
|
|
164
|
+
frames.push(data);
|
|
165
|
+
},
|
|
166
|
+
close() {
|
|
167
|
+
open = false;
|
|
168
|
+
},
|
|
169
|
+
frames,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const jsonFrames = (conn: FakeConnection): Array<Record<string, unknown>> =>
|
|
174
|
+
conn.frames
|
|
175
|
+
.filter((f): f is string => typeof f === "string")
|
|
176
|
+
.map((f) => JSON.parse(f) as Record<string, unknown>);
|
|
177
|
+
|
|
178
|
+
const stubPlugin = (): VoicePlugin => ({ initialize: async () => {}, close: async () => {} });
|
|
179
|
+
const stubReasoner = (): Reasoner => ({
|
|
180
|
+
// eslint-disable-next-line require-yield
|
|
181
|
+
stream: async function* () {
|
|
182
|
+
return;
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const cascadedPipeline = (): VoicePipeline<Record<string, unknown>> => ({
|
|
187
|
+
kind: "cascaded",
|
|
188
|
+
stt: () => ({ plugin: stubPlugin(), config: { model: "nova-3" } }),
|
|
189
|
+
tts: () => ({ plugin: stubPlugin(), config: { voice_id: "v" } }),
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// withVoice's base constraint is the real Agent surface; the fake satisfies the
|
|
193
|
+
// runtime contract but not its exact tagged-template `sql` type — cast for the test.
|
|
194
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
195
|
+
const asBase = (cls: unknown): any => cls;
|
|
196
|
+
|
|
197
|
+
const ctx = () => ({ request: new Request("https://agent.test/agents/voice/inst-1?sessionId=test-session") });
|
|
198
|
+
const ctxForSession = (sessionId: string) => ({
|
|
199
|
+
request: new Request(`https://agent.test/agents/voice/inst-1?sessionId=${encodeURIComponent(sessionId)}`),
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// --- Tests ---------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
describe("withVoice(Agent)", () => {
|
|
205
|
+
it("starts a Syrinx voice session on connect and sends a `ready` frame", async () => {
|
|
206
|
+
const VoiceAgent = withVoice<Record<string, unknown>, ReturnType<typeof asBase>>(
|
|
207
|
+
asBase(FakeAgentBase),
|
|
208
|
+
{ pipeline: cascadedPipeline(), reasoner: () => stubReasoner() },
|
|
209
|
+
);
|
|
210
|
+
const agent = new VoiceAgent({});
|
|
211
|
+
const conn = fakeConnection();
|
|
212
|
+
|
|
213
|
+
agent.onConnect(conn, ctx());
|
|
214
|
+
|
|
215
|
+
await vi.waitFor(() => {
|
|
216
|
+
expect(jsonFrames(conn).some((f) => f["type"] === "ready")).toBe(true);
|
|
217
|
+
});
|
|
218
|
+
const ready = jsonFrames(conn).find((f) => f["type"] === "ready");
|
|
219
|
+
expect(ready?.["sessionId"]).toBe("test-session");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("chains the base onConnect and holds a keepAlive lease, released on close", async () => {
|
|
223
|
+
const VoiceAgent = withVoice<Record<string, unknown>, ReturnType<typeof asBase>>(
|
|
224
|
+
asBase(FakeAgentBase),
|
|
225
|
+
{ pipeline: cascadedPipeline(), reasoner: () => stubReasoner() },
|
|
226
|
+
);
|
|
227
|
+
const agent = new VoiceAgent({});
|
|
228
|
+
const conn = fakeConnection();
|
|
229
|
+
|
|
230
|
+
agent.onConnect(conn, ctx());
|
|
231
|
+
await vi.waitFor(() => expect(agent.keepAliveCalls).toBe(1));
|
|
232
|
+
expect(agent.onConnectCalls).toBe(1); // base hook still ran
|
|
233
|
+
expect(agent.disposeCalls).toBe(0);
|
|
234
|
+
|
|
235
|
+
agent.onClose(conn, 1000, "bye", true);
|
|
236
|
+
expect(agent.onCloseCalls).toBe(1); // base hook still ran
|
|
237
|
+
expect(agent.disposeCalls).toBe(1); // lease released
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("pumps inbound frames into the running session without throwing", async () => {
|
|
241
|
+
const VoiceAgent = withVoice<Record<string, unknown>, ReturnType<typeof asBase>>(
|
|
242
|
+
asBase(FakeAgentBase),
|
|
243
|
+
{ pipeline: cascadedPipeline(), reasoner: () => stubReasoner() },
|
|
244
|
+
);
|
|
245
|
+
const agent = new VoiceAgent({});
|
|
246
|
+
const conn = fakeConnection();
|
|
247
|
+
|
|
248
|
+
agent.onConnect(conn, ctx());
|
|
249
|
+
await vi.waitFor(() => expect(jsonFrames(conn).some((f) => f["type"] === "ready")).toBe(true));
|
|
250
|
+
|
|
251
|
+
// A ping after ready is handled as a no-op by the edge protocol.
|
|
252
|
+
expect(() => agent.onMessage(conn, JSON.stringify({ type: "ping" }))).not.toThrow();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("defaults the reasoner to the agent's kuralle runtime when none is supplied", async () => {
|
|
256
|
+
class RuntimeAgent extends FakeAgentBase {
|
|
257
|
+
override runtime = {
|
|
258
|
+
run: () => ({
|
|
259
|
+
events: (async function* () {
|
|
260
|
+
return;
|
|
261
|
+
})(),
|
|
262
|
+
}),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
const VoiceAgent = withVoice<Record<string, unknown>, ReturnType<typeof asBase>>(
|
|
266
|
+
asBase(RuntimeAgent),
|
|
267
|
+
{ pipeline: cascadedPipeline() }, // no explicit reasoner
|
|
268
|
+
);
|
|
269
|
+
const agent = new VoiceAgent({});
|
|
270
|
+
const conn = fakeConnection();
|
|
271
|
+
|
|
272
|
+
agent.onConnect(conn, ctx());
|
|
273
|
+
await vi.waitFor(() => {
|
|
274
|
+
expect(jsonFrames(conn).some((f) => f["type"] === "ready")).toBe(true);
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("reports an initialization error frame when a cascaded agent has no brain", async () => {
|
|
279
|
+
const VoiceAgent = withVoice<Record<string, unknown>, ReturnType<typeof asBase>>(
|
|
280
|
+
asBase(FakeAgentBase),
|
|
281
|
+
{ pipeline: cascadedPipeline() }, // no reasoner, no runtime
|
|
282
|
+
);
|
|
283
|
+
const agent = new VoiceAgent({});
|
|
284
|
+
const conn = fakeConnection();
|
|
285
|
+
|
|
286
|
+
agent.onConnect(conn, ctx());
|
|
287
|
+
await vi.waitFor(() => {
|
|
288
|
+
const err = jsonFrames(conn).find((f) => f["type"] === "error");
|
|
289
|
+
expect(err).toBeDefined();
|
|
290
|
+
expect(String(err?.["message"])).toMatch(/reasoner/);
|
|
291
|
+
});
|
|
292
|
+
expect(jsonFrames(conn).some((f) => f["type"] === "ready")).toBe(false);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("gives each connection a distinct session id when no ?sessionId= is supplied", async () => {
|
|
296
|
+
const VoiceAgent = withVoice<Record<string, unknown>, ReturnType<typeof asBase>>(
|
|
297
|
+
asBase(FakeAgentBase),
|
|
298
|
+
{ pipeline: cascadedPipeline(), reasoner: () => stubReasoner() },
|
|
299
|
+
);
|
|
300
|
+
const agent = new VoiceAgent({});
|
|
301
|
+
const noQuery = () => ({ request: new Request("https://agent.test/agents/voice/inst-1") });
|
|
302
|
+
const a = fakeConnection("a");
|
|
303
|
+
const b = fakeConnection("b");
|
|
304
|
+
|
|
305
|
+
agent.onConnect(a, noQuery());
|
|
306
|
+
agent.onConnect(b, noQuery());
|
|
307
|
+
|
|
308
|
+
const sid = async (c: FakeConnection): Promise<string> => {
|
|
309
|
+
await vi.waitFor(() => expect(jsonFrames(c).some((f) => f["type"] === "ready")).toBe(true));
|
|
310
|
+
return String(jsonFrames(c).find((f) => f["type"] === "ready")?.["sessionId"]);
|
|
311
|
+
};
|
|
312
|
+
const sidA = await sid(a);
|
|
313
|
+
const sidB = await sid(b);
|
|
314
|
+
|
|
315
|
+
expect(sidA).not.toBe(sidB); // not shared -> no cross-wiring
|
|
316
|
+
expect(sidA).not.toBe("inst-1"); // not defaulted to the agent name
|
|
317
|
+
expect(sidB).not.toBe("inst-1");
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("transport:\"twilio\" speaks Media Streams — a μ-law media frame reaches the engine", async () => {
|
|
321
|
+
// Capture engine-ward audio so we can prove the Twilio runner decoded the μ-law
|
|
322
|
+
// media event and pushed it onto the session bus (no edge-protocol error frame).
|
|
323
|
+
const captured: UserAudioReceivedPacket[] = [];
|
|
324
|
+
const capturingStt = (): VoicePlugin => ({
|
|
325
|
+
initialize: async (bus: PipelineBus) => {
|
|
326
|
+
bus.on<UserAudioReceivedPacket>("user.audio_received", (pkt) => { captured.push(pkt); });
|
|
327
|
+
},
|
|
328
|
+
close: async () => {},
|
|
329
|
+
});
|
|
330
|
+
const twilioPipeline: VoicePipeline<Record<string, unknown>> = {
|
|
331
|
+
kind: "cascaded",
|
|
332
|
+
stt: () => ({ plugin: capturingStt(), config: { model: "nova-3" } }),
|
|
333
|
+
tts: () => ({ plugin: stubPlugin(), config: { voice_id: "v" } }),
|
|
334
|
+
};
|
|
335
|
+
const VoiceAgent = withVoice<Record<string, unknown>, ReturnType<typeof asBase>>(
|
|
336
|
+
asBase(FakeAgentBase),
|
|
337
|
+
{ transport: "twilio", pipeline: twilioPipeline, reasoner: () => stubReasoner() },
|
|
338
|
+
);
|
|
339
|
+
const agent = new VoiceAgent({});
|
|
340
|
+
const conn = fakeConnection();
|
|
341
|
+
|
|
342
|
+
agent.onConnect(conn, ctx());
|
|
343
|
+
|
|
344
|
+
// Twilio handshake, then a non-silent μ-law 8 kHz frame (160 samples = 20ms).
|
|
345
|
+
const tone = Int16Array.from({ length: 160 }, (_, i) => Math.round(8000 * Math.sin(i / 4)));
|
|
346
|
+
const payload = Buffer.from(encodePcm16ToMuLaw(tone)).toString("base64");
|
|
347
|
+
agent.onMessage(conn, JSON.stringify({ event: "connected", protocol: "Call", version: "1.0.0" }));
|
|
348
|
+
agent.onMessage(conn, JSON.stringify({
|
|
349
|
+
event: "start",
|
|
350
|
+
streamSid: "MZ1",
|
|
351
|
+
start: { streamSid: "MZ1", callSid: "CA1", mediaFormat: { encoding: "audio/x-mulaw", sampleRate: 8000, channels: 1 } },
|
|
352
|
+
}));
|
|
353
|
+
agent.onMessage(conn, JSON.stringify({ event: "media", streamSid: "MZ1", media: { payload } }));
|
|
354
|
+
|
|
355
|
+
await vi.waitFor(() => expect(captured.length).toBeGreaterThan(0));
|
|
356
|
+
// Resampled 8 kHz → 16 kHz engine PCM: non-empty audio reached the engine.
|
|
357
|
+
expect(captured[0]!.audio.byteLength).toBeGreaterThan(0);
|
|
358
|
+
// No edge-protocol error frame (the Twilio runner accepted the handshake).
|
|
359
|
+
expect(jsonFrames(conn).some((f) => f["type"] === "error")).toBe(false);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("transport:\"telnyx\" speaks Media Streaming — a PCMU media frame reaches the engine", async () => {
|
|
363
|
+
const captured: UserAudioReceivedPacket[] = [];
|
|
364
|
+
const capturingStt = (): VoicePlugin => ({
|
|
365
|
+
initialize: async (bus: PipelineBus) => {
|
|
366
|
+
bus.on<UserAudioReceivedPacket>("user.audio_received", (pkt) => { captured.push(pkt); });
|
|
367
|
+
},
|
|
368
|
+
close: async () => {},
|
|
369
|
+
});
|
|
370
|
+
const telnyxPipeline: VoicePipeline<Record<string, unknown>> = {
|
|
371
|
+
kind: "cascaded",
|
|
372
|
+
stt: () => ({ plugin: capturingStt(), config: { model: "nova-3" } }),
|
|
373
|
+
tts: () => ({ plugin: stubPlugin(), config: { voice_id: "v" } }),
|
|
374
|
+
};
|
|
375
|
+
const VoiceAgent = withVoice<Record<string, unknown>, ReturnType<typeof asBase>>(
|
|
376
|
+
asBase(FakeAgentBase),
|
|
377
|
+
{ transport: "telnyx", pipeline: telnyxPipeline, reasoner: () => stubReasoner() },
|
|
378
|
+
);
|
|
379
|
+
const agent = new VoiceAgent({});
|
|
380
|
+
const conn = fakeConnection();
|
|
381
|
+
|
|
382
|
+
agent.onConnect(conn, ctx());
|
|
383
|
+
|
|
384
|
+
const tone = Int16Array.from({ length: 160 }, (_, i) => Math.round(8000 * Math.sin(i / 4)));
|
|
385
|
+
const payload = Buffer.from(encodePcm16ToMuLaw(tone)).toString("base64");
|
|
386
|
+
agent.onMessage(conn, JSON.stringify({ event: "connected", version: "1.0.0" }));
|
|
387
|
+
agent.onMessage(conn, JSON.stringify({
|
|
388
|
+
event: "start",
|
|
389
|
+
stream_id: "stream-1",
|
|
390
|
+
start: {
|
|
391
|
+
stream_id: "stream-1",
|
|
392
|
+
call_control_id: "v3:cc1",
|
|
393
|
+
media_format: { encoding: "PCMU", sample_rate: 8000, channels: 1 },
|
|
394
|
+
},
|
|
395
|
+
}));
|
|
396
|
+
agent.onMessage(conn, JSON.stringify({ event: "media", stream_id: "stream-1", media: { payload } }));
|
|
397
|
+
|
|
398
|
+
await vi.waitFor(() => expect(captured.length).toBeGreaterThan(0));
|
|
399
|
+
expect(captured[0]!.audio.byteLength).toBeGreaterThan(0);
|
|
400
|
+
expect(captured[0]!.contextId).toBe("telnyx-v3:cc1");
|
|
401
|
+
expect(jsonFrames(conn).some((f) => f["type"] === "error")).toBe(false);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it("fires onToolCallStart with the tool + the live connection when the delegate tool is invoked", async () => {
|
|
405
|
+
const front = new FakeFront();
|
|
406
|
+
const calls: ToolCallStartContext[] = [];
|
|
407
|
+
const realtimePipeline: VoicePipeline<Record<string, unknown>> = {
|
|
408
|
+
kind: "realtime",
|
|
409
|
+
front: () => front,
|
|
410
|
+
delegateToolName: "consult_knowledge",
|
|
411
|
+
};
|
|
412
|
+
const VoiceAgent = withVoice<Record<string, unknown>, ReturnType<typeof asBase>>(
|
|
413
|
+
asBase(FakeAgentBase),
|
|
414
|
+
{
|
|
415
|
+
pipeline: realtimePipeline,
|
|
416
|
+
reasoner: () => stubReasoner(),
|
|
417
|
+
onToolCallStart: (c) => { calls.push(c); },
|
|
418
|
+
},
|
|
419
|
+
);
|
|
420
|
+
const agent = new VoiceAgent({});
|
|
421
|
+
const conn = fakeConnection();
|
|
422
|
+
|
|
423
|
+
agent.onConnect(conn, ctx());
|
|
424
|
+
await vi.waitFor(() => expect(jsonFrames(conn).some((f) => f["type"] === "ready")).toBe(true));
|
|
425
|
+
|
|
426
|
+
// The front model starts the turn, then invokes the delegate tool — the latency-mask moment.
|
|
427
|
+
front.emit({ type: "response_started" });
|
|
428
|
+
front.emit({ type: "tool_call", toolId: "t1", toolName: "consult_knowledge", args: { query: "fees" } });
|
|
429
|
+
|
|
430
|
+
await vi.waitFor(() => expect(calls.length).toBeGreaterThan(0));
|
|
431
|
+
expect(calls[0]!.toolName).toBe("consult_knowledge");
|
|
432
|
+
expect(calls[0]!.args).toEqual({ query: "fees" });
|
|
433
|
+
expect(calls[0]!.sessionId).toBe("test-session");
|
|
434
|
+
// The app gets the real connection — it can `connection.send(...)` a preamble/earcon trigger.
|
|
435
|
+
expect(calls[0]!.connection).toBe(conn);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it("G2/WBS-1: fires onDelegateQuery/onDelegateResult around the reasoner run (SLIIT log-wrapper replacement)", async () => {
|
|
439
|
+
const front = new FakeFront();
|
|
440
|
+
const answeringReasoner = (): Reasoner => ({
|
|
441
|
+
stream: async function* () {
|
|
442
|
+
yield { type: "text-delta", text: "March 31." } as const;
|
|
443
|
+
yield { type: "finish", reason: "stop", text: "March 31." } as const;
|
|
444
|
+
},
|
|
445
|
+
});
|
|
446
|
+
const queries: DelegateQueryContext[] = [];
|
|
447
|
+
const results: DelegateResultContext[] = [];
|
|
448
|
+
const VoiceAgent = withVoice<Record<string, unknown>, ReturnType<typeof asBase>>(
|
|
449
|
+
asBase(FakeAgentBase),
|
|
450
|
+
{
|
|
451
|
+
pipeline: { kind: "realtime", front: () => front, delegateToolName: "consult_knowledge" },
|
|
452
|
+
reasoner: () => answeringReasoner(),
|
|
453
|
+
onDelegateQuery: (c) => { queries.push(c); },
|
|
454
|
+
onDelegateResult: (c) => { results.push(c); },
|
|
455
|
+
},
|
|
456
|
+
);
|
|
457
|
+
const agent = new VoiceAgent({});
|
|
458
|
+
const conn = fakeConnection();
|
|
459
|
+
|
|
460
|
+
agent.onConnect(conn, ctx());
|
|
461
|
+
await vi.waitFor(() => expect(jsonFrames(conn).some((f) => f["type"] === "ready")).toBe(true));
|
|
462
|
+
|
|
463
|
+
front.emit({ type: "response_started" });
|
|
464
|
+
front.emit({ type: "tool_call", toolId: "t1", toolName: "consult_knowledge", args: { query: "exam deadline" } });
|
|
465
|
+
|
|
466
|
+
await vi.waitFor(() => expect(results.length).toBeGreaterThan(0));
|
|
467
|
+
expect(queries[0]).toMatchObject({
|
|
468
|
+
query: "exam deadline",
|
|
469
|
+
toolId: "t1",
|
|
470
|
+
toolName: "consult_knowledge",
|
|
471
|
+
sessionId: "test-session",
|
|
472
|
+
});
|
|
473
|
+
expect(queries[0]!.connection).toBe(conn);
|
|
474
|
+
expect(results[0]).toMatchObject({
|
|
475
|
+
query: "exam deadline",
|
|
476
|
+
answer: "March 31.",
|
|
477
|
+
grounded: false,
|
|
478
|
+
toolId: "t1",
|
|
479
|
+
toolName: "consult_knowledge",
|
|
480
|
+
sessionId: "test-session",
|
|
481
|
+
});
|
|
482
|
+
expect(results[0]!.durationMs).toBeGreaterThanOrEqual(0);
|
|
483
|
+
expect(results[0]!.connection).toBe(conn);
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it("routes post-result messages through the originating connection and resolved session", async () => {
|
|
487
|
+
const fronts = new Map<string, FakeFront>();
|
|
488
|
+
const results: DelegateResultContext[] = [];
|
|
489
|
+
const VoiceAgent = withVoice<Record<string, unknown>, ReturnType<typeof asBase>>(
|
|
490
|
+
asBase(FakeAgentBase),
|
|
491
|
+
{
|
|
492
|
+
pipeline: {
|
|
493
|
+
kind: "realtime",
|
|
494
|
+
front: (_env, pipelineCtx) => {
|
|
495
|
+
const front = new FakeFront();
|
|
496
|
+
fronts.set(pipelineCtx.sessionId, front);
|
|
497
|
+
return front;
|
|
498
|
+
},
|
|
499
|
+
delegateToolName: "consult_knowledge",
|
|
500
|
+
},
|
|
501
|
+
reasoner: () => ({
|
|
502
|
+
stream: async function* () {
|
|
503
|
+
yield { type: "finish", reason: "stop", text: "Answer" } as const;
|
|
504
|
+
},
|
|
505
|
+
}),
|
|
506
|
+
sessionId: (request) => `resolved-${new URL(request.url).searchParams.get("sessionId") ?? "missing"}`,
|
|
507
|
+
onDelegateResult: (result) => {
|
|
508
|
+
results.push(result);
|
|
509
|
+
result.connection.send(JSON.stringify({
|
|
510
|
+
type: "app.delegate_result",
|
|
511
|
+
sessionId: result.sessionId,
|
|
512
|
+
answer: result.answer,
|
|
513
|
+
}));
|
|
514
|
+
},
|
|
515
|
+
},
|
|
516
|
+
);
|
|
517
|
+
const agent = new VoiceAgent({});
|
|
518
|
+
const connectionA = fakeConnection("connection-a");
|
|
519
|
+
const connectionB = fakeConnection("connection-b");
|
|
520
|
+
|
|
521
|
+
agent.onConnect(connectionA, ctxForSession("wire-a"));
|
|
522
|
+
agent.onConnect(connectionB, ctxForSession("wire-b"));
|
|
523
|
+
await vi.waitFor(() => {
|
|
524
|
+
expect(jsonFrames(connectionA).some((frame) => frame["type"] === "ready")).toBe(true);
|
|
525
|
+
expect(jsonFrames(connectionB).some((frame) => frame["type"] === "ready")).toBe(true);
|
|
526
|
+
});
|
|
527
|
+
expect(jsonFrames(connectionA).find((frame) => frame["type"] === "ready")?.["sessionId"]).toBe("resolved-wire-a");
|
|
528
|
+
expect(jsonFrames(connectionB).find((frame) => frame["type"] === "ready")?.["sessionId"]).toBe("resolved-wire-b");
|
|
529
|
+
|
|
530
|
+
fronts.get("resolved-wire-a")!.emit({ type: "response_started" });
|
|
531
|
+
fronts.get("resolved-wire-a")!.emit({ type: "tool_call", toolId: "tool-a", toolName: "consult_knowledge", args: { query: "a" } });
|
|
532
|
+
fronts.get("resolved-wire-b")!.emit({ type: "response_started" });
|
|
533
|
+
fronts.get("resolved-wire-b")!.emit({ type: "tool_call", toolId: "tool-b", toolName: "consult_knowledge", args: { query: "b" } });
|
|
534
|
+
|
|
535
|
+
await vi.waitFor(() => expect(results).toHaveLength(2));
|
|
536
|
+
expect(results.map((result) => ({ sessionId: result.sessionId, connection: result.connection.id }))).toEqual([
|
|
537
|
+
{ sessionId: "resolved-wire-a", connection: "connection-a" },
|
|
538
|
+
{ sessionId: "resolved-wire-b", connection: "connection-b" },
|
|
539
|
+
]);
|
|
540
|
+
expect(jsonFrames(connectionA)).toContainEqual({ type: "app.delegate_result", sessionId: "resolved-wire-a", answer: "Answer" });
|
|
541
|
+
expect(jsonFrames(connectionB)).toContainEqual({ type: "app.delegate_result", sessionId: "resolved-wire-b", answer: "Answer" });
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
it("G2/WBS-1: throwing onDelegateQuery/onDelegateResult never break the call", async () => {
|
|
545
|
+
const front = new FakeFront();
|
|
546
|
+
const answered: string[] = [];
|
|
547
|
+
const VoiceAgent = withVoice<Record<string, unknown>, ReturnType<typeof asBase>>(
|
|
548
|
+
asBase(FakeAgentBase),
|
|
549
|
+
{
|
|
550
|
+
pipeline: { kind: "realtime", front: () => front, delegateToolName: "consult_knowledge" },
|
|
551
|
+
reasoner: () => ({
|
|
552
|
+
stream: async function* () {
|
|
553
|
+
yield { type: "finish", reason: "stop", text: "ok" } as const;
|
|
554
|
+
},
|
|
555
|
+
}),
|
|
556
|
+
onDelegateQuery: () => { throw new Error("query hook blew up"); },
|
|
557
|
+
onDelegateResult: (c) => { answered.push(c.answer); throw new Error("result hook blew up"); },
|
|
558
|
+
},
|
|
559
|
+
);
|
|
560
|
+
const agent = new VoiceAgent({});
|
|
561
|
+
const conn = fakeConnection();
|
|
562
|
+
|
|
563
|
+
agent.onConnect(conn, ctx());
|
|
564
|
+
await vi.waitFor(() => expect(jsonFrames(conn).some((f) => f["type"] === "ready")).toBe(true));
|
|
565
|
+
|
|
566
|
+
front.emit({ type: "response_started" });
|
|
567
|
+
front.emit({ type: "tool_call", toolId: "t1", toolName: "consult_knowledge", args: { query: "x" } });
|
|
568
|
+
|
|
569
|
+
await vi.waitFor(() => expect(answered.length).toBeGreaterThan(0));
|
|
570
|
+
// The connection stays usable after both hooks threw.
|
|
571
|
+
expect(() => agent.onMessage(conn, JSON.stringify({ type: "ping" }))).not.toThrow();
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
it("G1/WBS-2: realtime pipeline wraps the delegate answer in the envelope (default) with the configured render directive", async () => {
|
|
575
|
+
const front = new FakeFront();
|
|
576
|
+
const VoiceAgent = withVoice<Record<string, unknown>, ReturnType<typeof asBase>>(
|
|
577
|
+
asBase(FakeAgentBase),
|
|
578
|
+
{
|
|
579
|
+
pipeline: {
|
|
580
|
+
kind: "realtime",
|
|
581
|
+
front: () => front,
|
|
582
|
+
delegateToolName: "consult_knowledge",
|
|
583
|
+
renderDirective: "translate_faithfully",
|
|
584
|
+
},
|
|
585
|
+
reasoner: () => ({
|
|
586
|
+
stream: async function* () {
|
|
587
|
+
yield { type: "finish", reason: "stop", text: "The fee is 5000 rupees." } as const;
|
|
588
|
+
},
|
|
589
|
+
}),
|
|
590
|
+
},
|
|
591
|
+
);
|
|
592
|
+
const agent = new VoiceAgent({});
|
|
593
|
+
const conn = fakeConnection();
|
|
594
|
+
|
|
595
|
+
agent.onConnect(conn, ctx());
|
|
596
|
+
await vi.waitFor(() => expect(jsonFrames(conn).some((f) => f["type"] === "ready")).toBe(true));
|
|
597
|
+
|
|
598
|
+
front.emit({ type: "response_started" });
|
|
599
|
+
front.emit({ type: "tool_call", toolId: "t1", toolName: "consult_knowledge", args: { query: "fees" } });
|
|
600
|
+
|
|
601
|
+
await vi.waitFor(() => expect(front.injected.length).toBeGreaterThan(0));
|
|
602
|
+
expect(JSON.parse(front.injected[0]!.text)).toEqual({
|
|
603
|
+
response_text: "The fee is 5000 rupees.",
|
|
604
|
+
require_repeat_verbatim: true,
|
|
605
|
+
render: "translate_faithfully",
|
|
606
|
+
});
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
it("a throwing onToolCallStart never breaks the call", async () => {
|
|
610
|
+
const front = new FakeFront();
|
|
611
|
+
const VoiceAgent = withVoice<Record<string, unknown>, ReturnType<typeof asBase>>(
|
|
612
|
+
asBase(FakeAgentBase),
|
|
613
|
+
{
|
|
614
|
+
pipeline: { kind: "realtime", front: () => front, delegateToolName: "consult_knowledge" },
|
|
615
|
+
reasoner: () => stubReasoner(),
|
|
616
|
+
onToolCallStart: () => { throw new Error("app hook blew up"); },
|
|
617
|
+
},
|
|
618
|
+
);
|
|
619
|
+
const agent = new VoiceAgent({});
|
|
620
|
+
const conn = fakeConnection();
|
|
621
|
+
|
|
622
|
+
agent.onConnect(conn, ctx());
|
|
623
|
+
await vi.waitFor(() => expect(jsonFrames(conn).some((f) => f["type"] === "ready")).toBe(true));
|
|
624
|
+
|
|
625
|
+
front.emit({ type: "response_started" });
|
|
626
|
+
expect(() => {
|
|
627
|
+
front.emit({ type: "tool_call", toolId: "t1", toolName: "consult_knowledge", args: { query: "x" } });
|
|
628
|
+
}).not.toThrow();
|
|
629
|
+
// The connection stays usable (a ping after the throwing hook is still handled).
|
|
630
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
631
|
+
expect(() => agent.onMessage(conn, JSON.stringify({ type: "ping" }))).not.toThrow();
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
it("G4/WBS-4: realtime transcript survives a simulated eviction — the next instance resumes with prior context", async () => {
|
|
635
|
+
const db = sqliteFake();
|
|
636
|
+
class DurableBase extends FakeAgentBase {
|
|
637
|
+
override sql(strings?: TemplateStringsArray, ...values: unknown[]): unknown[] {
|
|
638
|
+
return db.sql(strings!, ...values);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
const fronts: FakeFront[] = [];
|
|
642
|
+
const seenResume: Array<VoicePipelineContext["resume"]> = [];
|
|
643
|
+
const capturedMessages: Array<readonly unknown[]> = [];
|
|
644
|
+
const makeAgentClass = () =>
|
|
645
|
+
withVoice<Record<string, unknown>, ReturnType<typeof asBase>>(asBase(DurableBase), {
|
|
646
|
+
pipeline: {
|
|
647
|
+
kind: "realtime",
|
|
648
|
+
front: (_env: unknown, pipelineCtx: VoicePipelineContext) => {
|
|
649
|
+
seenResume.push(pipelineCtx.resume);
|
|
650
|
+
const front = new FakeFront();
|
|
651
|
+
fronts.push(front);
|
|
652
|
+
return front;
|
|
653
|
+
},
|
|
654
|
+
},
|
|
655
|
+
reasoner: () => ({
|
|
656
|
+
stream: (turn) => {
|
|
657
|
+
capturedMessages.push([...turn.messages]);
|
|
658
|
+
return (async function* () {
|
|
659
|
+
yield { type: "finish", reason: "stop", text: "ok" } as const;
|
|
660
|
+
})();
|
|
661
|
+
},
|
|
662
|
+
}),
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
// First lifetime: one spoken exchange lands in the durable transcript.
|
|
666
|
+
const FirstAgent = makeAgentClass();
|
|
667
|
+
const first = new FirstAgent({});
|
|
668
|
+
const firstConn = fakeConnection("c1");
|
|
669
|
+
first.onConnect(firstConn, ctx());
|
|
670
|
+
await vi.waitFor(() => expect(jsonFrames(firstConn).some((f) => f["type"] === "ready")).toBe(true));
|
|
671
|
+
fronts[0]!.emit({ type: "response_started" });
|
|
672
|
+
fronts[0]!.emit({ type: "transcript", role: "user", text: "What are the fees?", final: true });
|
|
673
|
+
fronts[0]!.emit({ type: "transcript", role: "assistant", text: "Fees are 5000 rupees.", final: true });
|
|
674
|
+
fronts[0]!.emit({ type: "response_done" });
|
|
675
|
+
await vi.waitFor(() => expect(db.history.get("test-session")?.length).toBe(2));
|
|
676
|
+
first.onClose(firstConn, 1000, "evicted", true);
|
|
677
|
+
|
|
678
|
+
// Second lifetime (fresh class + instance = evicted DO, same SQLite).
|
|
679
|
+
const SecondAgent = makeAgentClass();
|
|
680
|
+
const second = new SecondAgent({});
|
|
681
|
+
const secondConn = fakeConnection("c2");
|
|
682
|
+
second.onConnect(secondConn, ctx());
|
|
683
|
+
await vi.waitFor(() => expect(jsonFrames(secondConn).some((f) => f["type"] === "ready")).toBe(true));
|
|
684
|
+
|
|
685
|
+
// The front factory sees the prior transcript via ctx.resume.
|
|
686
|
+
expect(seenResume[1]?.history()).toEqual([
|
|
687
|
+
{ role: "user", content: "What are the fees?" },
|
|
688
|
+
{ role: "assistant", content: "Fees are 5000 rupees." },
|
|
689
|
+
]);
|
|
690
|
+
|
|
691
|
+
// A delegate turn hands the reasoner the same prior context (re-seeded, R6).
|
|
692
|
+
fronts[1]!.emit({ type: "response_started" });
|
|
693
|
+
fronts[1]!.emit({ type: "tool_call", toolId: "t1", toolName: "consult_knowledge", args: { query: "deadlines?" } });
|
|
694
|
+
await vi.waitFor(() => expect(capturedMessages.length).toBeGreaterThan(0));
|
|
695
|
+
expect(capturedMessages[0]).toEqual([
|
|
696
|
+
{ role: "user", content: "What are the fees?" },
|
|
697
|
+
{ role: "assistant", content: "Fees are 5000 rupees." },
|
|
698
|
+
]);
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
it("G4/WBS-4: persists the latest native resume handle and exposes it on the next instance (Gemini passthrough, no replay)", async () => {
|
|
702
|
+
const db = sqliteFake();
|
|
703
|
+
class DurableBase extends FakeAgentBase {
|
|
704
|
+
override sql(strings?: TemplateStringsArray, ...values: unknown[]): unknown[] {
|
|
705
|
+
return db.sql(strings!, ...values);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
const fronts: FakeFront[] = [];
|
|
709
|
+
const seenResume: Array<VoicePipelineContext["resume"]> = [];
|
|
710
|
+
const makeAgentClass = () =>
|
|
711
|
+
withVoice<Record<string, unknown>, ReturnType<typeof asBase>>(asBase(DurableBase), {
|
|
712
|
+
pipeline: {
|
|
713
|
+
kind: "realtime",
|
|
714
|
+
front: (_env: unknown, pipelineCtx: VoicePipelineContext) => {
|
|
715
|
+
seenResume.push(pipelineCtx.resume);
|
|
716
|
+
const front = new FakeFront();
|
|
717
|
+
fronts.push(front);
|
|
718
|
+
return front;
|
|
719
|
+
},
|
|
720
|
+
},
|
|
721
|
+
reasoner: () => stubReasoner(),
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
const FirstAgent = makeAgentClass();
|
|
725
|
+
const first = new FirstAgent({});
|
|
726
|
+
const firstConn = fakeConnection("c1");
|
|
727
|
+
first.onConnect(firstConn, ctx());
|
|
728
|
+
await vi.waitFor(() => expect(jsonFrames(firstConn).some((f) => f["type"] === "ready")).toBe(true));
|
|
729
|
+
expect(seenResume[0]?.providerHandle).toBeUndefined();
|
|
730
|
+
fronts[0]!.emit({ type: "resumption_handle", handle: "handle-1" });
|
|
731
|
+
fronts[0]!.emit({ type: "resumption_handle", handle: "handle-2" });
|
|
732
|
+
await vi.waitFor(() => expect(db.handles.get("test-session")).toBe("handle-2"));
|
|
733
|
+
first.onClose(firstConn, 1000, "evicted", true);
|
|
734
|
+
|
|
735
|
+
const SecondAgent = makeAgentClass();
|
|
736
|
+
const second = new SecondAgent({});
|
|
737
|
+
const secondConn = fakeConnection("c2");
|
|
738
|
+
second.onConnect(secondConn, ctx());
|
|
739
|
+
await vi.waitFor(() => expect(jsonFrames(secondConn).some((f) => f["type"] === "ready")).toBe(true));
|
|
740
|
+
expect(seenResume[1]?.providerHandle).toBe("handle-2");
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
it("G4/WBS-4: cascaded pipeline re-seeds the ReasoningBridge from durable history after eviction", async () => {
|
|
744
|
+
const db = sqliteFake();
|
|
745
|
+
class DurableBase extends FakeAgentBase {
|
|
746
|
+
override sql(strings?: TemplateStringsArray, ...values: unknown[]): unknown[] {
|
|
747
|
+
return db.sql(strings!, ...values);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
const capturedMessages: Array<readonly unknown[]> = [];
|
|
751
|
+
const makeAgentClass = (reply: string) =>
|
|
752
|
+
withVoice<Record<string, unknown>, ReturnType<typeof asBase>>(asBase(DurableBase), {
|
|
753
|
+
pipeline: cascadedPipeline(),
|
|
754
|
+
reasoner: () => ({
|
|
755
|
+
stream: (turn) => {
|
|
756
|
+
capturedMessages.push([...turn.messages]);
|
|
757
|
+
return (async function* () {
|
|
758
|
+
yield { type: "text-delta", text: reply } as const;
|
|
759
|
+
yield { type: "finish", reason: "stop", text: reply } as const;
|
|
760
|
+
})();
|
|
761
|
+
},
|
|
762
|
+
}),
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
const FirstAgent = makeAgentClass("Answer one.");
|
|
766
|
+
const first = new FirstAgent({});
|
|
767
|
+
const firstConn = fakeConnection("c1");
|
|
768
|
+
first.onConnect(firstConn, ctx());
|
|
769
|
+
await vi.waitFor(() => expect(jsonFrames(firstConn).some((f) => f["type"] === "ready")).toBe(true));
|
|
770
|
+
first.onMessage(firstConn, JSON.stringify({ type: "text", text: "First question", contextId: "turn-1" }));
|
|
771
|
+
await vi.waitFor(() => expect(db.history.get("test-session")?.length).toBe(2));
|
|
772
|
+
first.onClose(firstConn, 1000, "evicted", true);
|
|
773
|
+
|
|
774
|
+
const SecondAgent = makeAgentClass("Answer two.");
|
|
775
|
+
const second = new SecondAgent({});
|
|
776
|
+
const secondConn = fakeConnection("c2");
|
|
777
|
+
second.onConnect(secondConn, ctx());
|
|
778
|
+
await vi.waitFor(() => expect(jsonFrames(secondConn).some((f) => f["type"] === "ready")).toBe(true));
|
|
779
|
+
second.onMessage(secondConn, JSON.stringify({ type: "text", text: "Second question", contextId: "turn-2" }));
|
|
780
|
+
await vi.waitFor(() => expect(capturedMessages.length).toBe(2));
|
|
781
|
+
expect(capturedMessages[1]).toEqual([
|
|
782
|
+
{ role: "user", content: "First question" },
|
|
783
|
+
{ role: "assistant", content: "Answer one." },
|
|
784
|
+
]);
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
it("forceEndVoice closes the connection", () => {
|
|
788
|
+
const VoiceAgent = withVoice<Record<string, unknown>, ReturnType<typeof asBase>>(
|
|
789
|
+
asBase(FakeAgentBase),
|
|
790
|
+
{ pipeline: cascadedPipeline(), reasoner: () => stubReasoner() },
|
|
791
|
+
);
|
|
792
|
+
const agent = new VoiceAgent({});
|
|
793
|
+
const conn = fakeConnection();
|
|
794
|
+
agent.onConnect(conn, ctx());
|
|
795
|
+
agent.forceEndVoice(conn);
|
|
796
|
+
expect(conn.readyState).toBe(3);
|
|
797
|
+
});
|
|
798
|
+
});
|