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.
- package/package.json +1 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +28 -0
- package/src/__tests__/app-bundler.test.ts +12 -33
- package/src/__tests__/browser-skill-endstate.test.ts +1 -5
- package/src/__tests__/call-orchestrator.test.ts +328 -0
- package/src/__tests__/call-state.test.ts +133 -0
- package/src/__tests__/call-store.test.ts +476 -0
- package/src/__tests__/commit-message-enrichment-service.test.ts +409 -0
- package/src/__tests__/config-schema.test.ts +49 -0
- package/src/__tests__/doordash-session.test.ts +9 -0
- package/src/__tests__/ipc-snapshot.test.ts +34 -0
- package/src/__tests__/registry.test.ts +13 -8
- package/src/__tests__/run-orchestrator-assistant-events.test.ts +218 -0
- package/src/__tests__/run-orchestrator.test.ts +3 -3
- package/src/__tests__/runtime-attachment-metadata.test.ts +17 -19
- package/src/__tests__/runtime-runs-http.test.ts +1 -19
- package/src/__tests__/runtime-runs.test.ts +7 -7
- package/src/__tests__/session-queue.test.ts +50 -0
- package/src/__tests__/turn-commit.test.ts +56 -0
- package/src/__tests__/workspace-git-service.test.ts +217 -0
- package/src/__tests__/workspace-heartbeat-service.test.ts +129 -0
- package/src/bundler/app-bundler.ts +29 -12
- package/src/calls/call-constants.ts +10 -0
- package/src/calls/call-orchestrator.ts +364 -0
- package/src/calls/call-state.ts +64 -0
- package/src/calls/call-store.ts +229 -0
- package/src/calls/relay-server.ts +298 -0
- package/src/calls/twilio-config.ts +34 -0
- package/src/calls/twilio-provider.ts +169 -0
- package/src/calls/twilio-routes.ts +236 -0
- package/src/calls/types.ts +37 -0
- package/src/calls/voice-provider.ts +14 -0
- package/src/cli/doordash.ts +5 -24
- package/src/config/bundled-skills/doordash/SKILL.md +104 -0
- package/src/config/bundled-skills/image-studio/TOOLS.json +2 -2
- package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +1 -1
- package/src/config/defaults.ts +11 -0
- package/src/config/schema.ts +57 -0
- package/src/config/system-prompt.ts +50 -1
- package/src/config/types.ts +1 -0
- package/src/daemon/handlers/config.ts +30 -0
- package/src/daemon/handlers/index.ts +6 -0
- package/src/daemon/handlers/work-items.ts +142 -2
- package/src/daemon/ipc-contract-inventory.json +12 -0
- package/src/daemon/ipc-contract.ts +52 -0
- package/src/daemon/lifecycle.ts +27 -5
- package/src/daemon/server.ts +10 -12
- package/src/daemon/session-tool-setup.ts +6 -0
- package/src/daemon/session.ts +40 -1
- package/src/index.ts +2 -0
- package/src/media/gemini-image-service.ts +1 -1
- package/src/memory/db.ts +266 -0
- package/src/memory/schema.ts +42 -0
- package/src/runtime/http-server.ts +189 -25
- package/src/runtime/http-types.ts +0 -2
- package/src/runtime/routes/attachment-routes.ts +6 -6
- package/src/runtime/routes/channel-routes.ts +16 -18
- package/src/runtime/routes/conversation-routes.ts +5 -9
- package/src/runtime/routes/run-routes.ts +4 -8
- package/src/runtime/run-orchestrator.ts +32 -5
- package/src/tools/calls/call-end.ts +117 -0
- package/src/tools/calls/call-start.ts +134 -0
- package/src/tools/calls/call-status.ts +97 -0
- package/src/tools/credentials/vault.ts +1 -1
- package/src/tools/registry.ts +2 -4
- package/src/tools/tasks/index.ts +2 -0
- package/src/tools/tasks/task-delete.ts +49 -8
- package/src/tools/tasks/task-run.ts +9 -1
- package/src/tools/tasks/work-item-enqueue.ts +93 -3
- package/src/tools/tasks/work-item-list.ts +10 -25
- package/src/tools/tasks/work-item-remove.ts +112 -0
- package/src/tools/tasks/work-item-update.ts +186 -0
- package/src/tools/tool-manifest.ts +39 -31
- package/src/tools/ui-surface/definitions.ts +3 -0
- package/src/work-items/work-item-store.ts +209 -0
- package/src/workspace/commit-message-enrichment-service.ts +260 -0
- package/src/workspace/commit-message-provider.ts +95 -0
- package/src/workspace/git-service.ts +187 -32
- package/src/workspace/heartbeat-service.ts +70 -13
- 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 {
|
|
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(
|
|
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(
|
|
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
|
|
214
|
-
|
|
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
|
|
224
|
-
expect(
|
|
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 () => {
|