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.
- package/bun.lock +68 -100
- package/package.json +3 -3
- package/src/__tests__/config-schema.test.ts +6 -0
- package/src/__tests__/handlers-twilio-config.test.ts +221 -0
- package/src/__tests__/ipc-snapshot.test.ts +9 -0
- package/src/__tests__/memory-regressions.test.ts +100 -2
- package/src/__tests__/provider-commit-message-generator.test.ts +303 -0
- package/src/__tests__/session-conflict-gate.test.ts +28 -25
- 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 +8 -8
- package/src/calls/twilio-provider.ts +4 -4
- package/src/calls/twilio-webhook-urls.ts +50 -0
- package/src/cli/map.ts +30 -6
- package/src/config/defaults.ts +1 -0
- package/src/config/schema.ts +4 -0
- package/src/config/vellum-skills/telegram-setup/SKILL.md +1 -5
- package/src/daemon/handlers/config.ts +44 -2
- package/src/daemon/ipc-contract-inventory.json +4 -0
- package/src/daemon/ipc-contract.ts +23 -0
- 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/memory/conflict-intent.ts +114 -0
- package/src/memory/job-handlers/conflict.ts +23 -1
- package/src/runtime/gateway-client.ts +36 -0
- package/src/runtime/http-server.ts +58 -2
- package/src/runtime/routes/channel-routes.ts +121 -79
- package/src/tools/browser/api-map.ts +123 -50
- package/src/tools/claude-code/claude-code.ts +130 -0
- package/src/workspace/commit-message-enrichment-service.ts +3 -3
- 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
|
|
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
|
|
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
|
+
});
|