fix(web): emit Done after response — SSE ordering fix (#2079) - #2104
Conversation
Move the terminal "Done" status out of thread_ops and emit it only after the gateway successfully responds via a new respond_then_done() helper in agent_loop. This guarantees the browser receives the assistant message before the turn-closing event, preventing the web UI from appearing stuck. Adds a regression test asserting the response event is captured before the Done status in the ordered event log. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Track whether a `response` SSE event was received for the current turn. When "Done" arrives without a preceding response, schedule a loadHistory() call after 1500ms so the user sees the answer even if the response event was lost to broadcast lag or a brief disconnect. This is the second prong of the fix described in #2079 — the backend ordering fix alone prevents the race, but this fallback handles residual edge cases (proxy buffering, SSE reconnection gaps). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…leaks Backend: - Send Done status when BeforeOutbound hook blocks the response, so the client still knows the turn is complete. - Send Done status for empty/suppressed responses (e.g. approval handled via send_status) to match pre-refactor behavior. Frontend: - Set _turnResponseReceived on stream_chunk events so streaming responses don't trigger a spurious loadHistory() when Done arrives. - Clear _doneWithoutResponseTimer on sendMessage() to prevent stale timers from a previous turn firing during the new one. - Clear turn-tracking state on switchThread() to prevent cross-thread contamination of the timer and flag. - Clear turn-tracking state on SSE reconnect (eventSource.onopen) to prevent stale timers from before the disconnect. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request ensures that assistant responses are emitted before the terminal "Done" status to prevent the web UI from closing a turn before the message is rendered. It introduces a respond_then_done helper in the agent loop and implements a safety net in the frontend to reload history if a "Done" status arrives without a preceding response event. A regression test was added to verify the event ordering. Feedback was provided regarding a bug in the frontend logic where the status field was incorrectly accessed as message instead of status.
I am having trouble creating individual review comments. Click here to see my feedback.
src/channels/web/static/app.js (779-795)
The status event payload from the backend for a StatusUpdate::Status(String) is {"status": "the string"}, not {"message": "the string"}. The current code checks data.message, which will be undefined, causing this logic to never execute and breaking the frontend safety net. You should check data.status instead.
if (data.status === 'Done' || data.status === 'Awaiting approval') {
finalizeActivityGroup();
enableChatInput();
// Safety net (#2079): if Done arrives but we never received a
// response event for this turn, the message may have been lost
// (broadcast lag, proxy buffering, brief SSE disconnect). Reload
// history after a short delay so the user sees the answer.
if (!_turnResponseReceived && data.status === 'Done') {
if (!_doneWithoutResponseTimer) {
_doneWithoutResponseTimer = setTimeout(() => {
_doneWithoutResponseTimer = null;
if (currentThreadId) loadHistory();
}, 1500);
}
}
_turnResponseReceived = false;
}
Code ReviewOverviewFixes a SSE ordering race (#2079) where the web UI could observe the turn-closing
What works well
Issues1.
|
- respond_then_done now emits Done regardless of respond outcome so the client always knows the turn ended, even on delivery failure - Extract send_done() helper to deduplicate the inline Done+warn blocks in the hook-blocked and empty-response paths - Add done_emitted_for_empty_response test covering the empty-response branch ordering invariant - Lift 1500ms magic number to DONE_WITHOUT_RESPONSE_TIMEOUT_MS constant - Add comment explaining _turnResponseReceived single-thread tracking Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
All five issues addressed in e23d47d:
|
Code ReviewOverviewFixes #2079 by reordering the SSE event stream so the assistant
The architectural direction is right — moving IssuesRegression:
|
…tcome
Distinguish "no response, turn complete" from "no response, turn paused"
in handle_message's return type so the run loop can decide whether to
emit the terminal Done status. The previous code lumped both into
Ok(Some("")), causing v1 NeedApproval to incorrectly emit Done after
ApprovalNeeded — which then tripped the new web UI safety net and
triggered a spurious loadHistory() under the live approval prompt.
- New HandleOutcome enum with Shutdown / Respond / NoResponse / Pending
- SubmissionResult::NeedApproval now maps to HandleOutcome::Pending
- Bridge handlers wrapped via HandleOutcome::from_legacy (their approval
flows return non-empty descriptive text, so they never need Pending)
- Regression test no_done_emitted_while_awaiting_approval drives a
v1 Always-approval probe and asserts no Done is captured
- Repaired pre-existing done_emitted_for_empty_response test, which
asserted the wrong invariant: the dispatcher substitutes empty LLM
responses with a fallback message, so a truly empty response never
reaches the run loop. Renamed and updated to assert the ordering.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
Pushed 7d3f804 to address the What changedIntroduced a enum HandleOutcome {
Shutdown, // /quit, single-message REPL exit
Respond(String), // send response, then Done
NoResponse, // empty response, send Done only
Pending, // turn paused — DO NOT send Done
}The previous code lumped both empty cases into Bridge handlers (engine v2) still return TestsAdded a regression test
I also had to repair the pre-existing Verification
The other smaller observations from my review (drain-loop intermediate |
…earai#2104) * fix(web): emit Done status after response to fix SSE ordering (nearai#2079) Move the terminal "Done" status out of thread_ops and emit it only after the gateway successfully responds via a new respond_then_done() helper in agent_loop. This guarantees the browser receives the assistant message before the turn-closing event, preventing the web UI from appearing stuck. Adds a regression test asserting the response event is captured before the Done status in the ordered event log. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(web): add frontend safety net for lost SSE response events (nearai#2079) Track whether a `response` SSE event was received for the current turn. When "Done" arrives without a preceding response, schedule a loadHistory() call after 1500ms so the user sees the answer even if the response event was lost to broadcast lag or a brief disconnect. This is the second prong of the fix described in nearai#2079 — the backend ordering fix alone prevents the race, but this fallback handles residual edge cases (proxy buffering, SSE reconnection gaps). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address review findings — Done on all paths, fix frontend timer leaks Backend: - Send Done status when BeforeOutbound hook blocks the response, so the client still knows the turn is complete. - Send Done status for empty/suppressed responses (e.g. approval handled via send_status) to match pre-refactor behavior. Frontend: - Set _turnResponseReceived on stream_chunk events so streaming responses don't trigger a spurious loadHistory() when Done arrives. - Clear _doneWithoutResponseTimer on sendMessage() to prevent stale timers from a previous turn firing during the new one. - Clear turn-tracking state on switchThread() to prevent cross-thread contamination of the timer and flag. - Clear turn-tracking state on SSE reconnect (eventSource.onopen) to prevent stale timers from before the disconnect. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address PR review — always emit Done, extract helper, add test - respond_then_done now emits Done regardless of respond outcome so the client always knows the turn ended, even on delivery failure - Extract send_done() helper to deduplicate the inline Done+warn blocks in the hook-blocked and empty-response paths - Add done_emitted_for_empty_response test covering the empty-response branch ordering invariant - Lift 1500ms magic number to DONE_WITHOUT_RESPONSE_TIMEOUT_MS constant - Add comment explaining _turnResponseReceived single-thread tracking Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(agent): suppress Done while awaiting approval; introduce HandleOutcome Distinguish "no response, turn complete" from "no response, turn paused" in handle_message's return type so the run loop can decide whether to emit the terminal Done status. The previous code lumped both into Ok(Some("")), causing v1 NeedApproval to incorrectly emit Done after ApprovalNeeded — which then tripped the new web UI safety net and triggered a spurious loadHistory() under the live approval prompt. - New HandleOutcome enum with Shutdown / Respond / NoResponse / Pending - SubmissionResult::NeedApproval now maps to HandleOutcome::Pending - Bridge handlers wrapped via HandleOutcome::from_legacy (their approval flows return non-empty descriptive text, so they never need Pending) - Regression test no_done_emitted_while_awaiting_approval drives a v1 Always-approval probe and asserts no Done is captured - Repaired pre-existing done_emitted_for_empty_response test, which asserted the wrong invariant: the dispatcher substitutes empty LLM responses with a fallback message, so a truly empty response never reaches the run loop. Renamed and updated to assert the ordering. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: ilblackdragon@gmail.com <ilblackdragon@gmail.com> (cherry picked from commit 00fd2e8)
…earai#2104) * fix(web): emit Done status after response to fix SSE ordering (nearai#2079) Move the terminal "Done" status out of thread_ops and emit it only after the gateway successfully responds via a new respond_then_done() helper in agent_loop. This guarantees the browser receives the assistant message before the turn-closing event, preventing the web UI from appearing stuck. Adds a regression test asserting the response event is captured before the Done status in the ordered event log. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(web): add frontend safety net for lost SSE response events (nearai#2079) Track whether a `response` SSE event was received for the current turn. When "Done" arrives without a preceding response, schedule a loadHistory() call after 1500ms so the user sees the answer even if the response event was lost to broadcast lag or a brief disconnect. This is the second prong of the fix described in nearai#2079 — the backend ordering fix alone prevents the race, but this fallback handles residual edge cases (proxy buffering, SSE reconnection gaps). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address review findings — Done on all paths, fix frontend timer leaks Backend: - Send Done status when BeforeOutbound hook blocks the response, so the client still knows the turn is complete. - Send Done status for empty/suppressed responses (e.g. approval handled via send_status) to match pre-refactor behavior. Frontend: - Set _turnResponseReceived on stream_chunk events so streaming responses don't trigger a spurious loadHistory() when Done arrives. - Clear _doneWithoutResponseTimer on sendMessage() to prevent stale timers from a previous turn firing during the new one. - Clear turn-tracking state on switchThread() to prevent cross-thread contamination of the timer and flag. - Clear turn-tracking state on SSE reconnect (eventSource.onopen) to prevent stale timers from before the disconnect. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address PR review — always emit Done, extract helper, add test - respond_then_done now emits Done regardless of respond outcome so the client always knows the turn ended, even on delivery failure - Extract send_done() helper to deduplicate the inline Done+warn blocks in the hook-blocked and empty-response paths - Add done_emitted_for_empty_response test covering the empty-response branch ordering invariant - Lift 1500ms magic number to DONE_WITHOUT_RESPONSE_TIMEOUT_MS constant - Add comment explaining _turnResponseReceived single-thread tracking Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(agent): suppress Done while awaiting approval; introduce HandleOutcome Distinguish "no response, turn complete" from "no response, turn paused" in handle_message's return type so the run loop can decide whether to emit the terminal Done status. The previous code lumped both into Ok(Some("")), causing v1 NeedApproval to incorrectly emit Done after ApprovalNeeded — which then tripped the new web UI safety net and triggered a spurious loadHistory() under the live approval prompt. - New HandleOutcome enum with Shutdown / Respond / NoResponse / Pending - SubmissionResult::NeedApproval now maps to HandleOutcome::Pending - Bridge handlers wrapped via HandleOutcome::from_legacy (their approval flows return non-empty descriptive text, so they never need Pending) - Regression test no_done_emitted_while_awaiting_approval drives a v1 Always-approval probe and asserts no Done is captured - Repaired pre-existing done_emitted_for_empty_response test, which asserted the wrong invariant: the dispatcher substitutes empty LLM responses with a fallback message, so a truly empty response never reaches the run loop. Renamed and updated to assert the ordering. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: ilblackdragon@gmail.com <ilblackdragon@gmail.com>
Summary
thread_ops.rsand emit it only afteragent_loop.rssuccessfully sends the response, via a newrespond_then_done()helper. This guarantees the browser receives the assistant message before the turn-closing SSE event.responseSSE event was received for the current turn. When "Done" arrives without one, triggerloadHistory()after 1500ms to recover the message from the server — handles residual edge cases like proxy buffering or brief SSE disconnects.Closes #2079
Test plan
cargo test -p ironclaw --test e2e_response_order response_order_tests::response_arrives_before_done_statuscargo clippy --all --benches --tests --examples --all-features— zero warningsresponsearrives beforeDone🤖 Generated with Claude Code