cc-libs/tests/ai.lua

1289 lines
43 KiB
Lua

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();