@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,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
|
+
});
|