Files
stack/agents/dewey/work/queue-81/build.md
T
jason.woltjeandClaude Opus 5.5 5afa8b0fe1 dewey: row 81 (#1575) packet, round 1
Console shows the reply to a web send: bus `replies` read (human and
reader, message-not-found on unknown ids, oldest first, cap 50, writes
nothing), /api/bus/replies, the Sent receipt's reply list, talk's timeout
names only mosaic talk. Base ff11aa4b, 12 paths, manifest 12/12 on a fresh
worktree. Gate green 18:29-18:35Z; 17/17 mutants killed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 13:36:30 -05:00

216 lines
12 KiB
Markdown

# Row 81 (#1575): Console shows the reply to a web send, round 1
Owner: Dewey. Reviewer: Darkwing, then Sage's gate. Brief:
`docs/plans/2026-10-11_console-reply-view.md` § "Show replies to a message
without receiving them", blob `dd42071d`. Lead decision 97 made the row;
decision 111 started it. Base `ff11aa4b`. None of the candidate's paths
changed between `ff11aa4b` and `refactor` HEAD (`1d4ba6bb`) when the packet
was built.
`build.patch` is `git diff --cached --binary ff11aa4b --` the twelve paths
below. Apply it with `git apply --index` to a detached worktree of the
base; `candidate-manifest.sha256` then checks 12/12 OK.
## What ships
- **Bus read `replies`** (`packages/bus/src/broker.mjs`). `{id}`, nothing
else (`keys` strict, `identifier` on the id). Returns the business's
messages whose `in_reply_to` is that id, `ORDER BY seq`, `LIMIT REPLIES`
(exported, 50). Human and reader sessions only; a role session gets
`human-required`, the same gate as `decisions` and `credentials`. It is
on the reader allowlist. An id naming no message in the session's
business refuses `message-not-found` with the same query `message.send`
uses for `in_reply_to` (`SELECT id FROM messages WHERE id=? AND
business=?`), and the refusal is recorded like every other. It writes
nothing: no `deliveries` row, no event on success.
- **views** passes it through: `replies(id)` calls `('replies', { id })`.
- **webui server**: `GET /api/bus/replies?id=<message id>`. `replies` joins
`BUS_VERBS`; the id must pass `subjectOk` (the broker's identifier rule)
or the server answers 400 `invalid-request` without calling the bus.
`message-not-found` already maps to 400 (`busStatus`). GET only, like the
other reads.
- **Console page** (`src/public/bus.js`). A Sent receipt is followed by the
replies to that message, read with `replies` on every render of the Inbox
(the ten-second refresh and Read again). Each reply shows its role, time,
class, a trail link and the body, through `txt` like every bus string.
"No reply yet" when the list is empty; "could not be read" when the read
failed, with `mosaic talk` named as the other place they print; a note at
50 that only the first 50 show. The receipt paragraph itself is
unchanged, so `.receipt` text stays what row 76's tests check. A new send
starts an empty list. The note under the box now says replies show under
the receipt and that the Console never marks one delivered or read.
- **CLI**: talk's timeout is `no reply from <role> within <n> s; it may
still come, and the next mosaic talk shows it`.
- **READMEs**: webui "Message a role" says where replies show, when they
refresh, what each state means, and that the Console never receives or
marks a message; the route paragraph lists `/api/bus/replies?id=`. bus
README: the verb table row, `replies` in the reader capability list, a
paragraph in "Human and reader paths", and `read.replies(messageId)` in
the shared-reads snippet.
## Decisions and tradeoffs
- **A new verb, not a `trail` field** (the brief left this to me). `trail`
takes a task ref, decision id or message id and returns `[]` for an
unknown subject; the brief says this read refuses an unknown id instead.
Putting replies in `trail` would mean either changing trail's answer for
unknown message ids or giving one field a different refusal rule from
the rest of the call. Trail also returns linked records as a flat list
of mixed tables; replies are a list of messages with their own cap. A
separate verb keeps trail's shape and every existing trail caller as
they are, and lets the Console read the replies without reading the whole
trail on each refresh.
- **Human and reader only.** The brief says "the same sessions as the other
reads (human and reader)". `inbox`/`tasks`/`agents`/`trail` are also open
to role sessions; `decisions` and `credentials` are not. I followed the
brief's wording and the newer two: a role gets `human-required`. A test
checks a role can't read replies even to a message it sent.
- **The receipt id is not checked against the broker before the read.** The
POST answer's id goes to `replies` as is; the server checks its form and
the bus refuses an unknown one. The page shows that as "could not be
read", not "No reply yet", so a bad id is visible.
- **A late answer is dropped.** If a send finishes while a replies read is
out, that read's rows belong to the earlier message. The composer keeps
the receipt object it read for and assigns the rows only if it is still
the current receipt (`receipt === r`). Tested with a held read.
- **A failed replies read doesn't mark the Inbox stale.** It is caught in
the composer, like the agents read for the role list, so the Inbox and
its count stay fresh and only the receipt says the replies couldn't be
read.
## Files
- `packages/bus/src/broker.mjs`: `REPLIES`, the allowlist entry, the
`replies` case.
- `packages/bus/src/views.mjs`: one line.
- `packages/bus/tests/replies.test.mjs`: new, 7 tests.
- `packages/bus/README.md`: verb table row, reader caps, one paragraph,
snippet line.
- `packages/webui/src/serve.mjs`: `BUS_VERBS`, the id check.
- `packages/webui/src/bus.mjs`: comment only.
- `packages/webui/src/public/bus.js`: `fetchRead`'s query name, the replies
state and renderer, the composer read, the reset on send, the note text.
- `packages/webui/tests/bus.test.mjs`: 1 new test, 3 widened.
- `packages/webui/tests/send-browser.test.mjs`: 1 new test, the note
assertion updated.
- `packages/webui/README.md`: "Message a role", the bus route paragraph.
- `packages/cli/src/cli.mjs`: the talk timeout message.
- `packages/cli/tests/verbs.test.mjs`: 1 new test, the timeout assertion.
## Tests
- `packages/bus/tests/replies.test.mjs` (new, 7):
- *a reply is listed for human and reader sessions, and its delivery rows
are unchanged*: a reply to the send is listed; a message to the human
with no `in_reply_to` and a reply to another message are not. Each row
equals the `messages` row `trail` gives for the reply's id. The
`deliveries` rows and the row counts of six tables are the same before
and after; the reply's own deliveries are only `routed`.
- *the reply still reaches the human message.receive (mosaic talk) after
the Console showed it*: listed by reader and human, then received,
read, and still listed.
- *a message with no reply gives an empty list*.
- *replies come oldest first and stop at REPLIES*: 51 replies from two
roles; the first 50 in send order.
- *an id that names no message in the business refuses
message-not-found*: an unknown UUID, another business's message, a task
ref and a decision id, for human and reader; each refusal recorded as
an `action.refused` event. The other business's reader reads its own.
- *strict arguments; a role session gets human-required*: seven bad
argument shapes give `invalid-request`; a role gets `human-required`,
including for a message it sent; the reader still can't
`message.receive`.
- *views passes replies through*.
- `packages/webui/tests/bus.test.mjs`: new *replies route serves the
replies to one message from the reader view* (200 with the row, an empty
list, unknown id 400 `message-not-found`, no delivery row, and seven
missing or malformed ids that never reach the bus, `?subject=` among
them). Widened: the GET-only test (405 on `replies`), the no-bus refusals
(`replies?id=%3C` 400, `replies?id=m-1` 503), the `busReader`
passthrough.
- `packages/webui/tests/send-browser.test.mjs`: new *a sent receipt lists
its replies as the page refreshes, oldest first and capped, and marks
none delivered or read*, in a browser against an in-process broker:
"No reply yet" after the send; after Read again, two replies in order
with role, class, trail link and a hostile body shown as text (no
script or img element, `window.injected` unset), the unrelated messages
absent, deliveries unchanged; a third reply shows on the page's own
ten-second refresh with no click; the human's `message.receive` then gets
all three, and after they are read they are still listed; 51 replies
show 50 and the cap note; an unknown sent id shows "could not be read"
with no stale banner on the Inbox; a new send starts an empty list; a
send that finishes while a replies read is held keeps its own empty list,
and the next read shows its own reply. The row 76 note assertion now
checks the new note.
- `packages/cli/tests/verbs.test.mjs`: the timeout assertion is now the
exact new message. New *a late reply the Console has listed still
reaches the next talk*: talk times out, the role replies, `replies`
lists it, and the next talk prints it and marks only it read.
New tests on base code (`new-tests-on-base.txt`): every new test fails
(`replies.test.mjs` at import, `REPLIES` isn't exported). Base tests on
candidate code (`base-tests-on-candidate.txt`): bus 95/95; webui 80/81 and
cli 113/114, the two failures being the row 76 note text and the old
timeout regex `/mosaic inbox/`, both text the brief changes.
## Mutants (`mutants.py`, `mutants.txt`)
17 single-site mutants, each run in a scratch copy of the candidate against
the test files named in `mutants.py`. All 17 killed:
| mutant | result |
|---|---|
| broker: no human-required | killed |
| broker: no message-not-found | killed |
| broker: newest first | killed |
| broker: cap 51 | killed |
| broker: reader not allowed | killed |
| broker: no strict keys | killed |
| views: subject arg | killed |
| serve: no id check | killed |
| serve: replies not a verb | killed |
| page: query as subject | killed |
| page: no replies read | killed |
| page: no reset on send | killed |
| page: no stale guard | killed |
| page: cap note off by one | killed |
| page: body unescaped | killed |
| page: failure shown as none | killed |
| cli: old message | killed |
`mutants.txt` is the raw output. Its `BAD-ANCHOR (0) serve: no id check`
line is my anchor's wrong indentation (the mutant did not run); the last
line is the rerun with the anchor fixed. The base tests don't reach
`replies` at all, so I didn't tabulate a base column: every new test fails
on base, above.
## Gate (my run on the candidate)
On the candidate in a worktree of `ff11aa4b`, run sequentially,
2026-10-12T18:29:24Z to 18:35:52Z (`gate-summary.txt`):
- `node --test 'packages/<p>/tests/*.test.mjs'`: bus 102/102, webui 83/83,
control-board 124/124, cli 115/115, each rc=0.
- `scripts/test-auth.sh`, `test-conductor.sh`, `test-config.sh`,
`test-discord.sh`, `test-extension-package.sh`, `test-foundation.sh`,
`test-queue.sh`, `test-release.sh`, `test-task.sh`: all rc=0.
The packet's tree is the one gated: `build.patch` applied to a fresh
worktree of `ff11aa4b` checks 12/12 against `candidate-manifest.sha256`.
## For reviewers
- **The bus README's verb table** sits in "Client protocol", not the reads
section the brief names. `decisions` and `credentials` have rows there,
so I added one for `replies`. One line; drop it if the brief's file
boundary is strict.
- **`packages/cli/README.md` lines 61-65** say "the reply still lands in
the inbox". That is the human's message inbox (what `message.receive`
returns), not `mosaic inbox`, so it isn't wrong, but it reads like the
timeout wording this row removed. The file isn't owned; a follow-up could
say "the next `mosaic talk` prints it".
- **Equivalent mutant not run:** dropping `business=?` from the replies
query. `message.send` refuses an `in_reply_to` outside the sender's
business, so no reply can point across businesses and the filter changes
no result. It stays as defence in depth, matching every other read.