@vellumai/assistant 0.3.13 → 0.3.14

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 (48) hide show
  1. package/ARCHITECTURE.md +17 -3
  2. package/README.md +2 -0
  3. package/docs/architecture/scheduling.md +81 -0
  4. package/package.json +1 -1
  5. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +22 -0
  6. package/src/__tests__/channel-policy.test.ts +19 -0
  7. package/src/__tests__/guardian-control-plane-policy.test.ts +584 -0
  8. package/src/__tests__/intent-routing.test.ts +22 -0
  9. package/src/__tests__/ipc-snapshot.test.ts +10 -0
  10. package/src/__tests__/notification-routing-intent.test.ts +186 -0
  11. package/src/__tests__/recording-handler.test.ts +191 -31
  12. package/src/__tests__/recording-intent-fallback.test.ts +181 -0
  13. package/src/__tests__/recording-intent-handler.test.ts +593 -73
  14. package/src/__tests__/recording-intent.test.ts +739 -343
  15. package/src/__tests__/recording-state-machine.test.ts +1109 -0
  16. package/src/__tests__/reminder-store.test.ts +20 -18
  17. package/src/__tests__/reminder.test.ts +2 -1
  18. package/src/channels/config.ts +1 -1
  19. package/src/config/bundled-skills/phone-calls/SKILL.md +1 -11
  20. package/src/config/bundled-skills/screen-recording/SKILL.md +91 -12
  21. package/src/config/system-prompt.ts +5 -0
  22. package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +1 -0
  23. package/src/daemon/handlers/misc.ts +258 -102
  24. package/src/daemon/handlers/recording.ts +417 -5
  25. package/src/daemon/handlers/sessions.ts +136 -62
  26. package/src/daemon/ipc-contract/computer-use.ts +23 -3
  27. package/src/daemon/ipc-contract/messages.ts +3 -1
  28. package/src/daemon/ipc-contract/shared.ts +6 -0
  29. package/src/daemon/ipc-contract-inventory.json +2 -0
  30. package/src/daemon/lifecycle.ts +2 -0
  31. package/src/daemon/recording-executor.ts +180 -0
  32. package/src/daemon/recording-intent-fallback.ts +132 -0
  33. package/src/daemon/recording-intent.ts +306 -15
  34. package/src/daemon/session-tool-setup.ts +4 -0
  35. package/src/notifications/README.md +69 -1
  36. package/src/notifications/adapters/sms.ts +80 -0
  37. package/src/notifications/broadcaster.ts +1 -0
  38. package/src/notifications/copy-composer.ts +3 -3
  39. package/src/notifications/decision-engine.ts +70 -1
  40. package/src/notifications/decisions-store.ts +24 -0
  41. package/src/notifications/destination-resolver.ts +2 -1
  42. package/src/notifications/emit-signal.ts +35 -3
  43. package/src/notifications/signal.ts +6 -0
  44. package/src/notifications/types.ts +3 -0
  45. package/src/schedule/scheduler.ts +15 -3
  46. package/src/tools/executor.ts +29 -0
  47. package/src/tools/guardian-control-plane-policy.ts +141 -0
  48. package/src/tools/types.ts +2 -0
@@ -1,7 +1,7 @@
1
-
1
+
2
2
  import * as net from 'node:net';
3
3
 
4
- import { beforeEach, describe, expect, mock,test } from 'bun:test';
4
+ import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test';
5
5
 
6
6
  // ─── Mocks (must be before any imports that depend on them) ─────────────────
7
7
 
@@ -13,6 +13,8 @@ const noopLogger = {
13
13
 
14
14
  mock.module('../util/logger.js', () => ({
15
15
  getLogger: () => noopLogger,
16
+ isDebug: () => false,
17
+ truncateForLog: (v: string) => v,
16
18
  }));
17
19
 
18
20
  mock.module('../config/loader.js', () => ({
@@ -53,37 +55,176 @@ mock.module('../daemon/identity-helpers.js', () => ({
53
55
  getAssistantName: () => mockAssistantName,
54
56
  }));
55
57
 
56
- // ── Mock recording-intent — we control the classification result ───────────
57
-
58
- let mockClassifyResult: 'start_only' | 'stop_only' | 'mixed' | 'none' = 'none';
58
+ // ── Mock recording-intent — we control the resolution result ───────────────
59
+ //
60
+ // Bun's mock.module() is global and persists across test files in the same
61
+ // process (no per-file isolation). To prevent this mock from breaking
62
+ // recording-intent.test.ts (which tests the REAL resolveRecordingIntent),
63
+ // we capture real function references before mocking and use a globalThis
64
+ // flag to conditionally delegate to them. The flag is only true while this
65
+ // file's tests are running; after this file completes (afterAll), the mock
66
+ // transparently delegates to the real implementation.
67
+
68
+ type RecordingIntentResult =
69
+ | { kind: 'none' }
70
+ | { kind: 'start_only' }
71
+ | { kind: 'stop_only' }
72
+ | { kind: 'start_with_remainder'; remainder: string }
73
+ | { kind: 'stop_with_remainder'; remainder: string }
74
+ | { kind: 'start_and_stop_only' }
75
+ | { kind: 'start_and_stop_with_remainder'; remainder: string }
76
+ | { kind: 'restart_only' }
77
+ | { kind: 'restart_with_remainder'; remainder: string }
78
+ | { kind: 'pause_only' }
79
+ | { kind: 'resume_only' };
80
+
81
+ let mockIntentResult: RecordingIntentResult = { kind: 'none' };
82
+
83
+ // Capture real function references BEFORE mock.module replaces the module.
84
+ // require() at this point returns the real module since mock.module has not
85
+ // been called yet for this specifier.
86
+ const _realRecordingIntentMod = require('../daemon/recording-intent.js');
87
+ const _realResolveRecordingIntent = _realRecordingIntentMod.resolveRecordingIntent;
88
+ const _realStripDynamicNames = _realRecordingIntentMod.stripDynamicNames;
89
+
90
+ // Flag: when true, the mock returns controlled test values; when false, it
91
+ // delegates to the real implementation. Starts false so that if the mock
92
+ // bleeds to other test files, those files get the real behavior.
93
+ (globalThis as any).__riHandlerUseMockIntent = false;
59
94
 
60
95
  mock.module('../daemon/recording-intent.js', () => ({
61
- classifyRecordingIntent: () => mockClassifyResult,
62
- // Keep legacy exports in case anything references them transitively
63
- isRecordingOnly: () => false,
64
- isStopRecordingOnly: () => false,
65
- detectRecordingIntent: () => false,
66
- detectStopRecordingIntent: () => false,
67
- stripRecordingIntent: (t: string) => t,
68
- stripStopRecordingIntent: (t: string) => t,
96
+ resolveRecordingIntent: (...args: any[]) => {
97
+ if ((globalThis as any).__riHandlerUseMockIntent) return mockIntentResult;
98
+ return _realResolveRecordingIntent(...args);
99
+ },
100
+ stripDynamicNames: (...args: any[]) => {
101
+ if ((globalThis as any).__riHandlerUseMockIntent) return args[0];
102
+ return _realStripDynamicNames(...args);
103
+ },
104
+ }));
105
+
106
+ // ── Mock recording-executor — we control the execution output ──────────────
107
+ //
108
+ // Same transparent-mock pattern as recording-intent above. We try to capture
109
+ // the real exports before mocking; if the require fails (e.g., due to missing
110
+ // transitive dependencies when this file runs in isolation), we fall back to
111
+ // the controlled mock since the real module is not needed in that scenario.
112
+
113
+ interface RecordingExecutionOutput {
114
+ handled: boolean;
115
+ responseText?: string;
116
+ remainderText?: string;
117
+ pendingStart?: boolean;
118
+ pendingStop?: boolean;
119
+ pendingRestart?: boolean;
120
+ recordingStarted?: boolean;
121
+ }
122
+
123
+ let mockExecuteResult: RecordingExecutionOutput = { handled: false };
124
+ let executorCalled = false;
125
+
126
+ let _realExecuteRecordingIntent: ((...args: any[]) => any) | null = null;
127
+ try {
128
+ const _mod = require('../daemon/recording-executor.js');
129
+ _realExecuteRecordingIntent = _mod.executeRecordingIntent;
130
+ } catch {
131
+ // Transitive dependency loading may fail when this file runs alone;
132
+ // the controlled mock will be used exclusively in that case.
133
+ }
134
+
135
+ mock.module('../daemon/recording-executor.js', () => ({
136
+ executeRecordingIntent: (...args: any[]) => {
137
+ if ((globalThis as any).__riHandlerUseMockIntent) {
138
+ executorCalled = true;
139
+ return mockExecuteResult;
140
+ }
141
+ if (_realExecuteRecordingIntent) return _realExecuteRecordingIntent(...args);
142
+ // Fallback if real function was not captured
143
+ return { handled: false };
144
+ },
69
145
  }));
70
146
 
71
147
  // ── Mock recording handlers ────────────────────────────────────────────────
148
+ //
149
+ // Same transparent-mock pattern. The intent test file re-mocks this module
150
+ // inside its own describe block, which will override this mock for those tests.
151
+ // The transparent fallback here ensures that if a third test file imports
152
+ // handlers/recording.js, it gets the real behavior.
72
153
 
73
154
  let recordingStartCalled = false;
74
155
  let recordingStopCalled = false;
156
+ let recordingRestartCalled = false;
157
+ let recordingPauseCalled = false;
158
+ let recordingResumeCalled = false;
159
+
160
+ let _realHandleRecordingStart: ((...args: any[]) => any) | null = null;
161
+ let _realHandleRecordingStop: ((...args: any[]) => any) | null = null;
162
+ let _realHandleRecordingRestart: ((...args: any[]) => any) | null = null;
163
+ let _realHandleRecordingPause: ((...args: any[]) => any) | null = null;
164
+ let _realHandleRecordingResume: ((...args: any[]) => any) | null = null;
165
+ let _realIsRecordingIdle: ((...args: any[]) => any) | null = null;
166
+ let _realRecordingHandlers: any = {};
167
+ let _realResetRecordingState: ((...args: any[]) => any) | null = null;
168
+
169
+ try {
170
+ const _mod = require('../daemon/handlers/recording.js');
171
+ _realHandleRecordingStart = _mod.handleRecordingStart;
172
+ _realHandleRecordingStop = _mod.handleRecordingStop;
173
+ _realHandleRecordingRestart = _mod.handleRecordingRestart;
174
+ _realHandleRecordingPause = _mod.handleRecordingPause;
175
+ _realHandleRecordingResume = _mod.handleRecordingResume;
176
+ _realIsRecordingIdle = _mod.isRecordingIdle;
177
+ _realRecordingHandlers = _mod.recordingHandlers ?? {};
178
+ _realResetRecordingState = _mod.__resetRecordingState;
179
+ } catch {
180
+ // Same as above — controlled mock will be used exclusively.
181
+ }
75
182
 
76
183
  mock.module('../daemon/handlers/recording.js', () => ({
77
- handleRecordingStart: () => {
78
- recordingStartCalled = true;
79
- return 'mock-recording-id';
184
+ handleRecordingStart: (...args: any[]) => {
185
+ if ((globalThis as any).__riHandlerUseMockIntent) {
186
+ recordingStartCalled = true;
187
+ return 'mock-recording-id';
188
+ }
189
+ return _realHandleRecordingStart?.(...args);
190
+ },
191
+ handleRecordingStop: (...args: any[]) => {
192
+ if ((globalThis as any).__riHandlerUseMockIntent) {
193
+ recordingStopCalled = true;
194
+ return 'mock-recording-id';
195
+ }
196
+ return _realHandleRecordingStop?.(...args);
197
+ },
198
+ handleRecordingRestart: (...args: any[]) => {
199
+ if ((globalThis as any).__riHandlerUseMockIntent) {
200
+ recordingRestartCalled = true;
201
+ return { initiated: true, responseText: 'Restarting screen recording.', operationToken: 'mock-token' };
202
+ }
203
+ return _realHandleRecordingRestart?.(...args);
80
204
  },
81
- handleRecordingStop: () => {
82
- recordingStopCalled = true;
83
- return 'mock-recording-id';
205
+ handleRecordingPause: (...args: any[]) => {
206
+ if ((globalThis as any).__riHandlerUseMockIntent) {
207
+ recordingPauseCalled = true;
208
+ return 'mock-recording-id';
209
+ }
210
+ return _realHandleRecordingPause?.(...args);
211
+ },
212
+ handleRecordingResume: (...args: any[]) => {
213
+ if ((globalThis as any).__riHandlerUseMockIntent) {
214
+ recordingResumeCalled = true;
215
+ return 'mock-recording-id';
216
+ }
217
+ return _realHandleRecordingResume?.(...args);
218
+ },
219
+ isRecordingIdle: (...args: any[]) => {
220
+ if ((globalThis as any).__riHandlerUseMockIntent) return true;
221
+ return _realIsRecordingIdle?.(...args) ?? true;
222
+ },
223
+ recordingHandlers: _realRecordingHandlers,
224
+ __resetRecordingState: (...args: any[]) => {
225
+ if ((globalThis as any).__riHandlerUseMockIntent) return;
226
+ return _realResetRecordingState?.(...args);
84
227
  },
85
- recordingHandlers: {},
86
- __resetRecordingState: noop,
87
228
  }));
88
229
 
89
230
  // ── Mock conversation store ────────────────────────────────────────────────
@@ -91,7 +232,10 @@ mock.module('../daemon/handlers/recording.js', () => ({
91
232
  mock.module('../memory/conversation-store.js', () => ({
92
233
  getMessages: () => [],
93
234
  addMessage: () => ({ id: 'msg-mock', role: 'assistant', content: '' }),
94
- createConversation: (title?: string) => ({ id: 'conv-mock', title: title ?? 'Untitled' }),
235
+ createConversation: (titleOrOpts?: string | { title?: string }) => {
236
+ const title = typeof titleOrOpts === 'string' ? titleOrOpts : titleOrOpts?.title ?? 'Untitled';
237
+ return { id: 'conv-mock', title };
238
+ },
95
239
  getConversation: () => ({ id: 'conv-mock' }),
96
240
  updateConversationTitle: noop,
97
241
  clearAll: noop,
@@ -102,7 +246,7 @@ mock.module('../memory/conversation-store.js', () => ({
102
246
  }));
103
247
 
104
248
  mock.module('../memory/conversation-title-service.js', () => ({
105
- GENERATING_TITLE: '(generating)',
249
+ GENERATING_TITLE: '(generating\u2026)',
106
250
  queueGenerateConversationTitle: noop,
107
251
  UNTITLED_FALLBACK: 'Untitled',
108
252
  }));
@@ -120,6 +264,10 @@ mock.module('../security/secret-ingress.js', () => ({
120
264
  checkIngressForSecrets: () => ({ blocked: false }),
121
265
  }));
122
266
 
267
+ mock.module('../security/secret-scanner.js', () => ({
268
+ redactSecrets: (text: string) => text,
269
+ }));
270
+
123
271
  // ── Mock classifier (for task_submit fallthrough) ──────────────────────────
124
272
 
125
273
  let classifierCalled = false;
@@ -147,6 +295,9 @@ mock.module('../daemon/handlers/computer-use.js', () => ({
147
295
 
148
296
  mock.module('../providers/provider-send-message.js', () => ({
149
297
  getConfiguredProvider: () => null,
298
+ extractText: (_response: unknown) => '',
299
+ createTimeout: (_ms: number) => ({ signal: new AbortController().signal, cleanup: () => {} }),
300
+ userMessage: (text: string) => ({ role: 'user', content: text }),
150
301
  }));
151
302
 
152
303
  // ── Mock external conversation store ───────────────────────────────────────
@@ -228,6 +379,10 @@ function createCtx(overrides?: Partial<HandlerContext>): {
228
379
  getQueueDepth: () => 0,
229
380
  setPreactivatedSkillIds: noop,
230
381
  redirectToSecurePrompt: noop,
382
+ setEscalationHandler: noop,
383
+ dispose: noop,
384
+ hasPendingConfirmation: () => false,
385
+ hasPendingSecret: () => false,
231
386
  };
232
387
 
233
388
  const sessions = new Map<string, any>();
@@ -258,19 +413,35 @@ function createCtx(overrides?: Partial<HandlerContext>): {
258
413
  return { ctx, sent, fakeSocket };
259
414
  }
260
415
 
416
+ function resetMockState(): void {
417
+ // Enable mock mode for this file's tests
418
+ (globalThis as any).__riHandlerUseMockIntent = true;
419
+ mockIntentResult = { kind: 'none' };
420
+ mockExecuteResult = { handled: false };
421
+ mockAssistantName = null;
422
+ recordingStartCalled = false;
423
+ recordingStopCalled = false;
424
+ recordingRestartCalled = false;
425
+ recordingPauseCalled = false;
426
+ recordingResumeCalled = false;
427
+ executorCalled = false;
428
+ classifierCalled = false;
429
+ }
430
+
431
+ // Disable mock mode after all tests in this file complete, so that if the
432
+ // mock bleeds to other test files they get the real implementation.
433
+ afterAll(() => {
434
+ (globalThis as any).__riHandlerUseMockIntent = false;
435
+ });
436
+
261
437
  // ─── Tests ──────────────────────────────────────────────────────────────────
262
438
 
263
439
  describe('recording intent handler integration — handleTaskSubmit', () => {
264
- beforeEach(() => {
265
- mockClassifyResult = 'none';
266
- mockAssistantName = null;
267
- recordingStartCalled = false;
268
- recordingStopCalled = false;
269
- classifierCalled = false;
270
- });
440
+ beforeEach(resetMockState);
271
441
 
272
- test('start_only → calls handleRecordingStart, sends task_routed + text_delta + message_complete, returns early', async () => {
273
- mockClassifyResult = 'start_only';
442
+ test('start_only → executeRecordingIntent called, sends task_routed + text_delta + message_complete, returns early', async () => {
443
+ mockIntentResult = { kind: 'start_only' };
444
+ mockExecuteResult = { handled: true, responseText: 'Starting screen recording.', recordingStarted: true };
274
445
  const { ctx, sent, fakeSocket } = createCtx();
275
446
 
276
447
  const { handleTaskSubmit } = await import('../daemon/handlers/misc.js');
@@ -280,18 +451,21 @@ describe('recording intent handler integration — handleTaskSubmit', () => {
280
451
  ctx,
281
452
  );
282
453
 
283
- expect(recordingStartCalled).toBe(true);
284
- expect(recordingStopCalled).toBe(false);
454
+ expect(executorCalled).toBe(true);
285
455
  expect(classifierCalled).toBe(false);
286
456
 
287
457
  const types = sent.map((m) => m.type);
288
458
  expect(types).toContain('task_routed');
289
459
  expect(types).toContain('assistant_text_delta');
290
460
  expect(types).toContain('message_complete');
461
+
462
+ const textDelta = sent.find((m) => m.type === 'assistant_text_delta');
463
+ expect(textDelta?.text).toBe('Starting screen recording.');
291
464
  });
292
465
 
293
- test('stop_only → calls handleRecordingStop, sends task_routed + text_delta + message_complete, returns early', async () => {
294
- mockClassifyResult = 'stop_only';
466
+ test('stop_only → executeRecordingIntent called, sends task_routed + text_delta + message_complete, returns early', async () => {
467
+ mockIntentResult = { kind: 'stop_only' };
468
+ mockExecuteResult = { handled: true, responseText: 'Stopping the recording.' };
295
469
  const { ctx, sent, fakeSocket } = createCtx();
296
470
 
297
471
  const { handleTaskSubmit } = await import('../daemon/handlers/misc.js');
@@ -301,18 +475,21 @@ describe('recording intent handler integration — handleTaskSubmit', () => {
301
475
  ctx,
302
476
  );
303
477
 
304
- expect(recordingStopCalled).toBe(true);
305
- expect(recordingStartCalled).toBe(false);
478
+ expect(executorCalled).toBe(true);
306
479
  expect(classifierCalled).toBe(false);
307
480
 
308
481
  const types = sent.map((m) => m.type);
309
482
  expect(types).toContain('task_routed');
310
483
  expect(types).toContain('assistant_text_delta');
311
484
  expect(types).toContain('message_complete');
485
+
486
+ const textDelta = sent.find((m) => m.type === 'assistant_text_delta');
487
+ expect(textDelta?.text).toBe('Stopping the recording.');
312
488
  });
313
489
 
314
- test('mixeddoes NOT call handleRecordingStart/Stop, falls through to classifier', async () => {
315
- mockClassifyResult = 'mixed';
490
+ test('start_with_remainderdefers recording, falls through to classifier with remaining text', async () => {
491
+ mockIntentResult = { kind: 'start_with_remainder', remainder: 'open Safari' };
492
+ mockExecuteResult = { handled: false, remainderText: 'open Safari', pendingStart: true };
316
493
  const { ctx, sent, fakeSocket } = createCtx();
317
494
 
318
495
  const { handleTaskSubmit } = await import('../daemon/handlers/misc.js');
@@ -322,11 +499,9 @@ describe('recording intent handler integration — handleTaskSubmit', () => {
322
499
  ctx,
323
500
  );
324
501
 
325
- expect(recordingStartCalled).toBe(false);
326
- expect(recordingStopCalled).toBe(false);
327
502
  expect(classifierCalled).toBe(true);
328
503
 
329
- // Should NOT have recording-specific messages before the classifier output
504
+ // Should NOT have recording-only messages before the classifier output
330
505
  const recordingSpecific = sent.filter(
331
506
  (m) => m.type === 'assistant_text_delta' && typeof m.text === 'string' &&
332
507
  (m.text.includes('Starting screen recording') || m.text.includes('Stopping the recording')),
@@ -334,8 +509,8 @@ describe('recording intent handler integration — handleTaskSubmit', () => {
334
509
  expect(recordingSpecific).toHaveLength(0);
335
510
  });
336
511
 
337
- test('none → does NOT call handleRecordingStart/Stop, falls through to classifier', async () => {
338
- mockClassifyResult = 'none';
512
+ test('none → does NOT call executeRecordingIntent, falls through to classifier', async () => {
513
+ mockIntentResult = { kind: 'none' };
339
514
  const { ctx, sent: _sent, fakeSocket } = createCtx();
340
515
 
341
516
  const { handleTaskSubmit } = await import('../daemon/handlers/misc.js');
@@ -345,23 +520,187 @@ describe('recording intent handler integration — handleTaskSubmit', () => {
345
520
  ctx,
346
521
  );
347
522
 
348
- expect(recordingStartCalled).toBe(false);
349
- expect(recordingStopCalled).toBe(false);
523
+ expect(executorCalled).toBe(false);
524
+ expect(classifierCalled).toBe(true);
525
+ });
526
+
527
+ test('restart_only → executeRecordingIntent called, sends task_routed + text_delta + message_complete, returns early', async () => {
528
+ mockIntentResult = { kind: 'restart_only' };
529
+ mockExecuteResult = { handled: true, responseText: 'Restarting screen recording.' };
530
+ const { ctx, sent, fakeSocket } = createCtx();
531
+
532
+ const { handleTaskSubmit } = await import('../daemon/handlers/misc.js');
533
+ await handleTaskSubmit(
534
+ { type: 'task_submit', task: 'restart the recording', source: 'voice' } as any,
535
+ fakeSocket,
536
+ ctx,
537
+ );
538
+
539
+ expect(executorCalled).toBe(true);
540
+ expect(classifierCalled).toBe(false);
541
+
542
+ const types = sent.map((m) => m.type);
543
+ expect(types).toContain('task_routed');
544
+ expect(types).toContain('assistant_text_delta');
545
+ expect(types).toContain('message_complete');
546
+
547
+ const textDelta = sent.find((m) => m.type === 'assistant_text_delta');
548
+ expect(textDelta?.text).toBe('Restarting screen recording.');
549
+ });
550
+
551
+ test('pause_only → executeRecordingIntent called, sends task_routed + text_delta + message_complete, returns early', async () => {
552
+ mockIntentResult = { kind: 'pause_only' };
553
+ mockExecuteResult = { handled: true, responseText: 'Pausing the recording.' };
554
+ const { ctx, sent, fakeSocket } = createCtx();
555
+
556
+ const { handleTaskSubmit } = await import('../daemon/handlers/misc.js');
557
+ await handleTaskSubmit(
558
+ { type: 'task_submit', task: 'pause the recording', source: 'voice' } as any,
559
+ fakeSocket,
560
+ ctx,
561
+ );
562
+
563
+ expect(executorCalled).toBe(true);
564
+ expect(classifierCalled).toBe(false);
565
+
566
+ const types = sent.map((m) => m.type);
567
+ expect(types).toContain('task_routed');
568
+ expect(types).toContain('assistant_text_delta');
569
+ expect(types).toContain('message_complete');
570
+
571
+ const textDelta = sent.find((m) => m.type === 'assistant_text_delta');
572
+ expect(textDelta?.text).toBe('Pausing the recording.');
573
+ });
574
+
575
+ test('resume_only → executeRecordingIntent called, sends task_routed + text_delta + message_complete, returns early', async () => {
576
+ mockIntentResult = { kind: 'resume_only' };
577
+ mockExecuteResult = { handled: true, responseText: 'Resuming the recording.' };
578
+ const { ctx, sent, fakeSocket } = createCtx();
579
+
580
+ const { handleTaskSubmit } = await import('../daemon/handlers/misc.js');
581
+ await handleTaskSubmit(
582
+ { type: 'task_submit', task: 'resume the recording', source: 'voice' } as any,
583
+ fakeSocket,
584
+ ctx,
585
+ );
586
+
587
+ expect(executorCalled).toBe(true);
588
+ expect(classifierCalled).toBe(false);
589
+
590
+ const types = sent.map((m) => m.type);
591
+ expect(types).toContain('task_routed');
592
+ expect(types).toContain('assistant_text_delta');
593
+ expect(types).toContain('message_complete');
594
+
595
+ const textDelta = sent.find((m) => m.type === 'assistant_text_delta');
596
+ expect(textDelta?.text).toBe('Resuming the recording.');
597
+ });
598
+
599
+ test('restart_with_remainder → defers restart, falls through to classifier with remaining text', async () => {
600
+ mockIntentResult = { kind: 'restart_with_remainder', remainder: 'open Safari' };
601
+ mockExecuteResult = { handled: false, remainderText: 'open Safari', pendingRestart: true };
602
+ const { ctx, sent, fakeSocket } = createCtx();
603
+
604
+ const { handleTaskSubmit } = await import('../daemon/handlers/misc.js');
605
+ await handleTaskSubmit(
606
+ { type: 'task_submit', task: 'restart the recording and open Safari', source: 'voice' } as any,
607
+ fakeSocket,
608
+ ctx,
609
+ );
610
+
350
611
  expect(classifierCalled).toBe(true);
612
+
613
+ // Should NOT have restart-specific messages before classifier output
614
+ const recordingSpecific = sent.filter(
615
+ (m) => m.type === 'assistant_text_delta' && typeof m.text === 'string' &&
616
+ m.text.includes('Restarting screen recording'),
617
+ );
618
+ expect(recordingSpecific).toHaveLength(0);
619
+ });
620
+
621
+ test('commandIntent restart → routes directly via handleRecordingRestart, returns early', async () => {
622
+ // commandIntent bypasses text-based intent resolution entirely
623
+ mockIntentResult = { kind: 'none' }; // should not matter
624
+ const { ctx, sent, fakeSocket } = createCtx();
625
+
626
+ const { handleTaskSubmit } = await import('../daemon/handlers/misc.js');
627
+ await handleTaskSubmit(
628
+ {
629
+ type: 'task_submit',
630
+ task: 'restart recording',
631
+ source: 'voice',
632
+ commandIntent: { domain: 'screen_recording', action: 'restart' },
633
+ } as any,
634
+ fakeSocket,
635
+ ctx,
636
+ );
637
+
638
+ expect(recordingRestartCalled).toBe(true);
639
+ expect(classifierCalled).toBe(false);
640
+
641
+ const types = sent.map((m) => m.type);
642
+ expect(types).toContain('task_routed');
643
+ expect(types).toContain('assistant_text_delta');
644
+ expect(types).toContain('message_complete');
645
+ });
646
+
647
+ test('commandIntent pause → routes directly via handleRecordingPause, returns early', async () => {
648
+ mockIntentResult = { kind: 'none' };
649
+ const { ctx, sent, fakeSocket } = createCtx();
650
+
651
+ const { handleTaskSubmit } = await import('../daemon/handlers/misc.js');
652
+ await handleTaskSubmit(
653
+ {
654
+ type: 'task_submit',
655
+ task: 'pause recording',
656
+ source: 'voice',
657
+ commandIntent: { domain: 'screen_recording', action: 'pause' },
658
+ } as any,
659
+ fakeSocket,
660
+ ctx,
661
+ );
662
+
663
+ expect(recordingPauseCalled).toBe(true);
664
+ expect(classifierCalled).toBe(false);
665
+
666
+ const types = sent.map((m) => m.type);
667
+ expect(types).toContain('task_routed');
668
+ expect(types).toContain('assistant_text_delta');
669
+ expect(types).toContain('message_complete');
670
+ });
671
+
672
+ test('commandIntent resume → routes directly via handleRecordingResume, returns early', async () => {
673
+ mockIntentResult = { kind: 'none' };
674
+ const { ctx, sent, fakeSocket } = createCtx();
675
+
676
+ const { handleTaskSubmit } = await import('../daemon/handlers/misc.js');
677
+ await handleTaskSubmit(
678
+ {
679
+ type: 'task_submit',
680
+ task: 'resume recording',
681
+ source: 'voice',
682
+ commandIntent: { domain: 'screen_recording', action: 'resume' },
683
+ } as any,
684
+ fakeSocket,
685
+ ctx,
686
+ );
687
+
688
+ expect(recordingResumeCalled).toBe(true);
689
+ expect(classifierCalled).toBe(false);
690
+
691
+ const types = sent.map((m) => m.type);
692
+ expect(types).toContain('task_routed');
693
+ expect(types).toContain('assistant_text_delta');
694
+ expect(types).toContain('message_complete');
351
695
  });
352
696
  });
353
697
 
354
698
  describe('recording intent handler integration — handleUserMessage', () => {
355
- beforeEach(() => {
356
- mockClassifyResult = 'none';
357
- mockAssistantName = null;
358
- recordingStartCalled = false;
359
- recordingStopCalled = false;
360
- classifierCalled = false;
361
- });
699
+ beforeEach(resetMockState);
362
700
 
363
- test('start_only → calls handleRecordingStart, sends text_delta + message_complete, returns early', async () => {
364
- mockClassifyResult = 'start_only';
701
+ test('start_only → executeRecordingIntent called, sends text_delta + message_complete, returns early', async () => {
702
+ mockIntentResult = { kind: 'start_only' };
703
+ mockExecuteResult = { handled: true, responseText: 'Starting screen recording.', recordingStarted: true };
365
704
  const { ctx, sent, fakeSocket } = createCtx();
366
705
 
367
706
  const { handleUserMessage } = await import('../daemon/handlers/sessions.js');
@@ -376,22 +715,20 @@ describe('recording intent handler integration — handleUserMessage', () => {
376
715
  ctx,
377
716
  );
378
717
 
379
- expect(recordingStartCalled).toBe(true);
380
- expect(recordingStopCalled).toBe(false);
718
+ expect(executorCalled).toBe(true);
381
719
 
382
720
  const types = sent.map((m) => m.type);
383
721
  expect(types).toContain('assistant_text_delta');
384
722
  expect(types).toContain('message_complete');
385
723
 
386
- // Should not proceed to enqueueMessage the message_complete means it returned early
387
- // The absence of enqueueMessage side effects is hard to test directly,
388
- // but we verify message_complete was the last message sent.
724
+ // message_complete should be the last message sent (recording returned early)
389
725
  const lastMsg = sent[sent.length - 1];
390
726
  expect(lastMsg.type).toBe('message_complete');
391
727
  });
392
728
 
393
- test('stop_only → calls handleRecordingStop, sends text_delta + message_complete, returns early', async () => {
394
- mockClassifyResult = 'stop_only';
729
+ test('stop_only → executeRecordingIntent called, sends text_delta + message_complete, returns early', async () => {
730
+ mockIntentResult = { kind: 'stop_only' };
731
+ mockExecuteResult = { handled: true, responseText: 'Stopping the recording.' };
395
732
  const { ctx, sent, fakeSocket } = createCtx();
396
733
 
397
734
  const { handleUserMessage } = await import('../daemon/handlers/sessions.js');
@@ -406,8 +743,7 @@ describe('recording intent handler integration — handleUserMessage', () => {
406
743
  ctx,
407
744
  );
408
745
 
409
- expect(recordingStopCalled).toBe(true);
410
- expect(recordingStartCalled).toBe(false);
746
+ expect(executorCalled).toBe(true);
411
747
 
412
748
  const types = sent.map((m) => m.type);
413
749
  expect(types).toContain('assistant_text_delta');
@@ -417,8 +753,9 @@ describe('recording intent handler integration — handleUserMessage', () => {
417
753
  expect(lastMsg.type).toBe('message_complete');
418
754
  });
419
755
 
420
- test('mixed → does NOT intercept, proceeds to normal message processing', async () => {
421
- mockClassifyResult = 'mixed';
756
+ test('start_with_remainder → does NOT return early, proceeds to normal message processing', async () => {
757
+ mockIntentResult = { kind: 'start_with_remainder', remainder: 'open Safari' };
758
+ mockExecuteResult = { handled: false, remainderText: 'open Safari', pendingStart: true };
422
759
  const { ctx, sent, fakeSocket } = createCtx();
423
760
 
424
761
  const { handleUserMessage } = await import('../daemon/handlers/sessions.js');
@@ -433,10 +770,10 @@ describe('recording intent handler integration — handleUserMessage', () => {
433
770
  ctx,
434
771
  );
435
772
 
436
- expect(recordingStartCalled).toBe(false);
437
- expect(recordingStopCalled).toBe(false);
773
+ // Should deferred recording start and proceed to normal processing
774
+ expect(recordingStartCalled).toBe(true);
438
775
 
439
- // Should NOT have recording-specific messages
776
+ // Should NOT have recording-specific intercept messages
440
777
  const recordingSpecific = sent.filter(
441
778
  (m) => m.type === 'assistant_text_delta' && typeof m.text === 'string' &&
442
779
  (m.text.includes('Starting screen recording') || m.text.includes('Stopping the recording')),
@@ -445,7 +782,7 @@ describe('recording intent handler integration — handleUserMessage', () => {
445
782
  });
446
783
 
447
784
  test('none → does NOT intercept, proceeds to normal message processing', async () => {
448
- mockClassifyResult = 'none';
785
+ mockIntentResult = { kind: 'none' };
449
786
  const { ctx, sent, fakeSocket } = createCtx();
450
787
 
451
788
  const { handleUserMessage } = await import('../daemon/handlers/sessions.js');
@@ -460,8 +797,7 @@ describe('recording intent handler integration — handleUserMessage', () => {
460
797
  ctx,
461
798
  );
462
799
 
463
- expect(recordingStartCalled).toBe(false);
464
- expect(recordingStopCalled).toBe(false);
800
+ expect(executorCalled).toBe(false);
465
801
 
466
802
  // Should NOT have recording-specific messages
467
803
  const recordingSpecific = sent.filter(
@@ -470,4 +806,188 @@ describe('recording intent handler integration — handleUserMessage', () => {
470
806
  );
471
807
  expect(recordingSpecific).toHaveLength(0);
472
808
  });
809
+
810
+ test('restart_only → executeRecordingIntent called, sends text_delta + message_complete, returns early', async () => {
811
+ mockIntentResult = { kind: 'restart_only' };
812
+ mockExecuteResult = { handled: true, responseText: 'Restarting screen recording.' };
813
+ const { ctx, sent, fakeSocket } = createCtx();
814
+
815
+ const { handleUserMessage } = await import('../daemon/handlers/sessions.js');
816
+ await handleUserMessage(
817
+ {
818
+ type: 'user_message',
819
+ sessionId: 'test-session',
820
+ content: 'restart the recording',
821
+ interface: 'vellum',
822
+ } as any,
823
+ fakeSocket,
824
+ ctx,
825
+ );
826
+
827
+ expect(executorCalled).toBe(true);
828
+
829
+ const types = sent.map((m) => m.type);
830
+ expect(types).toContain('assistant_text_delta');
831
+ expect(types).toContain('message_complete');
832
+
833
+ const lastMsg = sent[sent.length - 1];
834
+ expect(lastMsg.type).toBe('message_complete');
835
+ });
836
+
837
+ test('pause_only → executeRecordingIntent called, sends text_delta + message_complete, returns early', async () => {
838
+ mockIntentResult = { kind: 'pause_only' };
839
+ mockExecuteResult = { handled: true, responseText: 'Pausing the recording.' };
840
+ const { ctx, sent, fakeSocket } = createCtx();
841
+
842
+ const { handleUserMessage } = await import('../daemon/handlers/sessions.js');
843
+ await handleUserMessage(
844
+ {
845
+ type: 'user_message',
846
+ sessionId: 'test-session',
847
+ content: 'pause the recording',
848
+ interface: 'vellum',
849
+ } as any,
850
+ fakeSocket,
851
+ ctx,
852
+ );
853
+
854
+ expect(executorCalled).toBe(true);
855
+
856
+ const types = sent.map((m) => m.type);
857
+ expect(types).toContain('assistant_text_delta');
858
+ expect(types).toContain('message_complete');
859
+
860
+ const lastMsg = sent[sent.length - 1];
861
+ expect(lastMsg.type).toBe('message_complete');
862
+ });
863
+
864
+ test('resume_only → executeRecordingIntent called, sends text_delta + message_complete, returns early', async () => {
865
+ mockIntentResult = { kind: 'resume_only' };
866
+ mockExecuteResult = { handled: true, responseText: 'Resuming the recording.' };
867
+ const { ctx, sent, fakeSocket } = createCtx();
868
+
869
+ const { handleUserMessage } = await import('../daemon/handlers/sessions.js');
870
+ await handleUserMessage(
871
+ {
872
+ type: 'user_message',
873
+ sessionId: 'test-session',
874
+ content: 'resume the recording',
875
+ interface: 'vellum',
876
+ } as any,
877
+ fakeSocket,
878
+ ctx,
879
+ );
880
+
881
+ expect(executorCalled).toBe(true);
882
+
883
+ const types = sent.map((m) => m.type);
884
+ expect(types).toContain('assistant_text_delta');
885
+ expect(types).toContain('message_complete');
886
+
887
+ const lastMsg = sent[sent.length - 1];
888
+ expect(lastMsg.type).toBe('message_complete');
889
+ });
890
+
891
+ test('restart_with_remainder → defers restart, continues with remaining text', async () => {
892
+ mockIntentResult = { kind: 'restart_with_remainder', remainder: 'open Safari' };
893
+ mockExecuteResult = { handled: false, remainderText: 'open Safari', pendingRestart: true };
894
+ const { ctx, sent, fakeSocket } = createCtx();
895
+
896
+ const { handleUserMessage } = await import('../daemon/handlers/sessions.js');
897
+ await handleUserMessage(
898
+ {
899
+ type: 'user_message',
900
+ sessionId: 'test-session',
901
+ content: 'restart the recording and open Safari',
902
+ interface: 'vellum',
903
+ } as any,
904
+ fakeSocket,
905
+ ctx,
906
+ );
907
+
908
+ // Deferred restart should have been executed
909
+ expect(recordingRestartCalled).toBe(true);
910
+
911
+ // Should NOT have restart-specific intercept messages
912
+ const recordingSpecific = sent.filter(
913
+ (m) => m.type === 'assistant_text_delta' && typeof m.text === 'string' &&
914
+ m.text.includes('Restarting screen recording'),
915
+ );
916
+ expect(recordingSpecific).toHaveLength(0);
917
+ });
918
+
919
+ test('commandIntent restart → routes directly via handleRecordingRestart, returns early', async () => {
920
+ mockIntentResult = { kind: 'none' };
921
+ const { ctx, sent, fakeSocket } = createCtx();
922
+
923
+ const { handleUserMessage } = await import('../daemon/handlers/sessions.js');
924
+ await handleUserMessage(
925
+ {
926
+ type: 'user_message',
927
+ sessionId: 'test-session',
928
+ content: 'restart recording',
929
+ interface: 'vellum',
930
+ commandIntent: { domain: 'screen_recording', action: 'restart' },
931
+ } as any,
932
+ fakeSocket,
933
+ ctx,
934
+ );
935
+
936
+ expect(recordingRestartCalled).toBe(true);
937
+
938
+ const types = sent.map((m) => m.type);
939
+ expect(types).toContain('assistant_text_delta');
940
+ expect(types).toContain('message_complete');
941
+
942
+ const lastMsg = sent[sent.length - 1];
943
+ expect(lastMsg.type).toBe('message_complete');
944
+ });
945
+
946
+ test('commandIntent pause → routes directly via handleRecordingPause, returns early', async () => {
947
+ mockIntentResult = { kind: 'none' };
948
+ const { ctx, sent, fakeSocket } = createCtx();
949
+
950
+ const { handleUserMessage } = await import('../daemon/handlers/sessions.js');
951
+ await handleUserMessage(
952
+ {
953
+ type: 'user_message',
954
+ sessionId: 'test-session',
955
+ content: 'pause recording',
956
+ interface: 'vellum',
957
+ commandIntent: { domain: 'screen_recording', action: 'pause' },
958
+ } as any,
959
+ fakeSocket,
960
+ ctx,
961
+ );
962
+
963
+ expect(recordingPauseCalled).toBe(true);
964
+
965
+ const types = sent.map((m) => m.type);
966
+ expect(types).toContain('assistant_text_delta');
967
+ expect(types).toContain('message_complete');
968
+ });
969
+
970
+ test('commandIntent resume → routes directly via handleRecordingResume, returns early', async () => {
971
+ mockIntentResult = { kind: 'none' };
972
+ const { ctx, sent, fakeSocket } = createCtx();
973
+
974
+ const { handleUserMessage } = await import('../daemon/handlers/sessions.js');
975
+ await handleUserMessage(
976
+ {
977
+ type: 'user_message',
978
+ sessionId: 'test-session',
979
+ content: 'resume recording',
980
+ interface: 'vellum',
981
+ commandIntent: { domain: 'screen_recording', action: 'resume' },
982
+ } as any,
983
+ fakeSocket,
984
+ ctx,
985
+ );
986
+
987
+ expect(recordingResumeCalled).toBe(true);
988
+
989
+ const types = sent.map((m) => m.type);
990
+ expect(types).toContain('assistant_text_delta');
991
+ expect(types).toContain('message_complete');
992
+ });
473
993
  });