discoclaw 1.2.4 → 2.0.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 (87) hide show
  1. package/.context/voice.md +30 -2
  2. package/.env.example +7 -3
  3. package/.env.example.full +13 -32
  4. package/README.md +1 -1
  5. package/dist/cli/dashboard.js +7 -1
  6. package/dist/cli/dashboard.test.js +0 -4
  7. package/dist/cli/init-wizard.js +4 -8
  8. package/dist/cli/init-wizard.test.js +4 -10
  9. package/dist/config.js +5 -38
  10. package/dist/config.test.js +8 -72
  11. package/dist/cron/executor.js +72 -1
  12. package/dist/dashboard/api/metrics.js +7 -0
  13. package/dist/dashboard/api/metrics.test.js +16 -0
  14. package/dist/dashboard/api/traces.js +14 -0
  15. package/dist/dashboard/api/traces.test.js +40 -0
  16. package/dist/dashboard/page.js +187 -8
  17. package/dist/dashboard/server.js +82 -19
  18. package/dist/dashboard/server.test.js +123 -10
  19. package/dist/discord/actions.js +112 -6
  20. package/dist/discord/actions.test.js +117 -1
  21. package/dist/discord/deferred-runner.js +306 -219
  22. package/dist/discord/help-command.js +1 -1
  23. package/dist/discord/message-coordinator.js +4 -36
  24. package/dist/discord/models-command.js +1 -1
  25. package/dist/discord/reaction-handler.js +83 -5
  26. package/dist/discord/reaction-handler.test.js +55 -0
  27. package/dist/discord/verify-push.js +31 -36
  28. package/dist/discord/verify-push.test.js +34 -6
  29. package/dist/discord/voice-command.js +1 -31
  30. package/dist/discord/voice-command.test.js +21 -259
  31. package/dist/discord/voice-status-command.js +3 -22
  32. package/dist/discord/voice-status-command.test.js +16 -124
  33. package/dist/discord-followup.test.js +133 -0
  34. package/dist/health/config-doctor.js +5 -27
  35. package/dist/health/config-doctor.test.js +1 -4
  36. package/dist/index.js +15 -28
  37. package/dist/observability/trace-store.js +56 -0
  38. package/dist/observability/trace-utils.js +31 -0
  39. package/dist/runtime/codex-cli.js +3 -2
  40. package/dist/runtime/codex-cli.test.js +33 -0
  41. package/dist/runtime/model-tiers.js +1 -1
  42. package/dist/runtime/model-tiers.test.js +9 -0
  43. package/dist/runtime/openai-tool-schemas.js +17 -0
  44. package/dist/runtime-overrides.js +2 -3
  45. package/dist/runtime-overrides.test.js +27 -193
  46. package/dist/tasks/store.js +10 -6
  47. package/dist/tasks/store.test.js +44 -0
  48. package/dist/tasks/task-action-executor.test.js +162 -50
  49. package/dist/tasks/task-action-mutations.js +22 -2
  50. package/dist/tasks/task-action-read-ops.js +7 -1
  51. package/dist/tasks/task-action-runner-types.js +19 -1
  52. package/dist/voice/audio-pipeline.js +183 -96
  53. package/dist/voice/audio-receiver.js +8 -0
  54. package/dist/voice/audio-receiver.test.js +16 -0
  55. package/dist/voice/conversation-buffer.js +16 -6
  56. package/dist/voice/providers/gemini-live-provider.js +481 -0
  57. package/dist/voice/providers/gemini-live-provider.test.js +834 -0
  58. package/dist/voice/providers/gemini-live-responder.js +267 -0
  59. package/dist/voice/providers/gemini-live-responder.test.js +615 -0
  60. package/dist/voice/providers/gemini-live-token-estimator.js +100 -0
  61. package/dist/voice/providers/gemini-live-token-estimator.test.js +160 -0
  62. package/dist/voice/providers/gemini-live-types.js +32 -0
  63. package/dist/voice/providers/gemini-tool-mapper.js +91 -0
  64. package/dist/voice/providers/gemini-tool-mapper.test.js +253 -0
  65. package/dist/voice/providers/index.js +3 -0
  66. package/dist/voice/voice-prompt-builder.js +26 -17
  67. package/dist/voice/voice-prompt-builder.test.js +16 -1
  68. package/docs/configuration.md +4 -9
  69. package/docs/official-docs.md +6 -9
  70. package/docs/runtime-switching.md +1 -1
  71. package/package.json +1 -1
  72. package/dist/voice/audio-pipeline.test.js +0 -619
  73. package/dist/voice/stt-deepgram.js +0 -154
  74. package/dist/voice/stt-deepgram.test.js +0 -275
  75. package/dist/voice/stt-factory.js +0 -42
  76. package/dist/voice/stt-factory.test.js +0 -45
  77. package/dist/voice/stt-openai.js +0 -156
  78. package/dist/voice/stt-openai.test.js +0 -281
  79. package/dist/voice/tts-cartesia.js +0 -169
  80. package/dist/voice/tts-cartesia.test.js +0 -228
  81. package/dist/voice/tts-deepgram.js +0 -84
  82. package/dist/voice/tts-deepgram.test.js +0 -220
  83. package/dist/voice/tts-factory.js +0 -52
  84. package/dist/voice/tts-factory.test.js +0 -53
  85. package/dist/voice/tts-openai.js +0 -70
  86. package/dist/voice/tts-openai.test.js +0 -138
  87. package/dist/voice/types.test.js +0 -84
@@ -0,0 +1,615 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { EventEmitter } from 'node:events';
3
+ import { PassThrough } from 'node:stream';
4
+ import { GeminiLiveResponder } from './gemini-live-responder.js';
5
+ // ---------------------------------------------------------------------------
6
+ // Mock @discordjs/voice
7
+ // ---------------------------------------------------------------------------
8
+ vi.mock('@discordjs/voice', () => ({
9
+ AudioPlayerStatus: {
10
+ Idle: 'idle',
11
+ Playing: 'playing',
12
+ Buffering: 'buffering',
13
+ Paused: 'paused',
14
+ AutoPaused: 'autopaused',
15
+ },
16
+ StreamType: {
17
+ Raw: 'raw',
18
+ Arbitrary: 'arbitrary',
19
+ OggOpus: 'ogg/opus',
20
+ Opus: 'opus',
21
+ WebmOpus: 'webm/opus',
22
+ },
23
+ createAudioPlayer: vi.fn(),
24
+ createAudioResource: vi.fn(() => ({ type: 'mock-resource' })),
25
+ }));
26
+ // ---------------------------------------------------------------------------
27
+ // Helpers
28
+ // ---------------------------------------------------------------------------
29
+ function createLogger() {
30
+ return { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
31
+ }
32
+ function createMockPlayer() {
33
+ const emitter = new EventEmitter();
34
+ const player = {
35
+ state: { status: 'idle' },
36
+ play: vi.fn(() => {
37
+ const old = { ...player.state };
38
+ player.state = { status: 'playing' };
39
+ emitter.emit('stateChange', old, player.state);
40
+ }),
41
+ stop: vi.fn(() => {
42
+ if (player.state.status !== 'idle') {
43
+ const old = { ...player.state };
44
+ player.state = { status: 'idle' };
45
+ emitter.emit('stateChange', old, player.state);
46
+ }
47
+ }),
48
+ on: vi.fn((event, listener) => {
49
+ emitter.on(event, listener);
50
+ return player;
51
+ }),
52
+ removeListener: vi.fn((event, listener) => {
53
+ emitter.removeListener(event, listener);
54
+ return player;
55
+ }),
56
+ _emitter: emitter,
57
+ };
58
+ return player;
59
+ }
60
+ function createMockConnection() {
61
+ return {
62
+ subscribe: vi.fn(),
63
+ };
64
+ }
65
+ /** Mock provider that captures the onEvent listener for test injection. */
66
+ function createMockProvider() {
67
+ let listener = null;
68
+ return {
69
+ onEvent: vi.fn((cb) => {
70
+ listener = cb;
71
+ }),
72
+ /** Inject a test event into the registered listener. */
73
+ _inject(event) {
74
+ listener?.(event);
75
+ },
76
+ };
77
+ }
78
+ function createResponder(overrides = {}) {
79
+ const player = createMockPlayer();
80
+ const log = createLogger();
81
+ const connection = createMockConnection();
82
+ const provider = createMockProvider();
83
+ const responder = new GeminiLiveResponder({
84
+ log,
85
+ connection,
86
+ provider: provider,
87
+ createPlayer: () => player,
88
+ ...overrides,
89
+ });
90
+ return { responder, player, log, connection, provider };
91
+ }
92
+ // ---------------------------------------------------------------------------
93
+ // Tests
94
+ // ---------------------------------------------------------------------------
95
+ beforeEach(() => {
96
+ vi.clearAllMocks();
97
+ });
98
+ afterEach(() => {
99
+ vi.useRealTimers();
100
+ });
101
+ describe('GeminiLiveResponder', () => {
102
+ // -----------------------------------------------------------------------
103
+ // start()
104
+ // -----------------------------------------------------------------------
105
+ describe('start', () => {
106
+ it('subscribes the player to the connection', () => {
107
+ const { responder, connection, player } = createResponder();
108
+ responder.start();
109
+ expect(connection.subscribe).toHaveBeenCalledWith(player);
110
+ });
111
+ it('registers as the provider event listener', () => {
112
+ const { responder, provider } = createResponder();
113
+ responder.start();
114
+ expect(provider.onEvent).toHaveBeenCalledWith(expect.any(Function));
115
+ });
116
+ it('registers error and stateChange handlers on the player', () => {
117
+ const { responder, player } = createResponder();
118
+ responder.start();
119
+ expect(player.on).toHaveBeenCalledWith('stateChange', expect.any(Function));
120
+ expect(player.on).toHaveBeenCalledWith('error', expect.any(Function));
121
+ });
122
+ it('is idempotent — second call is a no-op', () => {
123
+ const { responder, connection } = createResponder();
124
+ responder.start();
125
+ responder.start();
126
+ expect(connection.subscribe).toHaveBeenCalledTimes(1);
127
+ });
128
+ it('logs player errors', () => {
129
+ const { responder, player, log } = createResponder();
130
+ responder.start();
131
+ const err = new Error('playback failed');
132
+ player._emitter.emit('error', err);
133
+ expect(log.error).toHaveBeenCalledWith(expect.objectContaining({ err }), 'gemini-live-responder: audio player error');
134
+ });
135
+ });
136
+ // -----------------------------------------------------------------------
137
+ // audio events
138
+ // -----------------------------------------------------------------------
139
+ describe('audio events', () => {
140
+ it('creates a stream and starts playback on first audio chunk', () => {
141
+ const { responder, player, provider, log } = createResponder();
142
+ responder.start();
143
+ // Send an audio event (4 bytes = 2 samples at 24kHz mono)
144
+ const pcm = Buffer.alloc(4, 0x42);
145
+ provider._inject({ type: 'audio', data: pcm });
146
+ expect(player.play).toHaveBeenCalledTimes(1);
147
+ expect(log.info).toHaveBeenCalledWith({}, 'gemini-live-responder: streaming playback started');
148
+ });
149
+ it('writes upsampled audio to the stream on subsequent chunks', () => {
150
+ const { responder, player, provider } = createResponder();
151
+ responder.start();
152
+ const chunk1 = Buffer.alloc(4, 0x01);
153
+ const chunk2 = Buffer.alloc(4, 0x02);
154
+ provider._inject({ type: 'audio', data: chunk1 });
155
+ provider._inject({ type: 'audio', data: chunk2 });
156
+ // play() called only once — stream reused within same turn
157
+ expect(player.play).toHaveBeenCalledTimes(1);
158
+ });
159
+ it('upsamples 24kHz mono audio to 48kHz stereo for Discord', () => {
160
+ const { responder, provider } = createResponder();
161
+ responder.start();
162
+ // 2 samples at 24kHz mono = 4 bytes -> 4 frames at 48kHz stereo = 16 bytes
163
+ const input = Buffer.alloc(4);
164
+ input.writeInt16LE(1000, 0);
165
+ input.writeInt16LE(2000, 2);
166
+ // Spy on PassThrough.write to capture what's written
167
+ const writeSpy = vi.spyOn(PassThrough.prototype, 'write');
168
+ provider._inject({ type: 'audio', data: input });
169
+ expect(writeSpy).toHaveBeenCalled();
170
+ const written = writeSpy.mock.calls[0][0];
171
+ expect(written.length).toBe(16); // 4 frames * 4 bytes each
172
+ writeSpy.mockRestore();
173
+ });
174
+ });
175
+ // -----------------------------------------------------------------------
176
+ // text events
177
+ // -----------------------------------------------------------------------
178
+ describe('text events', () => {
179
+ it('accumulates transcript text', async () => {
180
+ vi.useFakeTimers();
181
+ const onBotResponse = vi.fn();
182
+ const { responder, provider } = createResponder({ onBotResponse });
183
+ responder.start();
184
+ provider._inject({ type: 'text', text: 'Hello ' });
185
+ provider._inject({ type: 'text', text: 'world' });
186
+ provider._inject({ type: 'turn_complete' });
187
+ await vi.runAllTimersAsync();
188
+ expect(onBotResponse).toHaveBeenCalledWith('Hello world');
189
+ });
190
+ });
191
+ // -----------------------------------------------------------------------
192
+ // input_transcript events
193
+ // -----------------------------------------------------------------------
194
+ describe('input_transcript events', () => {
195
+ it('fires onInputTranscript callback with the transcript text', () => {
196
+ const onInputTranscript = vi.fn();
197
+ const { responder, provider } = createResponder({ onInputTranscript });
198
+ responder.start();
199
+ provider._inject({ type: 'input_transcript', text: 'hello world' });
200
+ expect(onInputTranscript).toHaveBeenCalledWith('hello world');
201
+ });
202
+ it('does not crash when onInputTranscript throws', () => {
203
+ const onInputTranscript = vi.fn(() => { throw new Error('callback error'); });
204
+ const { responder, provider, log } = createResponder({ onInputTranscript });
205
+ responder.start();
206
+ provider._inject({ type: 'input_transcript', text: 'hello' });
207
+ expect(onInputTranscript).toHaveBeenCalled();
208
+ expect(log.warn).toHaveBeenCalledWith(expect.objectContaining({ err: expect.any(Error) }), 'gemini-live-responder: onInputTranscript callback error');
209
+ });
210
+ it('is ignored when no callback is provided', () => {
211
+ const { responder, provider, log } = createResponder();
212
+ responder.start();
213
+ // Should not throw
214
+ provider._inject({ type: 'input_transcript', text: 'hello' });
215
+ // No warn logged — the event is simply a no-op
216
+ expect(log.warn).not.toHaveBeenCalled();
217
+ });
218
+ });
219
+ // -----------------------------------------------------------------------
220
+ // interrupted events
221
+ // -----------------------------------------------------------------------
222
+ describe('interrupted', () => {
223
+ it('destroys the stream and stops the player immediately', () => {
224
+ const { responder, player, provider, log } = createResponder();
225
+ responder.start();
226
+ // Start an audio stream
227
+ provider._inject({ type: 'audio', data: Buffer.alloc(4, 0x42) });
228
+ expect(player.play).toHaveBeenCalledTimes(1);
229
+ // Interrupt
230
+ provider._inject({ type: 'interrupted' });
231
+ expect(player.stop).toHaveBeenCalled();
232
+ expect(log.info).toHaveBeenCalledWith({}, 'gemini-live-responder: interrupted — stopping playback');
233
+ });
234
+ it('clears accumulated transcript on interrupt', () => {
235
+ const onBotResponse = vi.fn();
236
+ const { responder, provider } = createResponder({ onBotResponse });
237
+ responder.start();
238
+ provider._inject({ type: 'text', text: 'partial transcript' });
239
+ provider._inject({ type: 'interrupted' });
240
+ provider._inject({ type: 'turn_complete' });
241
+ // The interrupted event should have cleared the transcript
242
+ expect(onBotResponse).not.toHaveBeenCalled();
243
+ });
244
+ it('is safe when no stream is active', () => {
245
+ const { responder, provider } = createResponder();
246
+ responder.start();
247
+ // Should not throw
248
+ provider._inject({ type: 'interrupted' });
249
+ });
250
+ });
251
+ // -----------------------------------------------------------------------
252
+ // turn_complete events
253
+ // -----------------------------------------------------------------------
254
+ describe('turn_complete', () => {
255
+ it('ends the stream gracefully', () => {
256
+ const { responder, provider } = createResponder();
257
+ responder.start();
258
+ // Start an audio stream
259
+ provider._inject({ type: 'audio', data: Buffer.alloc(4, 0x42) });
260
+ // Spy on PassThrough.end
261
+ const endSpy = vi.spyOn(PassThrough.prototype, 'end');
262
+ provider._inject({ type: 'turn_complete' });
263
+ expect(endSpy).toHaveBeenCalled();
264
+ endSpy.mockRestore();
265
+ });
266
+ it('fires onBotResponse with accumulated transcript', async () => {
267
+ vi.useFakeTimers();
268
+ const onBotResponse = vi.fn();
269
+ const { responder, provider } = createResponder({ onBotResponse });
270
+ responder.start();
271
+ provider._inject({ type: 'text', text: 'Hello from Gemini' });
272
+ provider._inject({ type: 'turn_complete' });
273
+ await vi.runAllTimersAsync();
274
+ expect(onBotResponse).toHaveBeenCalledWith('Hello from Gemini');
275
+ });
276
+ it('does not fire onBotResponse when transcript is empty', async () => {
277
+ vi.useFakeTimers();
278
+ const onBotResponse = vi.fn();
279
+ const { responder, provider } = createResponder({ onBotResponse });
280
+ responder.start();
281
+ provider._inject({ type: 'turn_complete' });
282
+ await vi.runAllTimersAsync();
283
+ expect(onBotResponse).not.toHaveBeenCalled();
284
+ });
285
+ it('resets transcript after firing callback', async () => {
286
+ vi.useFakeTimers();
287
+ const onBotResponse = vi.fn();
288
+ const { responder, provider } = createResponder({ onBotResponse });
289
+ responder.start();
290
+ provider._inject({ type: 'text', text: 'first turn' });
291
+ provider._inject({ type: 'turn_complete' });
292
+ await vi.runAllTimersAsync();
293
+ provider._inject({ type: 'text', text: 'second turn' });
294
+ provider._inject({ type: 'turn_complete' });
295
+ await vi.runAllTimersAsync();
296
+ expect(onBotResponse).toHaveBeenCalledTimes(2);
297
+ expect(onBotResponse).toHaveBeenNthCalledWith(1, 'first turn');
298
+ expect(onBotResponse).toHaveBeenNthCalledWith(2, 'second turn');
299
+ });
300
+ it('flushes transcript that arrives shortly after turn_complete', async () => {
301
+ vi.useFakeTimers();
302
+ const onBotResponse = vi.fn();
303
+ const { responder, provider } = createResponder({ onBotResponse });
304
+ responder.start();
305
+ provider._inject({ type: 'turn_complete' });
306
+ provider._inject({ type: 'text', text: 'late transcript' });
307
+ await vi.runAllTimersAsync();
308
+ expect(onBotResponse).toHaveBeenCalledWith('late transcript');
309
+ });
310
+ it('does not crash when onBotResponse throws', async () => {
311
+ vi.useFakeTimers();
312
+ const onBotResponse = vi.fn(() => { throw new Error('callback error'); });
313
+ const { responder, provider, log } = createResponder({ onBotResponse });
314
+ responder.start();
315
+ provider._inject({ type: 'text', text: 'text' });
316
+ provider._inject({ type: 'turn_complete' });
317
+ await vi.runAllTimersAsync();
318
+ expect(onBotResponse).toHaveBeenCalled();
319
+ expect(log.warn).toHaveBeenCalledWith(expect.objectContaining({ err: expect.any(Error) }), 'gemini-live-responder: onBotResponse callback error');
320
+ });
321
+ it('creates a fresh stream for the next turn after turn_complete', () => {
322
+ const { responder, player, provider } = createResponder();
323
+ responder.start();
324
+ // First turn
325
+ provider._inject({ type: 'audio', data: Buffer.alloc(4, 0x01) });
326
+ provider._inject({ type: 'turn_complete' });
327
+ // Second turn — should create a new stream
328
+ provider._inject({ type: 'audio', data: Buffer.alloc(4, 0x02) });
329
+ expect(player.play).toHaveBeenCalledTimes(2);
330
+ });
331
+ });
332
+ // -----------------------------------------------------------------------
333
+ // isPlaying
334
+ // -----------------------------------------------------------------------
335
+ describe('isPlaying', () => {
336
+ it('returns false before start', () => {
337
+ const { responder } = createResponder();
338
+ expect(responder.isPlaying).toBe(false);
339
+ });
340
+ it('returns false when idle', () => {
341
+ const { responder, player } = createResponder();
342
+ responder.start();
343
+ player.state = { status: 'idle' };
344
+ expect(responder.isPlaying).toBe(false);
345
+ });
346
+ it('returns true when playing', () => {
347
+ const { responder, player } = createResponder();
348
+ responder.start();
349
+ player.state = { status: 'playing' };
350
+ expect(responder.isPlaying).toBe(true);
351
+ });
352
+ it('returns true when buffering', () => {
353
+ const { responder, player } = createResponder();
354
+ responder.start();
355
+ player.state = { status: 'buffering' };
356
+ expect(responder.isPlaying).toBe(true);
357
+ });
358
+ });
359
+ // -----------------------------------------------------------------------
360
+ // stop / destroy
361
+ // -----------------------------------------------------------------------
362
+ describe('stop', () => {
363
+ it('destroys active stream and stops player', () => {
364
+ const { responder, player, provider } = createResponder();
365
+ responder.start();
366
+ provider._inject({ type: 'audio', data: Buffer.alloc(4, 0x42) });
367
+ responder.stop();
368
+ expect(player.stop).toHaveBeenCalled();
369
+ });
370
+ it('clears transcript', async () => {
371
+ vi.useFakeTimers();
372
+ const onBotResponse = vi.fn();
373
+ const { responder, provider } = createResponder({ onBotResponse });
374
+ responder.start();
375
+ provider._inject({ type: 'text', text: 'partial' });
376
+ responder.stop();
377
+ provider._inject({ type: 'turn_complete' });
378
+ await vi.runAllTimersAsync();
379
+ expect(onBotResponse).not.toHaveBeenCalled();
380
+ });
381
+ it('is safe when not started', () => {
382
+ const { responder } = createResponder();
383
+ responder.stop(); // should not throw
384
+ });
385
+ });
386
+ describe('destroy', () => {
387
+ it('calls stop and nullifies the player', () => {
388
+ const { responder, player } = createResponder();
389
+ responder.start();
390
+ responder.destroy();
391
+ expect(player.stop).toHaveBeenCalled();
392
+ expect(responder.isPlaying).toBe(false);
393
+ });
394
+ it('allows start to be called again after destroy', () => {
395
+ const { responder, connection } = createResponder();
396
+ responder.start();
397
+ responder.destroy();
398
+ responder.start();
399
+ expect(connection.subscribe).toHaveBeenCalledTimes(2);
400
+ });
401
+ });
402
+ // -----------------------------------------------------------------------
403
+ // tool_call events
404
+ // -----------------------------------------------------------------------
405
+ describe('tool_call events', () => {
406
+ it('forwards tool_call events to onToolCall callback', () => {
407
+ const onToolCall = vi.fn();
408
+ const { responder, provider } = createResponder({ onToolCall });
409
+ responder.start();
410
+ const calls = [
411
+ { id: 'fc-1', name: 'web_search', args: { query: 'hello' } },
412
+ ];
413
+ provider._inject({ type: 'tool_call', functionCalls: calls });
414
+ expect(onToolCall).toHaveBeenCalledWith(calls);
415
+ });
416
+ it('forwards multiple function calls in a single event', () => {
417
+ const onToolCall = vi.fn();
418
+ const { responder, provider } = createResponder({ onToolCall });
419
+ responder.start();
420
+ const calls = [
421
+ { id: 'fc-1', name: 'web_search', args: { query: 'hello' } },
422
+ { id: 'fc-2', name: 'read_file', args: { file_path: '/tmp/x' } },
423
+ ];
424
+ provider._inject({ type: 'tool_call', functionCalls: calls });
425
+ expect(onToolCall).toHaveBeenCalledWith(calls);
426
+ expect(onToolCall.mock.calls[0][0]).toHaveLength(2);
427
+ });
428
+ it('logs tool call receipt', () => {
429
+ const onToolCall = vi.fn();
430
+ const { responder, provider, log } = createResponder({ onToolCall });
431
+ responder.start();
432
+ provider._inject({
433
+ type: 'tool_call',
434
+ functionCalls: [{ id: 'fc-1', name: 'bash', args: { command: 'ls' } }],
435
+ });
436
+ expect(log.info).toHaveBeenCalledWith({ count: 1, names: 'bash' }, 'gemini-live-responder: tool call received');
437
+ });
438
+ it('does not crash when onToolCall is not provided', () => {
439
+ const { responder, provider } = createResponder();
440
+ responder.start();
441
+ // Should not throw
442
+ provider._inject({
443
+ type: 'tool_call',
444
+ functionCalls: [{ id: 'fc-1', name: 'bash', args: {} }],
445
+ });
446
+ });
447
+ it('does not crash when onToolCall throws', () => {
448
+ const onToolCall = vi.fn(() => { throw new Error('callback error'); });
449
+ const { responder, provider, log } = createResponder({ onToolCall });
450
+ responder.start();
451
+ provider._inject({
452
+ type: 'tool_call',
453
+ functionCalls: [{ id: 'fc-1', name: 'bash', args: {} }],
454
+ });
455
+ expect(log.warn).toHaveBeenCalledWith(expect.objectContaining({ err: expect.any(Error) }), 'gemini-live-responder: onToolCall callback error');
456
+ });
457
+ });
458
+ // -----------------------------------------------------------------------
459
+ // session_rotating events
460
+ // -----------------------------------------------------------------------
461
+ describe('session_rotating', () => {
462
+ it('pauses playback and destroys the stream', () => {
463
+ const { responder, player, provider, log } = createResponder();
464
+ responder.start();
465
+ // Start an audio stream
466
+ provider._inject({ type: 'audio', data: Buffer.alloc(4, 0x42) });
467
+ expect(player.play).toHaveBeenCalledTimes(1);
468
+ // Planned rotation
469
+ provider._inject({ type: 'session_rotating', sessionAgeMs: 780000 });
470
+ expect(player.stop).toHaveBeenCalled();
471
+ expect(log.info).toHaveBeenCalledWith({}, 'gemini-live-responder: planned session rotation — pausing playback');
472
+ });
473
+ it('is safe when no stream is active', () => {
474
+ const { responder, provider } = createResponder();
475
+ responder.start();
476
+ // Should not throw
477
+ provider._inject({ type: 'session_rotating', sessionAgeMs: 780000 });
478
+ });
479
+ });
480
+ // -----------------------------------------------------------------------
481
+ // reconnecting events
482
+ // -----------------------------------------------------------------------
483
+ describe('reconnecting', () => {
484
+ it('pauses playback and destroys the stream', () => {
485
+ const { responder, player, provider, log } = createResponder();
486
+ responder.start();
487
+ // Start an audio stream
488
+ provider._inject({ type: 'audio', data: Buffer.alloc(4, 0x42) });
489
+ expect(player.play).toHaveBeenCalledTimes(1);
490
+ // Reconnecting
491
+ provider._inject({ type: 'reconnecting', attempt: 1, maxRetries: 3, hasResumeHandle: true });
492
+ expect(player.stop).toHaveBeenCalled();
493
+ expect(log.info).toHaveBeenCalledWith({ attempt: 1, maxRetries: 3, hasResumeHandle: true }, 'gemini-live-responder: session reconnecting — pausing playback');
494
+ });
495
+ it('is safe when no stream is active', () => {
496
+ const { responder, provider } = createResponder();
497
+ responder.start();
498
+ provider._inject({ type: 'reconnecting', attempt: 1, maxRetries: 3, hasResumeHandle: false });
499
+ });
500
+ });
501
+ // -----------------------------------------------------------------------
502
+ // reconnected events
503
+ // -----------------------------------------------------------------------
504
+ describe('reconnected', () => {
505
+ it('logs the successful reconnection', () => {
506
+ const { responder, provider, log } = createResponder();
507
+ responder.start();
508
+ provider._inject({ type: 'reconnected', attempt: 2 });
509
+ expect(log.info).toHaveBeenCalledWith({ attempt: 2 }, 'gemini-live-responder: session reconnected');
510
+ });
511
+ });
512
+ // -----------------------------------------------------------------------
513
+ // reconnect_failed events
514
+ // -----------------------------------------------------------------------
515
+ describe('reconnect_failed', () => {
516
+ it('stops playback and fires onSessionTerminated', () => {
517
+ const onSessionTerminated = vi.fn();
518
+ const { responder, player, provider, log } = createResponder({ onSessionTerminated });
519
+ responder.start();
520
+ // Start audio
521
+ provider._inject({ type: 'audio', data: Buffer.alloc(4, 0x42) });
522
+ provider._inject({ type: 'reconnect_failed', attempts: 3 });
523
+ expect(player.stop).toHaveBeenCalled();
524
+ expect(onSessionTerminated).toHaveBeenCalled();
525
+ expect(log.error).toHaveBeenCalledWith({ attempts: 3 }, 'gemini-live-responder: session terminally failed — all reconnect retries exhausted');
526
+ });
527
+ it('does not crash when onSessionTerminated is not provided', () => {
528
+ const { responder, provider } = createResponder();
529
+ responder.start();
530
+ provider._inject({ type: 'reconnect_failed', attempts: 3 });
531
+ });
532
+ it('does not crash when onSessionTerminated throws', () => {
533
+ const onSessionTerminated = vi.fn(() => { throw new Error('callback error'); });
534
+ const { responder, provider, log } = createResponder({ onSessionTerminated });
535
+ responder.start();
536
+ provider._inject({ type: 'reconnect_failed', attempts: 3 });
537
+ expect(onSessionTerminated).toHaveBeenCalled();
538
+ expect(log.warn).toHaveBeenCalledWith(expect.objectContaining({ err: expect.any(Error) }), 'gemini-live-responder: onSessionTerminated callback error');
539
+ });
540
+ });
541
+ // -----------------------------------------------------------------------
542
+ // token_warning events
543
+ // -----------------------------------------------------------------------
544
+ describe('token_warning', () => {
545
+ it('fires onTokenWarning callback', () => {
546
+ const onTokenWarning = vi.fn();
547
+ const { responder, provider } = createResponder({ onTokenWarning });
548
+ responder.start();
549
+ provider._inject({ type: 'token_warning', estimatedTokens: 250000, threshold: 'warn' });
550
+ expect(onTokenWarning).toHaveBeenCalledWith(250000, 'warn');
551
+ });
552
+ it('fires onTokenWarning with compress threshold', () => {
553
+ const onTokenWarning = vi.fn();
554
+ const { responder, provider } = createResponder({ onTokenWarning });
555
+ responder.start();
556
+ provider._inject({ type: 'token_warning', estimatedTokens: 500000, threshold: 'compress' });
557
+ expect(onTokenWarning).toHaveBeenCalledWith(500000, 'compress');
558
+ });
559
+ it('does not crash when onTokenWarning is not provided', () => {
560
+ const { responder, provider } = createResponder();
561
+ responder.start();
562
+ provider._inject({ type: 'token_warning', estimatedTokens: 250000, threshold: 'warn' });
563
+ });
564
+ it('does not crash when onTokenWarning throws', () => {
565
+ const onTokenWarning = vi.fn(() => { throw new Error('callback error'); });
566
+ const { responder, provider, log } = createResponder({ onTokenWarning });
567
+ responder.start();
568
+ provider._inject({ type: 'token_warning', estimatedTokens: 250000, threshold: 'warn' });
569
+ expect(onTokenWarning).toHaveBeenCalled();
570
+ expect(log.warn).toHaveBeenCalledWith(expect.objectContaining({ err: expect.any(Error) }), 'gemini-live-responder: onTokenWarning callback error');
571
+ });
572
+ });
573
+ // -----------------------------------------------------------------------
574
+ // fallback_recommended events
575
+ // -----------------------------------------------------------------------
576
+ describe('fallback_recommended', () => {
577
+ it('fires onFallbackRecommended callback', () => {
578
+ const onFallbackRecommended = vi.fn();
579
+ const { responder, provider } = createResponder({ onFallbackRecommended });
580
+ responder.start();
581
+ provider._inject({ type: 'fallback_recommended', reason: 'exhausted reconnect retries' });
582
+ expect(onFallbackRecommended).toHaveBeenCalledWith('exhausted reconnect retries');
583
+ });
584
+ it('does not crash when onFallbackRecommended is not provided', () => {
585
+ const { responder, provider } = createResponder();
586
+ responder.start();
587
+ provider._inject({ type: 'fallback_recommended', reason: 'test reason' });
588
+ });
589
+ it('does not crash when onFallbackRecommended throws', () => {
590
+ const onFallbackRecommended = vi.fn(() => { throw new Error('callback error'); });
591
+ const { responder, provider, log } = createResponder({ onFallbackRecommended });
592
+ responder.start();
593
+ provider._inject({ type: 'fallback_recommended', reason: 'test reason' });
594
+ expect(onFallbackRecommended).toHaveBeenCalled();
595
+ expect(log.warn).toHaveBeenCalledWith(expect.objectContaining({ err: expect.any(Error) }), 'gemini-live-responder: onFallbackRecommended callback error');
596
+ });
597
+ });
598
+ // -----------------------------------------------------------------------
599
+ // unhandled event types
600
+ // -----------------------------------------------------------------------
601
+ describe('unhandled events', () => {
602
+ it('ignores setup_complete events without error', () => {
603
+ const { responder, provider } = createResponder();
604
+ responder.start();
605
+ provider._inject({ type: 'setup_complete' });
606
+ // No crash, no log — just silently ignored
607
+ });
608
+ it('ignores error events without error', () => {
609
+ const { responder, provider } = createResponder();
610
+ responder.start();
611
+ provider._inject({ type: 'error', error: 'some error' });
612
+ // No crash — error handling is the provider's responsibility
613
+ });
614
+ });
615
+ });