diff --git a/apis/libai.lua b/apis/libai.lua index 679f059..6c1570f 100644 --- a/apis/libai.lua +++ b/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 '; + -- 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 '; + 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 '; 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) diff --git a/apis/libhttpws.lua b/apis/libhttpws.lua new file mode 100644 index 0000000..aaf6395 --- /dev/null +++ b/apis/libhttpws.lua @@ -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; diff --git a/manifest.json b/manifest.json index a2e732f..2846deb 100644 --- a/manifest.json +++ b/manifest.json @@ -1,6 +1,6 @@ { "name": "TrapOS", - "version": "0.8.16", + "version": "0.8.17", "branch": "next", "packages": [ "trapos" diff --git a/packages/index.json b/packages/index.json index 2a0a30f..70247a4 100644 --- a/packages/index.json +++ b/packages/index.json @@ -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" } } diff --git a/packages/trapos-ai/ccpm.json b/packages/trapos-ai/ccpm.json index 7de4a9f..da25ab8 100644 --- a/packages/trapos-ai/ccpm.json +++ b/packages/trapos-ai/ccpm.json @@ -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" diff --git a/packages/trapos/ccpm.json b/packages/trapos/ccpm.json index ebbee5b..8761d19 100644 --- a/packages/trapos/ccpm.json +++ b/packages/trapos/ccpm.json @@ -1,6 +1,6 @@ { "name": "trapos", - "version": "0.8.16", + "version": "0.8.17", "description": "TrapOS full install meta-package", "dependencies": [ "trapos-boot", diff --git a/programs/ai.lua b/programs/ai.lua index 16b59aa..d7eb561 100644 --- a/programs/ai.lua +++ b/programs/ai.lua @@ -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) diff --git a/tests/ai.lua b/tests/ai.lua index ec18eaa..e75576e 100644 --- a/tests/ai.lua +++ b/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: '. -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() diff --git a/tools/mcp-bridge/package.json b/tools/mcp-bridge/package.json index 32868a4..b94396f 100644 --- a/tools/mcp-bridge/package.json +++ b/tools/mcp-bridge/package.json @@ -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/", diff --git a/tools/mcp-bridge/src/index.ts b/tools/mcp-bridge/src/index.ts index 41b50ac..e30026c 100644 --- a/tools/mcp-bridge/src/index.ts +++ b/tools/mcp-bridge/src/index.ts @@ -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; diff --git a/tools/mcp-bridge/src/opencode-proxy.ts b/tools/mcp-bridge/src/opencode-proxy.ts new file mode 100644 index 0000000..cbbf8a0 --- /dev/null +++ b/tools/mcp-bridge/src/opencode-proxy.ts @@ -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 { + const frame = parseHttpRequestFrame(parseJsonFrame(data)); + if (!frame) { + return; + } + + const headers: Record = { + "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)); + } +} diff --git a/tools/mcp-bridge/src/protocol.ts b/tools/mcp-bridge/src/protocol.ts index 479cf84..1435996 100644 --- a/tools/mcp-bridge/src/protocol.ts +++ b/tools/mcp-bridge/src/protocol.ts @@ -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); diff --git a/tools/mcp-bridge/test-integration/ai-cli.test.ts b/tools/mcp-bridge/test-integration/ai-cli.test.ts new file mode 100644 index 0000000..72cc400 --- /dev/null +++ b/tools/mcp-bridge/test-integration/ai-cli.test.ts @@ -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((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((resolve) => opencode.close(() => resolve())); + } +}); + +async function handleOpencodeRequest( + req: IncomingMessage, + res: ServerResponse, + requests: RequestLog[], + createSessionId: () => string, +): Promise { + 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 { + return typeof value === "object" && value !== null; +} + +function readBody(req: IncomingMessage): Promise { + 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 { + return new Promise((resolve, reject) => { + if (proxy.address()) { + resolve(); + return; + } + proxy.once("listening", () => resolve()); + proxy.once("error", reject); + }); +} + +function closeWss(proxy: WebSocketServer): Promise { + for (const client of proxy.clients) { + client.terminate(); + } + return new Promise((resolve) => proxy.close(() => resolve())); +} diff --git a/tools/mcp-bridge/test-integration/lua/ai-cli-check.lua b/tools/mcp-bridge/test-integration/lua/ai-cli-check.lua new file mode 100644 index 0000000..835c6e8 --- /dev/null +++ b/tools/mcp-bridge/test-integration/lua/ai-cli-check.lua @@ -0,0 +1,27 @@ +-- Runs the real /programs/ai.lua CLI against a bridge proxy URL. +-- Usage: ai-cli-check +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(); diff --git a/tools/mcp-bridge/test-integration/lua/opencode-proxy-check.lua b/tools/mcp-bridge/test-integration/lua/opencode-proxy-check.lua new file mode 100644 index 0000000..63ebfa3 --- /dev/null +++ b/tools/mcp-bridge/test-integration/lua/opencode-proxy-check.lua @@ -0,0 +1,15 @@ +-- Drives the real libhttpws transport against the bridge opencode proxy. +-- Usage: opencode-proxy-check +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(); diff --git a/tools/mcp-bridge/test-integration/opencode-proxy.test.ts b/tools/mcp-bridge/test-integration/opencode-proxy.test.ts new file mode 100644 index 0000000..a1afaef --- /dev/null +++ b/tools/mcp-bridge/test-integration/opencode-proxy.test.ts @@ -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((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((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 { + return new Promise((resolve, reject) => { + if (proxy.address()) { + resolve(); + return; + } + proxy.once("listening", () => resolve()); + proxy.once("error", reject); + }); +} + +function closeWss(proxy: WebSocketServer): Promise { + for (const client of proxy.clients) { + client.terminate(); + } + return new Promise((resolve) => proxy.close(() => resolve())); +} diff --git a/tools/mcp-bridge/test/opencode-proxy.test.ts b/tools/mcp-bridge/test/opencode-proxy.test.ts new file mode 100644 index 0000000..005bbb2 --- /dev/null +++ b/tools/mcp-bridge/test/opencode-proxy.test.ts @@ -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 { + 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 }> { + 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((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((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 { + return new Promise((resolve, reject) => { + if (server.address()) { + resolve(); + return; + } + server.once("listening", () => resolve()); + server.once("error", reject); + }); +} + +function closeWss(proxy: WebSocketServer): Promise { + for (const client of proxy.clients) { + client.terminate(); + } + return new Promise((resolve) => proxy.close(() => resolve())); +}