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
|
@@ -1,285 +1,47 @@
|
|
|
1
|
-
import { describe,
|
|
2
|
-
import {
|
|
3
|
-
import * as voiceStatusCommand from './voice-status-command.js';
|
|
4
|
-
// ---------------------------------------------------------------------------
|
|
5
|
-
// Helpers
|
|
6
|
-
// ---------------------------------------------------------------------------
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { handleVoiceCommand, parseVoiceCommand } from './voice-command.js';
|
|
7
3
|
function makeSnapshot(overrides = {}) {
|
|
8
4
|
return {
|
|
9
5
|
enabled: true,
|
|
10
|
-
|
|
11
|
-
|
|
6
|
+
provider: 'gemini-live',
|
|
7
|
+
geminiKeySet: true,
|
|
8
|
+
model: 'gemini-2.5-pro',
|
|
12
9
|
homeChannel: 'voice-home',
|
|
13
|
-
deepgramKeySet: true,
|
|
14
|
-
cartesiaKeySet: false,
|
|
15
10
|
autoJoin: false,
|
|
16
11
|
actionsEnabled: true,
|
|
17
12
|
connections: [],
|
|
18
|
-
deepgramTtsVoice: 'aura-2-asteria-en',
|
|
19
13
|
...overrides,
|
|
20
14
|
};
|
|
21
15
|
}
|
|
22
16
|
function makeOpts(overrides = {}) {
|
|
23
17
|
return {
|
|
24
18
|
voiceEnabled: true,
|
|
25
|
-
|
|
19
|
+
statusSnapshot: makeSnapshot(),
|
|
26
20
|
...overrides,
|
|
27
21
|
};
|
|
28
22
|
}
|
|
29
|
-
// ---------------------------------------------------------------------------
|
|
30
|
-
// parseVoiceCommand
|
|
31
|
-
// ---------------------------------------------------------------------------
|
|
32
23
|
describe('parseVoiceCommand', () => {
|
|
33
|
-
|
|
34
|
-
it('returns status for bare !voice', () => {
|
|
24
|
+
it('supports bare, status, and help forms', () => {
|
|
35
25
|
expect(parseVoiceCommand('!voice')).toEqual({ action: 'status' });
|
|
36
|
-
});
|
|
37
|
-
// status subcommand
|
|
38
|
-
it('returns status for !voice status', () => {
|
|
39
26
|
expect(parseVoiceCommand('!voice status')).toEqual({ action: 'status' });
|
|
40
|
-
});
|
|
41
|
-
// set subcommand
|
|
42
|
-
it('returns set for !voice set <name>', () => {
|
|
43
|
-
expect(parseVoiceCommand('!voice set aura-2-luna-en')).toEqual({
|
|
44
|
-
action: 'set',
|
|
45
|
-
voice: 'aura-2-luna-en',
|
|
46
|
-
});
|
|
47
|
-
});
|
|
48
|
-
// help subcommand
|
|
49
|
-
it('returns help for !voice help', () => {
|
|
50
27
|
expect(parseVoiceCommand('!voice help')).toEqual({ action: 'help' });
|
|
51
28
|
});
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
expect(parseVoiceCommand('!VOICE')).toEqual({ action: 'status' });
|
|
55
|
-
expect(parseVoiceCommand('!Voice')).toEqual({ action: 'status' });
|
|
56
|
-
});
|
|
57
|
-
it('is case-insensitive for the status subcommand', () => {
|
|
58
|
-
expect(parseVoiceCommand('!VOICE STATUS')).toEqual({ action: 'status' });
|
|
59
|
-
expect(parseVoiceCommand('!Voice Status')).toEqual({ action: 'status' });
|
|
60
|
-
});
|
|
61
|
-
it('is case-insensitive for the set subcommand keyword', () => {
|
|
62
|
-
expect(parseVoiceCommand('!VOICE SET aura-2-luna-en')).toEqual({
|
|
63
|
-
action: 'set',
|
|
64
|
-
voice: 'aura-2-luna-en',
|
|
65
|
-
});
|
|
66
|
-
});
|
|
67
|
-
it('is case-insensitive for the help subcommand', () => {
|
|
68
|
-
expect(parseVoiceCommand('!VOICE HELP')).toEqual({ action: 'help' });
|
|
69
|
-
});
|
|
70
|
-
// Preserves original case for voice names
|
|
71
|
-
it('preserves original case for the voice name', () => {
|
|
72
|
-
expect(parseVoiceCommand('!voice set AURA-2-LUNA-EN')).toEqual({
|
|
73
|
-
action: 'set',
|
|
74
|
-
voice: 'AURA-2-LUNA-EN',
|
|
75
|
-
});
|
|
76
|
-
expect(parseVoiceCommand('!VOICE SET Aura-2-Asteria-EN')).toEqual({
|
|
77
|
-
action: 'set',
|
|
78
|
-
voice: 'Aura-2-Asteria-EN',
|
|
79
|
-
});
|
|
80
|
-
});
|
|
81
|
-
// Whitespace handling
|
|
82
|
-
it('trims surrounding whitespace', () => {
|
|
83
|
-
expect(parseVoiceCommand(' !voice ')).toEqual({ action: 'status' });
|
|
84
|
-
expect(parseVoiceCommand(' !voice status ')).toEqual({ action: 'status' });
|
|
85
|
-
});
|
|
86
|
-
it('collapses extra internal whitespace', () => {
|
|
87
|
-
expect(parseVoiceCommand('!voice status')).toEqual({ action: 'status' });
|
|
88
|
-
expect(parseVoiceCommand('!voice set aura-2-luna-en')).toEqual({
|
|
89
|
-
action: 'set',
|
|
90
|
-
voice: 'aura-2-luna-en',
|
|
91
|
-
});
|
|
92
|
-
});
|
|
93
|
-
// Rejection of invalid input
|
|
94
|
-
it('returns null when !voice set has no voice name', () => {
|
|
95
|
-
expect(parseVoiceCommand('!voice set')).toBeNull();
|
|
96
|
-
});
|
|
97
|
-
it('returns null when !voice set has extra tokens', () => {
|
|
98
|
-
expect(parseVoiceCommand('!voice set aura-2-luna-en extra')).toBeNull();
|
|
99
|
-
});
|
|
100
|
-
it('returns null for !voice status with extra tokens', () => {
|
|
101
|
-
expect(parseVoiceCommand('!voice status extra')).toBeNull();
|
|
102
|
-
});
|
|
103
|
-
it('returns null for !voice help with extra tokens', () => {
|
|
104
|
-
expect(parseVoiceCommand('!voice help extra')).toBeNull();
|
|
105
|
-
});
|
|
106
|
-
it('returns null for unknown subcommands', () => {
|
|
107
|
-
expect(parseVoiceCommand('!voice bogus')).toBeNull();
|
|
108
|
-
expect(parseVoiceCommand('!voice join')).toBeNull();
|
|
109
|
-
expect(parseVoiceCommand('!voice leave')).toBeNull();
|
|
110
|
-
});
|
|
111
|
-
it('returns null for non-voice commands', () => {
|
|
112
|
-
expect(parseVoiceCommand('!health')).toBeNull();
|
|
113
|
-
expect(parseVoiceCommand('!status')).toBeNull();
|
|
114
|
-
expect(parseVoiceCommand('!models')).toBeNull();
|
|
115
|
-
expect(parseVoiceCommand('!voicex')).toBeNull();
|
|
116
|
-
});
|
|
117
|
-
it('returns null for empty input', () => {
|
|
118
|
-
expect(parseVoiceCommand('')).toBeNull();
|
|
119
|
-
expect(parseVoiceCommand(' ')).toBeNull();
|
|
120
|
-
});
|
|
121
|
-
it('handles non-string input gracefully', () => {
|
|
122
|
-
expect(parseVoiceCommand(undefined)).toBeNull();
|
|
123
|
-
expect(parseVoiceCommand(null)).toBeNull();
|
|
29
|
+
it('rejects removed set subcommand', () => {
|
|
30
|
+
expect(parseVoiceCommand('!voice set aura-2-luna-en')).toBeNull();
|
|
124
31
|
});
|
|
125
32
|
});
|
|
126
|
-
// ---------------------------------------------------------------------------
|
|
127
|
-
// handleVoiceCommand
|
|
128
|
-
// ---------------------------------------------------------------------------
|
|
129
33
|
describe('handleVoiceCommand', () => {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
const result = await handleVoiceCommand({ action: 'help' }, makeOpts({ voiceEnabled: false }));
|
|
143
|
-
expect(result).toContain('disabled');
|
|
144
|
-
});
|
|
145
|
-
});
|
|
146
|
-
// Status delegation
|
|
147
|
-
describe('status', () => {
|
|
148
|
-
it('delegates to renderVoiceStatusReport with the provided snapshot', async () => {
|
|
149
|
-
const snapshot = makeSnapshot();
|
|
150
|
-
const spy = vi.spyOn(voiceStatusCommand, 'renderVoiceStatusReport').mockReturnValue('```text\nstatus\n```');
|
|
151
|
-
const result = await handleVoiceCommand({ action: 'status' }, makeOpts({ statusSnapshot: snapshot }));
|
|
152
|
-
expect(spy).toHaveBeenCalledWith(snapshot, undefined);
|
|
153
|
-
expect(result).toBe('```text\nstatus\n```');
|
|
154
|
-
spy.mockRestore();
|
|
155
|
-
});
|
|
156
|
-
it('passes botDisplayName to renderVoiceStatusReport', async () => {
|
|
157
|
-
const snapshot = makeSnapshot();
|
|
158
|
-
const spy = vi.spyOn(voiceStatusCommand, 'renderVoiceStatusReport').mockReturnValue('```text\nok\n```');
|
|
159
|
-
await handleVoiceCommand({ action: 'status' }, makeOpts({ statusSnapshot: snapshot, botDisplayName: 'MyBot' }));
|
|
160
|
-
expect(spy).toHaveBeenCalledWith(snapshot, 'MyBot');
|
|
161
|
-
spy.mockRestore();
|
|
162
|
-
});
|
|
163
|
-
it('returns unavailable message when statusSnapshot is not provided', async () => {
|
|
164
|
-
const result = await handleVoiceCommand({ action: 'status' }, makeOpts({ statusSnapshot: undefined }));
|
|
165
|
-
expect(result).toContain('unavailable');
|
|
166
|
-
});
|
|
167
|
-
});
|
|
168
|
-
// Set — no active pipelines
|
|
169
|
-
describe('set — no active pipelines', () => {
|
|
170
|
-
it('updates deepgramTtsVoice in voiceConfig', async () => {
|
|
171
|
-
const voiceConfig = { deepgramTtsVoice: 'aura-2-asteria-en' };
|
|
172
|
-
await handleVoiceCommand({ action: 'set', voice: 'aura-2-luna-en' }, makeOpts({ voiceConfig, activePipelineCount: 0 }));
|
|
173
|
-
expect(voiceConfig.deepgramTtsVoice).toBe('aura-2-luna-en');
|
|
174
|
-
});
|
|
175
|
-
it('does not call restartPipelines when activePipelineCount is 0', async () => {
|
|
176
|
-
const restartPipelines = vi.fn().mockResolvedValue(undefined);
|
|
177
|
-
await handleVoiceCommand({ action: 'set', voice: 'aura-2-luna-en' }, makeOpts({ voiceConfig: {}, activePipelineCount: 0, restartPipelines }));
|
|
178
|
-
expect(restartPipelines).not.toHaveBeenCalled();
|
|
179
|
-
});
|
|
180
|
-
it('does not call restartPipelines when activePipelineCount is absent', async () => {
|
|
181
|
-
const restartPipelines = vi.fn().mockResolvedValue(undefined);
|
|
182
|
-
await handleVoiceCommand({ action: 'set', voice: 'aura-2-luna-en' }, makeOpts({ voiceConfig: {}, restartPipelines }));
|
|
183
|
-
expect(restartPipelines).not.toHaveBeenCalled();
|
|
184
|
-
});
|
|
185
|
-
it('returns "will take effect on next pipeline start" message', async () => {
|
|
186
|
-
const result = await handleVoiceCommand({ action: 'set', voice: 'aura-2-luna-en' }, makeOpts({ voiceConfig: {}, activePipelineCount: 0 }));
|
|
187
|
-
expect(result).toContain('aura-2-luna-en');
|
|
188
|
-
expect(result).toContain('next pipeline start');
|
|
189
|
-
});
|
|
190
|
-
});
|
|
191
|
-
// Set — with active pipelines
|
|
192
|
-
describe('set — with active pipelines', () => {
|
|
193
|
-
it('calls restartPipelines when there is 1 active pipeline', async () => {
|
|
194
|
-
const restartPipelines = vi.fn().mockResolvedValue(undefined);
|
|
195
|
-
const voiceConfig = { deepgramTtsVoice: 'aura-2-asteria-en' };
|
|
196
|
-
await handleVoiceCommand({ action: 'set', voice: 'aura-2-luna-en' }, makeOpts({ voiceConfig, activePipelineCount: 1, restartPipelines }));
|
|
197
|
-
expect(restartPipelines).toHaveBeenCalledOnce();
|
|
198
|
-
});
|
|
199
|
-
it('returns "1 active pipeline restarted" message for a single pipeline', async () => {
|
|
200
|
-
const restartPipelines = vi.fn().mockResolvedValue(undefined);
|
|
201
|
-
const result = await handleVoiceCommand({ action: 'set', voice: 'aura-2-luna-en' }, makeOpts({ voiceConfig: {}, activePipelineCount: 1, restartPipelines }));
|
|
202
|
-
expect(result).toContain('aura-2-luna-en');
|
|
203
|
-
expect(result).toContain('1 active pipeline');
|
|
204
|
-
expect(result).toContain('restarted');
|
|
205
|
-
});
|
|
206
|
-
it('returns "N active pipelines restarted" for multiple pipelines', async () => {
|
|
207
|
-
const restartPipelines = vi.fn().mockResolvedValue(undefined);
|
|
208
|
-
const result = await handleVoiceCommand({ action: 'set', voice: 'aura-2-luna-en' }, makeOpts({ voiceConfig: {}, activePipelineCount: 3, restartPipelines }));
|
|
209
|
-
expect(result).toContain('3 active pipelines');
|
|
210
|
-
expect(result).toContain('restarted');
|
|
211
|
-
});
|
|
212
|
-
it('still updates voiceConfig before restarting pipelines', async () => {
|
|
213
|
-
const voiceConfig = { deepgramTtsVoice: 'aura-2-asteria-en' };
|
|
214
|
-
const restartPipelines = vi.fn().mockResolvedValue(undefined);
|
|
215
|
-
await handleVoiceCommand({ action: 'set', voice: 'aura-2-luna-en' }, makeOpts({ voiceConfig, activePipelineCount: 2, restartPipelines }));
|
|
216
|
-
expect(voiceConfig.deepgramTtsVoice).toBe('aura-2-luna-en');
|
|
217
|
-
});
|
|
218
|
-
});
|
|
219
|
-
// Set — via setTtsVoice (pipeline-level)
|
|
220
|
-
describe('set — via setTtsVoice', () => {
|
|
221
|
-
it('calls setTtsVoice with the new voice name', async () => {
|
|
222
|
-
const setTtsVoice = vi.fn().mockResolvedValue(1);
|
|
223
|
-
await handleVoiceCommand({ action: 'set', voice: 'aura-2-luna-en' }, makeOpts({ setTtsVoice }));
|
|
224
|
-
expect(setTtsVoice).toHaveBeenCalledWith('aura-2-luna-en');
|
|
225
|
-
});
|
|
226
|
-
it('returns "1 active pipeline restarted" when setTtsVoice returns 1', async () => {
|
|
227
|
-
const setTtsVoice = vi.fn().mockResolvedValue(1);
|
|
228
|
-
const result = await handleVoiceCommand({ action: 'set', voice: 'aura-2-luna-en' }, makeOpts({ setTtsVoice }));
|
|
229
|
-
expect(result).toContain('aura-2-luna-en');
|
|
230
|
-
expect(result).toContain('1 active pipeline');
|
|
231
|
-
expect(result).toContain('restarted');
|
|
232
|
-
});
|
|
233
|
-
it('returns "N active pipelines restarted" when setTtsVoice returns N > 1', async () => {
|
|
234
|
-
const setTtsVoice = vi.fn().mockResolvedValue(3);
|
|
235
|
-
const result = await handleVoiceCommand({ action: 'set', voice: 'aura-2-asteria-en' }, makeOpts({ setTtsVoice }));
|
|
236
|
-
expect(result).toContain('3 active pipelines');
|
|
237
|
-
expect(result).toContain('restarted');
|
|
238
|
-
});
|
|
239
|
-
it('returns "will take effect on next pipeline start" when setTtsVoice returns 0', async () => {
|
|
240
|
-
const setTtsVoice = vi.fn().mockResolvedValue(0);
|
|
241
|
-
const result = await handleVoiceCommand({ action: 'set', voice: 'aura-2-luna-en' }, makeOpts({ setTtsVoice }));
|
|
242
|
-
expect(result).toContain('aura-2-luna-en');
|
|
243
|
-
expect(result).toContain('next pipeline start');
|
|
244
|
-
});
|
|
245
|
-
it('prefers setTtsVoice over voiceConfig + restartPipelines', async () => {
|
|
246
|
-
const setTtsVoice = vi.fn().mockResolvedValue(1);
|
|
247
|
-
const restartPipelines = vi.fn().mockResolvedValue(undefined);
|
|
248
|
-
const voiceConfig = { deepgramTtsVoice: 'aura-2-asteria-en' };
|
|
249
|
-
await handleVoiceCommand({ action: 'set', voice: 'aura-2-luna-en' }, makeOpts({ setTtsVoice, restartPipelines, voiceConfig, activePipelineCount: 1 }));
|
|
250
|
-
expect(setTtsVoice).toHaveBeenCalledOnce();
|
|
251
|
-
expect(restartPipelines).not.toHaveBeenCalled();
|
|
252
|
-
});
|
|
253
|
-
});
|
|
254
|
-
// Set — non-deepgram provider
|
|
255
|
-
describe('set — non-deepgram TTS provider', () => {
|
|
256
|
-
it('returns error message naming the current provider', async () => {
|
|
257
|
-
const result = await handleVoiceCommand({ action: 'set', voice: 'some-voice' }, makeOpts({ ttsProvider: 'cartesia' }));
|
|
258
|
-
expect(result).toContain('deepgram');
|
|
259
|
-
expect(result).toContain('cartesia');
|
|
260
|
-
});
|
|
261
|
-
it('does not modify voiceConfig when provider is not deepgram', async () => {
|
|
262
|
-
const voiceConfig = { deepgramTtsVoice: 'original' };
|
|
263
|
-
await handleVoiceCommand({ action: 'set', voice: 'new-voice' }, makeOpts({ ttsProvider: 'openai', voiceConfig }));
|
|
264
|
-
expect(voiceConfig.deepgramTtsVoice).toBe('original');
|
|
265
|
-
});
|
|
266
|
-
});
|
|
267
|
-
// Help
|
|
268
|
-
describe('help', () => {
|
|
269
|
-
it('returns help text mentioning all subcommands', async () => {
|
|
270
|
-
const result = await handleVoiceCommand({ action: 'help' }, makeOpts());
|
|
271
|
-
expect(result).toContain('!voice');
|
|
272
|
-
expect(result).toContain('status');
|
|
273
|
-
expect(result).toContain('set');
|
|
274
|
-
expect(result).toContain('help');
|
|
275
|
-
});
|
|
276
|
-
it('includes example voice names', async () => {
|
|
277
|
-
const result = await handleVoiceCommand({ action: 'help' }, makeOpts());
|
|
278
|
-
expect(result).toContain('aura-2-asteria-en');
|
|
279
|
-
});
|
|
280
|
-
it('mentions the Deepgram provider requirement', async () => {
|
|
281
|
-
const result = await handleVoiceCommand({ action: 'help' }, makeOpts());
|
|
282
|
-
expect(result).toContain('Deepgram');
|
|
283
|
-
});
|
|
34
|
+
it('returns disabled message when voice is off', async () => {
|
|
35
|
+
const result = await handleVoiceCommand({ action: 'status' }, makeOpts({ voiceEnabled: false }));
|
|
36
|
+
expect(result).toContain('Voice is disabled');
|
|
37
|
+
});
|
|
38
|
+
it('renders status when snapshot is present', async () => {
|
|
39
|
+
const result = await handleVoiceCommand({ action: 'status' }, makeOpts());
|
|
40
|
+
expect(result).toContain('Provider: gemini-live');
|
|
41
|
+
});
|
|
42
|
+
it('returns Gemini-only help text', async () => {
|
|
43
|
+
const result = await handleVoiceCommand({ action: 'help' }, makeOpts());
|
|
44
|
+
expect(result).toContain('Gemini Live only');
|
|
45
|
+
expect(result).not.toContain('!voice set');
|
|
284
46
|
});
|
|
285
47
|
});
|
|
@@ -17,28 +17,9 @@ export function renderVoiceStatusReport(snapshot, botDisplayName = 'Discoclaw')
|
|
|
17
17
|
const lines = [];
|
|
18
18
|
lines.push(`${botDisplayName} Voice Status`);
|
|
19
19
|
lines.push(`Voice: ${snapshot.enabled ? 'enabled' : 'disabled'}`);
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const modelLabel = snapshot.deepgramSttModel ? `, model: ${snapshot.deepgramSttModel}` : '';
|
|
24
|
-
lines.push(`STT: ${snapshot.sttProvider} (${keyLabel}${modelLabel})`);
|
|
25
|
-
}
|
|
26
|
-
else {
|
|
27
|
-
lines.push(`STT: ${snapshot.sttProvider}`);
|
|
28
|
-
}
|
|
29
|
-
// TTS
|
|
30
|
-
if (snapshot.ttsProvider === 'deepgram') {
|
|
31
|
-
const keyLabel = snapshot.deepgramKeySet ? 'key: set' : 'key: MISSING';
|
|
32
|
-
const voiceLabel = snapshot.deepgramTtsVoice ? `, voice: ${snapshot.deepgramTtsVoice}` : '';
|
|
33
|
-
lines.push(`TTS: ${snapshot.ttsProvider} (${keyLabel}${voiceLabel})`);
|
|
34
|
-
}
|
|
35
|
-
else if (snapshot.ttsProvider === 'cartesia') {
|
|
36
|
-
const keyLabel = snapshot.cartesiaKeySet ? 'key: set' : 'key: MISSING';
|
|
37
|
-
lines.push(`TTS: ${snapshot.ttsProvider} (${keyLabel})`);
|
|
38
|
-
}
|
|
39
|
-
else {
|
|
40
|
-
lines.push(`TTS: ${snapshot.ttsProvider}`);
|
|
41
|
-
}
|
|
20
|
+
const keyLabel = snapshot.geminiKeySet ? 'key: set' : 'key: MISSING';
|
|
21
|
+
const modelLabel = snapshot.model ? `, model: ${snapshot.model}` : '';
|
|
22
|
+
lines.push(`Provider: ${snapshot.provider} (${keyLabel}${modelLabel})`);
|
|
42
23
|
lines.push(`Home channel: ${snapshot.homeChannel ?? '(not set)'}`);
|
|
43
24
|
lines.push(`Auto-join: ${snapshot.autoJoin ? 'on' : 'off'}`);
|
|
44
25
|
lines.push(`Actions: ${snapshot.actionsEnabled ? 'enabled' : 'disabled'}`);
|
|
@@ -1,149 +1,41 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { parseVoiceStatusCommand, renderVoiceStatusReport
|
|
3
|
-
// ---------------------------------------------------------------------------
|
|
4
|
-
// Helpers
|
|
5
|
-
// ---------------------------------------------------------------------------
|
|
2
|
+
import { parseVoiceStatusCommand, renderVoiceStatusReport } from './voice-status-command.js';
|
|
6
3
|
function makeSnapshot(overrides = {}) {
|
|
7
4
|
return {
|
|
8
5
|
enabled: true,
|
|
9
|
-
|
|
10
|
-
|
|
6
|
+
provider: 'gemini-live',
|
|
7
|
+
geminiKeySet: true,
|
|
8
|
+
model: 'gemini-2.5-pro',
|
|
11
9
|
homeChannel: 'voice-home',
|
|
12
|
-
deepgramKeySet: true,
|
|
13
|
-
cartesiaKeySet: true,
|
|
14
10
|
autoJoin: false,
|
|
15
11
|
actionsEnabled: true,
|
|
16
12
|
connections: [],
|
|
17
13
|
...overrides,
|
|
18
14
|
};
|
|
19
15
|
}
|
|
20
|
-
// ---------------------------------------------------------------------------
|
|
21
|
-
// parseVoiceStatusCommand
|
|
22
|
-
// ---------------------------------------------------------------------------
|
|
23
16
|
describe('parseVoiceStatusCommand', () => {
|
|
24
|
-
it('
|
|
17
|
+
it('parses !voice status case-insensitively', () => {
|
|
25
18
|
expect(parseVoiceStatusCommand('!voice status')).toBe(true);
|
|
26
|
-
});
|
|
27
|
-
it('is case-insensitive', () => {
|
|
28
19
|
expect(parseVoiceStatusCommand('!VOICE STATUS')).toBe(true);
|
|
29
|
-
expect(parseVoiceStatusCommand('!Voice Status')).toBe(true);
|
|
30
|
-
});
|
|
31
|
-
it('trims surrounding whitespace', () => {
|
|
32
|
-
expect(parseVoiceStatusCommand(' !voice status ')).toBe(true);
|
|
33
20
|
});
|
|
34
|
-
it('
|
|
35
|
-
expect(parseVoiceStatusCommand('!voice status')).toBe(true);
|
|
36
|
-
});
|
|
37
|
-
it('returns null for non-matching commands', () => {
|
|
21
|
+
it('rejects non-status commands', () => {
|
|
38
22
|
expect(parseVoiceStatusCommand('!voice')).toBeNull();
|
|
39
|
-
expect(parseVoiceStatusCommand('!voice join')).toBeNull();
|
|
40
|
-
expect(parseVoiceStatusCommand('!status')).toBeNull();
|
|
41
23
|
expect(parseVoiceStatusCommand('!health')).toBeNull();
|
|
42
|
-
expect(parseVoiceStatusCommand('')).toBeNull();
|
|
43
|
-
});
|
|
44
|
-
it('handles non-string input gracefully', () => {
|
|
45
|
-
expect(parseVoiceStatusCommand(undefined)).toBeNull();
|
|
46
|
-
expect(parseVoiceStatusCommand(null)).toBeNull();
|
|
47
24
|
});
|
|
48
25
|
});
|
|
49
|
-
// ---------------------------------------------------------------------------
|
|
50
|
-
// renderVoiceStatusReport
|
|
51
|
-
// ---------------------------------------------------------------------------
|
|
52
26
|
describe('renderVoiceStatusReport', () => {
|
|
53
|
-
it('renders
|
|
54
|
-
const out = renderVoiceStatusReport(makeSnapshot(
|
|
55
|
-
expect(out).toContain('
|
|
56
|
-
});
|
|
57
|
-
it('renders voice disabled', () => {
|
|
58
|
-
const out = renderVoiceStatusReport(makeSnapshot({ enabled: false }));
|
|
59
|
-
expect(out).toContain('Voice: disabled');
|
|
60
|
-
});
|
|
61
|
-
it('renders STT deepgram with key set', () => {
|
|
62
|
-
const out = renderVoiceStatusReport(makeSnapshot({ sttProvider: 'deepgram', deepgramKeySet: true }));
|
|
63
|
-
expect(out).toContain('STT: deepgram (key: set)');
|
|
64
|
-
});
|
|
65
|
-
it('renders STT deepgram with missing key', () => {
|
|
66
|
-
const out = renderVoiceStatusReport(makeSnapshot({ sttProvider: 'deepgram', deepgramKeySet: false }));
|
|
67
|
-
expect(out).toContain('STT: deepgram (key: MISSING)');
|
|
68
|
-
});
|
|
69
|
-
it('renders STT non-deepgram provider without key info', () => {
|
|
70
|
-
const out = renderVoiceStatusReport(makeSnapshot({ sttProvider: 'openai' }));
|
|
71
|
-
expect(out).toContain('STT: openai');
|
|
72
|
-
expect(out).not.toContain('STT: openai (');
|
|
73
|
-
});
|
|
74
|
-
it('renders TTS cartesia with key set', () => {
|
|
75
|
-
const out = renderVoiceStatusReport(makeSnapshot({ ttsProvider: 'cartesia', cartesiaKeySet: true }));
|
|
76
|
-
expect(out).toContain('TTS: cartesia (key: set)');
|
|
77
|
-
});
|
|
78
|
-
it('renders TTS cartesia with missing key', () => {
|
|
79
|
-
const out = renderVoiceStatusReport(makeSnapshot({ ttsProvider: 'cartesia', cartesiaKeySet: false }));
|
|
80
|
-
expect(out).toContain('TTS: cartesia (key: MISSING)');
|
|
81
|
-
});
|
|
82
|
-
it('renders TTS deepgram with key set', () => {
|
|
83
|
-
const out = renderVoiceStatusReport(makeSnapshot({ ttsProvider: 'deepgram', deepgramKeySet: true }));
|
|
84
|
-
expect(out).toContain('TTS: deepgram (key: set)');
|
|
85
|
-
});
|
|
86
|
-
it('renders TTS deepgram with missing key', () => {
|
|
87
|
-
const out = renderVoiceStatusReport(makeSnapshot({ ttsProvider: 'deepgram', deepgramKeySet: false }));
|
|
88
|
-
expect(out).toContain('TTS: deepgram (key: MISSING)');
|
|
89
|
-
});
|
|
90
|
-
it('renders TTS non-cartesia non-deepgram provider without key info', () => {
|
|
91
|
-
const out = renderVoiceStatusReport(makeSnapshot({ ttsProvider: 'openai' }));
|
|
92
|
-
expect(out).toContain('TTS: openai');
|
|
93
|
-
expect(out).not.toContain('TTS: openai (');
|
|
94
|
-
});
|
|
95
|
-
it('renders home channel when set', () => {
|
|
96
|
-
const out = renderVoiceStatusReport(makeSnapshot({ homeChannel: 'voice-home' }));
|
|
27
|
+
it('renders Gemini Live provider details', () => {
|
|
28
|
+
const out = renderVoiceStatusReport(makeSnapshot());
|
|
29
|
+
expect(out).toContain('Provider: gemini-live (key: set, model: gemini-2.5-pro)');
|
|
97
30
|
expect(out).toContain('Home channel: voice-home');
|
|
98
31
|
});
|
|
99
|
-
it('renders
|
|
100
|
-
const out = renderVoiceStatusReport(makeSnapshot({
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
expect(out).toContain('
|
|
106
|
-
});
|
|
107
|
-
it('renders auto-join off', () => {
|
|
108
|
-
const out = renderVoiceStatusReport(makeSnapshot({ autoJoin: false }));
|
|
109
|
-
expect(out).toContain('Auto-join: off');
|
|
110
|
-
});
|
|
111
|
-
it('renders actions enabled', () => {
|
|
112
|
-
const out = renderVoiceStatusReport(makeSnapshot({ actionsEnabled: true }));
|
|
113
|
-
expect(out).toContain('Actions: enabled');
|
|
114
|
-
});
|
|
115
|
-
it('renders actions disabled', () => {
|
|
116
|
-
const out = renderVoiceStatusReport(makeSnapshot({ actionsEnabled: false }));
|
|
117
|
-
expect(out).toContain('Actions: disabled');
|
|
118
|
-
});
|
|
119
|
-
it('renders Connections: none when no active connections', () => {
|
|
120
|
-
const out = renderVoiceStatusReport(makeSnapshot({ connections: [] }));
|
|
121
|
-
expect(out).toContain('Connections: none');
|
|
122
|
-
});
|
|
123
|
-
it('renders connection list with count and details', () => {
|
|
124
|
-
const snap = makeSnapshot({
|
|
125
|
-
connections: [
|
|
126
|
-
{ guildId: 'g1', channelId: 'vc1', state: 'ready', selfMute: false, selfDeaf: false },
|
|
127
|
-
{ guildId: 'g2', channelId: 'vc2', state: 'connecting', selfMute: true, selfDeaf: true },
|
|
128
|
-
],
|
|
129
|
-
});
|
|
130
|
-
const out = renderVoiceStatusReport(snap);
|
|
131
|
-
expect(out).toContain('Connections (2):');
|
|
32
|
+
it('renders missing Gemini key and active connections', () => {
|
|
33
|
+
const out = renderVoiceStatusReport(makeSnapshot({
|
|
34
|
+
geminiKeySet: false,
|
|
35
|
+
connections: [{ guildId: 'g1', channelId: 'vc1', state: 'ready', selfMute: false, selfDeaf: false }],
|
|
36
|
+
}));
|
|
37
|
+
expect(out).toContain('Provider: gemini-live (key: MISSING');
|
|
38
|
+
expect(out).toContain('Connections (1):');
|
|
132
39
|
expect(out).toContain('guild=g1: channel=vc1, state=ready, mute=false, deaf=false');
|
|
133
|
-
expect(out).toContain('guild=g2: channel=vc2, state=connecting, mute=true, deaf=true');
|
|
134
|
-
});
|
|
135
|
-
it('uses custom bot display name', () => {
|
|
136
|
-
const out = renderVoiceStatusReport(makeSnapshot(), 'MyBot');
|
|
137
|
-
expect(out).toContain('MyBot Voice Status');
|
|
138
|
-
expect(out).not.toContain('Discoclaw Voice Status');
|
|
139
|
-
});
|
|
140
|
-
it('defaults to Discoclaw when no name provided', () => {
|
|
141
|
-
const out = renderVoiceStatusReport(makeSnapshot());
|
|
142
|
-
expect(out).toContain('Discoclaw Voice Status');
|
|
143
|
-
});
|
|
144
|
-
it('wraps output in a fenced text code block', () => {
|
|
145
|
-
const out = renderVoiceStatusReport(makeSnapshot());
|
|
146
|
-
expect(out).toMatch(/^```text\n/);
|
|
147
|
-
expect(out).toMatch(/\n```$/);
|
|
148
40
|
});
|
|
149
41
|
});
|
|
@@ -303,6 +303,139 @@ describe('auto-follow-up for query actions', () => {
|
|
|
303
303
|
expect(secondPrompt).toContain('[Auto-follow-up]');
|
|
304
304
|
expect(secondPrompt).toContain('Done:');
|
|
305
305
|
});
|
|
306
|
+
it('microcompacts large follow-up payloads while preserving ids and failure clues', async () => {
|
|
307
|
+
let callCount = 0;
|
|
308
|
+
const runtime = {
|
|
309
|
+
invoke: vi.fn(async function* () {
|
|
310
|
+
callCount++;
|
|
311
|
+
if (callCount === 1) {
|
|
312
|
+
yield {
|
|
313
|
+
type: 'text_final',
|
|
314
|
+
text: [
|
|
315
|
+
'Review the latest results.',
|
|
316
|
+
'<discord-action>{"type":"channelList"}</discord-action>',
|
|
317
|
+
'<discord-action>{"type":"channelCreate","name":"ops-archive"}</discord-action>',
|
|
318
|
+
].join('\n'),
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
else {
|
|
322
|
+
yield { type: 'text_final', text: 'I preserved the key ids and the actionable failure details.' };
|
|
323
|
+
}
|
|
324
|
+
}),
|
|
325
|
+
};
|
|
326
|
+
const executeSpy = vi.spyOn(discordActions, 'executeDiscordActions').mockResolvedValue([
|
|
327
|
+
{
|
|
328
|
+
ok: true,
|
|
329
|
+
summary: [
|
|
330
|
+
'Messages in #ops:',
|
|
331
|
+
'[alice] alpha update (id:1001)',
|
|
332
|
+
'[bob] beta update (id:1002)',
|
|
333
|
+
'[carol] gamma update (id:1003)',
|
|
334
|
+
'[dave] delta update (id:1004)',
|
|
335
|
+
'[erin] epsilon update (id:1005)',
|
|
336
|
+
'[frank] zeta update (id:1006)',
|
|
337
|
+
'[grace] eta update (id:1007)',
|
|
338
|
+
'[heidi] theta update (id:1008)',
|
|
339
|
+
'[ivan] iota update (id:1009)',
|
|
340
|
+
].join('\n'),
|
|
341
|
+
},
|
|
342
|
+
{
|
|
343
|
+
ok: false,
|
|
344
|
+
error: [
|
|
345
|
+
'Forge sync failed while opening workspace state',
|
|
346
|
+
'Workspace: /home/davidmarsh/code/discoclaw/workspace/state.json',
|
|
347
|
+
'Last error: ENOENT: no such file or directory',
|
|
348
|
+
'Stack: at openWorkspaceState (src/discord/actions.ts:400:12)',
|
|
349
|
+
'Stack: at runForgeSync (src/discord/actions.ts:512:8)',
|
|
350
|
+
'Stack: at async executeForgeAction (src/discord/actions.ts:618:3)',
|
|
351
|
+
'Retry hint: recreate the state file, then rerun forge sync',
|
|
352
|
+
'Node: v24.0.0',
|
|
353
|
+
'cwd: /home/davidmarsh/code/discoclaw',
|
|
354
|
+
].join('\n'),
|
|
355
|
+
},
|
|
356
|
+
]);
|
|
357
|
+
try {
|
|
358
|
+
const handler = createMessageCreateHandler(baseParams(runtime), makeQueue());
|
|
359
|
+
await handler(makeMsg());
|
|
360
|
+
expect(runtime.invoke).toHaveBeenCalledTimes(2);
|
|
361
|
+
const secondPrompt = runtime.invoke.mock.calls[1][0].prompt;
|
|
362
|
+
expect(secondPrompt).toContain('[Auto-follow-up]');
|
|
363
|
+
expect(secondPrompt).toContain('Done: Messages in #ops:');
|
|
364
|
+
expect(secondPrompt).toContain('(id:1001)');
|
|
365
|
+
expect(secondPrompt).toContain('(id:1002)');
|
|
366
|
+
expect(secondPrompt).toContain('(id:1008)');
|
|
367
|
+
expect(secondPrompt).toContain('(id:1009)');
|
|
368
|
+
expect(secondPrompt).toContain('...[omitted 5 lines]');
|
|
369
|
+
expect(secondPrompt).not.toContain('(id:1005)');
|
|
370
|
+
expect(secondPrompt).toContain('Failed: Forge sync failed while opening workspace state');
|
|
371
|
+
expect(secondPrompt).toContain('Workspace: /home/davidmarsh/code/discoclaw/workspace/state.json');
|
|
372
|
+
expect(secondPrompt).toContain('Last error: ENOENT: no such file or directory');
|
|
373
|
+
expect(secondPrompt).toContain('Retry hint: recreate the state file, then rerun forge sync');
|
|
374
|
+
expect(secondPrompt).toContain('cwd: /home/davidmarsh/code/discoclaw');
|
|
375
|
+
expect(secondPrompt).not.toContain('Stack: at runForgeSync');
|
|
376
|
+
}
|
|
377
|
+
finally {
|
|
378
|
+
executeSpy.mockRestore();
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
it('keeps posted display output full while compacting the follow-up prompt', async () => {
|
|
382
|
+
let callCount = 0;
|
|
383
|
+
const runtime = {
|
|
384
|
+
invoke: vi.fn(async function* () {
|
|
385
|
+
callCount++;
|
|
386
|
+
if (callCount === 1) {
|
|
387
|
+
yield {
|
|
388
|
+
type: 'text_final',
|
|
389
|
+
text: [
|
|
390
|
+
'Here are the fetched messages.',
|
|
391
|
+
'<discord-action>{"type":"channelList"}</discord-action>',
|
|
392
|
+
].join('\n'),
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
else {
|
|
396
|
+
yield { type: 'text_final', text: 'The compacted follow-up still had enough detail to continue.' };
|
|
397
|
+
}
|
|
398
|
+
}),
|
|
399
|
+
};
|
|
400
|
+
const executeSpy = vi.spyOn(discordActions, 'executeDiscordActions').mockResolvedValue([{
|
|
401
|
+
ok: true,
|
|
402
|
+
summary: [
|
|
403
|
+
'Messages in #ops:',
|
|
404
|
+
'[alice] alpha update (id:1001)',
|
|
405
|
+
'[bob] beta update (id:1002)',
|
|
406
|
+
'[carol] gamma update (id:1003)',
|
|
407
|
+
'[dave] delta update (id:1004)',
|
|
408
|
+
'[erin] epsilon update (id:1005)',
|
|
409
|
+
'[frank] zeta update (id:1006)',
|
|
410
|
+
'[grace] eta update (id:1007)',
|
|
411
|
+
'[heidi] theta update (id:1008)',
|
|
412
|
+
'[ivan] iota update (id:1009)',
|
|
413
|
+
].join('\n'),
|
|
414
|
+
}]);
|
|
415
|
+
try {
|
|
416
|
+
const msg = makeMsg();
|
|
417
|
+
const handler = createMessageCreateHandler(baseParams(runtime), makeQueue());
|
|
418
|
+
await handler(msg);
|
|
419
|
+
expect(runtime.invoke).toHaveBeenCalledTimes(2);
|
|
420
|
+
const secondPrompt = runtime.invoke.mock.calls[1][0].prompt;
|
|
421
|
+
expect(secondPrompt).toContain('[Auto-follow-up]');
|
|
422
|
+
expect(secondPrompt).toContain('...[omitted 5 lines]');
|
|
423
|
+
expect(secondPrompt).not.toContain('(id:1005)');
|
|
424
|
+
const replyObj = await msg.reply.mock.results[0]?.value;
|
|
425
|
+
if (replyObj) {
|
|
426
|
+
const allEditContents = replyObj.edit.mock.calls.map((call) => contentFromEditArg(call[0]));
|
|
427
|
+
const postedContent = allEditContents[allEditContents.length - 1];
|
|
428
|
+
expect(postedContent).toContain('Done: Messages in #ops:');
|
|
429
|
+
expect(postedContent).toContain('(id:1001)');
|
|
430
|
+
expect(postedContent).toContain('(id:1005)');
|
|
431
|
+
expect(postedContent).toContain('(id:1009)');
|
|
432
|
+
expect(postedContent).not.toContain('...[omitted 5 lines]');
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
finally {
|
|
436
|
+
executeSpy.mockRestore();
|
|
437
|
+
}
|
|
438
|
+
});
|
|
306
439
|
it('does NOT trigger follow-up for mutation-only actions', async () => {
|
|
307
440
|
const runtime = {
|
|
308
441
|
invoke: vi.fn(async function* () {
|