local createLibTest = require('/apis/libtest'); local createAi = require('/apis/libai'); local testlib = createLibTest({ ... }); local function fakeSettings(initial) local values = initial or {}; local getCounts = {}; local saveCount = 0; return { get = function(key) getCounts[key] = (getCounts[key] or 0) + 1; return values[key]; end, set = function(key, value) values[key] = value; end, unset = function(key) values[key] = nil; end, save = function() saveCount = saveCount + 1; end, values = values, getCount = function(key) return getCounts[key] or 0; end, saveCount = function() return saveCount; end, }; end local function fakeAsyncSettings(initial) local values = { ['opencc.server_url'] = 'http://host', ['opencc.provider_id'] = 'anthropic', ['opencc.model_id'] = 'claude-opus-4-7', }; for key, value in pairs(initial or {}) do values[key] = value; end return fakeSettings(values); end local function response(code, body) return { getResponseCode = function() return code; end, readAll = function() return body; end, close = function() end, }; end -- postResults: list of responses returned in order for each POST call. -- getResults: list of responses returned in order for each GET call. local function fakeHttp(postResults, getResults) postResults = postResults or {}; getResults = getResults or {}; local postCalls = {}; local getCalls = {}; local postIdx = 0; local getIdx = 0; return { post = function(req) local url, body, headers, timeout = req.url, req.body, req.headers, req.timeout; postCalls[#postCalls + 1] = { url = url, body = body, headers = headers, timeout = timeout }; postIdx = postIdx + 1; local r = postResults[postIdx]; if type(r) == 'function' then return r(url, body, headers); end return r; end, get = function(req) local url, headers, timeout = req.url, req.headers, req.timeout; getCalls[#getCalls + 1] = { url = url, headers = headers, timeout = timeout }; getIdx = getIdx + 1; local r = getResults[getIdx]; if type(r) == 'function' then return r(url, headers); end return r; end, postCalls = postCalls, getCalls = getCalls, }; end local function httpError(code, body) return function() return nil, 'HTTP response code ' .. tostring(code), response(code, body); end; end -- Synchronous deterministic eventloop double for tests. -- setTimeout drains FIFO; runLoop runs until pending is empty or stopLoop fires. -- Returns (factory, state). state.sleeps accumulates every delay passed across -- all loops; state.lastLoop exposes the most recent loop for pending/stopped -- assertions. local function fakeEventloopFactory() local state = { sleeps = {}, lastLoop = nil }; local function factory() local pending = {}; local stopped = false; local api = {}; function api.setTimeout(fn, delay) state.sleeps[#state.sleeps + 1] = delay; pending[#pending + 1] = fn; return function() end; end function api.runLoop() stopped = false; while not stopped and #pending > 0 do local fn = table.remove(pending, 1); fn(); end end function api.stopLoop() stopped = true; end function api.onStart(fn) fn(); end function api.onStop() return function() end; end function api.inspect() return { pending = pending, stopped = stopped }; end state.lastLoop = api; return api; end return factory, state; end local function sessionResp(id) return response(200, textutils.serializeJSON({ id = id, title = 'cc-ai' })); end local function messageResp(reply) return response(200, textutils.serializeJSON({ info = { time = { completed = 1 } }, parts = { { type = 'text', text = reply } }, })); end local function asyncResp() return response(204, ''); end local function messageListResp(messages) return response(200, textutils.serializeJSON(messages)); end local function userMessage(id, text) return { info = { id = id, role = 'user' }, parts = { { type = 'text', text = text } }, }; end local function assistantMessage(id, reply, completed) return { info = { id = id, role = 'assistant', time = completed and { completed = 1 } or {} }, parts = { { type = 'text', text = reply } }, }; end local function postedText(call) local body = textutils.unserializeJSON(call.body); return body.parts[1].text; end -- base64 -- testlib.test('base64encode encodes simple ascii', function() -- "Man" -> "TWFu" is the canonical base64 test vector; tested indirectly via Authorization header local httpStub = fakeHttp( { sessionResp('ses_1'), messageResp('ok') }, {} ); local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' }); settingsStub.values['opencc.password'] = 'pass'; settingsStub.values['opencc.username'] = 'user'; createAi({ http = httpStub, settings = settingsStub }).ask('hello'); local auth = httpStub.postCalls[1].headers['Authorization']; -- base64('user:pass') = 'dXNlcjpwYXNz' testlib.assertEquals(auth, 'Basic dXNlcjpwYXNz'); end); testlib.test('base64encode handles padding with one remainder byte', function() local httpStub = fakeHttp( { sessionResp('ses_1'), messageResp('ok') }, {} ); local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' }); settingsStub.values['opencc.password'] = 'x'; settingsStub.values['opencc.username'] = 'a'; createAi({ http = httpStub, settings = settingsStub }).ask('hello'); -- base64('a:x') = 'YTp4' testlib.assertEquals(httpStub.postCalls[1].headers['Authorization'], 'Basic YTp4'); end); -- listSessions -- testlib.test('listSessions returns newest sessions first', function() local sessions = { { id = 'ses_old', title = 'old', time = { updated = 10 } }, { id = 'ses_created', title = 'created', time = { created = 20 } }, { id = 'ses_new', title = 'new', time = { updated = 30 } }, { id = 'ses_missing', title = 'missing' }, }; local httpStub = fakeHttp({}, { response(200, textutils.serializeJSON(sessions)) }); local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' }); local ai = createAi({ http = httpStub, settings = settingsStub }); local ok, result = ai.listSessions(); testlib.assertTrue(ok, tostring(result)); testlib.assertEquals(#result, 4); testlib.assertEquals(result[1].id, 'ses_new'); testlib.assertEquals(result[2].id, 'ses_created'); testlib.assertEquals(result[3].id, 'ses_old'); testlib.assertEquals(result[4].id, 'ses_missing'); end); testlib.test('listSessions logs session count when verbose', function() local sessions = { { id = 'ses_1', title = 'one', time = { updated = 10 } }, { id = 'ses_2', title = 'two', time = { updated = 20 } }, }; local httpStub = fakeHttp({}, { response(200, textutils.serializeJSON(sessions)) }); local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' }); local logs = {}; local ai = createAi({ http = httpStub, settings = settingsStub }); local ok = ai.listSessions({ log = function(message) logs[#logs + 1] = message; end, }); testlib.assertTrue(ok); testlib.assertTrue(string.find(logs[1], 'http://host', 1, true) ~= nil); testlib.assertEquals(logs[2], 'sessions returned: 2'); end); testlib.test('listSessions fails when server_url missing', function() local httpStub = fakeHttp({}, {}); local ai = createAi({ http = httpStub, settings = fakeSettings() }); local ok, err = ai.listSessions(); testlib.assertTrue(not ok); testlib.assertTrue(string.find(err, 'opencc.server_url', 1, true) ~= nil); testlib.assertTrue(string.find(err, 'set opencc.server_url', 1, true) ~= nil); testlib.assertTrue(string.find(err, 'settings set', 1, true) == nil); testlib.assertEquals(#httpStub.getCalls, 0); end); testlib.test('listSessions fails when server unreachable', function() local httpStub = fakeHttp({}, { nil }); local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' }); local ai = createAi({ http = httpStub, settings = settingsStub }); local ok, err = ai.listSessions(); testlib.assertTrue(not ok); testlib.assertTrue(string.find(err, 'injoignable', 1, true) ~= nil); end); testlib.test('listSessions maps HTTP error response codes', function() local httpStub = fakeHttp({}, { httpError(401, '{}') }); local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' }); local ai = createAi({ http = httpStub, settings = settingsStub }); local ok, err = ai.listSessions(); testlib.assertTrue(not ok); testlib.assertTrue(string.find(err, 'HTTP 401', 1, true) ~= nil); end); -- ask -- testlib.test('ask creates session then sends message when no session_id', function() local httpStub = fakeHttp( { sessionResp('ses_new'), messageResp('reply') }, {} ); local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host:4096' }); local ai = createAi({ http = httpStub, settings = settingsStub }); local ok, result = ai.ask('hello'); testlib.assertTrue(ok, tostring(result)); testlib.assertEquals(result.reply, 'reply'); testlib.assertEquals(result.sessionId, 'ses_new'); testlib.assertEquals(#httpStub.postCalls, 2); testlib.assertTrue(string.find(httpStub.postCalls[1].url, '/session', 1, true) ~= nil); testlib.assertTrue(string.find(httpStub.postCalls[2].url, '/session/ses_new/message', 1, true) ~= nil); end); testlib.test('ask creates cc-ai titled sessions', function() local httpStub = fakeHttp( { sessionResp('ses_new'), messageResp('reply') }, {} ); local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' }); local ai = createAi({ http = httpStub, settings = settingsStub }); ai.ask('hello'); local body = textutils.unserializeJSON(httpStub.postCalls[1].body); testlib.assertEquals(body.title, 'cc-ai'); end); testlib.test('ask saves new session_id to settings', function() local httpStub = fakeHttp( { sessionResp('ses_abc'), messageResp('reply') }, {} ); local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' }); local ai = createAi({ http = httpStub, settings = settingsStub }); ai.ask('hello'); testlib.assertEquals(settingsStub.values['opencc.session_id'], 'ses_abc'); testlib.assertEquals(settingsStub.saveCount(), 1); end); testlib.test('ask saves custom sessionSettingKey to settings', function() local httpStub = fakeHttp( { sessionResp('ses_trapgpt'), messageResp('reply') }, {} ); local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' }); local ai = createAi({ http = httpStub, settings = settingsStub }); ai.ask('hello', { sessionSettingKey = 'trapgpt.opencc.session_id' }); testlib.assertEquals(settingsStub.values['trapgpt.opencc.session_id'], 'ses_trapgpt'); testlib.assertEquals(settingsStub.values['opencc.session_id'], nil); testlib.assertEquals(settingsStub.saveCount(), 1); end); testlib.test('ask reuses existing session_id without creating a new session', function() local httpStub = fakeHttp( { messageResp('reply') }, {} ); local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host', ['opencc.session_id'] = 'ses_existing', }); local ai = createAi({ http = httpStub, settings = settingsStub }); local ok = ai.ask('hello'); testlib.assertTrue(ok); testlib.assertEquals(#httpStub.postCalls, 1); testlib.assertTrue(string.find(httpStub.postCalls[1].url, '/session/ses_existing/message', 1, true) ~= nil); end); testlib.test('ask reuses custom sessionSettingKey without creating a new session', function() local httpStub = fakeHttp( { messageResp('reply') }, {} ); local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host', ['opencc.session_id'] = 'ses_other', ['trapgpt.opencc.session_id'] = 'ses_trapgpt', }); local ai = createAi({ http = httpStub, settings = settingsStub }); local ok = ai.ask('hello', { sessionSettingKey = 'trapgpt.opencc.session_id' }); testlib.assertTrue(ok); testlib.assertEquals(#httpStub.postCalls, 1); testlib.assertTrue(string.find(httpStub.postCalls[1].url, '/session/ses_trapgpt/message', 1, true) ~= nil); end); testlib.test('ask falls back to blocking message when model is unset', function() local httpStub = fakeHttp( { sessionResp('ses_blocking'), messageResp('reply') }, {} ); local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' }); local ai = createAi({ http = httpStub, settings = settingsStub }); local ok, result = ai.ask('hello'); testlib.assertTrue(ok, tostring(result)); testlib.assertEquals(result.reply, 'reply'); testlib.assertEquals(#httpStub.postCalls, 2); testlib.assertTrue(string.find(httpStub.postCalls[2].url, '/session/ses_blocking/message', 1, true) ~= nil); testlib.assertEquals(#httpStub.getCalls, 0); end); testlib.test('ask sends exact prompt text', function() local httpStub = fakeHttp( { messageResp('reply') }, {} ); local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host', ['opencc.session_id'] = 'ses_1', }); local ai = createAi({ http = httpStub, settings = settingsStub }); ai.ask('my prompt'); local body = textutils.unserializeJSON(httpStub.postCalls[1].body); testlib.assertEquals(#body.parts, 1); testlib.assertEquals(body.parts[1].type, 'text'); testlib.assertEquals(body.parts[1].text, 'my prompt'); end); testlib.test('ask includes agent from settings', function() local httpStub = fakeHttp( { messageResp('reply') }, {} ); local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host', ['opencc.session_id'] = 'ses_1', ['opencc.agent'] = 'computercraft', }); local ai = createAi({ http = httpStub, settings = settingsStub }); ai.ask('hello'); local body = textutils.unserializeJSON(httpStub.postCalls[1].body); testlib.assertEquals(body.agent, 'computercraft'); end); testlib.test('ask option agent overrides settings', function() local httpStub = fakeHttp( { messageResp('reply') }, {} ); local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host', ['opencc.session_id'] = 'ses_1', ['opencc.agent'] = 'build', }); local ai = createAi({ http = httpStub, settings = settingsStub }); ai.ask('hello', { agent = 'computercraft' }); local body = textutils.unserializeJSON(httpStub.postCalls[1].body); testlib.assertEquals(body.agent, 'computercraft'); end); testlib.test('ask omits blank agent setting', function() local httpStub = fakeHttp( { messageResp('reply') }, {} ); local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host', ['opencc.session_id'] = 'ses_1', ['opencc.agent'] = ' ', }); local ai = createAi({ http = httpStub, settings = settingsStub }); ai.ask('hello'); local body = textutils.unserializeJSON(httpStub.postCalls[1].body); testlib.assertEquals(body.agent, nil); end); testlib.test('ask includes model when provider_id and model_id are set', function() local httpStub = fakeHttp( { messageResp('reply') }, {} ); local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host', ['opencc.session_id'] = 'ses_1', ['opencc.provider_id'] = 'anthropic', ['opencc.model_id'] = 'claude-opus-4-7', }); local ai = createAi({ http = httpStub, settings = settingsStub }); ai.ask('hello'); local body = textutils.unserializeJSON(httpStub.postCalls[1].body); testlib.assertEquals(body.model.providerID, 'anthropic'); testlib.assertEquals(body.model.modelID, 'claude-opus-4-7'); end); testlib.test('ask omits model when provider_id is missing', function() local httpStub = fakeHttp( { messageResp('reply') }, {} ); local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host', ['opencc.session_id'] = 'ses_1', ['opencc.model_id'] = 'claude-opus-4-7', }); local ai = createAi({ http = httpStub, settings = settingsStub }); ai.ask('hello'); local body = textutils.unserializeJSON(httpStub.postCalls[1].body); testlib.assertEquals(body.model, nil); end); testlib.test('ask omits model when model_id is missing', function() local httpStub = fakeHttp( { messageResp('reply') }, {} ); local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host', ['opencc.session_id'] = 'ses_1', ['opencc.provider_id'] = 'anthropic', }); local ai = createAi({ http = httpStub, settings = settingsStub }); ai.ask('hello'); local body = textutils.unserializeJSON(httpStub.postCalls[1].body); testlib.assertEquals(body.model, nil); end); testlib.test('ask omits model when neither provider_id nor model_id is set', function() local httpStub = fakeHttp( { messageResp('reply') }, {} ); local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host', ['opencc.session_id'] = 'ses_1', }); local ai = createAi({ http = httpStub, settings = settingsStub }); ai.ask('hello'); local body = textutils.unserializeJSON(httpStub.postCalls[1].body); testlib.assertEquals(body.model, nil); end); testlib.test('ask options providerID/modelID override settings', function() local httpStub = fakeHttp( { messageResp('reply') }, {} ); local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host', ['opencc.session_id'] = 'ses_1', ['opencc.provider_id'] = 'anthropic', ['opencc.model_id'] = 'claude-opus-4-7', }); local ai = createAi({ http = httpStub, settings = settingsStub }); ai.ask('hello', { providerID = 'openai', modelID = 'gpt-5' }); local body = textutils.unserializeJSON(httpStub.postCalls[1].body); testlib.assertEquals(body.model.providerID, 'openai'); testlib.assertEquals(body.model.modelID, 'gpt-5'); end); testlib.test('ask generates opencode-compatible message ids', function() local httpStub = fakeHttp( { messageResp('reply') }, {} ); local settingsStub = fakeAsyncSettings({ ['opencc.session_id'] = 'ses_1', }); local ai = createAi({ http = httpStub, settings = settingsStub, now = function() return 123.456; end, }); ai.ask('hello'); local body = textutils.unserializeJSON(httpStub.postCalls[1].body); testlib.assertTrue(string.find(body.messageID, '^msg_') ~= nil); end); testlib.test('ask includes agent in async prompts', function() local httpStub = fakeHttp( { asyncResp() }, { messageListResp({ assistantMessage('msg_1', 'reply', true) }), } ); local settingsStub = fakeAsyncSettings({ ['opencc.session_id'] = 'ses_1', ['opencc.agent'] = 'computercraft', }); local elFactory = fakeEventloopFactory(); local ai = createAi({ http = httpStub, settings = settingsStub, now = function() return 10; end, eventloop = elFactory, }); local ok = ai.ask('hello', { messageId = 'msg_1' }); testlib.assertTrue(ok); local body = textutils.unserializeJSON(httpStub.postCalls[1].body); testlib.assertEquals(body.agent, 'computercraft'); end); testlib.test('ask polls async message until completion', function() local httpStub = fakeHttp( { sessionResp('ses_1'), asyncResp() }, { messageListResp({ userMessage('msg_1', 'hello'), assistantMessage('msg_2', 'partial', false) }), messageListResp({ userMessage('msg_1', 'hello'), assistantMessage('msg_2', 'reply', true) }), } ); local settingsStub = fakeAsyncSettings(); local elFactory, elState = fakeEventloopFactory(); local ai = createAi({ http = httpStub, settings = settingsStub, now = function() return 10; end, eventloop = elFactory, }); local ok, result = ai.ask('hello', { messageId = 'msg_1', pollIntervalSeconds = 3 }); testlib.assertTrue(ok, tostring(result)); testlib.assertEquals(result.reply, 'reply'); testlib.assertEquals(result.messageId, 'msg_1'); testlib.assertEquals(#httpStub.getCalls, 2); testlib.assertTrue(string.find(httpStub.getCalls[1].url, '/session/ses_1/message', 1, true) ~= nil); -- First setTimeout fires the initial attempt (delay 0); second waits the poll interval. testlib.assertEquals(elState.sleeps[1], 0); testlib.assertEquals(elState.sleeps[2], 3); end); testlib.test('ask polling accepts assistant message with submitted id', function() local httpStub = fakeHttp( { sessionResp('ses_1'), asyncResp() }, { messageListResp({ assistantMessage('msg_1', 'reply', true) }), } ); local settingsStub = fakeAsyncSettings(); local elFactory = fakeEventloopFactory(); local ai = createAi({ http = httpStub, settings = settingsStub, now = function() return 10; end, eventloop = elFactory, }); local ok, result = ai.ask('hello', { messageId = 'msg_1' }); testlib.assertTrue(ok, tostring(result)); testlib.assertEquals(result.reply, 'reply'); testlib.assertEquals(#httpStub.getCalls, 1); end); testlib.test('ask polling logs diagnostic details', function() local httpStub = fakeHttp( { sessionResp('ses_1'), asyncResp() }, { messageListResp({ assistantMessage('msg_1', 'reply', true) }), } ); local settingsStub = fakeAsyncSettings(); local elFactory = fakeEventloopFactory(); local logs = {}; local ai = createAi({ http = httpStub, settings = settingsStub, now = function() return 10; end, eventloop = elFactory, }); local ok = ai.ask('hello', { messageId = 'msg_1', log = function(message) logs[#logs + 1] = message; end, }); testlib.assertTrue(ok); testlib.assertTrue(string.find(table.concat(logs, '\n'), 'sending async prompt msg_1', 1, true) ~= nil); testlib.assertTrue(string.find(table.concat(logs, '\n'), 'poll #1: messages=1, found=msg_1', 1, true) ~= nil); testlib.assertTrue(string.find(table.concat(logs, '\n'), 'complete=true, text=true', 1, true) ~= nil); end); testlib.test('ask polling tolerates assistant message without parts', function() local httpStub = fakeHttp( { sessionResp('ses_1'), asyncResp() }, { messageListResp({ userMessage('msg_1', 'hello'), { info = { id = 'msg_2', role = 'assistant', time = { completed = 1 } } }, }), messageListResp({ userMessage('msg_1', 'hello'), assistantMessage('msg_2', 'reply', true) }), } ); local settingsStub = fakeAsyncSettings(); local elFactory = fakeEventloopFactory(); local ai = createAi({ http = httpStub, settings = settingsStub, now = function() return 10; end, eventloop = elFactory, }); local ok, result = ai.ask('hello', { messageId = 'msg_1' }); testlib.assertTrue(ok, tostring(result)); testlib.assertEquals(result.reply, 'reply'); testlib.assertEquals(#httpStub.getCalls, 2); end); testlib.test('ask polling times out', function() local httpStub = fakeHttp( { sessionResp('ses_1'), asyncResp() }, { messageListResp({ userMessage('msg_1', 'hello'), assistantMessage('msg_2', 'partial', false) }), messageListResp({ userMessage('msg_1', 'hello'), assistantMessage('msg_2', 'partial', false) }), } ); local settingsStub = fakeAsyncSettings(); local now = 0; local elFactory, elState = fakeEventloopFactory(); -- Advance virtual time on every scheduled delay so the deadline is reached. local advancingFactory = function() local loop = elFactory(); local origSet = loop.setTimeout; loop.setTimeout = function(fn, delay) now = now + (delay or 0); return origSet(fn, delay); end return loop; end; local ai = createAi({ http = httpStub, settings = settingsStub, now = function() return now; end, eventloop = advancingFactory, }); local ok, err = ai.ask('hello', { messageId = 'msg_1', pollTimeoutSeconds = 1, pollIntervalSeconds = 1, }); testlib.assertTrue(not ok); testlib.assertTrue(string.find(err, 'delai depasse', 1, true) ~= nil); testlib.assertEquals(#httpStub.getCalls, 2); testlib.assertTrue(elState.lastLoop.inspect().stopped); testlib.assertEquals(#elState.lastLoop.inspect().pending, 0); end); testlib.test('ask polling does not call os.sleep', function() local httpStub = fakeHttp( { sessionResp('ses_1'), asyncResp() }, { messageListResp({ userMessage('msg_1', 'hello'), assistantMessage('msg_2', 'partial', false) }), messageListResp({ userMessage('msg_1', 'hello'), assistantMessage('msg_2', 'reply', true) }), } ); local settingsStub = fakeAsyncSettings(); local elFactory = fakeEventloopFactory(); local originalSleep = _G.sleep; local sleepCalls = 0; _G.sleep = function(n) sleepCalls = sleepCalls + 1; originalSleep(n); end local ai = createAi({ http = httpStub, settings = settingsStub, now = function() return 10; end, eventloop = elFactory, }); local ok = ai.ask('hello', { messageId = 'msg_1', pollIntervalSeconds = 3 }); _G.sleep = originalSleep; testlib.assertTrue(ok); testlib.assertEquals(sleepCalls, 0); end); testlib.test('pollMessage stops the private loop on success', function() local httpStub = fakeHttp( { sessionResp('ses_1'), asyncResp() }, { messageListResp({ userMessage('msg_1', 'hi'), assistantMessage('msg_2', 'reply', true) }), } ); local settingsStub = fakeAsyncSettings(); local elFactory, elState = fakeEventloopFactory(); local ai = createAi({ http = httpStub, settings = settingsStub, now = function() return 0; end, eventloop = elFactory, }); local ok = ai.ask('hi', { messageId = 'msg_1' }); testlib.assertTrue(ok); testlib.assertTrue(elState.lastLoop.inspect().stopped); testlib.assertEquals(#elState.lastLoop.inspect().pending, 0); end); testlib.test('pollMessage stops cleanly on HTTP error mid-poll', function() local httpStub = fakeHttp( { sessionResp('ses_1'), asyncResp() }, { messageListResp({ userMessage('msg_1', 'hi'), assistantMessage('msg_2', 'partial', false) }), httpError(500, '{}'), } ); local settingsStub = fakeAsyncSettings(); local elFactory, elState = fakeEventloopFactory(); local ai = createAi({ http = httpStub, settings = settingsStub, now = function() return 0; end, eventloop = elFactory, }); local ok, err = ai.ask('hi', { messageId = 'msg_1', pollIntervalSeconds = 1, pollTimeoutSeconds = 60 }); testlib.assertTrue(not ok); testlib.assertTrue(string.find(err, 'HTTP 500', 1, true) ~= nil); testlib.assertTrue(elState.lastLoop.inspect().stopped); testlib.assertEquals(#elState.lastLoop.inspect().pending, 0); end); testlib.test('pollMessage stops cleanly on 404 mid-poll', function() local httpStub = fakeHttp( { asyncResp() }, { messageListResp({ userMessage('msg_1', 'hi'), assistantMessage('msg_2', 'partial', false) }), response(404, '{}'), } ); local settingsStub = fakeAsyncSettings({ ['opencc.session_id'] = 'ses_1', }); local elFactory, elState = fakeEventloopFactory(); local ai = createAi({ http = httpStub, settings = settingsStub, now = function() return 0; end, eventloop = elFactory, }); local ok, err = ai.ask('hi', { messageId = 'msg_1', pollIntervalSeconds = 1, pollTimeoutSeconds = 60 }); testlib.assertTrue(not ok); testlib.assertTrue(string.find(err, 'session introuvable', 1, true) ~= nil); testlib.assertEquals(settingsStub.values['opencc.session_id'], nil); testlib.assertTrue(elState.lastLoop.inspect().stopped); testlib.assertEquals(#elState.lastLoop.inspect().pending, 0); end); testlib.test('ask rejects missing prompt without HTTP calls', function() local httpStub = fakeHttp({}, {}); local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' }); local ai = createAi({ http = httpStub, settings = settingsStub }); local ok, err = ai.ask(); testlib.assertTrue(not ok); testlib.assertTrue(string.find(err, 'missing prompt', 1, true) ~= nil); testlib.assertEquals(#httpStub.postCalls, 0); testlib.assertEquals(#httpStub.getCalls, 0); end); testlib.test('ask rejects blank prompt without HTTP calls', function() local httpStub = fakeHttp({}, {}); local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' }); local ai = createAi({ http = httpStub, settings = settingsStub }); local ok, err = ai.ask(' '); testlib.assertTrue(not ok); testlib.assertTrue(string.find(err, 'missing prompt', 1, true) ~= nil); testlib.assertEquals(#httpStub.postCalls, 0); testlib.assertEquals(#httpStub.getCalls, 0); end); testlib.test('ask concatenates multiple text parts', function() local httpStub = fakeHttp( { response(200, textutils.serializeJSON({ info = {}, parts = { { type = 'step-start' }, { type = 'text', text = 'hello ' }, { type = 'tool-call' }, { type = 'text', text = 'world' }, }, })) }, {} ); local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host', ['opencc.session_id'] = 'ses_1', }); local ai = createAi({ http = httpStub, settings = settingsStub }); local ok, result = ai.ask('hello'); testlib.assertTrue(ok, tostring(result)); testlib.assertEquals(result.reply, 'hello world'); end); testlib.test('ask fails with missing server_url', function() local httpStub = fakeHttp({}, {}); local ai = createAi({ http = httpStub, settings = fakeSettings() }); local ok, err = ai.ask('hello'); testlib.assertTrue(not ok); testlib.assertTrue(string.find(err, 'opencc.server_url', 1, true) ~= nil); testlib.assertTrue(string.find(err, 'set opencc.server_url', 1, true) ~= nil); testlib.assertTrue(string.find(err, 'settings set', 1, true) == nil); testlib.assertEquals(#httpStub.postCalls, 0); end); testlib.test('ask fails when server unreachable on session create', function() local httpStub = fakeHttp({ nil }, {}); local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' }); local ai = createAi({ http = httpStub, settings = settingsStub }); local ok, err = ai.ask('hello'); testlib.assertTrue(not ok); testlib.assertTrue(string.find(err, 'injoignable', 1, true) ~= nil); end); testlib.test('ask fails when server unreachable on message send', function() local httpStub = fakeHttp({ sessionResp('ses_1'), nil }, {}); local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' }); local ai = createAi({ http = httpStub, settings = settingsStub }); local ok, err = ai.ask('hello'); testlib.assertTrue(not ok); testlib.assertTrue(string.find(err, 'injoignable', 1, true) ~= nil); end); testlib.test('ask maps 401 on message send', function() local httpStub = fakeHttp( { sessionResp('ses_1'), response(401, '{}') }, {} ); local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' }); local ai = createAi({ http = httpStub, settings = settingsStub }); local ok, err = ai.ask('hello'); testlib.assertTrue(not ok); testlib.assertTrue(string.find(err, 'HTTP 401', 1, true) ~= nil); end); testlib.test('ask maps HTTP error response on message send', function() local httpStub = fakeHttp( { sessionResp('ses_1'), httpError(401, '{}') }, {} ); local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' }); local ai = createAi({ http = httpStub, settings = settingsStub }); local ok, err = ai.ask('hello'); testlib.assertTrue(not ok); testlib.assertTrue(string.find(err, 'HTTP 401', 1, true) ~= nil); end); testlib.test('ask on 404 clears session_id and suggests ai new', function() local httpStub = fakeHttp( { response(404, '{}') }, {} ); local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host', ['opencc.session_id'] = 'ses_stale', }); local ai = createAi({ http = httpStub, settings = settingsStub }); local ok, err = ai.ask('hello'); testlib.assertTrue(not ok); testlib.assertTrue(string.find(err, 'ai new', 1, true) ~= nil); testlib.assertEquals(settingsStub.values['opencc.session_id'], nil); end); testlib.test('ask on HTTP error 404 clears session_id', function() local httpStub = fakeHttp( { httpError(404, '{}') }, {} ); local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host', ['opencc.session_id'] = 'ses_stale', }); local ai = createAi({ http = httpStub, settings = settingsStub }); local ok, err = ai.ask('hello'); testlib.assertTrue(not ok); testlib.assertTrue(string.find(err, 'ai new', 1, true) ~= nil); testlib.assertEquals(settingsStub.values['opencc.session_id'], nil); end); testlib.test('ask persist=false creates a session without saving session_id', function() local httpStub = fakeHttp( { sessionResp('ses_ephemeral'), messageResp('reply') }, {} ); local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host', ['opencc.session_id'] = 'ses_persisted', }); local ai = createAi({ http = httpStub, settings = settingsStub }); local ok, result = ai.ask('hello', { persist = false }); testlib.assertTrue(ok, tostring(result)); testlib.assertEquals(result.sessionId, 'ses_ephemeral'); testlib.assertEquals(settingsStub.values['opencc.session_id'], 'ses_persisted'); testlib.assertEquals(settingsStub.getCount('opencc.session_id'), 0); testlib.assertEquals(settingsStub.saveCount(), 0); testlib.assertEquals(#httpStub.postCalls, 2); testlib.assertTrue(string.find(httpStub.postCalls[1].url, '/session', 1, true) ~= nil); end); testlib.test('ask persist=false does not clear session_id on 404', function() local httpStub = fakeHttp( { response(404, '{}') }, {} ); local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host', ['opencc.session_id'] = 'ses_persisted', }); local ai = createAi({ http = httpStub, settings = settingsStub }); local ok, err = ai.ask('hello', { persist = false, sessionId = 'ses_missing' }); testlib.assertTrue(not ok); testlib.assertTrue(string.find(err, 'session introuvable', 1, true) ~= nil); testlib.assertEquals(settingsStub.values['opencc.session_id'], 'ses_persisted'); testlib.assertEquals(settingsStub.getCount('opencc.session_id'), 0); testlib.assertEquals(settingsStub.saveCount(), 0); end); testlib.test('ask omits Authorization header when no password', function() local httpStub = fakeHttp( { sessionResp('ses_1'), messageResp('reply') }, {} ); local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' }); local ai = createAi({ http = httpStub, settings = settingsStub }); ai.ask('hello'); testlib.assertEquals(httpStub.postCalls[1].headers['Authorization'], nil); end); -- lua executor -- testlib.test('lua executor captures print write and returned values', function() local ai = createAi({ http = fakeHttp({}, {}), settings = fakeSettings() }); local executor = ai.createLuaExecutor({ live = false, luaTimeoutSeconds = false }); local ok, output, kind = executor('print("hello", "world")\nwrite("x")\nreturn 7, "z"'); testlib.assertTrue(ok, tostring(output)); testlib.assertEquals(kind, nil); testlib.assertEquals(output, 'hello\tworld\nx\n7\tz\n'); end); testlib.test('lua executor classifies syntax and identifier errors', function() local ai = createAi({ http = fakeHttp({}, {}), settings = fakeSettings() }); local executor = ai.createLuaExecutor({ live = false, luaTimeoutSeconds = false }); local syntaxOk, _, syntaxKind = executor('if true then'); local identifierOk, identifierErr, identifierKind = executor('definitely_missing()'); testlib.assertTrue(not syntaxOk); testlib.assertEquals(syntaxKind, 'syntax'); testlib.assertTrue(not identifierOk); testlib.assertEquals(identifierKind, 'identifier'); testlib.assertTrue(string.find(identifierErr, 'nil', 1, true) ~= nil); end); testlib.test('lua executor classifies non-identifier runtime errors as other', function() local ai = createAi({ http = fakeHttp({}, {}), settings = fakeSettings() }); local executor = ai.createLuaExecutor({ live = false, luaTimeoutSeconds = false }); local ok, err, kind = executor('error("boom")'); testlib.assertTrue(not ok); testlib.assertEquals(kind, 'other'); testlib.assertTrue(string.find(err, 'boom', 1, true) ~= nil); end); -- luaExec -- testlib.test('luaExec succeeds on first attempt and sends output for final reply', function() local httpStub = fakeHttp( { sessionResp('ses_lua'), messageResp('print("hello")'), messageResp('final reply') }, {} ); local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host', ['opencc.session_id'] = 'ses_persisted', }); local ai = createAi({ http = httpStub, settings = settingsStub }); local execCalls = {}; local ok, result = ai.luaExec('say hello', { executor = function(code) execCalls[#execCalls + 1] = code; return true, 'hello\n', nil; end, }); testlib.assertTrue(ok, tostring(result)); testlib.assertEquals(result.reply, 'final reply'); testlib.assertEquals(result.output, 'hello\n'); testlib.assertEquals(result.attempts, 1); testlib.assertEquals(#execCalls, 1); testlib.assertEquals(settingsStub.values['opencc.session_id'], 'ses_persisted'); testlib.assertEquals(settingsStub.getCount('opencc.session_id'), 0); testlib.assertEquals(settingsStub.saveCount(), 0); testlib.assertEquals(#httpStub.postCalls, 3); local sessionBody = textutils.unserializeJSON(httpStub.postCalls[1].body); testlib.assertEquals(sessionBody.title, 'cc-ai lua-exec'); testlib.assertTrue(string.find(postedText(httpStub.postCalls[2]), 'raw Lua code only', 1, true) ~= nil); testlib.assertTrue(string.find(postedText(httpStub.postCalls[2]), 'say hello', 1, true) ~= nil); testlib.assertTrue(string.find(postedText(httpStub.postCalls[3]), 'Lua output:', 1, true) ~= nil); testlib.assertTrue(string.find(postedText(httpStub.postCalls[3]), 'natural language', 1, true) ~= nil); testlib.assertTrue(string.find(postedText(httpStub.postCalls[3]), 'hello', 1, true) ~= nil); end); testlib.test('luaExec retries on identifier error and succeeds', function() local httpStub = fakeHttp( { sessionResp('ses_lua'), messageResp('fo0()'), messageResp('print("fixed")'), messageResp('fixed reply'), }, {} ); local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' }); local ai = createAi({ http = httpStub, settings = settingsStub }); local execCalls = 0; local ok, result = ai.luaExec('fix name', { executor = function() execCalls = execCalls + 1; if execCalls == 1 then return false, 'attempt to call a nil value (global "fo0")', 'identifier'; end return true, 'fixed\n', nil; end, }); testlib.assertTrue(ok, tostring(result)); testlib.assertEquals(result.reply, 'fixed reply'); testlib.assertEquals(result.attempts, 2); testlib.assertEquals(execCalls, 2); testlib.assertEquals(#httpStub.postCalls, 4); testlib.assertTrue(string.find(postedText(httpStub.postCalls[3]), 'Error kind: identifier', 1, true) ~= nil); testlib.assertTrue(string.find(postedText(httpStub.postCalls[3]), 'fo0()', 1, true) ~= nil); end); testlib.test('luaExec retries on syntax error and succeeds', function() local httpStub = fakeHttp( { sessionResp('ses_lua'), messageResp('if true then'), messageResp('print("ok")'), messageResp('ok reply'), }, {} ); local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' }); local ai = createAi({ http = httpStub, settings = settingsStub }); local execCalls = 0; local ok, result = ai.luaExec('write code', { executor = function() execCalls = execCalls + 1; if execCalls == 1 then return false, "'end' expected", 'syntax'; end return true, 'ok\n', nil; end, maxRetries = 1, }); testlib.assertTrue(ok, tostring(result)); testlib.assertEquals(result.reply, 'ok reply'); testlib.assertEquals(result.attempts, 2); testlib.assertEquals(execCalls, 2); testlib.assertEquals(#httpStub.postCalls, 4); testlib.assertTrue(string.find(postedText(httpStub.postCalls[3]), 'Error kind: syntax', 1, true) ~= nil); end); testlib.test('luaExec aborts on non-identifier runtime error', function() local httpStub = fakeHttp( { sessionResp('ses_lua'), messageResp('peripheral.wrap("left").call()') }, {} ); local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' }); local ai = createAi({ http = httpStub, settings = settingsStub }); local ok, result = ai.luaExec('use peripheral', { executor = function() return false, 'peripheral not present', 'other'; end, }); testlib.assertTrue(not ok); testlib.assertEquals(result.error, 'peripheral not present'); testlib.assertEquals(result.errorKind, 'other'); testlib.assertEquals(result.attempts, 1); testlib.assertEquals(#httpStub.postCalls, 2); end); testlib.test('luaExec exhausts retry budget', function() local httpStub = fakeHttp( { sessionResp('ses_lua'), messageResp('bad1()'), messageResp('bad2()'), messageResp('bad3()'), }, {} ); local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' }); local ai = createAi({ http = httpStub, settings = settingsStub }); local execCalls = 0; local ok, result = ai.luaExec('keep failing', { executor = function() execCalls = execCalls + 1; return false, 'attempt to call a nil value (global "bad")', 'identifier'; end, maxRetries = 2, }); testlib.assertTrue(not ok); testlib.assertEquals(result.errorKind, 'identifier'); testlib.assertEquals(result.attempts, 3); testlib.assertTrue(result.retryExhausted); testlib.assertEquals(execCalls, 3); testlib.assertEquals(#httpStub.postCalls, 4); end); -- ping -- testlib.test('ping sends pong prompt', function() local httpStub = fakeHttp( { messageResp('pong') }, {} ); local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host', ['opencc.session_id'] = 'ses_1', }); local ai = createAi({ http = httpStub, settings = settingsStub }); local ok, result = ai.ping(); testlib.assertTrue(ok, tostring(result)); testlib.assertEquals(result.reply, 'pong'); local body = textutils.unserializeJSON(httpStub.postCalls[1].body); testlib.assertEquals(body.parts[1].text, 'reply with exactly: pong'); end); -- clearSession -- testlib.test('clearSession unsets persisted session id', function() local settingsStub = fakeSettings({ ['opencc.session_id'] = 'ses_old' }); local ai = createAi({ http = fakeHttp({}, {}), settings = settingsStub }); ai.clearSession(); testlib.assertEquals(settingsStub.values['opencc.session_id'], nil); testlib.assertEquals(settingsStub.saveCount(), 1); end); testlib.test('clearSession unsets custom sessionSettingKey', function() local settingsStub = fakeSettings({ ['opencc.session_id'] = 'ses_old', ['trapgpt.opencc.session_id'] = 'ses_trapgpt', }); local ai = createAi({ http = fakeHttp({}, {}), settings = settingsStub }); ai.clearSession({ sessionSettingKey = 'trapgpt.opencc.session_id' }); testlib.assertEquals(settingsStub.values['opencc.session_id'], 'ses_old'); testlib.assertEquals(settingsStub.values['trapgpt.opencc.session_id'], nil); testlib.assertEquals(settingsStub.saveCount(), 1); end); testlib.run();