fix(ai): handle opencode async replies
This commit is contained in:
parent
1c07435099
commit
ace14111e0
129
NEXT_PLAN.md
Normal file
129
NEXT_PLAN.md
Normal file
@ -0,0 +1,129 @@
|
||||
# Diagnose & fix opencode async polling (round 2)
|
||||
|
||||
## Context
|
||||
|
||||
The first round added the `model` field to `/prompt_async` (commit `1c07435`). On the dev server (`http://127.0.0.1:4242`, password `jenesuispasunhacker`) the user now sees two symptoms:
|
||||
|
||||
1. **First `ai ping` on a fresh session "crashes" the computer.** The session is created, the user message is posted, opencode visibly generates a pong reply — but `ai` returns an error path and `startup/servers.lua` runs `parallel.waitForAny(shell, eventloop)` followed by `os.shutdown()`. Any path where either coroutine returns shuts the machine down. So the visible "crash" is really: *some* exit in our coroutine + harness-style shutdown logic in production.
|
||||
2. **Subsequent `ai` calls (including `ai sessions`) block.** The user reports the second `ai ping` is "blocked indefinitely" and even `ai sessions` doesn't return — which is odd because `ai.listSessions` does not poll, so this might either be a misreport or evidence of a wider hang.
|
||||
|
||||
The most load-bearing hypothesis is that `findAssistantMessage` in `apis/libai.lua` (introduced by `c61254d`) never matches: it assumes our submitted `messageID` is the **user** message id and looks for the *next* assistant message after it. The opencode docs we wrote in the same commit say the submitted id is the **assistant** message id (`docs/opencode_api.md` line 105: "Get a message by ID. Opencode validates caller-provided message IDs; use IDs starting with `msg`."). If the docs are right, our user message has a server-generated id, our submitted id appears on the assistant message itself, `seenSubmitted` stays `false`, and we poll until `opencc.poll_timeout_seconds` expires.
|
||||
|
||||
This explains both observations:
|
||||
- "First ping crashes" → poll timeout returns `false, 'delai depasse en attendant la reponse AI'`. `programs/ai.lua` prints the error and returns. Then the shell prompt gets shown again — *unless* something else in the program causes a Lua error in the call chain that makes either coroutine exit. We need a probe to confirm.
|
||||
- "Second ping blocks" → opencode keeps the previous assistant generation in-flight (we never acknowledged it) and queues new prompts behind it, OR the next poll loop is the same one, just on a session where opencode now refuses to generate a new turn.
|
||||
|
||||
The user also asked for two ergonomics:
|
||||
- A `--verbose` flag on `programs/ai.lua` so headless probes can see polling progress.
|
||||
- A way to disable `os.shutdown()` outside the harness (out of scope for the bug fix, captured as a follow-up).
|
||||
|
||||
## Plan
|
||||
|
||||
### Phase A — confirm the hypothesis with probes (no code changes)
|
||||
|
||||
Run these against `http://127.0.0.1:4242` with Basic auth `opencode:jenesuispasunhacker`.
|
||||
|
||||
1. Create a session and capture its id:
|
||||
```
|
||||
curl -s -u opencode:jenesuispasunhacker -X POST \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"title":"probe"}' \
|
||||
http://127.0.0.1:4242/session
|
||||
```
|
||||
2. POST a known messageID via prompt_async:
|
||||
```
|
||||
curl -s -u opencode:jenesuispasunhacker -X POST \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"messageID":"msg_probe_1","parts":[{"type":"text","text":"reply with exactly: pong"}],"model":{"providerID":"anthropic","modelID":"claude-opus-4-7"}}' \
|
||||
http://127.0.0.1:4242/session/<SID>/prompt_async
|
||||
```
|
||||
3. Poll the list and inspect ids/roles:
|
||||
```
|
||||
curl -s -u opencode:jenesuispasunhacker \
|
||||
http://127.0.0.1:4242/session/<SID>/message | jq '.[] | {id: .info.id, role: .info.role, finish: .info.finish, completed: .info.time.completed}'
|
||||
```
|
||||
|
||||
**Expected outcomes**:
|
||||
- If `msg_probe_1` appears with `role == "assistant"` → the doc-stated semantics are correct, `findAssistantMessage` is wrong, and we should poll for the message *whose own id matches* our submitted id.
|
||||
- If `msg_probe_1` appears with `role == "user"` → c61254d's reading is correct, the bug is elsewhere (model dispatch, message decoding, etc.).
|
||||
- If `msg_probe_1` doesn't appear at all → opencode is silently dropping it; investigate model or auth.
|
||||
|
||||
Record which case is real. The fix branches on this.
|
||||
|
||||
### Phase B — code changes (drive both cases)
|
||||
|
||||
Regardless of which probe outcome wins, `findAssistantMessage` is brittle (only handles one of two id-placement conventions and fails silently). Replace it with a more defensive lookup that handles both, in `apis/libai.lua` around lines 268–280:
|
||||
|
||||
```lua
|
||||
local function findAssistantMessage(messages, submittedMessageId)
|
||||
-- Case 1 (docs): our id is the assistant message id.
|
||||
for _, m in ipairs(messages) do
|
||||
if type(m) == 'table' and type(m.info) == 'table'
|
||||
and m.info.id == submittedMessageId and m.info.role == 'assistant' then
|
||||
return m;
|
||||
end
|
||||
end
|
||||
-- Case 2 (c61254d empirical): our id is the user message id; assistant follows.
|
||||
local seen = false;
|
||||
for _, m in ipairs(messages) do
|
||||
if type(m) == 'table' and type(m.info) == 'table' then
|
||||
if m.info.id == submittedMessageId then
|
||||
seen = true;
|
||||
elseif seen and m.info.role == 'assistant' then
|
||||
return m;
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil;
|
||||
end
|
||||
```
|
||||
|
||||
This keeps the existing `pollMessage` flow and `isMessageComplete` check unchanged; it just stops missing the message when opencode's id-placement matches the docs.
|
||||
|
||||
### Phase C — `--verbose` for `programs/ai.lua`
|
||||
|
||||
Add a `--verbose` (and `-v`) flag, parsed before `command` is taken. When set:
|
||||
- Pass a `log` callback into `ai.ask` (libai already accepts `log` in `luaExec`; extend `api.ask` to call `options.log(message)` from `pollMessage` at each poll iteration with: `'poll attempt #N: msgs=K, found=' .. (decoded and decoded.info.id or 'nil') .. ', complete=' .. tostring(isComplete)`).
|
||||
- `programs/ai.lua`'s `askAndPrint` / `printSessions` / ping handler all print these via a `[ai]` prefix when `--verbose` is on.
|
||||
|
||||
This makes the next round of headless probing useful without re-instrumenting the code.
|
||||
|
||||
### Phase D — harness verification
|
||||
|
||||
With the dev server already running, drive the probes via the harness so the diagnostic loop is reproducible:
|
||||
|
||||
```
|
||||
just trapos-exec '
|
||||
settings.set("opencc.server_url","http://127.0.0.1:4242");
|
||||
settings.set("opencc.password","jenesuispasunhacker");
|
||||
settings.set("opencc.provider_id","anthropic");
|
||||
settings.set("opencc.model_id","claude-opus-4-7");
|
||||
settings.unset("opencc.session_id");
|
||||
shell.run("/programs/ai.lua","--verbose","ping");
|
||||
'
|
||||
```
|
||||
|
||||
Expected: `pong` printed; no timeout. Then re-run without `--verbose` to confirm the regression no longer reproduces.
|
||||
|
||||
Also run `just check` and `just test`. Update the existing `tests/ai.lua` `'ask polls async message until completion'` test if the message-list shape changes to include the assistant-id-match case. Add a new test: `'ask finds reply when submitted id matches assistant message itself'` — message list contains a user message with a server id and an assistant message with `info.id == submitted` and `time.completed`.
|
||||
|
||||
### Files
|
||||
|
||||
- `apis/libai.lua` — rewrite `findAssistantMessage`; thread an optional `log` callback through `api.ask` → `pollMessage`.
|
||||
- `programs/ai.lua` — add `--verbose`/`-v` flag, prefixing log lines with `[ai]`.
|
||||
- `tests/ai.lua` — new test for assistant-id-match; adjust any test that asserts the exact poll output if needed.
|
||||
- Version bumps per [ADR-0011](docs/adrs/adr-0011-repo-conventions.md): `packages/trapos-ai/ccpm.json` patch bump, `packages/trapos/ccpm.json` patch bump, mirror in `packages/index.json`, `manifest.json`.
|
||||
|
||||
## Verification
|
||||
|
||||
1. Probe outputs from Phase A captured (and pasted into the PR or commit message).
|
||||
2. `just trapos-exec` invocation from Phase D returns `pong` on a fresh session id.
|
||||
3. Repeat the same invocation — second ping also returns `pong` (i.e., second-call hang is gone).
|
||||
4. `ai sessions` (added to the same probe script) returns the session list within a couple of seconds.
|
||||
5. `just check` clean; `just test` passes; new test green.
|
||||
|
||||
## Out of scope (captured as follow-ups)
|
||||
|
||||
- **Harness vs production shutdown.** `startup/servers.lua` ends with `os.shutdown()` so the headless harness exits cleanly. In production this is undesirable because *any* shell exit shuts the computer down. Follow-up: gate the shutdown on a setting (`trapos.harness_mode`) or an environment signal so production keeps the shell up.
|
||||
- **Auto-detect opencode default model** instead of requiring `opencc.provider_id` / `opencc.model_id` (kept from previous plan).
|
||||
- **Session auto-recovery on poll timeout.** Optional `opencc.reset_session_on_timeout` setting that calls `clearSession()` after a timeout so the next run starts on a fresh session. Likely unnecessary once `findAssistantMessage` is fixed, but worth revisiting if the second-ping hang persists.
|
||||
@ -51,9 +51,12 @@ local function statusCode(response)
|
||||
end
|
||||
|
||||
local function extractTextParts(parts)
|
||||
if type(parts) ~= 'table' then
|
||||
return '';
|
||||
end
|
||||
local texts = {};
|
||||
for _, part in ipairs(parts) do
|
||||
if part.type == 'text' and type(part.text) == 'string' then
|
||||
if type(part) == 'table' and part.type == 'text' and type(part.text) == 'string' then
|
||||
texts[#texts + 1] = part.text;
|
||||
end
|
||||
end
|
||||
@ -239,6 +242,16 @@ local function createAi(opts)
|
||||
return body;
|
||||
end
|
||||
|
||||
local function buildMessageBody(cfg, prompt)
|
||||
local body = {
|
||||
parts = { { type = 'text', text = prompt } },
|
||||
};
|
||||
if cfg.providerID and cfg.modelID then
|
||||
body.model = { providerID = cfg.providerID, modelID = cfg.modelID };
|
||||
end
|
||||
return body;
|
||||
end
|
||||
|
||||
local function createMessageId()
|
||||
local t = math.floor(nowFunc() * 1000);
|
||||
return 'msg_' .. tostring(t) .. '_' .. tostring(math.random(100000, 999999));
|
||||
@ -269,7 +282,9 @@ local function createAi(opts)
|
||||
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
|
||||
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;
|
||||
@ -290,10 +305,11 @@ local function createAi(opts)
|
||||
local doGet;
|
||||
local doPost;
|
||||
|
||||
local function pollMessage(cfg, sessionId, messageId, persist, sessionSettingKey)
|
||||
local function pollMessage(cfg, sessionId, messageId, persist, sessionSettingKey, log)
|
||||
local loop = eventloopFactory();
|
||||
local deadline = nowFunc() + cfg.pollTimeoutSeconds;
|
||||
local resultOk, resultValue;
|
||||
log = log or function() end;
|
||||
|
||||
local function finish(ok, value)
|
||||
resultOk, resultValue = ok, value;
|
||||
@ -301,6 +317,7 @@ local function createAi(opts)
|
||||
end
|
||||
|
||||
local function attempt()
|
||||
log('polling async message ' .. messageId);
|
||||
local body, code = doGet(cfg, '/session/' .. sessionId .. '/message');
|
||||
if not body then return finish(false, code); end
|
||||
if code == 404 then
|
||||
@ -318,6 +335,7 @@ local function createAi(opts)
|
||||
local decoded = findAssistantMessage(messages, messageId);
|
||||
local reply = decoded and extractTextParts(decoded.parts) or '';
|
||||
if decoded and reply ~= '' and isMessageComplete(decoded) then
|
||||
log('async reply completed');
|
||||
return finish(true, { reply = reply, sessionId = sessionId, messageId = messageId });
|
||||
end
|
||||
if nowFunc() >= deadline then
|
||||
@ -373,6 +391,28 @@ local function createAi(opts)
|
||||
});
|
||||
end
|
||||
|
||||
local function askBlocking(cfg, sessionId, prompt, persist, sessionSettingKey, log)
|
||||
log('sending blocking message');
|
||||
local body, code = doPost(cfg, '/session/' .. sessionId .. '/message', buildMessageBody(cfg, prompt));
|
||||
if not body then return false, code; end
|
||||
if code == 404 then
|
||||
return handleMissingSession(persist, sessionSettingKey);
|
||||
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 == '' then return false, 'reponse vide'; end
|
||||
return true, {
|
||||
reply = reply,
|
||||
sessionId = sessionId,
|
||||
messageId = type(decoded.info) == 'table' and decoded.info.id or nil,
|
||||
};
|
||||
end
|
||||
|
||||
function api.clearSession(options)
|
||||
options = options or {};
|
||||
settingsLib.unset(options.sessionSettingKey or DEFAULT_SESSION_SETTING_KEY);
|
||||
@ -402,6 +442,7 @@ local function createAi(opts)
|
||||
|
||||
function api.ask(prompt, options)
|
||||
options = options or {};
|
||||
local log = options.log or function() end;
|
||||
if isBlank(prompt) then
|
||||
return false, 'missing prompt; usage: ai <prompt>';
|
||||
end
|
||||
@ -417,6 +458,7 @@ local function createAi(opts)
|
||||
end
|
||||
|
||||
if not sessionId or sessionId == '' then
|
||||
log('creating session');
|
||||
local body, code = doPost(cfg, '/session', { title = options.sessionTitle or 'cc-ai' });
|
||||
if not body then return false, code; end
|
||||
if code and code ~= 200 then
|
||||
@ -431,9 +473,17 @@ local function createAi(opts)
|
||||
settingsLib.set(sessionSettingKey, sessionId);
|
||||
if settingsLib.save then settingsLib.save(); end
|
||||
end
|
||||
else
|
||||
log('reusing session ' .. sessionId);
|
||||
end
|
||||
|
||||
if not (cfg.providerID and cfg.modelID) then
|
||||
log('provider/model unset; using blocking message endpoint');
|
||||
return askBlocking(cfg, sessionId, prompt, 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, prompt));
|
||||
if not body then return false, code; end
|
||||
@ -452,7 +502,7 @@ local function createAi(opts)
|
||||
return true, { reply = reply, sessionId = sessionId, messageId = messageId };
|
||||
end
|
||||
|
||||
return pollMessage(cfg, sessionId, messageId, persist, sessionSettingKey);
|
||||
return pollMessage(cfg, sessionId, messageId, persist, sessionSettingKey, log);
|
||||
end
|
||||
|
||||
function api.createLuaExecutor(options)
|
||||
|
||||
@ -143,9 +143,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. Always send `providerID` / `modelID`.
|
||||
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`.
|
||||
|
||||
`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.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -90,7 +90,7 @@ Optional — override the Basic Auth username (default `opencode`):
|
||||
set opencc.username myuser
|
||||
```
|
||||
|
||||
Pick the provider and model. `ai` posts to `/session/:id/prompt_async`, which (unlike the blocking `/message` endpoint) does **not** fall back to a server-side default — the assistant message will never be generated if these are unset:
|
||||
Optional but recommended: pick the provider and model. When both are set, `ai` posts to `/session/:id/prompt_async`. Without them, `ai` falls back to blocking `/session/:id/message`, which can use the server default model but is more exposed to HTTP timeouts:
|
||||
|
||||
```sh
|
||||
set opencc.provider_id anthropic
|
||||
@ -131,5 +131,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 — often because opencode received the prompt but never started generation | Make sure `opencc.provider_id` and `opencc.model_id` are set; otherwise increase `opencc.poll_timeout_seconds` or check opencode logs |
|
||||
| `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 |
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "TrapOS",
|
||||
"version": "0.8.1",
|
||||
"version": "0.8.2",
|
||||
"branch": "next",
|
||||
"packages": [
|
||||
"trapos"
|
||||
|
||||
@ -5,8 +5,8 @@
|
||||
"trapos-boot": "0.3.0",
|
||||
"trapos-net": "0.3.0",
|
||||
"trapos-ui": "0.2.2",
|
||||
"trapos-ai": "0.6.1",
|
||||
"trapos-ai": "0.6.2",
|
||||
"trapos-sandbox": "0.1.0",
|
||||
"trapos": "0.8.1"
|
||||
"trapos": "0.8.2"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "trapos-ai",
|
||||
"version": "0.6.1",
|
||||
"version": "0.6.2",
|
||||
"description": "TrapOS AI client for opencode serve",
|
||||
"dependencies": ["trapos-core"],
|
||||
"files": [
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "trapos",
|
||||
"version": "0.8.1",
|
||||
"version": "0.8.2",
|
||||
"description": "TrapOS full install meta-package",
|
||||
"dependencies": ["trapos-boot", "trapos-net", "trapos-ui", "trapos-test", "trapos-ai"],
|
||||
"files": [],
|
||||
|
||||
@ -1,7 +1,18 @@
|
||||
local createAi = require('/apis/libai');
|
||||
local createVersion = require('/apis/libversion');
|
||||
|
||||
local args = table.pack(...);
|
||||
local rawArgs = table.pack(...);
|
||||
local args = { n = 0 };
|
||||
local verbose = false;
|
||||
|
||||
for i = 1, rawArgs.n do
|
||||
if rawArgs[i] == '--verbose' or rawArgs[i] == '-v' then
|
||||
verbose = true;
|
||||
else
|
||||
args.n = args.n + 1;
|
||||
args[args.n] = rawArgs[i];
|
||||
end
|
||||
end
|
||||
|
||||
local function printUsage()
|
||||
print('ai usage:');
|
||||
@ -14,6 +25,7 @@ local function printUsage()
|
||||
print(' ai --lua-exec <prompt>');
|
||||
print(' ai sessions');
|
||||
print(' ai --sessions');
|
||||
print(' ai --verbose <command>');
|
||||
print(' ai --version');
|
||||
print(' ai --help');
|
||||
print();
|
||||
@ -31,6 +43,17 @@ local function printUsage()
|
||||
print(' opencc.poll_interval_seconds (default: 2)');
|
||||
end
|
||||
|
||||
local function printAiLog(message)
|
||||
print('[ai] ' .. tostring(message));
|
||||
end
|
||||
|
||||
local function askOptions()
|
||||
if verbose then
|
||||
return { log = printAiLog };
|
||||
end
|
||||
return nil;
|
||||
end
|
||||
|
||||
local function joinArgs(start)
|
||||
local parts = {};
|
||||
for i = start, args.n do
|
||||
@ -55,7 +78,7 @@ local function printSessions(ai)
|
||||
end
|
||||
|
||||
local function askAndPrint(ai, prompt)
|
||||
local ok, result = ai.ask(prompt);
|
||||
local ok, result = ai.ask(prompt, askOptions());
|
||||
if not ok then
|
||||
print(result);
|
||||
return;
|
||||
@ -125,7 +148,7 @@ if (command == 'sessions' or command == '--sessions') and args.n == 1 then
|
||||
end
|
||||
|
||||
if command == 'ping' and args.n == 1 then
|
||||
local ok, result = ai.ping();
|
||||
local ok, result = ai.ping(askOptions());
|
||||
if not ok then
|
||||
print(result);
|
||||
return;
|
||||
|
||||
101
tests/ai.lua
101
tests/ai.lua
@ -21,6 +21,18 @@ local function fakeSettings(initial)
|
||||
};
|
||||
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,
|
||||
@ -244,7 +256,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()
|
||||
@ -305,7 +317,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()
|
||||
@ -324,7 +336,24 @@ 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 falls back to blocking message when model is unset', 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');
|
||||
|
||||
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 sends exact prompt text', function()
|
||||
@ -444,8 +473,7 @@ testlib.test('ask generates opencode-compatible message ids', function()
|
||||
{ messageResp('reply') },
|
||||
{}
|
||||
);
|
||||
local settingsStub = fakeSettings({
|
||||
['opencc.server_url'] = 'http://host',
|
||||
local settingsStub = fakeAsyncSettings({
|
||||
['opencc.session_id'] = 'ses_1',
|
||||
});
|
||||
local ai = createAi({
|
||||
@ -468,7 +496,7 @@ testlib.test('ask polls async message until completion', function()
|
||||
messageListResp({ userMessage('msg_1', 'hello'), assistantMessage('msg_2', 'reply', true) }),
|
||||
}
|
||||
);
|
||||
local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' });
|
||||
local settingsStub = fakeAsyncSettings();
|
||||
local elFactory, elState = fakeEventloopFactory();
|
||||
local ai = createAi({
|
||||
http = httpStub,
|
||||
@ -489,6 +517,56 @@ testlib.test('ask polls async message until completion', function()
|
||||
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 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 times out', function()
|
||||
local httpStub = fakeHttp(
|
||||
{ sessionResp('ses_1'), asyncResp() },
|
||||
@ -497,7 +575,7 @@ testlib.test('ask polling times out', function()
|
||||
messageListResp({ userMessage('msg_1', 'hello'), assistantMessage('msg_2', 'partial', false) }),
|
||||
}
|
||||
);
|
||||
local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' });
|
||||
local settingsStub = fakeAsyncSettings();
|
||||
local now = 0;
|
||||
local elFactory, elState = fakeEventloopFactory();
|
||||
-- Advance virtual time on every scheduled delay so the deadline is reached.
|
||||
@ -538,7 +616,7 @@ testlib.test('ask polling does not call os.sleep', function()
|
||||
messageListResp({ userMessage('msg_1', 'hello'), assistantMessage('msg_2', 'reply', true) }),
|
||||
}
|
||||
);
|
||||
local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' });
|
||||
local settingsStub = fakeAsyncSettings();
|
||||
local elFactory = fakeEventloopFactory();
|
||||
local originalSleep = _G.sleep;
|
||||
local sleepCalls = 0;
|
||||
@ -564,7 +642,7 @@ testlib.test('pollMessage stops the private loop on success', function()
|
||||
messageListResp({ userMessage('msg_1', 'hi'), assistantMessage('msg_2', 'reply', true) }),
|
||||
}
|
||||
);
|
||||
local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' });
|
||||
local settingsStub = fakeAsyncSettings();
|
||||
local elFactory, elState = fakeEventloopFactory();
|
||||
local ai = createAi({
|
||||
http = httpStub,
|
||||
@ -588,7 +666,7 @@ testlib.test('pollMessage stops cleanly on HTTP error mid-poll', function()
|
||||
httpError(500, '{}'),
|
||||
}
|
||||
);
|
||||
local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' });
|
||||
local settingsStub = fakeAsyncSettings();
|
||||
local elFactory, elState = fakeEventloopFactory();
|
||||
local ai = createAi({
|
||||
http = httpStub,
|
||||
@ -613,8 +691,7 @@ testlib.test('pollMessage stops cleanly on 404 mid-poll', function()
|
||||
response(404, '{}'),
|
||||
}
|
||||
);
|
||||
local settingsStub = fakeSettings({
|
||||
['opencc.server_url'] = 'http://host',
|
||||
local settingsStub = fakeAsyncSettings({
|
||||
['opencc.session_id'] = 'ses_1',
|
||||
});
|
||||
local elFactory, elState = fakeEventloopFactory();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user