fix: keep the working indicator alive through the empty-first-delta gap#159
Merged
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_messagethat (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)
src/lib/agent-chat/reducer.ts):appendTextDeltadrops an empty/whitespace-only delta instead of creating an emptyassistant_message; an empty delta merging into an existing streaming tail is reference-stable. An emptyassistant_textcompletion 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.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).src/components/chat/StreamingMarker.tsx): live elapsed-time suffix ("Working… · 40s").deriveTurnStartedAtanchors to the earliest reducer-stampedstarted_atafter the last non-queued user prompt; the 1s tick writestextContentdirectly so per-second updates never re-render the transcript. Suffix omitted when no start is derivable (post-send gap, old hydrated transcripts).src/components/chat/activity-steps.ts): the settled-summary fallback now countsother-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),
buildTranscriptSlotsuntouched and slot keys stable, reducer stays pure with injectable Clock, fix is provider-agnostic at reducer/slot level.Verification
tsc --noEmitclean; 2396/2396 tests pass (new coverage inreducer.test.ts,thinking.test.ts,activity-steps.test.ts, newStreamingMarker.test.tsx);cargo checkclean (no Rust changes).streamReplygained{ 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:emptyGapTicksknob.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:
After prose lands the run settles normally ("Ran commands and inspected the code · 6 steps · 5s") with the streamed text below:
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):