Skip to content

fix: keep the working indicator alive through the empty-first-delta gap#159

Merged
Zeus-Deus merged 1 commit into
mainfrom
fix/155-working-indicator-empty-delta
Jul 10, 2026
Merged

fix: keep the working indicator alive through the empty-first-delta gap#159
Zeus-Deus merged 1 commit into
mainfrom
fix/155-working-indicator-empty-delta

Conversation

@Zeus-Deus

@Zeus-Deus Zeus-Deus commented Jul 10, 2026

Copy link
Copy Markdown
Owner

Fixes #155

Problem

While an agent run is still in progress, the transcript could read finished and empty: the amber "Working" activity card settled to the green summary, followed by a blank assistant block, with no working indicator anywhere — until prose finally streamed in seconds later. Trigger: the partial-message stream opens a new text content block with an empty first text delta, which materialized a blank assistant_message that (a) settled the live activity run, (b) rendered as nothing, and (c) suppressed the tail marker.

Fix — 4 layers (1 and 2 each independently kill the main symptom)

  1. Reducer (src/lib/agent-chat/reducer.ts): appendTextDelta drops an empty/whitespace-only delta instead of creating an empty assistant_message; an empty delta merging into an existing streaming tail is reference-stable. An empty assistant_text completion with nothing to seal no longer lands a blank settled row. The guard is deterministic, so hydrate/replay keeps identical item counts vs live streaming.
  2. Marker gate (src/lib/agent-chat/thinking.ts): a streaming-but-empty assistant tail now keeps the tail marker up (defense in depth for providers that still land one).
  3. StreamingMarker (src/components/chat/StreamingMarker.tsx): live elapsed-time suffix ("Working… · 40s"). deriveTurnStartedAt anchors to the earliest reducer-stamped started_at after the last non-queued user prompt; the 1s tick writes textContent directly so per-second updates never re-render the transcript. Suffix omitted when no start is derivable (post-send gap, old hydrated transcripts).
  4. "Worked through 0 steps" (src/components/chat/activity-steps.ts): the settled-summary fallback now counts other-family tools (Task/agent spawns, MCP tools), so it never claims fewer steps than the header meta.

Invariants preserved: no double indication (marker still suppressed while the tail is a live working Activity block), buildTranscriptSlots untouched and slot keys stable, reducer stays pure with injectable Clock, fix is provider-agnostic at reducer/slot level.

Verification

  • tsc --noEmit clean; 2396/2396 tests pass (new coverage in reducer.test.ts, thinking.test.ts, activity-steps.test.ts, new StreamingMarker.test.tsx); cargo check clean (no Rust changes).
  • Visual (dev mock, headless browser): streamReply gained { emptyGapTicks } to reproduce the exact stream shape (empty first delta + silence before prose). During the gap the tail stays the amber "Working · 6 done · 0 running" card — never a settled header + blank block — then settles normally once prose lands. The tail marker renders as spinner + shimmer label + mono elapsed suffix.

Docs

  • docs/features/agent-chat.md: empty-delta drop, marker elapsed suffix, other-family counting.
  • docs/features/dev-mock-runtime.md: emptyGapTicks knob.

Screenshots

Captured against the dev mock (streamReply({ intervalMs: 400, emptyGapTicks: 22 })) in a headless browser.

Mid-gap — all 5 tools done, empty first delta already received. The tail stays the amber "Working · 6 done · 0 running" card. Before this fix it settled to the green summary followed by a blank assistant block:

Working card stays amber through the empty-delta gap

After prose lands the run settles normally ("Ran commands and inspected the code · 6 steps · 5s") with the streamed text below:

Settled card with streamed prose

The upgraded tail marker — spinner + shimmer label + mono elapsed suffix ("Working… · 1m 49s"; the large value is a mock artifact — on-demand mock streams have no fresh user prompt, so the anchor is the hydrated transcript's timestamps):

Streaming marker with elapsed time

While a turn was still running, an empty first text delta from the
partial-message stream materialized a blank assistant_message. That
non-step item settled the live amber Working card to the green summary,
rendered as nothing, and suppressed the tail marker — so the transcript
read finished-and-empty until prose finally streamed in.

Four layers, the first two each independently fixing the main symptom:

- reducer: drop empty/whitespace-only text deltas instead of creating an
  empty assistant_message (deterministic, so hydrate/replay stays in
  sync); an empty assistant_text completion no longer lands a blank row
- thinking: keep the tail marker up for a streaming-but-empty assistant
  tail (defense in depth for providers that still land one)
- StreamingMarker: live elapsed-time suffix ("Working… · 40s") ticked by
  writing textContent directly, so per-second updates never re-render
  the transcript; suffix omitted when no turn start is derivable
- activity-steps: count other-family tools (Task/agent spawns, MCP) in
  the settled-summary fallback so it never claims "Worked through 0
  steps" while the meta says otherwise

Dev mock gains streamReply({ emptyGapTicks }) to reproduce the stream
shape for visual verification.

Fixes #155
@Zeus-Deus Zeus-Deus merged commit 464591e into main Jul 10, 2026
4 checks passed
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.

Agent chat: transcript looks finished/empty mid-run — empty assistant message suppresses the working indicator

1 participant