feat(ai): add opencode bridge proxy

This commit is contained in:
Guillaume ARM 2026-06-12 00:17:04 +02:00
parent 93a4ad2395
commit 835f1ff4a7
17 changed files with 1014 additions and 831 deletions

View File

@ -2,16 +2,21 @@ local PING_PROMPT = 'reply with exactly: pong';
local DEFAULT_TIMEOUT_SECONDS = 60;
local MAX_TIMEOUT_SECONDS = 60;
local DEFAULT_POLL_TIMEOUT_SECONDS = 600;
local MAX_POLL_TIMEOUT_SECONDS = 600;
local DEFAULT_POLL_INTERVAL_SECONDS = 2;
local DEFAULT_REQUEST_TIMEOUT_SECONDS = 600;
local MAX_REQUEST_TIMEOUT_SECONDS = 600;
local DEFAULT_LUA_EXEC_MAX_RETRIES = 2;
local DEFAULT_LUA_EXEC_TIMEOUT_SECONDS = 5;
local DEFAULT_SESSION_SETTING_KEY = 'opencc.session_id';
local DEFAULT_AGENT_SETTING_KEY = 'opencc.agent';
local DEFAULT_VARIANT_SETTING_KEY = 'opencc.variant';
local DEFAULT_BRIDGE_SETTING_KEY = 'opencc.bridge_url';
local createHttp = require('/apis/libhttp');
local createHttpWs = require('/apis/libhttpws');
local function isWsUrl(url)
return type(url) == 'string' and string.match(url, '^wss?://') ~= nil;
end
local function isBlank(s)
return type(s) ~= 'string' or string.match(s, '^%s*$') ~= nil;
@ -30,13 +35,6 @@ local function extractTextParts(parts)
return table.concat(texts, '');
end
local function nowSeconds()
if os.epoch then
return os.epoch('utc') / 1000;
end
return os.clock();
end
local function tablePack(...)
return { n = select('#', ...), ... };
end
@ -155,10 +153,36 @@ local function createAi(opts)
local httpLib = opts.http or http;
local settingsLib = opts.settings or settings;
local eventloopFactory = opts.eventloop or require('/apis/eventloop');
local nowFunc = opts.now or nowSeconds;
local osLib = opts.os or os;
local httpClient = opts.httpClient or createHttp({ http = httpLib });
local function resolveRequestTimeout()
local raw = settingsLib.get('opencc.request_timeout_seconds');
local n = tonumber(raw);
if not n or n <= 0 then n = DEFAULT_REQUEST_TIMEOUT_SECONDS; end
if n > MAX_REQUEST_TIMEOUT_SECONDS then n = MAX_REQUEST_TIMEOUT_SECONDS; end
return n;
end
-- A ws:// bridge routes opencode calls through the mcp-bridge proxy, escaping
-- ComputerCraft's hard ~60s http timeout. Falls back to direct http otherwise.
local bridgeUrl = opts.bridgeUrl or settingsLib.get(DEFAULT_BRIDGE_SETTING_KEY);
if isBlank(bridgeUrl) and isWsUrl(settingsLib.get('opencc.server_url')) then
bridgeUrl = settingsLib.get('opencc.server_url');
end
local bridgeActive = not isBlank(bridgeUrl);
local httpClient = opts.httpClient;
if not httpClient then
if bridgeActive then
httpClient = createHttpWs({
http = httpLib,
bridgeUrl = bridgeUrl,
receiveTimeout = resolveRequestTimeout(),
});
else
httpClient = createHttp({ http = httpLib });
end
end
local api = {};
@ -171,23 +195,6 @@ local function createAi(opts)
return n;
end
local function resolvePollTimeout(options)
local raw = options.pollTimeoutSeconds;
if raw == nil then raw = settingsLib.get('opencc.poll_timeout_seconds'); end
local n = tonumber(raw);
if not n or n <= 0 then n = DEFAULT_POLL_TIMEOUT_SECONDS; end
if n > MAX_POLL_TIMEOUT_SECONDS then n = MAX_POLL_TIMEOUT_SECONDS; end
return n;
end
local function resolvePollInterval(options)
local raw = options.pollIntervalSeconds;
if raw == nil then raw = settingsLib.get('opencc.poll_interval_seconds'); end
local n = tonumber(raw);
if not n or n <= 0 then n = DEFAULT_POLL_INTERVAL_SECONDS; end
return n;
end
local function resolveLuaExecMaxRetries(options)
local n = tonumber(options.maxRetries);
if n and n >= 0 then return math.floor(n); end
@ -226,8 +233,12 @@ local function createAi(opts)
local function resolveConfig(options)
local url = options.serverUrl or settingsLib.get('opencc.server_url');
if not url or url == '' then
return nil, 'missing opencc.server_url; run: set opencc.server_url <url>';
-- In bridge mode the proxy holds the real opencode URL, so server_url is optional.
if isBlank(url) then
if not bridgeActive then
return nil, 'missing opencc.server_url; run: set opencc.server_url <url>';
end
url = '';
end
local username = options.username or settingsLib.get('opencc.username') or 'opencode';
local password = options.password or settingsLib.get('opencc.password') or '';
@ -243,28 +254,9 @@ local function createAi(opts)
agent = resolveAgent(options),
variant = resolveVariant(options),
timeoutSeconds = resolveTimeout(options),
pollTimeoutSeconds = resolvePollTimeout(options),
pollIntervalSeconds = resolvePollInterval(options),
};
end
local function buildPromptBody(cfg, messageId, prompt)
local body = {
messageID = messageId,
parts = { { type = 'text', text = prompt } },
};
if cfg.providerID and cfg.modelID then
body.model = { providerID = cfg.providerID, modelID = cfg.modelID };
end
if cfg.agent then
body.agent = cfg.agent;
end
if cfg.variant then
body.variant = cfg.variant;
end
return body;
end
local function buildMessageBody(cfg, prompt)
local body = {
parts = { { type = 'text', text = prompt } },
@ -281,44 +273,6 @@ local function createAi(opts)
return body;
end
local function createMessageId()
local t = math.floor(nowFunc() * 1000);
return 'msg_' .. tostring(t) .. '_' .. tostring(math.random(100000, 999999));
end
local function isMessageComplete(message)
if type(message) ~= 'table' or type(message.info) ~= 'table' then
return false;
end
if type(message.info.finish) == 'string' then
return true;
end
return type(message.info.time) == 'table' and message.info.time.completed ~= nil;
end
local function errorMessage(errorInfo)
if type(errorInfo) ~= 'table' then return nil; end
if type(errorInfo.data) == 'table' and type(errorInfo.data.message) == 'string' then
return errorInfo.data.message;
end
if type(errorInfo.message) == 'string' then
return errorInfo.message;
end
if type(errorInfo.name) == 'string' then
return errorInfo.name;
end
return 'unknown assistant error';
end
local function sessionStatusText(status)
if type(status) ~= 'table' then return nil; end
if type(status.type) ~= 'string' then return nil; end
if status.type == 'retry' then
return 'retry #' .. tostring(status.attempt or '?') .. ': ' .. tostring(status.message or 'unknown error');
end
return status.type;
end
local function decodeMessage(value)
local decoded = value;
if type(value) == 'string' then
@ -330,22 +284,6 @@ local function createAi(opts)
return decoded, nil;
end
local function findAssistantMessage(messages, submittedMessageId)
local seenSubmitted = false;
for _, message in ipairs(messages) do
if type(message) == 'table' and type(message.info) == 'table' then
if message.info.id == submittedMessageId and message.info.role == 'assistant' then
return message;
elseif message.info.id == submittedMessageId then
seenSubmitted = true;
elseif seenSubmitted and message.info.role == 'assistant' then
return message;
end
end
end
return nil;
end
local function handleMissingSession(persist, sessionSettingKey)
if persist then
settingsLib.unset(sessionSettingKey or DEFAULT_SESSION_SETTING_KEY);
@ -354,86 +292,11 @@ local function createAi(opts)
return false, 'session introuvable; lance: ai new <prompt>';
end
local doGet;
local doPost;
local function pollMessage(cfg, sessionId, messageId, persist, sessionSettingKey, log)
local loop = eventloopFactory();
local deadline = nowFunc() + cfg.pollTimeoutSeconds;
local resultOk, resultValue;
local attemptCount = 0;
log = log or function() end;
local function finish(ok, value)
resultOk, resultValue = ok, value;
loop.stopLoop();
end
local function attempt()
attemptCount = attemptCount + 1;
local body, code = doGet(cfg, '/session/' .. sessionId .. '/message');
if not body then
log('poll #' .. tostring(attemptCount) .. ': transient error: ' .. tostring(code));
if nowFunc() >= deadline then
return finish(false, code);
end
return loop.setTimeout(attempt, cfg.pollIntervalSeconds);
end
if code == 404 then
local ok, value = handleMissingSession(persist, sessionSettingKey);
return finish(ok, value);
end
if code and code ~= 200 then
return finish(false, 'erreur message: HTTP ' .. tostring(code));
end
local messages = textutils.unserializeJSON(body);
if type(messages) ~= 'table' then
return finish(false, 'reponse message invalide');
end
local decoded = findAssistantMessage(messages, messageId);
local reply = decoded and extractTextParts(decoded.parts) or '';
local complete = decoded and isMessageComplete(decoded) or false;
local matchedId = decoded and type(decoded.info) == 'table' and decoded.info.id or 'nil';
local assistantError = decoded and type(decoded.info) == 'table' and errorMessage(decoded.info.error) or nil;
log('poll #' .. tostring(attemptCount)
.. ': messages=' .. tostring(#messages)
.. ', found=' .. tostring(matchedId)
.. ', complete=' .. tostring(complete)
.. ', text=' .. tostring(reply ~= '')
.. ', error=' .. tostring(assistantError ~= nil));
if assistantError then
return finish(false, 'erreur assistant: ' .. assistantError);
end
if decoded and reply ~= '' and complete then
log('async reply completed');
return finish(true, { reply = reply, sessionId = sessionId, messageId = messageId });
end
if nowFunc() >= deadline then
local statusBody, statusCodeValue = doGet(cfg, '/session/status');
if statusBody and (not statusCodeValue or statusCodeValue == 200) then
local statuses = textutils.unserializeJSON(statusBody);
local statusText = type(statuses) == 'table' and sessionStatusText(statuses[sessionId]) or nil;
if statusText then
log('session status at timeout: ' .. statusText);
return finish(false, 'delai depasse en attendant la reponse AI (status: ' .. statusText .. ')');
end
end
return finish(false, 'delai depasse en attendant la reponse AI');
end
loop.setTimeout(attempt, cfg.pollIntervalSeconds);
end
loop.setTimeout(attempt, 0);
loop.runLoop();
return resultOk, resultValue;
end
function doGet(cfg, path)
local function doGet(cfg, path)
return httpClient.getJson(cfg, path);
end
function doPost(cfg, path, payload)
local function doPost(cfg, path, payload)
return httpClient.postJson(cfg, path, payload);
end
@ -575,32 +438,10 @@ local function createAi(opts)
promptWithContext = buildPromptWithCallerContext(prompt, osLib);
end
if options.blocking == true then
log('using blocking message endpoint');
return askBlocking(cfg, sessionId, promptWithContext, persist, sessionSettingKey, log);
end
local messageId = options.messageId or createMessageId();
log('sending async prompt ' .. messageId);
local body, code = doPost(cfg, '/session/' .. sessionId .. '/prompt_async',
buildPromptBody(cfg, messageId, promptWithContext));
if not body then return false, code; end
if code == 404 then
return handleMissingSession(persist, sessionSettingKey);
end
if code and code ~= 204 and code ~= 200 then
return false, 'erreur message: HTTP ' .. tostring(code);
end
if code == 200 and body and body ~= '' then
local decoded, decodeErr = decodeMessage(body);
if not decoded then return false, decodeErr; end
local reply = extractTextParts(decoded.parts);
if reply == '' then return false, 'reponse vide'; end
return true, { reply = reply, sessionId = sessionId, messageId = messageId };
end
return pollMessage(cfg, sessionId, messageId, persist, sessionSettingKey, log);
-- Synchronous /message only: opencode's prompt_async loops the model on some
-- builds and strands sessions as busy. The bridge ws transport removes the
-- ~60s http cap that previously made blocking impractical.
return askBlocking(cfg, sessionId, promptWithContext, persist, sessionSettingKey, log);
end
function api.createLuaExecutor(options)

140
apis/libhttpws.lua Normal file
View File

@ -0,0 +1,140 @@
-- WebSocket transport for the opencode bridge proxy.
--
-- Implements the same client surface as libhttp (getJson/postJson plus the
-- trimTrailingSlash/queryString helpers libai calls), but performs each call as
-- a blocking websocket round-trip to the mcp-bridge opencode proxy instead of a
-- direct http.get/post. ComputerCraft's http API caps requests at ~60s; a
-- websocket round-trip has no such cap, so the bridge can run a synchronous
-- opencode request server-side for as long as it needs.
local DEFAULT_RECEIVE_TIMEOUT_SECONDS = 600;
local function isBlank(s)
return type(s) ~= 'string' or string.match(s, '^%s*$') ~= nil;
end
local function trimTrailingSlash(s)
return (s:gsub('/+$', ''));
end
local function urlEncode(s)
return (tostring(s):gsub('[^%w%-_%.~]', function(c)
return string.format('%%%02X', string.byte(c));
end));
end
local function queryString(params)
local parts = {};
for _, item in ipairs(params) do
if not isBlank(item[2]) then
parts[#parts + 1] = urlEncode(item[1]) .. '=' .. urlEncode(item[2]);
end
end
if #parts == 0 then return ''; end
return '?' .. table.concat(parts, '&');
end
local function createHttpWs(opts)
opts = opts or {};
local httpLib = opts.http or http;
local textutilsLib = opts.textutils or textutils;
local bridgeUrl = opts.bridgeUrl;
local receiveTimeout = tonumber(opts.receiveTimeout) or DEFAULT_RECEIVE_TIMEOUT_SECONDS;
local api = {
trimTrailingSlash = trimTrailingSlash,
urlEncode = urlEncode,
queryString = queryString,
};
local activeWs = nil;
local function ensureSocket()
if activeWs then return activeWs, nil; end
if isBlank(bridgeUrl) then
return nil, 'missing opencc.bridge_url';
end
local ws, err = httpLib.websocket(bridgeUrl);
if not ws then
return nil, 'bridge unreachable: ' .. tostring(err);
end
activeWs = ws;
return ws, nil;
end
local function closeSocket()
if activeWs then
pcall(function() activeWs.close(); end);
activeWs = nil;
end
end
local idCounter = 0;
local function nextId()
idCounter = idCounter + 1;
local stamp = os.epoch and os.epoch('utc') or os.clock();
return 'req_' .. tostring(stamp) .. '_' .. tostring(idCounter) .. '_' .. tostring(math.random(100000, 999999));
end
local function trySend(ws, text)
return pcall(function() ws.send(text); end);
end
local function roundtrip(method, path, payload)
local ws, err = ensureSocket();
if not ws then return nil, err; end
local id = nextId();
local frame = { type = 'http', id = id, method = method, path = path };
if payload ~= nil then
frame.body = textutilsLib.serializeJSON(payload);
end
local text = textutilsLib.serializeJSON(frame);
if not trySend(ws, text) then
-- Socket went away; drop it and try one fresh reconnect.
closeSocket();
ws, err = ensureSocket();
if not ws then return nil, err; end
if not trySend(ws, text) then
closeSocket();
return nil, 'bridge send failed';
end
end
while true do
local ok, message = pcall(function() return ws.receive(receiveTimeout); end);
if not ok then
closeSocket();
return nil, 'bridge connection closed';
end
if message == nil then
return nil, 'timeout waiting for bridge response';
end
local decoded = textutilsLib.unserializeJSON(message);
if type(decoded) == 'table' and decoded.type == 'http-response' and decoded.id == id then
if decoded.status == nil or decoded.status == 0 then
return nil, decoded.error or 'bridge error';
end
return decoded.body, decoded.status;
end
-- Ignore frames that don't correlate (stale/other ids) and keep waiting.
end
end
function api.getJson(_cfg, path)
return roundtrip('GET', path, nil);
end
function api.postJson(_cfg, path, payload)
return roundtrip('POST', path, payload);
end
function api.close()
closeSocket();
end
return api;
end
return createHttpWs;

View File

@ -1,6 +1,6 @@
{
"name": "TrapOS",
"version": "0.8.16",
"version": "0.8.17",
"branch": "next",
"packages": [
"trapos"

View File

@ -5,8 +5,8 @@
"trapos-boot": "0.3.2",
"trapos-net": "0.3.0",
"trapos-ui": "0.2.2",
"trapos-ai": "0.6.14",
"trapos-ai": "0.7.0",
"trapos-sandbox": "0.2.2",
"trapos": "0.8.16"
"trapos": "0.8.17"
}
}

View File

@ -1,11 +1,12 @@
{
"name": "trapos-ai",
"version": "0.6.14",
"version": "0.7.0",
"description": "TrapOS AI client for opencode serve",
"dependencies": ["trapos-core"],
"files": [
"apis/libai.lua",
"apis/libhttp.lua",
"apis/libhttpws.lua",
"apis/libtrapgpt.lua",
"programs/ai.lua",
"programs/trapgpt.lua"

View File

@ -1,6 +1,6 @@
{
"name": "trapos",
"version": "0.8.16",
"version": "0.8.17",
"description": "TrapOS full install meta-package",
"dependencies": [
"trapos-boot",

View File

@ -41,21 +41,21 @@ local function printUsage()
print(' ai --version');
print(' ai --help');
print();
print('settings required:');
print(' opencc.server_url');
print('settings required (one of):');
print(' opencc.server_url (direct http opencode URL)');
print(' opencc.bridge_url (ws:// mcp-bridge proxy; bypasses CC 60s http cap)');
print();
print('settings optional:');
print(' opencc.username (default: opencode)');
print(' opencc.password (Basic Auth password)');
print(' opencc.username (default: opencode; direct mode only)');
print(' opencc.password (Basic Auth password; direct mode only)');
print(' opencc.session_id (auto-managed)');
print(' opencc.directory (optional session list scope)');
print(' opencc.agent (e.g. atm10-expert)');
print(' opencc.variant (e.g. low)');
print(' opencc.provider_id (e.g. anthropic)');
print(' opencc.model_id (e.g. claude-opus-4-7)');
print(' opencc.timeout_seconds (per HTTP call, max 60)');
print(' opencc.poll_timeout_seconds (default/max: 600)');
print(' opencc.poll_interval_seconds (default: 2)');
print(' opencc.timeout_seconds (per HTTP call, max 60; direct mode)');
print(' opencc.request_timeout_seconds (ws reply wait, default/max: 600)');
end
local function printAiLog(message)

View File

@ -28,18 +28,6 @@ local function fakeOs(computerId, computerLabel)
};
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,
@ -79,55 +67,65 @@ local function fakeHttp(postResults, getResults)
};
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
-- True network-level failure: no response handle at all (timeout / unreachable).
-- Drives callHttp's `not response` path -> 'serveur injoignable: <message>'.
local function httpTimeout(message)
return function()
return nil, message or 'Timed out';
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
@ -139,38 +137,19 @@ local function messageResp(reply)
}));
end
local function asyncResp()
return response(204, '');
local function wsResult(status, body)
return { status = status, body = body };
end
local function messageListResp(messages)
return response(200, textutils.serializeJSON(messages));
local function wsSessionResult(id)
return wsResult(200, textutils.serializeJSON({ id = id, title = 'cc-ai' }));
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 {} },
local function wsMessageResult(reply)
return wsResult(200, textutils.serializeJSON({
info = { time = { completed = 1 } },
parts = { { type = 'text', text = reply } },
};
end
local function assistantErrorMessage(id, message)
return {
info = {
id = id,
role = 'assistant',
error = { name = 'UnknownError', data = { message = message } },
time = { completed = 1 },
},
parts = {},
};
}));
end
local function postedText(call)
@ -370,7 +349,7 @@ testlib.test('ask creates session then sends message when no session_id', functi
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/prompt_async', 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()
@ -431,7 +410,7 @@ testlib.test('ask reuses existing session_id without creating a new session', fu
testlib.assertTrue(ok);
testlib.assertEquals(#httpStub.postCalls, 1);
testlib.assertTrue(string.find(httpStub.postCalls[1].url, '/session/ses_existing/prompt_async', 1, true) ~= nil);
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()
@ -450,29 +429,12 @@ testlib.test('ask reuses custom sessionSettingKey without creating a new session
testlib.assertTrue(ok);
testlib.assertEquals(#httpStub.postCalls, 1);
testlib.assertTrue(string.find(httpStub.postCalls[1].url, '/session/ses_trapgpt/prompt_async', 1, true) ~= nil);
testlib.assertTrue(string.find(httpStub.postCalls[1].url, '/session/ses_trapgpt/message', 1, true) ~= nil);
end);
testlib.test('ask can use blocking message when explicitly requested', function()
testlib.test('ask uses synchronous message endpoint by default', 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', { blocking = true });
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 uses async prompt when model is unset', function()
local httpStub = fakeHttp(
{ sessionResp('ses_async'), messageResp('reply') },
{ sessionResp('ses_sync'), messageResp('reply') },
{}
);
local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' });
@ -483,7 +445,9 @@ testlib.test('ask uses async prompt when model is unset', function()
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_async/prompt_async', 1, true) ~= nil);
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()
@ -735,271 +699,6 @@ testlib.test('ask options variant overrides setting', function()
testlib.assertEquals(body.variant, 'low');
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'] = 'atm10-expert',
});
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, 'atm10-expert');
end);
testlib.test('ask includes variant in async prompts', function()
local httpStub = fakeHttp(
{ asyncResp() },
{
messageListResp({ assistantMessage('msg_1', 'reply', true) }),
}
);
local settingsStub = fakeAsyncSettings({
['opencc.session_id'] = 'ses_1',
['opencc.variant'] = 'low',
});
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.variant, 'low');
end);
testlib.test('ask includes caller context in async prompts', function()
local httpStub = fakeHttp(
{ asyncResp() },
{
messageListResp({ assistantMessage('msg_1', 'reply', true) }),
}
);
local settingsStub = fakeAsyncSettings({
['opencc.session_id'] = 'ses_1',
});
local elFactory = fakeEventloopFactory();
local ai = createAi({
http = httpStub,
settings = settingsStub,
now = function() return 10; end,
eventloop = elFactory,
os = fakeOs(99, 'factory'),
});
local ok = ai.ask('status?', { messageId = 'msg_1' });
testlib.assertTrue(ok);
local body = textutils.unserializeJSON(httpStub.postCalls[1].body);
testlib.assertTrue(string.find(body.parts[1].text, 'computer id: 99', 1, true) ~= nil);
testlib.assertTrue(string.find(body.parts[1].text, 'computer label: factory', 1, true) ~= nil);
testlib.assertTrue(string.find(body.parts[1].text, 'User prompt:\nstatus?', 1, true) ~= nil);
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 reports assistant errors', function()
local httpStub = fakeHttp(
{ sessionResp('ses_1'), asyncResp() },
{
messageListResp({ userMessage('msg_1', 'hello'), assistantErrorMessage('msg_2', 'bad model') }),
}
);
local settingsStub = fakeAsyncSettings();
local elFactory = fakeEventloopFactory();
local ai = createAi({
http = httpStub,
settings = settingsStub,
now = function() return 10; end,
eventloop = elFactory,
});
local ok, err = ai.ask('hello', { messageId = 'msg_1' });
testlib.assertTrue(not ok);
testlib.assertTrue(string.find(err, 'erreur assistant: bad model', 1, true) ~= nil);
testlib.assertEquals(#httpStub.getCalls, 1);
end);
testlib.test('ask polling default timeout allows ten minute replies', 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) }),
messageListResp({ userMessage('msg_1', 'hello'), assistantMessage('msg_2', 'reply', true) }),
}
);
local settingsStub = fakeAsyncSettings();
local now = 0;
local elFactory = fakeEventloopFactory();
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, result = ai.ask('hello', { messageId = 'msg_1', pollIntervalSeconds = 300 });
testlib.assertTrue(ok, tostring(result));
testlib.assertEquals(result.reply, 'reply');
testlib.assertEquals(#httpStub.getCalls, 3);
end);
testlib.test('ask uses one minute HTTP timeout by default', function()
local httpStub = fakeHttp(
{ sessionResp('ses_1'), messageResp('reply') },
@ -1033,247 +732,6 @@ testlib.test('ask caps per-call HTTP timeout at one minute', function()
testlib.assertEquals(httpStub.postCalls[2].timeout, 60);
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, 3);
testlib.assertTrue(elState.lastLoop.inspect().stopped);
testlib.assertEquals(#elState.lastLoop.inspect().pending, 0);
end);
testlib.test('ask caps polling timeout at ten minutes', 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) }),
messageListResp({ userMessage('msg_1', 'hello'), assistantMessage('msg_2', 'partial', false) }),
}
);
local settingsStub = fakeAsyncSettings({
['opencc.poll_timeout_seconds'] = 1200,
});
local now = 0;
local elFactory = fakeEventloopFactory();
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', pollIntervalSeconds = 300 });
testlib.assertTrue(not ok);
testlib.assertTrue(string.find(err, 'delai depasse', 1, true) ~= nil);
testlib.assertEquals(#httpStub.getCalls, 4);
testlib.assertEquals(now, 600);
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 retries transient network timeout then succeeds', function()
local httpStub = fakeHttp(
{ sessionResp('ses_1'), asyncResp() },
{
messageListResp({ userMessage('msg_1', 'hi'), assistantMessage('msg_2', 'partial', false) }),
httpTimeout('Timed out'),
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, result = ai.ask('hi', { messageId = 'msg_1', pollIntervalSeconds = 1, pollTimeoutSeconds = 60 });
testlib.assertTrue(ok);
testlib.assertEquals(result.reply, 'reply');
testlib.assertEquals(#httpStub.getCalls, 3);
testlib.assertTrue(elState.lastLoop.inspect().stopped);
testlib.assertEquals(#elState.lastLoop.inspect().pending, 0);
end);
testlib.test('pollMessage fails on persistent timeout only after deadline', function()
local httpStub = fakeHttp(
{ sessionResp('ses_1'), asyncResp() },
{
httpTimeout('Timed out'),
httpTimeout('Timed out'),
httpTimeout('Timed out'),
}
);
local settingsStub = fakeAsyncSettings();
local elFactory, elState = fakeEventloopFactory();
local clock = 0;
local ai = createAi({
http = httpStub,
settings = settingsStub,
now = function()
local t = clock;
clock = clock + 30;
return t;
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, 'injoignable', 1, true) ~= nil);
testlib.assertTrue(#httpStub.getCalls > 1);
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' });
@ -1479,6 +937,152 @@ testlib.test('ask omits Authorization header when no password', function()
testlib.assertEquals(httpStub.postCalls[1].headers['Authorization'], nil);
end);
-- ask over the bridge ws transport --
testlib.test('ask over bridge uses synchronous message endpoint', function()
local ws = fakeWsHttp({
wsSessionResult('ses_ws'),
wsMessageResult('reply'),
});
local settingsStub = fakeSettings({ ['opencc.bridge_url'] = 'ws://bridge' });
local ai = createAi({ http = ws.http, settings = settingsStub });
local ok, result = ai.ask('hello');
testlib.assertTrue(ok, tostring(result));
testlib.assertEquals(result.reply, 'reply');
testlib.assertEquals(result.sessionId, 'ses_ws');
testlib.assertEquals(ws.connectUrl(), 'ws://bridge');
testlib.assertEquals(#ws.sent, 2);
testlib.assertEquals(ws.sent[1].method, 'POST');
testlib.assertEquals(ws.sent[1].path, '/session');
testlib.assertEquals(ws.sent[2].method, 'POST');
testlib.assertEquals(ws.sent[2].path, '/session/ses_ws/message');
testlib.assertTrue(string.find(ws.sent[2].path, 'prompt_async', 1, true) == nil);
end);
testlib.test('ask uses bridge when server_url has ws scheme', function()
local ws = fakeWsHttp({
wsSessionResult('ses_ws'),
wsMessageResult('reply'),
});
local settingsStub = fakeSettings({ ['opencc.server_url'] = 'ws://bridgehost' });
local ai = createAi({ http = ws.http, settings = settingsStub });
local ok, result = ai.ask('hello');
testlib.assertTrue(ok, tostring(result));
testlib.assertEquals(result.reply, 'reply');
testlib.assertEquals(ws.connectUrl(), 'ws://bridgehost');
testlib.assertTrue(#ws.sent >= 1);
end);
testlib.test('ask over bridge sends body as serialized json', function()
local ws = fakeWsHttp({ wsMessageResult('reply') });
local settingsStub = fakeSettings({
['opencc.bridge_url'] = 'ws://bridge',
['opencc.session_id'] = 'ses_1',
['opencc.agent'] = 'atm10-expert',
});
local ai = createAi({ http = ws.http, settings = settingsStub, os = fakeOs(5, 'turtle') });
local ok = ai.ask('hello');
testlib.assertTrue(ok);
testlib.assertEquals(#ws.sent, 1);
testlib.assertEquals(ws.sent[1].path, '/session/ses_1/message');
local body = textutils.unserializeJSON(ws.sent[1].body);
testlib.assertEquals(body.agent, 'atm10-expert');
testlib.assertTrue(string.find(body.parts[1].text, 'computer id: 5', 1, true) ~= nil);
end);
testlib.test('ask over bridge surfaces a transport error frame', function()
local ws = fakeWsHttp({ { status = 0, error = 'fetch failed: ECONNREFUSED' } });
local settingsStub = fakeSettings({
['opencc.bridge_url'] = 'ws://bridge',
['opencc.session_id'] = 'ses_1',
});
local ai = createAi({ http = ws.http, settings = settingsStub });
local ok, err = ai.ask('hello');
testlib.assertTrue(not ok);
testlib.assertTrue(string.find(err, 'fetch failed', 1, true) ~= nil);
end);
testlib.test('ask over bridge times out when no reply', function()
local ws = fakeWsHttp({});
local settingsStub = fakeSettings({
['opencc.bridge_url'] = 'ws://bridge',
['opencc.session_id'] = 'ses_1',
});
local ai = createAi({ http = ws.http, settings = settingsStub });
local ok, err = ai.ask('hello');
testlib.assertTrue(not ok);
testlib.assertTrue(string.find(err, 'timeout', 1, true) ~= nil);
end);
testlib.test('ask over bridge maps a 404 frame to missing session', function()
local ws = fakeWsHttp({ wsResult(404, '{}') });
local settingsStub = fakeSettings({
['opencc.bridge_url'] = 'ws://bridge',
['opencc.session_id'] = 'ses_stale',
});
local ai = createAi({ http = ws.http, settings = settingsStub });
local ok, err = ai.ask('hello');
testlib.assertTrue(not ok);
testlib.assertTrue(string.find(err, 'session introuvable', 1, true) ~= nil);
testlib.assertEquals(settingsStub.values['opencc.session_id'], nil);
end);
testlib.test('ask over bridge fails when websocket connect fails', function()
local ws = fakeWsHttp({}, { connectFail = true });
local settingsStub = fakeSettings({
['opencc.bridge_url'] = 'ws://bridge',
['opencc.session_id'] = 'ses_1',
});
local ai = createAi({ http = ws.http, settings = settingsStub });
local ok, err = ai.ask('hello');
testlib.assertTrue(not ok);
testlib.assertTrue(string.find(err, 'bridge unreachable', 1, true) ~= nil);
end);
testlib.test('ask over bridge uses request_timeout_seconds for ws receive', function()
local ws = fakeWsHttp({ wsMessageResult('reply') });
local settingsStub = fakeSettings({
['opencc.bridge_url'] = 'ws://bridge',
['opencc.session_id'] = 'ses_1',
['opencc.request_timeout_seconds'] = 120,
});
local ai = createAi({ http = ws.http, settings = settingsStub });
local ok = ai.ask('hello');
testlib.assertTrue(ok);
testlib.assertEquals(ws.lastReceiveTimeout(), 120);
end);
testlib.test('ask over bridge caps request_timeout_seconds at ten minutes', function()
local ws = fakeWsHttp({ wsMessageResult('reply') });
local settingsStub = fakeSettings({
['opencc.bridge_url'] = 'ws://bridge',
['opencc.session_id'] = 'ses_1',
['opencc.request_timeout_seconds'] = 5000,
});
local ai = createAi({ http = ws.http, settings = settingsStub });
local ok = ai.ask('hello');
testlib.assertTrue(ok);
testlib.assertEquals(ws.lastReceiveTimeout(), 600);
end);
-- lua executor --
testlib.test('lua executor captures print write and returned values', function()

View File

@ -3,6 +3,9 @@
"version": "0.3.0",
"private": true,
"type": "module",
"engines": {
"node": ">=18"
},
"scripts": {
"build": "tsc",
"eslint": "eslint . --cache --cache-location node_modules/.cache/eslint/",

View File

@ -1,5 +1,6 @@
import { LinkRegistry, startLinkServer } from "./link-server.js";
import { startMcpServer } from "./mcp-server.js";
import { startOpencodeProxy } from "./opencode-proxy.js";
const config = {
mcpHost: process.env.MCP_HOST ?? "127.0.0.1",
@ -7,6 +8,11 @@ const config = {
ccLinkHost: process.env.CC_LINK_HOST ?? "0.0.0.0",
ccLinkPort: readPort(process.env.CC_LINK_PORT, 3001),
probeTimeoutMs: readPort(process.env.CC_PROBE_TIMEOUT_MS, 2000),
opencodeProxyHost: process.env.OPENCODE_PROXY_HOST ?? "0.0.0.0",
opencodeProxyPort: readPort(process.env.OPENCODE_PROXY_PORT, 3002),
opencodeUrl: process.env.OPENCODE_URL,
opencodeUsername: process.env.OPENCODE_USERNAME,
opencodePassword: process.env.OPENCODE_PASSWORD,
};
const registry = new LinkRegistry();
@ -16,6 +22,19 @@ startLinkServer({ host: config.ccLinkHost, port: config.ccLinkPort, registry });
console.log(`MCP bridge listening on http://${config.mcpHost}:${config.mcpPort}`);
console.log(`ComputerCraft link listening on ws://${config.ccLinkHost}:${config.ccLinkPort}`);
if (config.opencodeUrl) {
startOpencodeProxy({
host: config.opencodeProxyHost,
port: config.opencodeProxyPort,
opencodeUrl: config.opencodeUrl,
username: config.opencodeUsername,
password: config.opencodePassword,
});
console.log(`opencode proxy listening on ws://${config.opencodeProxyHost}:${config.opencodeProxyPort} -> ${config.opencodeUrl}`);
} else {
console.log("opencode proxy disabled (set OPENCODE_URL to enable)");
}
function readPort(value: string | undefined, fallback: number): number {
if (!value) {
return fallback;

View File

@ -0,0 +1,73 @@
import { WebSocketServer, type WebSocket } from "ws";
import { parseHttpRequestFrame, parseJsonFrame, type HttpResponseFrame } from "./protocol.js";
export type OpencodeProxyOptions = {
host: string;
port: number;
opencodeUrl: string;
username?: string;
password?: string;
maxRequestMs?: number;
};
const DEFAULT_MAX_REQUEST_MS = 600_000;
// WebSocket endpoint where the ComputerCraft client is the requester and the
// bridge is the responder: it performs the opencode http call server-side (no
// short timeout) and returns the raw response. This lets the in-game client use
// opencode's synchronous /message endpoint without hitting CC's ~60s http cap.
export function startOpencodeProxy(options: OpencodeProxyOptions): WebSocketServer {
const baseUrl = options.opencodeUrl.replace(/\/+$/, "");
const maxRequestMs = options.maxRequestMs ?? DEFAULT_MAX_REQUEST_MS;
const authHeader = options.password
? "Basic " + Buffer.from(`${options.username ?? "opencode"}:${options.password}`).toString("base64")
: undefined;
const server = new WebSocketServer({ host: options.host, port: options.port });
server.on("connection", (ws) => {
ws.on("message", (data) => {
void handleFrame(ws, data);
});
ws.on("error", () => {
/* connection-level errors just drop the socket; nothing to clean up */
});
});
async function handleFrame(ws: WebSocket, data: unknown): Promise<void> {
const frame = parseHttpRequestFrame(parseJsonFrame(data));
if (!frame) {
return;
}
const headers: Record<string, string> = {
"Content-Type": "application/json",
Accept: "application/json",
};
if (authHeader) {
headers.Authorization = authHeader;
}
try {
const res = await fetch(baseUrl + frame.path, {
method: frame.method,
headers,
body: frame.method === "POST" ? frame.body : undefined,
signal: AbortSignal.timeout(maxRequestMs),
});
const body = await res.text();
send(ws, { type: "http-response", id: frame.id, status: res.status, body });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
send(ws, { type: "http-response", id: frame.id, status: 0, error: message });
}
}
return server;
}
function send(ws: WebSocket, frame: HttpResponseFrame): void {
if (ws.readyState === ws.OPEN) {
ws.send(JSON.stringify(frame));
}
}

View File

@ -14,6 +14,52 @@ export type ResponseMessage = {
export type LinkMessage = HelloMessage | ResponseMessage;
export type HttpRequestFrame = {
type: "http";
id: string;
method: "GET" | "POST";
path: string;
body?: string;
};
export type HttpResponseFrame = {
type: "http-response";
id: string;
status: number;
body?: string;
error?: string;
};
export function parseHttpRequestFrame(value: unknown): HttpRequestFrame | null {
if (!isRecord(value) || value.type !== "http") {
return null;
}
if (typeof value.id !== "string" || value.id === "") {
return null;
}
if (value.method !== "GET" && value.method !== "POST") {
return null;
}
if (typeof value.path !== "string" || !value.path.startsWith("/")) {
return null;
}
if (value.body !== undefined && typeof value.body !== "string") {
return null;
}
return {
type: "http",
id: value.id,
method: value.method,
path: value.path,
body: typeof value.body === "string" ? value.body : undefined,
};
}
export function parseJsonFrame(data: unknown): unknown {
if (typeof data === "string") {
return parseJson(data);

View File

@ -0,0 +1,181 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import type { WebSocketServer } from "ws";
import { formatFailure, startCraftos } from "./harness.js";
import { startOpencodeProxy } from "../src/opencode-proxy.js";
type RequestLog = {
method: string;
path: string;
body?: unknown;
};
test("ai CLI commands run through the opencode bridge proxy", async () => {
const requests: RequestLog[] = [];
let nextSession = 1;
const opencode = createServer((req, res) => {
void handleOpencodeRequest(req, res, requests, () => {
const id = `ses_${nextSession}`;
nextSession += 1;
return id;
});
});
await new Promise<void>((resolve) => opencode.listen(0, "127.0.0.1", resolve));
const opencodeUrl = `http://127.0.0.1:${serverPort(opencode)}`;
const proxy = startOpencodeProxy({ host: "127.0.0.1", port: 0, opencodeUrl });
await waitForListening(proxy);
const proxyUrl = `ws://127.0.0.1:${wssPort(proxy)}`;
const craftos = startCraftos("ai-cli-check.lua", {
mountRepo: true,
shellArgs: [proxyUrl],
timeoutMs: 20_000,
});
try {
const result = await craftos.done;
const output = result.output;
assert.equal(result.status, 0, formatFailure("expected craftos to exit cleanly", output));
assert.match(output, /ses_existing {2}Existing title/, formatFailure("expected sessions output", output));
assert.match(output, /\bpong\b/, formatFailure("expected ping reply", output));
assert.match(output, /new reply/, formatFailure("expected ai new reply", output));
assert.match(output, /plain reply/, formatFailure("expected plain ai reply", output));
assert.match(output, /SESSION_AFTER_PING=ses_1/, formatFailure("expected ping session to persist", output));
assert.match(output, /SESSION_AFTER_NEW=ses_2/, formatFailure("expected ai new to replace session", output));
assert.match(output, /SESSION_AFTER_ASK=ses_2/, formatFailure("expected plain ai to reuse new session", output));
assert.deepEqual(requests.map((r) => `${r.method} ${r.path}`), [
"GET /session",
"POST /session",
"POST /session/ses_1/message",
"POST /session",
"POST /session/ses_2/message",
"POST /session/ses_2/message",
]);
assert.match(promptText(requests[2].body), /reply with exactly: pong/);
assert.match(promptText(requests[4].body), /User prompt:\nfresh start/);
assert.match(promptText(requests[5].body), /User prompt:\ncontinue please/);
} finally {
craftos.abort();
await craftos.done.catch(() => undefined);
await closeWss(proxy);
await new Promise<void>((resolve) => opencode.close(() => resolve()));
}
});
async function handleOpencodeRequest(
req: IncomingMessage,
res: ServerResponse,
requests: RequestLog[],
createSessionId: () => string,
): Promise<void> {
const bodyText = await readBody(req);
const body: unknown = bodyText === "" ? undefined : JSON.parse(bodyText) as unknown;
const url = new URL(req.url ?? "/", "http://127.0.0.1");
requests.push({ method: req.method ?? "GET", path: url.pathname + url.search, body });
if (req.method === "GET" && url.pathname === "/session") {
respondJson(res, [
{ id: "ses_existing", title: "Existing title", time: { updated: 10 } },
]);
return;
}
if (req.method === "POST" && url.pathname === "/session") {
const id = createSessionId();
respondJson(res, { id, title: "cc-ai" });
return;
}
const messageMatch = url.pathname.match(/^\/session\/([^/]+)\/message$/);
if (req.method === "POST" && messageMatch) {
respondJson(res, {
info: { id: `msg_${requests.length}`, time: { completed: 1 } },
parts: [{ type: "text", text: replyForPrompt(promptText(body)) }],
});
return;
}
respondJson(res, { error: "not found" }, 404);
}
function replyForPrompt(prompt: string): string {
if (prompt.includes("reply with exactly: pong")) {
return "pong";
}
if (prompt.includes("fresh start")) {
return "new reply";
}
if (prompt.includes("continue please")) {
return "plain reply";
}
return `unhandled prompt: ${prompt}`;
}
function promptText(body: unknown): string {
if (!isObject(body) || !Array.isArray(body.parts)) {
return "";
}
const parts = body.parts as unknown[];
const first = parts[0];
if (!isObject(first) || typeof first.text !== "string") {
return "";
}
return first.text;
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function readBody(req: IncomingMessage): Promise<string> {
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => chunks.push(chunk));
return new Promise((resolve, reject) => {
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
req.on("error", reject);
});
}
function respondJson(res: ServerResponse, body: unknown, status = 200): void {
res.writeHead(status, { "content-type": "application/json" });
res.end(JSON.stringify(body));
}
function serverPort(server: Server): number {
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("server has no TCP address");
}
return address.port;
}
function wssPort(proxy: WebSocketServer): number {
const address = proxy.address();
if (!address || typeof address === "string") {
throw new Error("proxy has no TCP address");
}
return address.port;
}
function waitForListening(proxy: WebSocketServer): Promise<void> {
return new Promise((resolve, reject) => {
if (proxy.address()) {
resolve();
return;
}
proxy.once("listening", () => resolve());
proxy.once("error", reject);
});
}
function closeWss(proxy: WebSocketServer): Promise<void> {
for (const client of proxy.clients) {
client.terminate();
}
return new Promise<void>((resolve) => proxy.close(() => resolve()));
}

View File

@ -0,0 +1,27 @@
-- Runs the real /programs/ai.lua CLI against a bridge proxy URL.
-- Usage: ai-cli-check <ws-proxy-url>
local args = { ... };
local url = args[1];
settings.unset('opencc.server_url');
settings.unset('opencc.session_id');
settings.set('opencc.bridge_url', url);
settings.set('opencc.request_timeout_seconds', 10);
settings.save();
print('--- sessions ---');
shell.run('/programs/ai.lua', 'sessions');
print('--- ping ---');
shell.run('/programs/ai.lua', 'ping');
print('SESSION_AFTER_PING=' .. tostring(settings.get('opencc.session_id')));
print('--- new ---');
shell.run('/programs/ai.lua', 'new', 'fresh', 'start');
print('SESSION_AFTER_NEW=' .. tostring(settings.get('opencc.session_id')));
print('--- ask ---');
shell.run('/programs/ai.lua', 'continue', 'please');
print('SESSION_AFTER_ASK=' .. tostring(settings.get('opencc.session_id')));
os.shutdown();

View File

@ -0,0 +1,15 @@
-- Drives the real libhttpws transport against the bridge opencode proxy.
-- Usage: opencode-proxy-check <ws-proxy-url>
local createHttpWs = require('/apis/libhttpws');
local args = { ... };
local url = args[1];
local client = createHttpWs({ bridgeUrl = url, receiveTimeout = 10 });
local body, code = client.postJson({ url = '' }, '/session/ses_x/message', {
parts = { { type = 'text', text = 'ping' } },
});
print('STATUS=' .. tostring(code));
print('BODY=' .. tostring(body));
client.close();
os.shutdown();

View File

@ -0,0 +1,70 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createServer, type Server } from "node:http";
import type { WebSocketServer } from "ws";
import { formatFailure, startCraftos } from "./harness.js";
import { startOpencodeProxy } from "../src/opencode-proxy.js";
test("libhttpws drives a synchronous opencode call through the bridge proxy", async () => {
const opencode = createServer((_req, res) => {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ info: { finish: "stop" }, parts: [{ type: "text", text: "pong" }] }));
});
await new Promise<void>((resolve) => opencode.listen(0, "127.0.0.1", resolve));
const opencodeUrl = `http://127.0.0.1:${serverPort(opencode)}`;
const proxy = startOpencodeProxy({ host: "127.0.0.1", port: 0, opencodeUrl });
await waitForListening(proxy);
const proxyUrl = `ws://127.0.0.1:${wssPort(proxy)}`;
const craftos = startCraftos("opencode-proxy-check.lua", {
mountRepo: true,
shellArgs: [proxyUrl],
timeoutMs: 20_000,
});
try {
const result = await craftos.done;
assert.match(result.output, /STATUS=200/, formatFailure("expected STATUS=200", result.output));
assert.match(result.output, /pong/, formatFailure("expected reply body to contain pong", result.output));
} finally {
craftos.abort();
await craftos.done.catch(() => undefined);
await closeWss(proxy);
await new Promise<void>((resolve) => opencode.close(() => resolve()));
}
});
function serverPort(server: Server): number {
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("server has no TCP address");
}
return address.port;
}
function wssPort(proxy: WebSocketServer): number {
const address = proxy.address();
if (!address || typeof address === "string") {
throw new Error("proxy has no TCP address");
}
return address.port;
}
function waitForListening(proxy: WebSocketServer): Promise<void> {
return new Promise((resolve, reject) => {
if (proxy.address()) {
resolve();
return;
}
proxy.once("listening", () => resolve());
proxy.once("error", reject);
});
}
function closeWss(proxy: WebSocketServer): Promise<void> {
for (const client of proxy.clients) {
client.terminate();
}
return new Promise<void>((resolve) => proxy.close(() => resolve()));
}

View File

@ -0,0 +1,163 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import type { AddressInfo } from "node:net";
import { WebSocket, type RawData, type WebSocketServer } from "ws";
import { parseHttpRequestFrame, parseJsonFrame } from "../src/protocol.js";
import { startOpencodeProxy } from "../src/opencode-proxy.js";
test("parseHttpRequestFrame accepts a valid frame", () => {
const frame = parseJsonFrame(
JSON.stringify({ type: "http", id: "req_1", method: "POST", path: "/session/x/message", body: "{}" }),
);
assert.deepEqual(parseHttpRequestFrame(frame), {
type: "http",
id: "req_1",
method: "POST",
path: "/session/x/message",
body: "{}",
});
});
test("parseHttpRequestFrame omits absent body", () => {
const frame = parseHttpRequestFrame({ type: "http", id: "req_1", method: "GET", path: "/session" });
assert.deepEqual(frame, { type: "http", id: "req_1", method: "GET", path: "/session", body: undefined });
});
test("parseHttpRequestFrame rejects invalid frames", () => {
assert.equal(parseHttpRequestFrame({ type: "http", id: "", method: "GET", path: "/x" }), null);
assert.equal(parseHttpRequestFrame({ type: "http", id: "a", method: "DELETE", path: "/x" }), null);
assert.equal(parseHttpRequestFrame({ type: "http", id: "a", method: "GET", path: "no-slash" }), null);
assert.equal(parseHttpRequestFrame({ type: "http", id: "a", method: "GET", path: "/x", body: 5 }), null);
assert.equal(parseHttpRequestFrame({ type: "other", id: "a", method: "GET", path: "/x" }), null);
});
test("proxy forwards a request to opencode and returns the raw response", async () => {
const seen: { method?: string; url?: string; auth?: string; body?: string } = {};
const opencode = await startFakeOpencode((req, res, body) => {
seen.method = req.method;
seen.url = req.url;
seen.auth = req.headers.authorization;
seen.body = body;
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ info: { finish: "stop" }, parts: [{ type: "text", text: "pong" }] }));
});
const proxy = startOpencodeProxy({
host: "127.0.0.1",
port: 0,
opencodeUrl: opencode.url,
username: "opencode",
password: "secret",
});
await waitForListening(proxy);
try {
const response = await roundtrip(proxyUrl(proxy), {
type: "http",
id: "req_42",
method: "POST",
path: "/session/ses_1/message",
body: JSON.stringify({ parts: [{ type: "text", text: "ping" }] }),
});
assert.equal(response.type, "http-response");
assert.equal(response.id, "req_42");
assert.equal(response.status, 200);
const parsed = JSON.parse(response.body as string) as { parts: { text: string }[] };
assert.equal(parsed.parts[0].text, "pong");
assert.equal(seen.method, "POST");
assert.equal(seen.url, "/session/ses_1/message");
assert.equal(seen.auth, "Basic " + Buffer.from("opencode:secret").toString("base64"));
assert.equal(seen.body, JSON.stringify({ parts: [{ type: "text", text: "ping" }] }));
} finally {
await closeWss(proxy);
await opencode.close();
}
});
test("proxy maps a fetch failure to status 0", async () => {
// Point at a port with nothing listening so fetch rejects.
const proxy = startOpencodeProxy({ host: "127.0.0.1", port: 0, opencodeUrl: "http://127.0.0.1:1" });
await waitForListening(proxy);
try {
const response = await roundtrip(proxyUrl(proxy), {
type: "http",
id: "req_err",
method: "GET",
path: "/session",
});
assert.equal(response.id, "req_err");
assert.equal(response.status, 0);
assert.equal(typeof response.error, "string");
} finally {
await closeWss(proxy);
}
});
type HttpResponse = { type: string; id: string; status: number; body?: string; error?: string };
function rawToString(data: RawData): string {
if (Array.isArray(data)) {
return Buffer.concat(data).toString("utf8");
}
if (Buffer.isBuffer(data)) {
return data.toString("utf8");
}
return Buffer.from(data).toString("utf8");
}
function roundtrip(url: string, frame: unknown): Promise<HttpResponse> {
return new Promise((resolve, reject) => {
const ws = new WebSocket(url);
ws.on("open", () => ws.send(JSON.stringify(frame)));
ws.on("message", (data: RawData) => {
ws.close();
resolve(JSON.parse(rawToString(data)) as HttpResponse);
});
ws.on("error", reject);
});
}
async function startFakeOpencode(
handler: (req: IncomingMessage, res: ServerResponse, body: string) => void,
): Promise<{ url: string; close: () => Promise<void> }> {
const server = createServer((req, res) => {
const chunks: Buffer[] = [];
req.on("data", (c: Buffer) => chunks.push(c));
req.on("end", () => handler(req, res, Buffer.concat(chunks).toString("utf8")));
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const port = (server.address() as AddressInfo).port;
return {
url: `http://127.0.0.1:${port}`,
close: () => new Promise<void>((resolve) => server.close(() => resolve())),
};
}
function proxyUrl(proxy: WebSocketServer): string {
const address = proxy.address();
if (!address || typeof address === "string") {
throw new Error("proxy has no TCP address");
}
return `ws://127.0.0.1:${address.port}`;
}
function waitForListening(server: WebSocketServer | Server): Promise<void> {
return new Promise((resolve, reject) => {
if (server.address()) {
resolve();
return;
}
server.once("listening", () => resolve());
server.once("error", reject);
});
}
function closeWss(proxy: WebSocketServer): Promise<void> {
for (const client of proxy.clients) {
client.terminate();
}
return new Promise<void>((resolve) => proxy.close(() => resolve()));
}