vellum 0.2.2 → 0.2.8

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 (60) hide show
  1. package/bun.lock +68 -100
  2. package/package.json +3 -3
  3. package/src/__tests__/asset-materialize-tool.test.ts +2 -2
  4. package/src/__tests__/checker.test.ts +104 -0
  5. package/src/__tests__/config-schema.test.ts +6 -0
  6. package/src/__tests__/gateway-only-enforcement.test.ts +458 -0
  7. package/src/__tests__/handlers-twilio-config.test.ts +221 -0
  8. package/src/__tests__/ipc-snapshot.test.ts +20 -0
  9. package/src/__tests__/memory-regressions.test.ts +100 -2
  10. package/src/__tests__/oauth-callback-registry.test.ts +85 -0
  11. package/src/__tests__/oauth2-gateway-transport.test.ts +298 -0
  12. package/src/__tests__/provider-commit-message-generator.test.ts +342 -0
  13. package/src/__tests__/public-ingress-urls.test.ts +206 -0
  14. package/src/__tests__/session-conflict-gate.test.ts +28 -25
  15. package/src/__tests__/tool-executor.test.ts +88 -0
  16. package/src/__tests__/turn-commit.test.ts +64 -0
  17. package/src/calls/__tests__/twilio-webhook-urls.test.ts +162 -0
  18. package/src/calls/call-domain.ts +3 -3
  19. package/src/calls/twilio-config.ts +25 -9
  20. package/src/calls/twilio-provider.ts +4 -4
  21. package/src/calls/twilio-routes.ts +10 -2
  22. package/src/calls/twilio-webhook-urls.ts +47 -0
  23. package/src/cli/map.ts +30 -6
  24. package/src/config/defaults.ts +5 -0
  25. package/src/config/schema.ts +34 -2
  26. package/src/config/system-prompt.ts +1 -1
  27. package/src/config/types.ts +1 -0
  28. package/src/config/vellum-skills/telegram-setup/SKILL.md +1 -5
  29. package/src/daemon/computer-use-session.ts +2 -1
  30. package/src/daemon/handlers/config.ts +95 -4
  31. package/src/daemon/handlers/sessions.ts +2 -2
  32. package/src/daemon/handlers/work-items.ts +1 -1
  33. package/src/daemon/ipc-contract-inventory.json +8 -0
  34. package/src/daemon/ipc-contract.ts +39 -1
  35. package/src/daemon/ride-shotgun-handler.ts +2 -1
  36. package/src/daemon/session-agent-loop.ts +37 -2
  37. package/src/daemon/session-conflict-gate.ts +18 -109
  38. package/src/daemon/session-tool-setup.ts +7 -0
  39. package/src/inbound/public-ingress-urls.ts +106 -0
  40. package/src/memory/attachments-store.ts +0 -1
  41. package/src/memory/channel-delivery-store.ts +0 -1
  42. package/src/memory/conflict-intent.ts +114 -0
  43. package/src/memory/conversation-key-store.ts +0 -1
  44. package/src/memory/db.ts +346 -149
  45. package/src/memory/job-handlers/conflict.ts +23 -1
  46. package/src/memory/runs-store.ts +0 -3
  47. package/src/memory/schema.ts +0 -4
  48. package/src/runtime/gateway-client.ts +36 -0
  49. package/src/runtime/http-server.ts +140 -2
  50. package/src/runtime/routes/channel-routes.ts +121 -79
  51. package/src/security/oauth-callback-registry.ts +56 -0
  52. package/src/security/oauth2.ts +174 -58
  53. package/src/swarm/backend-claude-code.ts +1 -1
  54. package/src/tools/assets/search.ts +1 -36
  55. package/src/tools/browser/api-map.ts +123 -50
  56. package/src/tools/claude-code/claude-code.ts +131 -1
  57. package/src/tools/tasks/work-item-list.ts +16 -2
  58. package/src/workspace/commit-message-enrichment-service.ts +3 -3
  59. package/src/workspace/provider-commit-message-generator.ts +57 -14
  60. package/src/workspace/turn-commit.ts +6 -2
@@ -0,0 +1,206 @@
1
+ import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test';
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Mocks — silence logger output during tests
5
+ // ---------------------------------------------------------------------------
6
+
7
+ function makeLoggerStub(): Record<string, unknown> {
8
+ const stub: Record<string, unknown> = {};
9
+ for (const m of ['info', 'warn', 'error', 'debug', 'trace', 'fatal', 'silent', 'child']) {
10
+ stub[m] = m === 'child' ? () => makeLoggerStub() : () => {};
11
+ }
12
+ return stub;
13
+ }
14
+
15
+ mock.module('../util/logger.js', () => ({
16
+ getLogger: () => makeLoggerStub(),
17
+ }));
18
+
19
+ import {
20
+ getPublicBaseUrl,
21
+ getTwilioVoiceWebhookUrl,
22
+ getTwilioStatusCallbackUrl,
23
+ getTwilioConnectActionUrl,
24
+ getTwilioRelayUrl,
25
+ getOAuthCallbackUrl,
26
+ } from '../inbound/public-ingress-urls.js';
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // getPublicBaseUrl — fallback chain
30
+ // ---------------------------------------------------------------------------
31
+
32
+ describe('getPublicBaseUrl', () => {
33
+ let savedEnv: string | undefined;
34
+
35
+ beforeEach(() => {
36
+ savedEnv = process.env.TWILIO_WEBHOOK_BASE_URL;
37
+ delete process.env.TWILIO_WEBHOOK_BASE_URL;
38
+ });
39
+
40
+ afterEach(() => {
41
+ if (savedEnv !== undefined) {
42
+ process.env.TWILIO_WEBHOOK_BASE_URL = savedEnv;
43
+ } else {
44
+ delete process.env.TWILIO_WEBHOOK_BASE_URL;
45
+ }
46
+ });
47
+
48
+ test('prefers ingress.publicBaseUrl when set', () => {
49
+ const result = getPublicBaseUrl({
50
+ ingress: { publicBaseUrl: 'https://ingress.example.com/' },
51
+ calls: { webhookBaseUrl: 'https://calls.example.com' },
52
+ });
53
+ expect(result).toBe('https://ingress.example.com');
54
+ });
55
+
56
+ test('falls back to calls.webhookBaseUrl when ingress.publicBaseUrl is empty', () => {
57
+ const result = getPublicBaseUrl({
58
+ ingress: { publicBaseUrl: '' },
59
+ calls: { webhookBaseUrl: 'https://calls.example.com/' },
60
+ });
61
+ expect(result).toBe('https://calls.example.com');
62
+ });
63
+
64
+ test('falls back to calls.webhookBaseUrl when ingress is undefined', () => {
65
+ const result = getPublicBaseUrl({
66
+ calls: { webhookBaseUrl: 'https://calls.example.com' },
67
+ });
68
+ expect(result).toBe('https://calls.example.com');
69
+ });
70
+
71
+ test('falls back to TWILIO_WEBHOOK_BASE_URL env var when both config fields are empty', () => {
72
+ process.env.TWILIO_WEBHOOK_BASE_URL = 'https://env.example.com/';
73
+ const result = getPublicBaseUrl({
74
+ ingress: { publicBaseUrl: '' },
75
+ calls: { webhookBaseUrl: '' },
76
+ });
77
+ expect(result).toBe('https://env.example.com');
78
+ });
79
+
80
+ test('falls back to env var when config fields are undefined', () => {
81
+ process.env.TWILIO_WEBHOOK_BASE_URL = 'https://env.example.com';
82
+ const result = getPublicBaseUrl({});
83
+ expect(result).toBe('https://env.example.com');
84
+ });
85
+
86
+ test('throws when no source provides a value', () => {
87
+ expect(() => getPublicBaseUrl({
88
+ ingress: { publicBaseUrl: '' },
89
+ calls: { webhookBaseUrl: '' },
90
+ })).toThrow(/No public base URL configured/);
91
+ });
92
+
93
+ test('throws when all sources are undefined', () => {
94
+ expect(() => getPublicBaseUrl({})).toThrow(/No public base URL configured/);
95
+ });
96
+
97
+ test('normalizes trailing slashes from ingress.publicBaseUrl', () => {
98
+ const result = getPublicBaseUrl({
99
+ ingress: { publicBaseUrl: 'https://example.com///' },
100
+ });
101
+ expect(result).toBe('https://example.com');
102
+ });
103
+
104
+ test('trims whitespace from ingress.publicBaseUrl', () => {
105
+ const result = getPublicBaseUrl({
106
+ ingress: { publicBaseUrl: ' https://example.com ' },
107
+ });
108
+ expect(result).toBe('https://example.com');
109
+ });
110
+
111
+ test('skips whitespace-only ingress.publicBaseUrl and falls through', () => {
112
+ const result = getPublicBaseUrl({
113
+ ingress: { publicBaseUrl: ' ' },
114
+ calls: { webhookBaseUrl: 'https://calls.example.com' },
115
+ });
116
+ expect(result).toBe('https://calls.example.com');
117
+ });
118
+ });
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // getTwilioVoiceWebhookUrl
122
+ // ---------------------------------------------------------------------------
123
+
124
+ describe('getTwilioVoiceWebhookUrl', () => {
125
+ test('builds correct URL with callSessionId', () => {
126
+ const url = getTwilioVoiceWebhookUrl(
127
+ { ingress: { publicBaseUrl: 'https://example.com' } },
128
+ 'session-123',
129
+ );
130
+ expect(url).toBe('https://example.com/webhooks/twilio/voice?callSessionId=session-123');
131
+ });
132
+
133
+ test('normalizes base URL before composing', () => {
134
+ const url = getTwilioVoiceWebhookUrl(
135
+ { ingress: { publicBaseUrl: 'https://example.com/' } },
136
+ 'abc',
137
+ );
138
+ expect(url).toBe('https://example.com/webhooks/twilio/voice?callSessionId=abc');
139
+ });
140
+ });
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // getTwilioStatusCallbackUrl
144
+ // ---------------------------------------------------------------------------
145
+
146
+ describe('getTwilioStatusCallbackUrl', () => {
147
+ test('builds correct URL', () => {
148
+ const url = getTwilioStatusCallbackUrl({
149
+ ingress: { publicBaseUrl: 'https://example.com' },
150
+ });
151
+ expect(url).toBe('https://example.com/webhooks/twilio/status');
152
+ });
153
+ });
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // getTwilioConnectActionUrl
157
+ // ---------------------------------------------------------------------------
158
+
159
+ describe('getTwilioConnectActionUrl', () => {
160
+ test('builds correct URL', () => {
161
+ const url = getTwilioConnectActionUrl({
162
+ ingress: { publicBaseUrl: 'https://example.com' },
163
+ });
164
+ expect(url).toBe('https://example.com/webhooks/twilio/connect-action');
165
+ });
166
+ });
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // getTwilioRelayUrl — scheme conversion
170
+ // ---------------------------------------------------------------------------
171
+
172
+ describe('getTwilioRelayUrl', () => {
173
+ test('converts https to wss', () => {
174
+ const url = getTwilioRelayUrl({
175
+ ingress: { publicBaseUrl: 'https://example.com' },
176
+ });
177
+ expect(url).toBe('wss://example.com/webhooks/twilio/relay');
178
+ });
179
+
180
+ test('converts http to ws', () => {
181
+ const url = getTwilioRelayUrl({
182
+ ingress: { publicBaseUrl: 'http://localhost:7821' },
183
+ });
184
+ expect(url).toBe('ws://localhost:7821/webhooks/twilio/relay');
185
+ });
186
+
187
+ test('normalizes trailing slash before conversion', () => {
188
+ const url = getTwilioRelayUrl({
189
+ ingress: { publicBaseUrl: 'https://example.com/' },
190
+ });
191
+ expect(url).toBe('wss://example.com/webhooks/twilio/relay');
192
+ });
193
+ });
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // getOAuthCallbackUrl
197
+ // ---------------------------------------------------------------------------
198
+
199
+ describe('getOAuthCallbackUrl', () => {
200
+ test('builds correct URL', () => {
201
+ const url = getOAuthCallbackUrl({
202
+ ingress: { publicBaseUrl: 'https://example.com' },
203
+ });
204
+ expect(url).toBe('https://example.com/webhooks/oauth/callback');
205
+ });
206
+ });
@@ -39,8 +39,18 @@ let resolverResult: {
39
39
 
40
40
  const persistedMessages: Array<{ id: string; role: string; content: string; createdAt: number }> = [];
41
41
 
42
+ function makeMockLogger(): Record<string, unknown> {
43
+ const logger: Record<string, unknown> = {};
44
+ logger.child = () => logger;
45
+ logger.debug = () => {};
46
+ logger.info = () => {};
47
+ logger.warn = () => {};
48
+ logger.error = () => {};
49
+ return logger;
50
+ }
51
+
42
52
  mock.module('../util/logger.js', () => ({
43
- getLogger: () => new Proxy({} as Record<string, unknown>, { get: () => () => {} }),
53
+ getLogger: () => makeMockLogger(),
44
54
  }));
45
55
 
46
56
  mock.module('../util/platform.js', () => ({
@@ -305,7 +315,7 @@ describe('Session conflict soft gate', () => {
305
315
  await session.processMessage('Should I use React or Vue here?', [], (event) => events.push(event));
306
316
 
307
317
  expect(runCalls).toHaveLength(0);
308
- expect(resolverCallCount).toBe(1);
318
+ expect(resolverCallCount).toBe(0);
309
319
  expect(markAskedCalls).toEqual(['conflict-relevant']);
310
320
  const clarificationEvent = events.find((event) => event.type === 'assistant_text_delta');
311
321
  expect(clarificationEvent).toBeDefined();
@@ -315,7 +325,7 @@ describe('Session conflict soft gate', () => {
315
325
  expect(events.some((event) => event.type === 'message_complete')).toBe(true);
316
326
  });
317
327
 
318
- test('irrelevant unresolved conflict asks once and continues with normal answer flow', async () => {
328
+ test('irrelevant unresolved conflict does not inject side-question into normal answer flow', async () => {
319
329
  pendingConflicts = [{
320
330
  id: 'conflict-irrelevant',
321
331
  scopeId: 'default',
@@ -332,13 +342,6 @@ describe('Session conflict soft gate', () => {
332
342
  existingStatement: 'Use Postgres as the default database.',
333
343
  candidateStatement: 'Use MySQL as the default database.',
334
344
  }];
335
- resolverResult = {
336
- resolution: 'keep_existing',
337
- strategy: 'heuristic',
338
- resolvedStatement: null,
339
- explanation: 'Resolved by accident.',
340
- };
341
-
342
345
  const session = makeSession();
343
346
  await session.loadFromDb();
344
347
 
@@ -349,10 +352,10 @@ describe('Session conflict soft gate', () => {
349
352
  const injectedUser = runCalls[0][runCalls[0].length - 1];
350
353
  expect(injectedUser.role).toBe('user');
351
354
  const injectedText = extractText(injectedUser);
352
- expect(injectedText).toContain('Memory clarification request');
353
- expect(injectedText).toContain('Should I assume Postgres or MySQL?');
355
+ expect(injectedText).not.toContain('Memory clarification request');
356
+ expect(injectedText).not.toContain('Should I assume Postgres or MySQL?');
354
357
  expect(resolverCallCount).toBe(0);
355
- expect(markAskedCalls).toEqual(['conflict-irrelevant']);
358
+ expect(markAskedCalls).toEqual([]);
356
359
  expect(events.some((event) => event.type === 'message_complete')).toBe(true);
357
360
  });
358
361
 
@@ -379,7 +382,7 @@ describe('Session conflict soft gate', () => {
379
382
 
380
383
  // First turn asks the clarification and records it as asked.
381
384
  await session.processMessage('Should I assume Postgres or MySQL?', [], () => {});
382
- expect(resolverCallCount).toBe(1);
385
+ expect(resolverCallCount).toBe(0);
383
386
  expect(markAskedCalls).toEqual(['conflict-followup']);
384
387
 
385
388
  resolverResult = {
@@ -392,7 +395,7 @@ describe('Session conflict soft gate', () => {
392
395
  // Follow-up reply does not overlap statement tokens but should still resolve.
393
396
  await session.processMessage('Keep the new one.', [], () => {});
394
397
 
395
- expect(resolverCallCount).toBe(2);
398
+ expect(resolverCallCount).toBe(1);
396
399
  expect(markAskedCalls).toEqual(['conflict-followup']);
397
400
  expect(runCalls).toHaveLength(1);
398
401
  });
@@ -420,7 +423,7 @@ describe('Session conflict soft gate', () => {
420
423
 
421
424
  // First turn asks the clarification.
422
425
  await session.processMessage('Should I assume Postgres or MySQL?', [], () => {});
423
- expect(resolverCallCount).toBe(1);
426
+ expect(resolverCallCount).toBe(0);
424
427
  expect(markAskedCalls).toEqual(['conflict-concise']);
425
428
 
426
429
  resolverResult = {
@@ -433,7 +436,7 @@ describe('Session conflict soft gate', () => {
433
436
  // Short directional reply with no action verb should still resolve.
434
437
  await session.processMessage('both', [], () => {});
435
438
 
436
- expect(resolverCallCount).toBe(2);
439
+ expect(resolverCallCount).toBe(1);
437
440
  expect(runCalls).toHaveLength(1);
438
441
  });
439
442
 
@@ -460,7 +463,7 @@ describe('Session conflict soft gate', () => {
460
463
 
461
464
  // First turn: relevant question triggers clarification ask.
462
465
  await session.processMessage('Should I assume Postgres or MySQL?', [], () => {});
463
- expect(resolverCallCount).toBe(1);
466
+ expect(resolverCallCount).toBe(0);
464
467
  expect(markAskedCalls).toEqual(['conflict-unrelated']);
465
468
 
466
469
  // Second turn: unrelated question containing the cue word "new" should NOT
@@ -473,8 +476,8 @@ describe('Session conflict soft gate', () => {
473
476
  };
474
477
  await session.processMessage("What's new in Bun?", [], () => {});
475
478
 
476
- // The resolver should NOT have been called again for this unrelated question.
477
- expect(resolverCallCount).toBe(1);
479
+ // The resolver should NOT have been called for this unrelated question.
480
+ expect(resolverCallCount).toBe(0);
478
481
  // Normal agent loop should still run.
479
482
  expect(runCalls).toHaveLength(1);
480
483
  });
@@ -502,7 +505,7 @@ describe('Session conflict soft gate', () => {
502
505
 
503
506
  // First turn: triggers clarification ask.
504
507
  await session.processMessage('Should I assume Postgres or MySQL?', [], () => {});
505
- expect(resolverCallCount).toBe(1);
508
+ expect(resolverCallCount).toBe(0);
506
509
  expect(markAskedCalls).toEqual(['conflict-unrelated-no-qmark']);
507
510
 
508
511
  resolverResult = {
@@ -516,11 +519,11 @@ describe('Session conflict soft gate', () => {
516
519
  // Should NOT resolve the conflict.
517
520
  await session.processMessage('I started a new project today', [], () => {});
518
521
 
519
- expect(resolverCallCount).toBe(1);
522
+ expect(resolverCallCount).toBe(0);
520
523
  expect(runCalls).toHaveLength(1);
521
524
  });
522
525
 
523
- test('cooldown prevents repeated asks on subsequent turns', async () => {
526
+ test('irrelevant conflicts remain silent across subsequent turns', async () => {
524
527
  pendingConflicts = [{
525
528
  id: 'conflict-cooldown',
526
529
  scopeId: 'default',
@@ -547,9 +550,9 @@ describe('Session conflict soft gate', () => {
547
550
  expect(runCalls).toHaveLength(2);
548
551
  const firstUserText = extractText(runCalls[0][runCalls[0].length - 1]);
549
552
  const secondUserText = extractText(runCalls[1][runCalls[1].length - 1]);
550
- expect(firstUserText).toContain('Memory clarification request');
553
+ expect(firstUserText).not.toContain('Memory clarification request');
551
554
  expect(secondUserText).not.toContain('Memory clarification request');
552
- expect(markAskedCalls).toEqual(['conflict-cooldown']);
555
+ expect(markAskedCalls).toEqual([]);
553
556
  });
554
557
 
555
558
  test('passes session scopeId through to conflict store queries', async () => {
@@ -1965,3 +1965,91 @@ describe('buildSanitizedEnv — baseline: credential exclusion', () => {
1965
1965
  }
1966
1966
  });
1967
1967
  });
1968
+
1969
+ // ---------------------------------------------------------------------------
1970
+ // Persistent-allow lifecycle: roundtrip and auto-allow on subsequent invocation
1971
+ // ---------------------------------------------------------------------------
1972
+
1973
+ describe('ToolExecutor persistent-allow lifecycle', () => {
1974
+ beforeEach(() => {
1975
+ fakeToolResult = { content: 'ok', isError: false };
1976
+ lastCheckArgs = undefined;
1977
+ getToolOverride = undefined;
1978
+ checkResultOverride = undefined;
1979
+ checkFnOverride = undefined;
1980
+ if (addRuleSpy) { addRuleSpy.mockRestore(); addRuleSpy = undefined; }
1981
+ });
1982
+
1983
+ function setupAddRuleSpy() {
1984
+ addRuleSpy = spyOn(trustStore, 'addRule').mockImplementation(
1985
+ (tool: string, pattern: string, scope: string, decision = 'allow', priority = 100, options?: any) => {
1986
+ return { id: 'spy-rule-id', tool, pattern, scope, decision, priority, createdAt: Date.now(), ...options } as any;
1987
+ },
1988
+ );
1989
+ return addRuleSpy;
1990
+ }
1991
+
1992
+ test('persistent-allow roundtrip: always_allow saves rule and allows tool', async () => {
1993
+ // Simulate check() returning 'prompt' so the executor asks the user
1994
+ checkResultOverride = { decision: 'prompt', reason: 'Medium risk: requires approval' };
1995
+ const spy = setupAddRuleSpy();
1996
+
1997
+ // User responds with always_allow, selecting a pattern and scope
1998
+ const prompter = makePrompterWithDecision('always_allow', 'git *', '/tmp/project');
1999
+ const executor = new ToolExecutor(prompter);
2000
+ const result = await executor.execute('bash', { command: 'git status' }, makeContext());
2001
+
2002
+ // The tool should have been allowed to proceed
2003
+ expect(result.isError).toBe(false);
2004
+ expect(result.content).toBe('ok');
2005
+
2006
+ // addRule should have been called with the correct arguments
2007
+ expect(spy).toHaveBeenCalledTimes(1);
2008
+ const [tool, pattern, scope, decision] = spy.mock.calls[0];
2009
+ expect(tool).toBe('bash');
2010
+ expect(pattern).toBe('git *');
2011
+ expect(scope).toBe('/tmp/project');
2012
+ expect(decision).toBe('allow');
2013
+ });
2014
+
2015
+ test('auto-allow on subsequent invocation: matching rule skips prompt', async () => {
2016
+ // Simulate a previously saved rule by making check() return 'allow'
2017
+ // with a matched rule (as findHighestPriorityRule would).
2018
+ checkResultOverride = { decision: 'allow', reason: 'Matched trust rule: git *' };
2019
+
2020
+ let promptCalled = false;
2021
+ const trackingPrompter = {
2022
+ prompt: async () => { promptCalled = true; return { decision: 'allow' as const }; },
2023
+ resolveConfirmation: () => {},
2024
+ updateSender: () => {},
2025
+ dispose: () => {},
2026
+ } as unknown as PermissionPrompter;
2027
+
2028
+ const executor = new ToolExecutor(trackingPrompter);
2029
+ const result = await executor.execute('bash', { command: 'git status' }, makeContext());
2030
+
2031
+ // The tool should be auto-allowed
2032
+ expect(result.isError).toBe(false);
2033
+ expect(result.content).toBe('ok');
2034
+
2035
+ // The prompter should NOT have been called — the rule auto-allowed
2036
+ expect(promptCalled).toBe(false);
2037
+ });
2038
+
2039
+ test('always_allow with everywhere scope saves rule and allows tool', async () => {
2040
+ checkResultOverride = { decision: 'prompt', reason: 'Medium risk: requires approval' };
2041
+ const spy = setupAddRuleSpy();
2042
+
2043
+ const prompter = makePrompterWithDecision('always_allow', 'file_write:*', 'everywhere');
2044
+ const executor = new ToolExecutor(prompter);
2045
+ const result = await executor.execute('file_write', { path: '/tmp/test.txt', content: 'hello' }, makeContext());
2046
+
2047
+ expect(result.isError).toBe(false);
2048
+ expect(spy).toHaveBeenCalledTimes(1);
2049
+ const [tool, pattern, scope, decision] = spy.mock.calls[0];
2050
+ expect(tool).toBe('file_write');
2051
+ expect(pattern).toBe('file_write:*');
2052
+ expect(scope).toBe('everywhere');
2053
+ expect(decision).toBe('allow');
2054
+ });
2055
+ });
@@ -487,4 +487,68 @@ describe('LLM commit message integration', () => {
487
487
 
488
488
  expect(fullMessage).toContain('Turn:');
489
489
  });
490
+
491
+ test('changed files from preStatus are passed to generator', async () => {
492
+ let capturedContext: { changedFiles: string[] } | undefined;
493
+ let capturedOptions: { changedFiles: string[] } | undefined;
494
+
495
+ const llmResult: GenerateCommitMessageResult = {
496
+ message: 'feat: captured context test',
497
+ source: 'llm',
498
+ };
499
+
500
+ mock.module('../workspace/provider-commit-message-generator.js', () => ({
501
+ getCommitMessageGenerator: () => ({
502
+ generateCommitMessage: async (ctx: { changedFiles: string[] }, opts: { changedFiles: string[] }) => {
503
+ capturedContext = ctx;
504
+ capturedOptions = opts;
505
+ return llmResult;
506
+ },
507
+ }),
508
+ }));
509
+
510
+ const { commitTurnChanges: commit } = await import('../workspace/turn-commit.js');
511
+
512
+ const service = new WorkspaceGitService(testDir);
513
+ await service.ensureInitialized();
514
+
515
+ writeFileSync(join(testDir, 'alpha.ts'), 'export const a = 1;');
516
+ writeFileSync(join(testDir, 'beta.ts'), 'export const b = 2;');
517
+
518
+ await commit(testDir, 'sess_files', 1);
519
+
520
+ // The generator should have received the actual file list, not empty arrays
521
+ expect(capturedContext).toBeDefined();
522
+ expect(capturedContext!.changedFiles.length).toBeGreaterThan(0);
523
+ expect(capturedContext!.changedFiles).toContain('alpha.ts');
524
+ expect(capturedContext!.changedFiles).toContain('beta.ts');
525
+
526
+ expect(capturedOptions).toBeDefined();
527
+ expect(capturedOptions!.changedFiles.length).toBeGreaterThan(0);
528
+ expect(capturedOptions!.changedFiles).toContain('alpha.ts');
529
+ expect(capturedOptions!.changedFiles).toContain('beta.ts');
530
+ });
531
+
532
+ test('clean workspace skips LLM generator call', async () => {
533
+ let generatorCalled = false;
534
+
535
+ mock.module('../workspace/provider-commit-message-generator.js', () => ({
536
+ getCommitMessageGenerator: () => ({
537
+ generateCommitMessage: async () => {
538
+ generatorCalled = true;
539
+ return { message: 'should not be called', source: 'llm' as const };
540
+ },
541
+ }),
542
+ }));
543
+
544
+ const { commitTurnChanges: commit } = await import('../workspace/turn-commit.js');
545
+
546
+ const service = new WorkspaceGitService(testDir);
547
+ await service.ensureInitialized();
548
+
549
+ // No file changes — workspace is clean
550
+ await commit(testDir, 'sess_clean', 1);
551
+
552
+ expect(generatorCalled).toBe(false);
553
+ });
490
554
  });
@@ -0,0 +1,162 @@
1
+ import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test';
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Mocks — silence logger output during tests
5
+ // ---------------------------------------------------------------------------
6
+
7
+ function makeLoggerStub(): Record<string, unknown> {
8
+ const stub: Record<string, unknown> = {};
9
+ for (const m of ['info', 'warn', 'error', 'debug', 'trace', 'fatal', 'silent', 'child']) {
10
+ stub[m] = m === 'child' ? () => makeLoggerStub() : () => {};
11
+ }
12
+ return stub;
13
+ }
14
+
15
+ mock.module('../../util/logger.js', () => ({
16
+ getLogger: () => makeLoggerStub(),
17
+ }));
18
+
19
+ import {
20
+ normalizeBaseUrl,
21
+ buildTwilioVoiceWebhookUrl,
22
+ buildTwilioStatusCallbackUrl,
23
+ getWebhookBaseUrl,
24
+ } from '../twilio-webhook-urls.js';
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // normalizeBaseUrl
28
+ // ---------------------------------------------------------------------------
29
+
30
+ describe('normalizeBaseUrl', () => {
31
+ test('returns already-clean URL unchanged', () => {
32
+ expect(normalizeBaseUrl('https://example.com')).toBe('https://example.com');
33
+ });
34
+
35
+ test('strips trailing slash', () => {
36
+ expect(normalizeBaseUrl('https://example.com/')).toBe('https://example.com');
37
+ });
38
+
39
+ test('strips multiple trailing slashes', () => {
40
+ expect(normalizeBaseUrl('https://example.com///')).toBe('https://example.com');
41
+ });
42
+
43
+ test('trims leading and trailing whitespace', () => {
44
+ expect(normalizeBaseUrl(' https://example.com ')).toBe('https://example.com');
45
+ });
46
+
47
+ test('trims whitespace and strips trailing slash together', () => {
48
+ expect(normalizeBaseUrl(' https://example.com/ ')).toBe('https://example.com');
49
+ });
50
+ });
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // buildTwilioVoiceWebhookUrl
54
+ // ---------------------------------------------------------------------------
55
+
56
+ describe('buildTwilioVoiceWebhookUrl', () => {
57
+ test('returns correct URL with callSessionId', () => {
58
+ const url = buildTwilioVoiceWebhookUrl('https://example.com', 'session-123');
59
+ expect(url).toBe('https://example.com/webhooks/twilio/voice?callSessionId=session-123');
60
+ });
61
+
62
+ test('normalizes base URL before composing', () => {
63
+ const url = buildTwilioVoiceWebhookUrl('https://example.com/', 'abc');
64
+ expect(url).toBe('https://example.com/webhooks/twilio/voice?callSessionId=abc');
65
+ });
66
+ });
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // buildTwilioStatusCallbackUrl
70
+ // ---------------------------------------------------------------------------
71
+
72
+ describe('buildTwilioStatusCallbackUrl', () => {
73
+ test('returns correct URL', () => {
74
+ const url = buildTwilioStatusCallbackUrl('https://example.com');
75
+ expect(url).toBe('https://example.com/webhooks/twilio/status');
76
+ });
77
+
78
+ test('normalizes base URL before composing', () => {
79
+ const url = buildTwilioStatusCallbackUrl('https://example.com/');
80
+ expect(url).toBe('https://example.com/webhooks/twilio/status');
81
+ });
82
+ });
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // getWebhookBaseUrl
86
+ // ---------------------------------------------------------------------------
87
+
88
+ describe('getWebhookBaseUrl', () => {
89
+ let savedEnv: string | undefined;
90
+
91
+ beforeEach(() => {
92
+ savedEnv = process.env.TWILIO_WEBHOOK_BASE_URL;
93
+ delete process.env.TWILIO_WEBHOOK_BASE_URL;
94
+ });
95
+
96
+ afterEach(() => {
97
+ if (savedEnv !== undefined) {
98
+ process.env.TWILIO_WEBHOOK_BASE_URL = savedEnv;
99
+ } else {
100
+ delete process.env.TWILIO_WEBHOOK_BASE_URL;
101
+ }
102
+ });
103
+
104
+ test('uses config value when set', () => {
105
+ const result = getWebhookBaseUrl({ calls: { webhookBaseUrl: 'https://config.example.com/' } });
106
+ expect(result).toBe('https://config.example.com');
107
+ });
108
+
109
+ test('falls back to env var when config value is empty', () => {
110
+ process.env.TWILIO_WEBHOOK_BASE_URL = 'https://env.example.com/';
111
+ const result = getWebhookBaseUrl({ calls: { webhookBaseUrl: '' } });
112
+ expect(result).toBe('https://env.example.com');
113
+ });
114
+
115
+ test('falls back to env var when config value is undefined', () => {
116
+ process.env.TWILIO_WEBHOOK_BASE_URL = 'https://env.example.com';
117
+ const result = getWebhookBaseUrl({ calls: {} });
118
+ expect(result).toBe('https://env.example.com');
119
+ });
120
+
121
+ test('throws when neither config nor env var is set', () => {
122
+ expect(() => getWebhookBaseUrl({ calls: { webhookBaseUrl: '' } })).toThrow(
123
+ /No webhook base URL configured/,
124
+ );
125
+ });
126
+
127
+ test('throws when config is undefined and env var is unset', () => {
128
+ expect(() => getWebhookBaseUrl({ calls: {} })).toThrow(
129
+ /No webhook base URL configured/,
130
+ );
131
+ });
132
+
133
+ test('normalizes the returned URL', () => {
134
+ const result = getWebhookBaseUrl({ calls: { webhookBaseUrl: ' https://example.com/ ' } });
135
+ expect(result).toBe('https://example.com');
136
+ });
137
+
138
+ test('falls through when config value is whitespace-only', () => {
139
+ process.env.TWILIO_WEBHOOK_BASE_URL = 'https://env.example.com';
140
+ const result = getWebhookBaseUrl({ calls: { webhookBaseUrl: ' ' } });
141
+ expect(result).toBe('https://env.example.com');
142
+ });
143
+
144
+ test('falls through when config value is slash-only', () => {
145
+ process.env.TWILIO_WEBHOOK_BASE_URL = 'https://env.example.com';
146
+ const result = getWebhookBaseUrl({ calls: { webhookBaseUrl: '///' } });
147
+ expect(result).toBe('https://env.example.com');
148
+ });
149
+
150
+ test('throws when config is whitespace-only and env var is unset', () => {
151
+ expect(() => getWebhookBaseUrl({ calls: { webhookBaseUrl: ' ' } })).toThrow(
152
+ /No webhook base URL configured/,
153
+ );
154
+ });
155
+
156
+ test('throws when env var is whitespace-only and config is empty', () => {
157
+ process.env.TWILIO_WEBHOOK_BASE_URL = ' ';
158
+ expect(() => getWebhookBaseUrl({ calls: {} })).toThrow(
159
+ /No webhook base URL configured/,
160
+ );
161
+ });
162
+ });