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,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
+ });