refactor(cli): let the emitter report the output tail it leaves behind - #3304
Open
nankingjing wants to merge 5 commits into
Open
nankingjing wants to merge 5 commits into
nankingjing wants to merge 5 commits into
Conversation
Two places believed they owned terminal output: `consume_stream` streams assistant deltas live while `emit_output = true`, and `run_turn` decided whether to emit a separating newline before `spinner.finish` by guessing from `final_assistant_text(&summary)`. That guess agrees with reality only because every *other* writer happens to terminate its output with a newline — tool results go through `stream_markdown` (which appends one), thinking summaries are written with a trailing newline, and tool-call headers use `writeln!` — so the assistant text path is the only one that can leave a partial line. That is a property of the current renderers rather than of the streaming protocol, and it is not enforced anywhere. `TerminalTail` is state the emitter records into as it writes: both live writers (`AnthropicRuntimeClient::consume_stream` and the tool-result blocks in `CliToolExecutor::execute`) now push bytes through a `TailTrackingWriter` that reports the last byte written, and the caller that ends a turn asks where the emitter stopped instead of inferring it. The heuristic also left the failure arm unguarded. A turn that streamed text and then errored called `spinner.fail` on a partial line, which the line clear then erased; that arm now carries the same guard. Also gate the animated `spinner.tick` frame on `is_terminal()`. That frame is pure cursor control (save, rewrite line, restore) and emits no newline, so down a pipe it leaves an unterminated banner that the first streamed delta then continues on the same physical line.
…paths Adds a `context_window_retry` mock scenario whose first request to `/v1/messages` is refused with a 400 carrying the `context_window` marker and whose retry streams the answer, so the auto-compact recovery path runs end to end. The attempt counter is keyed per endpoint so the client's `count_tokens` preflight cannot consume the first attempt. The 400 is deliberate: it sits outside the client's retryable status set, so the client surfaces it and runs recovery rather than silently re-sending. Two assertions, both from review: the response body reaches stdout exactly once on the plain text path, and on the auto-compact retry path it lands on its own line exactly once alongside the `Done (after auto-compact)` marker. Removing the retry-site tail guard makes the second test fail, which is what pins it — the retried body streams with no trailing newline, so `spinner.finish` would clear it.
Adds a `stream_then_error` mock scenario that streams an answer and then dies, so a turn can end with text already on the terminal, and asserts that text keeps its own line when the failure banner is printed. The failure is delivered as a truncated trailing frame rather than a complete one. The mock writes the whole response in a single `write_all`, so a complete trailing frame shares a read chunk with the deltas -- and `SseParser::push` returns `Err` from inside its frame loop, discarding the events it had already parsed from that chunk. The client would then never render the text, and the test would be pinned to that parser behaviour instead of to the terminal state it is meant to cover. A truncated tail stays in the parser buffer, so `push` returns the deltas normally and the failure only surfaces from `finish()`. Removing the failure-arm tail guard makes this fail: stdout becomes "partial response before the stream failed" immediately followed by ESC[1G ESC[2K -- the banner's move-to-column-0 and clear-line, which erases the streamed text instead of printing underneath it.
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.
Stacked on #3273. This branch is built on
971d350, the head of #3273(
fix-duplicate-assistant-text-3258), so the first two commits in the diff arethat PR's. #3273 should land first; only
5c3a604,7116ddcandb6c140bare new here (498 insertions / 26 deletions across three files).
This is the follow-up asked for in review on #3273:
The problem
Two places believed they owned terminal output.
AnthropicRuntimeClient::consume_streamstreams assistant deltas live whenever
emit_outputis set, whilerun_turndecided whether to emit a separating newline before
spinner.finishby guessingfrom
final_assistant_text(&summary).That guess agrees with reality only because every other writer happens to
terminate its output with a newline — tool results go through
stream_markdown(which appends one), thinking summaries are written with a trailing newline, and
tool-call headers use
writeln!— so the assistant text path is the only onethat can leave a partial line. That is a property of the current renderers, not
of the streaming protocol, and nothing enforces it.
The guess also left the failure arm unguarded: a turn that streamed text and
then errored called
spinner.failon a partial line, which its line clear thenerased.
The change
TerminalTailis state the emitter records into as it writes. Both live writers— the deltas in
consume_streamand the tool-result blocks inCliToolExecutor::execute— now push their bytes through aTailTrackingWriterthat reports the last byte written, and the caller that ends a turn asks where
the emitter stopped instead of inferring it. All three turn-ending sites (the
success arm, the failure arm, and the auto-compact retry) read that one piece of
state.
Two smaller things ride along because they are the same boundary:
spinner.tickframe is now gated onis_terminal(). That frameis pure cursor control (save, rewrite line, restore) and emits no newline, so
down a pipe it left an unterminated banner that the first streamed delta then
continued on the same physical line.
run_prompt_*call sites passemit_output: falseand a detachedtail, since they capture output rather than render it.
What is and isn't a behaviour change
Honestly: the success and retry guards produce the same output as the old
heuristic today, because of the newline property above. The mechanism is
better — it measures instead of inferring, and it cannot go stale if a renderer
changes — but it is not observable through those two paths.
The observable change is the failure arm, which previously had no guard at
all. That is what the third test covers.
Tests
rust/crates/rusty-claude-cli/tests/compact_output.rs, against a mock service:text_prompt_mode_emits_the_response_body_exactly_once— the response bodyreaches stdout exactly once on the non-compact text path.
text_prompt_mode_emits_retried_body_once_after_auto_compact— on theauto-compact retry path the body lands on its own line exactly once, alongside
the
Done (after auto-compact)marker, and both attempts reached/v1/messages.text_prompt_mode_keeps_streamed_text_when_the_turn_fails— a turn thatstreams text and then fails keeps that text on its own line above the failure
banner.
Each was falsified by removing the guard it covers, not just observed passing.
For the failure arm, stdout becomes the streamed text immediately followed by
the banner's move-to-column-0 and clear-line:
i.e. the banner erases the answer instead of printing underneath it. The retry
case fails the same way with the guard removed.
Two things you should know before reviewing
I did not implement the review comment's literal
emit_output = falsesuggestion.
emit_outputis one flag doing two jobs: it gates whetherdeltas stream live, and it also gates whether tool result blocks are
echoed by
CliToolExecutor. Setting it false on the text path would silencetool-result rendering in the REPL, which is a regression, not a fix. The
shared
TerminalTailis what lets both writers keep rendering while thecaller still learns what they left behind.
While building the failure-arm test I hit a real client defect that this PR
does not fix.
SseParser::pushreturnsErrfrom inside its frame loop:so a frame that fails to deserialize discards every event already parsed from
the same read chunk. On the wire that means an unknown event type (or a
malformed frame) sharing a chunk with text deltas silently swallows the answer
the user was watching. The mock scenario here is written to avoid that path —
it truncates its failing frame so it lands in the parser buffer and detonates
in
finish(), after the deltas are delivered — precisely so this test pins theterminal state rather than that parser behaviour. The parser fix is a separate
change in a separate crate and is not in this diff.
Verification
cargo fmt --checkclean;cargo clippyclean formock-anthropic-serviceand for the
compact_outputtarget.reproduce at
971d350; the sets match exactly (17 at baseline = 11 + the 6HTTP-backed
compact_outputtests that only pass on this Windows box with alocal
SystemRoot/windir/SystemDrivere-export, which is not in the diff).fork PRs on this repo sit behind
action_required, so nothing has executed.For the record, the review comment quoted above is from
@1716775457damn, whoseauthor_associationisNONE. It shaped this change, but it is not maintainersign-off.