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.
- package/.context/voice.md +30 -2
- package/.env.example +7 -3
- package/.env.example.full +13 -32
- package/README.md +1 -1
- package/dist/cli/dashboard.js +7 -1
- package/dist/cli/dashboard.test.js +0 -4
- package/dist/cli/init-wizard.js +4 -8
- package/dist/cli/init-wizard.test.js +4 -10
- package/dist/config.js +5 -38
- package/dist/config.test.js +8 -72
- package/dist/cron/executor.js +72 -1
- package/dist/dashboard/api/metrics.js +7 -0
- package/dist/dashboard/api/metrics.test.js +16 -0
- package/dist/dashboard/api/traces.js +14 -0
- package/dist/dashboard/api/traces.test.js +40 -0
- package/dist/dashboard/page.js +187 -8
- package/dist/dashboard/server.js +82 -19
- package/dist/dashboard/server.test.js +123 -10
- package/dist/discord/actions.js +112 -6
- package/dist/discord/actions.test.js +117 -1
- package/dist/discord/deferred-runner.js +306 -219
- package/dist/discord/help-command.js +1 -1
- package/dist/discord/message-coordinator.js +4 -36
- package/dist/discord/models-command.js +1 -1
- package/dist/discord/reaction-handler.js +83 -5
- package/dist/discord/reaction-handler.test.js +55 -0
- package/dist/discord/verify-push.js +31 -36
- package/dist/discord/verify-push.test.js +34 -6
- package/dist/discord/voice-command.js +1 -31
- package/dist/discord/voice-command.test.js +21 -259
- package/dist/discord/voice-status-command.js +3 -22
- package/dist/discord/voice-status-command.test.js +16 -124
- package/dist/discord-followup.test.js +133 -0
- package/dist/health/config-doctor.js +5 -27
- package/dist/health/config-doctor.test.js +1 -4
- package/dist/index.js +15 -28
- package/dist/observability/trace-store.js +56 -0
- package/dist/observability/trace-utils.js +31 -0
- package/dist/runtime/codex-cli.js +3 -2
- package/dist/runtime/codex-cli.test.js +33 -0
- package/dist/runtime/model-tiers.js +1 -1
- package/dist/runtime/model-tiers.test.js +9 -0
- package/dist/runtime/openai-tool-schemas.js +17 -0
- package/dist/runtime-overrides.js +2 -3
- package/dist/runtime-overrides.test.js +27 -193
- package/dist/tasks/store.js +10 -6
- package/dist/tasks/store.test.js +44 -0
- package/dist/tasks/task-action-executor.test.js +162 -50
- package/dist/tasks/task-action-mutations.js +22 -2
- package/dist/tasks/task-action-read-ops.js +7 -1
- package/dist/tasks/task-action-runner-types.js +19 -1
- package/dist/voice/audio-pipeline.js +183 -96
- package/dist/voice/audio-receiver.js +8 -0
- package/dist/voice/audio-receiver.test.js +16 -0
- package/dist/voice/conversation-buffer.js +16 -6
- package/dist/voice/providers/gemini-live-provider.js +481 -0
- package/dist/voice/providers/gemini-live-provider.test.js +834 -0
- package/dist/voice/providers/gemini-live-responder.js +267 -0
- package/dist/voice/providers/gemini-live-responder.test.js +615 -0
- package/dist/voice/providers/gemini-live-token-estimator.js +100 -0
- package/dist/voice/providers/gemini-live-token-estimator.test.js +160 -0
- package/dist/voice/providers/gemini-live-types.js +32 -0
- package/dist/voice/providers/gemini-tool-mapper.js +91 -0
- package/dist/voice/providers/gemini-tool-mapper.test.js +253 -0
- package/dist/voice/providers/index.js +3 -0
- package/dist/voice/voice-prompt-builder.js +26 -17
- package/dist/voice/voice-prompt-builder.test.js +16 -1
- package/docs/configuration.md +4 -9
- package/docs/official-docs.md +6 -9
- package/docs/runtime-switching.md +1 -1
- package/package.json +1 -1
- package/dist/voice/audio-pipeline.test.js +0 -619
- package/dist/voice/stt-deepgram.js +0 -154
- package/dist/voice/stt-deepgram.test.js +0 -275
- package/dist/voice/stt-factory.js +0 -42
- package/dist/voice/stt-factory.test.js +0 -45
- package/dist/voice/stt-openai.js +0 -156
- package/dist/voice/stt-openai.test.js +0 -281
- package/dist/voice/tts-cartesia.js +0 -169
- package/dist/voice/tts-cartesia.test.js +0 -228
- package/dist/voice/tts-deepgram.js +0 -84
- package/dist/voice/tts-deepgram.test.js +0 -220
- package/dist/voice/tts-factory.js +0 -52
- package/dist/voice/tts-factory.test.js +0 -53
- package/dist/voice/tts-openai.js +0 -70
- package/dist/voice/tts-openai.test.js +0 -138
- 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
|
+
});
|