Skip to content

Tool blocks stuck in 'Running...' when user sends during streaming response #156

@dimakis

Description

@dimakis

Bug

When a user sends a message while the assistant is still streaming a response (mid-tool-call), tool blocks from the previous turn can get stuck showing "Running..." permanently. The assistant's response also gets visually split — part appears before the user message, part after.

Reproduction

  1. Start a session that triggers multiple sequential tool calls (e.g., writing 3 files)
  2. While the assistant is still mid-response (between tool calls), send a new message
  3. Observe: the last tool call(s) from the previous turn show "Running..." and never resolve

Screenshot

The third Write tool pill shows "Running..." even though the file write completed server-side. The user's message appears between the second and third Write calls, splitting the assistant turn.

Root Cause Analysis

MESSAGE_START force-finalizes in-flight messages

In useChatMessages.ts, the MESSAGE_START reducer case immediately finalizes the current streaming message:

case 'MESSAGE_START': {
  const base = state.current
    ? { ...state, messages: [...state.messages, finishCurrent(state.current)] }
    : state;
  // ...
}

When the user sends a message during streaming:

  1. Server receives the user's new message (interrupt or queue)
  2. Server emits MESSAGE_START for the new assistant response
  3. forceFlushPendingMessage closes open blocks server-side
  4. But the frontend reducer processes MESSAGE_START and finalizes the old current before the BLOCK_END events for the prior turn arrive
  5. Late-arriving BLOCK_END events are silently dropped because the block no longer exists in state.current

BLOCK_END dropped when block not in current

case 'BLOCK_END': {
  if (!state.current) return state;
  const block = state.current.blocks.get(action.blockId);
  if (!block) return state;  // ← silently dropped
  // ...
}

The block was moved to state.messages (finalized) but BLOCK_END still references the old blockId. Since the finalized message's blocks are a snapshot, the done: false flag is never updated.

Suggested Fix

Two complementary fixes:

1. BLOCK_END should check finalized messages too

When BLOCK_END arrives and the block isn't in state.current, search state.messages for the blockId and update it there:

case 'BLOCK_END': {
  if (state.current) {
    const block = state.current.blocks.get(action.blockId);
    if (block) {
      // ... existing logic (update current)
    }
  }
  // Fallback: check recently finalized messages
  const msgIdx = state.messages.findLastIndex(m =>
    m.blocks.some(b => b.blockId === action.blockId)
  );
  if (msgIdx >= 0) {
    // Update the block in the finalized message
  }
  return state;
}

2. finishCurrent should mark all blocks as done

When force-finalizing due to a new MESSAGE_START, mark any done: false blocks as done: true:

function finishCurrent(current: StreamingMessage): FinishedMessage {
  const blocks = Array.from(current.blockOrder, (id) => {
    const b = current.blocks.get(id)!;
    return { ...b, done: true };  // ← force done on finalize
  });
  // ...
}

Impact

  • Tool pills stuck in "Running..." state permanently (UX)
  • Assistant turns visually split across user messages (confusing)
  • Affects any session where the user sends during mid-stream

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions