import { describe, it, beforeEach, afterEach, mock } from 'node:test'; import assert from 'node:assert/strict'; import axios from 'axios'; import { extractBalancedJSON, extractJSONText } from '../llm.js'; const ENV_KEYS = [ 'OPENCODE_BASE_URL', 'OPENCODE_MODEL', 'OPENCODE_PROVIDER', 'OPENCODE_RETRY_ATTEMPTS', ]; let saved = {}; beforeEach(() => { saved = {}; for (const k of ENV_KEYS) { saved[k] = process.env[k]; delete process.env[k]; } }); afterEach(() => { for (const k of ENV_KEYS) { if (saved[k] === undefined) delete process.env[k]; else process.env[k] = saved[k]; } mock.restoreAll(); }); function mockOpenCodeResponse(content) { let calls = 0; mock.method(axios, 'post', async () => { calls += 1; if (calls === 1) return { data: { id: 'ses_test' } }; return { data: { parts: [{ type: 'text', text: content }] } }; }); } describe('chat - OpenCode', async () => { const { chat } = await import('../llm.js'); it('uses OpenCode server session API', async () => { process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096'; process.env.OPENCODE_PROVIDER = 'google'; process.env.OPENCODE_MODEL = 'gemini-2.5-flash'; const calls = []; mock.method(axios, 'post', async (url, payload, opts) => { calls.push({ url, payload, headers: opts.headers }); if (url.endsWith('/session')) return { data: { id: 'ses_test' } }; return { data: { parts: [{ type: 'text', text: 'opencode response' }] } }; }); const result = await chat('sys', 'user'); assert.equal(result, 'opencode response'); assert.equal(calls[0].url, 'http://opencode.local:4096/session'); assert.deepEqual(calls[0].payload.model, { providerID: 'google', id: 'gemini-2.5-flash' }); assert.equal(calls[1].url, 'http://opencode.local:4096/session/ses_test/message'); assert.deepEqual(calls[1].payload.model, { providerID: 'google', modelID: 'gemini-2.5-flash' }); assert.equal(calls[1].payload.system, 'sys'); assert.deepEqual(calls[1].payload.parts, [{ type: 'text', text: 'user' }]); assert.equal(calls[1].headers['Authorization'], undefined); }); it('passes an insecure https agent to OpenCode by default', async () => { process.env.OPENCODE_BASE_URL = 'https://opencode.local:4096'; const agents = []; mock.method(axios, 'post', async (url, _payload, opts) => { agents.push(opts.httpsAgent); if (url.endsWith('/session')) return { data: { id: 'ses_test' } }; return { data: { parts: [{ type: 'text', text: 'ok' }] } }; }); await chat('sys', 'user'); assert.equal(agents.length, 2); assert.equal(agents[0].options.rejectUnauthorized, false); assert.equal(agents[1].options.rejectUnauthorized, false); }); it('extracts text from OpenCode message parts', async () => { process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096'; let calls = 0; mock.method(axios, 'post', async () => { calls += 1; if (calls === 1) return { data: { id: 'ses_test' } }; return { data: { parts: [{ type: 'text', text: 'hello' }, { type: 'text', text: ' world' }] } }; }); const result = await chat('sys', 'user'); assert.equal(result, 'hello world'); }); it('throws an error when OpenCode fails instead of exiting the process', async () => { process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096'; process.env.OPENCODE_RETRY_ATTEMPTS = '1'; mock.method(axios, 'post', async () => { const err = new Error('Request failed with status code 500'); err.response = { status: 500, data: { error: 'provider overloaded' } }; throw err; }); const exitMock = mock.method(process, 'exit', () => { throw new Error('exit should not be called'); }); await assert.rejects(() => chat('sys', 'user'), /HTTP 500.*provider overloaded/); assert.equal(exitMock.mock.calls.length, 0); }); it('retries transient OpenCode failures before returning content', async () => { process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096'; process.env.OPENCODE_RETRY_ATTEMPTS = '2'; let messageAttempts = 0; mock.method(axios, 'post', async (url) => { if (url.endsWith('/session')) return { data: { id: 'ses_test' } }; messageAttempts += 1; if (messageAttempts === 1) { const err = new Error('Request failed with status code 500'); err.response = { status: 500, data: { error: 'temporary failure' } }; throw err; } return { data: { parts: [{ type: 'text', text: 'ok after retry' }] } }; }); const result = await chat('sys', 'user'); assert.equal(result, 'ok after retry'); assert.equal(messageAttempts, 2); }); }); describe('chatJSON', async () => { const { chatJSON } = await import('../llm.js'); it('parses plain JSON response', async () => { process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096'; mockOpenCodeResponse('[{"level":"critical"}]'); const result = await chatJSON('sys', 'user'); assert.deepEqual(result, [{ level: 'critical' }]); }); it('strips markdown code block before parsing', async () => { process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096'; mockOpenCodeResponse('```json\n[{"level":"info"}]\n```'); const result = await chatJSON('sys', 'user'); assert.deepEqual(result, [{ level: 'info' }]); }); it('extracts JSON array from surrounding prose', async () => { process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096'; mockOpenCodeResponse('**Reviewing findings**\n\n[{"level":"warning","suggestion":"x"}]\n\nDone.'); const result = await chatJSON('sys', 'user'); assert.deepEqual(result, [{ level: 'warning', suggestion: 'x' }]); }); it('extracts JSON object from surrounding prose', async () => { process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096'; mockOpenCodeResponse('**Begin Combine**\n{"merged_text":"repo block\\n\\nsource block"}'); const result = await chatJSON('sys', 'user'); assert.deepEqual(result, { merged_text: 'repo block\n\nsource block' }); }); it('returns [] when JSON is invalid', async () => { process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096'; mockOpenCodeResponse('not json'); const result = await chatJSON('sys', 'user'); assert.deepEqual(result, []); }); }); describe('extractBalancedJSON', () => { it('returns the whole object for a simple object from index 0', () => { const text = '{"a":1}'; assert.equal(extractBalancedJSON(text, 0), '{"a":1}'); }); it('returns the full balanced segment for deeply nested object/array', () => { const text = '{"a":[1,{"b":[2,{"c":3}]}],"d":4}'; assert.equal(extractBalancedJSON(text, 0), '{"a":[1,{"b":[2,{"c":3}]}],"d":4}'); }); it('does not let braces inside a string value break balancing', () => { const text = '{"a":"}{"}'; assert.equal(extractBalancedJSON(text, 0), '{"a":"}{"}'); }); it('handles an escaped quote inside a string value', () => { const text = '{"a":"\\""}'; assert.equal(extractBalancedJSON(text, 0), '{"a":"\\""}'); }); it('returns null for truncated/incomplete JSON', () => { const text = '{"a":1'; assert.equal(extractBalancedJSON(text, 0), null); }); it('extracts a balanced array when starting at a "["', () => { const text = '[1,[2,3],{"a":4}]'; assert.equal(extractBalancedJSON(text, 0), '[1,[2,3],{"a":4}]'); }); it('excludes trailing content after the balanced segment', () => { const text = '{"a":1} trailing text {"b":2}'; assert.equal(extractBalancedJSON(text, 0), '{"a":1}'); }); }); describe('extractJSONText', () => { it('strips a fenced ```json block', () => { const text = '```json\n{"a":1}\n```'; const result = extractJSONText(text); assert.deepEqual(JSON.parse(result), { a: 1 }); }); it('extracts a JSON object after leading prose', () => { const text = 'Here are the findings:\n{"level":"critical"}'; const result = extractJSONText(text); assert.deepEqual(JSON.parse(result), { level: 'critical' }); }); it('extracts an array embedded in surrounding text', () => { const text = 'prefix [1,2,3] suffix'; const result = extractJSONText(text); assert.deepEqual(JSON.parse(result), [1, 2, 3]); }); it('returns an already-pure JSON string as-is', () => { const text = '{"a":1,"b":[2,3]}'; const result = extractJSONText(text); assert.equal(result, '{"a":1,"b":[2,3]}'); assert.deepEqual(JSON.parse(result), { a: 1, b: [2, 3] }); }); it('returns the de-fenced original text when no valid JSON is found', () => { const text = '```\nnot json at all\n```'; const result = extractJSONText(text); assert.equal(result, 'not json at all'); }); });