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

12 KiB

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.