fix(ai): poll async assistant messages
This commit is contained in:
parent
b57f3d3973
commit
c61254d702
@ -217,7 +217,7 @@ local function createAi(opts)
|
||||
|
||||
local function createMessageId()
|
||||
local t = math.floor(nowFunc() * 1000);
|
||||
return 'cc_' .. tostring(t) .. '_' .. tostring(math.random(100000, 999999));
|
||||
return 'msg_' .. tostring(t) .. '_' .. tostring(math.random(100000, 999999));
|
||||
end
|
||||
|
||||
local function isMessageComplete(message)
|
||||
@ -230,14 +230,31 @@ local function createAi(opts)
|
||||
return type(message.info.time) == 'table' and message.info.time.completed ~= nil;
|
||||
end
|
||||
|
||||
local function decodeMessage(body)
|
||||
local decoded = textutils.unserializeJSON(body);
|
||||
local function decodeMessage(value)
|
||||
local decoded = value;
|
||||
if type(value) == 'string' then
|
||||
decoded = textutils.unserializeJSON(value);
|
||||
end
|
||||
if type(decoded) ~= 'table' or type(decoded.parts) ~= 'table' then
|
||||
return nil, 'reponse message invalide';
|
||||
end
|
||||
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 then
|
||||
seenSubmitted = true;
|
||||
elseif seenSubmitted and message.info.role == 'assistant' then
|
||||
return message;
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil;
|
||||
end
|
||||
|
||||
local function handleMissingSession(persist)
|
||||
if persist then
|
||||
settingsLib.unset('opencc.session_id');
|
||||
@ -252,17 +269,18 @@ local function createAi(opts)
|
||||
local function pollMessage(cfg, sessionId, messageId, persist)
|
||||
local deadline = nowFunc() + cfg.pollTimeoutSeconds;
|
||||
while true do
|
||||
local body, code = doGet(cfg, '/session/' .. sessionId .. '/message/' .. messageId);
|
||||
local body, code = doGet(cfg, '/session/' .. sessionId .. '/message');
|
||||
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
|
||||
local messages = textutils.unserializeJSON(body);
|
||||
if type(messages) ~= 'table' then return false, 'reponse message invalide'; end
|
||||
local decoded = findAssistantMessage(messages, messageId);
|
||||
local reply = decoded and extractTextParts(decoded.parts) or '';
|
||||
if decoded and reply ~= '' and isMessageComplete(decoded) then
|
||||
return true, { reply = reply, sessionId = sessionId, messageId = messageId };
|
||||
end
|
||||
if nowFunc() >= deadline then
|
||||
|
||||
@ -102,7 +102,7 @@ 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.
|
||||
Get a message by ID. Opencode validates caller-provided message IDs; use IDs starting with `msg`.
|
||||
|
||||
**Response** `200`:
|
||||
```json
|
||||
@ -130,9 +130,9 @@ Abort a running generation.
|
||||
|
||||
### `POST /session/:id/prompt_async`
|
||||
|
||||
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`.
|
||||
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`. Opencode validates caller-provided message IDs; use IDs starting with `msg`.
|
||||
|
||||
`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.
|
||||
`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. The submitted `messageID` identifies the user message; `ai` polls `GET /session/:id/message` and reads the completed assistant message that follows it.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "TrapOS",
|
||||
"version": "0.6.3",
|
||||
"version": "0.6.4",
|
||||
"branch": "next",
|
||||
"packages": [
|
||||
"trapos"
|
||||
|
||||
@ -5,8 +5,8 @@
|
||||
"trapos-boot": "0.2.2",
|
||||
"trapos-net": "0.2.1",
|
||||
"trapos-ui": "0.2.2",
|
||||
"trapos-ai": "0.5.2",
|
||||
"trapos-ai": "0.5.3",
|
||||
"trapos-sandbox": "0.1.0",
|
||||
"trapos": "0.6.3"
|
||||
"trapos": "0.6.4"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "trapos-ai",
|
||||
"version": "0.5.2",
|
||||
"version": "0.5.3",
|
||||
"description": "TrapOS AI client for opencode serve",
|
||||
"dependencies": ["trapos-core"],
|
||||
"files": [
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "trapos",
|
||||
"version": "0.6.3",
|
||||
"version": "0.6.4",
|
||||
"description": "TrapOS full install meta-package",
|
||||
"dependencies": ["trapos-boot", "trapos-net", "trapos-ui", "trapos-test", "trapos-ai"],
|
||||
"files": [],
|
||||
|
||||
52
tests/ai.lua
52
tests/ai.lua
@ -81,11 +81,22 @@ local function asyncResp()
|
||||
return response(204, '');
|
||||
end
|
||||
|
||||
local function pendingMessageResp(reply)
|
||||
return response(200, textutils.serializeJSON({
|
||||
info = { time = {} },
|
||||
local function messageListResp(messages)
|
||||
return response(200, textutils.serializeJSON(messages));
|
||||
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 {} },
|
||||
parts = { { type = 'text', text = reply } },
|
||||
}));
|
||||
};
|
||||
end
|
||||
|
||||
local function postedText(call)
|
||||
@ -266,10 +277,34 @@ testlib.test('ask sends exact prompt text', function()
|
||||
testlib.assertEquals(body.parts[1].text, 'my prompt');
|
||||
end);
|
||||
|
||||
testlib.test('ask generates opencode-compatible message ids', function()
|
||||
local httpStub = fakeHttp(
|
||||
{ messageResp('reply') },
|
||||
{}
|
||||
);
|
||||
local settingsStub = fakeSettings({
|
||||
['opencc.server_url'] = 'http://host',
|
||||
['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 polls async message until completion', function()
|
||||
local httpStub = fakeHttp(
|
||||
{ sessionResp('ses_1'), asyncResp() },
|
||||
{ pendingMessageResp('partial'), messageResp('reply') }
|
||||
{
|
||||
messageListResp({ userMessage('msg_1', 'hello'), assistantMessage('msg_2', 'partial', false) }),
|
||||
messageListResp({ userMessage('msg_1', 'hello'), assistantMessage('msg_2', 'reply', true) }),
|
||||
}
|
||||
);
|
||||
local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' });
|
||||
local sleeps = {};
|
||||
@ -286,14 +321,17 @@ testlib.test('ask polls async message until completion', function()
|
||||
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.assertTrue(string.find(httpStub.getCalls[1].url, '/session/ses_1/message', 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') }
|
||||
{
|
||||
messageListResp({ userMessage('msg_1', 'hello'), assistantMessage('msg_2', 'partial', false) }),
|
||||
messageListResp({ userMessage('msg_1', 'hello'), assistantMessage('msg_2', 'partial', false) }),
|
||||
}
|
||||
);
|
||||
local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' });
|
||||
local now = 0;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user