Skip to content

fix(web): emit Done after response — SSE ordering fix (#2079) - #2104

Merged
serrrfirat merged 5 commits into
stagingfrom
fix/sse-done-after-response
Apr 7, 2026
Merged

serrrfirat merged 5 commits into
stagingfrom
fix/sse-done-after-response

Conversation

@serrrfirat

@serrrfirat serrrfirat commented Apr 7, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

  • Backend: Move the terminal "Done" status out of thread_ops.rs and emit it only after agent_loop.rs successfully sends the response, via a new respond_then_done() helper. This guarantees the browser receives the assistant message before the turn-closing SSE event.
  • Frontend safety net: Track whether a response SSE event was received for the current turn. When "Done" arrives without one, trigger loadHistory() after 1500ms to recover the message from the server — handles residual edge cases like proxy buffering or brief SSE disconnects.
  • Regression test: Asserts the response event is captured before the Done status in an ordered event log.

Closes #2079

Test plan

  • cargo test -p ironclaw --test e2e_response_order response_order_tests::response_arrives_before_done_status
  • cargo clippy --all --benches --tests --examples --all-features — zero warnings
  • Manual: send message in web UI, verify in browser devtools EventStream that response arrives before Done
  • Manual: simulate lost response (e.g. throttle network) — verify history reloads after 1500ms

🤖 Generated with Claude Code

serrrfirat and others added 2 commits April 7, 2026 10:57
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>
@github-actions github-actions Bot added scope: agent Agent core (agent loop, router, scheduler) scope: channel/web Web gateway channel size: M 50-199 changed lines risk: medium Business logic, config, or moderate-risk modules contributor: core 20+ merged PRs labels Apr 7, 2026
…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>
@github-actions github-actions Bot added size: L 200-499 changed lines and removed size: M 50-199 changed lines labels Apr 7, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

critical

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;
    }

@ilblackdragon

Copy link
Copy Markdown
Member

Code Review

Overview

Fixes a SSE ordering race (#2079) where the web UI could observe the turn-closing Done status before the assistant response event, causing the response to be missed. The fix:

  • Removes two send_status("Done") calls from thread_ops.rs.
  • Adds a respond_then_done() helper in agent_loop.rs and routes all four outbound paths through it.
  • Adds a frontend safety net that calls loadHistory() 1.5s after a Done if no response was seen for the turn.
  • Adds a regression test (tests/e2e_response_order.rs) that asserts strict ordering on a captured event log.

What works well

  • Correct architectural move: terminal status now lives where outbound dispatch lives. thread_ops.rs no longer needs to know about channel lifecycle.
  • The new CapturedEvent enum on TestChannel is the right tool for testing ordering invariants — much cleaner than two parallel vectors.
  • Frontend cleanup is symmetric: _turnResponseReceived and _doneWithoutResponseTimer are reset on sendMessage, switchThread, and SSE onopen. No leaked timers across thread switches.
  • _turnResponseReceived = true is set in both the response and chunk handlers (app.js:758), so streaming responses won't trigger spurious history reloads.

Issues

1. respond_then_done swallows Done when respond fails (regression)

src/agent/agent_loop.rs:319

self.channels.respond(message, response).await?;   // ← early return on Err
if let Err(e) = self.channels.send_status(..., \"Done\", ...).await { ... }

If respond returns Err, the helper returns Err and Done is never emitted. The old code emitted Done unconditionally (before the response). The web UI's enableChatInput() only fires on Done, so a transient respond failure will now leave the input disabled forever — and the new safety net does NOT cover this case (it triggers only when Done arrives without response, not when neither arrives).

Suggestion: emit Done regardless of respond outcome:

let respond_result = self.channels.respond(message, response).await;
let _ = self.channels.send_status(&message.channel,
    StatusUpdate::Status(\"Done\".into()), &message.metadata).await;
respond_result

This also lets you delete the duplicated inline send_status(\"Done\") blocks at agent_loop.rs:984-996, 1037-1049 (hook-blocked and empty-response paths) — they exist precisely because the helper doesn't guarantee Done.

2. Duplicated send Done boilerplate

The same 12-line send_status(\"Done\") + warn! block appears three times inline in agent_loop.rs (helper, hook-blocked path, empty-response path). After fixing #1 above, these collapse into a single send_done(&message) helper invoked from one place in the helper plus the empty/hook-blocked branches.

3. Test coverage gap

tests/e2e_response_order.rs only covers the happy Ok(Some(non_empty)) path. The bug class is "Done arrives in the wrong order" — but the patch touches five code paths that emit Done:

  • happy path (covered)
  • hook-blocked (Err(err) branch)
  • modified-by-hook (HookOutcome::Continue { Some })
  • empty response
  • error response (Err(e))
  • drain-loop intermediate response

A parameterized version of the test (or even one extra case for the empty-response branch) would prevent regressions on the inline Done emissions, which are the most fragile part of this PR.

4. Frontend: 1500 ms is a magic number

app.js:799 — setTimeout(..., 1500). Worth lifting to a named constant near the other SSE constants (e.g. STREAM_DEBOUNCE_MS) for greppability and to make the safety-net delay a single point of tuning.

5. _turnResponseReceived is global, not per-thread

If a background thread emits a response while the user is viewing a different thread, the early isCurrentThread(...) guard returns before _turnResponseReceived is set. That's actually correct, but the inverse — switching to a thread mid-turn — relies on switchThread resetting state. The current cleanup looks correct, but a comment noting "single-thread tracking is intentional; per-thread state isn't needed because background threads filter by isCurrentThread" would help future readers.

Minor / nits

  • agent_loop.rs:319 — doc comment is good; consider also noting that Done is only sent on successful respond (until issue Move whatsapp channel source to channels-src/ for consistency #1 above is fixed).
  • test_channel.rs:175 — try_lock().expect(\"captured_events lock contention\") follows the existing pattern but could panic under unusual test interleavings; matches local convention so acceptable.
  • The PR description mentions "broadcast lag" as a possible cause — worth a sentence in the commit/code comment about whether the underlying broadcast bus actually preserves FIFO ordering between respond and send_status.

Risk assessment

  • Backend correctness: Medium risk — the respond_then_done early-return on respond failure is a real behavior change that should be addressed before merging.
  • Frontend: Low risk — safety net is additive and well-guarded.
  • Test coverage: Adequate for the happy path; insufficient for the four other branches the patch touches.

Recommendation

Request changes — fix issue #1 (always emit Done, even when respond fails) and ideally collapse the duplicated boilerplate from #2. Adding one extra test case for the empty-response branch would close the most important coverage gap.

- 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>
@serrrfirat

Copy link
Copy Markdown
Collaborator Author

All five issues addressed in e23d47d:

  1. respond_then_done always emits Done — stores the respond result, emits Done unconditionally, then returns the result. Client always knows the turn ended, even on delivery failure.
  2. Deduplicated boilerplate — extracted send_done() helper method; replaced both inline 12-line blocks (hook-blocked + empty-response paths) with single-line calls.
  3. Test coverage — added done_emitted_for_empty_response test covering the empty-response branch: asserts Done is emitted, no Response event is produced, and Done appears in captured events.
  4. Magic number — lifted 1500 to const DONE_WITHOUT_RESPONSE_TIMEOUT_MS = 1500 near the other SSE constants.
  5. Comment — added note above _turnResponseReceived explaining single-thread tracking is intentional since background thread events are filtered by isCurrentThread.

@ilblackdragon

Copy link
Copy Markdown
Member

Code Review

Overview

Fixes #2079 by reordering the SSE event stream so the assistant response event is emitted before the terminal Done status. The fix is implemented at three levels:

  1. Backend: Removes premature Done emissions from thread_ops.rs and introduces respond_then_done() / send_done() helpers in agent_loop.rs that fire Done after respond() returns.
  2. Frontend safety net: Tracks per-turn _turnResponseReceived and calls loadHistory() 1.5s after a Done arrives without a preceding response.
  3. Test: New e2e_response_order.rs regression test plus CapturedEvent ordering instrumentation in TestChannel.

The architectural direction is right — moving Done emission to a single, well-defined point above respond() is much cleaner than scattering it across thread_ops.rs.


Issues

Regression: Done now fires after Awaiting approval

agent_loop.rs::handle_message translates SubmissionResult::NeedApproval into Ok(Some(String::new())) (src/agent/agent_loop.rs:1654-1659). After this PR, the run-loop's empty arm at src/agent/agent_loop.rs:984-992 calls self.send_done(&message).await for every empty response — including the approval-pending case.

End-to-end consequence in the web UI:

  1. Backend emits approval_needed → frontend renders approval prompt (app.js:778).
  2. Backend emits status: Done (new behavior).
  3. Frontend _turnResponseReceived is still false because no response event arrived.
  4. The 1500 ms safety net at app.js:798-808 fires and calls loadHistory() underneath the live approval prompt.

The thread is in ThreadState::AwaitingApproval, not Completed — emitting Done is semantically wrong and races against the new safety net the same PR introduces. Two practical fixes:

  • Preferred: Distinguish "empty response" from "approval pending" in handle_message's return — e.g. introduce a Pending variant or return a small enum so the run loop knows not to call send_done on the approval path.
  • Quick patch: Before calling send_done in the empty arm, check the thread state and skip when it's AwaitingApproval. (Requires reading session state, which is racy but probably fine for a defensive guard.)

The author's comment ("Empty response, nothing to send (e.g. approval handled via send_status). Still send Done so the client knows the turn is complete.") suggests this case was considered, but the conclusion conflicts with the new frontend safety net the same PR adds.

Test gap for the approval path

e2e_response_order.rs covers the happy path and the empty-response path, but not the NeedApproval path. Whichever resolution you pick above, please add a test that runs an approval-requiring tool and asserts the captured-event sequence after ApprovalNeeded (no Done, or Done with whatever the desired behavior is).

Drain-loop intermediate response is silent on Done

In the queue-drain branch at src/agent/agent_loop.rs:1466-1475, the intermediate respond() between turns intentionally does not call respond_then_done. That's defensible (the conversation isn't done yet), but it's the only respond() call left in agent_loop.rs that doesn't pair with send_done. Worth a one-line comment so the next person doesn't "fix" it.


Smaller observations

  • respond_then_done always emits Done even when respond() fails (src/agent/agent_loop.rs:316-340). The doc-comment says this is intentional; agreed, but consider whether the client also wants an error SSE event in that case (out of scope for this PR).
  • send_done uses tracing::warn! for failed sends. Per CLAUDE.md's logging rules, warn! corrupts the REPL/TUI — but Done only matters for SSE-style channels and the warn is for a real error, so this is fine.
  • The _turnResponseReceived reset is plumbed into connectSSE, sendMessage, switchThread, and the Done handler itself — nice coverage. Worth verifying that nothing else (e.g. loadHistory finishing, manual SSE reconnect via Last-Event-ID) could leave the flag stale across turns.
  • CapturedEvent and wait_for_done in tests/support/test_channel.rs are clean reusable additions. The exponential backoff in wait_for_done (50 ms → 500 ms cap) is reasonable.
  • DONE_WITHOUT_RESPONSE_TIMEOUT_MS = 1500 is a tunable magic number — fine as a constant, but worth a comment on how it was chosen relative to typical broadcast lag.
  • The new respond_then_done and send_done helpers are well-documented and small enough to keep colocated in agent_loop.rs. No notes on style.

Recommendation

Request changes on the Done-after-Awaiting approval regression and missing approval-path test. The other observations are minor and can be addressed in the same revision or follow-up.

The core architectural change is correct and the regression test is good — once the approval path is handled, this is a clean fix.

…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>
@github-actions github-actions Bot added size: XL 500+ changed lines and removed size: L 200-499 changed lines labels Apr 7, 2026
@ilblackdragon

Copy link
Copy Markdown
Member

Pushed 7d3f804 to address the Done-after-Awaiting approval regression I flagged above.

What changed

Introduced a HandleOutcome enum so Agent::handle_message can distinguish "no response, turn complete" from "no response, turn paused":

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 Ok(Some("")), so SubmissionResult::NeedApproval returning an empty string was indistinguishable from a routine consuming a message. With the new enum, NeedApproval maps to HandleOutcome::Pending and the run loop's empty arm only fires send_done() for NoResponse.

Bridge handlers (engine v2) still return Result<Option<String>, Error> and are wrapped with HandleOutcome::from_legacy. Their approval flows emit non-empty descriptive text via insert_and_notify_pending_gate, so they always map to Respond — the legacy v1 process_user_input path is the only one that ever needs Pending.

Tests

Added a regression test no_done_emitted_while_awaiting_approval that drives a v1 Always-approval probe tool and asserts:

  1. ApprovalNeeded status is captured
  2. No Response event is emitted (the tool never executed)
  3. No Done status is emitted — the bug guard
  4. Exactly one ApprovalNeeded event is captured

I also had to repair the pre-existing done_emitted_for_empty_response test, which was failing on the PR baseline. The dispatcher in src/llm/reasoning.rs substitutes empty LLM responses with "I'm not sure how to respond to that.", so a truly empty response never reaches the run loop — the test was asserting an unreachable condition. Renamed it to done_emitted_after_empty_response_fallback and updated it to assert the actual ordering invariant (fallback response → Done).

Verification

  • cargo clippy --all --benches --tests --examples --all-features — zero warnings
  • cargo fmt --check — clean
  • cargo test -p ironclaw --test e2e_response_order — 3/3 pass
  • cargo test -p ironclaw --test e2e_engine_v2 — 9/9 pass (engine v2 path unaffected)
  • cargo test -p ironclaw --lib — 4266/4266 pass

The other smaller observations from my review (drain-loop intermediate respond() comment, doc-comment on the timeout constant) are still open if you'd like to address them in this PR or a follow-up.

@ironclaw-ci ironclaw-ci Bot mentioned this pull request Apr 10, 2026
JZKK720 pushed a commit to JZKK720/ironclaw that referenced this pull request Apr 13, 2026
…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)
@ironclaw-ci ironclaw-ci Bot mentioned this pull request Apr 18, 2026
theredspoon pushed a commit to theredspoon/ironclaw that referenced this pull request Jun 21, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor: core 20+ merged PRs risk: medium Business logic, config, or moderate-risk modules scope: agent Agent core (agent loop, router, scheduler) scope: channel/web Web gateway channel size: XL 500+ changed lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix: web UI messages stuck until refresh — SSE event ordering bug

2 participants