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.
- package/bun.lock +68 -100
- package/package.json +3 -3
- package/src/__tests__/asset-materialize-tool.test.ts +2 -2
- package/src/__tests__/checker.test.ts +104 -0
- package/src/__tests__/config-schema.test.ts +6 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +458 -0
- package/src/__tests__/handlers-twilio-config.test.ts +221 -0
- package/src/__tests__/ipc-snapshot.test.ts +20 -0
- package/src/__tests__/memory-regressions.test.ts +100 -2
- package/src/__tests__/oauth-callback-registry.test.ts +85 -0
- package/src/__tests__/oauth2-gateway-transport.test.ts +298 -0
- package/src/__tests__/provider-commit-message-generator.test.ts +342 -0
- package/src/__tests__/public-ingress-urls.test.ts +206 -0
- package/src/__tests__/session-conflict-gate.test.ts +28 -25
- package/src/__tests__/tool-executor.test.ts +88 -0
- package/src/__tests__/turn-commit.test.ts +64 -0
- package/src/calls/__tests__/twilio-webhook-urls.test.ts +162 -0
- package/src/calls/call-domain.ts +3 -3
- package/src/calls/twilio-config.ts +25 -9
- package/src/calls/twilio-provider.ts +4 -4
- package/src/calls/twilio-routes.ts +10 -2
- package/src/calls/twilio-webhook-urls.ts +47 -0
- package/src/cli/map.ts +30 -6
- package/src/config/defaults.ts +5 -0
- package/src/config/schema.ts +34 -2
- package/src/config/system-prompt.ts +1 -1
- package/src/config/types.ts +1 -0
- package/src/config/vellum-skills/telegram-setup/SKILL.md +1 -5
- package/src/daemon/computer-use-session.ts +2 -1
- package/src/daemon/handlers/config.ts +95 -4
- package/src/daemon/handlers/sessions.ts +2 -2
- package/src/daemon/handlers/work-items.ts +1 -1
- package/src/daemon/ipc-contract-inventory.json +8 -0
- package/src/daemon/ipc-contract.ts +39 -1
- package/src/daemon/ride-shotgun-handler.ts +2 -1
- package/src/daemon/session-agent-loop.ts +37 -2
- package/src/daemon/session-conflict-gate.ts +18 -109
- package/src/daemon/session-tool-setup.ts +7 -0
- package/src/inbound/public-ingress-urls.ts +106 -0
- package/src/memory/attachments-store.ts +0 -1
- package/src/memory/channel-delivery-store.ts +0 -1
- package/src/memory/conflict-intent.ts +114 -0
- package/src/memory/conversation-key-store.ts +0 -1
- package/src/memory/db.ts +346 -149
- package/src/memory/job-handlers/conflict.ts +23 -1
- package/src/memory/runs-store.ts +0 -3
- package/src/memory/schema.ts +0 -4
- package/src/runtime/gateway-client.ts +36 -0
- package/src/runtime/http-server.ts +140 -2
- package/src/runtime/routes/channel-routes.ts +121 -79
- package/src/security/oauth-callback-registry.ts +56 -0
- package/src/security/oauth2.ts +174 -58
- package/src/swarm/backend-claude-code.ts +1 -1
- package/src/tools/assets/search.ts +1 -36
- package/src/tools/browser/api-map.ts +123 -50
- package/src/tools/claude-code/claude-code.ts +131 -1
- package/src/tools/tasks/work-item-list.ts +16 -2
- package/src/workspace/commit-message-enrichment-service.ts +3 -3
- package/src/workspace/provider-commit-message-generator.ts +57 -14
- 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: () =>
|
|
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(
|
|
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
|
|
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([
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
477
|
-
expect(resolverCallCount).toBe(
|
|
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(
|
|
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(
|
|
522
|
+
expect(resolverCallCount).toBe(0);
|
|
520
523
|
expect(runCalls).toHaveLength(1);
|
|
521
524
|
});
|
|
522
525
|
|
|
523
|
-
test('
|
|
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([
|
|
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
|
+
});
|