fix: report refused sends at two remaining sendChat callers (#4198) - #4214
Conversation
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: |
Design Review (Fable 5) — ✅ PASSDesign-level review of Both other sendChat callers now match the established ChatPage receipt pattern; the design is scoped, guarded, and tested. One structural observation worth passing on. Design-Verdict: PASS Extends the proven ChatPage receipt contract to the last two blind callers, with race guards pinned by tests — sound and proportionate. Suggestions
[DESIGN-REVIEWED] a243f2f |
UX Review (Fable 5) — ✅ PASSUX-level review of UX-Verdict: PASS Silent send failures now speak in place with a retry path — the fix lands where the user is looking, in both themes. Suggestions
[UX-REVIEWED] a243f2f |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsI verified the retired key Capture script FINDING — website/capture/failed-send-report.tsx:96 — [OPUS-REVIEWED] a243f2f Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of Reviewing PR #4214 against the first-principles contract: I read the contract, the intent file, the full patch, and surveyed the repo for existing receipt-check mechanisms and sibling First-Principles-Verdict: CONCERNS Both sites are derived fixes for #4198, but the composer guard ships a redundant second ref, and the receipt check is now hand-rolled at a fourth call site. What this change shipsIntent: make a send the server refused say so where it was typed — a FIX (closes #4198). 12 observable items; the 10 most visible:
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] a243f2f |
881fdc4 to
ca34a51
Compare
|
ai-review-disposition (lane: UX Review, head ca34a51)
|
ca34a51 to
4a1f592
Compare
4a1f592 to
114acb5
Compare
114acb5 to
f309904
Compare
|
ai-review-disposition (lane: Design Review, head 0f90542)
|
0f90542 to
0fccd9b
Compare
An HTTP 4xx/5xx RESOLVES a fetch rather than rejecting it, so two api.sendChat callers that only handled rejection let a refused send look successful: - App.tsx requestFeature left the optimistic bubble next to a slot stuck `running`, with nothing said. It now reads the JSON receipt, appends a role:'error' row (server reason, else the existing pages.chatPage.send_failed key) addressed to the slot that owns the bubble, and undoes the optimistic running state while that slot is on screen. Both optimistic writes are slot-addressed now, so a mid-flight session switch cannot put the bubble in an unrelated transcript. - useSceneInteraction.sendToAgent unconditionally set 'sent' on resolve; the 'failed' state was reachable only via rejection. It now checks the receipt, reaches 'failed' for refused sends, hands the payload back by APPENDING to the draft with de-duplication (never clobbering text typed mid-flight), surfaces the server reason as the Retry tooltip, and no longer echoes a refused message into the mini thread. Send outcomes are guarded on the popover still targeting the same agent, and the 1.5s sent-reset timer can no longer clobber a later in-flight send. Vitest coverage for both sites (resolved-not-ok, rejected, queued, append-not-clobber, retarget guards, stale-timer guard) plus a capture harness and SHA-pinned screenshots of both failure surfaces. Closes #4198
0fccd9b to
a243f2f
Compare
…ev#4198) (kirodotdev#4214) An HTTP 4xx/5xx RESOLVES a fetch rather than rejecting it, so two api.sendChat callers that only handled rejection let a refused send look successful: - App.tsx requestFeature left the optimistic bubble next to a slot stuck `running`, with nothing said. It now reads the JSON receipt, appends a role:'error' row (server reason, else the existing pages.chatPage.send_failed key) addressed to the slot that owns the bubble, and undoes the optimistic running state while that slot is on screen. Both optimistic writes are slot-addressed now, so a mid-flight session switch cannot put the bubble in an unrelated transcript. - useSceneInteraction.sendToAgent unconditionally set 'sent' on resolve; the 'failed' state was reachable only via rejection. It now checks the receipt, reaches 'failed' for refused sends, hands the payload back by APPENDING to the draft with de-duplication (never clobbering text typed mid-flight), surfaces the server reason as the Retry tooltip, and no longer echoes a refused message into the mini thread. Send outcomes are guarded on the popover still targeting the same agent, and the 1.5s sent-reset timer can no longer clobber a later in-flight send. Vitest coverage for both sites (resolved-not-ok, rejected, queued, append-not-clobber, retarget guards, stale-timer guard) plus a capture harness and SHA-pinned screenshots of both failure surfaces. Closes kirodotdev#4198 Co-authored-by: Zezhen Xu <zezhexu@dev-dsk-zezhexu-2b-15d11a49.us-west-2.amazon.com>
Summary
An HTTP 4xx/5xx resolves a
fetchrather than rejecting it, so twoapi.sendChatcallers that only handled rejection let a refused send look successful. Both were flagged in #4198 (a deliberate follow-up out of PR #4180's First Principles review) and both now get the treatment the chat surfaces already have: read the JSON receipt ({ok, queued, error}), say so where the message was typed, and hand the payload back.Closes #4198
The two sites
website/src/App.tsx— the feature-request flowBefore:
await api.sendChat(...)insidetry { } catch { /* WS will handle response */ }— a refused send left the optimistic bubble on screen next to a slot stuckrunning, with nothing said.Now:
!body.ok && !body.queuedon resolve is failure (resolution is not success — no turn started, no WS response is coming).role:'error'transcript row in that session — the existing error presentation, no new component. It carries the server's own reason when present, else the existingpages.chatPage.send_failedcatalog key (present in every locale — no new string ships).appendSlotMessage), not the active slot:createSlot.fulfilledhas a switched-away guard, so a mid-flight session switch must not put the bubble in an unrelated transcript (local review finding, fixed).runningflag is set/undone only while that slot is on screen —slotRunningdescribes the active slot, and clearing it after a switch would clobber another session's live indicator. A stale flag self-heals from the next server snapshot.website/src/hooks/useSceneInteraction.tsx— the scene popover composerBefore:
.then(r => { r.body?.cancel() })then an unconditionalsetSendState('sent')— the'failed'state (line 301) was reachable only via rejection, i.e. unreachable for precisely the errors that matter. The state name asserted the opposite of what happened.Now:
'failed'(red Retry) instead of'sent'. The stale "sendChat streams SSE" comment is corrected —?ws=1answers with a JSON receipt and the turn streams over WS.<input>, where a newline would be silently stripped.title(review finding; no new catalog key).s === 'sent' ? 'idle' : s) so a stale timer can't re-enable submit while a later send is in flight (review finding, fixed).steerChatparses throughj(), which throws on HTTP errors.Screenshots
Feature-request transcript after a refused send (real row registry; before the fix this surface showed the bubble alone next to a stuck-running slot):
Scene popover after a refused send, captured end-to-end: the harness stubs
/api/chatto answer 409{ok:false}(which resolves — the exact shape the old code reported as'sent'), the runner clicks the agent, types, sends, and asserts the draft is handed back before shooting:Light theme: feature-request · scene popover. Harness:
website/capture/failed-send-report.{tsx,html}+website/scripts/capture-failed-send-report.mjs(each scene asserts its marker and the runner exits nonzero on a mismatch).Tests
website/src/test/App.featureRequestFailure.test.tsx(new): resolved-not-ok appends the error row with the server reason and undoes running; receipt-without-reason falls back to the shared key; transport reject reports the same way; an accepted send stays untouched.website/src/test/UseSceneInteractionCoverage.test.tsx: resolved-not-ok reaches Retry with the payload kept and no mini-thread echo; an unreadable receipt (HTML error page) is a failure, not a success; a queued acceptance issent; append-does-not-clobber (mid-flight edit keeps newer text, payload appended); retargeted failure does not flag the new composer; the server reason lands on the Retrytitle; a stale sent-reset can't clobber a later in-flight send. The obsolete "swallows a rejected stream cancel" test (which pinned the streaming misunderstanding) is replaced by the receipt contract; the sharedsendChatmock now answers with a JSON receipt.Verification
npx tsc -bclean; affected test files 76/76; full website vitest 21166 passed / 1 failed =CliPanelCoveragecustom-theme repaint, a known pre-existing flake (passes in isolation).isort/flake8/mypyclean at pinned versions;pytest55695 passed — 7 failures intest_artifact_source.py/test_artifacts_handlers.pyreproduce on main on this host (a.kirodirectory at the temp root makes the classifier treat/tmpas a project root) and pass under a clean TMPDIR; not from this diff.Local review round (pre-push)
Two model-pinned read-only reviewers (GPT 5.6-sol mirroring codex-review, Opus 5 mirroring claude-review + base-ref AUTOSDE). Verdicts: 0 blocking. All actionable findings fixed in this commit: optimistic-write slot addressing + guarded running (both reviewers), stale sent-timer race (GPT), scene reason surfaced (Opus). Two low-severity advisories are accepted as documented limitations rather than grown into new machinery:
role:'error'row does not survive a switch-away-then-back rehydrate (server history wins). The stuck-running half self-heals from the server; only the textual reason is lost in that narrow window. Fixing it needs new persistence machinery — out of scope for this fix.'sent'for the same case.CI round 1 responses (this head)
apps.designTweak.status.send_failed("Send failed: {{error}}") catalog entry, present in every locale — still no new string. (The lane's suggested keyapps.mochi.chat.send_failedis actually the connection-blame copy; the designTweak one is the framed pattern.)pages.chatPage.send_failed= "Send failed" (en.manual.json), which carries no connection claim. The quoted copy belongs toapps.mochi.chat.send_failed, which this path never renders.role="status"line under the composer instead of a Retry-button tooltip, reachable by keyboard, touch, and AT.