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
@@ -1,285 +1,47 @@
1
- import { describe, it, expect, vi } from 'vitest';
2
- import { parseVoiceCommand, handleVoiceCommand } from './voice-command.js';
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
- sttProvider: 'deepgram',
11
- ttsProvider: 'deepgram',
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
- ttsProvider: 'deepgram',
19
+ statusSnapshot: makeSnapshot(),
26
20
  ...overrides,
27
21
  };
28
22
  }
29
- // ---------------------------------------------------------------------------
30
- // parseVoiceCommand
31
- // ---------------------------------------------------------------------------
32
23
  describe('parseVoiceCommand', () => {
33
- // Bare command
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
- // Case insensitivity command and subcommand
53
- it('is case-insensitive for the command token', () => {
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
- // Voice-disabled guard
131
- describe('voice-disabled guard', () => {
132
- it('returns disabled message for status when voice is off', async () => {
133
- const result = await handleVoiceCommand({ action: 'status' }, makeOpts({ voiceEnabled: false }));
134
- expect(result).toContain('disabled');
135
- expect(result).toContain('DISCOCLAW_VOICE_ENABLED');
136
- });
137
- it('returns disabled message for set when voice is off', async () => {
138
- const result = await handleVoiceCommand({ action: 'set', voice: 'aura-2-luna-en' }, makeOpts({ voiceEnabled: false }));
139
- expect(result).toContain('disabled');
140
- });
141
- it('returns disabled message for help when voice is off', async () => {
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
- // STT
21
- if (snapshot.sttProvider === 'deepgram') {
22
- const keyLabel = snapshot.deepgramKeySet ? 'key: set' : 'key: MISSING';
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, } from './voice-status-command.js';
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
- sttProvider: 'deepgram',
10
- ttsProvider: 'cartesia',
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('returns true for !voice status', () => {
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('collapses extra internal whitespace', () => {
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 voice enabled', () => {
54
- const out = renderVoiceStatusReport(makeSnapshot({ enabled: true }));
55
- expect(out).toContain('Voice: enabled');
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 home channel as (not set) when absent', () => {
100
- const out = renderVoiceStatusReport(makeSnapshot({ homeChannel: undefined }));
101
- expect(out).toContain('Home channel: (not set)');
102
- });
103
- it('renders auto-join on', () => {
104
- const out = renderVoiceStatusReport(makeSnapshot({ autoJoin: true }));
105
- expect(out).toContain('Auto-join: on');
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* () {