feat(ai): poll async opencode responses

This commit is contained in:
Guillaume ARM 2026-06-09 17:07:24 +02:00
parent dea9bd52ee
commit cf54b492b2
9 changed files with 191 additions and 27 deletions

View File

@ -2,6 +2,8 @@ local PING_PROMPT = 'reply with exactly: pong';
local DEFAULT_TIMEOUT_SECONDS = 60;
local MAX_TIMEOUT_SECONDS = 60;
local DEFAULT_POLL_TIMEOUT_SECONDS = 300;
local DEFAULT_POLL_INTERVAL_SECONDS = 2;
local DEFAULT_LUA_EXEC_MAX_RETRIES = 2;
local DEFAULT_LUA_EXEC_TIMEOUT_SECONDS = 5;
@ -57,6 +59,13 @@ 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
@ -146,6 +155,8 @@ local function createAi(opts)
local httpLib = opts.http or http;
local settingsLib = opts.settings or settings;
local sleepFunc = opts.sleep or sleep;
local nowFunc = opts.now or nowSeconds;
local api = {};
@ -158,6 +169,22 @@ 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
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
@ -183,9 +210,68 @@ local function createAi(opts)
username = username,
password = password,
timeoutSeconds = resolveTimeout(options),
pollTimeoutSeconds = resolvePollTimeout(options),
pollIntervalSeconds = resolvePollInterval(options),
};
end
local function createMessageId()
local t = math.floor(nowFunc() * 1000);
return 'cc_' .. 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 decodeMessage(body)
local decoded = textutils.unserializeJSON(body);
if type(decoded) ~= 'table' or type(decoded.parts) ~= 'table' then
return nil, 'reponse message invalide';
end
return decoded, nil;
end
local function handleMissingSession(persist)
if persist then
settingsLib.unset('opencc.session_id');
if settingsLib.save then settingsLib.save(); end
end
return false, 'session introuvable; lance: ai new <prompt>';
end
local doGet;
local doPost;
local function pollMessage(cfg, sessionId, messageId, persist)
local deadline = nowFunc() + cfg.pollTimeoutSeconds;
while true do
local body, code = doGet(cfg, '/session/' .. sessionId .. '/message/' .. messageId);
if not body then return false, code; end
if code == 404 then return handleMissingSession(persist); end
if code and code ~= 200 then
return false, 'erreur message: HTTP ' .. tostring(code);
end
local decoded, decodeErr = decodeMessage(body);
if not decoded then return false, decodeErr; end
local reply = extractTextParts(decoded.parts);
if reply ~= '' and isMessageComplete(decoded) then
return true, { reply = reply, sessionId = sessionId, messageId = messageId };
end
if nowFunc() >= deadline then
return false, 'delai depasse en attendant la reponse AI';
end
sleepFunc(cfg.pollIntervalSeconds);
end
end
local function buildHeaders(cfg)
local headers = {
['Content-Type'] = 'application/json',
@ -211,7 +297,7 @@ local function createAi(opts)
return body, code;
end
local function doGet(cfg, path)
function doGet(cfg, path)
return callHttp('get', {
url = cfg.url .. path,
headers = buildHeaders(cfg),
@ -219,7 +305,7 @@ local function createAi(opts)
});
end
local function doPost(cfg, path, payload)
function doPost(cfg, path, payload)
return callHttp('post', {
url = cfg.url .. path,
body = textutils.serializeJSON(payload),
@ -286,32 +372,28 @@ local function createAi(opts)
end
end
local body, code = doPost(cfg, '/session/' .. sessionId .. '/message', {
local messageId = options.messageId or createMessageId();
local body, code = doPost(cfg, '/session/' .. sessionId .. '/prompt_async', {
messageID = messageId,
parts = { { type = 'text', text = prompt } },
});
if not body then return false, code; end
if code == 404 then
if persist then
settingsLib.unset('opencc.session_id');
if settingsLib.save then settingsLib.save(); end
end
return false, 'session introuvable; lance: ai new <prompt>';
return handleMissingSession(persist);
end
if code and code ~= 200 then
if code and code ~= 204 and code ~= 200 then
return false, 'erreur message: HTTP ' .. tostring(code);
end
local decoded = textutils.unserializeJSON(body);
if type(decoded) ~= 'table' or type(decoded.parts) ~= 'table' then
return false, 'reponse message invalide';
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
local reply = extractTextParts(decoded.parts);
if reply == '' then
return false, 'reponse vide';
end
return true, { reply = reply, sessionId = sessionId };
return pollMessage(cfg, sessionId, messageId, persist);
end
function api.createLuaExecutor(options)

View File

@ -100,6 +100,22 @@ Parts can include non-text types (`tool-call`, `step-start`, etc.) — collect a
---
### `GET /session/:id/message/:messageID`
Get a message by ID. `ai` uses this to poll async prompts until the assistant message has text parts and completion metadata.
**Response** `200`:
```json
{
"info": { "id": "msg_xyz", "sessionID": "ses_abc123", "role": "assistant", "time": { "completed": 1234567890 } },
"parts": [
{ "type": "text", "text": "the reply" }
]
}
```
---
### `DELETE /session/:id`
Delete a session.
@ -114,7 +130,9 @@ Abort a running generation.
### `POST /session/:id/prompt_async`
Fire-and-forget variant. Returns `204` immediately; result arrives over the SSE stream.
Fire-and-forget variant. Returns `204` immediately. Include `messageID` in the request body to make the assistant response addressable by `GET /session/:id/message/:messageID`.
`ai` uses this endpoint by default to avoid `504` failures from the blocking `/message` endpoint when the LLM takes longer than one HTTP request timeout.
---

View File

@ -124,4 +124,5 @@ Set settings inside the harness before running, or inject them via the test API.
| `missing prompt` | No prompt was passed | Run `ai <prompt>` or `ai ping` |
| `session introuvable; lance: ai new <prompt>` | Session was deleted or server restarted | Run `ai new <prompt>` |
| `erreur message: HTTP 504` | AI took too long | Retry; consider a faster model |
| `delai depasse en attendant la reponse AI` | Async polling timed out | Increase `opencc.poll_timeout_seconds` or check opencode logs |
| `reponse vide` | Reply had no text parts | Check opencode logs |

View File

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

View File

@ -5,8 +5,8 @@
"trapos-boot": "0.2.2",
"trapos-net": "0.2.1",
"trapos-ui": "0.2.2",
"trapos-ai": "0.5.1",
"trapos-ai": "0.5.2",
"trapos-sandbox": "0.1.0",
"trapos": "0.6.2"
"trapos": "0.6.3"
}
}

View File

@ -1,6 +1,6 @@
{
"name": "trapos-ai",
"version": "0.5.1",
"version": "0.5.2",
"description": "TrapOS AI client for opencode serve",
"dependencies": ["trapos-core"],
"files": [

View File

@ -1,6 +1,6 @@
{
"name": "trapos",
"version": "0.6.2",
"version": "0.6.3",
"description": "TrapOS full install meta-package",
"dependencies": ["trapos-boot", "trapos-net", "trapos-ui", "trapos-test", "trapos-ai"],
"files": [],

View File

@ -24,6 +24,9 @@ local function printUsage()
print(' opencc.username (default: opencode)');
print(' opencc.password (Basic Auth password)');
print(' opencc.session_id (auto-managed)');
print(' opencc.timeout_seconds (per HTTP call, max 60)');
print(' opencc.poll_timeout_seconds (default: 300)');
print(' opencc.poll_interval_seconds (default: 2)');
end
local function joinArgs(start)

View File

@ -72,7 +72,18 @@ end
local function messageResp(reply)
return response(200, textutils.serializeJSON({
info = {},
info = { time = { completed = 1 } },
parts = { { type = 'text', text = reply } },
}));
end
local function asyncResp()
return response(204, '');
end
local function pendingMessageResp(reply)
return response(200, textutils.serializeJSON({
info = { time = {} },
parts = { { type = 'text', text = reply } },
}));
end
@ -187,7 +198,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/message', 1, true) ~= nil);
testlib.assertTrue(string.find(httpStub.postCalls[2].url, '/session/ses_new/prompt_async', 1, true) ~= nil);
end);
testlib.test('ask creates cc-ai titled sessions', function()
@ -233,7 +244,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/message', 1, true) ~= nil);
testlib.assertTrue(string.find(httpStub.postCalls[1].url, '/session/ses_existing/prompt_async', 1, true) ~= nil);
end);
testlib.test('ask sends exact prompt text', function()
@ -255,6 +266,55 @@ testlib.test('ask sends exact prompt text', function()
testlib.assertEquals(body.parts[1].text, 'my prompt');
end);
testlib.test('ask polls async message until completion', function()
local httpStub = fakeHttp(
{ sessionResp('ses_1'), asyncResp() },
{ pendingMessageResp('partial'), messageResp('reply') }
);
local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' });
local sleeps = {};
local ai = createAi({
http = httpStub,
settings = settingsStub,
now = function() return 10; end,
sleep = function(n) sleeps[#sleeps + 1] = n; end,
});
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/msg_1', 1, true) ~= nil);
testlib.assertEquals(sleeps[1], 3);
end);
testlib.test('ask polling times out', function()
local httpStub = fakeHttp(
{ sessionResp('ses_1'), asyncResp() },
{ pendingMessageResp('partial'), pendingMessageResp('partial') }
);
local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' });
local now = 0;
local ai = createAi({
http = httpStub,
settings = settingsStub,
now = function() return now; end,
sleep = function(n) now = now + n; end,
});
local ok, err = ai.ask('hello', {
messageId = 'msg_1',
pollTimeoutSeconds = 1,
pollIntervalSeconds = 1,
});
testlib.assertTrue(not ok);
testlib.assertTrue(string.find(err, 'delai depasse', 1, true) ~= nil);
testlib.assertEquals(#httpStub.getCalls, 2);
end);
testlib.test('ask rejects missing prompt without HTTP calls', function()
local httpStub = fakeHttp({}, {});
local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' });