llm-switcher 1.6.6 → 1.6.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.
@@ -0,0 +1,316 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import http from 'node:http';
7
+ import { spawn } from 'node:child_process';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
11
+ const listen = server => new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
12
+
13
+ async function until(check, timeoutMs = 6000) {
14
+ const deadline = Date.now() + timeoutMs;
15
+ while (Date.now() < deadline) {
16
+ if (await check()) return;
17
+ await new Promise(r => setTimeout(r, 25));
18
+ }
19
+ throw new Error('timeout waiting for condition');
20
+ }
21
+
22
+ async function fixture(t) {
23
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-goalcheck-'));
24
+ const adminToken = 'admin-secret-token-123';
25
+ fs.writeFileSync(path.join(dir, 'admin.token'), adminToken, { mode: 0o600 });
26
+
27
+ let nativeVerdict = { ok: true, reason: 'looks complete' };
28
+ let jevScores = { complete: 0.14, evidence: 0.84, unfinished: 0.63 };
29
+ let jevStatus = 200;
30
+
31
+ // Mock native upstream (Anthropic format)
32
+ const nativeUp = http.createServer((req, res) => {
33
+ let raw = '';
34
+ req.on('data', b => raw += b);
35
+ req.on('end', () => {
36
+ if (req.method !== 'POST' || !raw.trim()) {
37
+ res.setHeader('content-type', 'application/json');
38
+ return res.end('{"data":[]}');
39
+ }
40
+ const body = JSON.parse(raw);
41
+ const isStream = Boolean(body.stream);
42
+ const respText = JSON.stringify(nativeVerdict);
43
+
44
+ if (isStream) {
45
+ res.setHeader('content-type', 'text/event-stream');
46
+ res.write(`event: message_start\ndata: {"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","model":"claude-3-haiku","content":[],"usage":{"input_tokens":10,"output_tokens":5}}}\n\n`);
47
+ res.write(`event: content_block_start\ndata: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}\n\n`);
48
+ res.write(`event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":${JSON.stringify(respText)}}}\n\n`);
49
+ res.write(`event: content_block_stop\ndata: {"type":"content_block_stop","index":0}\n\n`);
50
+ res.write(`event: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"end_turn"}}\n\n`);
51
+ res.write(`event: message_stop\ndata: {"type":"message_stop"}\n\n`);
52
+ res.end();
53
+ } else {
54
+ res.setHeader('content-type', 'application/json');
55
+ res.end(JSON.stringify({
56
+ id: 'msg_1',
57
+ type: 'message',
58
+ role: 'assistant',
59
+ model: 'claude-3-haiku',
60
+ content: [{ type: 'text', text: respText }],
61
+ stop_reason: 'end_turn'
62
+ }));
63
+ }
64
+ });
65
+ });
66
+ await listen(nativeUp);
67
+
68
+ // Mock Jev decision upstream
69
+ let jevRequests = [];
70
+ const jevUp = http.createServer((req, res) => {
71
+ let raw = '';
72
+ req.on('data', b => raw += b);
73
+ req.on('end', () => {
74
+ if (req.method === 'POST') {
75
+ jevRequests.push({ url: req.url, headers: req.headers, body: JSON.parse(raw) });
76
+ }
77
+ if (jevStatus !== 200) {
78
+ res.statusCode = jevStatus;
79
+ return res.end('error');
80
+ }
81
+ res.setHeader('content-type', 'application/json');
82
+ res.end(JSON.stringify({
83
+ model: 'typesafe/jev-latest',
84
+ answers: {
85
+ complete: { type: 'noul', noul: jevScores.complete },
86
+ evidence: { type: 'noul', noul: jevScores.evidence },
87
+ unfinished: { type: 'noul', noul: jevScores.unfinished }
88
+ }
89
+ }));
90
+ });
91
+ });
92
+ await listen(jevUp);
93
+
94
+ const reserve = http.createServer();
95
+ await listen(reserve);
96
+ const port = reserve.address().port;
97
+ await new Promise(r => reserve.close(r));
98
+
99
+ const configPath = path.join(dir, 'config.json');
100
+ fs.writeFileSync(configPath, JSON.stringify({
101
+ port,
102
+ activeProfile: 'p1',
103
+ activeProfiles: { claude: 'p1' },
104
+ profiles: {
105
+ p1: {
106
+ name: 'NativeMock',
107
+ mode: 'direct',
108
+ tool: 'claude',
109
+ inFormat: 'auto',
110
+ outFormat: 'anthropic',
111
+ baseURL: `http://127.0.0.1:${nativeUp.address().port}`,
112
+ apiKey: 'sk-native-key'
113
+ }
114
+ },
115
+ goalCheck: {
116
+ enabled: false,
117
+ backend: 'jev',
118
+ baseURL: `http://127.0.0.1:${jevUp.address().port}`,
119
+ apiKey: 'jev-test-key',
120
+ model: 'typesafe/jev-latest',
121
+ completeMin: 0.90,
122
+ evidenceMin: 0.85,
123
+ unfinishedMax: 0.10
124
+ }
125
+ }));
126
+
127
+ const child = spawn(process.execPath, [path.join(root, 'proxy.mjs')], {
128
+ env: {
129
+ ...process.env,
130
+ LLM_SWITCHER_CONFIG: configPath,
131
+ LLM_SWITCHER_STATE_DIR: dir,
132
+ LLM_SWITCHER_PORT: String(port)
133
+ },
134
+ stdio: ['ignore', 'pipe', 'pipe']
135
+ });
136
+
137
+ let logs = '';
138
+ child.stdout.on('data', b => logs += b);
139
+ child.stderr.on('data', b => logs += b);
140
+
141
+ t.after(async () => {
142
+ if (child.exitCode === null) {
143
+ const done = new Promise(r => child.once('exit', r));
144
+ child.kill();
145
+ await done;
146
+ }
147
+ nativeUp.closeAllConnections?.();
148
+ jevUp.closeAllConnections?.();
149
+ await Promise.all([
150
+ new Promise(r => nativeUp.close(r)),
151
+ new Promise(r => jevUp.close(r))
152
+ ]);
153
+ fs.rmSync(dir, { recursive: true, force: true });
154
+ });
155
+
156
+ await until(async () => (await fetch(`http://127.0.0.1:${port}/health`).catch(() => null))?.ok);
157
+
158
+ const adminHeaders = {
159
+ 'x-llm-switcher-token': adminToken,
160
+ 'content-type': 'application/json'
161
+ };
162
+
163
+ const sendAdmin = (path, method = 'GET', body = null) => fetch(`http://127.0.0.1:${port}${path}`, {
164
+ method,
165
+ headers: adminHeaders,
166
+ body: body ? JSON.stringify(body) : undefined
167
+ });
168
+
169
+ const sendClaude = (body, stream = false) => fetch(`http://127.0.0.1:${port}/v1/messages`, {
170
+ method: 'POST',
171
+ headers: {
172
+ 'content-type': 'application/json',
173
+ 'user-agent': 'claude-cli/2.1.295'
174
+ },
175
+ body: JSON.stringify({ ...body, stream })
176
+ });
177
+
178
+ return {
179
+ port,
180
+ sendAdmin,
181
+ sendClaude,
182
+ setNativeVerdict: v => { nativeVerdict = v; },
183
+ setJevScores: s => { jevScores = s; },
184
+ setJevStatus: st => { jevStatus = st; },
185
+ jevRequests,
186
+ logs: () => logs
187
+ };
188
+ }
189
+
190
+ const evaluatorRequest = {
191
+ model: 'claude-3-haiku-20240307',
192
+ system: 'You are evaluating a stop-condition hook in Claude Code. Read the conversation transcript carefully, then judge whether the user-provided condition is satisfied.',
193
+ messages: [
194
+ { role: 'user', content: 'Please fix the failing tests in repo.' },
195
+ { role: 'assistant', content: 'I have edited the code and reports.' },
196
+ {
197
+ role: 'user',
198
+ content: 'Based on the conversation transcript above, has the following stopping condition been satisfied? Answer based on transcript evidence only.\n\nCondition: Fix all failing tests.'
199
+ }
200
+ ],
201
+ tools: []
202
+ };
203
+
204
+ test('goalcheck e2e: admin API endpoints and probe', async t => {
205
+ const f = await fixture(t);
206
+
207
+ // 1. GET /api/goal-check
208
+ const r1 = await f.sendAdmin('/api/goal-check');
209
+ assert.equal(r1.status, 200);
210
+ const data1 = await r1.json();
211
+ assert.equal(data1.goalCheck.enabled, false);
212
+ assert.equal(data1.goalCheck.apiKey, '__LLM_SWITCHER_KEEP_KEY__');
213
+ assert.equal(data1.goalCheck.hasApiKey, true);
214
+ assert.equal(data1.status.configured, true);
215
+
216
+ // 2. POST /api/goal-check/test (Probe)
217
+ const probeRes = await f.sendAdmin('/api/goal-check/test', 'POST', {
218
+ model: 'typesafe/jev-latest'
219
+ });
220
+ assert.equal(probeRes.status, 200);
221
+ const probeData = await probeRes.json();
222
+ assert.equal(probeData.ok, true);
223
+ assert.ok(probeData.message.includes('Connected successfully'));
224
+
225
+ // 3. POST /api/goal-check (enable feature)
226
+ const updateRes = await f.sendAdmin('/api/goal-check', 'POST', {
227
+ enabled: true,
228
+ completeMin: 0.90
229
+ });
230
+ assert.equal(updateRes.status, 200);
231
+ const updateData = await updateRes.json();
232
+ assert.equal(updateData.goalCheck.enabled, true);
233
+ assert.equal(updateData.status.enabled, true);
234
+
235
+ // 4. GET /api/goal-check/logs
236
+ const logsRes = await f.sendAdmin('/api/goal-check/logs');
237
+ assert.equal(logsRes.status, 200);
238
+ const logsData = await logsRes.json();
239
+ assert.ok(Array.isArray(logsData.logs));
240
+ });
241
+
242
+ test('goalcheck e2e: live interception, veto, and allow behaviors', async t => {
243
+ const f = await fixture(t);
244
+
245
+ // Case A: Feature is OFF -> passes through untouched
246
+ f.setNativeVerdict({ ok: true, reason: 'native pass' });
247
+ const offRes = await f.sendClaude(evaluatorRequest, false);
248
+ assert.equal(offRes.status, 200);
249
+ const offJson = await offRes.json();
250
+ const offVerdict = JSON.parse(offJson.content[0].text);
251
+ assert.equal(offVerdict.ok, true);
252
+ assert.equal(offVerdict.reason, 'native pass');
253
+ assert.equal(f.jevRequests.length, 0); // Jev was not called!
254
+
255
+ // Enable Goal Check
256
+ await f.sendAdmin('/api/goal-check', 'POST', { enabled: true });
257
+
258
+ // Case B: Feature is ON -> Native passes, but Jev has low complete score (0.14) -> VETOED!
259
+ f.setJevScores({ complete: 0.14, evidence: 0.84, unfinished: 0.63 });
260
+ f.setNativeVerdict({ ok: true, reason: 'audit report PASS' });
261
+
262
+ // Test Non-streaming JSON
263
+ const vetoRes = await f.sendClaude(evaluatorRequest, false);
264
+ assert.equal(vetoRes.status, 200);
265
+ const vetoJson = await vetoRes.json();
266
+ const vetoVerdict = JSON.parse(vetoJson.content[0].text);
267
+ assert.equal(vetoVerdict.ok, false);
268
+ assert.match(vetoVerdict.reason, /Jev has not confirmed full completion/);
269
+ assert.match(vetoVerdict.reason, /complete=0.14/);
270
+ assert.equal(f.jevRequests.length, 1); // Jev was called!
271
+
272
+ // Test SSE streaming: verify valid event stream and vetoed result
273
+ const sseRes = await f.sendClaude(evaluatorRequest, true);
274
+ assert.equal(sseRes.status, 200);
275
+ assert.ok(sseRes.headers.get('content-type').includes('text/event-stream'));
276
+ const sseText = await sseRes.text();
277
+ assert.ok(sseText.includes('event: message_start'));
278
+ assert.ok(sseText.includes('event: content_block_delta'));
279
+ assert.ok(sseText.includes('event: message_stop'));
280
+ assert.ok(!sseText.includes('\\"ok\\":true')); // NEVER sends ok:true!
281
+ assert.ok(sseText.includes('\\"ok\\":false'));
282
+
283
+ // Case C: Feature is ON -> Native passes AND Jev scores pass all thresholds -> ALLOWED!
284
+ f.setJevScores({ complete: 0.95, evidence: 0.90, unfinished: 0.05 });
285
+ const allowRes = await f.sendClaude(evaluatorRequest, false);
286
+ assert.equal(allowRes.status, 200);
287
+ const allowJson = await allowRes.json();
288
+ const allowVerdict = JSON.parse(allowJson.content[0].text);
289
+ assert.equal(allowVerdict.ok, true);
290
+ assert.equal(allowVerdict.reason, 'audit report PASS');
291
+
292
+ // Case D: Native rejects (ok: false) -> preserves native rejection
293
+ f.setNativeVerdict({ ok: false, reason: 'test command exited 1' });
294
+ const rejectRes = await f.sendClaude(evaluatorRequest, false);
295
+ assert.equal(rejectRes.status, 200);
296
+ const rejectJson = await rejectRes.json();
297
+ const rejectVerdict = JSON.parse(rejectJson.content[0].text);
298
+ assert.equal(rejectVerdict.ok, false);
299
+ assert.equal(rejectVerdict.reason, 'test command exited 1');
300
+
301
+ // Case E: Jev unavailable (HTTP 502) -> fails closed
302
+ f.setNativeVerdict({ ok: true, reason: 'claimed done' });
303
+ f.setJevStatus(502);
304
+ const failRes = await f.sendClaude(evaluatorRequest, false);
305
+ assert.equal(failRes.status, 200);
306
+ const failJson = await failRes.json();
307
+ const failVerdict = JSON.parse(failJson.content[0].text);
308
+ assert.equal(failVerdict.ok, false);
309
+ assert.match(failVerdict.reason, /Goal Check verification unavailable/);
310
+
311
+ // Check logs endpoint recorded the checks
312
+ const logsRes = await f.sendAdmin('/api/goal-check/logs');
313
+ const logsData = await logsRes.json();
314
+ assert.ok(logsData.logs.length >= 4);
315
+ assert.equal(logsData.logs[0].verdictType, 'Unavailable');
316
+ });
@@ -0,0 +1,312 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import {
5
+ GOAL_CHECK_DEFAULTS,
6
+ goalCheckPolicy,
7
+ validateGoalCheckPatch,
8
+ redactGoalCheck,
9
+ isGoalEvaluatorRequest,
10
+ extractStoppingCondition,
11
+ buildBoundedEvidence,
12
+ buildJevQuestions,
13
+ combineVerdicts,
14
+ parseEvaluatorVerdict,
15
+ redactSecrets
16
+ } from '../goalcheck.mjs';
17
+
18
+ import {
19
+ resolveJevTarget,
20
+ extractScoreFromAnswer
21
+ } from '../goalcheck-call.mjs';
22
+
23
+ test('goalcheck: policy defaults and bounded values', () => {
24
+ const p = goalCheckPolicy({});
25
+ assert.equal(p.enabled, false);
26
+ assert.equal(p.backend, 'jev');
27
+ assert.equal(p.model, 'typesafe/jev-latest');
28
+ assert.equal(p.completeMin, 0.90);
29
+ assert.equal(p.evidenceMin, 0.85);
30
+ assert.equal(p.unfinishedMax, 0.10);
31
+ assert.equal(p.timeoutMs, 8000);
32
+ assert.equal(p.totalTimeoutMs, 25000);
33
+ assert.equal(p.maxStateChars, 60000);
34
+
35
+ // Clamping
36
+ const clamped = goalCheckPolicy({
37
+ goalCheck: {
38
+ completeMin: 1.5,
39
+ evidenceMin: -0.2,
40
+ unfinishedMax: 2.0,
41
+ timeoutMs: 100, // min 500
42
+ totalTimeoutMs: 200000, // max 120000
43
+ maxStateChars: 100 // min 500
44
+ }
45
+ });
46
+ assert.equal(clamped.completeMin, 1.0);
47
+ assert.equal(clamped.evidenceMin, 0.0);
48
+ assert.equal(clamped.unfinishedMax, 1.0);
49
+ assert.equal(clamped.timeoutMs, 500);
50
+ assert.equal(clamped.totalTimeoutMs, 120000);
51
+ assert.equal(clamped.maxStateChars, 500);
52
+ });
53
+
54
+ test('goalcheck: patch validation', () => {
55
+ assert.match(validateGoalCheckPatch(null), /must be an object/);
56
+ assert.match(validateGoalCheckPatch({ unknownField: true }), /unknown goal check field/);
57
+ assert.match(validateGoalCheckPatch({ enabled: 'yes' }), /enabled must be a boolean/);
58
+ assert.match(validateGoalCheckPatch({ completeMin: 1.2 }), /completeMin must be a number from 0 to 1/);
59
+ assert.match(validateGoalCheckPatch({ completeMin: -0.1 }), /completeMin must be a number from 0 to 1/);
60
+ assert.match(validateGoalCheckPatch({ timeoutMs: 100 }), /timeoutMs must be an integer from 500 to 60000/);
61
+ assert.match(validateGoalCheckPatch({ baseURL: 'ftp://bad' }), /baseURL must start with http/);
62
+
63
+ // While disabled, empty model or url is allowed
64
+ assert.equal(validateGoalCheckPatch({ enabled: false, model: '' }), '');
65
+
66
+ // Enabling without model is rejected
67
+ assert.match(validateGoalCheckPatch({ enabled: true, model: '' }), /model cannot be empty/);
68
+
69
+ // Valid patch
70
+ assert.equal(validateGoalCheckPatch({
71
+ enabled: true,
72
+ model: 'typesafe/jev-latest',
73
+ baseURL: 'https://api.example.com/v1',
74
+ completeMin: 0.88
75
+ }), '');
76
+ });
77
+
78
+ test('goalcheck: redaction', () => {
79
+ const policy = { enabled: true, apiKey: 'secret-key-123', model: 'typesafe/jev-latest' };
80
+ const redacted = redactGoalCheck(policy);
81
+ assert.equal(redacted.apiKey, '__LLM_SWITCHER_KEEP_KEY__');
82
+ assert.equal(redacted.hasApiKey, true);
83
+
84
+ const noKey = redactGoalCheck({ enabled: false, apiKey: '' });
85
+ assert.equal(noKey.apiKey, '');
86
+ assert.equal(noKey.hasApiKey, false);
87
+ });
88
+
89
+ test('goalcheck: evaluator request detection', () => {
90
+ const validEvaluatorPayload = {
91
+ system: 'You are evaluating a stop-condition hook in Claude Code. Read the conversation transcript carefully...',
92
+ messages: [
93
+ { role: 'user', content: 'Fix the bug in auth.js' },
94
+ { role: 'assistant', content: 'Fixed in commit 123' },
95
+ {
96
+ role: 'user',
97
+ content: 'Based on the conversation transcript above, has the following stopping condition been satisfied? Answer based on transcript evidence only.\n\nCondition: Fix the bug in auth.js'
98
+ }
99
+ ],
100
+ tools: []
101
+ };
102
+
103
+ const res = isGoalEvaluatorRequest('anthropic', validEvaluatorPayload, { 'user-agent': 'claude-cli/2.1.295' });
104
+ assert.equal(res.isEvaluator, true);
105
+ assert.equal(res.isSupported, true);
106
+ assert.equal(res.condition, 'Fix the bug in auth.js');
107
+
108
+ // Normal request without stop-hook system prompt
109
+ const normalPayload = {
110
+ system: 'You are Claude, a helpful AI assistant.',
111
+ messages: [{ role: 'user', content: 'What is the goal of AI?' }]
112
+ };
113
+ assert.deepEqual(isGoalEvaluatorRequest('anthropic', normalPayload), { isEvaluator: false, isSupported: false });
114
+
115
+ // Non-anthropic client
116
+ assert.deepEqual(isGoalEvaluatorRequest('responses', validEvaluatorPayload), { isEvaluator: false, isSupported: false });
117
+
118
+ // Evaluator template but with execution tools -> recognized but unsupported shape
119
+ const toolPayload = { ...validEvaluatorPayload, tools: [{ name: 'bash', description: 'run' }] };
120
+ const toolRes = isGoalEvaluatorRequest('anthropic', toolPayload);
121
+ assert.equal(toolRes.isEvaluator, true);
122
+ assert.equal(toolRes.isSupported, false);
123
+ });
124
+
125
+ test('goalcheck: extract stopping condition', () => {
126
+ const payload = {
127
+ messages: [
128
+ { role: 'user', content: 'Earlier chat' },
129
+ { role: 'user', content: 'Based on transcript...\n\nCondition: Ensure all unit tests pass with 0 failures.' }
130
+ ]
131
+ };
132
+ assert.equal(extractStoppingCondition(payload), 'Ensure all unit tests pass with 0 failures.');
133
+ });
134
+
135
+ test('goalcheck: bounded evidence building and secret redaction', () => {
136
+ const payload = {
137
+ messages: [
138
+ { role: 'user', content: 'Please deploy using key sk-1234567890abcdef1234' },
139
+ {
140
+ role: 'assistant',
141
+ content: [
142
+ { type: 'text', text: 'Running test suite' },
143
+ { type: 'tool_use', id: 'call_1', name: 'bash', input: { command: 'npm test' } }
144
+ ]
145
+ },
146
+ {
147
+ role: 'user',
148
+ content: [
149
+ { type: 'tool_result', tool_use_id: 'call_1', content: 'Pass: 167 tests', is_error: false }
150
+ ]
151
+ },
152
+ {
153
+ role: 'user',
154
+ content: 'Based on transcript...\n\nCondition: Tests must pass'
155
+ }
156
+ ]
157
+ };
158
+
159
+ const ev = buildBoundedEvidence(payload, 5000);
160
+ assert.equal(ev.stateTooLarge, false);
161
+ assert.ok(ev.state.includes('STOPPING CONDITION:\n\nTests must pass'));
162
+ assert.ok(ev.state.includes('[Tool Call call_1]: bash({"command":"npm test"})'));
163
+ assert.ok(ev.state.includes('[Tool Result call_1]: is_error=false\nPass: 167 tests'));
164
+ // Secret should be redacted
165
+ assert.ok(!ev.state.includes('sk-1234567890abcdef1234'));
166
+ assert.ok(ev.state.includes('[REDACTED_SECRET]'));
167
+
168
+ // Test size overflow
169
+ const evSmall = buildBoundedEvidence(payload, 100);
170
+ assert.equal(evSmall.stateTooLarge, true);
171
+ assert.ok(evSmall.chars > 100);
172
+ });
173
+
174
+ test('goalcheck: parseEvaluatorVerdict', () => {
175
+ assert.deepEqual(parseEvaluatorVerdict('{"ok": true, "reason": "all fixed"}'), {
176
+ ok: true,
177
+ reason: 'all fixed',
178
+ impossible: false
179
+ });
180
+
181
+ assert.deepEqual(parseEvaluatorVerdict('```json\n{"ok": false, "reason": "test failed", "impossible": true}\n```'), {
182
+ ok: false,
183
+ reason: 'test failed',
184
+ impossible: true
185
+ });
186
+
187
+ assert.deepEqual(parseEvaluatorVerdict('malformed non-json text'), {
188
+ malformed: true,
189
+ raw: 'malformed non-json text'
190
+ });
191
+ });
192
+
193
+ test('goalcheck: combineVerdicts decision matrix', () => {
194
+ const thresholds = { completeMin: 0.90, evidenceMin: 0.85, unfinishedMax: 0.10 };
195
+
196
+ // 1. Native rejected
197
+ const nativeFalse = combineVerdicts({
198
+ nativeVerdict: { ok: false, reason: 'missing file' },
199
+ jevResult: { ok: true, scores: { complete: 0.99, evidence: 0.99, unfinished: 0.01 } },
200
+ thresholds
201
+ });
202
+ assert.equal(nativeFalse.ok, false);
203
+ assert.equal(nativeFalse.reason, 'missing file');
204
+ assert.equal(nativeFalse.verdictType, 'Blocked');
205
+
206
+ // 2. Native impossible
207
+ const nativeImp = combineVerdicts({
208
+ nativeVerdict: { ok: false, impossible: true, reason: 'unsolvable' },
209
+ jevResult: null,
210
+ thresholds
211
+ });
212
+ assert.equal(nativeImp.ok, false);
213
+ assert.equal(nativeImp.impossible, true);
214
+ assert.equal(nativeImp.verdictType, 'Blocked');
215
+
216
+ // 3. Native unavailable / malformed
217
+ const nativeBad = combineVerdicts({
218
+ nativeVerdict: { malformed: true },
219
+ jevResult: { ok: true },
220
+ thresholds
221
+ });
222
+ assert.equal(nativeBad.ok, false);
223
+ assert.equal(nativeBad.verdictType, 'Unavailable');
224
+
225
+ // 4. Native ok: true AND Jev passes all 3 thresholds
226
+ const bothPass = combineVerdicts({
227
+ nativeVerdict: { ok: true, reason: 'tests passed' },
228
+ jevResult: { ok: true, scores: { complete: 0.95, evidence: 0.92, unfinished: 0.04 } },
229
+ thresholds
230
+ });
231
+ assert.equal(bothPass.ok, true);
232
+ assert.equal(bothPass.verdictType, 'Allowed');
233
+ assert.equal(bothPass.reason, 'tests passed');
234
+
235
+ // 5. Native ok: true BUT Jev complete fails
236
+ const completeFail = combineVerdicts({
237
+ nativeVerdict: { ok: true, reason: 'audit pass claimed' },
238
+ jevResult: { ok: true, scores: { complete: 0.14, evidence: 0.88, unfinished: 0.63 } },
239
+ thresholds
240
+ });
241
+ assert.equal(completeFail.ok, false);
242
+ assert.equal(completeFail.verdictType, 'Blocked');
243
+ assert.match(completeFail.reason, /complete=0.14 < min 0.90/);
244
+ assert.match(completeFail.reason, /unfinished=0.63 > max 0.10/);
245
+
246
+ // 6. Native ok: true BUT Jev state too large
247
+ const stateLarge = combineVerdicts({
248
+ nativeVerdict: { ok: true, reason: 'done' },
249
+ jevResult: { stateTooLarge: true, chars: 70000, maxChars: 60000 },
250
+ thresholds
251
+ });
252
+ assert.equal(stateLarge.ok, false);
253
+ assert.equal(stateLarge.verdictType, 'Insufficient evidence');
254
+
255
+ // 7. Native ok: true BUT Jev HTTP/network error
256
+ const jevErr = combineVerdicts({
257
+ nativeVerdict: { ok: true, reason: 'done' },
258
+ jevResult: { ok: false, reason: 'http-502', message: 'HTTP 502 Bad Gateway' },
259
+ thresholds
260
+ });
261
+ assert.equal(jevErr.ok, false);
262
+ assert.equal(jevErr.verdictType, 'Unavailable');
263
+
264
+ // 8. Native ok: true BUT Jev score NaN or missing
265
+ const jevNaN = combineVerdicts({
266
+ nativeVerdict: { ok: true, reason: 'done' },
267
+ jevResult: { ok: true, scores: { complete: Number.NaN, evidence: 0.9, unfinished: 0.05 } },
268
+ thresholds
269
+ });
270
+ assert.equal(jevNaN.ok, false);
271
+ assert.equal(jevNaN.verdictType, 'Unavailable');
272
+ });
273
+
274
+ test('goalcheck-call: target resolution and score extraction', () => {
275
+ const profiles = {
276
+ 'intact-claude': {
277
+ baseURL: 'https://intact.example.com/v1',
278
+ apiKey: 'intact-key-xyz'
279
+ }
280
+ };
281
+
282
+ // From sourceProfile
283
+ const t1 = resolveJevTarget({ sourceProfile: 'intact-claude', model: 'typesafe/jev-latest' }, profiles);
284
+ assert.equal(t1.url, 'https://intact.example.com/v1/systemone');
285
+ assert.equal(t1.apiKey, 'intact-key-xyz');
286
+ assert.equal(t1.model, 'typesafe/jev-latest');
287
+
288
+ // Override baseURL and key
289
+ const t2 = resolveJevTarget({
290
+ baseURL: 'https://custom.provider.com/api',
291
+ apiKey: 'custom-key',
292
+ model: 'typesafe/jev-custom'
293
+ }, profiles);
294
+ assert.equal(t2.url, 'https://custom.provider.com/api/systemone');
295
+ assert.equal(t2.apiKey, 'custom-key');
296
+ assert.equal(t2.model, 'typesafe/jev-custom');
297
+
298
+ // Full decisionEndpoint override
299
+ const t3 = resolveJevTarget({
300
+ decisionEndpoint: 'https://alpha.decision.org/judge',
301
+ apiKey: 'k'
302
+ });
303
+ assert.equal(t3.url, 'https://alpha.decision.org/judge');
304
+
305
+ // Score extraction
306
+ assert.equal(extractScoreFromAnswer({ noul: 0.85 }), 0.85);
307
+ assert.equal(extractScoreFromAnswer({ score: 0.72 }), 0.72);
308
+ assert.equal(extractScoreFromAnswer({ probabilities: { yes: 0.93 } }), 0.93);
309
+ assert.equal(extractScoreFromAnswer({ choice: 'yes', confidence: 0.89 }), 0.89);
310
+ assert.equal(extractScoreFromAnswer({ choice: 'no', confidence: 0.80 }), 0.20);
311
+ assert.ok(Number.isNaN(extractScoreFromAnswer({ invalid: true })));
312
+ });