Two selects that never wrote anything, and a queue

The approval card in Auto mode and the missing /effort were one bug. Both
selects hung their hx-patch on an empty sibling form reached by form="…",
and htmx binds a trigger to the annotated element: change fires on the
select and bubbles to its ancestors, which a sibling is not. The live rows
read agent_mode=manual and params_json={} while the browser showed Auto and
Effort: high. policy.py was never involved.

The verb moves onto the control; the empty form stays as value scoping,
which is the half of the CLAUDE.md note that was right. conftest gains
control_named so a test asserts the element carrying the name carries the
verb, rather than asserting the markup that was there throughout.

The composer's highlight was a third instance of the same carelessness in
CSS: .tok-mention is written for the transcript and scoped to nothing, so
the mirror painted its token in accent-coloured monospace over the
textarea's own text. Scoped under .msg; the mirror restates transparency
and font rather than inheriting them, and bleeds by box-shadow.

/effort is now offered before the first prompt and _new_chat reads it.
/index re-walks the project directory on demand, file_write drops the
listing it just invalidated, and the index ladder falls through to SFTP on
a host that refuses exec instead of returning nothing.

A second message during a reply is queued rather than starting a second
concurrent generation: a real Message row with queued set, so it survives a
restart and can be withdrawn. _drain hands one on at the end of a reply,
_inject takes one in at a tool-round boundary so an agent can be steered
mid-task. Stop leaves the queue undelivered. The terminal's Auto toggle
becomes off/copy/send, and send posts straight to the chat without touching
the composer.

@ now offers notes, skills, this chat's attachments and a URL to fetch; a
knowledge base attaches as a reference rather than a copy. copy_document
carries provenance, which was the one attach path that dropped it.

Also fixes an unrelated live bug: the round loop compared against the
global MAX_ROUNDS of 3 while sizing itself from the agent budget of 40, so
agent replies stopped after three rounds and reported forty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-02 19:49:34 +02:00
parent 0bee366488
commit 8a3a225fea
31 changed files with 2131 additions and 81 deletions
+103 -1
View File
@@ -20,7 +20,7 @@ lembas info # paths + counts, useful when confused
lembas secret-key # generate LEMBAS_SECRET_KEY
lembas create-admin # create or promote an admin
pytest # 1009 tests, ~58s
pytest # 1051 tests, ~60s
# PLAN.md tracks what is and is not built
ruff check . # lint (line length 100)
python scripts/build_artwork.py # regenerate artwork (SVG + PWA icons;
@@ -392,6 +392,22 @@ than appear as an empty heading. Anything else wanting the listing gets the same
deal: the `@` picker offers no files until one exists, because a keystroke must
never wait on a machine.
**And it only ever goes stale in one direction.** `_warm_index` returns early
whenever anything is cached, so within the 300s TTL a reply never re-walks;
after it lapses, the next reply rebuilds. What that misses is the tree changing
underneath — so `file_write` calls `index.forget_dir` for the directory it just
wrote into (the one place the cache is *known* wrong, and a model reading a
stale listing concludes the file it created does not exist), and `/index`
`POST /api/chats/{id}/index` is the "look again now" for everything else,
notably anything done by hand in the terminal panel. Read-only, so it is outside
`agent/policy.py` for the reason the directory browser is.
**The ladder falls through on failure, not just on absence.** `_from_git` and
`_from_find` raising `ExecError` — an SFTP-only account, a forced command, a
shell of `/bin/false` — used to escape the loop and be caught outside it,
returning an empty listing without ever trying the SFTP rung that exists for
exactly that host. Each rung catches its own now.
**A listing is budgeted, not dumped.** A tree of a thousand files costs the
window on every request forever and buries the four names that mattered.
`index.render` collapses what will not fit to `src/vendor/ (412 files)` and says
@@ -448,6 +464,34 @@ selects in the composer — the agent mode and the effort — belong to empty
`form="…"`. A form cannot nest inside another; the browser silently drops the
inner one, and the control then posts nothing at all.
**`form="…"` scopes the values; it does not route the event.** That is half of
the paragraph above, and taking it for the whole cost both those selects an
entire release in which they wrote nothing. htmx binds a trigger listener to the
annotated element itself unless `from:` says otherwise —
`if(c.from){t=m(l,c.from)}else{t=[l]}` — and `change` fires on the select and
bubbles through its **DOM ancestors**, which a sibling form is not. So the verb
goes **on the control**; the empty form stays, earning its keep as the answer to
htmx's "whose values are these", which is `function Nt(e){return e.form||g(e,"form")}`
`e.form` first, so a form-associated control resolves to the empty form and
the PATCH carries that one field. Without it, `closest("form")` finds the
composer and the request carries `project_dir`, which `update_chat` answers with
a 409. `tests/conftest.py:control_named` exists to pin this: the element
carrying the `name` must be the element carrying the verb. The three failures in
this feature — `hx-post` at a PATCH-only route, a menu built after it was
written to, a trigger bound where the event does not go — were all *silent*, and
all three had passing tests that asserted the markup rather than the property.
**A rule written for one context matches every context.** `.tok-mention` styles
a mention in the transcript: accent colour, monospace, 0.95em. Nothing scoped it
there, so it also hit the composer mirror's spans — and `.composer__mirror`'s
`color: transparent` is *inherited*, which loses to a colour the span declares
itself. The mirror painted its token visibly, in a different font, over the
textarea's own text: doubled, and shifted from that point on because the metrics
differ. Transcript token styles are `.msg .tok-*`; the mirror's restate
`color: transparent` and `font: inherit` rather than relying on inheritance, and
bleed with `box-shadow` rather than padding and a negative margin, because a
shadow cannot move a glyph.
**Unused columns are worse than missing ones.** `Model.params_json` documented
itself as "default sampling params applied to new chats using this model" and
was applied nowhere for its entire existence, which is how a per-model default
@@ -463,6 +507,17 @@ where it came from into `chat.document_context`'s tag, because a model handed
asked to change something. Those two are attribute values in a tag we write, so
`_attr` strips quotes and angle brackets rather than escaping them.
**`@` offers everything a chat can reach, and a knowledge base is the exception
that proves the rule.** Project files, documents, notes, skills, this chat's
earlier attachments and a URL all resolve to *an attachment*, copied — a
transcript must not change because somebody edited a note afterwards, the same
rule as PDF extraction. A **base** is a reference instead: `POST
/api/chats/{id}/bases` puts it on `Chat.knowledge_bases`, which already narrows
`knowledge_search`, and the harness already names the attached bases. Copying a
folder of contracts into the window would cost the context on every request
forever to answer one question. It therefore needs an existing chat, so it is
absent on the new-chat screen — the same reason project files are.
**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
an `HX-Trigger` for the toast; `unread_notified` stops the same arrival being
@@ -526,6 +581,43 @@ the attribute to win. Anything toggled with `hidden` depends on that line.
`ui.js` flips `data-composer-action` plus `type` (`submit``button`) when a
message in the thread is still streaming. Do not add a second button back.
**A second message while a reply streams is queued, not sent.** It used to be
accepted outright: a second assistant placeholder, a second concurrent
`Generation` answering the same chat from a different prefix of it, and
`ui.js`'s first-match `querySelector(".msg[sse-connect]")` pointing Stop at
whichever bubble came first in the document. A queued turn is a real `Message`
with `queued` set — so it survives a restart, is in the transcript the moment it
is typed, and can be withdrawn before it is ever sent. `build_messages` skips
it. It must never carry `sse-connect`; the streaming shell is still the only
thing that starts a generation, so one on a queued bubble is that second
generation again.
**Delivery is two places, and neither is a special case.** `_drain` runs in
`_run`'s `finally:` between `_persist` and `done` — after the row is
authoritative, before the flag `_follow` breaks on, because the `done` frame is
the last thing that reaches a browser and has to carry the next turn's bubbles
out of band. It takes **one** waiting prompt, not all of them: draining the lot
puts two consecutive user turns in the next request. `_inject` takes one *into*
a reply at a tool-round boundary, which is the point of queueing in an agent
chat — steering work already under way — and restamps the assistant
placeholder's `created_at` so the reply still sorts before the prompt it
answered. It refuses on the last round: a prompt delivered into a reply that
then runs out of budget is marked delivered and never sent again.
**Stop leaves the queue alone**, deliberately, and `_drain` refuses on stopped,
errored and superseded. Stopped is the reader's decision; errored would feed the
next prompt into an endpoint that has just failed; superseded is the same test
`_persist` makes, without which regenerating drains the queue as a side effect.
Cancellation sets `stopped`, so a restart never fires off a reply with nobody
watching.
**An interjection is sent verbatim, in the user role.** Everything else this
codebase injects is quoted and attributed because it came out of a file, a page
or a machine; this one genuinely is the person at the keyboard. Wrapping it
would teach a model that a user turn can be a quotation, which is the exact
distinction `execute_plan` and `Capture.as_text` rely on. What the model needs —
that this can happen at all — is the `core.interjection` harness fragment.
**The tool loop is inside one generation.** `services/generation.py:_run()` runs
up to `tools_service.MAX_ROUNDS` request rounds for a single reply: stream,
accumulate tool calls, run them, append the results, ask again. `Generation`
@@ -838,6 +930,16 @@ therefore no markers — at which point Copy and Send fall back to scraping the
screen and say so, and the automatic toggle is **disabled rather than degraded**.
Forty arbitrary lines attached to every message is worse than nothing attached.
**The automatic toggle has three states, and a select to say which.** Off, copy,
send. It was a boolean doing the wrong one of them: it appended into the
composer, on top of whatever was being typed there. `send` posts straight to
`/api/chats/{id}/messages` and never touches the composer — which is what makes
the queue load-bearing, since commands finish while a reply is running. Not
persisted between page loads, deliberately: a switch that forwards everything
you type in a shell to a model is not something to inherit from last week's
session. A cycling icon button was the obvious shape and cannot say which of
three states it is in.
**The nginx vhost must pass upgrades through.** `deploy/nginx-vhost.conf` used
to set `Connection ""`, which is right for SSE and fails every WebSocket
handshake — and a failed handshake tells the browser nothing: no status, no