@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,1109 @@
1
+ import * as net from 'node:net';
2
+
3
+ import { beforeEach, describe, expect, mock,test } from 'bun:test';
4
+
5
+ // ─── Mocks (must be before any imports that depend on them) ─────────────────
6
+
7
+ const noop = () => {};
8
+ const noopLogger = {
9
+ info: noop, warn: noop, error: noop, debug: noop, trace: noop, fatal: noop,
10
+ child: () => noopLogger,
11
+ };
12
+
13
+ mock.module('../util/logger.js', () => ({
14
+ getLogger: () => noopLogger,
15
+ }));
16
+
17
+ mock.module('../config/loader.js', () => ({
18
+ getConfig: () => ({
19
+ daemon: { standaloneRecording: true },
20
+ provider: 'mock-provider',
21
+ permissions: { mode: 'legacy' },
22
+ apiKeys: {},
23
+ sandbox: { enabled: false },
24
+ timeouts: { toolExecutionTimeoutSec: 30, permissionTimeoutSec: 5 },
25
+ skills: { load: { extraDirs: [] } },
26
+ secretDetection: { enabled: false, allowOneTimeSend: false },
27
+ contextWindow: {
28
+ enabled: true,
29
+ maxInputTokens: 180000,
30
+ targetInputTokens: 110000,
31
+ compactThreshold: 0.8,
32
+ preserveRecentUserTurns: 8,
33
+ summaryMaxTokens: 1200,
34
+ chunkTokens: 12000,
35
+ },
36
+ }),
37
+ invalidateConfigCache: noop,
38
+ loadConfig: noop,
39
+ saveConfig: noop,
40
+ loadRawConfig: () => ({}),
41
+ saveRawConfig: noop,
42
+ getNestedValue: () => undefined,
43
+ setNestedValue: noop,
44
+ }));
45
+
46
+ // Conversation store mock
47
+ const mockMessages: Array<{ id: string; role: string; content: string }> = [];
48
+ let mockMessageIdCounter = 0;
49
+
50
+ mock.module('../memory/conversation-store.js', () => ({
51
+ getMessages: () => mockMessages,
52
+ addMessage: (_convId: string, role: string, content: string) => {
53
+ const msg = { id: `msg-${++mockMessageIdCounter}`, role, content };
54
+ mockMessages.push(msg);
55
+ return msg;
56
+ },
57
+ createConversation: () => ({ id: 'conv-mock' }),
58
+ getConversation: () => ({ id: 'conv-mock' }),
59
+ }));
60
+
61
+ // Attachments store mock
62
+ mock.module('../memory/attachments-store.js', () => ({
63
+ uploadFileBackedAttachment: () => ({ id: 'att-mock', originalFilename: 'test.mov', mimeType: 'video/quicktime', sizeBytes: 1024 }),
64
+ linkAttachmentToMessage: noop,
65
+ setAttachmentThumbnail: noop,
66
+ }));
67
+
68
+ // Mock node:fs
69
+ mock.module('node:fs', async () => {
70
+ const realFs = await import('fs');
71
+ return {
72
+ ...realFs,
73
+ existsSync: (p: string) => {
74
+ if (p.includes('recording') || p.includes('/tmp/')) return true;
75
+ return realFs.existsSync(p);
76
+ },
77
+ statSync: (p: string, opts?: any) => {
78
+ if (p.includes('recording') || p.includes('/tmp/')) return { size: 1024 };
79
+ return realFs.statSync(p, opts);
80
+ },
81
+ };
82
+ });
83
+
84
+ // Mock video thumbnail
85
+ mock.module('../daemon/video-thumbnail.js', () => ({
86
+ generateVideoThumbnailFromPath: async () => null,
87
+ }));
88
+
89
+ // ─── Imports (after mocks) ──────────────────────────────────────────────────
90
+
91
+ import {
92
+ __resetRecordingState,
93
+ getActiveRestartToken,
94
+ handleRecordingPause,
95
+ handleRecordingRestart,
96
+ handleRecordingResume,
97
+ handleRecordingStart,
98
+ handleRecordingStop,
99
+ isRecordingIdle,
100
+ recordingHandlers,
101
+ } from '../daemon/handlers/recording.js';
102
+ import type { HandlerContext } from '../daemon/handlers/shared.js';
103
+ import type { RecordingStatus } from '../daemon/ipc-contract/computer-use.js';
104
+ import { executeRecordingIntent } from '../daemon/recording-executor.js';
105
+ import { DebouncerMap } from '../util/debounce.js';
106
+
107
+ // ─── Test helpers ───────────────────────────────────────────────────────────
108
+
109
+ function createCtx(): { ctx: HandlerContext; sent: Array<{ type: string; [k: string]: unknown }>; fakeSocket: net.Socket } {
110
+ const sent: Array<{ type: string; [k: string]: unknown }> = [];
111
+ const fakeSocket = {} as net.Socket;
112
+ const socketToSession = new Map<net.Socket, string>();
113
+
114
+ const ctx: HandlerContext = {
115
+ sessions: new Map(),
116
+ socketToSession,
117
+ cuSessions: new Map(),
118
+ socketToCuSession: new Map(),
119
+ cuObservationParseSequence: new Map(),
120
+ socketSandboxOverride: new Map(),
121
+ sharedRequestTimestamps: [],
122
+ debounceTimers: new DebouncerMap({ defaultDelayMs: 200 }),
123
+ suppressConfigReload: false,
124
+ setSuppressConfigReload: noop,
125
+ updateConfigFingerprint: noop,
126
+ send: (_socket, msg) => { sent.push(msg as { type: string; [k: string]: unknown }); },
127
+ broadcast: noop,
128
+ clearAllSessions: () => 0,
129
+ getOrCreateSession: () => { throw new Error('not implemented'); },
130
+ touchSession: noop,
131
+ };
132
+
133
+ return { ctx, sent, fakeSocket };
134
+ }
135
+
136
+ // ─── Restart state machine tests ────────────────────────────────────────────
137
+
138
+ describe('handleRecordingRestart', () => {
139
+ beforeEach(() => {
140
+ __resetRecordingState();
141
+ mockMessages.length = 0;
142
+ mockMessageIdCounter = 0;
143
+ });
144
+
145
+ test('sends recording_stop and defers start until stop-ack', () => {
146
+ const { ctx, sent, fakeSocket } = createCtx();
147
+ const conversationId = 'conv-restart-1';
148
+ ctx.socketToSession.set(fakeSocket, conversationId);
149
+
150
+ // Start a recording first
151
+ const originalId = handleRecordingStart(conversationId, undefined, fakeSocket, ctx);
152
+ expect(originalId).not.toBeNull();
153
+ sent.length = 0;
154
+
155
+ const result = handleRecordingRestart(conversationId, fakeSocket, ctx);
156
+
157
+ expect(result.initiated).toBe(true);
158
+ expect(result.operationToken).toBeTruthy();
159
+ expect(result.responseText).toBe('Restarting screen recording.');
160
+
161
+ // Should have sent only recording_stop (start is deferred)
162
+ const stopMsgs = sent.filter((m) => m.type === 'recording_stop');
163
+ const startMsgs = sent.filter((m) => m.type === 'recording_start');
164
+ expect(stopMsgs).toHaveLength(1);
165
+ expect(startMsgs).toHaveLength(0);
166
+
167
+ // Simulate the client acknowledging the stop
168
+ const stoppedStatus: RecordingStatus = {
169
+ type: 'recording_status',
170
+ sessionId: originalId!,
171
+ status: 'stopped',
172
+ attachToConversationId: conversationId,
173
+ };
174
+ recordingHandlers.recording_status(stoppedStatus, fakeSocket, ctx);
175
+
176
+ // NOW the deferred recording_start should have been sent
177
+ const startMsgsAfterAck = sent.filter((m) => m.type === 'recording_start');
178
+ expect(startMsgsAfterAck).toHaveLength(1);
179
+ expect(startMsgsAfterAck[0].operationToken).toBe(result.operationToken);
180
+ });
181
+
182
+ test('returns "no active recording" with reason when nothing is recording', () => {
183
+ const { ctx, fakeSocket } = createCtx();
184
+
185
+ const result = handleRecordingRestart('conv-no-rec', fakeSocket, ctx);
186
+
187
+ expect(result.initiated).toBe(false);
188
+ expect(result.reason).toBe('no_active_recording');
189
+ expect(result.responseText).toBe('No active recording to restart.');
190
+ });
191
+
192
+ test('generates unique operation token for each restart', () => {
193
+ const { ctx, sent, fakeSocket } = createCtx();
194
+ const conversationId = 'conv-restart-unique';
195
+ ctx.socketToSession.set(fakeSocket, conversationId);
196
+
197
+ // First restart cycle
198
+ const originalId = handleRecordingStart(conversationId, undefined, fakeSocket, ctx);
199
+ const result1 = handleRecordingRestart(conversationId, fakeSocket, ctx);
200
+
201
+ // Simulate the stop-ack to trigger the deferred start
202
+ const stoppedStatus1: RecordingStatus = {
203
+ type: 'recording_status',
204
+ sessionId: originalId!,
205
+ status: 'stopped',
206
+ attachToConversationId: conversationId,
207
+ };
208
+ recordingHandlers.recording_status(stoppedStatus1, fakeSocket, ctx);
209
+
210
+ // Simulate the first restart completing (started status)
211
+ const startMsg1 = sent.filter((m) => m.type === 'recording_start').pop();
212
+ const status1: RecordingStatus = {
213
+ type: 'recording_status',
214
+ sessionId: startMsg1!.recordingId as string,
215
+ status: 'started',
216
+ operationToken: result1.operationToken,
217
+ };
218
+ recordingHandlers.recording_status(status1, fakeSocket, ctx);
219
+
220
+ // Second restart cycle
221
+ sent.length = 0;
222
+ const result2 = handleRecordingRestart(conversationId, fakeSocket, ctx);
223
+
224
+ expect(result1.operationToken).not.toBe(result2.operationToken);
225
+ });
226
+ });
227
+
228
+ // ─── Restart cancel tests ───────────────────────────────────────────────────
229
+
230
+ describe('restart_cancelled status', () => {
231
+ beforeEach(() => {
232
+ __resetRecordingState();
233
+ mockMessages.length = 0;
234
+ mockMessageIdCounter = 0;
235
+ });
236
+
237
+ test('emits restart_cancelled response, never "new recording started"', () => {
238
+ const { ctx, sent, fakeSocket } = createCtx();
239
+ const conversationId = 'conv-cancel-1';
240
+ ctx.socketToSession.set(fakeSocket, conversationId);
241
+
242
+ // Start -> restart
243
+ const originalId = handleRecordingStart(conversationId, undefined, fakeSocket, ctx);
244
+ const restartResult = handleRecordingRestart(conversationId, fakeSocket, ctx);
245
+ expect(restartResult.initiated).toBe(true);
246
+
247
+ // Simulate the stop-ack to trigger the deferred start
248
+ const stoppedStatus: RecordingStatus = {
249
+ type: 'recording_status',
250
+ sessionId: originalId!,
251
+ status: 'stopped',
252
+ attachToConversationId: conversationId,
253
+ };
254
+ recordingHandlers.recording_status(stoppedStatus, fakeSocket, ctx);
255
+
256
+ // Get the new recording ID from the deferred recording_start message
257
+ const startMsg = sent.filter((m) => m.type === 'recording_start').pop();
258
+ sent.length = 0;
259
+
260
+ // Client sends restart_cancelled (picker was closed) with the correct operation token
261
+ const cancelStatus: RecordingStatus = {
262
+ type: 'recording_status',
263
+ sessionId: startMsg!.recordingId as string,
264
+ status: 'restart_cancelled',
265
+ attachToConversationId: conversationId,
266
+ operationToken: restartResult.operationToken,
267
+ };
268
+ recordingHandlers.recording_status(cancelStatus, fakeSocket, ctx);
269
+
270
+ // Should have emitted the cancellation message
271
+ const textDeltas = sent.filter((m) => m.type === 'assistant_text_delta');
272
+ expect(textDeltas).toHaveLength(1);
273
+ expect(textDeltas[0].text).toBe('Recording restart cancelled.');
274
+
275
+ // Should NOT have "new recording started" anywhere
276
+ const startedMsgs = sent.filter(
277
+ (m) => m.type === 'assistant_text_delta' && typeof m.text === 'string' &&
278
+ m.text.includes('new recording started'),
279
+ );
280
+ expect(startedMsgs).toHaveLength(0);
281
+
282
+ // Recording should be truly idle after cancel
283
+ expect(isRecordingIdle()).toBe(true);
284
+ });
285
+
286
+ test('cleans up restart state on cancel', () => {
287
+ const { ctx, sent, fakeSocket } = createCtx();
288
+ const conversationId = 'conv-cancel-cleanup';
289
+ ctx.socketToSession.set(fakeSocket, conversationId);
290
+
291
+ const originalId = handleRecordingStart(conversationId, undefined, fakeSocket, ctx);
292
+ const restartResult = handleRecordingRestart(conversationId, fakeSocket, ctx);
293
+
294
+ // Before stop-ack: not idle (mid-restart)
295
+ expect(isRecordingIdle()).toBe(false);
296
+
297
+ // Simulate the stop-ack to trigger the deferred start
298
+ const stoppedStatus: RecordingStatus = {
299
+ type: 'recording_status',
300
+ sessionId: originalId!,
301
+ status: 'stopped',
302
+ attachToConversationId: conversationId,
303
+ };
304
+ recordingHandlers.recording_status(stoppedStatus, fakeSocket, ctx);
305
+
306
+ // Still not idle — the new recording has started
307
+ expect(isRecordingIdle()).toBe(false);
308
+
309
+ const startMsg = sent.filter((m) => m.type === 'recording_start').pop();
310
+ const cancelStatus: RecordingStatus = {
311
+ type: 'recording_status',
312
+ sessionId: startMsg!.recordingId as string,
313
+ status: 'restart_cancelled',
314
+ attachToConversationId: conversationId,
315
+ operationToken: restartResult.operationToken,
316
+ };
317
+ recordingHandlers.recording_status(cancelStatus, fakeSocket, ctx);
318
+
319
+ // After cancel: truly idle
320
+ expect(isRecordingIdle()).toBe(true);
321
+ expect(getActiveRestartToken()).toBeNull();
322
+ });
323
+ });
324
+
325
+ // ─── Stale completion guard tests ───────────────────────────────────────────
326
+
327
+ describe('stale completion guard (operation token)', () => {
328
+ beforeEach(() => {
329
+ __resetRecordingState();
330
+ mockMessages.length = 0;
331
+ mockMessageIdCounter = 0;
332
+ });
333
+
334
+ test('rejects recording_status with stale operation token', () => {
335
+ const { ctx, sent, fakeSocket } = createCtx();
336
+ const conversationId = 'conv-stale-1';
337
+ ctx.socketToSession.set(fakeSocket, conversationId);
338
+
339
+ // Start recording -> restart (creates operation token)
340
+ const originalId = handleRecordingStart(conversationId, undefined, fakeSocket, ctx);
341
+ const restartResult = handleRecordingRestart(conversationId, fakeSocket, ctx);
342
+ expect(restartResult.initiated).toBe(true);
343
+
344
+ // Simulate the stop-ack to trigger the deferred start
345
+ const stoppedStatus: RecordingStatus = {
346
+ type: 'recording_status',
347
+ sessionId: originalId!,
348
+ status: 'stopped',
349
+ attachToConversationId: conversationId,
350
+ };
351
+ recordingHandlers.recording_status(stoppedStatus, fakeSocket, ctx);
352
+
353
+ const startMsg = sent.filter((m) => m.type === 'recording_start').pop();
354
+ sent.length = 0;
355
+
356
+ // Simulate a stale "started" status from a PREVIOUS restart cycle
357
+ const staleStatus: RecordingStatus = {
358
+ type: 'recording_status',
359
+ sessionId: startMsg!.recordingId as string,
360
+ status: 'started',
361
+ operationToken: 'old-stale-token-from-previous-cycle',
362
+ };
363
+ recordingHandlers.recording_status(staleStatus, fakeSocket, ctx);
364
+
365
+ // Should have been rejected — no "started" confirmation messages
366
+ const textDeltas = sent.filter((m) => m.type === 'assistant_text_delta');
367
+ expect(textDeltas).toHaveLength(0);
368
+
369
+ // Active restart token should still be set (not cleared by stale completion)
370
+ expect(getActiveRestartToken()).toBe(restartResult.operationToken!);
371
+ });
372
+
373
+ test('accepts recording_status with matching operation token', () => {
374
+ const { ctx, sent, fakeSocket } = createCtx();
375
+ const conversationId = 'conv-matching-1';
376
+ ctx.socketToSession.set(fakeSocket, conversationId);
377
+
378
+ const originalId = handleRecordingStart(conversationId, undefined, fakeSocket, ctx);
379
+ const restartResult = handleRecordingRestart(conversationId, fakeSocket, ctx);
380
+
381
+ // Simulate the stop-ack to trigger the deferred start
382
+ const stoppedStatus: RecordingStatus = {
383
+ type: 'recording_status',
384
+ sessionId: originalId!,
385
+ status: 'stopped',
386
+ attachToConversationId: conversationId,
387
+ };
388
+ recordingHandlers.recording_status(stoppedStatus, fakeSocket, ctx);
389
+
390
+ const startMsg = sent.filter((m) => m.type === 'recording_start').pop();
391
+
392
+ // Send status with the CORRECT token
393
+ const validStatus: RecordingStatus = {
394
+ type: 'recording_status',
395
+ sessionId: startMsg!.recordingId as string,
396
+ status: 'started',
397
+ operationToken: restartResult.operationToken,
398
+ };
399
+ recordingHandlers.recording_status(validStatus, fakeSocket, ctx);
400
+
401
+ // Should have been accepted — restart token cleared
402
+ expect(getActiveRestartToken()).toBeNull();
403
+ });
404
+
405
+ test('allows tokenless recording_status during active restart (old recording ack)', () => {
406
+ const { ctx, sent, fakeSocket } = createCtx();
407
+ const conversationId = 'conv-tokenless-1';
408
+ ctx.socketToSession.set(fakeSocket, conversationId);
409
+
410
+ // Start recording -> restart (creates operation token)
411
+ handleRecordingStart(conversationId, undefined, fakeSocket, ctx);
412
+ const restartResult = handleRecordingRestart(conversationId, fakeSocket, ctx);
413
+ expect(restartResult.initiated).toBe(true);
414
+
415
+ const startMsgs = sent.filter((m) => m.type === 'recording_start');
416
+ const oldStartMsg = startMsgs[0]; // first recording_start = original/old recording
417
+ sent.length = 0;
418
+
419
+ // Simulate a tokenless "stopped" status arriving during the restart.
420
+ // This represents the OLD recording's stopped ack — it was started before
421
+ // the restart was initiated, so it has no operationToken. This MUST be
422
+ // allowed through for the deferred restart pattern to work.
423
+ const tokenlessStatus: RecordingStatus = {
424
+ type: 'recording_status',
425
+ sessionId: oldStartMsg!.recordingId as string,
426
+ status: 'stopped',
427
+ attachToConversationId: conversationId,
428
+ // No operationToken — from old recording, should be allowed
429
+ };
430
+ recordingHandlers.recording_status(tokenlessStatus, fakeSocket, ctx);
431
+
432
+ // Should have triggered the deferred restart start (not emitted text deltas)
433
+ const newStartMsgs = sent.filter((m) => m.type === 'recording_start');
434
+ expect(newStartMsgs).toHaveLength(1);
435
+
436
+ // No text deltas — the stopped handler short-circuited to deferred restart
437
+ const textDeltas = sent.filter((m) => m.type === 'assistant_text_delta');
438
+ expect(textDeltas).toHaveLength(0);
439
+ });
440
+
441
+ test('no ghost state after restart stop/start handoff', () => {
442
+ const { ctx, sent, fakeSocket } = createCtx();
443
+ const conversationId = 'conv-ghost-1';
444
+ ctx.socketToSession.set(fakeSocket, conversationId);
445
+
446
+ const originalId = handleRecordingStart(conversationId, undefined, fakeSocket, ctx);
447
+
448
+ // Restart sends stop and defers start until stop-ack
449
+ const restartResult = handleRecordingRestart(conversationId, fakeSocket, ctx);
450
+ expect(restartResult.initiated).toBe(true);
451
+
452
+ // Simulate the stop-ack to trigger the deferred start
453
+ const stoppedStatus: RecordingStatus = {
454
+ type: 'recording_status',
455
+ sessionId: originalId!,
456
+ status: 'stopped',
457
+ attachToConversationId: conversationId,
458
+ };
459
+ recordingHandlers.recording_status(stoppedStatus, fakeSocket, ctx);
460
+
461
+ // The new recording should be active (not the old one)
462
+ const startMsgs = sent.filter((m) => m.type === 'recording_start');
463
+ expect(startMsgs.length).toBeGreaterThanOrEqual(2); // original + deferred restart
464
+
465
+ // The last recording_start should have the operation token
466
+ const lastStart = startMsgs[startMsgs.length - 1];
467
+ expect(lastStart.operationToken).toBe(restartResult.operationToken);
468
+ });
469
+ });
470
+
471
+ // ─── Pause/resume state transition tests ────────────────────────────────────
472
+
473
+ describe('handleRecordingPause', () => {
474
+ beforeEach(() => {
475
+ __resetRecordingState();
476
+ });
477
+
478
+ test('sends recording_pause for active recording', () => {
479
+ const { ctx, sent, fakeSocket } = createCtx();
480
+ const conversationId = 'conv-pause-1';
481
+ ctx.socketToSession.set(fakeSocket, conversationId);
482
+
483
+ const recordingId = handleRecordingStart(conversationId, undefined, fakeSocket, ctx);
484
+ expect(recordingId).not.toBeNull();
485
+ sent.length = 0;
486
+
487
+ const result = handleRecordingPause(conversationId, ctx);
488
+
489
+ expect(result).toBe(recordingId!);
490
+ expect(sent).toHaveLength(1);
491
+ expect(sent[0].type).toBe('recording_pause');
492
+ expect(sent[0].recordingId).toBe(recordingId);
493
+ });
494
+
495
+ test('returns undefined when no active recording', () => {
496
+ const { ctx } = createCtx();
497
+
498
+ const result = handleRecordingPause('conv-no-rec', ctx);
499
+ expect(result).toBeUndefined();
500
+ });
501
+
502
+ test('resolves to globally active recording from different conversation', () => {
503
+ const { ctx, sent, fakeSocket } = createCtx();
504
+ const convA = 'conv-owner-pause';
505
+ ctx.socketToSession.set(fakeSocket, convA);
506
+
507
+ const recordingId = handleRecordingStart(convA, undefined, fakeSocket, ctx);
508
+ sent.length = 0;
509
+
510
+ const result = handleRecordingPause('conv-other-pause', ctx);
511
+ expect(result).toBe(recordingId!);
512
+ });
513
+ });
514
+
515
+ describe('handleRecordingResume', () => {
516
+ beforeEach(() => {
517
+ __resetRecordingState();
518
+ });
519
+
520
+ test('sends recording_resume for active recording', () => {
521
+ const { ctx, sent, fakeSocket } = createCtx();
522
+ const conversationId = 'conv-resume-1';
523
+ ctx.socketToSession.set(fakeSocket, conversationId);
524
+
525
+ const recordingId = handleRecordingStart(conversationId, undefined, fakeSocket, ctx);
526
+ expect(recordingId).not.toBeNull();
527
+ sent.length = 0;
528
+
529
+ const result = handleRecordingResume(conversationId, ctx);
530
+
531
+ expect(result).toBe(recordingId!);
532
+ expect(sent).toHaveLength(1);
533
+ expect(sent[0].type).toBe('recording_resume');
534
+ expect(sent[0].recordingId).toBe(recordingId);
535
+ });
536
+
537
+ test('returns undefined when no active recording', () => {
538
+ const { ctx } = createCtx();
539
+
540
+ const result = handleRecordingResume('conv-no-rec', ctx);
541
+ expect(result).toBeUndefined();
542
+ });
543
+ });
544
+
545
+ // ─── isRecordingIdle tests ──────────────────────────────────────────────────
546
+
547
+ describe('isRecordingIdle', () => {
548
+ beforeEach(() => {
549
+ __resetRecordingState();
550
+ });
551
+
552
+ test('returns true when no recording and no pending restart', () => {
553
+ expect(isRecordingIdle()).toBe(true);
554
+ });
555
+
556
+ test('returns false when recording is active', () => {
557
+ const { ctx, fakeSocket } = createCtx();
558
+ handleRecordingStart('conv-idle-1', undefined, fakeSocket, ctx);
559
+ expect(isRecordingIdle()).toBe(false);
560
+ });
561
+
562
+ test('returns false when mid-restart (between stop-ack and start confirmation)', () => {
563
+ const { ctx, fakeSocket } = createCtx();
564
+ const conversationId = 'conv-idle-restart';
565
+ ctx.socketToSession.set(fakeSocket, conversationId);
566
+
567
+ handleRecordingStart(conversationId, undefined, fakeSocket, ctx);
568
+ handleRecordingRestart(conversationId, fakeSocket, ctx);
569
+
570
+ // Mid-restart: the old recording maps are still present AND there's a
571
+ // pending restart, so the system is not idle
572
+ expect(isRecordingIdle()).toBe(false);
573
+ });
574
+
575
+ test('returns true after restart completes', () => {
576
+ const { ctx, sent, fakeSocket } = createCtx();
577
+ const conversationId = 'conv-idle-complete';
578
+ ctx.socketToSession.set(fakeSocket, conversationId);
579
+
580
+ const originalId = handleRecordingStart(conversationId, undefined, fakeSocket, ctx);
581
+ const restartResult = handleRecordingRestart(conversationId, fakeSocket, ctx);
582
+
583
+ // Simulate the stop-ack to trigger the deferred start
584
+ const stoppedStatus: RecordingStatus = {
585
+ type: 'recording_status',
586
+ sessionId: originalId!,
587
+ status: 'stopped',
588
+ attachToConversationId: conversationId,
589
+ };
590
+ recordingHandlers.recording_status(stoppedStatus, fakeSocket, ctx);
591
+
592
+ // Simulate the new recording starting
593
+ const startMsg = sent.filter((m) => m.type === 'recording_start').pop();
594
+ const startedStatus: RecordingStatus = {
595
+ type: 'recording_status',
596
+ sessionId: startMsg!.recordingId as string,
597
+ status: 'started',
598
+ operationToken: restartResult.operationToken,
599
+ };
600
+ recordingHandlers.recording_status(startedStatus, fakeSocket, ctx);
601
+
602
+ // Restart is complete, but recording is still active
603
+ expect(getActiveRestartToken()).toBeNull();
604
+ // Not idle because the new recording is still running
605
+ expect(isRecordingIdle()).toBe(false);
606
+ });
607
+ });
608
+
609
+ // ─── Recording executor integration tests ───────────────────────────────────
610
+
611
+ describe('executeRecordingIntent — restart/pause/resume', () => {
612
+ beforeEach(() => {
613
+ __resetRecordingState();
614
+ });
615
+
616
+ test('restart_only executes actual restart (deferred start)', () => {
617
+ const { ctx, sent, fakeSocket } = createCtx();
618
+ const conversationId = 'conv-exec-restart';
619
+ ctx.socketToSession.set(fakeSocket, conversationId);
620
+
621
+ // Start a recording first
622
+ const originalId = handleRecordingStart(conversationId, undefined, fakeSocket, ctx);
623
+ sent.length = 0;
624
+
625
+ const result = executeRecordingIntent(
626
+ { kind: 'restart_only' },
627
+ { conversationId, socket: fakeSocket, ctx },
628
+ );
629
+
630
+ expect(result.handled).toBe(true);
631
+ expect(result.responseText).toBe('Restarting screen recording.');
632
+
633
+ // Should have sent only stop (start is deferred until stop-ack)
634
+ const stopMsgs = sent.filter((m) => m.type === 'recording_stop');
635
+ const startMsgs = sent.filter((m) => m.type === 'recording_start');
636
+ expect(stopMsgs).toHaveLength(1);
637
+ expect(startMsgs).toHaveLength(0);
638
+
639
+ // Simulate the stop-ack to trigger the deferred start
640
+ const stoppedStatus: RecordingStatus = {
641
+ type: 'recording_status',
642
+ sessionId: originalId!,
643
+ status: 'stopped',
644
+ attachToConversationId: conversationId,
645
+ };
646
+ recordingHandlers.recording_status(stoppedStatus, fakeSocket, ctx);
647
+
648
+ // NOW the deferred start should have been sent
649
+ const startMsgsAfterAck = sent.filter((m) => m.type === 'recording_start');
650
+ expect(startMsgsAfterAck).toHaveLength(1);
651
+ });
652
+
653
+ test('restart_only returns "no active recording" when idle', () => {
654
+ const { ctx, fakeSocket } = createCtx();
655
+
656
+ const result = executeRecordingIntent(
657
+ { kind: 'restart_only' },
658
+ { conversationId: 'conv-no-rec', socket: fakeSocket, ctx },
659
+ );
660
+
661
+ expect(result.handled).toBe(true);
662
+ expect(result.responseText).toBe('No active recording to restart.');
663
+ });
664
+
665
+ test('restart_with_remainder returns deferred restart', () => {
666
+ const { ctx, fakeSocket } = createCtx();
667
+
668
+ const result = executeRecordingIntent(
669
+ { kind: 'restart_with_remainder', remainder: 'do something else' },
670
+ { conversationId: 'conv-rem', socket: fakeSocket, ctx },
671
+ );
672
+
673
+ expect(result.handled).toBe(false);
674
+ expect(result.pendingRestart).toBe(true);
675
+ expect(result.remainderText).toBe('do something else');
676
+ });
677
+
678
+ test('pause_only executes actual pause', () => {
679
+ const { ctx, sent, fakeSocket } = createCtx();
680
+ const conversationId = 'conv-exec-pause';
681
+ ctx.socketToSession.set(fakeSocket, conversationId);
682
+
683
+ handleRecordingStart(conversationId, undefined, fakeSocket, ctx);
684
+ sent.length = 0;
685
+
686
+ const result = executeRecordingIntent(
687
+ { kind: 'pause_only' },
688
+ { conversationId, socket: fakeSocket, ctx },
689
+ );
690
+
691
+ expect(result.handled).toBe(true);
692
+ expect(result.responseText).toBe('Pausing the recording.');
693
+
694
+ const pauseMsgs = sent.filter((m) => m.type === 'recording_pause');
695
+ expect(pauseMsgs).toHaveLength(1);
696
+ });
697
+
698
+ test('pause_only returns "no active recording" when idle', () => {
699
+ const { ctx, fakeSocket } = createCtx();
700
+
701
+ const result = executeRecordingIntent(
702
+ { kind: 'pause_only' },
703
+ { conversationId: 'conv-no-rec', socket: fakeSocket, ctx },
704
+ );
705
+
706
+ expect(result.handled).toBe(true);
707
+ expect(result.responseText).toBe('No active recording to pause.');
708
+ });
709
+
710
+ test('resume_only executes actual resume', () => {
711
+ const { ctx, sent, fakeSocket } = createCtx();
712
+ const conversationId = 'conv-exec-resume';
713
+ ctx.socketToSession.set(fakeSocket, conversationId);
714
+
715
+ handleRecordingStart(conversationId, undefined, fakeSocket, ctx);
716
+ sent.length = 0;
717
+
718
+ const result = executeRecordingIntent(
719
+ { kind: 'resume_only' },
720
+ { conversationId, socket: fakeSocket, ctx },
721
+ );
722
+
723
+ expect(result.handled).toBe(true);
724
+ expect(result.responseText).toBe('Resuming the recording.');
725
+
726
+ const resumeMsgs = sent.filter((m) => m.type === 'recording_resume');
727
+ expect(resumeMsgs).toHaveLength(1);
728
+ });
729
+
730
+ test('resume_only returns "no active recording" when idle', () => {
731
+ const { ctx, fakeSocket } = createCtx();
732
+
733
+ const result = executeRecordingIntent(
734
+ { kind: 'resume_only' },
735
+ { conversationId: 'conv-no-rec', socket: fakeSocket, ctx },
736
+ );
737
+
738
+ expect(result.handled).toBe(true);
739
+ expect(result.responseText).toBe('No active recording to resume.');
740
+ });
741
+ });
742
+
743
+ // ─── Recording status paused/resumed acknowledgement tests ──────────────────
744
+
745
+ describe('recording_status paused/resumed', () => {
746
+ beforeEach(() => {
747
+ __resetRecordingState();
748
+ });
749
+
750
+ test('handles paused status without error', () => {
751
+ const { ctx, fakeSocket } = createCtx();
752
+ const conversationId = 'conv-status-paused';
753
+
754
+ const recordingId = handleRecordingStart(conversationId, undefined, fakeSocket, ctx);
755
+ expect(recordingId).not.toBeNull();
756
+
757
+ const statusMsg: RecordingStatus = {
758
+ type: 'recording_status',
759
+ sessionId: recordingId!,
760
+ status: 'paused',
761
+ };
762
+
763
+ expect(() => {
764
+ recordingHandlers.recording_status(statusMsg, fakeSocket, ctx);
765
+ }).not.toThrow();
766
+ });
767
+
768
+ test('handles resumed status without error', () => {
769
+ const { ctx, fakeSocket } = createCtx();
770
+ const conversationId = 'conv-status-resumed';
771
+
772
+ const recordingId = handleRecordingStart(conversationId, undefined, fakeSocket, ctx);
773
+ expect(recordingId).not.toBeNull();
774
+
775
+ const statusMsg: RecordingStatus = {
776
+ type: 'recording_status',
777
+ sessionId: recordingId!,
778
+ status: 'resumed',
779
+ };
780
+
781
+ expect(() => {
782
+ recordingHandlers.recording_status(statusMsg, fakeSocket, ctx);
783
+ }).not.toThrow();
784
+ });
785
+ });
786
+
787
+ // ─── Failed during restart cleans up restart state ──────────────────────────
788
+
789
+ describe('failure during restart', () => {
790
+ beforeEach(() => {
791
+ __resetRecordingState();
792
+ mockMessages.length = 0;
793
+ mockMessageIdCounter = 0;
794
+ });
795
+
796
+ test('failed status during restart clears pending restart state (old recording fails)', () => {
797
+ const { ctx, sent, fakeSocket } = createCtx();
798
+ const conversationId = 'conv-fail-restart';
799
+ ctx.socketToSession.set(fakeSocket, conversationId);
800
+
801
+ const originalId = handleRecordingStart(conversationId, undefined, fakeSocket, ctx);
802
+ handleRecordingRestart(conversationId, fakeSocket, ctx);
803
+ sent.length = 0;
804
+
805
+ // Simulate the old recording failing to stop (before stop-ack)
806
+ const failedStatus: RecordingStatus = {
807
+ type: 'recording_status',
808
+ sessionId: originalId!,
809
+ status: 'failed',
810
+ error: 'Permission denied',
811
+ attachToConversationId: conversationId,
812
+ };
813
+ recordingHandlers.recording_status(failedStatus, fakeSocket, ctx);
814
+
815
+ // Restart state and deferred restart should be cleaned up
816
+ expect(getActiveRestartToken()).toBeNull();
817
+ expect(isRecordingIdle()).toBe(true);
818
+ });
819
+
820
+ test('failed status during restart clears state (new recording fails after deferred start)', () => {
821
+ const { ctx, sent, fakeSocket } = createCtx();
822
+ const conversationId = 'conv-fail-restart-new';
823
+ ctx.socketToSession.set(fakeSocket, conversationId);
824
+
825
+ const originalId = handleRecordingStart(conversationId, undefined, fakeSocket, ctx);
826
+ const restartResult = handleRecordingRestart(conversationId, fakeSocket, ctx);
827
+
828
+ // Simulate the stop-ack to trigger the deferred start
829
+ const stoppedStatus: RecordingStatus = {
830
+ type: 'recording_status',
831
+ sessionId: originalId!,
832
+ status: 'stopped',
833
+ attachToConversationId: conversationId,
834
+ };
835
+ recordingHandlers.recording_status(stoppedStatus, fakeSocket, ctx);
836
+
837
+ const startMsg = sent.filter((m) => m.type === 'recording_start').pop();
838
+ sent.length = 0;
839
+
840
+ // Simulate new recording failing (with the correct operation token)
841
+ const failedStatus: RecordingStatus = {
842
+ type: 'recording_status',
843
+ sessionId: startMsg!.recordingId as string,
844
+ status: 'failed',
845
+ error: 'Permission denied',
846
+ attachToConversationId: conversationId,
847
+ operationToken: restartResult.operationToken,
848
+ };
849
+ recordingHandlers.recording_status(failedStatus, fakeSocket, ctx);
850
+
851
+ // Restart state should be cleaned up
852
+ expect(getActiveRestartToken()).toBeNull();
853
+ expect(isRecordingIdle()).toBe(true);
854
+ });
855
+ });
856
+
857
+ // ─── start_and_stop_only from idle state ─────────────────────────────────────
858
+
859
+ describe('start_and_stop_only fallback to plain start when idle', () => {
860
+ beforeEach(() => {
861
+ __resetRecordingState();
862
+ });
863
+
864
+ test('falls back to handleRecordingStart when no active recording', () => {
865
+ const { ctx, sent, fakeSocket } = createCtx();
866
+ const conversationId = 'conv-stop-start-idle';
867
+ ctx.socketToSession.set(fakeSocket, conversationId);
868
+
869
+ // No recording is active — start_and_stop_only should fall back to a
870
+ // plain start rather than returning "No active recording to restart."
871
+ const result = executeRecordingIntent(
872
+ { kind: 'start_and_stop_only' },
873
+ { conversationId, socket: fakeSocket, ctx },
874
+ );
875
+
876
+ expect(result.handled).toBe(true);
877
+ expect(result.recordingStarted).toBe(true);
878
+ expect(result.responseText).toBe('Starting screen recording.');
879
+
880
+ // Should have sent only a recording_start (no stop since nothing was active)
881
+ const stopMsgs = sent.filter((m) => m.type === 'recording_stop');
882
+ const startMsgs = sent.filter((m) => m.type === 'recording_start');
883
+ expect(stopMsgs).toHaveLength(0);
884
+ expect(startMsgs).toHaveLength(1);
885
+ });
886
+
887
+ test('goes through restart when a recording is active (deferred start)', () => {
888
+ const { ctx, sent, fakeSocket } = createCtx();
889
+ const conversationId = 'conv-stop-start-active';
890
+ ctx.socketToSession.set(fakeSocket, conversationId);
891
+
892
+ // Start a recording first
893
+ const originalId = handleRecordingStart(conversationId, undefined, fakeSocket, ctx);
894
+ expect(originalId).not.toBeNull();
895
+ sent.length = 0;
896
+
897
+ // Now start_and_stop_only should go through handleRecordingRestart
898
+ const result = executeRecordingIntent(
899
+ { kind: 'start_and_stop_only' },
900
+ { conversationId, socket: fakeSocket, ctx },
901
+ );
902
+
903
+ expect(result.handled).toBe(true);
904
+ expect(result.recordingStarted).toBe(true);
905
+ expect(result.responseText).toBe('Stopping current recording and starting a new one.');
906
+
907
+ // Should have sent only stop (start is deferred until stop-ack)
908
+ const stopMsgs = sent.filter((m) => m.type === 'recording_stop');
909
+ const startMsgs = sent.filter((m) => m.type === 'recording_start');
910
+ expect(stopMsgs).toHaveLength(1);
911
+ expect(startMsgs).toHaveLength(0);
912
+
913
+ // Simulate the stop-ack to trigger the deferred start
914
+ const stoppedStatus: RecordingStatus = {
915
+ type: 'recording_status',
916
+ sessionId: originalId!,
917
+ status: 'stopped',
918
+ attachToConversationId: conversationId,
919
+ };
920
+ recordingHandlers.recording_status(stoppedStatus, fakeSocket, ctx);
921
+
922
+ // NOW the deferred start should have been sent
923
+ const startMsgsAfterAck = sent.filter((m) => m.type === 'recording_start');
924
+ expect(startMsgsAfterAck).toHaveLength(1);
925
+ });
926
+ });
927
+
928
+ // ─── start_and_stop_with_remainder from idle state ───────────────────────────
929
+
930
+ describe('start_and_stop_with_remainder fallback to plain start when idle', () => {
931
+ beforeEach(() => {
932
+ __resetRecordingState();
933
+ });
934
+
935
+ test('sets pendingStart (not pendingRestart) when no active recording', () => {
936
+ const { ctx, fakeSocket } = createCtx();
937
+ const conversationId = 'conv-rem-idle';
938
+
939
+ const result = executeRecordingIntent(
940
+ { kind: 'start_and_stop_with_remainder', remainder: 'do something' },
941
+ { conversationId, socket: fakeSocket, ctx },
942
+ );
943
+
944
+ expect(result.handled).toBe(false);
945
+ expect(result.pendingStart).toBe(true);
946
+ expect(result.pendingRestart).toBeUndefined();
947
+ expect(result.remainderText).toBe('do something');
948
+ });
949
+
950
+ test('sets pendingRestart when a recording is active', () => {
951
+ const { ctx, fakeSocket } = createCtx();
952
+ const conversationId = 'conv-rem-active';
953
+ ctx.socketToSession.set(fakeSocket, conversationId);
954
+
955
+ // Start a recording first
956
+ handleRecordingStart(conversationId, undefined, fakeSocket, ctx);
957
+
958
+ const result = executeRecordingIntent(
959
+ { kind: 'start_and_stop_with_remainder', remainder: 'do something' },
960
+ { conversationId, socket: fakeSocket, ctx },
961
+ );
962
+
963
+ expect(result.handled).toBe(false);
964
+ expect(result.pendingRestart).toBe(true);
965
+ expect(result.pendingStart).toBeUndefined();
966
+ expect(result.remainderText).toBe('do something');
967
+ });
968
+ });
969
+
970
+ // ─── Deferred restart race condition tests ───────────────────────────────────
971
+
972
+ describe('deferred restart prevents race condition', () => {
973
+ beforeEach(() => {
974
+ __resetRecordingState();
975
+ mockMessages.length = 0;
976
+ mockMessageIdCounter = 0;
977
+ });
978
+
979
+ test('recording_start is NOT sent until client acks the stop', () => {
980
+ const { ctx, sent, fakeSocket } = createCtx();
981
+ const conversationId = 'conv-deferred-race';
982
+ ctx.socketToSession.set(fakeSocket, conversationId);
983
+
984
+ handleRecordingStart(conversationId, undefined, fakeSocket, ctx);
985
+ sent.length = 0;
986
+
987
+ handleRecordingRestart(conversationId, fakeSocket, ctx);
988
+
989
+ // Only recording_stop should have been sent — no recording_start yet
990
+ expect(sent.filter((m) => m.type === 'recording_stop')).toHaveLength(1);
991
+ expect(sent.filter((m) => m.type === 'recording_start')).toHaveLength(0);
992
+
993
+ // System is mid-restart — not idle
994
+ expect(isRecordingIdle()).toBe(false);
995
+ });
996
+
997
+ test('stop-ack timeout cleans up deferred restart state', () => {
998
+ // This test uses a real timer via bun's jest-compatible API
999
+ const { ctx, fakeSocket } = createCtx();
1000
+ const conversationId = 'conv-deferred-timeout';
1001
+ ctx.socketToSession.set(fakeSocket, conversationId);
1002
+
1003
+ handleRecordingStart(conversationId, undefined, fakeSocket, ctx);
1004
+ handleRecordingRestart(conversationId, fakeSocket, ctx);
1005
+
1006
+ // Mid-restart: not idle
1007
+ expect(isRecordingIdle()).toBe(false);
1008
+
1009
+ // We cannot easily test the setTimeout firing here without mocking timers,
1010
+ // but we can verify the state is correctly set up for the timeout to clean up.
1011
+ expect(getActiveRestartToken()).not.toBeNull();
1012
+ });
1013
+
1014
+ test('cross-conversation restart: conversation B restarts recording owned by A', () => {
1015
+ const { ctx, sent, fakeSocket } = createCtx();
1016
+ const convA = 'conv-owner-A';
1017
+ const convB = 'conv-requester-B';
1018
+ ctx.socketToSession.set(fakeSocket, convA);
1019
+
1020
+ // Conversation A starts a recording
1021
+ const originalId = handleRecordingStart(convA, undefined, fakeSocket, ctx);
1022
+ expect(originalId).not.toBeNull();
1023
+ sent.length = 0;
1024
+
1025
+ // Conversation B requests a restart (cross-conversation via global fallback)
1026
+ const result = handleRecordingRestart(convB, fakeSocket, ctx);
1027
+ expect(result.initiated).toBe(true);
1028
+ expect(result.operationToken).toBeTruthy();
1029
+
1030
+ // Should have sent recording_stop (start is deferred)
1031
+ expect(sent.filter((m) => m.type === 'recording_stop')).toHaveLength(1);
1032
+ expect(sent.filter((m) => m.type === 'recording_start')).toHaveLength(0);
1033
+
1034
+ // Simulate the client acknowledging the stop. The stopped status resolves
1035
+ // conversationId from standaloneRecordingConversationId which maps to A.
1036
+ const stoppedStatus: RecordingStatus = {
1037
+ type: 'recording_status',
1038
+ sessionId: originalId!,
1039
+ status: 'stopped',
1040
+ attachToConversationId: convA,
1041
+ };
1042
+ recordingHandlers.recording_status(stoppedStatus, fakeSocket, ctx);
1043
+
1044
+ // The deferred recording_start MUST have been triggered even though the
1045
+ // stopped callback resolved to conversation A (owner), not B (requester).
1046
+ const startMsgs = sent.filter((m) => m.type === 'recording_start');
1047
+ expect(startMsgs).toHaveLength(1);
1048
+ expect(startMsgs[0].operationToken).toBe(result.operationToken);
1049
+
1050
+ // The new recording is owned by B (the requester). Simulate the client
1051
+ // confirming the new recording started. The 'started' status resolves
1052
+ // conversationId to B, so pendingRestartByConversation must have been
1053
+ // migrated from A to B for the restart cycle to complete.
1054
+ const newRecordingId = startMsgs[0].recordingId as string;
1055
+ const startedStatus: RecordingStatus = {
1056
+ type: 'recording_status',
1057
+ sessionId: newRecordingId,
1058
+ status: 'started',
1059
+ operationToken: result.operationToken,
1060
+ attachToConversationId: convB,
1061
+ };
1062
+ recordingHandlers.recording_status(startedStatus, fakeSocket, ctx);
1063
+
1064
+ // Restart cycle must be fully complete: activeRestartToken cleared
1065
+ expect(getActiveRestartToken()).toBeNull();
1066
+
1067
+ // Not idle yet because the new recording is still running
1068
+ expect(isRecordingIdle()).toBe(false);
1069
+
1070
+ // Stop the new recording and verify system returns to idle
1071
+ handleRecordingStop(convB, ctx);
1072
+ const newStoppedStatus: RecordingStatus = {
1073
+ type: 'recording_status',
1074
+ sessionId: newRecordingId,
1075
+ status: 'stopped',
1076
+ attachToConversationId: convB,
1077
+ };
1078
+ recordingHandlers.recording_status(newStoppedStatus, fakeSocket, ctx);
1079
+
1080
+ expect(isRecordingIdle()).toBe(true);
1081
+ });
1082
+
1083
+ test('normal stop (non-restart) does not trigger deferred start', () => {
1084
+ const { ctx, sent, fakeSocket } = createCtx();
1085
+ const conversationId = 'conv-normal-stop';
1086
+ ctx.socketToSession.set(fakeSocket, conversationId);
1087
+
1088
+ const recordingId = handleRecordingStart(conversationId, undefined, fakeSocket, ctx);
1089
+ expect(recordingId).not.toBeNull();
1090
+
1091
+ // Manually stop (not via restart)
1092
+ handleRecordingStop(conversationId, ctx);
1093
+ sent.length = 0;
1094
+
1095
+ // Simulate stop-ack without file path (e.g. very short recording)
1096
+ const stoppedStatus: RecordingStatus = {
1097
+ type: 'recording_status',
1098
+ sessionId: recordingId!,
1099
+ status: 'stopped',
1100
+ attachToConversationId: conversationId,
1101
+ };
1102
+ recordingHandlers.recording_status(stoppedStatus, fakeSocket, ctx);
1103
+
1104
+ // Should NOT have sent a recording_start (no deferred restart pending)
1105
+ const startMsgs = sent.filter((m) => m.type === 'recording_start');
1106
+ expect(startMsgs).toHaveLength(0);
1107
+ expect(isRecordingIdle()).toBe(true);
1108
+ });
1109
+ });