@vellumai/assistant 0.3.13 → 0.3.15

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 (57) hide show
  1. package/ARCHITECTURE.md +17 -3
  2. package/Dockerfile +1 -1
  3. package/README.md +2 -0
  4. package/docs/architecture/scheduling.md +81 -0
  5. package/package.json +1 -1
  6. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +22 -0
  7. package/src/__tests__/channel-policy.test.ts +19 -0
  8. package/src/__tests__/guardian-control-plane-policy.test.ts +582 -0
  9. package/src/__tests__/guardian-outbound-http.test.ts +8 -8
  10. package/src/__tests__/intent-routing.test.ts +22 -0
  11. package/src/__tests__/ipc-snapshot.test.ts +10 -0
  12. package/src/__tests__/notification-routing-intent.test.ts +185 -0
  13. package/src/__tests__/recording-handler.test.ts +191 -31
  14. package/src/__tests__/recording-intent-fallback.test.ts +180 -0
  15. package/src/__tests__/recording-intent-handler.test.ts +597 -74
  16. package/src/__tests__/recording-intent.test.ts +738 -342
  17. package/src/__tests__/recording-state-machine.test.ts +1109 -0
  18. package/src/__tests__/reminder-store.test.ts +20 -18
  19. package/src/__tests__/reminder.test.ts +2 -1
  20. package/src/channels/config.ts +1 -1
  21. package/src/config/bundled-skills/phone-calls/SKILL.md +1 -11
  22. package/src/config/bundled-skills/screen-recording/SKILL.md +91 -12
  23. package/src/config/system-prompt.ts +5 -0
  24. package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +1 -0
  25. package/src/daemon/handlers/config-channels.ts +6 -6
  26. package/src/daemon/handlers/index.ts +1 -1
  27. package/src/daemon/handlers/misc.ts +258 -102
  28. package/src/daemon/handlers/recording.ts +417 -5
  29. package/src/daemon/handlers/sessions.ts +142 -68
  30. package/src/daemon/ipc-contract/computer-use.ts +23 -3
  31. package/src/daemon/ipc-contract/messages.ts +3 -1
  32. package/src/daemon/ipc-contract/shared.ts +6 -0
  33. package/src/daemon/ipc-contract-inventory.json +2 -0
  34. package/src/daemon/lifecycle.ts +2 -0
  35. package/src/daemon/recording-executor.ts +180 -0
  36. package/src/daemon/recording-intent-fallback.ts +132 -0
  37. package/src/daemon/recording-intent.ts +306 -15
  38. package/src/daemon/session-tool-setup.ts +4 -0
  39. package/src/memory/conversation-attention-store.ts +5 -5
  40. package/src/notifications/README.md +69 -1
  41. package/src/notifications/adapters/sms.ts +80 -0
  42. package/src/notifications/broadcaster.ts +1 -0
  43. package/src/notifications/copy-composer.ts +3 -3
  44. package/src/notifications/decision-engine.ts +70 -1
  45. package/src/notifications/decisions-store.ts +24 -0
  46. package/src/notifications/destination-resolver.ts +2 -1
  47. package/src/notifications/emit-signal.ts +35 -3
  48. package/src/notifications/signal.ts +6 -0
  49. package/src/notifications/types.ts +3 -0
  50. package/src/runtime/guardian-outbound-actions.ts +9 -9
  51. package/src/runtime/http-server.ts +7 -7
  52. package/src/runtime/routes/conversation-attention-routes.ts +3 -3
  53. package/src/runtime/routes/integration-routes.ts +5 -5
  54. package/src/schedule/scheduler.ts +15 -3
  55. package/src/tools/executor.ts +29 -0
  56. package/src/tools/guardian-control-plane-policy.ts +141 -0
  57. package/src/tools/types.ts +2 -0
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Tests for fire-time routing intent enforcement.
3
+ *
4
+ * Validates that the post-decision enforcement step correctly overrides
5
+ * the decision engine's channel selection based on the routing intent
6
+ * persisted on the reminder at create time.
7
+ */
8
+
9
+ import { describe, expect, mock, test } from 'bun:test';
10
+
11
+ mock.module('../util/logger.js', () => ({
12
+ getLogger: () =>
13
+ new Proxy({} as Record<string, unknown>, {
14
+ get: () => () => {},
15
+ }),
16
+ isDebug: () => false,
17
+ truncateForLog: (v: string) => v,
18
+ }));
19
+
20
+ import { enforceRoutingIntent } from '../notifications/decision-engine.js';
21
+ import type { NotificationChannel, NotificationDecision } from '../notifications/types.js';
22
+
23
+ // -- Helpers -----------------------------------------------------------------
24
+
25
+ function makeDecision(overrides?: Partial<NotificationDecision>): NotificationDecision {
26
+ return {
27
+ shouldNotify: true,
28
+ selectedChannels: ['vellum'],
29
+ reasoningSummary: 'LLM selected vellum only',
30
+ renderedCopy: {
31
+ vellum: { title: 'Reminder', body: 'Test reminder' },
32
+ },
33
+ dedupeKey: 'routing-test-001',
34
+ confidence: 0.9,
35
+ fallbackUsed: false,
36
+ ...overrides,
37
+ };
38
+ }
39
+
40
+ // -- Tests -------------------------------------------------------------------
41
+
42
+ describe('routing intent enforcement', () => {
43
+ describe('all_channels intent', () => {
44
+ test('forces selection to all connected channels', () => {
45
+ const decision = makeDecision({ selectedChannels: ['vellum'] });
46
+ const connected: NotificationChannel[] = ['vellum', 'telegram'];
47
+
48
+ const enforced = enforceRoutingIntent(decision, 'all_channels', connected);
49
+
50
+ expect(enforced.selectedChannels).toEqual(['vellum', 'telegram']);
51
+ expect(enforced.reasoningSummary).toContain('routing_intent=all_channels');
52
+ });
53
+
54
+ test('selects all channels even when LLM picked none', () => {
55
+ const decision = makeDecision({ selectedChannels: [] });
56
+ const connected: NotificationChannel[] = ['vellum', 'telegram'];
57
+
58
+ // shouldNotify must be true for enforcement to apply
59
+ const enforced = enforceRoutingIntent(decision, 'all_channels', connected);
60
+ expect(enforced.selectedChannels).toEqual(['vellum', 'telegram']);
61
+ });
62
+
63
+ test('does not modify decision when shouldNotify is false', () => {
64
+ const decision = makeDecision({ shouldNotify: false, selectedChannels: [] });
65
+ const connected: NotificationChannel[] = ['vellum', 'telegram'];
66
+
67
+ const enforced = enforceRoutingIntent(decision, 'all_channels', connected);
68
+
69
+ expect(enforced.shouldNotify).toBe(false);
70
+ expect(enforced.selectedChannels).toEqual([]);
71
+ });
72
+
73
+ test('single connected channel selects that channel', () => {
74
+ const decision = makeDecision({ selectedChannels: ['vellum'] });
75
+ const connected: NotificationChannel[] = ['vellum'];
76
+
77
+ const enforced = enforceRoutingIntent(decision, 'all_channels', connected);
78
+ expect(enforced.selectedChannels).toEqual(['vellum']);
79
+ });
80
+
81
+ test('includes SMS when SMS is among connected channels', () => {
82
+ const decision = makeDecision({ selectedChannels: ['vellum'] });
83
+ const connected: NotificationChannel[] = ['vellum', 'telegram', 'sms'];
84
+
85
+ const enforced = enforceRoutingIntent(decision, 'all_channels', connected);
86
+
87
+ expect(enforced.selectedChannels).toEqual(['vellum', 'telegram', 'sms']);
88
+ expect(enforced.reasoningSummary).toContain('routing_intent=all_channels');
89
+ });
90
+
91
+ test('excludes SMS when SMS is not among connected channels', () => {
92
+ const decision = makeDecision({ selectedChannels: ['vellum'] });
93
+ const connected: NotificationChannel[] = ['vellum', 'telegram'];
94
+
95
+ const enforced = enforceRoutingIntent(decision, 'all_channels', connected);
96
+
97
+ expect(enforced.selectedChannels).toEqual(['vellum', 'telegram']);
98
+ expect(enforced.selectedChannels).not.toContain('sms');
99
+ });
100
+ });
101
+
102
+ describe('multi_channel intent', () => {
103
+ test('expands to all connected when LLM picked fewer than 2 and 2+ are connected', () => {
104
+ const decision = makeDecision({ selectedChannels: ['vellum'] });
105
+ const connected: NotificationChannel[] = ['vellum', 'telegram'];
106
+
107
+ const enforced = enforceRoutingIntent(decision, 'multi_channel', connected);
108
+
109
+ expect(enforced.selectedChannels).toEqual(['vellum', 'telegram']);
110
+ expect(enforced.reasoningSummary).toContain('routing_intent=multi_channel');
111
+ });
112
+
113
+ test('does not override when LLM already picked 2+ channels', () => {
114
+ const decision = makeDecision({ selectedChannels: ['vellum', 'telegram'] });
115
+ const connected: NotificationChannel[] = ['vellum', 'telegram'];
116
+
117
+ const enforced = enforceRoutingIntent(decision, 'multi_channel', connected);
118
+
119
+ expect(enforced.selectedChannels).toEqual(['vellum', 'telegram']);
120
+ // No enforcement annotation since decision already satisfied the intent
121
+ expect(enforced.reasoningSummary).not.toContain('routing_intent=multi_channel');
122
+ });
123
+
124
+ test('does not expand when only 1 channel is connected', () => {
125
+ const decision = makeDecision({ selectedChannels: ['vellum'] });
126
+ const connected: NotificationChannel[] = ['vellum'];
127
+
128
+ const enforced = enforceRoutingIntent(decision, 'multi_channel', connected);
129
+
130
+ // Cannot expand to 2+ when only 1 is available
131
+ expect(enforced.selectedChannels).toEqual(['vellum']);
132
+ });
133
+
134
+ test('does not modify decision when shouldNotify is false', () => {
135
+ const decision = makeDecision({ shouldNotify: false, selectedChannels: [] });
136
+ const connected: NotificationChannel[] = ['vellum', 'telegram'];
137
+
138
+ const enforced = enforceRoutingIntent(decision, 'multi_channel', connected);
139
+
140
+ expect(enforced.shouldNotify).toBe(false);
141
+ expect(enforced.selectedChannels).toEqual([]);
142
+ });
143
+ });
144
+
145
+ describe('single_channel intent', () => {
146
+ test('does not modify the decision', () => {
147
+ const decision = makeDecision({ selectedChannels: ['vellum'] });
148
+ const connected: NotificationChannel[] = ['vellum', 'telegram'];
149
+
150
+ const enforced = enforceRoutingIntent(decision, 'single_channel', connected);
151
+
152
+ expect(enforced.selectedChannels).toEqual(['vellum']);
153
+ expect(enforced.reasoningSummary).toBe(decision.reasoningSummary);
154
+ });
155
+ });
156
+
157
+ describe('undefined routing intent', () => {
158
+ test('does not modify the decision', () => {
159
+ const decision = makeDecision({ selectedChannels: ['vellum'] });
160
+ const connected: NotificationChannel[] = ['vellum', 'telegram'];
161
+
162
+ const enforced = enforceRoutingIntent(decision, undefined, connected);
163
+
164
+ expect(enforced.selectedChannels).toEqual(['vellum']);
165
+ });
166
+ });
167
+
168
+ describe('copy generation at fire time', () => {
169
+ test('existing rendered copy is preserved through enforcement', () => {
170
+ const decision = makeDecision({
171
+ selectedChannels: ['vellum'],
172
+ renderedCopy: {
173
+ vellum: { title: 'Reminder', body: 'Pick up groceries' },
174
+ },
175
+ });
176
+ const connected: NotificationChannel[] = ['vellum', 'telegram'];
177
+
178
+ const enforced = enforceRoutingIntent(decision, 'all_channels', connected);
179
+
180
+ // Channels expanded but copy from LLM is preserved
181
+ expect(enforced.selectedChannels).toEqual(['vellum', 'telegram']);
182
+ expect(enforced.renderedCopy.vellum?.body).toBe('Pick up groceries');
183
+ });
184
+ });
185
+ });
@@ -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 { beforeEach, describe, expect, mock, test } from 'bun:test';
5
5
 
6
6
  // ─── Mocks (must be before any imports that depend on them) ─────────────────
7
7
 
@@ -70,20 +70,30 @@ mock.module('../memory/attachments-store.js', () => ({
70
70
  return att;
71
71
  },
72
72
  linkAttachmentToMessage: noop,
73
+ setAttachmentThumbnail: noop,
74
+ }));
75
+
76
+ // ── Mock video thumbnail ───────────────────────────────────────────────────
77
+
78
+ mock.module('../daemon/video-thumbnail.js', () => ({
79
+ generateVideoThumbnail: async () => null,
80
+ generateVideoThumbnailFromPath: async () => null,
73
81
  }));
74
82
 
75
- // Mock node:fs for file existence checks in the recording handler
83
+ // The allowed recordings directory used by the recording handler
84
+ const ALLOWED_RECORDINGS_DIR = `${process.env.HOME}/Library/Application Support/vellum-assistant/recordings`;
85
+
86
+ // Mock node:fs for file existence/stat checks and realpathSync in the recording handler
76
87
  let mockFileExists = true;
77
88
  let mockFileSize = 1024;
78
89
 
79
90
  mock.module('node:fs', () => {
80
- // Re-export real fs for non-mocked functions and add our overrides
81
91
  // eslint-disable-next-line @typescript-eslint/no-require-imports
82
92
  const realFs = require('fs');
83
93
  return {
84
94
  ...realFs,
85
95
  existsSync: (p: string) => {
86
- // Only intercept paths that look like recording files
96
+ // Intercept paths that look like recording files (allowed dir or /tmp/)
87
97
  if (p.includes('recording') || p.includes('/tmp/')) return mockFileExists;
88
98
  return realFs.existsSync(p);
89
99
  },
@@ -91,12 +101,19 @@ mock.module('node:fs', () => {
91
101
  if (p.includes('recording') || p.includes('/tmp/')) return { size: mockFileSize };
92
102
  return realFs.statSync(p, opts);
93
103
  },
104
+ realpathSync: (p: string) => {
105
+ // For test paths under the allowed directory or /tmp/, return as-is
106
+ // to avoid hitting the filesystem (which would throw ENOENT)
107
+ if (p.includes('recording') || p.includes('/tmp/') || p.includes('vellum-assistant')) return p;
108
+ return realFs.realpathSync(p);
109
+ },
110
+ readFileSync: realFs.readFileSync,
94
111
  };
95
112
  });
96
113
 
97
114
  // ─── Imports (after mocks) ──────────────────────────────────────────────────
98
115
 
99
- import { __resetRecordingState,handleRecordingStart, handleRecordingStop, recordingHandlers } from '../daemon/handlers/recording.js';
116
+ import { __resetRecordingState, handleRecordingStart, handleRecordingStop, recordingHandlers } from '../daemon/handlers/recording.js';
100
117
  import type { HandlerContext } from '../daemon/handlers/shared.js';
101
118
  import type { RecordingStatus } from '../daemon/ipc-contract/computer-use.js';
102
119
  import { DebouncerMap } from '../util/debounce.js';
@@ -288,7 +305,7 @@ describe('recordingHandlers.recording_status', () => {
288
305
  mockFileSize = 1024;
289
306
  });
290
307
 
291
- test('handles started status without errors', () => {
308
+ test('handles started status without errors', async () => {
292
309
  const { ctx, fakeSocket } = createCtx();
293
310
  const conversationId = 'conv-status-1';
294
311
 
@@ -302,12 +319,10 @@ describe('recordingHandlers.recording_status', () => {
302
319
  };
303
320
 
304
321
  // Should not throw
305
- expect(() => {
306
- recordingHandlers.recording_status(statusMsg, fakeSocket, ctx);
307
- }).not.toThrow();
322
+ await recordingHandlers.recording_status(statusMsg, fakeSocket, ctx);
308
323
  });
309
324
 
310
- test('handles stopped status with file — creates attachment and notifies client', () => {
325
+ test('handles stopped status with file — creates attachment and notifies client', async () => {
311
326
  const { ctx, sent, fakeSocket } = createCtx();
312
327
  const conversationId = 'conv-status-stopped';
313
328
 
@@ -325,11 +340,11 @@ describe('recordingHandlers.recording_status', () => {
325
340
  type: 'recording_status',
326
341
  sessionId: recordingId!,
327
342
  status: 'stopped',
328
- filePath: '/tmp/recording.mov',
343
+ filePath: `${ALLOWED_RECORDINGS_DIR}/recording.mov`,
329
344
  durationMs: 5000,
330
345
  };
331
346
 
332
- recordingHandlers.recording_status(statusMsg, fakeSocket, ctx);
347
+ await recordingHandlers.recording_status(statusMsg, fakeSocket, ctx);
333
348
 
334
349
  // Should have sent assistant_text_delta and message_complete
335
350
  const textDeltas = sent.filter((m) => m.type === 'assistant_text_delta');
@@ -351,7 +366,7 @@ describe('recordingHandlers.recording_status', () => {
351
366
  expect(createdMsg).toBeTruthy();
352
367
  });
353
368
 
354
- test('handles stopped status and creates assistant message when none exists', () => {
369
+ test('handles stopped status and creates assistant message when none exists', async () => {
355
370
  const { ctx, sent, fakeSocket } = createCtx();
356
371
  const conversationId = 'conv-status-no-msg';
357
372
 
@@ -367,11 +382,11 @@ describe('recordingHandlers.recording_status', () => {
367
382
  type: 'recording_status',
368
383
  sessionId: recordingId!,
369
384
  status: 'stopped',
370
- filePath: '/tmp/recording.mp4',
385
+ filePath: `${ALLOWED_RECORDINGS_DIR}/recording.mp4`,
371
386
  durationMs: 3000,
372
387
  };
373
388
 
374
- recordingHandlers.recording_status(statusMsg, fakeSocket, ctx);
389
+ await recordingHandlers.recording_status(statusMsg, fakeSocket, ctx);
375
390
 
376
391
  // An assistant message should have been created via addMessage mock
377
392
  expect(mockMessages.length).toBeGreaterThanOrEqual(1);
@@ -379,7 +394,7 @@ describe('recordingHandlers.recording_status', () => {
379
394
  expect(createdMsg).toBeTruthy();
380
395
  });
381
396
 
382
- test('handles stopped status when file does not exist — notifies client', () => {
397
+ test('handles stopped status when file does not exist — notifies client', async () => {
383
398
  const { ctx, sent, fakeSocket } = createCtx();
384
399
  const conversationId = 'conv-status-no-file';
385
400
 
@@ -394,29 +409,176 @@ describe('recordingHandlers.recording_status', () => {
394
409
  type: 'recording_status',
395
410
  sessionId: recordingId!,
396
411
  status: 'stopped',
397
- filePath: '/tmp/nonexistent.mov',
412
+ filePath: `${ALLOWED_RECORDINGS_DIR}/nonexistent.mov`,
398
413
  durationMs: 1000,
399
414
  };
400
415
 
401
416
  // Should not throw — the handler logs the error and notifies the client
402
- expect(() => {
403
- recordingHandlers.recording_status(statusMsg, fakeSocket, ctx);
404
- }).not.toThrow();
417
+ await recordingHandlers.recording_status(statusMsg, fakeSocket, ctx);
405
418
 
406
419
  // No attachment should have been created
407
420
  expect(mockAttachments.length).toBe(0);
408
421
 
409
- // Client should be notified that the file is unavailable
422
+ // Client should be notified that the recording failed to save
410
423
  const textDeltas = sent.filter((m) => m.type === 'assistant_text_delta');
411
424
  expect(textDeltas.length).toBeGreaterThanOrEqual(1);
412
- expect(textDeltas[0].text).toContain('unavailable');
425
+ expect(textDeltas[0].text).toContain('Recording failed to save');
413
426
 
414
427
  const completes = sent.filter((m) => m.type === 'message_complete');
415
428
  expect(completes.length).toBeGreaterThanOrEqual(1);
416
429
  expect(completes[0].sessionId).toBe(conversationId);
417
430
  });
418
431
 
419
- test('handles failed status and notifies client', () => {
432
+ test('handles stopped status with zero-length file — treated as failure', async () => {
433
+ const { ctx, sent, fakeSocket } = createCtx();
434
+ const conversationId = 'conv-status-zero-file';
435
+
436
+ ctx.socketToSession.set(fakeSocket, conversationId);
437
+ mockFileExists = true;
438
+ mockFileSize = 0;
439
+
440
+ const recordingId = handleRecordingStart(conversationId, undefined, fakeSocket, ctx);
441
+ expect(recordingId).not.toBeNull();
442
+ sent.length = 0;
443
+
444
+ const statusMsg: RecordingStatus = {
445
+ type: 'recording_status',
446
+ sessionId: recordingId!,
447
+ status: 'stopped',
448
+ filePath: `${ALLOWED_RECORDINGS_DIR}/recording-empty.mov`,
449
+ durationMs: 2000,
450
+ };
451
+
452
+ await recordingHandlers.recording_status(statusMsg, fakeSocket, ctx);
453
+
454
+ // No attachment should have been created for a zero-length file
455
+ expect(mockAttachments.length).toBe(0);
456
+
457
+ // Client should be told the recording failed to save
458
+ const textDeltas = sent.filter((m) => m.type === 'assistant_text_delta');
459
+ expect(textDeltas.length).toBeGreaterThanOrEqual(1);
460
+ expect(textDeltas[0].text).toContain('Recording failed to save');
461
+
462
+ // Should NOT contain the success message
463
+ const hasSuccessMessage = textDeltas.some(
464
+ (m) => typeof m.text === 'string' && m.text.includes('recording complete')
465
+ );
466
+ expect(hasSuccessMessage).toBe(false);
467
+
468
+ const completes = sent.filter((m) => m.type === 'message_complete');
469
+ expect(completes.length).toBeGreaterThanOrEqual(1);
470
+ expect(completes[0].sessionId).toBe(conversationId);
471
+ });
472
+
473
+ test('successful finalization — attachment created and success message sent', async () => {
474
+ const { ctx, sent, fakeSocket } = createCtx();
475
+ const conversationId = 'conv-status-success';
476
+
477
+ ctx.socketToSession.set(fakeSocket, conversationId);
478
+ mockFileExists = true;
479
+ mockFileSize = 4096;
480
+
481
+ const recordingId = handleRecordingStart(conversationId, undefined, fakeSocket, ctx);
482
+ expect(recordingId).not.toBeNull();
483
+ sent.length = 0;
484
+
485
+ const statusMsg: RecordingStatus = {
486
+ type: 'recording_status',
487
+ sessionId: recordingId!,
488
+ status: 'stopped',
489
+ filePath: `${ALLOWED_RECORDINGS_DIR}/recording-good.mov`,
490
+ durationMs: 5000,
491
+ };
492
+
493
+ await recordingHandlers.recording_status(statusMsg, fakeSocket, ctx);
494
+
495
+ // Attachment should have been created
496
+ expect(mockAttachments.length).toBe(1);
497
+ expect(mockAttachments[0].sizeBytes).toBe(4096);
498
+
499
+ // Success message should be present
500
+ const textDeltas = sent.filter((m) => m.type === 'assistant_text_delta');
501
+ expect(textDeltas.length).toBeGreaterThanOrEqual(1);
502
+ expect(textDeltas[0].text).toContain('Screen recording complete');
503
+
504
+ // Should NOT contain failure message
505
+ const hasFailureMessage = textDeltas.some(
506
+ (m) => typeof m.text === 'string' && m.text.includes('Recording failed')
507
+ );
508
+ expect(hasFailureMessage).toBe(false);
509
+ });
510
+
511
+ test('rejects file path outside allowed directory', async () => {
512
+ const { ctx, sent, fakeSocket } = createCtx();
513
+ const conversationId = 'conv-status-outside-dir';
514
+
515
+ ctx.socketToSession.set(fakeSocket, conversationId);
516
+ mockFileExists = true;
517
+ mockFileSize = 4096;
518
+
519
+ const recordingId = handleRecordingStart(conversationId, undefined, fakeSocket, ctx);
520
+ expect(recordingId).not.toBeNull();
521
+ sent.length = 0;
522
+
523
+ const statusMsg: RecordingStatus = {
524
+ type: 'recording_status',
525
+ sessionId: recordingId!,
526
+ status: 'stopped',
527
+ filePath: '/tmp/evil.mov',
528
+ durationMs: 5000,
529
+ };
530
+
531
+ await recordingHandlers.recording_status(statusMsg, fakeSocket, ctx);
532
+
533
+ // No attachment should have been created — path is outside allowlist
534
+ expect(mockAttachments.length).toBe(0);
535
+
536
+ // Client should be told the recording is unavailable
537
+ const textDeltas = sent.filter((m) => m.type === 'assistant_text_delta');
538
+ expect(textDeltas.length).toBeGreaterThanOrEqual(1);
539
+ expect(textDeltas[0].text).toContain('Recording file is unavailable or expired');
540
+
541
+ const completes = sent.filter((m) => m.type === 'message_complete');
542
+ expect(completes.length).toBeGreaterThanOrEqual(1);
543
+ expect(completes[0].sessionId).toBe(conversationId);
544
+ });
545
+
546
+ test('failed finalization — failure status sent and no success message', async () => {
547
+ const { ctx, sent, fakeSocket } = createCtx();
548
+ const conversationId = 'conv-status-fail-final';
549
+
550
+ ctx.socketToSession.set(fakeSocket, conversationId);
551
+
552
+ const recordingId = handleRecordingStart(conversationId, undefined, fakeSocket, ctx);
553
+ expect(recordingId).not.toBeNull();
554
+ sent.length = 0;
555
+
556
+ // Client reports failure (writer finalization error)
557
+ const statusMsg: RecordingStatus = {
558
+ type: 'recording_status',
559
+ sessionId: recordingId!,
560
+ status: 'failed',
561
+ error: 'Video writer finished with non-completed status 3',
562
+ };
563
+
564
+ await recordingHandlers.recording_status(statusMsg, fakeSocket, ctx);
565
+
566
+ // No attachment should have been created
567
+ expect(mockAttachments.length).toBe(0);
568
+
569
+ // Should send failure message, not success
570
+ const textDeltas = sent.filter((m) => m.type === 'assistant_text_delta');
571
+ expect(textDeltas.length).toBeGreaterThanOrEqual(1);
572
+ expect(textDeltas[0].text).toContain('Recording failed');
573
+
574
+ // Should NOT contain the success message
575
+ const hasSuccessMessage = textDeltas.some(
576
+ (m) => typeof m.text === 'string' && m.text.includes('recording complete')
577
+ );
578
+ expect(hasSuccessMessage).toBe(false);
579
+ });
580
+
581
+ test('handles failed status and notifies client', async () => {
420
582
  const { ctx, sent, fakeSocket } = createCtx();
421
583
  const conversationId = 'conv-status-failed';
422
584
 
@@ -433,7 +595,7 @@ describe('recordingHandlers.recording_status', () => {
433
595
  error: 'Permission denied',
434
596
  };
435
597
 
436
- recordingHandlers.recording_status(statusMsg, fakeSocket, ctx);
598
+ await recordingHandlers.recording_status(statusMsg, fakeSocket, ctx);
437
599
 
438
600
  // Should send error notification
439
601
  const textDeltas = sent.filter((m) => m.type === 'assistant_text_delta');
@@ -445,7 +607,7 @@ describe('recordingHandlers.recording_status', () => {
445
607
  expect(completes.length).toBeGreaterThanOrEqual(1);
446
608
  });
447
609
 
448
- test('handles failed status with no error message', () => {
610
+ test('handles failed status with no error message', async () => {
449
611
  const { ctx, sent, fakeSocket } = createCtx();
450
612
  const conversationId = 'conv-status-failed-no-err';
451
613
 
@@ -461,14 +623,14 @@ describe('recordingHandlers.recording_status', () => {
461
623
  status: 'failed',
462
624
  };
463
625
 
464
- recordingHandlers.recording_status(statusMsg, fakeSocket, ctx);
626
+ await recordingHandlers.recording_status(statusMsg, fakeSocket, ctx);
465
627
 
466
628
  const textDeltas = sent.filter((m) => m.type === 'assistant_text_delta');
467
629
  expect(textDeltas.length).toBeGreaterThanOrEqual(1);
468
630
  expect(textDeltas[0].text).toContain('unknown error');
469
631
  });
470
632
 
471
- test('handles status with attachToConversationId fallback', () => {
633
+ test('handles status with attachToConversationId fallback', async () => {
472
634
  const { ctx, sent, fakeSocket } = createCtx();
473
635
  const conversationId = 'conv-fallback';
474
636
 
@@ -485,9 +647,7 @@ describe('recordingHandlers.recording_status', () => {
485
647
  };
486
648
 
487
649
  // Should not throw — uses attachToConversationId as fallback
488
- expect(() => {
489
- recordingHandlers.recording_status(statusMsg, fakeSocket, ctx);
490
- }).not.toThrow();
650
+ await recordingHandlers.recording_status(statusMsg, fakeSocket, ctx);
491
651
 
492
652
  const textDeltas = sent.filter((m) => m.type === 'assistant_text_delta');
493
653
  expect(textDeltas.length).toBeGreaterThanOrEqual(1);