@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.
Files changed (78) hide show
  1. package/.turbo/turbo-build.log +9 -9
  2. package/CHANGELOG.md +13 -0
  3. package/dist/assemblyai-C969QGi4.js +35 -0
  4. package/dist/cartesia-BfQPOQ7Y.js +37 -0
  5. package/dist/host/_pipeline-test-fakes.d.ts +3 -1
  6. package/dist/host/providers/stt/deepgram.d.ts +28 -0
  7. package/dist/host/providers/tts/cartesia.d.ts +1 -1
  8. package/dist/host/providers/tts/rime.d.ts +44 -0
  9. package/dist/host/runtime-barrel.d.ts +4 -2
  10. package/dist/host/runtime-barrel.js +1432 -1208
  11. package/dist/host/runtime.d.ts +2 -2
  12. package/dist/host/s2s.d.ts +16 -16
  13. package/dist/host/session-core.d.ts +37 -0
  14. package/dist/host/transports/pipeline-transport.d.ts +48 -0
  15. package/dist/host/transports/s2s-transport.d.ts +19 -0
  16. package/dist/host/transports/types.d.ts +45 -0
  17. package/dist/host/ws-handler.d.ts +14 -10
  18. package/dist/sdk/protocol.d.ts +6 -5
  19. package/dist/sdk/providers/llm-barrel.js +1 -1
  20. package/dist/sdk/providers/stt/deepgram.d.ts +35 -0
  21. package/dist/sdk/providers/stt-barrel.d.ts +1 -0
  22. package/dist/sdk/providers/stt-barrel.js +2 -2
  23. package/dist/sdk/providers/tts/cartesia.d.ts +12 -4
  24. package/dist/sdk/providers/tts/rime.d.ts +42 -0
  25. package/dist/sdk/providers/tts-barrel.d.ts +1 -0
  26. package/dist/sdk/providers/tts-barrel.js +2 -2
  27. package/host/_pipeline-test-fakes.ts +6 -3
  28. package/host/_test-utils.ts +209 -128
  29. package/host/cleanup.test.ts +25 -298
  30. package/host/integration/pipeline-reference.integration.test.ts +30 -35
  31. package/host/providers/resolve.ts +10 -2
  32. package/host/providers/stt/deepgram.test.ts +229 -0
  33. package/host/providers/stt/deepgram.ts +172 -0
  34. package/host/providers/tts/cartesia.ts +7 -3
  35. package/host/providers/tts/rime.test.ts +251 -0
  36. package/host/providers/tts/rime.ts +322 -0
  37. package/host/runtime-barrel.ts +4 -2
  38. package/host/runtime.test.ts +13 -46
  39. package/host/runtime.ts +131 -23
  40. package/host/s2s.test.ts +122 -131
  41. package/host/s2s.ts +44 -52
  42. package/host/session-core.test.ts +257 -0
  43. package/host/session-core.ts +262 -0
  44. package/host/transports/pipeline-transport.test.ts +651 -0
  45. package/host/transports/pipeline-transport.ts +532 -0
  46. package/host/{fixture-replay.test.ts → transports/s2s-transport-fixtures.test.ts} +76 -106
  47. package/host/transports/s2s-transport.test.ts +56 -0
  48. package/host/transports/s2s-transport.ts +116 -0
  49. package/host/transports/types.test.ts +22 -0
  50. package/host/transports/types.ts +51 -0
  51. package/host/ws-handler.test.ts +324 -242
  52. package/host/ws-handler.ts +56 -59
  53. package/package.json +2 -1
  54. package/sdk/__snapshots__/exports.test.ts.snap +3 -3
  55. package/sdk/protocol-compat.test.ts +8 -0
  56. package/sdk/protocol.ts +6 -5
  57. package/sdk/providers/stt/deepgram.ts +43 -0
  58. package/sdk/providers/stt-barrel.ts +2 -0
  59. package/sdk/providers/tts/cartesia.ts +15 -5
  60. package/sdk/providers/tts/rime.ts +52 -0
  61. package/sdk/providers/tts-barrel.ts +2 -0
  62. package/dist/assemblyai-Cxg9eobY.js +0 -18
  63. package/dist/cartesia-DwDk2tEu.js +0 -10
  64. package/dist/host/pipeline-session-ctx.d.ts +0 -24
  65. package/dist/host/pipeline-session.d.ts +0 -52
  66. package/dist/host/session-ctx.d.ts +0 -73
  67. package/dist/host/session.d.ts +0 -62
  68. package/host/pipeline-session-ctx.test.ts +0 -31
  69. package/host/pipeline-session-ctx.ts +0 -36
  70. package/host/pipeline-session.test.ts +0 -672
  71. package/host/pipeline-session.ts +0 -533
  72. package/host/s2s-fixtures.test.ts +0 -237
  73. package/host/session-ctx.test.ts +0 -387
  74. package/host/session-ctx.ts +0 -134
  75. package/host/session-fixture-replay.test.ts +0 -128
  76. package/host/session.test.ts +0 -634
  77. package/host/session.ts +0 -412
  78. /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
- emitter: Emitter<S2sEvents>,
96
+ callbacks: S2sCallbacks,
98
97
  msg: S2sServerMessage,
99
98
  state: DispatchState,
100
- dispatchCtx: DispatchContext,
99
+ ctx: DispatchContext,
101
100
  ): void {
102
101
  switch (msg.type) {
103
102
  case "session.ready":
104
- emitter.emit("ready", { sessionId: msg.session_id });
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
- emitter.emit("event", { type: "speech_started" });
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
- emitter.emit("event", { type: "speech_stopped" });
116
+ callbacks.onSpeechStopped();
118
117
  }
119
118
  break;
120
119
  case "transcript.user":
121
- emitter.emit("event", { type: "user_transcript", text: msg.text });
120
+ callbacks.onUserTranscript(msg.text);
122
121
  break;
123
122
  case "reply.started":
124
- emitter.emit("replyStarted", { replyId: msg.reply_id });
123
+ callbacks.onReplyStarted(msg.reply_id);
125
124
  break;
126
125
  case "transcript.agent":
127
- emitter.emit("event", {
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
- emitter.emit("event", {
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
- dispatchCtx.log.info("S2S << reply.done", {
146
- ...(dispatchCtx.sid !== undefined ? { sid: dispatchCtx.sid } : {}),
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
- emitter.emit("event", { type: "cancelled" });
140
+ callbacks.onCancelled();
151
141
  } else {
152
- emitter.emit("event", { type: "reply_done" });
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
- emitter.emit("sessionExpired");
158
- else emitter.emit("error", new Error(msg.message));
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
- emitter.emit("error", new Error(msg.message));
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
- export type S2sEvents = {
182
- ready: (detail: { sessionId: string }) => void;
183
- replyStarted: (detail: { replyId: string }) => void;
184
- sessionExpired: () => void;
185
- event: (event: S2sEvent) => void;
186
- audio: (detail: { audio: Uint8Array }) => void;
187
- error: (err: Error) => void;
188
- close: (code: number, reason: string) => void;
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(msg);
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
- const audioBytes = base64ToUint8(obj.data);
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 dispatchS2sMessage;
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
- function handleS2sMessage(ev: { data: unknown }): void {
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(emitter, parsed, dispatchState, dispatchCtx);
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
- emitter.emit("close", code, reason);
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
- emitter.emit("error", errObj);
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
+ });