@alexkroman1/aai 1.4.5 → 1.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/.turbo/turbo-build.log +9 -9
- package/CHANGELOG.md +13 -0
- package/dist/assemblyai-C969QGi4.js +35 -0
- package/dist/cartesia-BfQPOQ7Y.js +37 -0
- package/dist/host/_pipeline-test-fakes.d.ts +3 -1
- package/dist/host/providers/stt/deepgram.d.ts +28 -0
- package/dist/host/providers/tts/cartesia.d.ts +1 -1
- package/dist/host/providers/tts/rime.d.ts +44 -0
- package/dist/host/runtime-barrel.d.ts +4 -2
- package/dist/host/runtime-barrel.js +1432 -1208
- package/dist/host/runtime.d.ts +2 -2
- package/dist/host/s2s.d.ts +16 -16
- package/dist/host/session-core.d.ts +37 -0
- package/dist/host/transports/pipeline-transport.d.ts +48 -0
- package/dist/host/transports/s2s-transport.d.ts +19 -0
- package/dist/host/transports/types.d.ts +45 -0
- package/dist/host/ws-handler.d.ts +14 -10
- package/dist/sdk/protocol.d.ts +6 -5
- package/dist/sdk/providers/llm-barrel.js +1 -1
- package/dist/sdk/providers/stt/deepgram.d.ts +35 -0
- package/dist/sdk/providers/stt-barrel.d.ts +1 -0
- package/dist/sdk/providers/stt-barrel.js +2 -2
- package/dist/sdk/providers/tts/cartesia.d.ts +12 -4
- package/dist/sdk/providers/tts/rime.d.ts +42 -0
- package/dist/sdk/providers/tts-barrel.d.ts +1 -0
- package/dist/sdk/providers/tts-barrel.js +2 -2
- package/host/_pipeline-test-fakes.ts +6 -3
- package/host/_test-utils.ts +209 -128
- package/host/cleanup.test.ts +25 -298
- package/host/integration/pipeline-reference.integration.test.ts +30 -35
- package/host/providers/resolve.ts +10 -2
- package/host/providers/stt/deepgram.test.ts +229 -0
- package/host/providers/stt/deepgram.ts +172 -0
- package/host/providers/tts/cartesia.ts +7 -3
- package/host/providers/tts/rime.test.ts +251 -0
- package/host/providers/tts/rime.ts +322 -0
- package/host/runtime-barrel.ts +4 -2
- package/host/runtime.test.ts +13 -46
- package/host/runtime.ts +131 -23
- package/host/s2s.test.ts +122 -131
- package/host/s2s.ts +44 -52
- package/host/session-core.test.ts +257 -0
- package/host/session-core.ts +262 -0
- package/host/transports/pipeline-transport.test.ts +651 -0
- package/host/transports/pipeline-transport.ts +532 -0
- package/host/{fixture-replay.test.ts → transports/s2s-transport-fixtures.test.ts} +76 -106
- package/host/transports/s2s-transport.test.ts +56 -0
- package/host/transports/s2s-transport.ts +116 -0
- package/host/transports/types.test.ts +22 -0
- package/host/transports/types.ts +51 -0
- package/host/ws-handler.test.ts +324 -242
- package/host/ws-handler.ts +56 -59
- package/package.json +2 -1
- package/sdk/__snapshots__/exports.test.ts.snap +3 -3
- package/sdk/protocol-compat.test.ts +8 -0
- package/sdk/protocol.ts +6 -5
- package/sdk/providers/stt/deepgram.ts +43 -0
- package/sdk/providers/stt-barrel.ts +2 -0
- package/sdk/providers/tts/cartesia.ts +15 -5
- package/sdk/providers/tts/rime.ts +52 -0
- package/sdk/providers/tts-barrel.ts +2 -0
- package/dist/assemblyai-Cxg9eobY.js +0 -18
- package/dist/cartesia-DwDk2tEu.js +0 -10
- package/dist/host/pipeline-session-ctx.d.ts +0 -24
- package/dist/host/pipeline-session.d.ts +0 -52
- package/dist/host/session-ctx.d.ts +0 -73
- package/dist/host/session.d.ts +0 -62
- package/host/pipeline-session-ctx.test.ts +0 -31
- package/host/pipeline-session-ctx.ts +0 -36
- package/host/pipeline-session.test.ts +0 -672
- package/host/pipeline-session.ts +0 -533
- package/host/s2s-fixtures.test.ts +0 -237
- package/host/session-ctx.test.ts +0 -387
- package/host/session-ctx.ts +0 -134
- package/host/session-fixture-replay.test.ts +0 -128
- package/host/session.test.ts +0 -634
- package/host/session.ts +0 -412
- /package/dist/{anthropic-BrUCPKUc.js → anthropic-CcLZygAr.js} +0 -0
package/host/s2s.ts
CHANGED
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import type { JSONSchema7 } from "json-schema";
|
|
7
|
-
import { createNanoEvents, type Emitter, type Unsubscribe } from "nanoevents";
|
|
8
7
|
import WsWebSocket from "ws";
|
|
9
8
|
import { z } from "zod";
|
|
10
9
|
import { WS_OPEN } from "../sdk/constants.ts";
|
|
@@ -94,71 +93,64 @@ type DispatchContext = {
|
|
|
94
93
|
};
|
|
95
94
|
|
|
96
95
|
function dispatchS2sMessage(
|
|
97
|
-
|
|
96
|
+
callbacks: S2sCallbacks,
|
|
98
97
|
msg: S2sServerMessage,
|
|
99
98
|
state: DispatchState,
|
|
100
|
-
|
|
99
|
+
ctx: DispatchContext,
|
|
101
100
|
): void {
|
|
102
101
|
switch (msg.type) {
|
|
103
102
|
case "session.ready":
|
|
104
|
-
|
|
103
|
+
callbacks.onSessionReady(msg.session_id);
|
|
105
104
|
break;
|
|
106
105
|
case "session.updated":
|
|
107
106
|
break;
|
|
108
107
|
case "input.speech.started":
|
|
109
108
|
if (!state.speechActive) {
|
|
110
109
|
state.speechActive = true;
|
|
111
|
-
|
|
110
|
+
callbacks.onSpeechStarted();
|
|
112
111
|
}
|
|
113
112
|
break;
|
|
114
113
|
case "input.speech.stopped":
|
|
115
114
|
if (state.speechActive) {
|
|
116
115
|
state.speechActive = false;
|
|
117
|
-
|
|
116
|
+
callbacks.onSpeechStopped();
|
|
118
117
|
}
|
|
119
118
|
break;
|
|
120
119
|
case "transcript.user":
|
|
121
|
-
|
|
120
|
+
callbacks.onUserTranscript(msg.text);
|
|
122
121
|
break;
|
|
123
122
|
case "reply.started":
|
|
124
|
-
|
|
123
|
+
callbacks.onReplyStarted(msg.reply_id);
|
|
125
124
|
break;
|
|
126
125
|
case "transcript.agent":
|
|
127
|
-
|
|
128
|
-
type: "agent_transcript",
|
|
129
|
-
text: msg.text,
|
|
130
|
-
_interrupted: msg.interrupted,
|
|
131
|
-
});
|
|
126
|
+
callbacks.onAgentTranscript(msg.text, msg.interrupted);
|
|
132
127
|
break;
|
|
133
128
|
case "tool.call":
|
|
134
|
-
|
|
135
|
-
type: "tool_call",
|
|
136
|
-
toolCallId: msg.call_id,
|
|
137
|
-
toolName: msg.name,
|
|
138
|
-
args: msg.args,
|
|
139
|
-
});
|
|
129
|
+
callbacks.onToolCall(msg.call_id, msg.name, msg.args);
|
|
140
130
|
break;
|
|
141
131
|
case "reply.done":
|
|
142
132
|
// Log every raw reply.done arrival from the S2S service — one line per
|
|
143
133
|
// event, before any client-facing dedup — so we can cross-check which
|
|
144
134
|
// stalled sessions actually received reply.done for their turn.
|
|
145
|
-
|
|
146
|
-
...(
|
|
135
|
+
ctx.log.info("S2S << reply.done", {
|
|
136
|
+
...(ctx.sid !== undefined ? { sid: ctx.sid } : {}),
|
|
147
137
|
status: msg.status ?? "completed",
|
|
148
138
|
});
|
|
149
139
|
if (msg.status === "interrupted") {
|
|
150
|
-
|
|
140
|
+
callbacks.onCancelled();
|
|
151
141
|
} else {
|
|
152
|
-
|
|
142
|
+
callbacks.onReplyDone();
|
|
153
143
|
}
|
|
154
144
|
break;
|
|
155
145
|
case "session.error":
|
|
156
|
-
if (msg.code === "session_not_found" || msg.code === "session_forbidden")
|
|
157
|
-
|
|
158
|
-
else
|
|
146
|
+
if (msg.code === "session_not_found" || msg.code === "session_forbidden") {
|
|
147
|
+
callbacks.onSessionExpired();
|
|
148
|
+
} else {
|
|
149
|
+
callbacks.onError(new Error(msg.message));
|
|
150
|
+
}
|
|
159
151
|
break;
|
|
160
152
|
case "error":
|
|
161
|
-
|
|
153
|
+
callbacks.onError(new Error(msg.message));
|
|
162
154
|
break;
|
|
163
155
|
default:
|
|
164
156
|
break;
|
|
@@ -178,18 +170,24 @@ export type S2sToolSchema = {
|
|
|
178
170
|
parameters: JSONSchema7;
|
|
179
171
|
};
|
|
180
172
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
173
|
+
/** Callbacks fired into the owning session at construction time. */
|
|
174
|
+
export type S2sCallbacks = {
|
|
175
|
+
onSessionReady(sessionId: string): void;
|
|
176
|
+
onReplyStarted(replyId: string): void;
|
|
177
|
+
onReplyDone(): void;
|
|
178
|
+
onCancelled(): void;
|
|
179
|
+
onAudio(bytes: Uint8Array): void;
|
|
180
|
+
onUserTranscript(text: string): void;
|
|
181
|
+
onAgentTranscript(text: string, interrupted: boolean): void;
|
|
182
|
+
onToolCall(callId: string, name: string, args: Record<string, unknown>): void;
|
|
183
|
+
onSpeechStarted(): void;
|
|
184
|
+
onSpeechStopped(): void;
|
|
185
|
+
onSessionExpired(): void;
|
|
186
|
+
onError(err: Error): void;
|
|
187
|
+
onClose(code: number, reason: string): void;
|
|
189
188
|
};
|
|
190
189
|
|
|
191
190
|
export type S2sHandle = {
|
|
192
|
-
on<K extends keyof S2sEvents>(event: K, cb: S2sEvents[K]): Unsubscribe;
|
|
193
191
|
sendAudio(audio: Uint8Array): void;
|
|
194
192
|
/**
|
|
195
193
|
* Send a pre-encoded audio wire frame. For perf-critical callers (load tests)
|
|
@@ -206,6 +204,7 @@ export type ConnectS2sOptions = {
|
|
|
206
204
|
apiKey: string;
|
|
207
205
|
config: S2SConfig;
|
|
208
206
|
createWebSocket: CreateS2sWebSocket;
|
|
207
|
+
callbacks: S2sCallbacks;
|
|
209
208
|
logger?: Logger;
|
|
210
209
|
/**
|
|
211
210
|
* Session id attached to diagnostic log lines (e.g. raw `reply.done`
|
|
@@ -216,7 +215,7 @@ export type ConnectS2sOptions = {
|
|
|
216
215
|
};
|
|
217
216
|
|
|
218
217
|
export function connectS2s(opts: ConnectS2sOptions): Promise<S2sHandle> {
|
|
219
|
-
const { apiKey, config, createWebSocket, logger: log = consoleLogger, sid } = opts;
|
|
218
|
+
const { apiKey, config, createWebSocket, callbacks, logger: log = consoleLogger, sid } = opts;
|
|
220
219
|
|
|
221
220
|
return new Promise((resolve, reject) => {
|
|
222
221
|
log.info("S2S connecting", { url: config.wssUrl });
|
|
@@ -225,7 +224,6 @@ export function connectS2s(opts: ConnectS2sOptions): Promise<S2sHandle> {
|
|
|
225
224
|
headers: { Authorization: `Bearer ${apiKey}` },
|
|
226
225
|
});
|
|
227
226
|
|
|
228
|
-
const emitter = createNanoEvents<S2sEvents>();
|
|
229
227
|
const dispatchState: DispatchState = { speechActive: false };
|
|
230
228
|
const dispatchCtx: DispatchContext = sid !== undefined ? { log, sid } : { log };
|
|
231
229
|
let opened = false;
|
|
@@ -247,8 +245,6 @@ export function connectS2s(opts: ConnectS2sOptions): Promise<S2sHandle> {
|
|
|
247
245
|
}
|
|
248
246
|
|
|
249
247
|
const handle: S2sHandle = {
|
|
250
|
-
on: emitter.on.bind(emitter),
|
|
251
|
-
|
|
252
248
|
sendAudio(audio: Uint8Array): void {
|
|
253
249
|
if (ws.readyState !== WS_OPEN) {
|
|
254
250
|
log.debug("S2S sendAudio dropped: socket not open");
|
|
@@ -263,9 +259,8 @@ export function connectS2s(opts: ConnectS2sOptions): Promise<S2sHandle> {
|
|
|
263
259
|
},
|
|
264
260
|
|
|
265
261
|
sendToolResult(callId: string, result: string): void {
|
|
266
|
-
const msg = { type: "tool.result", call_id: callId, result };
|
|
267
262
|
log.info("S2S >> tool.result", { call_id: callId, resultLength: result.length });
|
|
268
|
-
send(
|
|
263
|
+
send({ type: "tool.result", call_id: callId, result });
|
|
269
264
|
},
|
|
270
265
|
|
|
271
266
|
updateSession(sessionConfig: S2sSessionConfig): void {
|
|
@@ -299,8 +294,7 @@ export function connectS2s(opts: ConnectS2sOptions): Promise<S2sHandle> {
|
|
|
299
294
|
|
|
300
295
|
function handleAudioFastPath(obj: { type?: unknown; data?: unknown }): boolean {
|
|
301
296
|
if (obj.type === "reply.audio" && typeof obj.data === "string") {
|
|
302
|
-
|
|
303
|
-
emitter.emit("audio", { audio: audioBytes });
|
|
297
|
+
callbacks.onAudio(base64ToUint8(obj.data));
|
|
304
298
|
return true;
|
|
305
299
|
}
|
|
306
300
|
return false;
|
|
@@ -309,13 +303,13 @@ export function connectS2s(opts: ConnectS2sOptions): Promise<S2sHandle> {
|
|
|
309
303
|
function logIncoming(obj: { type?: unknown }): void {
|
|
310
304
|
// reply.audio and input.audio are ~95% of traffic — skip logging.
|
|
311
305
|
if (obj.type === "reply.audio" || obj.type === "input.audio") return;
|
|
312
|
-
// reply.done gets a richer log (sid + status) inside
|
|
306
|
+
// reply.done gets a richer log (sid + status) inside dispatch;
|
|
313
307
|
// skip the generic line here to avoid a duplicate.
|
|
314
308
|
if (obj.type === "reply.done") return;
|
|
315
309
|
log.info(`S2S << ${obj.type}`);
|
|
316
310
|
}
|
|
317
311
|
|
|
318
|
-
|
|
312
|
+
ws.addEventListener("message", (ev) => {
|
|
319
313
|
const raw = tryParseJson(ev.data);
|
|
320
314
|
if (raw === undefined) return;
|
|
321
315
|
|
|
@@ -334,10 +328,8 @@ export function connectS2s(opts: ConnectS2sOptions): Promise<S2sHandle> {
|
|
|
334
328
|
);
|
|
335
329
|
return;
|
|
336
330
|
}
|
|
337
|
-
dispatchS2sMessage(
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
ws.addEventListener("message", handleS2sMessage);
|
|
331
|
+
dispatchS2sMessage(callbacks, parsed, dispatchState, dispatchCtx);
|
|
332
|
+
});
|
|
341
333
|
|
|
342
334
|
ws.addEventListener("close", (ev) => {
|
|
343
335
|
const code = ev.code ?? 0;
|
|
@@ -346,7 +338,7 @@ export function connectS2s(opts: ConnectS2sOptions): Promise<S2sHandle> {
|
|
|
346
338
|
if (!opened) {
|
|
347
339
|
reject(new Error(`WebSocket closed before open (code: ${code})`));
|
|
348
340
|
}
|
|
349
|
-
|
|
341
|
+
callbacks.onClose(code, reason);
|
|
350
342
|
});
|
|
351
343
|
|
|
352
344
|
ws.addEventListener("error", (ev) => {
|
|
@@ -356,7 +348,7 @@ export function connectS2s(opts: ConnectS2sOptions): Promise<S2sHandle> {
|
|
|
356
348
|
if (!opened) {
|
|
357
349
|
reject(errObj);
|
|
358
350
|
} else {
|
|
359
|
-
|
|
351
|
+
callbacks.onError(errObj);
|
|
360
352
|
}
|
|
361
353
|
});
|
|
362
354
|
});
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { describe, expect, test, vi } from "vitest";
|
|
2
|
+
import type { ClientEvent, ClientSink } from "../sdk/protocol.ts";
|
|
3
|
+
import { DEFAULT_SYSTEM_PROMPT } from "../sdk/types.ts";
|
|
4
|
+
import { flush } from "./_test-utils.ts";
|
|
5
|
+
import type { SessionCore, SessionCoreOptions } from "./session-core.ts";
|
|
6
|
+
import { createSessionCore } from "./session-core.ts";
|
|
7
|
+
import type { Transport } from "./transports/types.ts";
|
|
8
|
+
|
|
9
|
+
function makeSink(): {
|
|
10
|
+
events: ClientEvent[];
|
|
11
|
+
audioChunks: Uint8Array[];
|
|
12
|
+
audioDoneCount: number;
|
|
13
|
+
sink: ClientSink;
|
|
14
|
+
} {
|
|
15
|
+
const events: ClientEvent[] = [];
|
|
16
|
+
const audioChunks: Uint8Array[] = [];
|
|
17
|
+
let audioDoneCount = 0;
|
|
18
|
+
return {
|
|
19
|
+
events,
|
|
20
|
+
audioChunks,
|
|
21
|
+
get audioDoneCount() {
|
|
22
|
+
return audioDoneCount;
|
|
23
|
+
},
|
|
24
|
+
sink: {
|
|
25
|
+
get open() {
|
|
26
|
+
return true;
|
|
27
|
+
},
|
|
28
|
+
event: (e) => {
|
|
29
|
+
events.push(e);
|
|
30
|
+
},
|
|
31
|
+
playAudioChunk: (chunk) => {
|
|
32
|
+
audioChunks.push(chunk);
|
|
33
|
+
},
|
|
34
|
+
playAudioDone: () => {
|
|
35
|
+
audioDoneCount++;
|
|
36
|
+
},
|
|
37
|
+
} satisfies ClientSink,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function makeTransport(): Transport & { starts: number; stops: number } {
|
|
42
|
+
let starts = 0,
|
|
43
|
+
stops = 0;
|
|
44
|
+
return {
|
|
45
|
+
start: async () => {
|
|
46
|
+
starts++;
|
|
47
|
+
},
|
|
48
|
+
stop: async () => {
|
|
49
|
+
stops++;
|
|
50
|
+
},
|
|
51
|
+
sendUserAudio: vi.fn(),
|
|
52
|
+
sendToolResult: vi.fn(),
|
|
53
|
+
cancelReply: vi.fn(),
|
|
54
|
+
get starts() {
|
|
55
|
+
return starts;
|
|
56
|
+
},
|
|
57
|
+
get stops() {
|
|
58
|
+
return stops;
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function makeCore(overrides: Partial<SessionCoreOptions> = {}): {
|
|
64
|
+
core: SessionCore;
|
|
65
|
+
sink: ReturnType<typeof makeSink>;
|
|
66
|
+
transport: ReturnType<typeof makeTransport>;
|
|
67
|
+
} {
|
|
68
|
+
const sink = makeSink();
|
|
69
|
+
const transport = makeTransport();
|
|
70
|
+
const core = createSessionCore({
|
|
71
|
+
id: "s-test",
|
|
72
|
+
agent: "test-agent",
|
|
73
|
+
client: sink.sink,
|
|
74
|
+
agentConfig: { name: "test", systemPrompt: DEFAULT_SYSTEM_PROMPT, greeting: "" },
|
|
75
|
+
executeTool: vi.fn(async () => "ok"),
|
|
76
|
+
transport,
|
|
77
|
+
...overrides,
|
|
78
|
+
});
|
|
79
|
+
return { core, sink, transport };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
describe("createSessionCore — lifecycle", () => {
|
|
83
|
+
test("start/stop calls transport", async () => {
|
|
84
|
+
const { core, transport } = makeCore();
|
|
85
|
+
await core.start();
|
|
86
|
+
expect(transport.starts).toBe(1);
|
|
87
|
+
await core.stop();
|
|
88
|
+
expect(transport.stops).toBe(1);
|
|
89
|
+
});
|
|
90
|
+
test("stop is idempotent", async () => {
|
|
91
|
+
const { core, transport } = makeCore();
|
|
92
|
+
await core.start();
|
|
93
|
+
await core.stop();
|
|
94
|
+
await core.stop();
|
|
95
|
+
expect(transport.stops).toBe(1);
|
|
96
|
+
});
|
|
97
|
+
test("post-stop onAudio does not reschedule the idle timer", async () => {
|
|
98
|
+
vi.useFakeTimers();
|
|
99
|
+
try {
|
|
100
|
+
const { core, sink } = makeCore({
|
|
101
|
+
agentConfig: {
|
|
102
|
+
name: "test",
|
|
103
|
+
systemPrompt: DEFAULT_SYSTEM_PROMPT,
|
|
104
|
+
greeting: "",
|
|
105
|
+
idleTimeoutMs: 1000,
|
|
106
|
+
} as unknown as SessionCoreOptions["agentConfig"],
|
|
107
|
+
});
|
|
108
|
+
await core.start();
|
|
109
|
+
await core.stop();
|
|
110
|
+
core.onAudio(new Uint8Array([1]));
|
|
111
|
+
vi.advanceTimersByTime(5000);
|
|
112
|
+
expect(sink.events.some((e) => e.type === "idle_timeout")).toBe(false);
|
|
113
|
+
} finally {
|
|
114
|
+
vi.useRealTimers();
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("createSessionCore — client inbound", () => {
|
|
120
|
+
test("onAudio forwards to transport", async () => {
|
|
121
|
+
const { core, transport } = makeCore();
|
|
122
|
+
await core.start();
|
|
123
|
+
const audio = new Uint8Array([1, 2, 3]);
|
|
124
|
+
core.onAudio(audio);
|
|
125
|
+
expect(transport.sendUserAudio).toHaveBeenCalledWith(audio);
|
|
126
|
+
});
|
|
127
|
+
test("onCancel cancels the reply and emits cancelled", async () => {
|
|
128
|
+
const { core, transport, sink } = makeCore();
|
|
129
|
+
await core.start();
|
|
130
|
+
core.onCancel();
|
|
131
|
+
expect(transport.cancelReply).toHaveBeenCalledOnce();
|
|
132
|
+
expect(sink.events.some((e) => e.type === "cancelled")).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
test("onReset emits reset", async () => {
|
|
135
|
+
const { core, sink } = makeCore();
|
|
136
|
+
await core.start();
|
|
137
|
+
core.onReset();
|
|
138
|
+
expect(sink.events.some((e) => e.type === "reset")).toBe(true);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("createSessionCore — transport inbound (basic)", () => {
|
|
143
|
+
test("onAudioChunk forwards to sink", async () => {
|
|
144
|
+
const { core, sink } = makeCore();
|
|
145
|
+
await core.start();
|
|
146
|
+
const pcm = new Uint8Array([9, 8, 7]);
|
|
147
|
+
core.onAudioChunk(pcm);
|
|
148
|
+
expect(sink.audioChunks).toContain(pcm);
|
|
149
|
+
});
|
|
150
|
+
test("onUserTranscript pushes to history and emits", async () => {
|
|
151
|
+
const { core, sink } = makeCore();
|
|
152
|
+
await core.start();
|
|
153
|
+
core.onUserTranscript("hello");
|
|
154
|
+
expect(sink.events.some((e) => e.type === "user_transcript")).toBe(true);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("createSessionCore — reply dedup", () => {
|
|
159
|
+
test("first reply_done emits reply_done + audio_done", async () => {
|
|
160
|
+
const { core, sink } = makeCore();
|
|
161
|
+
await core.start();
|
|
162
|
+
core.onReplyStarted("r1");
|
|
163
|
+
core.onReplyDone();
|
|
164
|
+
expect(sink.events.some((e) => e.type === "reply_done")).toBe(true);
|
|
165
|
+
expect(sink.audioDoneCount).toBeGreaterThanOrEqual(1);
|
|
166
|
+
});
|
|
167
|
+
test("duplicate reply_done is dropped", async () => {
|
|
168
|
+
const { core, sink } = makeCore();
|
|
169
|
+
await core.start();
|
|
170
|
+
core.onReplyStarted("r1");
|
|
171
|
+
core.onReplyDone();
|
|
172
|
+
core.onReplyDone();
|
|
173
|
+
const dones = sink.events.filter((e) => e.type === "reply_done");
|
|
174
|
+
expect(dones).toHaveLength(1);
|
|
175
|
+
});
|
|
176
|
+
test("onCancelled clears currentReplyId so subsequent replyDone is dropped", async () => {
|
|
177
|
+
const { core, sink } = makeCore();
|
|
178
|
+
await core.start();
|
|
179
|
+
core.onReplyStarted("r1");
|
|
180
|
+
core.onCancelled();
|
|
181
|
+
core.onReplyDone();
|
|
182
|
+
expect(sink.events.filter((e) => e.type === "reply_done")).toHaveLength(0);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe("createSessionCore — tool call pending results", () => {
|
|
187
|
+
test("tool_call executes, tool_call_done fires, reply_done forwards results to transport", async () => {
|
|
188
|
+
const executeTool = vi.fn(async () => "tool-output");
|
|
189
|
+
const { core, sink, transport } = makeCore({ executeTool });
|
|
190
|
+
await core.start();
|
|
191
|
+
core.onReplyStarted("r1");
|
|
192
|
+
core.onToolCall("cid", "my_tool", {});
|
|
193
|
+
// Let the async tool IIFE settle and push to pendingTools
|
|
194
|
+
await flush();
|
|
195
|
+
core.onReplyDone();
|
|
196
|
+
// Poll until tool results are forwarded and toolCallDone fires
|
|
197
|
+
await vi.waitFor(() =>
|
|
198
|
+
expect(transport.sendToolResult).toHaveBeenCalledWith("cid", "tool-output"),
|
|
199
|
+
);
|
|
200
|
+
expect(sink.events.some((e) => e.type === "tool_call_done")).toBe(true);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe("createSessionCore — idle timeout", () => {
|
|
205
|
+
test("emits idle_timeout after agentConfig.idleTimeoutMs of no audio", async () => {
|
|
206
|
+
vi.useFakeTimers();
|
|
207
|
+
try {
|
|
208
|
+
const { core, sink } = makeCore({
|
|
209
|
+
agentConfig: {
|
|
210
|
+
name: "t",
|
|
211
|
+
systemPrompt: DEFAULT_SYSTEM_PROMPT,
|
|
212
|
+
greeting: "",
|
|
213
|
+
idleTimeoutMs: 1000,
|
|
214
|
+
} as unknown as SessionCoreOptions["agentConfig"],
|
|
215
|
+
});
|
|
216
|
+
await core.start();
|
|
217
|
+
expect(sink.events.filter((e) => e.type === "idle_timeout")).toHaveLength(0);
|
|
218
|
+
vi.advanceTimersByTime(1001);
|
|
219
|
+
expect(sink.events.filter((e) => e.type === "idle_timeout")).toHaveLength(1);
|
|
220
|
+
} finally {
|
|
221
|
+
vi.useRealTimers();
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
test("onAudio resets the idle timer", async () => {
|
|
225
|
+
vi.useFakeTimers();
|
|
226
|
+
try {
|
|
227
|
+
const { core, sink } = makeCore({
|
|
228
|
+
agentConfig: {
|
|
229
|
+
name: "t",
|
|
230
|
+
systemPrompt: DEFAULT_SYSTEM_PROMPT,
|
|
231
|
+
greeting: "",
|
|
232
|
+
idleTimeoutMs: 1000,
|
|
233
|
+
} as unknown as SessionCoreOptions["agentConfig"],
|
|
234
|
+
});
|
|
235
|
+
await core.start();
|
|
236
|
+
vi.advanceTimersByTime(500);
|
|
237
|
+
core.onAudio(new Uint8Array([1]));
|
|
238
|
+
vi.advanceTimersByTime(800);
|
|
239
|
+
expect(sink.events.filter((e) => e.type === "idle_timeout")).toHaveLength(0);
|
|
240
|
+
vi.advanceTimersByTime(300);
|
|
241
|
+
expect(sink.events.filter((e) => e.type === "idle_timeout")).toHaveLength(1);
|
|
242
|
+
} finally {
|
|
243
|
+
vi.useRealTimers();
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
describe("createSessionCore — history", () => {
|
|
249
|
+
test("onHistory appends and onUserTranscript pushes user messages", async () => {
|
|
250
|
+
const { core } = makeCore();
|
|
251
|
+
await core.start();
|
|
252
|
+
core.onHistory([{ role: "user", content: "prior" }]);
|
|
253
|
+
core.onUserTranscript("now");
|
|
254
|
+
// No direct introspection — but onReset clears history and replay should see no effect on subsequent behavior.
|
|
255
|
+
core.onReset();
|
|
256
|
+
});
|
|
257
|
+
});
|