@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
@@ -53,6 +53,8 @@ function makeCompletingSession(): Session {
53
53
  setAssistantId: () => {},
54
54
  setGuardianContext: () => {},
55
55
  setCommandIntent: () => {},
56
+ setTurnChannelContext: () => {},
57
+ setVoiceCallControlPrompt: () => {},
56
58
  updateClient: () => {},
57
59
  runAgentLoop: async () => {
58
60
  processing = true;
@@ -76,6 +78,8 @@ function makeHangingSession(): Session {
76
78
  setAssistantId: () => {},
77
79
  setGuardianContext: () => {},
78
80
  setCommandIntent: () => {},
81
+ setTurnChannelContext: () => {},
82
+ setVoiceCallControlPrompt: () => {},
79
83
  updateClient: () => {},
80
84
  runAgentLoop: async () => {
81
85
  processing = true;
@@ -97,6 +101,8 @@ function makeFailingSession(errorMsg: string): Session {
97
101
  setAssistantId: () => {},
98
102
  setGuardianContext: () => {},
99
103
  setCommandIntent: () => {},
104
+ setTurnChannelContext: () => {},
105
+ setVoiceCallControlPrompt: () => {},
100
106
  updateClient: () => {},
101
107
  runAgentLoop: async (_content: string, _messageId: string, onEvent: (msg: ServerMessage) => void) => {
102
108
  onEvent({ type: 'error', message: errorMsg });
@@ -117,6 +123,8 @@ function makeConfirmationSession(toolName: string): Session {
117
123
  setAssistantId: () => {},
118
124
  setGuardianContext: () => {},
119
125
  setCommandIntent: () => {},
126
+ setTurnChannelContext: () => {},
127
+ setVoiceCallControlPrompt: () => {},
120
128
  updateClient: (handler: (msg: ServerMessage) => void) => {
121
129
  clientHandler = handler;
122
130
  },
@@ -163,7 +171,7 @@ describe('runtime runs — swarm lifecycle', () => {
163
171
  deriveDefaultStrictSideEffects: () => false,
164
172
  });
165
173
 
166
- const run = await orchestrator.startRun(conversation.id, 'Build a feature');
174
+ const { run } = await orchestrator.startRun(conversation.id, 'Build a feature');
167
175
  expect(run.status).toBe('running');
168
176
 
169
177
  // Wait for agent loop to complete
@@ -181,7 +189,7 @@ describe('runtime runs — swarm lifecycle', () => {
181
189
  deriveDefaultStrictSideEffects: () => false,
182
190
  });
183
191
 
184
- const run = await orchestrator.startRun(conversation.id, 'Run swarm');
192
+ const { run } = await orchestrator.startRun(conversation.id, 'Run swarm');
185
193
 
186
194
  await new Promise((r) => setTimeout(r, 50));
187
195
 
@@ -198,7 +206,7 @@ describe('runtime runs — swarm lifecycle', () => {
198
206
  deriveDefaultStrictSideEffects: () => false,
199
207
  });
200
208
 
201
- const run = await orchestrator.startRun(conversation.id, 'Delegate a swarm task');
209
+ const { run } = await orchestrator.startRun(conversation.id, 'Delegate a swarm task');
202
210
 
203
211
  // Give agent loop time to emit confirmation_request
204
212
  await new Promise((r) => setTimeout(r, 50));
@@ -216,7 +224,7 @@ describe('runtime runs — swarm lifecycle', () => {
216
224
  deriveDefaultStrictSideEffects: () => false,
217
225
  });
218
226
 
219
- const run = await orchestrator.startRun(conversation.id, 'Run with approval');
227
+ const { run } = await orchestrator.startRun(conversation.id, 'Run with approval');
220
228
  await new Promise((r) => setTimeout(r, 50));
221
229
 
222
230
  // Verify pending state
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Tests for POST /v1/messages queue-if-busy behavior and hub publishing.
3
+ *
4
+ * Validates that:
5
+ * - Messages are accepted (202) when the session is idle, with hub events published.
6
+ * - Messages are queued (202, queued: true) when the session is busy, not 409.
7
+ * - SSE subscribers receive events from messages sent via this endpoint.
8
+ */
9
+ import { describe, test, expect, beforeEach, afterAll, mock } from 'bun:test';
10
+ import { mkdtempSync, rmSync, realpathSync } from 'node:fs';
11
+ import { tmpdir } from 'node:os';
12
+ import { join } from 'node:path';
13
+ import type { ServerMessage } from '../daemon/ipc-protocol.js';
14
+ import type { Session } from '../daemon/session.js';
15
+
16
+ const testDir = realpathSync(mkdtempSync(join(tmpdir(), 'send-endpoint-busy-test-')));
17
+
18
+ mock.module('../util/platform.js', () => ({
19
+ getRootDir: () => testDir,
20
+ getDataDir: () => testDir,
21
+ isMacOS: () => process.platform === 'darwin',
22
+ isLinux: () => process.platform === 'linux',
23
+ isWindows: () => process.platform === 'win32',
24
+ getSocketPath: () => join(testDir, 'test.sock'),
25
+ getPidPath: () => join(testDir, 'test.pid'),
26
+ getDbPath: () => join(testDir, 'test.db'),
27
+ getLogPath: () => join(testDir, 'test.log'),
28
+ ensureDataDir: () => {},
29
+ }));
30
+
31
+ mock.module('../util/logger.js', () => ({
32
+ getLogger: () => new Proxy({} as Record<string, unknown>, {
33
+ get: () => () => {},
34
+ }),
35
+ }));
36
+
37
+ mock.module('../config/loader.js', () => ({
38
+ getConfig: () => ({
39
+ model: 'test',
40
+ provider: 'test',
41
+ apiKeys: {},
42
+ memory: { enabled: false },
43
+ rateLimit: { maxRequestsPerMinute: 0, maxTokensPerSession: 0 },
44
+ secretDetection: { enabled: false },
45
+ }),
46
+ }));
47
+
48
+ import { initializeDb, getDb, resetDb } from '../memory/db.js';
49
+ import { RuntimeHttpServer } from '../runtime/http-server.js';
50
+ import { AssistantEventHub } from '../runtime/assistant-event-hub.js';
51
+ import type { AssistantEvent } from '../runtime/assistant-event.js';
52
+
53
+ initializeDb();
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Session helpers
57
+ // ---------------------------------------------------------------------------
58
+
59
+ /** Session that completes its agent loop quickly and emits a text delta + message_complete. */
60
+ function makeCompletingSession(): Session {
61
+ let processing = false;
62
+ return {
63
+ isProcessing: () => processing,
64
+ persistUserMessage: (_content: string, _attachments: unknown[], requestId?: string) => {
65
+ processing = true;
66
+ return requestId ?? 'msg-1';
67
+ },
68
+ memoryPolicy: { scopeId: 'default', includeDefaultFallback: false, strictSideEffects: false },
69
+ setChannelCapabilities: () => {},
70
+ setAssistantId: () => {},
71
+ setGuardianContext: () => {},
72
+ setCommandIntent: () => {},
73
+ updateClient: () => {},
74
+ enqueueMessage: () => ({ queued: false, requestId: 'noop' }),
75
+ runAgentLoop: async (_content: string, _messageId: string, onEvent: (msg: ServerMessage) => void) => {
76
+ onEvent({ type: 'assistant_text_delta', text: 'Hello!' });
77
+ onEvent({ type: 'message_complete', sessionId: 'test-session' });
78
+ processing = false;
79
+ },
80
+ handleConfirmationResponse: () => {},
81
+ handleSecretResponse: () => {},
82
+ } as unknown as Session;
83
+ }
84
+
85
+ /** Session that hangs forever in the agent loop (simulates a busy session). */
86
+ function makeHangingSession(): Session {
87
+ let processing = false;
88
+ const enqueuedMessages: Array<{ content: string; onEvent: (msg: ServerMessage) => void; requestId: string }> = [];
89
+ return {
90
+ isProcessing: () => processing,
91
+ persistUserMessage: (_content: string, _attachments: unknown[], requestId?: string) => {
92
+ processing = true;
93
+ return requestId ?? 'msg-1';
94
+ },
95
+ memoryPolicy: { scopeId: 'default', includeDefaultFallback: false, strictSideEffects: false },
96
+ setChannelCapabilities: () => {},
97
+ setAssistantId: () => {},
98
+ setGuardianContext: () => {},
99
+ setCommandIntent: () => {},
100
+ updateClient: () => {},
101
+ enqueueMessage: (content: string, _attachments: unknown[], onEvent: (msg: ServerMessage) => void, requestId: string) => {
102
+ enqueuedMessages.push({ content, onEvent, requestId });
103
+ return { queued: true, requestId };
104
+ },
105
+ runAgentLoop: async () => {
106
+ // Hang forever
107
+ await new Promise<void>(() => {});
108
+ },
109
+ handleConfirmationResponse: () => {},
110
+ handleSecretResponse: () => {},
111
+ _enqueuedMessages: enqueuedMessages,
112
+ } as unknown as Session;
113
+ }
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Tests
117
+ // ---------------------------------------------------------------------------
118
+
119
+ const TEST_TOKEN = 'test-bearer-token-send';
120
+ const AUTH_HEADERS = { Authorization: `Bearer ${TEST_TOKEN}` };
121
+
122
+ describe('POST /v1/messages — queue-if-busy and hub publishing', () => {
123
+ let server: RuntimeHttpServer;
124
+ let port: number;
125
+ let eventHub: AssistantEventHub;
126
+
127
+ beforeEach(() => {
128
+ const db = getDb();
129
+ db.run('DELETE FROM messages');
130
+ db.run('DELETE FROM conversations');
131
+ db.run('DELETE FROM conversation_keys');
132
+ eventHub = new AssistantEventHub();
133
+ });
134
+
135
+ afterAll(() => {
136
+ resetDb();
137
+ try { rmSync(testDir, { recursive: true, force: true }); } catch { /* best effort */ }
138
+ });
139
+
140
+ async function startServer(sessionFactory: () => Session): Promise<void> {
141
+ port = 19000 + Math.floor(Math.random() * 1000);
142
+ server = new RuntimeHttpServer({
143
+ port,
144
+ bearerToken: TEST_TOKEN,
145
+ sendMessageDeps: {
146
+ getOrCreateSession: async () => sessionFactory(),
147
+ assistantEventHub: eventHub,
148
+ resolveAttachments: () => [],
149
+ },
150
+ });
151
+ await server.start();
152
+ }
153
+
154
+ async function stopServer(): Promise<void> {
155
+ await server?.stop();
156
+ }
157
+
158
+ function messagesUrl(): string {
159
+ return `http://127.0.0.1:${port}/v1/messages`;
160
+ }
161
+
162
+ // ── Idle session: immediate processing ──────────────────────────────
163
+
164
+ test('returns 202 with accepted: true and messageId when session is idle', async () => {
165
+ await startServer(() => makeCompletingSession());
166
+
167
+ const res = await fetch(messagesUrl(), {
168
+ method: 'POST',
169
+ headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
170
+ body: JSON.stringify({ conversationKey: 'conv-idle', content: 'Hello', sourceChannel: 'macos' }),
171
+ });
172
+ const body = await res.json() as { accepted: boolean; messageId: string };
173
+
174
+ expect(res.status).toBe(202);
175
+ expect(body.accepted).toBe(true);
176
+ expect(body.messageId).toBeDefined();
177
+
178
+ await stopServer();
179
+ });
180
+
181
+ test('publishes events to assistantEventHub when session is idle', async () => {
182
+ const publishedEvents: AssistantEvent[] = [];
183
+
184
+ await startServer(() => makeCompletingSession());
185
+
186
+ eventHub.subscribe(
187
+ { assistantId: 'self' },
188
+ (event) => { publishedEvents.push(event); },
189
+ );
190
+
191
+ const res = await fetch(messagesUrl(), {
192
+ method: 'POST',
193
+ headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
194
+ body: JSON.stringify({ conversationKey: 'conv-hub', content: 'Hello hub', sourceChannel: 'macos' }),
195
+ });
196
+ expect(res.status).toBe(202);
197
+
198
+ // Wait for the async agent loop to complete and events to be published
199
+ await new Promise((r) => setTimeout(r, 100));
200
+
201
+ // Should have received assistant_text_delta and message_complete
202
+ const types = publishedEvents.map((e) => e.message.type);
203
+ expect(types).toContain('assistant_text_delta');
204
+ expect(types).toContain('message_complete');
205
+
206
+ await stopServer();
207
+ });
208
+
209
+ // ── Busy session: queue-if-busy ─────────────────────────────────────
210
+
211
+ test('returns 202 with queued: true when session is busy (not 409)', async () => {
212
+ const session = makeHangingSession();
213
+ await startServer(() => session);
214
+
215
+ // First message starts the agent loop and makes the session busy
216
+ const res1 = await fetch(messagesUrl(), {
217
+ method: 'POST',
218
+ headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
219
+ body: JSON.stringify({ conversationKey: 'conv-busy', content: 'First', sourceChannel: 'macos' }),
220
+ });
221
+ expect(res1.status).toBe(202);
222
+ const body1 = await res1.json() as { accepted: boolean; messageId: string };
223
+ expect(body1.accepted).toBe(true);
224
+ expect(body1.messageId).toBeDefined();
225
+
226
+ // Wait for the agent loop to start
227
+ await new Promise((r) => setTimeout(r, 30));
228
+
229
+ // Second message should be queued, not rejected
230
+ const res2 = await fetch(messagesUrl(), {
231
+ method: 'POST',
232
+ headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
233
+ body: JSON.stringify({ conversationKey: 'conv-busy', content: 'Second', sourceChannel: 'macos' }),
234
+ });
235
+ const body2 = await res2.json() as { accepted: boolean; queued: boolean };
236
+
237
+ expect(res2.status).toBe(202);
238
+ expect(body2.accepted).toBe(true);
239
+ expect(body2.queued).toBe(true);
240
+
241
+ await stopServer();
242
+ });
243
+
244
+ // ── Validation ──────────────────────────────────────────────────────
245
+
246
+ test('returns 400 when sourceChannel is missing', async () => {
247
+ await startServer(() => makeCompletingSession());
248
+
249
+ const res = await fetch(messagesUrl(), {
250
+ method: 'POST',
251
+ headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
252
+ body: JSON.stringify({ conversationKey: 'conv-val', content: 'Hello' }),
253
+ });
254
+ expect(res.status).toBe(400);
255
+
256
+ await stopServer();
257
+ });
258
+
259
+ test('returns 400 when content is empty', async () => {
260
+ await startServer(() => makeCompletingSession());
261
+
262
+ const res = await fetch(messagesUrl(), {
263
+ method: 'POST',
264
+ headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
265
+ body: JSON.stringify({ conversationKey: 'conv-empty', content: '', sourceChannel: 'macos' }),
266
+ });
267
+ expect(res.status).toBe(400);
268
+
269
+ await stopServer();
270
+ });
271
+
272
+ test('returns 400 when conversationKey is missing', async () => {
273
+ await startServer(() => makeCompletingSession());
274
+
275
+ const res = await fetch(messagesUrl(), {
276
+ method: 'POST',
277
+ headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
278
+ body: JSON.stringify({ content: 'Hello', sourceChannel: 'macos' }),
279
+ });
280
+ expect(res.status).toBe(400);
281
+
282
+ await stopServer();
283
+ });
284
+ });
@@ -194,9 +194,9 @@ mock.module('../calls/call-state.js', () => ({
194
194
  registerCallCompletionNotifier: () => {},
195
195
  unregisterCallCompletionNotifier: () => {},
196
196
  fireCallCompletionNotifier: () => {},
197
- registerCallOrchestrator: () => {},
198
- unregisterCallOrchestrator: () => {},
199
- getCallOrchestrator: () => undefined,
197
+ registerCallController: () => {},
198
+ unregisterCallController: () => {},
199
+ getCallController: () => undefined,
200
200
  }));
201
201
 
202
202
  mock.module('../calls/call-store.js', () => ({
@@ -397,8 +397,8 @@ describe('SubagentManager sendMessage validation', () => {
397
397
  const subagentId = 'sub-1';
398
398
  injectFakeSubagent(manager, subagentId, makeState(subagentId));
399
399
 
400
- expect(manager.sendMessage(subagentId, '')).toBe(false);
401
- expect(manager.sendMessage(subagentId, ' ')).toBe(false);
402
- expect(manager.sendMessage(subagentId, '\n\t')).toBe(false);
400
+ expect(manager.sendMessage(subagentId, '')).toBe('empty');
401
+ expect(manager.sendMessage(subagentId, ' ')).toBe('empty');
402
+ expect(manager.sendMessage(subagentId, '\n\t')).toBe('empty');
403
403
  });
404
404
  });