fix(ai): use async prompts by default
This commit is contained in:
parent
3b647090fa
commit
3cea167261
141
apis/libai.lua
141
apis/libai.lua
@ -10,65 +10,12 @@ local DEFAULT_LUA_EXEC_TIMEOUT_SECONDS = 5;
|
|||||||
local DEFAULT_SESSION_SETTING_KEY = 'opencc.session_id';
|
local DEFAULT_SESSION_SETTING_KEY = 'opencc.session_id';
|
||||||
local DEFAULT_AGENT_SETTING_KEY = 'opencc.agent';
|
local DEFAULT_AGENT_SETTING_KEY = 'opencc.agent';
|
||||||
|
|
||||||
local B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
local createHttp = require('/apis/libhttp');
|
||||||
|
|
||||||
local function base64encode(s)
|
|
||||||
local pad = (3 - #s % 3) % 3;
|
|
||||||
s = s .. string.rep('\0', pad);
|
|
||||||
local r = {};
|
|
||||||
for i = 1, #s, 3 do
|
|
||||||
local a, b, c = s:byte(i), s:byte(i + 1), s:byte(i + 2);
|
|
||||||
local n = a * 65536 + b * 256 + c;
|
|
||||||
r[#r + 1] = B64:sub(math.floor(n / 262144) % 64 + 1, math.floor(n / 262144) % 64 + 1)
|
|
||||||
.. B64:sub(math.floor(n / 4096) % 64 + 1, math.floor(n / 4096) % 64 + 1)
|
|
||||||
.. B64:sub(math.floor(n / 64) % 64 + 1, math.floor(n / 64) % 64 + 1)
|
|
||||||
.. B64:sub(n % 64 + 1, n % 64 + 1);
|
|
||||||
end
|
|
||||||
local result = table.concat(r);
|
|
||||||
if pad > 0 then
|
|
||||||
result = result:sub(1, #result - pad) .. string.rep('=', pad);
|
|
||||||
end
|
|
||||||
return result;
|
|
||||||
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 isBlank(s)
|
local function isBlank(s)
|
||||||
return type(s) ~= 'string' or string.match(s, '^%s*$') ~= nil;
|
return type(s) ~= 'string' or string.match(s, '^%s*$') ~= nil;
|
||||||
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 readAllAndClose(response)
|
|
||||||
local body = response.readAll();
|
|
||||||
response.close();
|
|
||||||
return body;
|
|
||||||
end
|
|
||||||
|
|
||||||
local function statusCode(response)
|
|
||||||
if response.getResponseCode then
|
|
||||||
return response.getResponseCode();
|
|
||||||
end
|
|
||||||
return nil;
|
|
||||||
end
|
|
||||||
|
|
||||||
local function extractTextParts(parts)
|
local function extractTextParts(parts)
|
||||||
if type(parts) ~= 'table' then
|
if type(parts) ~= 'table' then
|
||||||
return '';
|
return '';
|
||||||
@ -210,6 +157,7 @@ local function createAi(opts)
|
|||||||
local eventloopFactory = opts.eventloop or require('/apis/eventloop');
|
local eventloopFactory = opts.eventloop or require('/apis/eventloop');
|
||||||
local nowFunc = opts.now or nowSeconds;
|
local nowFunc = opts.now or nowSeconds;
|
||||||
local osLib = opts.os or os;
|
local osLib = opts.os or os;
|
||||||
|
local httpClient = opts.httpClient or createHttp({ http = httpLib });
|
||||||
|
|
||||||
local api = {};
|
local api = {};
|
||||||
|
|
||||||
@ -278,7 +226,7 @@ local function createAi(opts)
|
|||||||
local directory = options.directory or settingsLib.get('opencc.directory');
|
local directory = options.directory or settingsLib.get('opencc.directory');
|
||||||
local providerId, modelId = resolveModel(options);
|
local providerId, modelId = resolveModel(options);
|
||||||
return {
|
return {
|
||||||
url = trimTrailingSlash(url),
|
url = httpClient.trimTrailingSlash(url),
|
||||||
username = username,
|
username = username,
|
||||||
password = password,
|
password = password,
|
||||||
directory = directory,
|
directory = directory,
|
||||||
@ -333,6 +281,29 @@ 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 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 function decodeMessage(value)
|
||||||
local decoded = value;
|
local decoded = value;
|
||||||
if type(value) == 'string' then
|
if type(value) == 'string' then
|
||||||
@ -409,16 +380,30 @@ local function createAi(opts)
|
|||||||
local reply = decoded and extractTextParts(decoded.parts) or '';
|
local reply = decoded and extractTextParts(decoded.parts) or '';
|
||||||
local complete = decoded and isMessageComplete(decoded) or false;
|
local complete = decoded and isMessageComplete(decoded) or false;
|
||||||
local matchedId = decoded and type(decoded.info) == 'table' and decoded.info.id or 'nil';
|
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)
|
log('poll #' .. tostring(attemptCount)
|
||||||
.. ': messages=' .. tostring(#messages)
|
.. ': messages=' .. tostring(#messages)
|
||||||
.. ', found=' .. tostring(matchedId)
|
.. ', found=' .. tostring(matchedId)
|
||||||
.. ', complete=' .. tostring(complete)
|
.. ', complete=' .. tostring(complete)
|
||||||
.. ', text=' .. tostring(reply ~= ''));
|
.. ', text=' .. tostring(reply ~= '')
|
||||||
|
.. ', error=' .. tostring(assistantError ~= nil));
|
||||||
|
if assistantError then
|
||||||
|
return finish(false, 'erreur assistant: ' .. assistantError);
|
||||||
|
end
|
||||||
if decoded and reply ~= '' and complete then
|
if decoded and reply ~= '' and complete then
|
||||||
log('async reply completed');
|
log('async reply completed');
|
||||||
return finish(true, { reply = reply, sessionId = sessionId, messageId = messageId });
|
return finish(true, { reply = reply, sessionId = sessionId, messageId = messageId });
|
||||||
end
|
end
|
||||||
if nowFunc() >= deadline then
|
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');
|
return finish(false, 'delai depasse en attendant la reponse AI');
|
||||||
end
|
end
|
||||||
loop.setTimeout(attempt, cfg.pollIntervalSeconds);
|
loop.setTimeout(attempt, cfg.pollIntervalSeconds);
|
||||||
@ -429,46 +414,12 @@ local function createAi(opts)
|
|||||||
return resultOk, resultValue;
|
return resultOk, resultValue;
|
||||||
end
|
end
|
||||||
|
|
||||||
local function buildHeaders(cfg)
|
|
||||||
local headers = {
|
|
||||||
['Content-Type'] = 'application/json',
|
|
||||||
['Accept'] = 'application/json',
|
|
||||||
};
|
|
||||||
if cfg.password and cfg.password ~= '' then
|
|
||||||
headers['Authorization'] = 'Basic ' .. base64encode(cfg.username .. ':' .. cfg.password);
|
|
||||||
end
|
|
||||||
return headers;
|
|
||||||
end
|
|
||||||
|
|
||||||
local function callHttp(method, request)
|
|
||||||
local ok, response, httpErr, errorResponse = pcall(httpLib[method], request);
|
|
||||||
if not ok then
|
|
||||||
return nil, 'http ' .. method .. ' threw: ' .. tostring(response);
|
|
||||||
end
|
|
||||||
response = response or errorResponse;
|
|
||||||
if not response then
|
|
||||||
return nil, 'serveur injoignable: ' .. tostring(httpErr or 'unknown error');
|
|
||||||
end
|
|
||||||
local code = statusCode(response);
|
|
||||||
local body = readAllAndClose(response);
|
|
||||||
return body, code;
|
|
||||||
end
|
|
||||||
|
|
||||||
function doGet(cfg, path)
|
function doGet(cfg, path)
|
||||||
return callHttp('get', {
|
return httpClient.getJson(cfg, path);
|
||||||
url = cfg.url .. path,
|
|
||||||
headers = buildHeaders(cfg),
|
|
||||||
timeout = cfg.timeoutSeconds,
|
|
||||||
});
|
|
||||||
end
|
end
|
||||||
|
|
||||||
function doPost(cfg, path, payload)
|
function doPost(cfg, path, payload)
|
||||||
return callHttp('post', {
|
return httpClient.postJson(cfg, path, payload);
|
||||||
url = cfg.url .. path,
|
|
||||||
body = textutils.serializeJSON(payload),
|
|
||||||
headers = buildHeaders(cfg),
|
|
||||||
timeout = cfg.timeoutSeconds,
|
|
||||||
});
|
|
||||||
end
|
end
|
||||||
|
|
||||||
local function askBlocking(cfg, sessionId, prompt, persist, sessionSettingKey, log)
|
local function askBlocking(cfg, sessionId, prompt, persist, sessionSettingKey, log)
|
||||||
@ -494,7 +445,7 @@ local function createAi(opts)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function listSessionsWithDirectory(cfg, directory)
|
local function listSessionsWithDirectory(cfg, directory)
|
||||||
return doGet(cfg, '/session' .. queryString({ { 'directory', directory } }));
|
return doGet(cfg, '/session' .. httpClient.queryString({ { 'directory', directory } }));
|
||||||
end
|
end
|
||||||
|
|
||||||
local function decodeSessionList(body, log)
|
local function decodeSessionList(body, log)
|
||||||
@ -609,8 +560,8 @@ local function createAi(opts)
|
|||||||
promptWithContext = buildPromptWithCallerContext(prompt, osLib);
|
promptWithContext = buildPromptWithCallerContext(prompt, osLib);
|
||||||
end
|
end
|
||||||
|
|
||||||
if not (cfg.providerID and cfg.modelID) then
|
if options.blocking == true then
|
||||||
log('provider/model unset; using blocking message endpoint');
|
log('using blocking message endpoint');
|
||||||
return askBlocking(cfg, sessionId, promptWithContext, persist, sessionSettingKey, log);
|
return askBlocking(cfg, sessionId, promptWithContext, persist, sessionSettingKey, log);
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
122
apis/libhttp.lua
Normal file
122
apis/libhttp.lua
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
local B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
||||||
|
|
||||||
|
local function base64encode(s)
|
||||||
|
local pad = (3 - #s % 3) % 3;
|
||||||
|
s = s .. string.rep('\0', pad);
|
||||||
|
local r = {};
|
||||||
|
for i = 1, #s, 3 do
|
||||||
|
local a, b, c = s:byte(i), s:byte(i + 1), s:byte(i + 2);
|
||||||
|
local n = a * 65536 + b * 256 + c;
|
||||||
|
r[#r + 1] = B64:sub(math.floor(n / 262144) % 64 + 1, math.floor(n / 262144) % 64 + 1)
|
||||||
|
.. B64:sub(math.floor(n / 4096) % 64 + 1, math.floor(n / 4096) % 64 + 1)
|
||||||
|
.. B64:sub(math.floor(n / 64) % 64 + 1, math.floor(n / 64) % 64 + 1)
|
||||||
|
.. B64:sub(n % 64 + 1, n % 64 + 1);
|
||||||
|
end
|
||||||
|
local result = table.concat(r);
|
||||||
|
if pad > 0 then
|
||||||
|
result = result:sub(1, #result - pad) .. string.rep('=', pad);
|
||||||
|
end
|
||||||
|
return result;
|
||||||
|
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 isBlank(s)
|
||||||
|
return type(s) ~= 'string' or string.match(s, '^%s*$') ~= nil;
|
||||||
|
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 readAllAndClose(response)
|
||||||
|
local body = response.readAll();
|
||||||
|
response.close();
|
||||||
|
return body;
|
||||||
|
end
|
||||||
|
|
||||||
|
local function statusCode(response)
|
||||||
|
if response.getResponseCode then
|
||||||
|
return response.getResponseCode();
|
||||||
|
end
|
||||||
|
return nil;
|
||||||
|
end
|
||||||
|
|
||||||
|
local function createHttp(opts)
|
||||||
|
opts = opts or {};
|
||||||
|
local httpLib = opts.http or http;
|
||||||
|
local textutilsLib = opts.textutils or textutils;
|
||||||
|
|
||||||
|
local api = {
|
||||||
|
base64encode = base64encode,
|
||||||
|
trimTrailingSlash = trimTrailingSlash,
|
||||||
|
urlEncode = urlEncode,
|
||||||
|
queryString = queryString,
|
||||||
|
};
|
||||||
|
|
||||||
|
function api.basicAuth(username, password)
|
||||||
|
return 'Basic ' .. base64encode(tostring(username or '') .. ':' .. tostring(password or ''));
|
||||||
|
end
|
||||||
|
|
||||||
|
function api.jsonHeaders(options)
|
||||||
|
options = options or {};
|
||||||
|
local headers = {
|
||||||
|
['Content-Type'] = 'application/json',
|
||||||
|
['Accept'] = 'application/json',
|
||||||
|
};
|
||||||
|
if options.password and options.password ~= '' then
|
||||||
|
headers['Authorization'] = api.basicAuth(options.username, options.password);
|
||||||
|
end
|
||||||
|
return headers;
|
||||||
|
end
|
||||||
|
|
||||||
|
function api.call(method, request)
|
||||||
|
local ok, response, httpErr, errorResponse = pcall(httpLib[method], request);
|
||||||
|
if not ok then
|
||||||
|
return nil, 'http ' .. method .. ' threw: ' .. tostring(response);
|
||||||
|
end
|
||||||
|
response = response or errorResponse;
|
||||||
|
if not response then
|
||||||
|
return nil, 'serveur injoignable: ' .. tostring(httpErr or 'unknown error');
|
||||||
|
end
|
||||||
|
local code = statusCode(response);
|
||||||
|
local body = readAllAndClose(response);
|
||||||
|
return body, code;
|
||||||
|
end
|
||||||
|
|
||||||
|
function api.getJson(cfg, path)
|
||||||
|
return api.call('get', {
|
||||||
|
url = cfg.url .. path,
|
||||||
|
headers = api.jsonHeaders(cfg),
|
||||||
|
timeout = cfg.timeoutSeconds,
|
||||||
|
});
|
||||||
|
end
|
||||||
|
|
||||||
|
function api.postJson(cfg, path, payload)
|
||||||
|
return api.call('post', {
|
||||||
|
url = cfg.url .. path,
|
||||||
|
body = textutilsLib.serializeJSON(payload),
|
||||||
|
headers = api.jsonHeaders(cfg),
|
||||||
|
timeout = cfg.timeoutSeconds,
|
||||||
|
});
|
||||||
|
end
|
||||||
|
|
||||||
|
return api;
|
||||||
|
end
|
||||||
|
|
||||||
|
return createHttp;
|
||||||
@ -144,7 +144,7 @@ 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 so the submitted message can be matched to the later assistant response. Opencode validates caller-provided message IDs; use IDs starting with `msg`.
|
Fire-and-forget variant. Returns `204` immediately and starts generation in the background. Include `messageID` in the request body so the submitted user message can be matched to the later assistant response. Opencode validates caller-provided message IDs; use IDs starting with `msg`.
|
||||||
|
|
||||||
**Request body:**
|
**Request body:**
|
||||||
```json
|
```json
|
||||||
@ -158,9 +158,9 @@ Fire-and-forget variant. Returns `204` immediately. Include `messageID` in the r
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Unlike `/message`, `model` is **not** optional in practice — omitting it causes the request to be accepted (`204`) without triggering generation, so the assistant message never appears. `ai` only uses this async endpoint when `opencc.provider_id` and `opencc.model_id` are configured; otherwise it falls back to blocking `POST /session/:id/message`.
|
`agent` and `model` are optional. Omit `model` to use the server/session default model, or include it to force a specific provider/model for this prompt.
|
||||||
|
|
||||||
When async mode is available, `ai` uses this endpoint 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 or assistant message depending on the opencode response shape; `ai` polls `GET /session/:id/message` and reads the completed assistant message. If `opencc.agent` or `--agent <name>` is set, `ai` includes it as `agent` in the request body.
|
`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 after it. If `opencc.agent` or `--agent <name>` is set, `ai` includes it as `agent` in the request body. If generation fails in the background, opencode records the failure on the assistant message or session event stream; `ai` surfaces assistant message errors while polling.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -116,7 +116,7 @@ Optional — scope `ai sessions` to a specific opencode project directory. If om
|
|||||||
set opencc.directory /Users/garm/trap/cc-libs
|
set opencc.directory /Users/garm/trap/cc-libs
|
||||||
```
|
```
|
||||||
|
|
||||||
Optional but recommended: pick the provider and model. When both are set, `ai` posts to `/session/:id/prompt_async` and polls for completion. Without them, `ai` falls back to blocking `/session/:id/message`, which can use the server default model but is more exposed to HTTP timeouts:
|
Optional: pick the provider and model for requests from this computer. If omitted, `ai` still posts to `/session/:id/prompt_async`; opencode uses the server/session default model:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
set opencc.provider_id anthropic
|
set opencc.provider_id anthropic
|
||||||
@ -169,6 +169,7 @@ Set settings inside the harness before running, or inject them via the test API.
|
|||||||
| `erreur message: HTTP 401` | Wrong password | Check `opencc.password` matches `OPENCODE_SERVER_PASSWORD` |
|
| `erreur message: HTTP 401` | Wrong password | Check `opencc.password` matches `OPENCODE_SERVER_PASSWORD` |
|
||||||
| `missing prompt` | No prompt was passed | Run `ai <prompt>` or `ai ping` |
|
| `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>` |
|
| `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 |
|
| `erreur message: HTTP 504` | Blocking mode AI call took too long | Retry; prefer the default async mode |
|
||||||
| `delai depasse en attendant la reponse AI` | Async polling timed out | Increase `opencc.poll_timeout_seconds` or check opencode logs |
|
| `erreur assistant: ...` | Opencode accepted the async prompt but generation failed | Check the provider/model/agent and opencode logs |
|
||||||
|
| `delai depasse en attendant la reponse AI` | Async polling timed out | Increase `opencc.poll_timeout_seconds`; use `ai --verbose` and check opencode logs |
|
||||||
| `reponse vide` | Reply had no text parts | Check opencode logs |
|
| `reponse vide` | Reply had no text parts | Check opencode logs |
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "TrapOS",
|
"name": "TrapOS",
|
||||||
"version": "0.8.14",
|
"version": "0.8.15",
|
||||||
"branch": "next",
|
"branch": "next",
|
||||||
"packages": [
|
"packages": [
|
||||||
"trapos"
|
"trapos"
|
||||||
|
|||||||
@ -5,8 +5,8 @@
|
|||||||
"trapos-boot": "0.3.2",
|
"trapos-boot": "0.3.2",
|
||||||
"trapos-net": "0.3.0",
|
"trapos-net": "0.3.0",
|
||||||
"trapos-ui": "0.2.2",
|
"trapos-ui": "0.2.2",
|
||||||
"trapos-ai": "0.6.12",
|
"trapos-ai": "0.6.13",
|
||||||
"trapos-sandbox": "0.2.2",
|
"trapos-sandbox": "0.2.2",
|
||||||
"trapos": "0.8.14"
|
"trapos": "0.8.15"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "trapos-ai",
|
"name": "trapos-ai",
|
||||||
"version": "0.6.12",
|
"version": "0.6.13",
|
||||||
"description": "TrapOS AI client for opencode serve",
|
"description": "TrapOS AI client for opencode serve",
|
||||||
"dependencies": ["trapos-core"],
|
"dependencies": ["trapos-core"],
|
||||||
"files": [
|
"files": [
|
||||||
"apis/libai.lua",
|
"apis/libai.lua",
|
||||||
|
"apis/libhttp.lua",
|
||||||
"apis/libtrapgpt.lua",
|
"apis/libtrapgpt.lua",
|
||||||
"programs/ai.lua",
|
"programs/ai.lua",
|
||||||
"programs/trapgpt.lua"
|
"programs/trapgpt.lua"
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "trapos",
|
"name": "trapos",
|
||||||
"version": "0.8.14",
|
"version": "0.8.15",
|
||||||
"description": "TrapOS full install meta-package",
|
"description": "TrapOS full install meta-package",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"trapos-boot",
|
"trapos-boot",
|
||||||
|
|||||||
67
tests/ai.lua
67
tests/ai.lua
@ -161,6 +161,18 @@ local function assistantMessage(id, reply, completed)
|
|||||||
};
|
};
|
||||||
end
|
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)
|
local function postedText(call)
|
||||||
local body = textutils.unserializeJSON(call.body);
|
local body = textutils.unserializeJSON(call.body);
|
||||||
return body.parts[1].text;
|
return body.parts[1].text;
|
||||||
@ -358,7 +370,7 @@ testlib.test('ask creates session then sends message when no session_id', functi
|
|||||||
testlib.assertEquals(result.sessionId, 'ses_new');
|
testlib.assertEquals(result.sessionId, 'ses_new');
|
||||||
testlib.assertEquals(#httpStub.postCalls, 2);
|
testlib.assertEquals(#httpStub.postCalls, 2);
|
||||||
testlib.assertTrue(string.find(httpStub.postCalls[1].url, '/session', 1, true) ~= nil);
|
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);
|
end);
|
||||||
|
|
||||||
testlib.test('ask creates cc-ai titled sessions', function()
|
testlib.test('ask creates cc-ai titled sessions', function()
|
||||||
@ -419,7 +431,7 @@ testlib.test('ask reuses existing session_id without creating a new session', fu
|
|||||||
|
|
||||||
testlib.assertTrue(ok);
|
testlib.assertTrue(ok);
|
||||||
testlib.assertEquals(#httpStub.postCalls, 1);
|
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);
|
end);
|
||||||
|
|
||||||
testlib.test('ask reuses custom sessionSettingKey without creating a new session', function()
|
testlib.test('ask reuses custom sessionSettingKey without creating a new session', function()
|
||||||
@ -438,10 +450,10 @@ testlib.test('ask reuses custom sessionSettingKey without creating a new session
|
|||||||
|
|
||||||
testlib.assertTrue(ok);
|
testlib.assertTrue(ok);
|
||||||
testlib.assertEquals(#httpStub.postCalls, 1);
|
testlib.assertEquals(#httpStub.postCalls, 1);
|
||||||
testlib.assertTrue(string.find(httpStub.postCalls[1].url, '/session/ses_trapgpt/message', 1, true) ~= nil);
|
testlib.assertTrue(string.find(httpStub.postCalls[1].url, '/session/ses_trapgpt/prompt_async', 1, true) ~= nil);
|
||||||
end);
|
end);
|
||||||
|
|
||||||
testlib.test('ask falls back to blocking message when model is unset', function()
|
testlib.test('ask can use blocking message when explicitly requested', function()
|
||||||
local httpStub = fakeHttp(
|
local httpStub = fakeHttp(
|
||||||
{ sessionResp('ses_blocking'), messageResp('reply') },
|
{ sessionResp('ses_blocking'), messageResp('reply') },
|
||||||
{}
|
{}
|
||||||
@ -449,13 +461,29 @@ testlib.test('ask falls back to blocking message when model is unset', function(
|
|||||||
local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' });
|
local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' });
|
||||||
local ai = createAi({ http = httpStub, settings = settingsStub });
|
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') },
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' });
|
||||||
|
local ai = createAi({ http = httpStub, settings = settingsStub });
|
||||||
|
|
||||||
local ok, result = ai.ask('hello');
|
local ok, result = ai.ask('hello');
|
||||||
|
|
||||||
testlib.assertTrue(ok, tostring(result));
|
testlib.assertTrue(ok, tostring(result));
|
||||||
testlib.assertEquals(result.reply, 'reply');
|
testlib.assertEquals(result.reply, 'reply');
|
||||||
testlib.assertEquals(#httpStub.postCalls, 2);
|
testlib.assertEquals(#httpStub.postCalls, 2);
|
||||||
testlib.assertTrue(string.find(httpStub.postCalls[2].url, '/session/ses_blocking/message', 1, true) ~= nil);
|
testlib.assertTrue(string.find(httpStub.postCalls[2].url, '/session/ses_async/prompt_async', 1, true) ~= nil);
|
||||||
testlib.assertEquals(#httpStub.getCalls, 0);
|
|
||||||
end);
|
end);
|
||||||
|
|
||||||
testlib.test('ask wraps prompt with caller context', function()
|
testlib.test('ask wraps prompt with caller context', function()
|
||||||
@ -834,6 +862,29 @@ testlib.test('ask polling tolerates assistant message without parts', function()
|
|||||||
testlib.assertEquals(#httpStub.getCalls, 2);
|
testlib.assertEquals(#httpStub.getCalls, 2);
|
||||||
end);
|
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()
|
testlib.test('ask polling default timeout allows ten minute replies', function()
|
||||||
local httpStub = fakeHttp(
|
local httpStub = fakeHttp(
|
||||||
{ sessionResp('ses_1'), asyncResp() },
|
{ sessionResp('ses_1'), asyncResp() },
|
||||||
@ -938,7 +989,7 @@ testlib.test('ask polling times out', function()
|
|||||||
|
|
||||||
testlib.assertTrue(not ok);
|
testlib.assertTrue(not ok);
|
||||||
testlib.assertTrue(string.find(err, 'delai depasse', 1, true) ~= nil);
|
testlib.assertTrue(string.find(err, 'delai depasse', 1, true) ~= nil);
|
||||||
testlib.assertEquals(#httpStub.getCalls, 2);
|
testlib.assertEquals(#httpStub.getCalls, 3);
|
||||||
testlib.assertTrue(elState.lastLoop.inspect().stopped);
|
testlib.assertTrue(elState.lastLoop.inspect().stopped);
|
||||||
testlib.assertEquals(#elState.lastLoop.inspect().pending, 0);
|
testlib.assertEquals(#elState.lastLoop.inspect().pending, 0);
|
||||||
end);
|
end);
|
||||||
@ -977,7 +1028,7 @@ testlib.test('ask caps polling timeout at ten minutes', function()
|
|||||||
|
|
||||||
testlib.assertTrue(not ok);
|
testlib.assertTrue(not ok);
|
||||||
testlib.assertTrue(string.find(err, 'delai depasse', 1, true) ~= nil);
|
testlib.assertTrue(string.find(err, 'delai depasse', 1, true) ~= nil);
|
||||||
testlib.assertEquals(#httpStub.getCalls, 3);
|
testlib.assertEquals(#httpStub.getCalls, 4);
|
||||||
testlib.assertEquals(now, 600);
|
testlib.assertEquals(now, 600);
|
||||||
end);
|
end);
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user