@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.
- package/ARCHITECTURE.md +17 -3
- package/Dockerfile +1 -1
- package/README.md +2 -0
- package/docs/architecture/scheduling.md +81 -0
- package/package.json +1 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +22 -0
- package/src/__tests__/channel-policy.test.ts +19 -0
- package/src/__tests__/guardian-control-plane-policy.test.ts +582 -0
- package/src/__tests__/guardian-outbound-http.test.ts +8 -8
- package/src/__tests__/intent-routing.test.ts +22 -0
- package/src/__tests__/ipc-snapshot.test.ts +10 -0
- package/src/__tests__/notification-routing-intent.test.ts +185 -0
- package/src/__tests__/recording-handler.test.ts +191 -31
- package/src/__tests__/recording-intent-fallback.test.ts +180 -0
- package/src/__tests__/recording-intent-handler.test.ts +597 -74
- package/src/__tests__/recording-intent.test.ts +738 -342
- package/src/__tests__/recording-state-machine.test.ts +1109 -0
- package/src/__tests__/reminder-store.test.ts +20 -18
- package/src/__tests__/reminder.test.ts +2 -1
- package/src/channels/config.ts +1 -1
- package/src/config/bundled-skills/phone-calls/SKILL.md +1 -11
- package/src/config/bundled-skills/screen-recording/SKILL.md +91 -12
- package/src/config/system-prompt.ts +5 -0
- package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +1 -0
- package/src/daemon/handlers/config-channels.ts +6 -6
- package/src/daemon/handlers/index.ts +1 -1
- package/src/daemon/handlers/misc.ts +258 -102
- package/src/daemon/handlers/recording.ts +417 -5
- package/src/daemon/handlers/sessions.ts +142 -68
- package/src/daemon/ipc-contract/computer-use.ts +23 -3
- package/src/daemon/ipc-contract/messages.ts +3 -1
- package/src/daemon/ipc-contract/shared.ts +6 -0
- package/src/daemon/ipc-contract-inventory.json +2 -0
- package/src/daemon/lifecycle.ts +2 -0
- package/src/daemon/recording-executor.ts +180 -0
- package/src/daemon/recording-intent-fallback.ts +132 -0
- package/src/daemon/recording-intent.ts +306 -15
- package/src/daemon/session-tool-setup.ts +4 -0
- package/src/memory/conversation-attention-store.ts +5 -5
- package/src/notifications/README.md +69 -1
- package/src/notifications/adapters/sms.ts +80 -0
- package/src/notifications/broadcaster.ts +1 -0
- package/src/notifications/copy-composer.ts +3 -3
- package/src/notifications/decision-engine.ts +70 -1
- package/src/notifications/decisions-store.ts +24 -0
- package/src/notifications/destination-resolver.ts +2 -1
- package/src/notifications/emit-signal.ts +35 -3
- package/src/notifications/signal.ts +6 -0
- package/src/notifications/types.ts +3 -0
- package/src/runtime/guardian-outbound-actions.ts +9 -9
- package/src/runtime/http-server.ts +7 -7
- package/src/runtime/routes/conversation-attention-routes.ts +3 -3
- package/src/runtime/routes/integration-routes.ts +5 -5
- package/src/schedule/scheduler.ts +15 -3
- package/src/tools/executor.ts +29 -0
- package/src/tools/guardian-control-plane-policy.ts +141 -0
- 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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
|
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('
|
|
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
|
|
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
|
-
|
|
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);
|