Skip to content

fix(web): isSending honors activeTurnId so thinking indicator survives stale terminal events#32057

Merged
dvargasfuertes merged 2 commits into
mainfrom
apollo/turn-is-sending-active-turn
May 26, 2026
Merged

fix(web): isSending honors activeTurnId so thinking indicator survives stale terminal events#32057
dvargasfuertes merged 2 commits into
mainfrom
apollo/turn-is-sending-active-turn

Conversation

@vellum-apollo-bot
Copy link
Copy Markdown
Contributor

When a stale message_complete (or assistant_activity_state{phase:idle}) from a previous turn lands during the await sendMessageViaStream(...) window inside sendMessage, completeTurn() resets phase: idle and clears activeTurnId. The subsequent acceptSend(turnId) restores activeTurnId but does not touch phase or lastTerminalReason, leaving the turn store in an illegal but observable shape:

phase: "idle"
activeTurnId: "turn-NEW"
lastTerminalReason: "complete"

The old phase-only isSending predicate returned false here, so shouldShowThinkingIndicator failed on its first AND-clause and the thinking indicator stayed hidden until the first assistant delta arrived. _vellumDebug.chat.thinkingIndicator() reported failingConditions: ["notSendingAndNotRestoredProcessing"] in exactly this shape in the wild.

Fix

Treat activeTurnId !== null as authoritative for in-flight state. Keep the existing phase checks so the post-complete "queued waiting" state (phase: "queued", activeTurnId: null, pendingQueuedCount > 0) still reports as sending.

export function isSending(state: TurnState): boolean {
  return (
    state.activeTurnId !== null ||
    state.phase === "queued" ||
    state.phase === "thinking" ||
    state.phase === "streaming" ||
    state.phase === "awaiting_user_input"
  );
}

Once the first assistant delta arrives, onTextDelta transitions phase → "streaming". The indicator continues to render through the rest of the turn from the streaming/thinking phases as before.

Why not fix the stale-terminal race itself?

The deeper fix is to gate handleMessageComplete / handleAssistantActivityState on a turn-id match (the daemon already carries requestId on activity-state events; message-complete would need to grow one). That requires daemon coordination and is a separate concern. This PR fixes the symptom at the predicate that all consumers read from.

Tests

  • turn-store.test.ts — three new cases pinning the predicate to the actual invariant: returns true for the illegal shape, false for terminal idle, true for queued-waiting.
  • use-message-reconciliation.test.tsx — updated does NOT call onPollReconciled when turn is already idle to match its name (genuine terminal idle has activeTurnId: null). The illegal-state recovery is already covered by the dedicated "stuck sending" rescue tests above it.
  • 268 tests pass across the touched areas.

…s stale terminal events

When a stale 'message_complete' (or 'assistant_activity_state{phase:idle}')
from a previous turn lands during the await window inside 'sendMessage',
'completeTurn' resets phase to idle and clears activeTurnId. The
subsequent 'acceptSend(turnId)' restores activeTurnId but does not touch
phase or lastTerminalReason, leaving the store in an illegal but
observable shape:

  phase: 'idle'
  activeTurnId: 'turn-NEW'
  lastTerminalReason: 'complete'

The old phase-only 'isSending' predicate returned false here, so
'shouldShowThinkingIndicator' failed on its first AND-clause and the
indicator stayed hidden until the first assistant delta arrived.

Treat 'activeTurnId !== null' as authoritative for in-flight state and
keep the phase checks so the post-complete 'queued waiting' state
(phase='queued', activeTurnId=null, pendingQueuedCount>0) still reports
as sending.

Adds regression tests for the illegal shape and the terminal-idle
counterpart, and updates the misaligned 'does NOT call onPollReconciled
when turn is already idle' reconciliation test to match its intent
(genuine terminal idle = activeTurnId null). The illegal-state stuckness
recovery is already exercised by the existing 'stuck sending' rescue
tests above it.
Comment on lines 111 to 114
/** True when we are waiting for the first assistant text delta. */
export function isThinking(state: TurnState): boolean {
return state.phase === "thinking";
}
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.

Check this out. Based on the comment, we expected the state.phase to be thinking, instead of adding this active turn id null check, which I think would always render the thinking indicator during the lifetime of the assistant response

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch — you're right. The fix belongs in the state machine, not the predicate.

Reverted isSending to phase-only. Moved the recovery into acceptSend (and the matching USER_SEND_ACCEPTED reducer case): when the POST resolves and we're entering the "waiting for first delta" window, restore phase: "thinking" + lastTerminalReason: null if a stale terminal landed between requestSend and acceptSend. Non-terminal phases (e.g. awaiting_user_input from a surface that's still active) are left alone.

This matches the isThinking doc comment exactly — phase IS thinking through that window, so all downstream selectors keep working unchanged.

Pushed as 24c7b2f.

Per Vargas's review: 'we expected state.phase to be thinking' (per
isThinking doc comment 'True when we are waiting for the first
assistant text delta'). Patching the predicate was the wrong layer —
the state machine itself should keep phase=thinking from acceptSend
until the first delta.

Revert isSending to phase-only. Make acceptSend / USER_SEND_ACCEPTED
restore (phase=thinking, lastTerminalReason=null) when phase was
clobbered to idle/errored by a stale terminal event during the POST
await. Non-terminal phases (e.g. awaiting_user_input from a surface)
are left alone.
@dvargasfuertes dvargasfuertes merged commit 0c1695a into main May 26, 2026
2 of 12 checks passed
@dvargasfuertes dvargasfuertes deleted the apollo/turn-is-sending-active-turn branch May 26, 2026 13:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant