vellum 0.2.2 → 0.2.7

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 (32) hide show
  1. package/bun.lock +68 -100
  2. package/package.json +3 -3
  3. package/src/__tests__/config-schema.test.ts +6 -0
  4. package/src/__tests__/handlers-twilio-config.test.ts +221 -0
  5. package/src/__tests__/ipc-snapshot.test.ts +9 -0
  6. package/src/__tests__/memory-regressions.test.ts +100 -2
  7. package/src/__tests__/provider-commit-message-generator.test.ts +303 -0
  8. package/src/__tests__/session-conflict-gate.test.ts +28 -25
  9. package/src/calls/__tests__/twilio-webhook-urls.test.ts +162 -0
  10. package/src/calls/call-domain.ts +3 -3
  11. package/src/calls/twilio-config.ts +8 -8
  12. package/src/calls/twilio-provider.ts +4 -4
  13. package/src/calls/twilio-webhook-urls.ts +50 -0
  14. package/src/cli/map.ts +30 -6
  15. package/src/config/defaults.ts +1 -0
  16. package/src/config/schema.ts +4 -0
  17. package/src/config/vellum-skills/telegram-setup/SKILL.md +1 -5
  18. package/src/daemon/handlers/config.ts +44 -2
  19. package/src/daemon/ipc-contract-inventory.json +4 -0
  20. package/src/daemon/ipc-contract.ts +23 -0
  21. package/src/daemon/ride-shotgun-handler.ts +2 -1
  22. package/src/daemon/session-agent-loop.ts +37 -2
  23. package/src/daemon/session-conflict-gate.ts +18 -109
  24. package/src/memory/conflict-intent.ts +114 -0
  25. package/src/memory/job-handlers/conflict.ts +23 -1
  26. package/src/runtime/gateway-client.ts +36 -0
  27. package/src/runtime/http-server.ts +58 -2
  28. package/src/runtime/routes/channel-routes.ts +121 -79
  29. package/src/tools/browser/api-map.ts +123 -50
  30. package/src/tools/claude-code/claude-code.ts +130 -0
  31. package/src/workspace/commit-message-enrichment-service.ts +3 -3
  32. package/src/workspace/provider-commit-message-generator.ts +28 -1
@@ -646,6 +646,7 @@ describe('AssistantConfigSchema', () => {
646
646
  expect(result.calls).toEqual({
647
647
  enabled: true,
648
648
  provider: 'twilio',
649
+ webhookBaseUrl: '',
649
650
  maxDurationSeconds: 3600,
650
651
  userConsultTimeoutSeconds: 120,
651
652
  disclosure: {
@@ -658,6 +659,11 @@ describe('AssistantConfigSchema', () => {
658
659
  });
659
660
  });
660
661
 
662
+ test('calls.webhookBaseUrl defaults to empty string', () => {
663
+ const result = AssistantConfigSchema.parse({});
664
+ expect(result.calls.webhookBaseUrl).toBe('');
665
+ });
666
+
661
667
  test('accepts valid calls config overrides', () => {
662
668
  const result = AssistantConfigSchema.parse({
663
669
  calls: {
@@ -0,0 +1,221 @@
1
+ import { describe, test, expect, mock, beforeEach } from 'bun:test';
2
+ import { mkdtempSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import * as net from 'node:net';
6
+
7
+ const testDir = mkdtempSync(join(tmpdir(), 'handlers-twilio-cfg-test-'));
8
+
9
+ let rawConfigStore: Record<string, unknown> = {};
10
+ const saveRawConfigCalls: Record<string, unknown>[] = [];
11
+
12
+ mock.module('../config/loader.js', () => ({
13
+ getConfig: () => ({}),
14
+ loadConfig: () => ({}),
15
+ loadRawConfig: () => ({ ...rawConfigStore }),
16
+ saveRawConfig: (cfg: Record<string, unknown>) => {
17
+ saveRawConfigCalls.push(cfg);
18
+ rawConfigStore = { ...cfg };
19
+ },
20
+ saveConfig: () => {},
21
+ invalidateConfigCache: () => {},
22
+ }));
23
+
24
+ mock.module('../util/platform.js', () => ({
25
+ getRootDir: () => testDir,
26
+ getDataDir: () => testDir,
27
+ getIpcBlobDir: () => join(testDir, 'ipc-blobs'),
28
+ isMacOS: () => process.platform === 'darwin',
29
+ isLinux: () => process.platform === 'linux',
30
+ isWindows: () => process.platform === 'win32',
31
+ getSocketPath: () => join(testDir, 'test.sock'),
32
+ getPidPath: () => join(testDir, 'test.pid'),
33
+ getDbPath: () => join(testDir, 'test.db'),
34
+ getLogPath: () => join(testDir, 'test.log'),
35
+ ensureDataDir: () => {},
36
+ }));
37
+
38
+ mock.module('../util/logger.js', () => ({
39
+ getLogger: () => ({
40
+ info: () => {},
41
+ warn: () => {},
42
+ error: () => {},
43
+ debug: () => {},
44
+ trace: () => {},
45
+ fatal: () => {},
46
+ child: () => ({
47
+ info: () => {},
48
+ warn: () => {},
49
+ error: () => {},
50
+ debug: () => {},
51
+ }),
52
+ }),
53
+ }));
54
+
55
+ mock.module('../memory/app-store.js', () => ({
56
+ queryAppRecords: () => [],
57
+ createAppRecord: () => {},
58
+ updateAppRecord: () => {},
59
+ deleteAppRecord: () => {},
60
+ listApps: () => [],
61
+ getApp: () => undefined,
62
+ createApp: () => {},
63
+ updateApp: () => {},
64
+ }));
65
+
66
+ mock.module('../slack/slack-webhook.js', () => ({
67
+ postToSlackWebhook: async () => {},
68
+ }));
69
+
70
+ import { handleMessage, type HandlerContext } from '../daemon/handlers.js';
71
+ import type {
72
+ TwilioWebhookConfigRequest,
73
+ ServerMessage,
74
+ } from '../daemon/ipc-contract.js';
75
+
76
+ function createTestContext(): { ctx: HandlerContext; sent: ServerMessage[] } {
77
+ const sent: ServerMessage[] = [];
78
+ const ctx: HandlerContext = {
79
+ sessions: new Map(),
80
+ socketToSession: new Map(),
81
+ cuSessions: new Map(),
82
+ socketToCuSession: new Map(),
83
+ cuObservationParseSequence: new Map(),
84
+ socketSandboxOverride: new Map(),
85
+ sharedRequestTimestamps: [],
86
+ debounceTimers: new Map(),
87
+ suppressConfigReload: false,
88
+ setSuppressConfigReload: () => {},
89
+ updateConfigFingerprint: () => {},
90
+ send: (_socket, msg) => { sent.push(msg); },
91
+ broadcast: () => {},
92
+ clearAllSessions: () => 0,
93
+ getOrCreateSession: () => { throw new Error('not implemented'); },
94
+ touchSession: () => {},
95
+ };
96
+ return { ctx, sent };
97
+ }
98
+
99
+ describe('Twilio webhook config handler', () => {
100
+ beforeEach(() => {
101
+ rawConfigStore = {};
102
+ saveRawConfigCalls.length = 0;
103
+ });
104
+
105
+ test('get returns empty string when no config set', () => {
106
+ rawConfigStore = {};
107
+
108
+ const msg: TwilioWebhookConfigRequest = {
109
+ type: 'twilio_webhook_config',
110
+ action: 'get',
111
+ };
112
+
113
+ const { ctx, sent } = createTestContext();
114
+ handleMessage(msg, {} as net.Socket, ctx);
115
+
116
+ expect(sent).toHaveLength(1);
117
+ const res = sent[0] as { type: string; webhookBaseUrl: string; success: boolean };
118
+ expect(res.type).toBe('twilio_webhook_config_response');
119
+ expect(res.success).toBe(true);
120
+ expect(res.webhookBaseUrl).toBe('');
121
+ });
122
+
123
+ test('set persists value and returns it', () => {
124
+ rawConfigStore = {};
125
+
126
+ const msg: TwilioWebhookConfigRequest = {
127
+ type: 'twilio_webhook_config',
128
+ action: 'set',
129
+ webhookBaseUrl: 'https://example.com/twilio',
130
+ };
131
+
132
+ const { ctx, sent } = createTestContext();
133
+ handleMessage(msg, {} as net.Socket, ctx);
134
+
135
+ expect(sent).toHaveLength(1);
136
+ const res = sent[0] as { type: string; webhookBaseUrl: string; success: boolean };
137
+ expect(res.type).toBe('twilio_webhook_config_response');
138
+ expect(res.success).toBe(true);
139
+ expect(res.webhookBaseUrl).toBe('https://example.com/twilio');
140
+
141
+ expect(saveRawConfigCalls).toHaveLength(1);
142
+ const saved = saveRawConfigCalls[0] as { calls?: { webhookBaseUrl?: string } };
143
+ expect(saved.calls?.webhookBaseUrl).toBe('https://example.com/twilio');
144
+ });
145
+
146
+ test('set normalizes trailing slashes', () => {
147
+ rawConfigStore = {};
148
+
149
+ const msg: TwilioWebhookConfigRequest = {
150
+ type: 'twilio_webhook_config',
151
+ action: 'set',
152
+ webhookBaseUrl: 'https://example.com/twilio///',
153
+ };
154
+
155
+ const { ctx, sent } = createTestContext();
156
+ handleMessage(msg, {} as net.Socket, ctx);
157
+
158
+ expect(sent).toHaveLength(1);
159
+ const res = sent[0] as { type: string; webhookBaseUrl: string; success: boolean };
160
+ expect(res.webhookBaseUrl).toBe('https://example.com/twilio');
161
+
162
+ const saved = saveRawConfigCalls[0] as { calls?: { webhookBaseUrl?: string } };
163
+ expect(saved.calls?.webhookBaseUrl).toBe('https://example.com/twilio');
164
+ });
165
+
166
+ test('set treats empty string as unset', () => {
167
+ rawConfigStore = { calls: { webhookBaseUrl: 'https://example.com/twilio' } };
168
+ saveRawConfigCalls.length = 0;
169
+
170
+ const msg: TwilioWebhookConfigRequest = {
171
+ type: 'twilio_webhook_config',
172
+ action: 'set',
173
+ webhookBaseUrl: '',
174
+ };
175
+
176
+ const { ctx, sent } = createTestContext();
177
+ handleMessage(msg, {} as net.Socket, ctx);
178
+
179
+ expect(sent).toHaveLength(1);
180
+ const res = sent[0] as { type: string; webhookBaseUrl: string; success: boolean };
181
+ expect(res.success).toBe(true);
182
+ expect(res.webhookBaseUrl).toBe('');
183
+
184
+ const saved = saveRawConfigCalls[0] as { calls?: { webhookBaseUrl?: string } };
185
+ expect(saved.calls?.webhookBaseUrl).toBeUndefined();
186
+ });
187
+
188
+ test('get after set roundtrip works', () => {
189
+ rawConfigStore = {};
190
+
191
+ // Set
192
+ const setMsg: TwilioWebhookConfigRequest = {
193
+ type: 'twilio_webhook_config',
194
+ action: 'set',
195
+ webhookBaseUrl: 'https://my-server.ngrok.io',
196
+ };
197
+
198
+ const { ctx: setCtx, sent: setSent } = createTestContext();
199
+ handleMessage(setMsg, {} as net.Socket, setCtx);
200
+
201
+ expect(setSent).toHaveLength(1);
202
+ const setRes = setSent[0] as { type: string; webhookBaseUrl: string; success: boolean };
203
+ expect(setRes.success).toBe(true);
204
+ expect(setRes.webhookBaseUrl).toBe('https://my-server.ngrok.io');
205
+
206
+ // Get (rawConfigStore was updated by the mock saveRawConfig)
207
+ const getMsg: TwilioWebhookConfigRequest = {
208
+ type: 'twilio_webhook_config',
209
+ action: 'get',
210
+ };
211
+
212
+ const { ctx: getCtx, sent: getSent } = createTestContext();
213
+ handleMessage(getMsg, {} as net.Socket, getCtx);
214
+
215
+ expect(getSent).toHaveLength(1);
216
+ const getRes = getSent[0] as { type: string; webhookBaseUrl: string; success: boolean };
217
+ expect(getRes.type).toBe('twilio_webhook_config_response');
218
+ expect(getRes.success).toBe(true);
219
+ expect(getRes.webhookBaseUrl).toBe('https://my-server.ngrok.io');
220
+ });
221
+ });
@@ -339,6 +339,10 @@ const clientMessages: Record<ClientMessageType, ClientMessage> = {
339
339
  type: 'slack_webhook_config',
340
340
  action: 'get',
341
341
  },
342
+ twilio_webhook_config: {
343
+ type: 'twilio_webhook_config',
344
+ action: 'get',
345
+ },
342
346
  vercel_api_config: {
343
347
  type: 'vercel_api_config',
344
348
  action: 'get',
@@ -1125,6 +1129,11 @@ const serverMessages: Record<ServerMessageType, ServerMessage> = {
1125
1129
  webhookUrl: 'https://hooks.slack.com/services/T00/B00/xxx',
1126
1130
  success: true,
1127
1131
  },
1132
+ twilio_webhook_config_response: {
1133
+ type: 'twilio_webhook_config_response',
1134
+ webhookBaseUrl: 'https://example.com/twilio',
1135
+ success: true,
1136
+ },
1128
1137
  vercel_api_config_response: {
1129
1138
  type: 'vercel_api_config_response',
1130
1139
  hasToken: true,
@@ -949,7 +949,7 @@ describe('Memory regressions', () => {
949
949
  id: 'msg-conflicts-bg',
950
950
  conversationId: 'conv-conflicts-bg',
951
951
  role: 'user',
952
- content: JSON.stringify([{ type: 'text', text: 'Keep the new one instead.' }]),
952
+ content: JSON.stringify([{ type: 'text', text: 'Keep the new MySQL default instead.' }]),
953
953
  createdAt: now + 1,
954
954
  }).run();
955
955
 
@@ -1047,7 +1047,7 @@ describe('Memory regressions', () => {
1047
1047
  id: 'msg-conflicts-age',
1048
1048
  conversationId: 'conv-conflicts-age',
1049
1049
  role: 'user',
1050
- content: JSON.stringify([{ type: 'text', text: 'Keep the new one instead.' }]),
1050
+ content: JSON.stringify([{ type: 'text', text: 'Keep the new Bun runtime instead.' }]),
1051
1051
  createdAt: now + 1,
1052
1052
  }).run();
1053
1053
 
@@ -1118,6 +1118,104 @@ describe('Memory regressions', () => {
1118
1118
  }
1119
1119
  });
1120
1120
 
1121
+ test('background conflict resolver ignores clarification-like replies with no topical overlap when conflict was never asked', async () => {
1122
+ const db = getDb();
1123
+ const now = 1_700_001_400_000;
1124
+ const originalConflictsEnabled = TEST_CONFIG.memory.conflicts.enabled;
1125
+ TEST_CONFIG.memory.conflicts.enabled = true;
1126
+
1127
+ try {
1128
+ db.insert(conversations).values({
1129
+ id: 'conv-conflicts-unrelated',
1130
+ title: null,
1131
+ createdAt: now,
1132
+ updatedAt: now,
1133
+ totalInputTokens: 0,
1134
+ totalOutputTokens: 0,
1135
+ totalEstimatedCost: 0,
1136
+ contextSummary: null,
1137
+ contextCompactedMessageCount: 0,
1138
+ contextCompactedAt: null,
1139
+ }).run();
1140
+
1141
+ db.insert(messages).values({
1142
+ id: 'msg-conflicts-unrelated',
1143
+ conversationId: 'conv-conflicts-unrelated',
1144
+ role: 'user',
1145
+ content: JSON.stringify([{ type: 'text', text: 'Keep the new one instead.' }]),
1146
+ createdAt: now + 1,
1147
+ }).run();
1148
+
1149
+ db.insert(memoryItems).values([
1150
+ {
1151
+ id: 'item-conflict-existing-unrelated',
1152
+ kind: 'preference',
1153
+ subject: 'database',
1154
+ statement: 'Use Postgres by default.',
1155
+ status: 'active',
1156
+ confidence: 0.8,
1157
+ fingerprint: 'fp-conflict-existing-unrelated',
1158
+ verificationState: 'assistant_inferred',
1159
+ scopeId: 'scope-conflicts-unrelated',
1160
+ firstSeenAt: now - 10_000,
1161
+ lastSeenAt: now - 5_000,
1162
+ validFrom: now - 10_000,
1163
+ invalidAt: null,
1164
+ },
1165
+ {
1166
+ id: 'item-conflict-candidate-unrelated',
1167
+ kind: 'preference',
1168
+ subject: 'database',
1169
+ statement: 'Use MySQL by default.',
1170
+ status: 'pending_clarification',
1171
+ confidence: 0.8,
1172
+ fingerprint: 'fp-conflict-candidate-unrelated',
1173
+ verificationState: 'assistant_inferred',
1174
+ scopeId: 'scope-conflicts-unrelated',
1175
+ firstSeenAt: now - 9_000,
1176
+ lastSeenAt: now - 4_000,
1177
+ validFrom: now - 9_000,
1178
+ invalidAt: null,
1179
+ },
1180
+ ]).run();
1181
+
1182
+ const conflict = createOrUpdatePendingConflict({
1183
+ scopeId: 'scope-conflicts-unrelated',
1184
+ existingItemId: 'item-conflict-existing-unrelated',
1185
+ candidateItemId: 'item-conflict-candidate-unrelated',
1186
+ relationship: 'ambiguous_contradiction',
1187
+ });
1188
+ db.update(memoryItemConflicts)
1189
+ .set({ createdAt: now, updatedAt: now, lastAskedAt: null })
1190
+ .where(eq(memoryItemConflicts.id, conflict.id))
1191
+ .run();
1192
+
1193
+ enqueueResolvePendingConflictsForMessageJob('msg-conflicts-unrelated', 'scope-conflicts-unrelated');
1194
+ const processed = await runMemoryJobsOnce();
1195
+ expect(processed).toBe(1);
1196
+
1197
+ const existing = db
1198
+ .select()
1199
+ .from(memoryItems)
1200
+ .where(eq(memoryItems.id, 'item-conflict-existing-unrelated'))
1201
+ .get();
1202
+ const candidate = db
1203
+ .select()
1204
+ .from(memoryItems)
1205
+ .where(eq(memoryItems.id, 'item-conflict-candidate-unrelated'))
1206
+ .get();
1207
+ const updatedConflict = getConflictById(conflict.id);
1208
+
1209
+ expect(existing?.status).toBe('active');
1210
+ expect(existing?.invalidAt).toBeNull();
1211
+ expect(candidate?.status).toBe('pending_clarification');
1212
+ expect(updatedConflict?.status).toBe('pending_clarification');
1213
+ expect(updatedConflict?.resolutionNote).toBeNull();
1214
+ } finally {
1215
+ TEST_CONFIG.memory.conflicts.enabled = originalConflictsEnabled;
1216
+ }
1217
+ });
1218
+
1121
1219
  test('cleanup job enqueue is deduped and retention overrides upgrade payload', () => {
1122
1220
  const db = getDb();
1123
1221
 
@@ -0,0 +1,303 @@
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 output too long (subject > 72 chars)
266
+ test('LLM output too long (subject > 72 chars) → returns deterministic, reason "invalid_output"', async () => {
267
+ const longSubject = 'a'.repeat(73);
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('deterministic');
274
+ expect(result.reason).toBe('invalid_output');
275
+ });
276
+
277
+ // 12. Keyless provider (Ollama) skips API key preflight
278
+ test('Ollama without API key → does not return missing_provider_api_key', async () => {
279
+ (currentConfig as Record<string, unknown>).provider = 'ollama';
280
+ currentConfig.apiKeys = {} as Record<string, string>;
281
+ // Ollama has no default fast model, so it will fall through to provider_error,
282
+ // but crucially it should NOT be blocked by missing_provider_api_key
283
+ const gen = getCommitMessageGenerator();
284
+ const result = await gen.generateCommitMessage(baseContext, {
285
+ changedFiles: baseContext.changedFiles,
286
+ });
287
+ expect(result.source).toBe('deterministic');
288
+ expect(result.reason).not.toBe('missing_provider_api_key');
289
+ });
290
+
291
+ // 13. No default fast model for unknown provider
292
+ test('No default fast model for unknown provider → returns deterministic fallback', async () => {
293
+ (currentConfig as Record<string, unknown>).provider = 'exotic-provider';
294
+ currentConfig.apiKeys = { 'exotic-provider': 'sk-exotic' } as Record<string, string>;
295
+ const gen = getCommitMessageGenerator();
296
+ const result = await gen.generateCommitMessage(baseContext, {
297
+ changedFiles: baseContext.changedFiles,
298
+ });
299
+ expect(result.source).toBe('deterministic');
300
+ expect(result.reason).toBe('provider_error');
301
+ expect(mockSendMessage).not.toHaveBeenCalled();
302
+ });
303
+ });