vellum 0.2.0 → 0.2.1

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 (80) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +28 -0
  3. package/src/__tests__/app-bundler.test.ts +12 -33
  4. package/src/__tests__/browser-skill-endstate.test.ts +1 -5
  5. package/src/__tests__/call-orchestrator.test.ts +328 -0
  6. package/src/__tests__/call-state.test.ts +133 -0
  7. package/src/__tests__/call-store.test.ts +476 -0
  8. package/src/__tests__/commit-message-enrichment-service.test.ts +409 -0
  9. package/src/__tests__/config-schema.test.ts +49 -0
  10. package/src/__tests__/doordash-session.test.ts +9 -0
  11. package/src/__tests__/ipc-snapshot.test.ts +34 -0
  12. package/src/__tests__/registry.test.ts +13 -8
  13. package/src/__tests__/run-orchestrator-assistant-events.test.ts +218 -0
  14. package/src/__tests__/run-orchestrator.test.ts +3 -3
  15. package/src/__tests__/runtime-attachment-metadata.test.ts +17 -19
  16. package/src/__tests__/runtime-runs-http.test.ts +1 -19
  17. package/src/__tests__/runtime-runs.test.ts +7 -7
  18. package/src/__tests__/session-queue.test.ts +50 -0
  19. package/src/__tests__/turn-commit.test.ts +56 -0
  20. package/src/__tests__/workspace-git-service.test.ts +217 -0
  21. package/src/__tests__/workspace-heartbeat-service.test.ts +129 -0
  22. package/src/bundler/app-bundler.ts +29 -12
  23. package/src/calls/call-constants.ts +10 -0
  24. package/src/calls/call-orchestrator.ts +364 -0
  25. package/src/calls/call-state.ts +64 -0
  26. package/src/calls/call-store.ts +229 -0
  27. package/src/calls/relay-server.ts +298 -0
  28. package/src/calls/twilio-config.ts +34 -0
  29. package/src/calls/twilio-provider.ts +169 -0
  30. package/src/calls/twilio-routes.ts +236 -0
  31. package/src/calls/types.ts +37 -0
  32. package/src/calls/voice-provider.ts +14 -0
  33. package/src/cli/doordash.ts +5 -24
  34. package/src/config/bundled-skills/doordash/SKILL.md +104 -0
  35. package/src/config/bundled-skills/image-studio/TOOLS.json +2 -2
  36. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +1 -1
  37. package/src/config/defaults.ts +11 -0
  38. package/src/config/schema.ts +57 -0
  39. package/src/config/system-prompt.ts +50 -1
  40. package/src/config/types.ts +1 -0
  41. package/src/daemon/handlers/config.ts +30 -0
  42. package/src/daemon/handlers/index.ts +6 -0
  43. package/src/daemon/handlers/work-items.ts +142 -2
  44. package/src/daemon/ipc-contract-inventory.json +12 -0
  45. package/src/daemon/ipc-contract.ts +52 -0
  46. package/src/daemon/lifecycle.ts +27 -5
  47. package/src/daemon/server.ts +10 -12
  48. package/src/daemon/session-tool-setup.ts +6 -0
  49. package/src/daemon/session.ts +40 -1
  50. package/src/index.ts +2 -0
  51. package/src/media/gemini-image-service.ts +1 -1
  52. package/src/memory/db.ts +266 -0
  53. package/src/memory/schema.ts +42 -0
  54. package/src/runtime/http-server.ts +189 -25
  55. package/src/runtime/http-types.ts +0 -2
  56. package/src/runtime/routes/attachment-routes.ts +6 -6
  57. package/src/runtime/routes/channel-routes.ts +16 -18
  58. package/src/runtime/routes/conversation-routes.ts +5 -9
  59. package/src/runtime/routes/run-routes.ts +4 -8
  60. package/src/runtime/run-orchestrator.ts +32 -5
  61. package/src/tools/calls/call-end.ts +117 -0
  62. package/src/tools/calls/call-start.ts +134 -0
  63. package/src/tools/calls/call-status.ts +97 -0
  64. package/src/tools/credentials/vault.ts +1 -1
  65. package/src/tools/registry.ts +2 -4
  66. package/src/tools/tasks/index.ts +2 -0
  67. package/src/tools/tasks/task-delete.ts +49 -8
  68. package/src/tools/tasks/task-run.ts +9 -1
  69. package/src/tools/tasks/work-item-enqueue.ts +93 -3
  70. package/src/tools/tasks/work-item-list.ts +10 -25
  71. package/src/tools/tasks/work-item-remove.ts +112 -0
  72. package/src/tools/tasks/work-item-update.ts +186 -0
  73. package/src/tools/tool-manifest.ts +39 -31
  74. package/src/tools/ui-surface/definitions.ts +3 -0
  75. package/src/work-items/work-item-store.ts +209 -0
  76. package/src/workspace/commit-message-enrichment-service.ts +260 -0
  77. package/src/workspace/commit-message-provider.ts +95 -0
  78. package/src/workspace/git-service.ts +187 -32
  79. package/src/workspace/heartbeat-service.ts +70 -13
  80. package/src/workspace/turn-commit.ts +39 -49
@@ -0,0 +1,476 @@
1
+ import { describe, test, expect, beforeEach, afterAll, mock } from 'bun:test';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+
6
+ const testDir = mkdtempSync(join(tmpdir(), 'call-store-test-'));
7
+
8
+ mock.module('../util/platform.js', () => ({
9
+ getDataDir: () => testDir,
10
+ isMacOS: () => process.platform === 'darwin',
11
+ isLinux: () => process.platform === 'linux',
12
+ isWindows: () => process.platform === 'win32',
13
+ getSocketPath: () => join(testDir, 'test.sock'),
14
+ getPidPath: () => join(testDir, 'test.pid'),
15
+ getDbPath: () => join(testDir, 'test.db'),
16
+ getLogPath: () => join(testDir, 'test.log'),
17
+ ensureDataDir: () => {},
18
+ }));
19
+
20
+ mock.module('../util/logger.js', () => ({
21
+ getLogger: () => new Proxy({} as Record<string, unknown>, {
22
+ get: () => () => {},
23
+ }),
24
+ }));
25
+
26
+ import { initializeDb, getDb } from '../memory/db.js';
27
+ import { conversations } from '../memory/schema.js';
28
+ import {
29
+ createCallSession,
30
+ getCallSession,
31
+ getCallSessionByCallSid,
32
+ getActiveCallSessionForConversation,
33
+ updateCallSession,
34
+ recordCallEvent,
35
+ getCallEvents,
36
+ createPendingQuestion,
37
+ getPendingQuestion,
38
+ answerPendingQuestion,
39
+ expirePendingQuestions,
40
+ } from '../calls/call-store.js';
41
+
42
+ initializeDb();
43
+
44
+ afterAll(() => {
45
+ try { rmSync(testDir, { recursive: true }); } catch { /* best effort */ }
46
+ });
47
+
48
+ /** Ensure a conversation row exists for the given ID so FK constraints pass. */
49
+ let ensuredConvIds = new Set<string>();
50
+ function ensureConversation(id: string): void {
51
+ if (ensuredConvIds.has(id)) return;
52
+ const db = getDb();
53
+ const now = Date.now();
54
+ db.insert(conversations).values({
55
+ id,
56
+ title: `Test conversation ${id}`,
57
+ createdAt: now,
58
+ updatedAt: now,
59
+ }).run();
60
+ ensuredConvIds.add(id);
61
+ }
62
+
63
+ function resetTables() {
64
+ const db = getDb();
65
+ db.run('DELETE FROM call_pending_questions');
66
+ db.run('DELETE FROM call_events');
67
+ db.run('DELETE FROM call_sessions');
68
+ db.run('DELETE FROM conversations');
69
+ ensuredConvIds = new Set();
70
+ }
71
+
72
+ /** Wrapper that ensures the FK conversation row exists before creating a session. */
73
+ function createTestCallSession(opts: Parameters<typeof createCallSession>[0]) {
74
+ ensureConversation(opts.conversationId);
75
+ return createCallSession(opts);
76
+ }
77
+
78
+ describe('call-store', () => {
79
+ beforeEach(() => {
80
+ resetTables();
81
+ });
82
+
83
+ // ── Call Sessions ─────────────────────────────────────────────────
84
+
85
+ test('createCallSession creates a session with correct defaults', () => {
86
+ const session = createTestCallSession({
87
+ conversationId: 'conv-1',
88
+ provider: 'twilio',
89
+ fromNumber: '+15551234567',
90
+ toNumber: '+15559876543',
91
+ task: 'Book appointment',
92
+ });
93
+
94
+ expect(session.id).toBeDefined();
95
+ expect(session.conversationId).toBe('conv-1');
96
+ expect(session.provider).toBe('twilio');
97
+ expect(session.fromNumber).toBe('+15551234567');
98
+ expect(session.toNumber).toBe('+15559876543');
99
+ expect(session.task).toBe('Book appointment');
100
+ expect(session.status).toBe('initiated');
101
+ expect(session.providerCallSid).toBeNull();
102
+ expect(session.startedAt).toBeNull();
103
+ expect(session.endedAt).toBeNull();
104
+ expect(session.lastError).toBeNull();
105
+ expect(typeof session.createdAt).toBe('number');
106
+ expect(typeof session.updatedAt).toBe('number');
107
+ });
108
+
109
+ test('createCallSession defaults task to null when not provided', () => {
110
+ const session = createTestCallSession({
111
+ conversationId: 'conv-2',
112
+ provider: 'twilio',
113
+ fromNumber: '+15551111111',
114
+ toNumber: '+15552222222',
115
+ });
116
+
117
+ expect(session.task).toBeNull();
118
+ });
119
+
120
+ test('getCallSession retrieves by ID', () => {
121
+ const created = createTestCallSession({
122
+ conversationId: 'conv-3',
123
+ provider: 'twilio',
124
+ fromNumber: '+15551111111',
125
+ toNumber: '+15552222222',
126
+ });
127
+
128
+ const retrieved = getCallSession(created.id);
129
+ expect(retrieved).not.toBeNull();
130
+ expect(retrieved!.id).toBe(created.id);
131
+ expect(retrieved!.conversationId).toBe('conv-3');
132
+ });
133
+
134
+ test('getCallSession returns null for missing ID', () => {
135
+ const result = getCallSession('nonexistent-id');
136
+ expect(result).toBeNull();
137
+ });
138
+
139
+ test('getCallSessionByCallSid looks up by provider call SID', () => {
140
+ const session = createTestCallSession({
141
+ conversationId: 'conv-4',
142
+ provider: 'twilio',
143
+ fromNumber: '+15551111111',
144
+ toNumber: '+15552222222',
145
+ });
146
+
147
+ updateCallSession(session.id, { providerCallSid: 'CA_test_sid_123' });
148
+
149
+ const found = getCallSessionByCallSid('CA_test_sid_123');
150
+ expect(found).not.toBeNull();
151
+ expect(found!.id).toBe(session.id);
152
+ expect(found!.providerCallSid).toBe('CA_test_sid_123');
153
+ });
154
+
155
+ test('getCallSessionByCallSid returns null for unknown SID', () => {
156
+ const result = getCallSessionByCallSid('CA_unknown');
157
+ expect(result).toBeNull();
158
+ });
159
+
160
+ test('getActiveCallSessionForConversation finds non-terminal sessions', () => {
161
+ const session = createTestCallSession({
162
+ conversationId: 'conv-5',
163
+ provider: 'twilio',
164
+ fromNumber: '+15551111111',
165
+ toNumber: '+15552222222',
166
+ });
167
+
168
+ const active = getActiveCallSessionForConversation('conv-5');
169
+ expect(active).not.toBeNull();
170
+ expect(active!.id).toBe(session.id);
171
+ });
172
+
173
+ test('getActiveCallSessionForConversation returns null when all sessions are completed', () => {
174
+ const session = createTestCallSession({
175
+ conversationId: 'conv-6',
176
+ provider: 'twilio',
177
+ fromNumber: '+15551111111',
178
+ toNumber: '+15552222222',
179
+ });
180
+
181
+ updateCallSession(session.id, { status: 'completed' });
182
+
183
+ const active = getActiveCallSessionForConversation('conv-6');
184
+ expect(active).toBeNull();
185
+ });
186
+
187
+ test('getActiveCallSessionForConversation returns null when all sessions are failed', () => {
188
+ const session = createTestCallSession({
189
+ conversationId: 'conv-7',
190
+ provider: 'twilio',
191
+ fromNumber: '+15551111111',
192
+ toNumber: '+15552222222',
193
+ });
194
+
195
+ updateCallSession(session.id, { status: 'failed' });
196
+
197
+ const active = getActiveCallSessionForConversation('conv-7');
198
+ expect(active).toBeNull();
199
+ });
200
+
201
+ test('getActiveCallSessionForConversation returns most recent active session', () => {
202
+ // Create two sessions for the same conversation
203
+ const older = createTestCallSession({
204
+ conversationId: 'conv-8',
205
+ provider: 'twilio',
206
+ fromNumber: '+15551111111',
207
+ toNumber: '+15552222222',
208
+ });
209
+ // Mark older as completed
210
+ updateCallSession(older.id, { status: 'completed' });
211
+
212
+ const newer = createTestCallSession({
213
+ conversationId: 'conv-8',
214
+ provider: 'twilio',
215
+ fromNumber: '+15551111111',
216
+ toNumber: '+15553333333',
217
+ });
218
+
219
+ const active = getActiveCallSessionForConversation('conv-8');
220
+ expect(active).not.toBeNull();
221
+ expect(active!.id).toBe(newer.id);
222
+ });
223
+
224
+ test('updateCallSession updates status, providerCallSid, and timestamps', () => {
225
+ const session = createTestCallSession({
226
+ conversationId: 'conv-9',
227
+ provider: 'twilio',
228
+ fromNumber: '+15551111111',
229
+ toNumber: '+15552222222',
230
+ });
231
+
232
+ const now = Date.now();
233
+ updateCallSession(session.id, {
234
+ status: 'in_progress',
235
+ providerCallSid: 'CA_updated_sid',
236
+ startedAt: now,
237
+ });
238
+
239
+ const updated = getCallSession(session.id);
240
+ expect(updated).not.toBeNull();
241
+ expect(updated!.status).toBe('in_progress');
242
+ expect(updated!.providerCallSid).toBe('CA_updated_sid');
243
+ expect(updated!.startedAt).toBe(now);
244
+ // updatedAt should be updated
245
+ expect(updated!.updatedAt).toBeGreaterThanOrEqual(session.updatedAt);
246
+ });
247
+
248
+ test('updateCallSession sets endedAt and lastError', () => {
249
+ const session = createTestCallSession({
250
+ conversationId: 'conv-10',
251
+ provider: 'twilio',
252
+ fromNumber: '+15551111111',
253
+ toNumber: '+15552222222',
254
+ });
255
+
256
+ const endTime = Date.now();
257
+ updateCallSession(session.id, {
258
+ status: 'failed',
259
+ endedAt: endTime,
260
+ lastError: 'Network timeout',
261
+ });
262
+
263
+ const updated = getCallSession(session.id);
264
+ expect(updated!.status).toBe('failed');
265
+ expect(updated!.endedAt).toBe(endTime);
266
+ expect(updated!.lastError).toBe('Network timeout');
267
+ });
268
+
269
+ // ── Call Events ───────────────────────────────────────────────────
270
+
271
+ test('recordCallEvent creates events with correct fields', () => {
272
+ const session = createTestCallSession({
273
+ conversationId: 'conv-11',
274
+ provider: 'twilio',
275
+ fromNumber: '+15551111111',
276
+ toNumber: '+15552222222',
277
+ });
278
+
279
+ const event = recordCallEvent(session.id, 'call_started', { twilioStatus: 'initiated' });
280
+
281
+ expect(event.id).toBeDefined();
282
+ expect(event.callSessionId).toBe(session.id);
283
+ expect(event.eventType).toBe('call_started');
284
+ expect(typeof event.createdAt).toBe('number');
285
+ });
286
+
287
+ test('recordCallEvent stores JSON payload', () => {
288
+ const session = createTestCallSession({
289
+ conversationId: 'conv-12',
290
+ provider: 'twilio',
291
+ fromNumber: '+15551111111',
292
+ toNumber: '+15552222222',
293
+ });
294
+
295
+ const payload = { text: 'Hello, how are you?', lang: 'en-US' };
296
+ const event = recordCallEvent(session.id, 'caller_spoke', payload);
297
+
298
+ const parsed = JSON.parse(event.payloadJson);
299
+ expect(parsed.text).toBe('Hello, how are you?');
300
+ expect(parsed.lang).toBe('en-US');
301
+ });
302
+
303
+ test('recordCallEvent defaults payload to empty JSON object', () => {
304
+ const session = createTestCallSession({
305
+ conversationId: 'conv-13',
306
+ provider: 'twilio',
307
+ fromNumber: '+15551111111',
308
+ toNumber: '+15552222222',
309
+ });
310
+
311
+ const event = recordCallEvent(session.id, 'call_connected');
312
+
313
+ expect(event.payloadJson).toBe('{}');
314
+ });
315
+
316
+ test('getCallEvents retrieves events in creation order', () => {
317
+ const session = createTestCallSession({
318
+ conversationId: 'conv-14',
319
+ provider: 'twilio',
320
+ fromNumber: '+15551111111',
321
+ toNumber: '+15552222222',
322
+ });
323
+
324
+ recordCallEvent(session.id, 'call_started');
325
+ recordCallEvent(session.id, 'call_connected');
326
+ recordCallEvent(session.id, 'caller_spoke', { transcript: 'Hi' });
327
+
328
+ const events = getCallEvents(session.id);
329
+ expect(events).toHaveLength(3);
330
+ expect(events[0].eventType).toBe('call_started');
331
+ expect(events[1].eventType).toBe('call_connected');
332
+ expect(events[2].eventType).toBe('caller_spoke');
333
+ // Should be in ascending creation order
334
+ expect(events[0].createdAt).toBeLessThanOrEqual(events[1].createdAt);
335
+ expect(events[1].createdAt).toBeLessThanOrEqual(events[2].createdAt);
336
+ });
337
+
338
+ test('getCallEvents returns empty array for session with no events', () => {
339
+ const session = createTestCallSession({
340
+ conversationId: 'conv-15',
341
+ provider: 'twilio',
342
+ fromNumber: '+15551111111',
343
+ toNumber: '+15552222222',
344
+ });
345
+
346
+ const events = getCallEvents(session.id);
347
+ expect(events).toHaveLength(0);
348
+ });
349
+
350
+ // ── Pending Questions ─────────────────────────────────────────────
351
+
352
+ test('createPendingQuestion creates with status pending', () => {
353
+ const session = createTestCallSession({
354
+ conversationId: 'conv-16',
355
+ provider: 'twilio',
356
+ fromNumber: '+15551111111',
357
+ toNumber: '+15552222222',
358
+ });
359
+
360
+ const question = createPendingQuestion(session.id, 'What is your preferred date?');
361
+
362
+ expect(question.id).toBeDefined();
363
+ expect(question.callSessionId).toBe(session.id);
364
+ expect(question.questionText).toBe('What is your preferred date?');
365
+ expect(question.status).toBe('pending');
366
+ expect(typeof question.askedAt).toBe('number');
367
+ expect(question.answeredAt).toBeNull();
368
+ expect(question.answerText).toBeNull();
369
+ });
370
+
371
+ test('getPendingQuestion finds pending question for session', () => {
372
+ const session = createTestCallSession({
373
+ conversationId: 'conv-17',
374
+ provider: 'twilio',
375
+ fromNumber: '+15551111111',
376
+ toNumber: '+15552222222',
377
+ });
378
+
379
+ const created = createPendingQuestion(session.id, 'What is your name?');
380
+
381
+ const found = getPendingQuestion(session.id);
382
+ expect(found).not.toBeNull();
383
+ expect(found!.id).toBe(created.id);
384
+ expect(found!.questionText).toBe('What is your name?');
385
+ expect(found!.status).toBe('pending');
386
+ });
387
+
388
+ test('getPendingQuestion returns null when no pending questions', () => {
389
+ const session = createTestCallSession({
390
+ conversationId: 'conv-18',
391
+ provider: 'twilio',
392
+ fromNumber: '+15551111111',
393
+ toNumber: '+15552222222',
394
+ });
395
+
396
+ const found = getPendingQuestion(session.id);
397
+ expect(found).toBeNull();
398
+ });
399
+
400
+ test('answerPendingQuestion updates status to answered', () => {
401
+ const session = createTestCallSession({
402
+ conversationId: 'conv-19',
403
+ provider: 'twilio',
404
+ fromNumber: '+15551111111',
405
+ toNumber: '+15552222222',
406
+ });
407
+
408
+ const question = createPendingQuestion(session.id, 'What color?');
409
+ answerPendingQuestion(question.id, 'Blue');
410
+
411
+ // Should no longer appear as pending
412
+ const pending = getPendingQuestion(session.id);
413
+ expect(pending).toBeNull();
414
+
415
+ // Verify the record was updated by querying directly
416
+ const db = getDb();
417
+ const raw = (db as unknown as { $client: import('bun:sqlite').Database }).$client;
418
+ const updated = raw.query('SELECT * FROM call_pending_questions WHERE id = ?').get(question.id) as {
419
+ status: string;
420
+ answer_text: string;
421
+ answered_at: number;
422
+ };
423
+ expect(updated.status).toBe('answered');
424
+ expect(updated.answer_text).toBe('Blue');
425
+ expect(typeof updated.answered_at).toBe('number');
426
+ });
427
+
428
+ test('expirePendingQuestions marks all pending questions as expired', () => {
429
+ const session = createTestCallSession({
430
+ conversationId: 'conv-20',
431
+ provider: 'twilio',
432
+ fromNumber: '+15551111111',
433
+ toNumber: '+15552222222',
434
+ });
435
+
436
+ createPendingQuestion(session.id, 'Question 1');
437
+ createPendingQuestion(session.id, 'Question 2');
438
+
439
+ expirePendingQuestions(session.id);
440
+
441
+ // No more pending questions
442
+ const pending = getPendingQuestion(session.id);
443
+ expect(pending).toBeNull();
444
+
445
+ // Verify both were expired
446
+ const raw = (getDb() as unknown as { $client: import('bun:sqlite').Database }).$client;
447
+ const rows = raw.query('SELECT status FROM call_pending_questions WHERE call_session_id = ?').all(session.id) as Array<{ status: string }>;
448
+ expect(rows).toHaveLength(2);
449
+ for (const row of rows) {
450
+ expect(row.status).toBe('expired');
451
+ }
452
+ });
453
+
454
+ test('expirePendingQuestions does not affect already-answered questions', () => {
455
+ const session = createTestCallSession({
456
+ conversationId: 'conv-21',
457
+ provider: 'twilio',
458
+ fromNumber: '+15551111111',
459
+ toNumber: '+15552222222',
460
+ });
461
+
462
+ const q1 = createPendingQuestion(session.id, 'Question 1');
463
+ createPendingQuestion(session.id, 'Question 2');
464
+
465
+ // Answer q1 first
466
+ answerPendingQuestion(q1.id, 'Answer 1');
467
+
468
+ // Then expire all pending
469
+ expirePendingQuestions(session.id);
470
+
471
+ // q1 should still be answered, not expired
472
+ const raw = (getDb() as unknown as { $client: import('bun:sqlite').Database }).$client;
473
+ const q1Row = raw.query('SELECT status FROM call_pending_questions WHERE id = ?').get(q1.id) as { status: string };
474
+ expect(q1Row.status).toBe('answered');
475
+ });
476
+ });