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 function createMessageId()
|
||||||
local t = math.floor(nowFunc() * 1000);
|
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
|
end
|
||||||
|
|
||||||
local function isMessageComplete(message)
|
local function isMessageComplete(message)
|
||||||
@ -230,14 +230,31 @@ local function createAi(opts)
|
|||||||
return type(message.info.time) == 'table' and message.info.time.completed ~= nil;
|
return type(message.info.time) == 'table' and message.info.time.completed ~= nil;
|
||||||
end
|
end
|
||||||
|
|
||||||
local function decodeMessage(body)
|
local function decodeMessage(value)
|
||||||
local decoded = textutils.unserializeJSON(body);
|
local decoded = value;
|
||||||
|
if type(value) == 'string' then
|
||||||
|
decoded = textutils.unserializeJSON(value);
|
||||||
|
end
|
||||||
if type(decoded) ~= 'table' or type(decoded.parts) ~= 'table' then
|
if type(decoded) ~= 'table' or type(decoded.parts) ~= 'table' then
|
||||||
return nil, 'reponse message invalide';
|
return nil, 'reponse message invalide';
|
||||||
end
|
end
|
||||||
return decoded, nil;
|
return decoded, nil;
|
||||||
end
|
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)
|
local function handleMissingSession(persist)
|
||||||
if persist then
|
if persist then
|
||||||
settingsLib.unset('opencc.session_id');
|
settingsLib.unset('opencc.session_id');
|
||||||
@ -252,17 +269,18 @@ local function createAi(opts)
|
|||||||
local function pollMessage(cfg, sessionId, messageId, persist)
|
local function pollMessage(cfg, sessionId, messageId, persist)
|
||||||
local deadline = nowFunc() + cfg.pollTimeoutSeconds;
|
local deadline = nowFunc() + cfg.pollTimeoutSeconds;
|
||||||
while true do
|
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 not body then return false, code; end
|
||||||
if code == 404 then return handleMissingSession(persist); end
|
if code == 404 then return handleMissingSession(persist); end
|
||||||
if code and code ~= 200 then
|
if code and code ~= 200 then
|
||||||
return false, 'erreur message: HTTP ' .. tostring(code);
|
return false, 'erreur message: HTTP ' .. tostring(code);
|
||||||
end
|
end
|
||||||
|
|
||||||
local decoded, decodeErr = decodeMessage(body);
|
local messages = textutils.unserializeJSON(body);
|
||||||
if not decoded then return false, decodeErr; end
|
if type(messages) ~= 'table' then return false, 'reponse message invalide'; end
|
||||||
local reply = extractTextParts(decoded.parts);
|
local decoded = findAssistantMessage(messages, messageId);
|
||||||
if reply ~= '' and isMessageComplete(decoded) then
|
local reply = decoded and extractTextParts(decoded.parts) or '';
|
||||||
|
if decoded and reply ~= '' and isMessageComplete(decoded) then
|
||||||
return true, { reply = reply, sessionId = sessionId, messageId = messageId };
|
return true, { reply = reply, sessionId = sessionId, messageId = messageId };
|
||||||
end
|
end
|
||||||
if nowFunc() >= deadline then
|
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 /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`:
|
**Response** `200`:
|
||||||
```json
|
```json
|
||||||
@ -130,9 +130,9 @@ Abort a running generation.
|
|||||||
|
|
||||||
### `POST /session/:id/prompt_async`
|
### `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",
|
"name": "TrapOS",
|
||||||
"version": "0.6.3",
|
"version": "0.6.4",
|
||||||
"branch": "next",
|
"branch": "next",
|
||||||
"packages": [
|
"packages": [
|
||||||
"trapos"
|
"trapos"
|
||||||
|
|||||||
@ -5,8 +5,8 @@
|
|||||||
"trapos-boot": "0.2.2",
|
"trapos-boot": "0.2.2",
|
||||||
"trapos-net": "0.2.1",
|
"trapos-net": "0.2.1",
|
||||||
"trapos-ui": "0.2.2",
|
"trapos-ui": "0.2.2",
|
||||||
"trapos-ai": "0.5.2",
|
"trapos-ai": "0.5.3",
|
||||||
"trapos-sandbox": "0.1.0",
|
"trapos-sandbox": "0.1.0",
|
||||||
"trapos": "0.6.3"
|
"trapos": "0.6.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "trapos-ai",
|
"name": "trapos-ai",
|
||||||
"version": "0.5.2",
|
"version": "0.5.3",
|
||||||
"description": "TrapOS AI client for opencode serve",
|
"description": "TrapOS AI client for opencode serve",
|
||||||
"dependencies": ["trapos-core"],
|
"dependencies": ["trapos-core"],
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "trapos",
|
"name": "trapos",
|
||||||
"version": "0.6.3",
|
"version": "0.6.4",
|
||||||
"description": "TrapOS full install meta-package",
|
"description": "TrapOS full install meta-package",
|
||||||
"dependencies": ["trapos-boot", "trapos-net", "trapos-ui", "trapos-test", "trapos-ai"],
|
"dependencies": ["trapos-boot", "trapos-net", "trapos-ui", "trapos-test", "trapos-ai"],
|
||||||
"files": [],
|
"files": [],
|
||||||
|
|||||||
52
tests/ai.lua
52
tests/ai.lua
@ -81,11 +81,22 @@ local function asyncResp()
|
|||||||
return response(204, '');
|
return response(204, '');
|
||||||
end
|
end
|
||||||
|
|
||||||
local function pendingMessageResp(reply)
|
local function messageListResp(messages)
|
||||||
return response(200, textutils.serializeJSON({
|
return response(200, textutils.serializeJSON(messages));
|
||||||
info = { time = {} },
|
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 } },
|
parts = { { type = 'text', text = reply } },
|
||||||
}));
|
};
|
||||||
end
|
end
|
||||||
|
|
||||||
local function postedText(call)
|
local function postedText(call)
|
||||||
@ -266,10 +277,34 @@ testlib.test('ask sends exact prompt text', function()
|
|||||||
testlib.assertEquals(body.parts[1].text, 'my prompt');
|
testlib.assertEquals(body.parts[1].text, 'my prompt');
|
||||||
end);
|
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()
|
testlib.test('ask polls async message until completion', function()
|
||||||
local httpStub = fakeHttp(
|
local httpStub = fakeHttp(
|
||||||
{ sessionResp('ses_1'), asyncResp() },
|
{ 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 settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' });
|
||||||
local sleeps = {};
|
local sleeps = {};
|
||||||
@ -286,14 +321,17 @@ testlib.test('ask polls async message until completion', function()
|
|||||||
testlib.assertEquals(result.reply, 'reply');
|
testlib.assertEquals(result.reply, 'reply');
|
||||||
testlib.assertEquals(result.messageId, 'msg_1');
|
testlib.assertEquals(result.messageId, 'msg_1');
|
||||||
testlib.assertEquals(#httpStub.getCalls, 2);
|
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);
|
testlib.assertEquals(sleeps[1], 3);
|
||||||
end);
|
end);
|
||||||
|
|
||||||
testlib.test('ask polling times out', function()
|
testlib.test('ask polling times out', function()
|
||||||
local httpStub = fakeHttp(
|
local httpStub = fakeHttp(
|
||||||
{ sessionResp('ses_1'), asyncResp() },
|
{ 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 settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' });
|
||||||
local now = 0;
|
local now = 0;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user