fix: hoist the empty-message 400 above every /api/chat dispatch branch (#4223) - #4233
Conversation
Design Review (Fable 5) — ✅ PASSAdvisory design-level review of Diff is small and well-contained; the handler context confirms the guard sits after slot resolution/ownership checks (unchanged ordering) and above every dispatch branch, the steer/requeue sibling returns are genuinely non-empty-gated, and Design-Verdict: PASS Root-cause fix at the right layer: one hoisted guard makes every dispatch branch honest, with the alternative weighed and the ratchet moved correctly. [DESIGN-REVIEWED] 14018b0 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsThe candidate discovery pass found no candidates. I've independently verified the control flow:
No legitimate empty-message flow is lost, the No findings. [OPUS-REVIEWED] 14018b0 Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — ✅ PASSAdvisory premise-level review of Reading complete. I verified the guard placement, grepped for sibling empty-message guards across handlers, checked the error-code ratchet mechanics, and confirmed the First-Principles-Verdict: PASS One placement fix at cause level — the guard now precedes every early-returning branch, so a false What this change shipsIntent: stop
Root-cause check: the cause named (guard below an early-returning branch) is the thing removed, not patched around. Sibling scan for the same defect shape — a message/question validity guard sitting below a dispatch branch: Subtractions
[FIRST-PRINCIPLES-REVIEWED] 14018b0 |
c1326c9 to
059701c
Compare
#4223) The busy-slot branch of POST /api/chat guarded queue_append and the queue_push broadcast on a non-empty message, but returned {"ok": true, "queued": true} unconditionally. A programmatic caller (app token, curl, integration) posting an empty message — possibly with attachments in meta — therefore received an acceptance receipt for work that was silently dropped: nothing queued, no broadcast, attachments lost. The root cause is guard placement: the single if-not-message 400 sat below the early-returning busy branch, so any branch above it could bypass the check. (The dashboard composer is unaffected: it always inlines staged files into the message text.) Hoist the one guard above every dispatch branch (steer/queue, crew, subagent-hold, new turn) instead of cloning the refusal into the branch that bypassed it today — no future branch added above the old guard position can re-create the bug. The 400 carries code: message_required, the backend-owned string already used for this refusal in handlers/messaging.py, giving the error one machine-readable shape regardless of slot state. queued: true now implies a real enqueue on every path. Closes #4223
059701c to
14018b0
Compare
|
Disposition for First Principles CONCERNS on 059701c — fixed in 14018b0. The concern held: the first revision cloned the refusal into the busy branch and left the root-cause placement (guard below early-returning branches) intact. Applied the suggested subtraction verbatim: hoisted the single Behavior is input-identical: the steer path already required non-empty text, the crew and subagent-hold branches sat below the old guard, and the 404/409 ordering plus the agent/theme side effects before the guard are unchanged. Affected suites (127 tests incl. the busy-slot receipt tests and the error-code contract) re-ran green. |
bolichen97
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: fix (3 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean, security checklist all-NO, AI reviewers green. Category: fix — hoists the empty-message 400 guard above all /api/chat dispatch branches, clear root cause, 3 files.
bolichen97
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: fix (3 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean, security checklist all-NO, AI reviewers green. Category: fix — hoists empty-message 400 validation above all /api/chat dispatch branches, clear root cause (#4223).
kirodotdev#4223) (kirodotdev#4233) The busy-slot branch of POST /api/chat guarded queue_append and the queue_push broadcast on a non-empty message, but returned {"ok": true, "queued": true} unconditionally. A programmatic caller (app token, curl, integration) posting an empty message — possibly with attachments in meta — therefore received an acceptance receipt for work that was silently dropped: nothing queued, no broadcast, attachments lost. The root cause is guard placement: the single if-not-message 400 sat below the early-returning busy branch, so any branch above it could bypass the check. (The dashboard composer is unaffected: it always inlines staged files into the message text.) Hoist the one guard above every dispatch branch (steer/queue, crew, subagent-hold, new turn) instead of cloning the refusal into the branch that bypassed it today — no future branch added above the old guard position can re-create the bug. The 400 carries code: message_required, the backend-owned string already used for this refusal in handlers/messaging.py, giving the error one machine-readable shape regardless of slot state. queued: true now implies a real enqueue on every path. Closes kirodotdev#4223 Co-authored-by: Patrick Gao <patrigao@amazon.com>
Problem / Motivation
The busy-slot branch of
POST /api/chatguardsslot.queue_appendand thequeue_pushWebSocket broadcast on a non-empty message, but its{"ok": true, "queued": true}response returns unconditionally. The idle path'sif not message:400 sits below the busy branch, so it is unreachable while a turn is running. A programmatic caller (app token, curl, integration) that POSTs an emptymessageto a busy slot — possibly with attachments inmeta— therefore receives an acceptance receipt for work that was silently dropped: nothing queued, no broadcast, attachments lost. The dashboard composer cannot hit this (it always inlines staged files into the wire text), so the affected surface is the HTTP API contract.Why it matters
Every API caller that trusts
queued: trueis misinformed, and the response gives no way to distinguish this from a real enqueue — the failure is invisible until the user notices their attachment never arrived. Two review lanes on PR #4180 independently identified this server response as the root cause and asked for this separate filing.What changed (motivation → approach → change)
Symptom: a false
queued: truereceipt for empty busy-slot sends. Root cause: guard placement — the singleif not message:400 sat below the early-returning busy branch, so any branch above it bypassed the check. Change: hoist that one guard above every dispatch branch (steer/queue, crew, subagent-hold, new turn), so no current or future branch can bypass it, and delete the now-redundant refusal copies. The 400 carriescode: message_required— the backend-owned string already established for this refusal inhandlers/messaging.py(AGENTS.md backend-string convention) — giving the error one machine-readable shape regardless of slot state.queued: truenow implies a realqueue_appendon every path.The first pushed revision cloned the refusal into the busy branch; the First Principles reviewer correctly flagged that this left the bug-causing placement intact, and the hoist is its suggested subtraction (2 refusal sites → 1 guard). Behavior is input-identical: the steer path already required a non-empty message, the crew and subagent-hold branches sat below the old guard, and the agent/theme side-effect ordering before the guard is unchanged.
Deliberately not done: queuing attachment-only sends (the larger behavioral alternative) — the honest refusal is consistent with the side-chat path, which also 400s on empty text. The two sibling
{"ok": true, "queued": true}returns were verified truthful: the requeue-after-kill return fires only after the turn'sfinallyrequeued the steer (non-empty by the steer gate), and the subagent-hold return now sits below the hoisted guard.Pre-push review (two model-pinned read-only subagents mirroring the codex-review and claude-review contracts): 0 blocking findings; 3 advisories fixed in this diff (comment framing corrected to programmatic callers, test docstrings reworded to present-tense invariants,
codeunified), 1 noted out of scope (see below). Server-side First Principles concern addressed by the hoist.Tests
test/test_queue_during_subagents.py::TestApiChatBusySlotEmptyMessage(new, on a real slot made busy via a live never-done task, soslot.runningis authentic):test_attachment_only_send_gets_honest_400— busy slot + empty message +metaattachments → 400 withcode: message_required,queue_depth == 0, zeroqueue_pushbroadcasts. Fails on the pre-fix code withassert 200 == 400(verified red-first).test_nonempty_message_still_queued— busy slot + non-empty message →queued: true, one queue entry, exactly onequeue_pushbroadcast.Gates: isort / flake8 / mypy clean; full pytest run 55,239 passed on the initial head, affected suites (127 tests incl. the error-code contract) re-run green after the hoist. 459 failures in the run were reproduced byte-identically on pristine
mainin the same sandbox (missing editable-install entry-point metadata plus jq/docker/userns host drift) and are unrelated to this diff.Manual verification
N/A — unit coverage sufficient: the change is a single guarded JSON response in one handler branch, and both the refusal and the preserved enqueue path are locked by handler-level tests against a real
TestServer.Related Issues
Closes #4223
Out-of-scope note for reviewers:
restoreComposerAfterFailedSendinwebsite/src/pages/ChatPage.tsxdoes not restore staged files after a failed send. Moot for this server-side fix (the composer cannot produce an empty-text send today), but worth a follow-up if the frontend ever allows one. The client guard added by PR #4180 is not inmain, so nowebsite/change rides here; removing that guard once both land belongs to a follow-up.Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)