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,298 @@
|
|
|
1
|
+
import { describe, test, expect, mock, beforeEach } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Mocks — must be set up before importing the module under test
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
let mockPublicBaseUrl = '';
|
|
8
|
+
|
|
9
|
+
mock.module('../config/loader.js', () => ({
|
|
10
|
+
loadConfig: () => ({
|
|
11
|
+
ingress: { publicBaseUrl: mockPublicBaseUrl },
|
|
12
|
+
}),
|
|
13
|
+
getConfig: () => ({
|
|
14
|
+
ingress: { publicBaseUrl: mockPublicBaseUrl },
|
|
15
|
+
}),
|
|
16
|
+
loadRawConfig: () => ({}),
|
|
17
|
+
saveConfig: () => {},
|
|
18
|
+
invalidateConfigCache: () => {},
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
mock.module('../util/logger.js', () => ({
|
|
22
|
+
getLogger: () => ({
|
|
23
|
+
info: () => {},
|
|
24
|
+
warn: () => {},
|
|
25
|
+
error: () => {},
|
|
26
|
+
debug: () => {},
|
|
27
|
+
trace: () => {},
|
|
28
|
+
fatal: () => {},
|
|
29
|
+
child: () => ({
|
|
30
|
+
info: () => {},
|
|
31
|
+
warn: () => {},
|
|
32
|
+
error: () => {},
|
|
33
|
+
debug: () => {},
|
|
34
|
+
}),
|
|
35
|
+
}),
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
// Track registerPendingCallback calls
|
|
39
|
+
let pendingCallbacks: Map<string, { resolve: (code: string) => void; reject: (error: Error) => void }> = new Map();
|
|
40
|
+
|
|
41
|
+
mock.module('../security/oauth-callback-registry.js', () => ({
|
|
42
|
+
registerPendingCallback: (state: string, resolve: (code: string) => void, reject: (error: Error) => void) => {
|
|
43
|
+
pendingCallbacks.set(state, { resolve, reject });
|
|
44
|
+
},
|
|
45
|
+
consumeCallback: () => true,
|
|
46
|
+
consumeCallbackError: () => true,
|
|
47
|
+
clearAllCallbacks: () => { pendingCallbacks.clear(); },
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
let mockOAuthCallbackUrl = '';
|
|
51
|
+
|
|
52
|
+
mock.module('../inbound/public-ingress-urls.js', () => ({
|
|
53
|
+
getOAuthCallbackUrl: () => mockOAuthCallbackUrl,
|
|
54
|
+
getPublicBaseUrl: () => 'https://gw.example.com',
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
// Mock fetch for token exchange
|
|
58
|
+
let mockTokenResponse: { ok: boolean; status: number; body: Record<string, unknown> } = {
|
|
59
|
+
ok: true,
|
|
60
|
+
status: 200,
|
|
61
|
+
body: {
|
|
62
|
+
access_token: 'test-access-token',
|
|
63
|
+
refresh_token: 'test-refresh-token',
|
|
64
|
+
expires_in: 3600,
|
|
65
|
+
scope: 'read write',
|
|
66
|
+
token_type: 'Bearer',
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const originalFetch = globalThis.fetch;
|
|
71
|
+
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
72
|
+
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
|
73
|
+
if (url.includes('token')) {
|
|
74
|
+
if (!mockTokenResponse.ok) {
|
|
75
|
+
return new Response(JSON.stringify({ error: 'invalid_grant' }), {
|
|
76
|
+
status: mockTokenResponse.status,
|
|
77
|
+
headers: { 'Content-Type': 'application/json' },
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
return new Response(JSON.stringify(mockTokenResponse.body), {
|
|
81
|
+
status: mockTokenResponse.status,
|
|
82
|
+
headers: { 'Content-Type': 'application/json' },
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return originalFetch(input, init);
|
|
86
|
+
}) as typeof fetch;
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// Import module under test AFTER mocks are in place
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
import { startOAuth2Flow, type OAuth2Config } from '../security/oauth2.js';
|
|
93
|
+
|
|
94
|
+
const BASE_OAUTH_CONFIG: OAuth2Config = {
|
|
95
|
+
authUrl: 'https://provider.example.com/authorize',
|
|
96
|
+
tokenUrl: 'https://provider.example.com/token',
|
|
97
|
+
scopes: ['read', 'write'],
|
|
98
|
+
clientId: 'test-client-id',
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
beforeEach(() => {
|
|
102
|
+
mockPublicBaseUrl = '';
|
|
103
|
+
mockOAuthCallbackUrl = 'https://gw.example.com/webhooks/oauth/callback';
|
|
104
|
+
pendingCallbacks.clear();
|
|
105
|
+
mockTokenResponse = {
|
|
106
|
+
ok: true,
|
|
107
|
+
status: 200,
|
|
108
|
+
body: {
|
|
109
|
+
access_token: 'test-access-token',
|
|
110
|
+
refresh_token: 'test-refresh-token',
|
|
111
|
+
expires_in: 3600,
|
|
112
|
+
scope: 'read write',
|
|
113
|
+
token_type: 'Bearer',
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// Tests
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
describe('OAuth2 gateway transport', () => {
|
|
123
|
+
describe('auto-detection', () => {
|
|
124
|
+
test('selects gateway transport when ingress.publicBaseUrl is configured', async () => {
|
|
125
|
+
mockPublicBaseUrl = 'https://gw.example.com';
|
|
126
|
+
|
|
127
|
+
let capturedAuthUrl = '';
|
|
128
|
+
const flowPromise = startOAuth2Flow(BASE_OAUTH_CONFIG, {
|
|
129
|
+
openUrl: (url) => { capturedAuthUrl = url; },
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// Give the flow a tick to register the callback and open the browser
|
|
133
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
134
|
+
|
|
135
|
+
// The auth URL should contain the gateway redirect_uri, not a loopback one
|
|
136
|
+
expect(capturedAuthUrl).toContain('redirect_uri=');
|
|
137
|
+
expect(capturedAuthUrl).not.toContain('127.0.0.1');
|
|
138
|
+
expect(capturedAuthUrl).toContain(encodeURIComponent('https://gw.example.com'));
|
|
139
|
+
|
|
140
|
+
// Resolve the pending callback to complete the flow
|
|
141
|
+
const entries = Array.from(pendingCallbacks.entries());
|
|
142
|
+
expect(entries.length).toBe(1);
|
|
143
|
+
const [, { resolve }] = entries[0];
|
|
144
|
+
resolve('auth-code-from-gateway');
|
|
145
|
+
|
|
146
|
+
const result = await flowPromise;
|
|
147
|
+
expect(result.tokens.accessToken).toBe('test-access-token');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('selects loopback transport when ingress.publicBaseUrl is empty', async () => {
|
|
151
|
+
mockPublicBaseUrl = '';
|
|
152
|
+
|
|
153
|
+
let capturedAuthUrl = '';
|
|
154
|
+
const flowPromise = startOAuth2Flow(BASE_OAUTH_CONFIG, {
|
|
155
|
+
openUrl: (url) => { capturedAuthUrl = url; },
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Give the flow a tick
|
|
159
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
160
|
+
|
|
161
|
+
// The auth URL should contain a loopback redirect_uri
|
|
162
|
+
expect(capturedAuthUrl).toContain('redirect_uri=');
|
|
163
|
+
expect(capturedAuthUrl).toContain('127.0.0.1');
|
|
164
|
+
|
|
165
|
+
// Extract the redirect_uri to send the callback
|
|
166
|
+
const authUrlParsed = new URL(capturedAuthUrl);
|
|
167
|
+
const redirectUri = authUrlParsed.searchParams.get('redirect_uri')!;
|
|
168
|
+
const stateParam = authUrlParsed.searchParams.get('state')!;
|
|
169
|
+
|
|
170
|
+
// Simulate the OAuth provider callback to the loopback server
|
|
171
|
+
const callbackUrl = `${redirectUri}?code=loopback-code&state=${stateParam}`;
|
|
172
|
+
await fetch(callbackUrl);
|
|
173
|
+
|
|
174
|
+
const result = await flowPromise;
|
|
175
|
+
expect(result.tokens.accessToken).toBe('test-access-token');
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe('explicit transport', () => {
|
|
180
|
+
test('uses gateway transport when explicitly specified', async () => {
|
|
181
|
+
// Even with no publicBaseUrl, explicit gateway should work
|
|
182
|
+
mockPublicBaseUrl = 'https://gw.example.com';
|
|
183
|
+
|
|
184
|
+
let capturedAuthUrl = '';
|
|
185
|
+
const flowPromise = startOAuth2Flow(
|
|
186
|
+
BASE_OAUTH_CONFIG,
|
|
187
|
+
{ openUrl: (url) => { capturedAuthUrl = url; } },
|
|
188
|
+
{ callbackTransport: 'gateway' },
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
192
|
+
|
|
193
|
+
expect(capturedAuthUrl).toContain(encodeURIComponent('https://gw.example.com'));
|
|
194
|
+
|
|
195
|
+
const entries = Array.from(pendingCallbacks.entries());
|
|
196
|
+
expect(entries.length).toBe(1);
|
|
197
|
+
entries[0][1].resolve('explicit-gateway-code');
|
|
198
|
+
|
|
199
|
+
const result = await flowPromise;
|
|
200
|
+
expect(result.tokens.accessToken).toBe('test-access-token');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('uses loopback transport when explicitly specified', async () => {
|
|
204
|
+
// Even with publicBaseUrl configured, explicit loopback should work
|
|
205
|
+
mockPublicBaseUrl = 'https://gw.example.com';
|
|
206
|
+
|
|
207
|
+
let capturedAuthUrl = '';
|
|
208
|
+
const flowPromise = startOAuth2Flow(
|
|
209
|
+
BASE_OAUTH_CONFIG,
|
|
210
|
+
{ openUrl: (url) => { capturedAuthUrl = url; } },
|
|
211
|
+
{ callbackTransport: 'loopback' },
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
215
|
+
|
|
216
|
+
expect(capturedAuthUrl).toContain('127.0.0.1');
|
|
217
|
+
|
|
218
|
+
const authUrlParsed = new URL(capturedAuthUrl);
|
|
219
|
+
const redirectUri = authUrlParsed.searchParams.get('redirect_uri')!;
|
|
220
|
+
const stateParam = authUrlParsed.searchParams.get('state')!;
|
|
221
|
+
|
|
222
|
+
await fetch(`${redirectUri}?code=loopback-code&state=${stateParam}`);
|
|
223
|
+
|
|
224
|
+
const result = await flowPromise;
|
|
225
|
+
expect(result.tokens.accessToken).toBe('test-access-token');
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe('gateway transport flow', () => {
|
|
230
|
+
test('success: register callback, consume with code, exchange for tokens', async () => {
|
|
231
|
+
mockPublicBaseUrl = 'https://gw.example.com';
|
|
232
|
+
|
|
233
|
+
const flowPromise = startOAuth2Flow(
|
|
234
|
+
BASE_OAUTH_CONFIG,
|
|
235
|
+
{ openUrl: () => {} },
|
|
236
|
+
{ callbackTransport: 'gateway' },
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
240
|
+
|
|
241
|
+
// A callback should be registered
|
|
242
|
+
const entries = Array.from(pendingCallbacks.entries());
|
|
243
|
+
expect(entries.length).toBe(1);
|
|
244
|
+
|
|
245
|
+
// Simulate gateway delivering the authorization code
|
|
246
|
+
const [state, { resolve }] = entries[0];
|
|
247
|
+
expect(typeof state).toBe('string');
|
|
248
|
+
expect(state.length).toBeGreaterThan(0);
|
|
249
|
+
|
|
250
|
+
resolve('gateway-auth-code');
|
|
251
|
+
|
|
252
|
+
const result = await flowPromise;
|
|
253
|
+
expect(result.tokens.accessToken).toBe('test-access-token');
|
|
254
|
+
expect(result.tokens.refreshToken).toBe('test-refresh-token');
|
|
255
|
+
expect(result.tokens.expiresIn).toBe(3600);
|
|
256
|
+
expect(result.grantedScopes).toEqual(['read', 'write']);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test('error: register callback, consume with error, rejects', async () => {
|
|
260
|
+
mockPublicBaseUrl = 'https://gw.example.com';
|
|
261
|
+
|
|
262
|
+
const flowPromise = startOAuth2Flow(
|
|
263
|
+
BASE_OAUTH_CONFIG,
|
|
264
|
+
{ openUrl: () => {} },
|
|
265
|
+
{ callbackTransport: 'gateway' },
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
269
|
+
|
|
270
|
+
const entries = Array.from(pendingCallbacks.entries());
|
|
271
|
+
expect(entries.length).toBe(1);
|
|
272
|
+
|
|
273
|
+
// Simulate the gateway delivering an error (e.g. user denied access)
|
|
274
|
+
const [, { reject }] = entries[0];
|
|
275
|
+
reject(new Error('OAuth2 authorization denied: access_denied'));
|
|
276
|
+
|
|
277
|
+
await expect(flowPromise).rejects.toThrow('OAuth2 authorization denied: access_denied');
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test('token exchange failure propagates error', async () => {
|
|
281
|
+
mockPublicBaseUrl = 'https://gw.example.com';
|
|
282
|
+
mockTokenResponse = { ok: false, status: 400, body: { error: 'invalid_grant' } };
|
|
283
|
+
|
|
284
|
+
const flowPromise = startOAuth2Flow(
|
|
285
|
+
BASE_OAUTH_CONFIG,
|
|
286
|
+
{ openUrl: () => {} },
|
|
287
|
+
{ callbackTransport: 'gateway' },
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
291
|
+
|
|
292
|
+
const entries = Array.from(pendingCallbacks.entries());
|
|
293
|
+
entries[0][1].resolve('code-that-fails-exchange');
|
|
294
|
+
|
|
295
|
+
await expect(flowPromise).rejects.toThrow('OAuth2 token exchange failed');
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
});
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, mock } from 'bun:test';
|
|
2
|
+
import type { CommitContext } from '../workspace/commit-message-provider.js';
|
|
3
|
+
import type { Provider, ProviderResponse } from '../providers/types.js';
|
|
4
|
+
import type { AssistantConfig } from '../config/types.js';
|
|
5
|
+
import { DEFAULT_CONFIG } from '../config/defaults.js';
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Deep-clone a base config so each test can tweak fields independently
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
function cloneConfig(): AssistantConfig {
|
|
11
|
+
const cfg = structuredClone(DEFAULT_CONFIG);
|
|
12
|
+
cfg.provider = 'anthropic';
|
|
13
|
+
cfg.apiKeys = { anthropic: 'sk-test-key' } as Record<string, string>;
|
|
14
|
+
cfg.workspaceGit.commitMessageLLM = {
|
|
15
|
+
...cfg.workspaceGit.commitMessageLLM,
|
|
16
|
+
enabled: true,
|
|
17
|
+
useConfiguredProvider: true,
|
|
18
|
+
providerFastModelOverrides: {},
|
|
19
|
+
timeoutMs: 5000,
|
|
20
|
+
maxTokens: 120,
|
|
21
|
+
temperature: 0.2,
|
|
22
|
+
maxFilesInPrompt: 30,
|
|
23
|
+
maxDiffBytes: 12000,
|
|
24
|
+
minRemainingTurnBudgetMs: 1000,
|
|
25
|
+
breaker: {
|
|
26
|
+
openAfterFailures: 3,
|
|
27
|
+
backoffBaseMs: 2000,
|
|
28
|
+
backoffMaxMs: 60000,
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
return cfg;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let currentConfig = cloneConfig();
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Mock: config/loader
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
mock.module('../config/loader.js', () => ({
|
|
40
|
+
getConfig: () => currentConfig,
|
|
41
|
+
loadConfig: () => currentConfig,
|
|
42
|
+
invalidateConfigCache: () => {},
|
|
43
|
+
saveConfig: () => {},
|
|
44
|
+
loadRawConfig: () => ({}),
|
|
45
|
+
saveRawConfig: () => {},
|
|
46
|
+
getNestedValue: () => undefined,
|
|
47
|
+
setNestedValue: () => {},
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Mock: providers/registry
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
const mockSendMessage = mock<Provider['sendMessage']>();
|
|
54
|
+
const mockProvider: Provider = {
|
|
55
|
+
name: 'mock-provider',
|
|
56
|
+
sendMessage: mockSendMessage,
|
|
57
|
+
};
|
|
58
|
+
let getProviderShouldThrow = false;
|
|
59
|
+
|
|
60
|
+
mock.module('../providers/registry.js', () => ({
|
|
61
|
+
getProvider: (_name: string) => {
|
|
62
|
+
if (getProviderShouldThrow) {
|
|
63
|
+
throw new Error('Provider not initialized');
|
|
64
|
+
}
|
|
65
|
+
return mockProvider;
|
|
66
|
+
},
|
|
67
|
+
registerProvider: () => {},
|
|
68
|
+
listProviders: () => ['mock-provider'],
|
|
69
|
+
initializeProviders: () => {},
|
|
70
|
+
}));
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Mock: logger (noop)
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
mock.module('../util/logger.js', () => ({
|
|
76
|
+
getLogger: () =>
|
|
77
|
+
new Proxy({} as Record<string, unknown>, {
|
|
78
|
+
get: () => () => {},
|
|
79
|
+
}),
|
|
80
|
+
}));
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// Import the module under test AFTER mocks are set up
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
import {
|
|
86
|
+
_resetCommitMessageGenerator,
|
|
87
|
+
getCommitMessageGenerator,
|
|
88
|
+
} from '../workspace/provider-commit-message-generator.js';
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// Shared context
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
const baseContext: CommitContext = {
|
|
94
|
+
workspaceDir: '/tmp/test',
|
|
95
|
+
trigger: 'turn' as const,
|
|
96
|
+
sessionId: 'sess_test',
|
|
97
|
+
turnNumber: 1,
|
|
98
|
+
changedFiles: ['file.txt'],
|
|
99
|
+
timestampMs: Date.now(),
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
function makeSuccessResponse(text: string): ProviderResponse {
|
|
103
|
+
return {
|
|
104
|
+
content: [{ type: 'text', text }],
|
|
105
|
+
model: 'mock-model',
|
|
106
|
+
usage: { inputTokens: 10, outputTokens: 5 },
|
|
107
|
+
stopReason: 'end_turn',
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
describe('ProviderCommitMessageGenerator', () => {
|
|
112
|
+
beforeEach(() => {
|
|
113
|
+
_resetCommitMessageGenerator();
|
|
114
|
+
currentConfig = cloneConfig();
|
|
115
|
+
mockSendMessage.mockReset();
|
|
116
|
+
getProviderShouldThrow = false;
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// 1. disabled
|
|
120
|
+
test('disabled → returns deterministic, reason "disabled"', async () => {
|
|
121
|
+
currentConfig.workspaceGit.commitMessageLLM.enabled = false;
|
|
122
|
+
const gen = getCommitMessageGenerator();
|
|
123
|
+
const result = await gen.generateCommitMessage(baseContext, {
|
|
124
|
+
changedFiles: baseContext.changedFiles,
|
|
125
|
+
});
|
|
126
|
+
expect(result.source).toBe('deterministic');
|
|
127
|
+
expect(result.reason).toBe('disabled');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// 2. useConfiguredProvider false
|
|
131
|
+
test('useConfiguredProvider false → returns deterministic, reason "disabled"', async () => {
|
|
132
|
+
currentConfig.workspaceGit.commitMessageLLM.useConfiguredProvider = false;
|
|
133
|
+
const gen = getCommitMessageGenerator();
|
|
134
|
+
const result = await gen.generateCommitMessage(baseContext, {
|
|
135
|
+
changedFiles: baseContext.changedFiles,
|
|
136
|
+
});
|
|
137
|
+
expect(result.source).toBe('deterministic');
|
|
138
|
+
expect(result.reason).toBe('disabled');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// 3. missing API key
|
|
142
|
+
test('missing API key → returns deterministic, reason "missing_provider_api_key"', async () => {
|
|
143
|
+
currentConfig.apiKeys = {} as Record<string, string>;
|
|
144
|
+
const gen = getCommitMessageGenerator();
|
|
145
|
+
const result = await gen.generateCommitMessage(baseContext, {
|
|
146
|
+
changedFiles: baseContext.changedFiles,
|
|
147
|
+
});
|
|
148
|
+
expect(result.source).toBe('deterministic');
|
|
149
|
+
expect(result.reason).toBe('missing_provider_api_key');
|
|
150
|
+
expect(mockSendMessage).not.toHaveBeenCalled();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// 4. breaker open
|
|
154
|
+
test('breaker open → returns deterministic, reason "breaker_open"', async () => {
|
|
155
|
+
// Force the breaker open by simulating enough failures
|
|
156
|
+
currentConfig.workspaceGit.commitMessageLLM.breaker.openAfterFailures = 1;
|
|
157
|
+
const gen = getCommitMessageGenerator();
|
|
158
|
+
|
|
159
|
+
// Trigger a failure to open the breaker — provider throws
|
|
160
|
+
mockSendMessage.mockRejectedValueOnce(new Error('provider error'));
|
|
161
|
+
await gen.generateCommitMessage(baseContext, {
|
|
162
|
+
changedFiles: baseContext.changedFiles,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// Now the breaker should be open
|
|
166
|
+
const result = await gen.generateCommitMessage(baseContext, {
|
|
167
|
+
changedFiles: baseContext.changedFiles,
|
|
168
|
+
});
|
|
169
|
+
expect(result.source).toBe('deterministic');
|
|
170
|
+
expect(result.reason).toBe('breaker_open');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// 5. insufficient budget
|
|
174
|
+
test('insufficient budget → returns deterministic, reason "insufficient_budget"', async () => {
|
|
175
|
+
const gen = getCommitMessageGenerator();
|
|
176
|
+
const result = await gen.generateCommitMessage(baseContext, {
|
|
177
|
+
changedFiles: baseContext.changedFiles,
|
|
178
|
+
deadlineMs: Date.now() - 1000, // already expired
|
|
179
|
+
});
|
|
180
|
+
expect(result.source).toBe('deterministic');
|
|
181
|
+
expect(result.reason).toBe('insufficient_budget');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// 6. LLM success
|
|
185
|
+
test('LLM success → returns LLM message, source "llm", fast model passed', async () => {
|
|
186
|
+
const commitMsg = 'feat: add new feature';
|
|
187
|
+
mockSendMessage.mockResolvedValueOnce(makeSuccessResponse(commitMsg));
|
|
188
|
+
const gen = getCommitMessageGenerator();
|
|
189
|
+
const result = await gen.generateCommitMessage(baseContext, {
|
|
190
|
+
changedFiles: baseContext.changedFiles,
|
|
191
|
+
});
|
|
192
|
+
expect(result.source).toBe('llm');
|
|
193
|
+
expect(result.message).toBe(commitMsg);
|
|
194
|
+
expect(result.reason).toBeUndefined();
|
|
195
|
+
|
|
196
|
+
// Verify the fast model was passed in the config
|
|
197
|
+
const callArgs = mockSendMessage.mock.calls[0];
|
|
198
|
+
const options = callArgs[3] as { config: { model: string } };
|
|
199
|
+
expect(options.config.model).toBe('claude-haiku-4-5-20251001');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// 7. fast-model override
|
|
203
|
+
test('fast-model override → uses override instead of default', async () => {
|
|
204
|
+
currentConfig.workspaceGit.commitMessageLLM.providerFastModelOverrides = {
|
|
205
|
+
anthropic: 'claude-sonnet-4-20250514',
|
|
206
|
+
};
|
|
207
|
+
const commitMsg = 'fix: resolve issue';
|
|
208
|
+
mockSendMessage.mockResolvedValueOnce(makeSuccessResponse(commitMsg));
|
|
209
|
+
const gen = getCommitMessageGenerator();
|
|
210
|
+
const result = await gen.generateCommitMessage(baseContext, {
|
|
211
|
+
changedFiles: baseContext.changedFiles,
|
|
212
|
+
});
|
|
213
|
+
expect(result.source).toBe('llm');
|
|
214
|
+
expect(result.message).toBe(commitMsg);
|
|
215
|
+
|
|
216
|
+
const callArgs = mockSendMessage.mock.calls[0];
|
|
217
|
+
const options = callArgs[3] as { config: { model: string } };
|
|
218
|
+
expect(options.config.model).toBe('claude-sonnet-4-20250514');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// 8. LLM timeout
|
|
222
|
+
test('LLM timeout → returns deterministic, reason "timeout"', async () => {
|
|
223
|
+
// Set a very short timeout and make sendMessage take too long
|
|
224
|
+
currentConfig.workspaceGit.commitMessageLLM.timeoutMs = 1;
|
|
225
|
+
mockSendMessage.mockImplementationOnce(
|
|
226
|
+
(_msgs, _tools, _sys, options) => {
|
|
227
|
+
// Wait until the abort signal fires
|
|
228
|
+
return new Promise<ProviderResponse>((_resolve, reject) => {
|
|
229
|
+
options?.signal?.addEventListener('abort', () => {
|
|
230
|
+
reject(new Error('aborted'));
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
},
|
|
234
|
+
);
|
|
235
|
+
const gen = getCommitMessageGenerator();
|
|
236
|
+
const result = await gen.generateCommitMessage(baseContext, {
|
|
237
|
+
changedFiles: baseContext.changedFiles,
|
|
238
|
+
});
|
|
239
|
+
expect(result.source).toBe('deterministic');
|
|
240
|
+
expect(result.reason).toBe('timeout');
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// 9. LLM provider error
|
|
244
|
+
test('LLM provider error → returns deterministic, reason "provider_error"', async () => {
|
|
245
|
+
mockSendMessage.mockRejectedValueOnce(new Error('API error'));
|
|
246
|
+
const gen = getCommitMessageGenerator();
|
|
247
|
+
const result = await gen.generateCommitMessage(baseContext, {
|
|
248
|
+
changedFiles: baseContext.changedFiles,
|
|
249
|
+
});
|
|
250
|
+
expect(result.source).toBe('deterministic');
|
|
251
|
+
expect(result.reason).toBe('provider_error');
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// 10. LLM invalid output (empty string)
|
|
255
|
+
test('LLM invalid output (empty string) → returns deterministic, reason "invalid_output"', async () => {
|
|
256
|
+
mockSendMessage.mockResolvedValueOnce(makeSuccessResponse(''));
|
|
257
|
+
const gen = getCommitMessageGenerator();
|
|
258
|
+
const result = await gen.generateCommitMessage(baseContext, {
|
|
259
|
+
changedFiles: baseContext.changedFiles,
|
|
260
|
+
});
|
|
261
|
+
expect(result.source).toBe('deterministic');
|
|
262
|
+
expect(result.reason).toBe('invalid_output');
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// 11. LLM subject > 72 chars → truncated to 72, still source "llm"
|
|
266
|
+
test('LLM subject > 72 chars → truncated to 72, source "llm"', async () => {
|
|
267
|
+
const longSubject = 'a'.repeat(100);
|
|
268
|
+
mockSendMessage.mockResolvedValueOnce(makeSuccessResponse(longSubject));
|
|
269
|
+
const gen = getCommitMessageGenerator();
|
|
270
|
+
const result = await gen.generateCommitMessage(baseContext, {
|
|
271
|
+
changedFiles: baseContext.changedFiles,
|
|
272
|
+
});
|
|
273
|
+
expect(result.source).toBe('llm');
|
|
274
|
+
expect(result.reason).toBeUndefined();
|
|
275
|
+
expect(result.message.split('\n')[0].length).toBeLessThanOrEqual(72);
|
|
276
|
+
expect(result.message).toBe('a'.repeat(72));
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// 11b. LLM subject > 72 chars with body → subject truncated, body preserved
|
|
280
|
+
test('LLM subject > 72 chars with body → subject truncated, body preserved', async () => {
|
|
281
|
+
const longSubject = 'b'.repeat(80);
|
|
282
|
+
const body = '\n\n- bullet one\n- bullet two';
|
|
283
|
+
mockSendMessage.mockResolvedValueOnce(makeSuccessResponse(longSubject + body));
|
|
284
|
+
const gen = getCommitMessageGenerator();
|
|
285
|
+
const result = await gen.generateCommitMessage(baseContext, {
|
|
286
|
+
changedFiles: baseContext.changedFiles,
|
|
287
|
+
});
|
|
288
|
+
expect(result.source).toBe('llm');
|
|
289
|
+
expect(result.reason).toBeUndefined();
|
|
290
|
+
expect(result.message.split('\n')[0].length).toBeLessThanOrEqual(72);
|
|
291
|
+
expect(result.message).toBe('b'.repeat(72) + body);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// 12. Keyless provider (Ollama) without fast model → missing_fast_model (skips API key check)
|
|
295
|
+
test('Ollama without API key or fast model → returns deterministic, reason "missing_fast_model"', async () => {
|
|
296
|
+
(currentConfig as Record<string, unknown>).provider = 'ollama';
|
|
297
|
+
currentConfig.apiKeys = {} as Record<string, string>;
|
|
298
|
+
const gen = getCommitMessageGenerator();
|
|
299
|
+
const result = await gen.generateCommitMessage(baseContext, {
|
|
300
|
+
changedFiles: baseContext.changedFiles,
|
|
301
|
+
});
|
|
302
|
+
expect(result.source).toBe('deterministic');
|
|
303
|
+
expect(result.reason).toBe('missing_fast_model');
|
|
304
|
+
expect(result.reason).not.toBe('missing_provider_api_key');
|
|
305
|
+
expect(mockSendMessage).not.toHaveBeenCalled();
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// 13. Unknown provider without fast model default → missing_fast_model, no provider call
|
|
309
|
+
test('Unknown provider without fast model default → returns deterministic, reason "missing_fast_model"', async () => {
|
|
310
|
+
(currentConfig as Record<string, unknown>).provider = 'exotic-provider';
|
|
311
|
+
currentConfig.apiKeys = { 'exotic-provider': 'sk-exotic' } as Record<string, string>;
|
|
312
|
+
const gen = getCommitMessageGenerator();
|
|
313
|
+
const result = await gen.generateCommitMessage(baseContext, {
|
|
314
|
+
changedFiles: baseContext.changedFiles,
|
|
315
|
+
});
|
|
316
|
+
expect(result.source).toBe('deterministic');
|
|
317
|
+
expect(result.reason).toBe('missing_fast_model');
|
|
318
|
+
expect(mockSendMessage).not.toHaveBeenCalled();
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// 14. Fast-model override enables LLM path for provider without built-in default
|
|
322
|
+
test('fast-model override enables LLM path for provider without built-in default', async () => {
|
|
323
|
+
(currentConfig as Record<string, unknown>).provider = 'ollama';
|
|
324
|
+
currentConfig.apiKeys = {} as Record<string, string>; // Ollama is keyless
|
|
325
|
+
currentConfig.workspaceGit.commitMessageLLM.providerFastModelOverrides = {
|
|
326
|
+
ollama: 'llama3.2:3b',
|
|
327
|
+
};
|
|
328
|
+
const commitMsg = 'fix: local model commit';
|
|
329
|
+
mockSendMessage.mockResolvedValueOnce(makeSuccessResponse(commitMsg));
|
|
330
|
+
const gen = getCommitMessageGenerator();
|
|
331
|
+
const result = await gen.generateCommitMessage(baseContext, {
|
|
332
|
+
changedFiles: baseContext.changedFiles,
|
|
333
|
+
});
|
|
334
|
+
expect(result.source).toBe('llm');
|
|
335
|
+
expect(result.message).toBe(commitMsg);
|
|
336
|
+
|
|
337
|
+
// Verify the override model was passed
|
|
338
|
+
const callArgs = mockSendMessage.mock.calls[0];
|
|
339
|
+
const options = callArgs[3] as { config: { model: string } };
|
|
340
|
+
expect(options.config.model).toBe('llama3.2:3b');
|
|
341
|
+
});
|
|
342
|
+
});
|