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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user