feat(ai): add lua exec command
This commit is contained in:
parent
58af683ca8
commit
f6d6c9b5af
263
apis/libai.lua
263
apis/libai.lua
@ -1,6 +1,8 @@
|
|||||||
local PING_PROMPT = 'reply with exactly: pong';
|
local PING_PROMPT = 'reply with exactly: pong';
|
||||||
|
|
||||||
local DEFAULT_TIMEOUT_SECONDS = 1200;
|
local DEFAULT_TIMEOUT_SECONDS = 1200;
|
||||||
|
local DEFAULT_LUA_EXEC_MAX_RETRIES = 2;
|
||||||
|
local DEFAULT_LUA_EXEC_TIMEOUT_SECONDS = 5;
|
||||||
|
|
||||||
local B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
local B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
||||||
|
|
||||||
@ -54,6 +56,83 @@ local function extractTextParts(parts)
|
|||||||
return table.concat(texts, '');
|
return table.concat(texts, '');
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function tablePack(...)
|
||||||
|
return { n = select('#', ...), ... };
|
||||||
|
end
|
||||||
|
|
||||||
|
local function endsWithNewline(s)
|
||||||
|
return type(s) == 'string' and string.sub(s, -1) == '\n';
|
||||||
|
end
|
||||||
|
|
||||||
|
local function valuesToLine(values, first, last)
|
||||||
|
local parts = {};
|
||||||
|
for i = first, last do
|
||||||
|
parts[#parts + 1] = tostring(values[i]);
|
||||||
|
end
|
||||||
|
return table.concat(parts, '\t');
|
||||||
|
end
|
||||||
|
|
||||||
|
local function classifyLuaRuntimeError(err)
|
||||||
|
local text = tostring(err or '');
|
||||||
|
if string.find(text, 'attempt to', 1, true) and string.find(text, 'nil value', 1, true) then
|
||||||
|
return 'identifier';
|
||||||
|
end
|
||||||
|
if string.find(text, 'global', 1, true) and string.find(text, 'nil', 1, true) then
|
||||||
|
return 'identifier';
|
||||||
|
end
|
||||||
|
return 'other';
|
||||||
|
end
|
||||||
|
|
||||||
|
local function renderOutput(output)
|
||||||
|
if output == nil or output == '' then
|
||||||
|
return '(no output)';
|
||||||
|
end
|
||||||
|
return output;
|
||||||
|
end
|
||||||
|
|
||||||
|
local function buildLuaExecPrompt(userPrompt)
|
||||||
|
return table.concat({
|
||||||
|
'Write ComputerCraft Lua code to answer this user request.',
|
||||||
|
'Reply with raw Lua code only. Do not use markdown fences or explanations.',
|
||||||
|
'The code runs locally with normal ComputerCraft globals available.',
|
||||||
|
'Use print() or write() for values that should be sent back. Returned values are captured too.',
|
||||||
|
'',
|
||||||
|
'User request:',
|
||||||
|
userPrompt,
|
||||||
|
}, '\n');
|
||||||
|
end
|
||||||
|
|
||||||
|
local function buildLuaCorrectionPrompt(userPrompt, code, err, errorKind)
|
||||||
|
return table.concat({
|
||||||
|
'The previous ComputerCraft Lua failed.',
|
||||||
|
'Reply with corrected raw Lua code only. Do not use markdown fences or explanations.',
|
||||||
|
'',
|
||||||
|
'Original user request:',
|
||||||
|
userPrompt,
|
||||||
|
'',
|
||||||
|
'Error kind: ' .. tostring(errorKind),
|
||||||
|
'Error:',
|
||||||
|
tostring(err),
|
||||||
|
'',
|
||||||
|
'Previous code:',
|
||||||
|
code,
|
||||||
|
}, '\n');
|
||||||
|
end
|
||||||
|
|
||||||
|
local function buildLuaOutputPrompt(userPrompt, output)
|
||||||
|
return table.concat({
|
||||||
|
'The Lua executed successfully.',
|
||||||
|
'Answer the original user request in natural language using the output below.',
|
||||||
|
'Do not write more Lua unless the user explicitly asked for code.',
|
||||||
|
'',
|
||||||
|
'Original user request:',
|
||||||
|
userPrompt,
|
||||||
|
'',
|
||||||
|
'Lua output:',
|
||||||
|
renderOutput(output),
|
||||||
|
}, '\n');
|
||||||
|
end
|
||||||
|
|
||||||
local function sessionTime(session)
|
local function sessionTime(session)
|
||||||
if type(session) ~= 'table' or type(session.time) ~= 'table' then
|
if type(session) ~= 'table' or type(session.time) ~= 'table' then
|
||||||
return 0;
|
return 0;
|
||||||
@ -77,6 +156,19 @@ local function createAi(opts)
|
|||||||
return DEFAULT_TIMEOUT_SECONDS;
|
return DEFAULT_TIMEOUT_SECONDS;
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function resolveLuaExecMaxRetries(options)
|
||||||
|
local n = tonumber(options.maxRetries);
|
||||||
|
if n and n >= 0 then return math.floor(n); end
|
||||||
|
return DEFAULT_LUA_EXEC_MAX_RETRIES;
|
||||||
|
end
|
||||||
|
|
||||||
|
local function resolveLuaExecTimeout(options)
|
||||||
|
if options.luaTimeoutSeconds == false then return nil; end
|
||||||
|
local n = tonumber(options.luaTimeoutSeconds);
|
||||||
|
if n and n > 0 then return n; end
|
||||||
|
return DEFAULT_LUA_EXEC_TIMEOUT_SECONDS;
|
||||||
|
end
|
||||||
|
|
||||||
local function resolveConfig(options)
|
local function resolveConfig(options)
|
||||||
local url = options.serverUrl or settingsLib.get('opencc.server_url');
|
local url = options.serverUrl or settingsLib.get('opencc.server_url');
|
||||||
if not url or url == '' then
|
if not url or url == '' then
|
||||||
@ -169,13 +261,14 @@ local function createAi(opts)
|
|||||||
local cfg, err = resolveConfig(options);
|
local cfg, err = resolveConfig(options);
|
||||||
if not cfg then return false, err; end
|
if not cfg then return false, err; end
|
||||||
|
|
||||||
|
local persist = options.persist ~= false;
|
||||||
local sessionId = options.sessionId;
|
local sessionId = options.sessionId;
|
||||||
if sessionId == nil then
|
if persist and sessionId == nil then
|
||||||
sessionId = settingsLib.get('opencc.session_id');
|
sessionId = settingsLib.get('opencc.session_id');
|
||||||
end
|
end
|
||||||
|
|
||||||
if not sessionId or sessionId == '' then
|
if not sessionId or sessionId == '' then
|
||||||
local body, code = doPost(cfg, '/session', { title = 'cc-ai' });
|
local body, code = doPost(cfg, '/session', { title = options.sessionTitle or 'cc-ai' });
|
||||||
if not body then return false, code; end
|
if not body then return false, code; end
|
||||||
if code and code ~= 200 then
|
if code and code ~= 200 then
|
||||||
return false, 'impossible de creer une session: HTTP ' .. tostring(code);
|
return false, 'impossible de creer une session: HTTP ' .. tostring(code);
|
||||||
@ -185,17 +278,21 @@ local function createAi(opts)
|
|||||||
return false, 'reponse session invalide';
|
return false, 'reponse session invalide';
|
||||||
end
|
end
|
||||||
sessionId = decoded.id;
|
sessionId = decoded.id;
|
||||||
|
if persist then
|
||||||
settingsLib.set('opencc.session_id', sessionId);
|
settingsLib.set('opencc.session_id', sessionId);
|
||||||
if settingsLib.save then settingsLib.save(); end
|
if settingsLib.save then settingsLib.save(); end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
local body, code = doPost(cfg, '/session/' .. sessionId .. '/message', {
|
local body, code = doPost(cfg, '/session/' .. sessionId .. '/message', {
|
||||||
parts = { { type = 'text', text = prompt } },
|
parts = { { type = 'text', text = prompt } },
|
||||||
});
|
});
|
||||||
if not body then return false, code; end
|
if not body then return false, code; end
|
||||||
if code == 404 then
|
if code == 404 then
|
||||||
|
if persist then
|
||||||
settingsLib.unset('opencc.session_id');
|
settingsLib.unset('opencc.session_id');
|
||||||
if settingsLib.save then settingsLib.save(); end
|
if settingsLib.save then settingsLib.save(); end
|
||||||
|
end
|
||||||
return false, 'session introuvable; lance: ai new <prompt>';
|
return false, 'session introuvable; lance: ai new <prompt>';
|
||||||
end
|
end
|
||||||
if code and code ~= 200 then
|
if code and code ~= 200 then
|
||||||
@ -215,6 +312,168 @@ local function createAi(opts)
|
|||||||
return true, { reply = reply, sessionId = sessionId };
|
return true, { reply = reply, sessionId = sessionId };
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function api.createLuaExecutor(options)
|
||||||
|
options = options or {};
|
||||||
|
local baseEnv = options.env or _G;
|
||||||
|
local live = options.live ~= false;
|
||||||
|
local livePrint = options.print or print;
|
||||||
|
local liveWrite = options.write or write;
|
||||||
|
local timeoutSeconds = resolveLuaExecTimeout(options);
|
||||||
|
|
||||||
|
return function(code)
|
||||||
|
local buffer = {};
|
||||||
|
|
||||||
|
local function append(text)
|
||||||
|
buffer[#buffer + 1] = text;
|
||||||
|
end
|
||||||
|
|
||||||
|
local function capturedPrint(...)
|
||||||
|
local values = tablePack(...);
|
||||||
|
local line = valuesToLine(values, 1, values.n);
|
||||||
|
append(line .. '\n');
|
||||||
|
if live then livePrint(...); end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function capturedWrite(text)
|
||||||
|
text = tostring(text or '');
|
||||||
|
append(text);
|
||||||
|
if live then liveWrite(text); end
|
||||||
|
end
|
||||||
|
|
||||||
|
local env = setmetatable({
|
||||||
|
print = capturedPrint,
|
||||||
|
write = capturedWrite,
|
||||||
|
}, { __index = baseEnv });
|
||||||
|
local chunk, loadErr = load(code, 'ai-lua-exec', 't', env);
|
||||||
|
if not chunk then
|
||||||
|
return false, tostring(loadErr), 'syntax';
|
||||||
|
end
|
||||||
|
|
||||||
|
local result;
|
||||||
|
local finished = false;
|
||||||
|
local function runner()
|
||||||
|
result = tablePack(pcall(chunk));
|
||||||
|
finished = true;
|
||||||
|
end
|
||||||
|
|
||||||
|
if timeoutSeconds then
|
||||||
|
parallel.waitForAny(runner, function() sleep(timeoutSeconds); end);
|
||||||
|
else
|
||||||
|
runner();
|
||||||
|
end
|
||||||
|
|
||||||
|
if not finished then
|
||||||
|
return false, 'lua execution timed out after ' .. tostring(timeoutSeconds) .. 's', 'other';
|
||||||
|
end
|
||||||
|
if not result[1] then
|
||||||
|
return false, tostring(result[2]), classifyLuaRuntimeError(result[2]);
|
||||||
|
end
|
||||||
|
if result.n > 1 then
|
||||||
|
if #buffer > 0 and not endsWithNewline(buffer[#buffer]) then
|
||||||
|
append('\n');
|
||||||
|
end
|
||||||
|
append(valuesToLine(result, 2, result.n) .. '\n');
|
||||||
|
end
|
||||||
|
return true, table.concat(buffer), nil;
|
||||||
|
end;
|
||||||
|
end
|
||||||
|
|
||||||
|
function api.luaExec(userPrompt, options)
|
||||||
|
options = options or {};
|
||||||
|
if isBlank(userPrompt) then
|
||||||
|
return false, { error = 'missing prompt; usage: ai lua-exec <prompt>', attempts = 0 };
|
||||||
|
end
|
||||||
|
|
||||||
|
local log = options.log or function() end;
|
||||||
|
local executor = options.executor or api.createLuaExecutor(options);
|
||||||
|
local maxRetries = resolveLuaExecMaxRetries(options);
|
||||||
|
local maxAttempts = maxRetries + 1;
|
||||||
|
local sessionId;
|
||||||
|
|
||||||
|
local function askOptions()
|
||||||
|
return {
|
||||||
|
persist = false,
|
||||||
|
sessionId = sessionId,
|
||||||
|
sessionTitle = 'cc-ai lua-exec',
|
||||||
|
serverUrl = options.serverUrl,
|
||||||
|
username = options.username,
|
||||||
|
password = options.password,
|
||||||
|
timeoutSeconds = options.timeoutSeconds,
|
||||||
|
};
|
||||||
|
end
|
||||||
|
|
||||||
|
log('requesting Lua from AI');
|
||||||
|
local ok, result = api.ask(buildLuaExecPrompt(userPrompt), askOptions());
|
||||||
|
if not ok then
|
||||||
|
return false, { error = result, attempts = 0, errorKind = 'ai' };
|
||||||
|
end
|
||||||
|
sessionId = result.sessionId;
|
||||||
|
log('session: ' .. sessionId);
|
||||||
|
|
||||||
|
local code = result.reply;
|
||||||
|
for attempt = 1, maxAttempts do
|
||||||
|
log('attempt ' .. tostring(attempt) .. '/' .. tostring(maxAttempts));
|
||||||
|
log('code:\n' .. code);
|
||||||
|
|
||||||
|
local execOk, outputOrErr, errorKind = executor(code);
|
||||||
|
if execOk then
|
||||||
|
local output = outputOrErr or '';
|
||||||
|
log('output:\n' .. renderOutput(output));
|
||||||
|
log('requesting final reply');
|
||||||
|
local finalOk, finalResult = api.ask(buildLuaOutputPrompt(userPrompt, output), askOptions());
|
||||||
|
if not finalOk then
|
||||||
|
return false, {
|
||||||
|
error = finalResult,
|
||||||
|
attempts = attempt,
|
||||||
|
errorKind = 'ai',
|
||||||
|
code = code,
|
||||||
|
output = output,
|
||||||
|
sessionId = sessionId,
|
||||||
|
};
|
||||||
|
end
|
||||||
|
log('final reply received');
|
||||||
|
return true, {
|
||||||
|
reply = finalResult.reply,
|
||||||
|
output = output,
|
||||||
|
code = code,
|
||||||
|
attempts = attempt,
|
||||||
|
sessionId = sessionId,
|
||||||
|
};
|
||||||
|
end
|
||||||
|
|
||||||
|
errorKind = errorKind or 'other';
|
||||||
|
log('error (' .. tostring(errorKind) .. '):\n' .. tostring(outputOrErr));
|
||||||
|
if (errorKind ~= 'syntax' and errorKind ~= 'identifier') or attempt >= maxAttempts then
|
||||||
|
return false, {
|
||||||
|
error = outputOrErr,
|
||||||
|
attempts = attempt,
|
||||||
|
errorKind = errorKind,
|
||||||
|
code = code,
|
||||||
|
sessionId = sessionId,
|
||||||
|
retryExhausted = attempt >= maxAttempts,
|
||||||
|
};
|
||||||
|
end
|
||||||
|
|
||||||
|
log('requesting corrected Lua');
|
||||||
|
local correctionOk, correctionResult = api.ask(
|
||||||
|
buildLuaCorrectionPrompt(userPrompt, code, outputOrErr, errorKind),
|
||||||
|
askOptions()
|
||||||
|
);
|
||||||
|
if not correctionOk then
|
||||||
|
return false, {
|
||||||
|
error = correctionResult,
|
||||||
|
attempts = attempt,
|
||||||
|
errorKind = 'ai',
|
||||||
|
code = code,
|
||||||
|
sessionId = sessionId,
|
||||||
|
};
|
||||||
|
end
|
||||||
|
code = correctionResult.reply;
|
||||||
|
end
|
||||||
|
|
||||||
|
return false, { error = 'lua-exec failed unexpectedly', attempts = maxAttempts };
|
||||||
|
end
|
||||||
|
|
||||||
function api.ping(options)
|
function api.ping(options)
|
||||||
return api.ask(PING_PROMPT, options);
|
return api.ask(PING_PROMPT, options);
|
||||||
end
|
end
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "TrapOS",
|
"name": "TrapOS",
|
||||||
"version": "0.6.0",
|
"version": "0.6.1",
|
||||||
"branch": "next",
|
"branch": "next",
|
||||||
"packages": [
|
"packages": [
|
||||||
"trapos"
|
"trapos"
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
"trapos-boot": "0.2.2",
|
"trapos-boot": "0.2.2",
|
||||||
"trapos-net": "0.2.1",
|
"trapos-net": "0.2.1",
|
||||||
"trapos-ui": "0.2.2",
|
"trapos-ui": "0.2.2",
|
||||||
"trapos-ai": "0.4.2",
|
"trapos-ai": "0.5.0",
|
||||||
"trapos": "0.6.0"
|
"trapos": "0.6.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "trapos-ai",
|
"name": "trapos-ai",
|
||||||
"version": "0.4.2",
|
"version": "0.5.0",
|
||||||
"description": "TrapOS AI client for opencode serve",
|
"description": "TrapOS AI client for opencode serve",
|
||||||
"dependencies": ["trapos-core"],
|
"dependencies": ["trapos-core"],
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "trapos",
|
"name": "trapos",
|
||||||
"version": "0.6.0",
|
"version": "0.6.1",
|
||||||
"description": "TrapOS full install meta-package",
|
"description": "TrapOS full install meta-package",
|
||||||
"dependencies": ["trapos-boot", "trapos-net", "trapos-ui", "trapos-test", "trapos-ai"],
|
"dependencies": ["trapos-boot", "trapos-net", "trapos-ui", "trapos-test", "trapos-ai"],
|
||||||
"files": [],
|
"files": [],
|
||||||
|
|||||||
@ -10,6 +10,8 @@ local function printUsage()
|
|||||||
print(' ai ping');
|
print(' ai ping');
|
||||||
print(' ai new <prompt>');
|
print(' ai new <prompt>');
|
||||||
print(' ai --new <prompt>');
|
print(' ai --new <prompt>');
|
||||||
|
print(' ai lua-exec <prompt>');
|
||||||
|
print(' ai --lua-exec <prompt>');
|
||||||
print(' ai sessions');
|
print(' ai sessions');
|
||||||
print(' ai --sessions');
|
print(' ai --sessions');
|
||||||
print(' ai --version');
|
print(' ai --version');
|
||||||
@ -56,6 +58,43 @@ local function askAndPrint(ai, prompt)
|
|||||||
print(result.reply);
|
print(result.reply);
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function printLuaExecLog(message)
|
||||||
|
local text = tostring(message or '');
|
||||||
|
if text == '' then
|
||||||
|
print('[lua-exec]');
|
||||||
|
return;
|
||||||
|
end
|
||||||
|
|
||||||
|
local start = 1;
|
||||||
|
while start <= #text do
|
||||||
|
local newline = string.find(text, '\n', start, true);
|
||||||
|
if not newline then
|
||||||
|
print('[lua-exec] ' .. string.sub(text, start));
|
||||||
|
return;
|
||||||
|
end
|
||||||
|
print('[lua-exec] ' .. string.sub(text, start, newline - 1));
|
||||||
|
start = newline + 1;
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function luaExec(ai, prompt)
|
||||||
|
local ok, result = ai.luaExec(prompt, {
|
||||||
|
executor = ai.createLuaExecutor({ live = true }),
|
||||||
|
log = printLuaExecLog,
|
||||||
|
});
|
||||||
|
if not ok then
|
||||||
|
printLuaExecLog('failed after ' .. tostring(result.attempts or 0) .. ' attempt(s)');
|
||||||
|
if result.errorKind then
|
||||||
|
printLuaExecLog('error kind: ' .. tostring(result.errorKind));
|
||||||
|
end
|
||||||
|
printLuaExecLog(tostring(result.error));
|
||||||
|
return;
|
||||||
|
end
|
||||||
|
|
||||||
|
printLuaExecLog('final reply:');
|
||||||
|
print(result.reply);
|
||||||
|
end
|
||||||
|
|
||||||
local command = args[1];
|
local command = args[1];
|
||||||
|
|
||||||
if command == '--version' or command == '-version' or command == 'version' then
|
if command == '--version' or command == '-version' or command == 'version' then
|
||||||
@ -101,6 +140,16 @@ if command == 'new' or command == '--new' then
|
|||||||
return;
|
return;
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if command == 'lua-exec' or command == '--lua-exec' then
|
||||||
|
local prompt = joinArgs(2);
|
||||||
|
if prompt == '' then
|
||||||
|
printUsage();
|
||||||
|
return;
|
||||||
|
end
|
||||||
|
luaExec(ai, prompt);
|
||||||
|
return;
|
||||||
|
end
|
||||||
|
|
||||||
if string.sub(command, 1, 1) == '-' then
|
if string.sub(command, 1, 1) == '-' then
|
||||||
printUsage();
|
printUsage();
|
||||||
return;
|
return;
|
||||||
|
|||||||
249
tests/ai.lua
249
tests/ai.lua
@ -5,13 +5,18 @@ local testlib = createLibTest({ ... });
|
|||||||
|
|
||||||
local function fakeSettings(initial)
|
local function fakeSettings(initial)
|
||||||
local values = initial or {};
|
local values = initial or {};
|
||||||
|
local getCounts = {};
|
||||||
local saveCount = 0;
|
local saveCount = 0;
|
||||||
return {
|
return {
|
||||||
get = function(key) return values[key]; end,
|
get = function(key)
|
||||||
|
getCounts[key] = (getCounts[key] or 0) + 1;
|
||||||
|
return values[key];
|
||||||
|
end,
|
||||||
set = function(key, value) values[key] = value; end,
|
set = function(key, value) values[key] = value; end,
|
||||||
unset = function(key) values[key] = nil; end,
|
unset = function(key) values[key] = nil; end,
|
||||||
save = function() saveCount = saveCount + 1; end,
|
save = function() saveCount = saveCount + 1; end,
|
||||||
values = values,
|
values = values,
|
||||||
|
getCount = function(key) return getCounts[key] or 0; end,
|
||||||
saveCount = function() return saveCount; end,
|
saveCount = function() return saveCount; end,
|
||||||
};
|
};
|
||||||
end
|
end
|
||||||
@ -72,6 +77,11 @@ local function messageResp(reply)
|
|||||||
}));
|
}));
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function postedText(call)
|
||||||
|
local body = textutils.unserializeJSON(call.body);
|
||||||
|
return body.parts[1].text;
|
||||||
|
end
|
||||||
|
|
||||||
-- base64 --
|
-- base64 --
|
||||||
|
|
||||||
testlib.test('base64encode encodes simple ascii', function()
|
testlib.test('base64encode encodes simple ascii', function()
|
||||||
@ -395,6 +405,48 @@ testlib.test('ask on HTTP error 404 clears session_id', function()
|
|||||||
testlib.assertEquals(settingsStub.values['opencc.session_id'], nil);
|
testlib.assertEquals(settingsStub.values['opencc.session_id'], nil);
|
||||||
end);
|
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()
|
testlib.test('ask omits Authorization header when no password', function()
|
||||||
local httpStub = fakeHttp(
|
local httpStub = fakeHttp(
|
||||||
{ sessionResp('ses_1'), messageResp('reply') },
|
{ sessionResp('ses_1'), messageResp('reply') },
|
||||||
@ -408,6 +460,201 @@ testlib.test('ask omits Authorization header when no password', function()
|
|||||||
testlib.assertEquals(httpStub.postCalls[1].headers['Authorization'], nil);
|
testlib.assertEquals(httpStub.postCalls[1].headers['Authorization'], nil);
|
||||||
end);
|
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 --
|
-- ping --
|
||||||
|
|
||||||
testlib.test('ping sends pong prompt', function()
|
testlib.test('ping sends pong prompt', function()
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user