feat(chat-core): extract transport sendTurn and adopt it in ChatPane - #5909
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 The design checks out: the removed client-side workaround is backed by a server guard already at the base commit, and the divergent Design-Verdict: PASS A real drift bug, fixed at its root by one shared receipt contract, with each caller's recovery policy kept explicit and test-pinned. Watch
[DESIGN-REVIEWED] 3033462 |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All checks done. The server-side empty-message guard is base behavior ( First-Principles-Verdict: CONCERNS Cause-level consolidation that earns its place — but the nearest sibling, ChatPage.send, hand-rolls the identical contract and is absent from the named follow-ups. What this change shipsIntent: make ChatPane's two send paths read one HTTP receipt identically so a turn is neither silently lost nor duplicated — an ADDITION (consolidation) with declared behavior changes.
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 3033462 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
UX Review (Fable 5) — 🟡 CONCERNSUX-level review of All evidence gathered. The diff is a transport refactor with one genuinely new user-facing path: the question-card fallback now recovers on the 10-second deadline, but routes it through the generic connection-failure copy. UX-Verdict: CONCERNS The new response-late recovery on the question card asserts "Couldn't send — check your connection and try again" when the code knows delivery is indeterminate. Watch
Suggestions
[UX-REVIEWED] 3033462 |
44099c3 to
08b4933
Compare
|
First-principles CONCERNS — addressed in 08b4933.
|
|
/ai-review override gpt 08b4933: Deliberate duplicate-over-loss tradeoff, not a defect — on response-late the question card is already cleared, so NOT restoring silently loses the user's answer (the strictly worse outcome); the divergence is documented at the call site and in the PR body, and this PR's own Design Review explicitly endorsed it. |
Human judgment recorded@CrysisDeu marked the gpt AI finding as false positive, not applicable, or explicitly accepted for
This decision applies only to this commit. A new push requires a new judgment. |
08b4933 to
a1f1e55
Compare
|
First-principles CONCERNS —
(Split 2026-09-04 from a combined record so each disposition carries exactly one finding; the UX-lane items now live in their own comments.) |
a1f1e55 to
9398cb6
Compare
5fc88be to
3033462
Compare
|
First-principles CONCERNS (round 3) — remaining
|
|
First-principles CONCERNS (round 3) — undeclared removal of the client-side drop guard — fixed in c16411b.
|
|
UX CONCERNS — copy for the
|
|
UX CONCERNS —
|
|
First-principles CONCERNS (round 3) — duplicate spelling of the delivery rule — fixed in c16411b.
|
|
First-principles CONCERNS (round 3) —
|
bolichen97
left a comment
There was a problem hiding this comment.
Approving — the extraction is behavior-preserving where it claims to be, and the one deliberate behavior change is scoped, explained, and pinned by a test.
Status-by-status equivalence check against the deleted ChatPane code:
refused->reportFailedSend(body.error): same, andreceipt.reasoncarries the samebody.errornarrowing.unknown-> silent: same, and correctly still silent, since a 2xx may already have started a turn.transport-error->reportFailedSend()with no reason: matches the old non-abort.catchleg, including the "no body to quote" rationale.response-late-> silent in the composer: matches the oldAbortErrorearly return.dispatched==confirmedDelivered(body)==ok && !queued(verified insendDelivery.ts), so both theconfirmOptimisticSendgate and theretireStatelessQuestiongate keep their exact previous predicate — the old site spelled the latterbody.ok && !body.queued, which is the same test.resolveAskAfterSend(receipt.body, ...)still receives the raw acceptance body, so card/ask logic is untouched.
The one deletion that is not a pure move — the body.queued && !llm.trim() attachment-only workaround — is genuinely obsolete rather than dropped: chat_handlers.py now carries the hoisted if not message: return 400 message_required guard ABOVE every dispatch branch (steer/queue, crew, subagent-hold, new turn), and that guard is already on main, not introduced here. A 400 is non-2xx, so readSendReceipt classifies it refused and the same reportFailedSend recovery fires — now with the server's reason instead of a bare failure. ChatPane.dirSend.test.tsx adds a case pinning exactly that refusal path, so the removed client workaround is replaced by an assertion rather than by nothing.
The intentional divergence is correct and correctly localized: the question-card fallback restores on response-late while the composer does not. The card has already destroyed the only visible copy of the answer, so a silently lost answer has no other trace, and the worst case is a visible duplicate the user can delete; the composer keeps its optimistic row pending to avoid inviting a duplicate turn. sendTurn distinguishes this from a merely malformed 2xx by checking controller.signal.aborted when the parser says unknown, which is the right way to preserve the deadline signal through a parser that folds an abort-during-response.json() into unknown.
sendTurn never rejects (every leg returns a receipt, clearTimeout in finally), so the old if (!res) guard in the fallback is subsumed: a throw from readSendReceipt lands in the catch as transport-error, which fails and restores exactly as before.
Truly no visual delta: the only ChatPane edits are the two send callbacks and their comments — no JSX element, class, prop, icon, ordering, or affordance changes anywhere in the diff.
Test coverage matches the seam: 11 transport contract tests cover all six statuses plus the pre-deadline non-fire, the unreadable-non-2xx and unreadable-2xx split, and the wire shape (/api/chat?ws=1, slot, meta); ChatPane tests pin the server-provided mid, the card late-restore policy, and the empty-wire refusal.
All checks pass (57 pass, 7 skipped, 0 failing).
Non-blocking, pre-existing on main and not this PR's to fix: the confirmedDelivered docstring in sendDelivery.ts still describes the busy branch as queueing only non-empty text while answering {ok, queued} either way — the hoisted server guard has made that paragraph stale. Worth a follow-up sweep now that the client workaround it justified is gone.
ChatPage's composer send and its mid-turn steer each hand-rolled the POST to /api/chat: an AbortController and 10 s timer, the resolves-not-rejects trap, readSendReceipt, and a catch that had to tell an abort from a network failure. The steer path was worse -- a separate api.steerChat helper with no deadline whose failure was only a console line. Both now call sendTurn (chat-core transport) and branch on receipt.status; the receipt policy (error row, composer restore, optimistic confirm, card retirement, ask resolution) is unchanged and stays on the surface. sendTurn gains the two flags this endpoint takes that ChatPage sets: steer (inject into the running turn / skip the sub-agent hold) and colorTheme. api.steerChat has no send-path consumer left on this surface (useSceneInteraction still uses it; its own P2 slice). RFC chat-core extraction, P2 (transport), third slice: after ChatPane (#5909), ChatEmbed (#8599) and SideChat (#8655), every user-facing composer send is on sendTurn.
ChatPage's composer send and its mid-turn steer each hand-rolled the POST to /api/chat: an AbortController and 10 s timer, the resolves-not-rejects trap, readSendReceipt, and a catch that had to tell an abort from a network failure. The steer path was worse -- a separate api.steerChat helper with no deadline whose failure was only a console line. Both now call sendTurn (chat-core transport) and branch on receipt.status; the receipt policy (error row, composer restore, optimistic confirm, card retirement, ask resolution) is unchanged and stays on the surface. sendTurn gains the two flags this endpoint takes that ChatPage sets: steer (inject into the running turn / skip the sub-agent hold) and colorTheme. api.steerChat has no send-path consumer left on this surface (useSceneInteraction still uses it; its own P2 slice). RFC chat-core extraction, P2 (transport), third slice: after ChatPane (#5909), ChatEmbed (#8599) and SideChat (#8655), every user-facing composer send is on sendTurn.
ChatPage's composer send and its mid-turn steer each hand-rolled the POST to /api/chat: an AbortController and 10 s timer, the resolves-not-rejects trap, readSendReceipt, and a catch that had to tell an abort from a network failure. The steer path was worse -- a separate api.steerChat helper with no deadline whose failure was only a console line. Both now call sendTurn (chat-core transport) and branch on receipt.status; the receipt policy (error row, composer restore, optimistic confirm, card retirement, ask resolution) is unchanged and stays on the surface. sendTurn gains the two flags this endpoint takes that ChatPage sets: steer (inject into the running turn / skip the sub-agent hold) and colorTheme. api.steerChat has no send-path consumer left on this surface (useSceneInteraction still uses it; its own P2 slice). RFC chat-core extraction, P2 (transport), third slice: after ChatPane (#5909), ChatEmbed (#8599) and SideChat (#8655), every user-facing composer send is on sendTurn.
ChatPage's composer send and its mid-turn steer each hand-rolled the POST to /api/chat: an AbortController and 10 s timer, the resolves-not-rejects trap, readSendReceipt, and a catch that had to tell an abort from a network failure. The steer path was worse -- a separate api.steerChat helper with no deadline whose failure was only a console line. Both now call sendTurn (chat-core transport) and branch on receipt.status; the receipt policy (error row, composer restore, optimistic confirm, card retirement, ask resolution) is unchanged and stays on the surface. sendTurn gains the two flags this endpoint takes that ChatPage sets: steer (inject into the running turn / skip the sub-agent hold) and colorTheme. api.steerChat has no send-path consumer left on this surface (useSceneInteraction still uses it; its own P2 slice). RFC chat-core extraction, P2 (transport), third slice: after ChatPane (#5909), ChatEmbed (#8599) and SideChat (#8655), every user-facing composer send is on sendTurn.
ChatPage's composer send and its mid-turn steer each hand-rolled the POST to /api/chat: an AbortController and 10 s timer, the resolves-not-rejects trap, readSendReceipt, and a catch that had to tell an abort from a network failure. The steer path was worse -- a separate api.steerChat helper with no deadline whose failure was only a console line. Both now call sendTurn (chat-core transport) and branch on receipt.status; the receipt policy (error row, composer restore, optimistic confirm, card retirement, ask resolution) is unchanged and stays on the surface. sendTurn gains the two flags this endpoint takes that ChatPage sets: steer (inject into the running turn / skip the sub-agent hold) and colorTheme. api.steerChat has no send-path consumer left on this surface (useSceneInteraction still uses it; its own P2 slice). RFC chat-core extraction, P2 (transport), third slice: after ChatPane (#5909), ChatEmbed (#8599) and SideChat (#8655), every user-facing composer send is on sendTurn.
ChatPage's composer send and its mid-turn steer each hand-rolled the POST to /api/chat: an AbortController and 10 s timer, the resolves-not-rejects trap, readSendReceipt, and a catch that had to tell an abort from a network failure. The steer path was worse -- a separate api.steerChat helper with no deadline whose failure was only a console line. Both now call sendTurn (chat-core transport) and branch on receipt.status; the receipt policy (error row, composer restore, optimistic confirm, card retirement, ask resolution) is unchanged and stays on the surface. sendTurn gains the two flags this endpoint takes that ChatPage sets: steer (inject into the running turn / skip the sub-agent hold) and colorTheme. api.steerChat has no send-path consumer left on this surface (useSceneInteraction still uses it; its own P2 slice). RFC chat-core extraction, P2 (transport), third slice: after ChatPane (#5909), ChatEmbed (#8599) and SideChat (#8655), every user-facing composer send is on sendTurn.
ChatPage's composer send and its mid-turn steer each hand-rolled the POST to /api/chat: an AbortController and 10 s timer, the resolves-not-rejects trap, readSendReceipt, and a catch that had to tell an abort from a network failure. The steer path was worse -- a separate api.steerChat helper with no deadline whose failure was only a console line. Both now call sendTurn (chat-core transport) and branch on receipt.status; the receipt policy (error row, composer restore, optimistic confirm, card retirement, ask resolution) is unchanged and stays on the surface. sendTurn gains the two flags this endpoint takes that ChatPage sets: steer (inject into the running turn / skip the sub-agent hold) and colorTheme. api.steerChat has no send-path consumer left on this surface (useSceneInteraction still uses it; its own P2 slice). RFC chat-core extraction, P2 (transport), third slice: after ChatPane (#5909), ChatEmbed (#8599) and SideChat (#8655), every user-facing composer send is on sendTurn.
ChatPage's composer send and its mid-turn steer each hand-rolled the POST to /api/chat: an AbortController and 10 s timer, the resolves-not-rejects trap, readSendReceipt, and a catch that had to tell an abort from a network failure. The steer path was worse -- a separate api.steerChat helper with no deadline whose failure was only a console line. Both now call sendTurn (chat-core transport) and branch on receipt.status; the receipt policy (error row, composer restore, optimistic confirm, card retirement, ask resolution) is unchanged and stays on the surface. sendTurn gains the two flags this endpoint takes that ChatPage sets: steer (inject into the running turn / skip the sub-agent hold) and colorTheme. api.steerChat has no send-path consumer left on this surface (useSceneInteraction still uses it; its own P2 slice). RFC chat-core extraction, P2 (transport), third slice: after ChatPane (#5909), ChatEmbed (#8599) and SideChat (#8655), every user-facing composer send is on sendTurn.
ChatPage's composer send and its mid-turn steer each hand-rolled the POST to /api/chat: an AbortController and 10 s timer, the resolves-not-rejects trap, readSendReceipt, and a catch that had to tell an abort from a network failure. The steer path was worse -- a separate api.steerChat helper with no deadline whose failure was only a console line. Both now call sendTurn (chat-core transport) and branch on receipt.status; the receipt policy (error row, composer restore, optimistic confirm, card retirement, ask resolution) is unchanged and stays on the surface. sendTurn gains the two flags this endpoint takes that ChatPage sets: steer (inject into the running turn / skip the sub-agent hold) and colorTheme. api.steerChat has no send-path consumer left on this surface (useSceneInteraction still uses it; its own P2 slice). RFC chat-core extraction, P2 (transport), third slice: after ChatPane (#5909), ChatEmbed (#8599) and SideChat (#8655), every user-facing composer send is on sendTurn.
#8689) ChatPage's composer send and its mid-turn steer each hand-rolled the POST to /api/chat: an AbortController and 10 s timer, the resolves-not-rejects trap, readSendReceipt, and a catch that had to tell an abort from a network failure. The steer path was worse -- a separate api.steerChat helper with no deadline whose failure was only a console line. Both now call sendTurn (chat-core transport) and branch on receipt.status; the receipt policy (error row, composer restore, optimistic confirm, card retirement, ask resolution) is unchanged and stays on the surface. sendTurn gains the two flags this endpoint takes that ChatPage sets: steer (inject into the running turn / skip the sub-agent hold) and colorTheme. api.steerChat has no send-path consumer left on this surface (useSceneInteraction still uses it; its own P2 slice). RFC chat-core extraction, P2 (transport), third slice: after ChatPane (#5909), ChatEmbed (#8599) and SideChat (#8655), every user-facing composer send is on sendTurn.
Why no screenshot: This changes only transport receipt handling and deterministic tests; no rendered component, layout, style, motion, or interaction affordance changes.
Problem / Motivation
ChatPane had two send sites that independently interpreted the same HTTP receipt. The composer and the question-card fallback could drift on refused, unreadable, queued, aborted, and transport-error outcomes; the fallback also had no bounded response contract.
Why it matters
Receipt drift can silently lose a user's turn or recover an already-delivered turn and invite a duplicate. A shared transport seam makes delivery classification consistent while leaving each surface responsible for its own recovery policy.
What changed (motivation → approach → change)
A new chat-core sendTurn transport wraps the existing readSendReceipt parser and normalizes dispatched, queued, refused, unknown, response-late, and transport-error outcomes. It owns the 10-second abort deadline and never rejects.
Both ChatPane send paths now consume that contract. The normal composer preserves current-main optimistic confirmation, including the server-provided message ID, follow-up options, pending questions, and queue behavior. It leaves unknown and response-late sends pending rather than restoring potentially delivered content. The question-card fallback deliberately restores on response-late because the card has already removed the only visible copy; a deterministic policy test now pins that caller-specific choice.
The server now rejects an empty wire message before every dispatch branch, so the obsolete client-side queued-but-dropped workaround is replaced by a refusal-path assertion.
The rebase preserves current-main behavior and does not import #6825's separate markSendFailed policy. #6825 is the explicit follow-up that must rebase its failure behavior onto this transport caller after this PR lands. #6823 follow-up options, #6307 ACP/rate-limit behavior, #5819 cross-session send, and #4904 fork-merge UI remain independently owned.
Tests
Manual verification
N/A — pure transport and receipt-policy behavior with no rendered, layout, or styling delta; deterministic automated coverage exercises the fetch boundary and caller policy.
Related Issues
Chat-core extraction phase P2. Follow-up semantic rebase point: #6825.
Checklist
Contribution License Agreement
N/A — repository placeholder only; no OSPO CLA text has been supplied.
Body-read deadline follow-up
The GPT review found one valid residual path: a 2xx response can deliver its headers and then stall while
response.json()reads the body. The abort is intentionally classified as unreadable by the shared parser, but the transport must preserve that the deadline fired.sendTurnnow returnsresponse-latewhen the parser reportsunknownafter its controller has aborted, so card/composer callers can recover their only visible input. A deterministic fake-timer test holds the body read open until abort and pins this contract.Additional validation:
chatCoreTransport.contract.test.ts: 11 passednpm run typecheck