@vellumai/assistant 0.3.8 → 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.
- package/package.json +1 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +20 -0
- package/src/__tests__/approval-routes-http.test.ts +704 -0
- package/src/__tests__/call-controller.test.ts +835 -0
- package/src/__tests__/call-state.test.ts +24 -24
- package/src/__tests__/ipc-snapshot.test.ts +14 -0
- package/src/__tests__/relay-server.test.ts +9 -9
- package/src/__tests__/run-orchestrator.test.ts +399 -3
- package/src/__tests__/runtime-runs.test.ts +12 -4
- package/src/__tests__/session-init.benchmark.test.ts +3 -3
- package/src/__tests__/voice-session-bridge.test.ts +869 -0
- package/src/calls/{call-orchestrator.ts → call-controller.ts} +156 -257
- package/src/calls/call-domain.ts +21 -21
- package/src/calls/call-state.ts +12 -12
- package/src/calls/guardian-dispatch.ts +43 -3
- package/src/calls/relay-server.ts +34 -39
- package/src/calls/twilio-routes.ts +3 -3
- package/src/calls/voice-session-bridge.ts +244 -0
- package/src/config/defaults.ts +5 -0
- package/src/config/notifications-schema.ts +15 -0
- package/src/config/schema.ts +13 -0
- package/src/config/types.ts +1 -0
- package/src/daemon/ipc-contract/notifications.ts +9 -0
- package/src/daemon/ipc-contract-inventory.json +2 -0
- package/src/daemon/ipc-contract.ts +4 -1
- package/src/daemon/lifecycle.ts +84 -1
- package/src/daemon/session-agent-loop.ts +4 -0
- package/src/daemon/session-process.ts +51 -0
- package/src/daemon/session-runtime-assembly.ts +32 -0
- package/src/daemon/session.ts +5 -0
- package/src/memory/db-init.ts +80 -0
- package/src/memory/guardian-action-store.ts +2 -2
- package/src/memory/migrations/019-notification-tables-schema-migration.ts +70 -0
- package/src/memory/migrations/index.ts +1 -0
- package/src/memory/migrations/registry.ts +5 -0
- package/src/memory/schema-migration.ts +1 -0
- package/src/memory/schema.ts +59 -0
- package/src/notifications/README.md +134 -0
- package/src/notifications/adapters/macos.ts +55 -0
- package/src/notifications/adapters/telegram.ts +65 -0
- package/src/notifications/broadcaster.ts +175 -0
- package/src/notifications/copy-composer.ts +118 -0
- package/src/notifications/decision-engine.ts +391 -0
- package/src/notifications/decisions-store.ts +158 -0
- package/src/notifications/deliveries-store.ts +130 -0
- package/src/notifications/destination-resolver.ts +54 -0
- package/src/notifications/deterministic-checks.ts +187 -0
- package/src/notifications/emit-signal.ts +191 -0
- package/src/notifications/events-store.ts +145 -0
- package/src/notifications/preference-extractor.ts +223 -0
- package/src/notifications/preference-summary.ts +110 -0
- package/src/notifications/preferences-store.ts +142 -0
- package/src/notifications/runtime-dispatch.ts +100 -0
- package/src/notifications/signal.ts +24 -0
- package/src/notifications/types.ts +75 -0
- package/src/runtime/http-server.ts +10 -0
- package/src/runtime/pending-interactions.ts +73 -0
- package/src/runtime/routes/approval-routes.ts +179 -0
- package/src/runtime/routes/channel-inbound-routes.ts +39 -4
- package/src/runtime/routes/conversation-routes.ts +31 -1
- package/src/runtime/routes/run-routes.ts +1 -1
- package/src/runtime/run-orchestrator.ts +157 -2
- package/src/tools/browser/browser-manager.ts +1 -1
- package/src/__tests__/call-orchestrator.test.ts +0 -1496
|
@@ -16,11 +16,11 @@ import {
|
|
|
16
16
|
registerCallCompletionNotifier,
|
|
17
17
|
unregisterCallCompletionNotifier,
|
|
18
18
|
fireCallCompletionNotifier,
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
registerCallController,
|
|
20
|
+
unregisterCallController,
|
|
21
|
+
getCallController,
|
|
22
22
|
} from '../calls/call-state.js';
|
|
23
|
-
import type {
|
|
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
|
-
|
|
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
|
-
// ──
|
|
138
|
+
// ── Controller registry ─────────────────────────────────────────
|
|
139
139
|
|
|
140
|
-
test('
|
|
141
|
-
const
|
|
140
|
+
test('registerCallController + getCallController: retrieves controller', () => {
|
|
141
|
+
const fakeController = { id: 'fake-ctrl' } as unknown as CallController;
|
|
142
142
|
|
|
143
|
-
|
|
143
|
+
registerCallController('test-session', fakeController);
|
|
144
144
|
|
|
145
|
-
const retrieved =
|
|
146
|
-
expect(retrieved).toBe(
|
|
145
|
+
const retrieved = getCallController('test-session');
|
|
146
|
+
expect(retrieved).toBe(fakeController);
|
|
147
147
|
});
|
|
148
148
|
|
|
149
|
-
test('
|
|
150
|
-
const
|
|
149
|
+
test('unregisterCallController: getCallController returns undefined after unregister', () => {
|
|
150
|
+
const fakeController = { id: 'fake-ctrl-2' } as unknown as CallController;
|
|
151
151
|
|
|
152
|
-
|
|
153
|
-
|
|
152
|
+
registerCallController('test-session', fakeController);
|
|
153
|
+
unregisterCallController('test-session');
|
|
154
154
|
|
|
155
|
-
const retrieved =
|
|
155
|
+
const retrieved = getCallController('test-session');
|
|
156
156
|
expect(retrieved).toBeUndefined();
|
|
157
157
|
});
|
|
158
158
|
|
|
159
|
-
test('
|
|
160
|
-
const retrieved =
|
|
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
|
|
165
|
-
const first = { id: 'first' } as unknown as
|
|
166
|
-
const second = { id: 'second' } as unknown as
|
|
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
|
-
|
|
169
|
-
|
|
168
|
+
registerCallController('test-session', first);
|
|
169
|
+
registerCallController('test-session', second);
|
|
170
170
|
|
|
171
|
-
const retrieved =
|
|
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
|
|
267
|
-
expect(relay.
|
|
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.
|
|
818
|
+
expect(relay.getController()).not.toBeNull();
|
|
819
819
|
|
|
820
820
|
relay.destroy();
|
|
821
821
|
|
|
822
|
-
expect(relay.
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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
|
+
});
|