Ask several questions on one card

One `ask_user` call can now carry several questions, and they come back in a
single submit. Asking one at a time cost a round trip and an interruption
each, and by the third you had forgotten the first.

Each question becomes an item with its own key; several items share a call
index, because they belong to one call and one tool turn has to answer them
all. Each answer is quoted beside the question it belongs to -- with four on
a card, a bare list would leave the model matching them up by position and
sometimes getting it wrong.

Options are radios rather than submit buttons, so picking one does not send
the form while two other questions are still blank. What you type beats what
you picked: someone who writes in the box after clicking an option meant the
writing.

`_questions_in` also reads the shapes a small model actually sends -- a bare
`question` string, a list of plain strings, one object where a list belonged.
Getting that wrong costs a whole round trip and shows a card saying nothing.

Two test fixes, both mine. `test_posting_a_message_stores_both_turns` raced
the background generation it started: against a connection that refuses
instantly the reply sometimes won, writing the error and marking the row
complete before the assertions could read it. And the generation registry is
module-global, so a test that started a reply left an entry -- and a Task
belonging to a closed event loop -- for the rest of the session.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-01 22:01:46 +02:00
parent 8c3fe97939
commit 671e49cae8
10 changed files with 673 additions and 138 deletions
+45 -3
View File
@@ -245,9 +245,51 @@ work: a follower arriving late has no earlier fragments to append to. It also
means Markdown is re-rendered whole, which is required anyway -- a list or code
fence is only correct once its context exists.
**Stopping sets a flag the producer checks.** `generation.request_stop()`;
whatever arrived is kept and the message is marked `stopped`, which is distinct
from `error`. In-process, so single-worker only.
**Two frames must be able to blank themselves, and the rest must not.**
`reasoning`, `tools` and `render` are only sent when they have something in
them, so a frame can never wipe what is on screen. `metrics`, `status` and
`ask` are sent on every version bump *including empty*, because each has to be
able to clear: an approval card that survived being answered would be a button
you could press twice.
**Stopping sets a flag the producer checks -- except while it is paused.**
`generation.request_stop()`; whatever arrived is kept and the message is marked
`stopped`, distinct from `error`. In-process, so single-worker only. `cancel` is
read in exactly one place, between streamed chunks, and a reply waiting on an
approval produces no chunks -- so `request_stop` also resolves
`generation.pending`, and that is the wakeup. Without it the Stop button does
nothing at all while a card is on screen, silently.
**Asking a person is one primitive with three uses.**
`services/interaction.py`: a command waiting to be allowed, a question the model
asked, and "this reply is waiting for you" are all *pause, render a block in the
bubble, wait for a POST, resume*. It pauses a **round, not a call** -- a round's
calls run together under a semaphore, and parking four coroutines on four
separate answers inside that gather would queue them behind each other
invisibly, and hand the reader four cards for commands whose order matters. So
one card covers everything in the round, and `_authorise` returns pre-decided
outcomes keyed by call index, which is what keeps
`zip(calls, outcomes, strict=True)` aligned.
**One `ask_user` call may carry several questions, and they come back at once.**
Each becomes an `Item` with its own `key`; several items can share an `index`
because they belong to one call, and one tool turn answers them all with each
answer quoted beside its question. Asking one at a time would cost a round trip
and an interruption each, and answering the third would mean having forgotten
the first. `_questions_in` reads the singular form and bare strings too: a small
model sends something close to the schema rather than the schema, and getting it
wrong costs a whole round trip to show a card that says nothing.
**A paused reply is deliberately not `done`.** That is what lets a page reload
reattach to it. Its *timeout* is what stops it lingering, not `_prune`, which
only drops finished ones -- so `approval_timeout` is clamped to at least a
minute on read, and `_prune` resolves anything whose deadline is long past as a
backstop.
**Nothing is persisted while paused.** A restart abandons the pending question
along with the reply, and a reload starts the turn afresh -- the model asks
again. That is consistent with "a restart abandons replies in flight", but it
means an approval is not a durable record of consent.
**Unread is polled, not pushed.** A browser on another chat has no connection
to the one that finished. `/api/chats/unread` returns out-of-band dot spans and