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,409 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
2
+ import { mkdirSync, rmSync, writeFileSync, existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { execFileSync } from 'node:child_process';
6
+ import {
7
+ CommitEnrichmentService,
8
+ _resetEnrichmentService,
9
+ } from '../workspace/commit-message-enrichment-service.js';
10
+ import { WorkspaceGitService, _resetGitServiceRegistry } from '../workspace/git-service.js';
11
+ import type { CommitContext } from '../workspace/commit-message-provider.js';
12
+
13
+ describe('CommitEnrichmentService', () => {
14
+ let testDir: string;
15
+ let gitService: WorkspaceGitService;
16
+
17
+ beforeEach(async () => {
18
+ testDir = join(tmpdir(), `vellum-enrichment-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
19
+ mkdirSync(testDir, { recursive: true });
20
+ _resetGitServiceRegistry();
21
+ _resetEnrichmentService();
22
+
23
+ gitService = new WorkspaceGitService(testDir);
24
+ await gitService.ensureInitialized();
25
+ });
26
+
27
+ afterEach(async () => {
28
+ if (existsSync(testDir)) {
29
+ rmSync(testDir, { recursive: true, force: true });
30
+ }
31
+ });
32
+
33
+ function makeContext(overrides?: Partial<CommitContext>): CommitContext {
34
+ return {
35
+ workspaceDir: testDir,
36
+ trigger: 'turn',
37
+ sessionId: 'sess_test',
38
+ turnNumber: 1,
39
+ changedFiles: ['file.txt'],
40
+ timestampMs: Date.now(),
41
+ ...overrides,
42
+ };
43
+ }
44
+
45
+ async function createCommit(): Promise<string> {
46
+ writeFileSync(join(testDir, `file-${Date.now()}.txt`), 'content');
47
+ await gitService.commitChanges('test commit');
48
+ return await gitService.getHeadHash();
49
+ }
50
+
51
+ test('enqueue and execute writes git note on success', async () => {
52
+ const commitHash = await createCommit();
53
+ const service = new CommitEnrichmentService({
54
+ maxQueueSize: 10,
55
+ maxConcurrency: 1,
56
+ jobTimeoutMs: 5000,
57
+ maxRetries: 0,
58
+ });
59
+
60
+ service.enqueue({
61
+ workspaceDir: testDir,
62
+ commitHash,
63
+ context: makeContext(),
64
+ gitService,
65
+ });
66
+
67
+ // Wait for async processing
68
+ await service.shutdown();
69
+
70
+ // Verify git note was written
71
+ const noteContent = execFileSync('git', ['notes', '--ref=vellum', 'show', commitHash], {
72
+ cwd: testDir,
73
+ encoding: 'utf-8',
74
+ });
75
+
76
+ const note = JSON.parse(noteContent);
77
+ expect(note.enriched).toBe(true);
78
+ expect(note.trigger).toBe('turn');
79
+ expect(note.sessionId).toBe('sess_test');
80
+ expect(note.turnNumber).toBe(1);
81
+ expect(note.filesChanged).toBe(1);
82
+ expect(service._getSucceededCount()).toBe(1);
83
+ });
84
+
85
+ test('queue overflow drops oldest job', async () => {
86
+ const service = new CommitEnrichmentService({
87
+ maxQueueSize: 2,
88
+ maxConcurrency: 1,
89
+ jobTimeoutMs: 30000,
90
+ maxRetries: 0,
91
+ });
92
+
93
+ const hash1 = await createCommit();
94
+ const hash2 = await createCommit();
95
+ const hash3 = await createCommit();
96
+
97
+ // Enqueue 3 jobs — hash1 starts immediately (active worker),
98
+ // hash2 goes to queue (size=1), hash3 goes to queue (size=2), no overflow drop.
99
+ service.enqueue({ workspaceDir: testDir, commitHash: hash1, context: makeContext(), gitService });
100
+ service.enqueue({ workspaceDir: testDir, commitHash: hash2, context: makeContext(), gitService });
101
+ service.enqueue({ workspaceDir: testDir, commitHash: hash3, context: makeContext(), gitService });
102
+
103
+ // No overflow drops — queue size 2 can hold 2 pending while 1 is active
104
+ expect(service._getDroppedCount()).toBe(0);
105
+ expect(service._getQueueSize()).toBe(2);
106
+
107
+ // Shutdown discards the 2 pending jobs
108
+ await service.shutdown();
109
+ expect(service._getDroppedCount()).toBe(2);
110
+ expect(service._getSucceededCount()).toBe(1);
111
+ });
112
+
113
+ test('queue overflow actually drops when truly full', async () => {
114
+ // Create a service where the worker is slow
115
+ const service = new CommitEnrichmentService({
116
+ maxQueueSize: 1,
117
+ maxConcurrency: 1,
118
+ jobTimeoutMs: 30000,
119
+ maxRetries: 0,
120
+ });
121
+
122
+ const hash1 = await createCommit();
123
+ const hash2 = await createCommit();
124
+ const hash3 = await createCommit();
125
+
126
+ // hash1 starts processing immediately (active worker = 1, queue empty)
127
+ // hash2 goes to queue (queue size = 1)
128
+ // hash3 tries to go to queue but it's full → drops hash2, adds hash3
129
+ service.enqueue({ workspaceDir: testDir, commitHash: hash1, context: makeContext(), gitService });
130
+ service.enqueue({ workspaceDir: testDir, commitHash: hash2, context: makeContext(), gitService });
131
+ service.enqueue({ workspaceDir: testDir, commitHash: hash3, context: makeContext(), gitService });
132
+
133
+ expect(service._getDroppedCount()).toBe(1);
134
+
135
+ await service.shutdown();
136
+ });
137
+
138
+ test('fire-and-forget enqueue does not block caller', async () => {
139
+ const commitHash = await createCommit();
140
+ const service = new CommitEnrichmentService({
141
+ maxQueueSize: 10,
142
+ maxConcurrency: 1,
143
+ jobTimeoutMs: 5000,
144
+ maxRetries: 0,
145
+ });
146
+
147
+ const start = Date.now();
148
+ service.enqueue({
149
+ workspaceDir: testDir,
150
+ commitHash,
151
+ context: makeContext(),
152
+ gitService,
153
+ });
154
+ const elapsed = Date.now() - start;
155
+
156
+ // enqueue should return immediately (< 50ms)
157
+ expect(elapsed).toBeLessThan(50);
158
+
159
+ await service.shutdown();
160
+ });
161
+
162
+ test('graceful shutdown drains in-flight and discards pending', async () => {
163
+ const hash1 = await createCommit();
164
+ const hash2 = await createCommit();
165
+
166
+ const service = new CommitEnrichmentService({
167
+ maxQueueSize: 10,
168
+ maxConcurrency: 1,
169
+ jobTimeoutMs: 5000,
170
+ maxRetries: 0,
171
+ });
172
+
173
+ service.enqueue({ workspaceDir: testDir, commitHash: hash1, context: makeContext(), gitService });
174
+ service.enqueue({ workspaceDir: testDir, commitHash: hash2, context: makeContext(), gitService });
175
+
176
+ // Shutdown should complete without hanging
177
+ await service.shutdown();
178
+
179
+ // The first job was in-flight and should complete. The second was pending
180
+ // and should be discarded, counted as dropped.
181
+ expect(service._getSucceededCount()).toBe(1);
182
+ expect(service._getDroppedCount()).toBe(1);
183
+ expect(service._getQueueSize()).toBe(0);
184
+ });
185
+
186
+ test('shutdown discards all pending jobs and counts them as dropped', async () => {
187
+ // Use maxConcurrency 1 so only one job starts processing; the rest stay pending.
188
+ const hashes: string[] = [];
189
+ for (let i = 0; i < 5; i++) {
190
+ hashes.push(await createCommit());
191
+ }
192
+
193
+ const service = new CommitEnrichmentService({
194
+ maxQueueSize: 10,
195
+ maxConcurrency: 1,
196
+ jobTimeoutMs: 5000,
197
+ maxRetries: 0,
198
+ });
199
+
200
+ for (const hash of hashes) {
201
+ service.enqueue({ workspaceDir: testDir, commitHash: hash, context: makeContext(), gitService });
202
+ }
203
+
204
+ // First job is in-flight, remaining 4 are pending
205
+ await service.shutdown();
206
+
207
+ // In-flight job completes, pending jobs are discarded
208
+ expect(service._getSucceededCount()).toBe(1);
209
+ expect(service._getDroppedCount()).toBe(4);
210
+ });
211
+
212
+ test('shutdown does not cause concurrency spike', async () => {
213
+ const hashes: string[] = [];
214
+ for (let i = 0; i < 3; i++) {
215
+ hashes.push(await createCommit());
216
+ }
217
+
218
+ const service = new CommitEnrichmentService({
219
+ maxQueueSize: 10,
220
+ maxConcurrency: 1,
221
+ jobTimeoutMs: 5000,
222
+ maxRetries: 0,
223
+ });
224
+
225
+ for (const hash of hashes) {
226
+ service.enqueue({ workspaceDir: testDir, commitHash: hash, context: makeContext(), gitService });
227
+ }
228
+
229
+ await service.shutdown();
230
+
231
+ // Active workers should be 0 after shutdown
232
+ expect(service._getActiveWorkers()).toBe(0);
233
+ });
234
+
235
+ test('discards jobs enqueued after shutdown', async () => {
236
+ const commitHash = await createCommit();
237
+ const service = new CommitEnrichmentService({
238
+ maxQueueSize: 10,
239
+ maxConcurrency: 1,
240
+ jobTimeoutMs: 5000,
241
+ maxRetries: 0,
242
+ });
243
+
244
+ await service.shutdown();
245
+
246
+ // Enqueue after shutdown should be silently discarded
247
+ service.enqueue({
248
+ workspaceDir: testDir,
249
+ commitHash,
250
+ context: makeContext(),
251
+ gitService,
252
+ });
253
+
254
+ expect(service._getQueueSize()).toBe(0);
255
+ expect(service._getSucceededCount()).toBe(0);
256
+ });
257
+
258
+ test('multiple successful enrichments write separate git notes', async () => {
259
+ const hash1 = await createCommit();
260
+ const hash2 = await createCommit();
261
+
262
+ const service = new CommitEnrichmentService({
263
+ maxQueueSize: 10,
264
+ maxConcurrency: 1,
265
+ jobTimeoutMs: 5000,
266
+ maxRetries: 0,
267
+ });
268
+
269
+ service.enqueue({
270
+ workspaceDir: testDir,
271
+ commitHash: hash1,
272
+ context: makeContext({ turnNumber: 1 }),
273
+ gitService,
274
+ });
275
+ service.enqueue({
276
+ workspaceDir: testDir,
277
+ commitHash: hash2,
278
+ context: makeContext({ turnNumber: 2 }),
279
+ gitService,
280
+ });
281
+
282
+ // Wait for queue to drain before shutdown (avoids discarding pending jobs)
283
+ while (service._getQueueSize() > 0 || service._getActiveWorkers() > 0) {
284
+ await new Promise(resolve => setTimeout(resolve, 50));
285
+ }
286
+ await service.shutdown();
287
+
288
+ // Both notes should exist
289
+ const note1 = JSON.parse(execFileSync('git', ['notes', '--ref=vellum', 'show', hash1], {
290
+ cwd: testDir, encoding: 'utf-8',
291
+ }));
292
+ const note2 = JSON.parse(execFileSync('git', ['notes', '--ref=vellum', 'show', hash2], {
293
+ cwd: testDir, encoding: 'utf-8',
294
+ }));
295
+
296
+ expect(note1.turnNumber).toBe(1);
297
+ expect(note2.turnNumber).toBe(2);
298
+ expect(service._getSucceededCount()).toBe(2);
299
+ });
300
+
301
+ test('job timeout triggers retry with backoff then fails after max retries', async () => {
302
+ // Use a very short timeout so the real git notes write times out
303
+ const service = new CommitEnrichmentService({
304
+ maxQueueSize: 10,
305
+ maxConcurrency: 1,
306
+ jobTimeoutMs: 1, // 1ms timeout — will always time out
307
+ maxRetries: 2,
308
+ });
309
+
310
+ const commitHash = await createCommit();
311
+ service.enqueue({
312
+ workspaceDir: testDir,
313
+ commitHash,
314
+ context: makeContext(),
315
+ gitService,
316
+ });
317
+
318
+ // Wait for all retries to complete (initial + 2 retries, with backoff)
319
+ // Backoff: 1s after attempt 1, 2s after attempt 2 = ~3s total
320
+ // But since the job itself is very fast to time out, total time is dominated by backoff
321
+ while (service._getActiveWorkers() > 0 || service._getQueueSize() > 0) {
322
+ await new Promise(resolve => setTimeout(resolve, 100));
323
+ }
324
+ await service.shutdown();
325
+
326
+ // After 1 initial attempt + 2 retries (3 total), the job should be counted as failed
327
+ expect(service._getFailedCount()).toBe(1);
328
+ expect(service._getSucceededCount()).toBe(0);
329
+ }, 15000); // Allow up to 15s for backoff delays
330
+
331
+ test('queue overflow drop behavior is deterministic', async () => {
332
+ // With maxQueueSize=2 and maxConcurrency=1:
333
+ // - Job A starts processing immediately (in-flight)
334
+ // - Job B enters queue (size=1)
335
+ // - Job C enters queue (size=2)
336
+ // - Job D overflows: drops oldest (B), adds D → queue has [C, D]
337
+ // - Job E overflows: drops oldest (C), adds E → queue has [D, E]
338
+ const service = new CommitEnrichmentService({
339
+ maxQueueSize: 2,
340
+ maxConcurrency: 1,
341
+ jobTimeoutMs: 30000,
342
+ maxRetries: 0,
343
+ });
344
+
345
+ const hashA = await createCommit();
346
+ const hashB = await createCommit();
347
+ const hashC = await createCommit();
348
+ const hashD = await createCommit();
349
+ const hashE = await createCommit();
350
+
351
+ service.enqueue({ workspaceDir: testDir, commitHash: hashA, context: makeContext({ turnNumber: 1 }), gitService });
352
+ service.enqueue({ workspaceDir: testDir, commitHash: hashB, context: makeContext({ turnNumber: 2 }), gitService });
353
+ service.enqueue({ workspaceDir: testDir, commitHash: hashC, context: makeContext({ turnNumber: 3 }), gitService });
354
+ // No drops yet: A is in-flight, B and C in queue (size=2)
355
+ expect(service._getDroppedCount()).toBe(0);
356
+
357
+ service.enqueue({ workspaceDir: testDir, commitHash: hashD, context: makeContext({ turnNumber: 4 }), gitService });
358
+ // Queue was full (2), so oldest (B) was dropped
359
+ expect(service._getDroppedCount()).toBe(1);
360
+
361
+ service.enqueue({ workspaceDir: testDir, commitHash: hashE, context: makeContext({ turnNumber: 5 }), gitService });
362
+ // Queue was full again (2), so oldest (C) was dropped
363
+ expect(service._getDroppedCount()).toBe(2);
364
+
365
+ // Queue should have exactly 2 items: D and E
366
+ expect(service._getQueueSize()).toBe(2);
367
+
368
+ await service.shutdown();
369
+
370
+ // A was in-flight and completed; D and E were pending and discarded at shutdown
371
+ expect(service._getSucceededCount()).toBe(1);
372
+ // 2 overflow drops + 2 shutdown discards = 4 total
373
+ expect(service._getDroppedCount()).toBe(4);
374
+ });
375
+
376
+ test('enqueue is fire-and-forget and never throws even when called rapidly', async () => {
377
+ const service = new CommitEnrichmentService({
378
+ maxQueueSize: 3,
379
+ maxConcurrency: 1,
380
+ jobTimeoutMs: 5000,
381
+ maxRetries: 0,
382
+ });
383
+
384
+ const hashes: string[] = [];
385
+ for (let i = 0; i < 5; i++) {
386
+ hashes.push(await createCommit());
387
+ }
388
+
389
+ // Rapidly enqueue more jobs than the queue can hold — must never throw
390
+ const fn = () => {
391
+ for (const hash of hashes) {
392
+ service.enqueue({
393
+ workspaceDir: testDir,
394
+ commitHash: hash,
395
+ context: makeContext(),
396
+ gitService,
397
+ });
398
+ }
399
+ };
400
+
401
+ expect(fn).not.toThrow();
402
+
403
+ // Some jobs should have been dropped due to overflow (queue size 3, 1 in-flight)
404
+ // 5 jobs: 1 in-flight + 3 queue + 1 overflow = at least 1 drop
405
+ expect(service._getDroppedCount()).toBeGreaterThanOrEqual(1);
406
+
407
+ await service.shutdown();
408
+ });
409
+ });
@@ -496,6 +496,55 @@ describe('AssistantConfigSchema', () => {
496
496
  expect(msgs.some(m => m.includes('permissions.mode'))).toBe(true);
497
497
  }
498
498
  });
499
+
500
+ test('applies workspaceGit defaults including interactiveGitTimeoutMs', () => {
501
+ const result = AssistantConfigSchema.parse({});
502
+ expect(result.workspaceGit).toEqual({
503
+ turnCommitMaxWaitMs: 4000,
504
+ failureBackoffBaseMs: 2000,
505
+ failureBackoffMaxMs: 60000,
506
+ interactiveGitTimeoutMs: 10000,
507
+ enrichmentQueueSize: 50,
508
+ enrichmentConcurrency: 1,
509
+ enrichmentJobTimeoutMs: 30000,
510
+ enrichmentMaxRetries: 2,
511
+ });
512
+ });
513
+
514
+ test('accepts custom workspaceGit.interactiveGitTimeoutMs', () => {
515
+ const result = AssistantConfigSchema.parse({
516
+ workspaceGit: { interactiveGitTimeoutMs: 5000 },
517
+ });
518
+ expect(result.workspaceGit.interactiveGitTimeoutMs).toBe(5000);
519
+ // Other fields should still get defaults
520
+ expect(result.workspaceGit.turnCommitMaxWaitMs).toBe(4000);
521
+ });
522
+
523
+ test('rejects non-positive workspaceGit.interactiveGitTimeoutMs', () => {
524
+ const zeroResult = AssistantConfigSchema.safeParse({
525
+ workspaceGit: { interactiveGitTimeoutMs: 0 },
526
+ });
527
+ expect(zeroResult.success).toBe(false);
528
+
529
+ const negativeResult = AssistantConfigSchema.safeParse({
530
+ workspaceGit: { interactiveGitTimeoutMs: -1 },
531
+ });
532
+ expect(negativeResult.success).toBe(false);
533
+ });
534
+
535
+ test('rejects non-integer workspaceGit.interactiveGitTimeoutMs', () => {
536
+ const result = AssistantConfigSchema.safeParse({
537
+ workspaceGit: { interactiveGitTimeoutMs: 3.5 },
538
+ });
539
+ expect(result.success).toBe(false);
540
+ });
541
+
542
+ test('rejects non-number workspaceGit.interactiveGitTimeoutMs', () => {
543
+ const result = AssistantConfigSchema.safeParse({
544
+ workspaceGit: { interactiveGitTimeoutMs: 'fast' },
545
+ });
546
+ expect(result.success).toBe(false);
547
+ });
499
548
  });
500
549
 
501
550
  // ---------------------------------------------------------------------------
@@ -11,6 +11,7 @@ import {
11
11
 
12
12
  // Override getDataDir to use a temp directory during tests
13
13
  const TEST_DIR = join(tmpdir(), `vellum-dd-test-${process.pid}`);
14
+ let originalDataDir: string | undefined;
14
15
 
15
16
  // We mock getDataDir by patching the module at the fs level:
16
17
  // session.ts calls getSessionDir() -> join(getDataDir(), 'doordash')
@@ -81,11 +82,19 @@ describe('DoorDash session persistence', () => {
81
82
  // we test via importFromRecording which exercises save+load.
82
83
 
83
84
  beforeEach(() => {
85
+ originalDataDir = process.env.BASE_DATA_DIR;
86
+ process.env.BASE_DATA_DIR = TEST_DIR;
84
87
  // Ensure test dir exists
85
88
  mkdirSync(TEST_DIR, { recursive: true });
86
89
  });
87
90
 
88
91
  afterEach(() => {
92
+ // Restore original BASE_DATA_DIR
93
+ if (originalDataDir === undefined) {
94
+ delete process.env.BASE_DATA_DIR;
95
+ } else {
96
+ process.env.BASE_DATA_DIR = originalDataDir;
97
+ }
89
98
  // Clean up test dir
90
99
  if (existsSync(TEST_DIR)) {
91
100
  rmSync(TEST_DIR, { recursive: true, force: true });
@@ -70,6 +70,10 @@ const clientMessages: Record<ClientMessageType, ClientMessage> = {
70
70
  type: 'model_set',
71
71
  model: 'claude-opus-4-6',
72
72
  },
73
+ image_gen_model_set: {
74
+ type: 'image_gen_model_set',
75
+ model: 'gemini-2.5-flash-image',
76
+ },
73
77
  history_request: {
74
78
  type: 'history_request',
75
79
  sessionId: 'sess-001',
@@ -437,10 +441,18 @@ const clientMessages: Record<ClientMessageType, ClientMessage> = {
437
441
  type: 'work_item_complete',
438
442
  id: 'wi-001',
439
443
  },
444
+ work_item_delete: {
445
+ type: 'work_item_delete',
446
+ id: 'wi-001',
447
+ },
440
448
  work_item_run_task: {
441
449
  type: 'work_item_run_task',
442
450
  id: 'wi-001',
443
451
  },
452
+ work_item_output: {
453
+ type: 'work_item_output',
454
+ id: 'wi-001',
455
+ },
444
456
  document_save: {
445
457
  type: 'document_save',
446
458
  surfaceId: 'doc-001',
@@ -1309,12 +1321,31 @@ const serverMessages: Record<ServerMessageType, ServerMessage> = {
1309
1321
  updatedAt: 1700001000,
1310
1322
  },
1311
1323
  },
1324
+ work_item_delete_response: {
1325
+ type: 'work_item_delete_response',
1326
+ id: 'wi-001',
1327
+ success: true,
1328
+ },
1312
1329
  work_item_run_task_response: {
1313
1330
  type: 'work_item_run_task_response',
1314
1331
  id: 'wi-001',
1315
1332
  lastRunId: 'run-001',
1316
1333
  success: true,
1317
1334
  },
1335
+ work_item_output_response: {
1336
+ type: 'work_item_output_response',
1337
+ id: 'wi-001',
1338
+ success: true,
1339
+ output: {
1340
+ title: 'Process report',
1341
+ status: 'completed',
1342
+ runId: 'run-001',
1343
+ conversationId: 'conv-001',
1344
+ completedAt: 1700002000,
1345
+ summary: 'Report processed successfully.',
1346
+ highlights: ['- Key finding 1', '- Key finding 2'],
1347
+ },
1348
+ },
1318
1349
  work_item_status_changed: {
1319
1350
  type: 'work_item_status_changed',
1320
1351
  item: {
@@ -1328,6 +1359,9 @@ const serverMessages: Record<ServerMessageType, ServerMessage> = {
1328
1359
  updatedAt: 1700001000,
1329
1360
  },
1330
1361
  },
1362
+ tasks_changed: {
1363
+ type: 'tasks_changed',
1364
+ },
1331
1365
  open_tasks_window: {
1332
1366
  type: 'open_tasks_window',
1333
1367
  },
@@ -19,7 +19,7 @@ import {
19
19
  __resetRegistryForTesting,
20
20
  __clearRegistryForTesting,
21
21
  } from '../tools/registry.js';
22
- import { eagerModules, explicitTools, lazyTools } from '../tools/tool-manifest.js';
22
+ import { eagerModuleToolNames, explicitTools, lazyTools } from '../tools/tool-manifest.js';
23
23
 
24
24
  // Clean up global registry after this file completes to prevent
25
25
  // contamination of subsequent test files in combined runs.
@@ -175,8 +175,8 @@ describe('tool manifest', () => {
175
175
  expect(lazyNames.has('swarm_delegate')).toBe(true);
176
176
  });
177
177
 
178
- test('eager module list contains expected count', () => {
179
- expect(eagerModules.length).toBe(28);
178
+ test('eager module tool names list contains expected count', () => {
179
+ expect(eagerModuleToolNames.length).toBe(31);
180
180
  });
181
181
 
182
182
  test('explicit tools list includes memory, credential, and timer tools', () => {
@@ -192,7 +192,7 @@ describe('tool manifest', () => {
192
192
  test('registered tool count is at least eager + lazy + host', async () => {
193
193
  await initializeTools();
194
194
  const tools = getAllTools();
195
- expect(tools.length).toBeGreaterThanOrEqual(eagerModules.length + lazyTools.length);
195
+ expect(tools.length).toBeGreaterThanOrEqual(eagerModuleToolNames.length + lazyTools.length);
196
196
  });
197
197
  });
198
198
 
@@ -210,8 +210,13 @@ describe('baseline characterization: hardcoded tool loading', () => {
210
210
  }
211
211
  });
212
212
 
213
- test('gmail eager module is NOT in eagerModules manifest', () => {
214
- expect(eagerModules).not.toContain('./gmail/executors.js');
213
+ test('gmail tool names are NOT in eagerModuleToolNames manifest', () => {
214
+ const gmailTools = ['gmail_search', 'gmail_list_messages', 'gmail_get_message', 'gmail_mark_read',
215
+ 'gmail_draft', 'gmail_archive', 'gmail_batch_archive', 'gmail_label', 'gmail_batch_label',
216
+ 'gmail_trash', 'gmail_send', 'gmail_unsubscribe'];
217
+ for (const name of gmailTools) {
218
+ expect(eagerModuleToolNames).not.toContain(name);
219
+ }
215
220
  });
216
221
 
217
222
  test('weather tool is NOT in global registry after initializeTools()', async () => {
@@ -220,8 +225,8 @@ describe('baseline characterization: hardcoded tool loading', () => {
220
225
  expect(tool).toBeUndefined();
221
226
  });
222
227
 
223
- test('weather eager module is NOT in eagerModules manifest', () => {
224
- expect(eagerModules).not.toContain('./weather/get-weather.js');
228
+ test('weather tool name is NOT in eagerModuleToolNames manifest', () => {
229
+ expect(eagerModuleToolNames).not.toContain('get_weather');
225
230
  });
226
231
 
227
232
  test('claude_code is NOT in global registry after initializeTools()', async () => {