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 fakeOs(computerId, computerLabel) return { getComputerID = function() return computerId; end, getComputerLabel = function() return computerLabel; end, }; 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 -- Fake CC http library exposing websocket(), for the bridge ws transport. -- wsResults: list of { status=, body=, error= } returned per round-trip in order. -- A nil entry simulates ws.receive timing out. opts.connectFail makes -- http.websocket return (nil, err). local function fakeWsHttp(wsResults, opts) wsResults = wsResults or {}; opts = opts or {}; local sent = {}; local receiveTimeouts = {}; local idx = 0; local lastId = nil; local closed = false; local connectUrl = nil; local ws = { send = function(text) local frame = textutils.unserializeJSON(text); sent[#sent + 1] = frame; lastId = frame and frame.id; end, receive = function(timeout) receiveTimeouts[#receiveTimeouts + 1] = timeout; idx = idx + 1; local r = wsResults[idx]; if r == nil then return nil; end return textutils.serializeJSON({ type = 'http-response', id = lastId, status = r.status, body = r.body, error = r.error, }); end, close = function() closed = true; end, }; local httpLib = { websocket = function(url) connectUrl = url; if opts.connectFail then return nil, 'refused'; end return ws; end, }; return { http = httpLib, sent = sent, isClosed = function() return closed; end, connectUrl = function() return connectUrl; end, lastReceiveTimeout = function() return receiveTimeouts[#receiveTimeouts]; end, }; end local function httpError(code, body) return function() return nil, 'HTTP response code ' .. tostring(code), response(code, body); end; 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 wsResult(status, body) return { status = status, body = body }; end local function wsSessionResult(id) return wsResult(200, textutils.serializeJSON({ id = id, title = 'cc-ai' })); end local function wsMessageResult(reply) return wsResult(200, textutils.serializeJSON({ info = { time = { completed = 1 } }, 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 sends configured directory query', function() local sessions = { { id = 'ses_1', title = 'one', time = { updated = 10 } }, }; local httpStub = fakeHttp({}, { response(200, textutils.serializeJSON(sessions)) }); local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host', ['opencc.directory'] = '/Users/garm/trap/cc-libs', }); local ai = createAi({ http = httpStub, settings = settingsStub }); local ok, result = ai.listSessions(); testlib.assertTrue(ok, tostring(result)); testlib.assertEquals(#result, 1); testlib.assertEquals(httpStub.getCalls[1].url, 'http://host/session?directory=%2FUsers%2Fgarm%2Ftrap%2Fcc-libs'); end); testlib.test('listSessions caps HTTP timeout at one minute', function() local httpStub = fakeHttp({}, { response(200, '[]') }); local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host', ['opencc.timeout_seconds'] = 120, }); local ai = createAi({ http = httpStub, settings = settingsStub }); local ok = ai.listSessions(); testlib.assertTrue(ok); testlib.assertEquals(httpStub.getCalls[1].timeout, 60); end); testlib.test('listSessions retries with persisted session directory when list is empty', function() local scopedSessions = { { id = 'ses_existing', title = 'existing', time = { updated = 20 } }, }; local session = { id = 'ses_existing', title = 'existing', directory = '/Users/garm/trap/cc-libs', }; local httpStub = fakeHttp({}, { response(200, '[]'), response(200, textutils.serializeJSON(session)), response(200, textutils.serializeJSON(scopedSessions)), }); local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host', ['opencc.session_id'] = 'ses_existing', }); local logs = {}; local ai = createAi({ http = httpStub, settings = settingsStub }); local ok, result = ai.listSessions({ log = function(message) logs[#logs + 1] = message; end, }); testlib.assertTrue(ok, tostring(result)); testlib.assertEquals(#result, 1); testlib.assertEquals(result[1].id, 'ses_existing'); testlib.assertEquals(httpStub.getCalls[1].url, 'http://host/session'); testlib.assertEquals(httpStub.getCalls[2].url, 'http://host/session/ses_existing'); testlib.assertEquals(httpStub.getCalls[3].url, 'http://host/session?directory=%2FUsers%2Fgarm%2Ftrap%2Fcc-libs'); testlib.assertTrue(string.find(logs[2], 'resolving directory', 1, true) ~= nil); 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 uses synchronous message endpoint by default', function() local httpStub = fakeHttp( { sessionResp('ses_sync'), 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_sync/message', 1, true) ~= nil); testlib.assertTrue(string.find(httpStub.postCalls[2].url, 'prompt_async', 1, true) == nil); testlib.assertEquals(#httpStub.getCalls, 0); end); testlib.test('ask wraps prompt with caller context', 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, os = fakeOs(42, 'storage-main'), }); 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.assertTrue(string.find(body.parts[1].text, '