@vellumai/assistant 0.3.7 → 0.3.9

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 (76) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +20 -0
  3. package/src/__tests__/approval-routes-http.test.ts +704 -0
  4. package/src/__tests__/call-controller.test.ts +835 -0
  5. package/src/__tests__/call-state.test.ts +24 -24
  6. package/src/__tests__/ipc-snapshot.test.ts +14 -0
  7. package/src/__tests__/relay-server.test.ts +9 -9
  8. package/src/__tests__/run-orchestrator.test.ts +399 -3
  9. package/src/__tests__/runtime-runs.test.ts +12 -4
  10. package/src/__tests__/send-endpoint-busy.test.ts +284 -0
  11. package/src/__tests__/session-init.benchmark.test.ts +3 -3
  12. package/src/__tests__/subagent-manager-notify.test.ts +3 -3
  13. package/src/__tests__/voice-session-bridge.test.ts +869 -0
  14. package/src/calls/{call-orchestrator.ts → call-controller.ts} +156 -257
  15. package/src/calls/call-domain.ts +21 -21
  16. package/src/calls/call-state.ts +12 -12
  17. package/src/calls/guardian-dispatch.ts +43 -3
  18. package/src/calls/relay-server.ts +34 -39
  19. package/src/calls/twilio-routes.ts +3 -3
  20. package/src/calls/voice-session-bridge.ts +244 -0
  21. package/src/config/bundled-skills/media-processing/SKILL.md +81 -14
  22. package/src/config/bundled-skills/media-processing/TOOLS.json +3 -3
  23. package/src/config/bundled-skills/media-processing/services/preprocess.ts +3 -3
  24. package/src/config/defaults.ts +5 -0
  25. package/src/config/notifications-schema.ts +15 -0
  26. package/src/config/schema.ts +13 -0
  27. package/src/config/types.ts +1 -0
  28. package/src/daemon/daemon-control.ts +13 -12
  29. package/src/daemon/handlers/subagents.ts +10 -3
  30. package/src/daemon/ipc-contract/notifications.ts +9 -0
  31. package/src/daemon/ipc-contract-inventory.json +2 -0
  32. package/src/daemon/ipc-contract.ts +4 -1
  33. package/src/daemon/lifecycle.ts +100 -1
  34. package/src/daemon/server.ts +8 -0
  35. package/src/daemon/session-agent-loop.ts +4 -0
  36. package/src/daemon/session-process.ts +51 -0
  37. package/src/daemon/session-runtime-assembly.ts +32 -0
  38. package/src/daemon/session.ts +5 -0
  39. package/src/memory/db-init.ts +80 -0
  40. package/src/memory/guardian-action-store.ts +2 -2
  41. package/src/memory/migrations/016-memory-segments-indexes.ts +1 -0
  42. package/src/memory/migrations/019-notification-tables-schema-migration.ts +70 -0
  43. package/src/memory/migrations/index.ts +1 -0
  44. package/src/memory/migrations/registry.ts +5 -0
  45. package/src/memory/schema-migration.ts +1 -0
  46. package/src/memory/schema.ts +59 -1
  47. package/src/notifications/README.md +134 -0
  48. package/src/notifications/adapters/macos.ts +55 -0
  49. package/src/notifications/adapters/telegram.ts +65 -0
  50. package/src/notifications/broadcaster.ts +175 -0
  51. package/src/notifications/copy-composer.ts +118 -0
  52. package/src/notifications/decision-engine.ts +391 -0
  53. package/src/notifications/decisions-store.ts +158 -0
  54. package/src/notifications/deliveries-store.ts +130 -0
  55. package/src/notifications/destination-resolver.ts +54 -0
  56. package/src/notifications/deterministic-checks.ts +187 -0
  57. package/src/notifications/emit-signal.ts +191 -0
  58. package/src/notifications/events-store.ts +145 -0
  59. package/src/notifications/preference-extractor.ts +223 -0
  60. package/src/notifications/preference-summary.ts +110 -0
  61. package/src/notifications/preferences-store.ts +142 -0
  62. package/src/notifications/runtime-dispatch.ts +100 -0
  63. package/src/notifications/signal.ts +24 -0
  64. package/src/notifications/types.ts +75 -0
  65. package/src/runtime/http-server.ts +15 -0
  66. package/src/runtime/http-types.ts +22 -0
  67. package/src/runtime/pending-interactions.ts +73 -0
  68. package/src/runtime/routes/approval-routes.ts +179 -0
  69. package/src/runtime/routes/channel-inbound-routes.ts +39 -4
  70. package/src/runtime/routes/conversation-routes.ts +107 -1
  71. package/src/runtime/routes/run-routes.ts +1 -1
  72. package/src/runtime/run-orchestrator.ts +157 -2
  73. package/src/subagent/manager.ts +6 -6
  74. package/src/tools/browser/browser-manager.ts +1 -1
  75. package/src/tools/subagent/message.ts +9 -2
  76. package/src/__tests__/call-orchestrator.test.ts +0 -1496
@@ -16,11 +16,11 @@ import {
16
16
  registerCallCompletionNotifier,
17
17
  unregisterCallCompletionNotifier,
18
18
  fireCallCompletionNotifier,
19
- registerCallOrchestrator,
20
- unregisterCallOrchestrator,
21
- getCallOrchestrator,
19
+ registerCallController,
20
+ unregisterCallController,
21
+ getCallController,
22
22
  } from '../calls/call-state.js';
23
- import type { CallOrchestrator } from '../calls/call-orchestrator.js';
23
+ import type { CallController } from '../calls/call-controller.js';
24
24
 
25
25
  describe('call-state', () => {
26
26
  // Clean up notifiers between tests
@@ -28,7 +28,7 @@ describe('call-state', () => {
28
28
  unregisterCallQuestionNotifier('test-conv');
29
29
  unregisterCallTranscriptNotifier('test-conv');
30
30
  unregisterCallCompletionNotifier('test-conv');
31
- unregisterCallOrchestrator('test-session');
31
+ unregisterCallController('test-session');
32
32
  });
33
33
 
34
34
  // ── Question notifiers ────────────────────────────────────────────
@@ -135,40 +135,40 @@ describe('call-state', () => {
135
135
  fireCallCompletionNotifier('unregistered-conv', 'session-1');
136
136
  });
137
137
 
138
- // ── Orchestrator registry ─────────────────────────────────────────
138
+ // ── Controller registry ─────────────────────────────────────────
139
139
 
140
- test('registerCallOrchestrator + getCallOrchestrator: retrieves orchestrator', () => {
141
- const fakeOrchestrator = { id: 'fake-orch' } as unknown as CallOrchestrator;
140
+ test('registerCallController + getCallController: retrieves controller', () => {
141
+ const fakeController = { id: 'fake-ctrl' } as unknown as CallController;
142
142
 
143
- registerCallOrchestrator('test-session', fakeOrchestrator);
143
+ registerCallController('test-session', fakeController);
144
144
 
145
- const retrieved = getCallOrchestrator('test-session');
146
- expect(retrieved).toBe(fakeOrchestrator);
145
+ const retrieved = getCallController('test-session');
146
+ expect(retrieved).toBe(fakeController);
147
147
  });
148
148
 
149
- test('unregisterCallOrchestrator: getCallOrchestrator returns undefined after unregister', () => {
150
- const fakeOrchestrator = { id: 'fake-orch-2' } as unknown as CallOrchestrator;
149
+ test('unregisterCallController: getCallController returns undefined after unregister', () => {
150
+ const fakeController = { id: 'fake-ctrl-2' } as unknown as CallController;
151
151
 
152
- registerCallOrchestrator('test-session', fakeOrchestrator);
153
- unregisterCallOrchestrator('test-session');
152
+ registerCallController('test-session', fakeController);
153
+ unregisterCallController('test-session');
154
154
 
155
- const retrieved = getCallOrchestrator('test-session');
155
+ const retrieved = getCallController('test-session');
156
156
  expect(retrieved).toBeUndefined();
157
157
  });
158
158
 
159
- test('getCallOrchestrator returns undefined for unregistered session', () => {
160
- const retrieved = getCallOrchestrator('nonexistent-session');
159
+ test('getCallController returns undefined for unregistered session', () => {
160
+ const retrieved = getCallController('nonexistent-session');
161
161
  expect(retrieved).toBeUndefined();
162
162
  });
163
163
 
164
- test('registering a new orchestrator for same session overwrites the previous one', () => {
165
- const first = { id: 'first' } as unknown as CallOrchestrator;
166
- const second = { id: 'second' } as unknown as CallOrchestrator;
164
+ test('registering a new controller for same session overwrites the previous one', () => {
165
+ const first = { id: 'first' } as unknown as CallController;
166
+ const second = { id: 'second' } as unknown as CallController;
167
167
 
168
- registerCallOrchestrator('test-session', first);
169
- registerCallOrchestrator('test-session', second);
168
+ registerCallController('test-session', first);
169
+ registerCallController('test-session', second);
170
170
 
171
- const retrieved = getCallOrchestrator('test-session');
171
+ const retrieved = getCallController('test-session');
172
172
  expect(retrieved).toBe(second);
173
173
  });
174
174
  });
@@ -51,6 +51,11 @@ const clientMessages: Record<ClientMessageType, ClientMessage> = {
51
51
  type: 'session_switch',
52
52
  sessionId: 'sess-002',
53
53
  },
54
+ session_rename: {
55
+ type: 'session_rename',
56
+ sessionId: 'sess-002',
57
+ title: 'Renamed session',
58
+ },
54
59
  ping: {
55
60
  type: 'ping',
56
61
  },
@@ -1064,6 +1069,15 @@ const serverMessages: Record<ServerMessageType, ServerMessage> = {
1064
1069
  label: 'Call Sidd',
1065
1070
  message: 'Remember to call Sidd about the project',
1066
1071
  },
1072
+ notification_intent: {
1073
+ type: 'notification_intent',
1074
+ sourceEventName: 'guardian.question',
1075
+ title: '⚠️ Attention needed',
1076
+ body: 'Your assistant needs your input.',
1077
+ deepLinkMetadata: {
1078
+ conversationId: 'conv-guardian-001',
1079
+ },
1080
+ },
1067
1081
  schedule_complete: {
1068
1082
  type: 'schedule_complete',
1069
1083
  scheduleId: 'sched-001',
@@ -263,8 +263,8 @@ describe('relay-server', () => {
263
263
  const connectedEvents = events.filter(e => e.eventType === 'call_connected');
264
264
  expect(connectedEvents.length).toBe(1);
265
265
 
266
- // Verify orchestrator was created
267
- expect(relay.getOrchestrator()).not.toBeNull();
266
+ // Verify controller was created
267
+ expect(relay.getController()).not.toBeNull();
268
268
 
269
269
  relay.destroy();
270
270
  });
@@ -815,11 +815,11 @@ describe('relay-server', () => {
815
815
  to: '+15552222222',
816
816
  }));
817
817
 
818
- expect(relay.getOrchestrator()).not.toBeNull();
818
+ expect(relay.getController()).not.toBeNull();
819
819
 
820
820
  relay.destroy();
821
821
 
822
- expect(relay.getOrchestrator()).toBeNull();
822
+ expect(relay.getController()).toBeNull();
823
823
  });
824
824
 
825
825
  test('destroy: can be called multiple times without error', () => {
@@ -1145,7 +1145,7 @@ describe('relay-server', () => {
1145
1145
  to: '+15551111111',
1146
1146
  }));
1147
1147
 
1148
- const runtimeContext = (relay.getOrchestrator() as unknown as { guardianContext?: { sourceChannel?: string; actorRole?: string; guardianExternalUserId?: string } })?.guardianContext;
1148
+ const runtimeContext = (relay.getController() as unknown as { guardianContext?: { sourceChannel?: string; actorRole?: string; guardianExternalUserId?: string } })?.guardianContext;
1149
1149
  expect(runtimeContext?.sourceChannel).toBe('voice');
1150
1150
  expect(runtimeContext?.actorRole).toBe('guardian');
1151
1151
  expect(runtimeContext?.guardianExternalUserId).toBe('+15550001111');
@@ -1181,7 +1181,7 @@ describe('relay-server', () => {
1181
1181
  to: '+15551111111',
1182
1182
  }));
1183
1183
 
1184
- const runtimeContext = (relay.getOrchestrator() as unknown as {
1184
+ const runtimeContext = (relay.getController() as unknown as {
1185
1185
  guardianContext?: {
1186
1186
  sourceChannel?: string;
1187
1187
  actorRole?: string;
@@ -1197,7 +1197,7 @@ describe('relay-server', () => {
1197
1197
  relay.destroy();
1198
1198
  });
1199
1199
 
1200
- test('inbound guardian verification updates orchestrator context to guardian', async () => {
1200
+ test('inbound guardian verification updates controller context to guardian', async () => {
1201
1201
  ensureConversation('conv-guardian-context-upgrade');
1202
1202
  const session = createCallSession({
1203
1203
  conversationId: 'conv-guardian-context-upgrade',
@@ -1219,7 +1219,7 @@ describe('relay-server', () => {
1219
1219
  to: session.toNumber,
1220
1220
  }));
1221
1221
 
1222
- const preVerify = (relay.getOrchestrator() as unknown as {
1222
+ const preVerify = (relay.getController() as unknown as {
1223
1223
  guardianContext?: { actorRole?: string };
1224
1224
  })?.guardianContext;
1225
1225
  expect(preVerify?.actorRole).toBe('unverified_channel');
@@ -1233,7 +1233,7 @@ describe('relay-server', () => {
1233
1233
 
1234
1234
  await new Promise((resolve) => setTimeout(resolve, 10));
1235
1235
 
1236
- const postVerify = (relay.getOrchestrator() as unknown as {
1236
+ const postVerify = (relay.getController() as unknown as {
1237
1237
  guardianContext?: { sourceChannel?: string; actorRole?: string; guardianExternalUserId?: string };
1238
1238
  })?.guardianContext;
1239
1239
  expect(postVerify?.sourceChannel).toBe('voice');
@@ -36,6 +36,7 @@ import { initializeDb, getDb, resetDb } from '../memory/db.js';
36
36
  import { createConversation } from '../memory/conversation-store.js';
37
37
  import { createRun, getRun, setRunConfirmation } from '../memory/runs-store.js';
38
38
  import { RunOrchestrator } from '../runtime/run-orchestrator.js';
39
+ import type { VoiceRunEventSink } from '../runtime/run-orchestrator.js';
39
40
  import type { ChannelCapabilities } from '../daemon/session-runtime-assembly.js';
40
41
 
41
42
  initializeDb();
@@ -53,6 +54,7 @@ function makeSessionWithConfirmation(message: ServerMessage): Session {
53
54
  setGuardianContext: () => {},
54
55
  setCommandIntent: () => {},
55
56
  setTurnChannelContext: () => {},
57
+ setVoiceCallControlPrompt: () => {},
56
58
  updateClient: (handler: (msg: ServerMessage) => void) => {
57
59
  clientHandler = handler;
58
60
  },
@@ -78,6 +80,7 @@ function makeSessionWithEvent(message: ServerMessage): Session {
78
80
  setGuardianContext: () => {},
79
81
  setCommandIntent: () => {},
80
82
  setTurnChannelContext: () => {},
83
+ setVoiceCallControlPrompt: () => {},
81
84
  updateClient: () => {},
82
85
  runAgentLoop: async (_content: string, _messageId: string, onEvent: (msg: ServerMessage) => void) => {
83
86
  onEvent(message);
@@ -110,7 +113,7 @@ describe('run failure detection', () => {
110
113
  deriveDefaultStrictSideEffects: () => false,
111
114
  });
112
115
 
113
- const run = await orchestrator.startRun(conversation.id, 'Hello');
116
+ const { run } = await orchestrator.startRun(conversation.id, 'Hello');
114
117
 
115
118
  // The agent loop fires asynchronously; give it a tick to settle.
116
119
  await new Promise((r) => setTimeout(r, 50));
@@ -133,7 +136,7 @@ describe('run failure detection', () => {
133
136
  deriveDefaultStrictSideEffects: () => false,
134
137
  });
135
138
 
136
- const run = await orchestrator.startRun(conversation.id, 'Hello');
139
+ const { run } = await orchestrator.startRun(conversation.id, 'Hello');
137
140
 
138
141
  await new Promise((r) => setTimeout(r, 50));
139
142
 
@@ -212,7 +215,7 @@ describe('run approval state executionTarget', () => {
212
215
  deriveDefaultStrictSideEffects: () => false,
213
216
  });
214
217
 
215
- const run = await orchestrator.startRun(conversation.id, 'Run host command');
218
+ const { run } = await orchestrator.startRun(conversation.id, 'Run host command');
216
219
  const stored = orchestrator.getRun(run.id);
217
220
  expect(stored?.status).toBe('needs_confirmation');
218
221
  expect(stored?.pendingConfirmation?.executionTarget).toBe('host');
@@ -246,6 +249,7 @@ describe('startRun channel capability resolution', () => {
246
249
  setGuardianContext: () => {},
247
250
  setCommandIntent: () => {},
248
251
  setTurnChannelContext: () => {},
252
+ setVoiceCallControlPrompt: () => {},
249
253
  updateClient: () => {},
250
254
  runAgentLoop: async () => {},
251
255
  handleConfirmationResponse: () => {},
@@ -284,6 +288,7 @@ describe('startRun channel capability resolution', () => {
284
288
  setGuardianContext: () => {},
285
289
  setCommandIntent: () => {},
286
290
  setTurnChannelContext: () => {},
291
+ setVoiceCallControlPrompt: () => {},
287
292
  updateClient: () => {},
288
293
  runAgentLoop: async () => {},
289
294
  handleConfirmationResponse: () => {},
@@ -318,6 +323,7 @@ describe('startRun channel capability resolution', () => {
318
323
  setGuardianContext: () => {},
319
324
  setCommandIntent: () => {},
320
325
  setTurnChannelContext: () => {},
326
+ setVoiceCallControlPrompt: () => {},
321
327
  updateClient: () => {},
322
328
  runAgentLoop: async () => {},
323
329
  handleConfirmationResponse: () => {},
@@ -365,6 +371,7 @@ describe('strictSideEffects re-derivation across runs', () => {
365
371
  setGuardianContext: () => {},
366
372
  setCommandIntent: () => {},
367
373
  setTurnChannelContext: () => {},
374
+ setVoiceCallControlPrompt: () => {},
368
375
  updateClient: () => {},
369
376
  runAgentLoop: async () => {},
370
377
  handleConfirmationResponse: () => {},
@@ -403,6 +410,7 @@ describe('strictSideEffects re-derivation across runs', () => {
403
410
  setGuardianContext: () => {},
404
411
  setCommandIntent: () => {},
405
412
  setTurnChannelContext: () => {},
413
+ setVoiceCallControlPrompt: () => {},
406
414
  updateClient: () => {},
407
415
  runAgentLoop: async () => {},
408
416
  handleConfirmationResponse: () => {},
@@ -442,6 +450,7 @@ describe('strictSideEffects re-derivation across runs', () => {
442
450
  setGuardianContext: () => {},
443
451
  setCommandIntent: () => {},
444
452
  setTurnChannelContext: () => {},
453
+ setVoiceCallControlPrompt: () => {},
445
454
  updateClient: () => {},
446
455
  runAgentLoop: async () => {},
447
456
  handleConfirmationResponse: () => {},
@@ -461,3 +470,390 @@ describe('strictSideEffects re-derivation across runs', () => {
461
470
  expect((session as unknown as { memoryPolicy: { strictSideEffects: boolean } }).memoryPolicy.strictSideEffects).toBe(false);
462
471
  });
463
472
  });
473
+
474
+ // ═══════════════════════════════════════════════════════════════════════════
475
+ // VoiceRunEventSink forwarding
476
+ // ═══════════════════════════════════════════════════════════════════════════
477
+
478
+ describe('eventSink forwarding', () => {
479
+ beforeEach(() => {
480
+ const db = getDb();
481
+ db.run('DELETE FROM message_runs');
482
+ db.run('DELETE FROM messages');
483
+ db.run('DELETE FROM conversations');
484
+ });
485
+
486
+ test('eventSink receives assistant_text_delta events', async () => {
487
+ const conversation = createConversation('event sink delta test');
488
+ const deltaMsg: ServerMessage = {
489
+ type: 'assistant_text_delta',
490
+ text: 'Hello from agent',
491
+ sessionId: conversation.id,
492
+ };
493
+ const session = makeSessionWithEvent(deltaMsg);
494
+
495
+ const receivedDeltas: string[] = [];
496
+ const sink: VoiceRunEventSink = {
497
+ onTextDelta: (text) => receivedDeltas.push(text),
498
+ onMessageComplete: () => {},
499
+ onError: () => {},
500
+ onToolUse: () => {},
501
+ };
502
+
503
+ const orchestrator = new RunOrchestrator({
504
+ getOrCreateSession: async () => session,
505
+ resolveAttachments: () => [],
506
+ deriveDefaultStrictSideEffects: () => false,
507
+ });
508
+
509
+ await orchestrator.startRun(conversation.id, 'Hello', undefined, {
510
+ eventSink: sink,
511
+ });
512
+ await new Promise((r) => setTimeout(r, 50));
513
+
514
+ expect(receivedDeltas).toEqual(['Hello from agent']);
515
+ });
516
+
517
+ test('eventSink receives error events', async () => {
518
+ const conversation = createConversation('event sink error test');
519
+ const errMsg: ServerMessage = {
520
+ type: 'error',
521
+ message: 'Something broke',
522
+ };
523
+ const session = makeSessionWithEvent(errMsg);
524
+
525
+ const receivedErrors: string[] = [];
526
+ const sink: VoiceRunEventSink = {
527
+ onTextDelta: () => {},
528
+ onMessageComplete: () => {},
529
+ onError: (msg) => receivedErrors.push(msg),
530
+ onToolUse: () => {},
531
+ };
532
+
533
+ const orchestrator = new RunOrchestrator({
534
+ getOrCreateSession: async () => session,
535
+ resolveAttachments: () => [],
536
+ deriveDefaultStrictSideEffects: () => false,
537
+ });
538
+
539
+ await orchestrator.startRun(conversation.id, 'Hello', undefined, {
540
+ eventSink: sink,
541
+ });
542
+ await new Promise((r) => setTimeout(r, 50));
543
+
544
+ expect(receivedErrors).toEqual(['Something broke']);
545
+ });
546
+
547
+ test('eventSink receives tool_use_start events', async () => {
548
+ const conversation = createConversation('event sink tool test');
549
+ const toolMsg: ServerMessage = {
550
+ type: 'tool_use_start',
551
+ toolName: 'web_search',
552
+ input: { query: 'test' },
553
+ sessionId: conversation.id,
554
+ };
555
+ const session = makeSessionWithEvent(toolMsg);
556
+
557
+ const receivedTools: Array<{ name: string; input: Record<string, unknown> }> = [];
558
+ const sink: VoiceRunEventSink = {
559
+ onTextDelta: () => {},
560
+ onMessageComplete: () => {},
561
+ onError: () => {},
562
+ onToolUse: (name, input) => receivedTools.push({ name, input }),
563
+ };
564
+
565
+ const orchestrator = new RunOrchestrator({
566
+ getOrCreateSession: async () => session,
567
+ resolveAttachments: () => [],
568
+ deriveDefaultStrictSideEffects: () => false,
569
+ });
570
+
571
+ await orchestrator.startRun(conversation.id, 'Hello', undefined, {
572
+ eventSink: sink,
573
+ });
574
+ await new Promise((r) => setTimeout(r, 50));
575
+
576
+ expect(receivedTools).toHaveLength(1);
577
+ expect(receivedTools[0].name).toBe('web_search');
578
+ expect(receivedTools[0].input).toEqual({ query: 'test' });
579
+ });
580
+
581
+ test('eventSink receives onMessageComplete on generation_cancelled', async () => {
582
+ const conversation = createConversation('event sink cancelled test');
583
+ const cancelledMsg: ServerMessage = {
584
+ type: 'generation_cancelled',
585
+ sessionId: conversation.id,
586
+ };
587
+ const session = makeSessionWithEvent(cancelledMsg);
588
+
589
+ let messageCompleteCount = 0;
590
+ const receivedErrors: string[] = [];
591
+ const sink: VoiceRunEventSink = {
592
+ onTextDelta: () => {},
593
+ onMessageComplete: () => { messageCompleteCount++; },
594
+ onError: (msg) => receivedErrors.push(msg),
595
+ onToolUse: () => {},
596
+ };
597
+
598
+ const orchestrator = new RunOrchestrator({
599
+ getOrCreateSession: async () => session,
600
+ resolveAttachments: () => [],
601
+ deriveDefaultStrictSideEffects: () => false,
602
+ });
603
+
604
+ await orchestrator.startRun(conversation.id, 'Hello', undefined, {
605
+ eventSink: sink,
606
+ });
607
+ await new Promise((r) => setTimeout(r, 50));
608
+
609
+ // generation_cancelled should be forwarded as onMessageComplete
610
+ expect(messageCompleteCount).toBe(1);
611
+ // It should NOT trigger onError
612
+ expect(receivedErrors).toHaveLength(0);
613
+ });
614
+
615
+ test('eventSink receives onError when runAgentLoop throws', async () => {
616
+ const conversation = createConversation('event sink exception test');
617
+
618
+ // Build a session whose runAgentLoop throws an exception instead of
619
+ // emitting events — simulating an unhandled crash in the agent loop.
620
+ const session = {
621
+ isProcessing: () => false,
622
+ persistUserMessage: () => undefined as unknown as string,
623
+ memoryPolicy: { scopeId: 'default', includeDefaultFallback: false, strictSideEffects: false },
624
+ setChannelCapabilities: () => {},
625
+ setAssistantId: () => {},
626
+ setGuardianContext: () => {},
627
+ setCommandIntent: () => {},
628
+ setTurnChannelContext: () => {},
629
+ setVoiceCallControlPrompt: () => {},
630
+ updateClient: () => {},
631
+ runAgentLoop: async () => {
632
+ throw new Error('Unexpected agent crash');
633
+ },
634
+ handleConfirmationResponse: () => {},
635
+ } as unknown as Session;
636
+
637
+ const receivedErrors: string[] = [];
638
+ const sink: VoiceRunEventSink = {
639
+ onTextDelta: () => {},
640
+ onMessageComplete: () => {},
641
+ onError: (msg) => receivedErrors.push(msg),
642
+ onToolUse: () => {},
643
+ };
644
+
645
+ const orchestrator = new RunOrchestrator({
646
+ getOrCreateSession: async () => session,
647
+ resolveAttachments: () => [],
648
+ deriveDefaultStrictSideEffects: () => false,
649
+ });
650
+
651
+ await orchestrator.startRun(conversation.id, 'Hello', undefined, {
652
+ eventSink: sink,
653
+ });
654
+ await new Promise((r) => setTimeout(r, 50));
655
+
656
+ // The exception message should be forwarded to the event sink
657
+ expect(receivedErrors).toEqual(['Unexpected agent crash']);
658
+ });
659
+
660
+ test('no events forwarded when eventSink is not provided', async () => {
661
+ const conversation = createConversation('no sink test');
662
+ const deltaMsg: ServerMessage = {
663
+ type: 'assistant_text_delta',
664
+ text: 'Hello',
665
+ sessionId: conversation.id,
666
+ };
667
+ const session = makeSessionWithEvent(deltaMsg);
668
+
669
+ const orchestrator = new RunOrchestrator({
670
+ getOrCreateSession: async () => session,
671
+ resolveAttachments: () => [],
672
+ deriveDefaultStrictSideEffects: () => false,
673
+ });
674
+
675
+ // Should not throw when no eventSink is provided
676
+ const { run } = await orchestrator.startRun(conversation.id, 'Hello');
677
+ await new Promise((r) => setTimeout(r, 50));
678
+
679
+ const stored = orchestrator.getRun(run.id);
680
+ expect(stored?.status).toBe('completed');
681
+ });
682
+ });
683
+
684
+ // ═══════════════════════════════════════════════════════════════════════════
685
+ // Run abort / cancellation
686
+ // ═══════════════════════════════════════════════════════════════════════════
687
+
688
+ describe('run abort', () => {
689
+ beforeEach(() => {
690
+ const db = getDb();
691
+ db.run('DELETE FROM message_runs');
692
+ db.run('DELETE FROM messages');
693
+ db.run('DELETE FROM conversations');
694
+ });
695
+
696
+ test('startRun returns an abort function', async () => {
697
+ const conversation = createConversation('abort handle test');
698
+ const session = {
699
+ isProcessing: () => false,
700
+ currentRequestId: undefined as string | undefined,
701
+ persistUserMessage: (_c: string, _a: unknown[], reqId: string) => {
702
+ session.currentRequestId = reqId;
703
+ return undefined as unknown as string;
704
+ },
705
+ memoryPolicy: { scopeId: 'default', includeDefaultFallback: false, strictSideEffects: false },
706
+ setChannelCapabilities: () => {},
707
+ setAssistantId: () => {},
708
+ setGuardianContext: () => {},
709
+ setCommandIntent: () => {},
710
+ setTurnChannelContext: () => {},
711
+ setVoiceCallControlPrompt: () => {},
712
+ updateClient: () => {},
713
+ runAgentLoop: async () => {},
714
+ handleConfirmationResponse: () => {},
715
+ abort: () => {},
716
+ } as unknown as Session;
717
+
718
+ const orchestrator = new RunOrchestrator({
719
+ getOrCreateSession: async () => session,
720
+ resolveAttachments: () => [],
721
+ deriveDefaultStrictSideEffects: () => false,
722
+ });
723
+
724
+ const handle = await orchestrator.startRun(conversation.id, 'Hello');
725
+ expect(typeof handle.abort).toBe('function');
726
+ expect(handle.run.id).toBeDefined();
727
+ });
728
+
729
+ test('aborting a run does not crash session state', async () => {
730
+ const conversation = createConversation('abort safety test');
731
+ let abortCalled = false;
732
+
733
+ const session = {
734
+ isProcessing: () => false,
735
+ currentRequestId: undefined as string | undefined,
736
+ persistUserMessage: (_c: string, _a: unknown[], reqId: string) => {
737
+ session.currentRequestId = reqId;
738
+ return undefined as unknown as string;
739
+ },
740
+ memoryPolicy: { scopeId: 'default', includeDefaultFallback: false, strictSideEffects: false },
741
+ setChannelCapabilities: () => {},
742
+ setAssistantId: () => {},
743
+ setGuardianContext: () => {},
744
+ setCommandIntent: () => {},
745
+ setTurnChannelContext: () => {},
746
+ setVoiceCallControlPrompt: () => {},
747
+ updateClient: () => {},
748
+ runAgentLoop: async () => {
749
+ // Simulate a long-running agent loop
750
+ await new Promise((r) => setTimeout(r, 200));
751
+ },
752
+ handleConfirmationResponse: () => {},
753
+ abort: () => { abortCalled = true; },
754
+ } as unknown as Session;
755
+
756
+ const orchestrator = new RunOrchestrator({
757
+ getOrCreateSession: async () => session,
758
+ resolveAttachments: () => [],
759
+ deriveDefaultStrictSideEffects: () => false,
760
+ });
761
+
762
+ const handle = await orchestrator.startRun(conversation.id, 'Hello');
763
+
764
+ // Abort immediately — session still has same requestId
765
+ handle.abort();
766
+ expect(abortCalled).toBe(true);
767
+
768
+ // Wait for cleanup to settle
769
+ await new Promise((r) => setTimeout(r, 300));
770
+
771
+ // Session state should not be corrupted — the run completes normally
772
+ // since the mock runAgentLoop resolves after 200ms regardless.
773
+ const stored = orchestrator.getRun(handle.run.id);
774
+ expect(stored).not.toBeNull();
775
+ });
776
+
777
+ test('stale abort handle is a no-op when session has moved to a new run', async () => {
778
+ const conversation = createConversation('stale abort test');
779
+ let abortCalled = false;
780
+
781
+ const session = {
782
+ isProcessing: () => false,
783
+ currentRequestId: undefined as string | undefined,
784
+ persistUserMessage: (_c: string, _a: unknown[], reqId: string) => {
785
+ session.currentRequestId = reqId;
786
+ return undefined as unknown as string;
787
+ },
788
+ memoryPolicy: { scopeId: 'default', includeDefaultFallback: false, strictSideEffects: false },
789
+ setChannelCapabilities: () => {},
790
+ setAssistantId: () => {},
791
+ setGuardianContext: () => {},
792
+ setCommandIntent: () => {},
793
+ setTurnChannelContext: () => {},
794
+ setVoiceCallControlPrompt: () => {},
795
+ updateClient: () => {},
796
+ runAgentLoop: async () => {},
797
+ handleConfirmationResponse: () => {},
798
+ abort: () => { abortCalled = true; },
799
+ } as unknown as Session;
800
+
801
+ const orchestrator = new RunOrchestrator({
802
+ getOrCreateSession: async () => session,
803
+ resolveAttachments: () => [],
804
+ deriveDefaultStrictSideEffects: () => false,
805
+ });
806
+
807
+ // Start first run and capture its handle
808
+ const handle1 = await orchestrator.startRun(conversation.id, 'First turn');
809
+ await new Promise((r) => setTimeout(r, 50));
810
+
811
+ // Start second run — session's currentRequestId now belongs to run 2
812
+ const _handle2 = await orchestrator.startRun(conversation.id, 'Second turn');
813
+
814
+ // Attempt to abort using the stale handle from run 1.
815
+ // Since the session has moved to a new requestId, this should be a no-op.
816
+ handle1.abort();
817
+ expect(abortCalled).toBe(false);
818
+ });
819
+
820
+ test('abort works when session still has matching requestId', async () => {
821
+ const conversation = createConversation('matching abort test');
822
+ let abortCalled = false;
823
+
824
+ const session = {
825
+ isProcessing: () => false,
826
+ currentRequestId: undefined as string | undefined,
827
+ persistUserMessage: (_c: string, _a: unknown[], reqId: string) => {
828
+ session.currentRequestId = reqId;
829
+ return undefined as unknown as string;
830
+ },
831
+ memoryPolicy: { scopeId: 'default', includeDefaultFallback: false, strictSideEffects: false },
832
+ setChannelCapabilities: () => {},
833
+ setAssistantId: () => {},
834
+ setGuardianContext: () => {},
835
+ setCommandIntent: () => {},
836
+ setTurnChannelContext: () => {},
837
+ setVoiceCallControlPrompt: () => {},
838
+ updateClient: () => {},
839
+ runAgentLoop: async () => {
840
+ // Keep the agent loop running so the session stays on this requestId
841
+ await new Promise((r) => setTimeout(r, 500));
842
+ },
843
+ handleConfirmationResponse: () => {},
844
+ abort: () => { abortCalled = true; },
845
+ } as unknown as Session;
846
+
847
+ const orchestrator = new RunOrchestrator({
848
+ getOrCreateSession: async () => session,
849
+ resolveAttachments: () => [],
850
+ deriveDefaultStrictSideEffects: () => false,
851
+ });
852
+
853
+ const handle = await orchestrator.startRun(conversation.id, 'Hello');
854
+
855
+ // Abort while the session is still processing this run
856
+ handle.abort();
857
+ expect(abortCalled).toBe(true);
858
+ });
859
+ });