feat(ai): add opencode bridge proxy
This commit is contained in:
parent
93a4ad2395
commit
835f1ff4a7
257
apis/libai.lua
257
apis/libai.lua
@ -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
140
apis/libhttpws.lua
Normal 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;
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "TrapOS",
|
||||
"version": "0.8.16",
|
||||
"version": "0.8.17",
|
||||
"branch": "next",
|
||||
"packages": [
|
||||
"trapos"
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "trapos",
|
||||
"version": "0.8.16",
|
||||
"version": "0.8.17",
|
||||
"description": "TrapOS full install meta-package",
|
||||
"dependencies": [
|
||||
"trapos-boot",
|
||||
|
||||
@ -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)
|
||||
|
||||
826
tests/ai.lua
826
tests/ai.lua
@ -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()
|
||||
|
||||
@ -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/",
|
||||
|
||||
@ -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;
|
||||
|
||||
73
tools/mcp-bridge/src/opencode-proxy.ts
Normal file
73
tools/mcp-bridge/src/opencode-proxy.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
181
tools/mcp-bridge/test-integration/ai-cli.test.ts
Normal file
181
tools/mcp-bridge/test-integration/ai-cli.test.ts
Normal 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()));
|
||||
}
|
||||
27
tools/mcp-bridge/test-integration/lua/ai-cli-check.lua
Normal file
27
tools/mcp-bridge/test-integration/lua/ai-cli-check.lua
Normal 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();
|
||||
@ -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();
|
||||
70
tools/mcp-bridge/test-integration/opencode-proxy.test.ts
Normal file
70
tools/mcp-bridge/test-integration/opencode-proxy.test.ts
Normal 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()));
|
||||
}
|
||||
163
tools/mcp-bridge/test/opencode-proxy.test.ts
Normal file
163
tools/mcp-bridge/test/opencode-proxy.test.ts
Normal 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()));
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user