Skip to content

refactor(cli): let the emitter report the output tail it leaves behind - #3304

Open
nankingjing wants to merge 5 commits into
ultraworkers:mainfrom
nankingjing:fix-stream-output-ownership
Open

nankingjing wants to merge 5 commits into
ultraworkers:mainfrom
nankingjing:fix-stream-output-ownership

Conversation

@nankingjing

Copy link
Copy Markdown

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 are
that PR's. #3273 should land first; only 5c3a604, 7116ddc and b6c140b
are new here
(498 insertions / 26 deletions across three files).

This is the follow-up asked for in review on #3273:

Agreed, a separate PR is the right call — moving the output-ownership boundary
deserves its own diff and its own test coverage rather than riding along on a
minimal fix. [...] For the follow-up, I'd specifically assert that non-compact
stdout contains the response body exactly once, and add a case on the
auto-compact retry path — that's where a naive version is most likely to drop
or duplicate a line.

The problem

Two places believed they owned terminal output. AnthropicRuntimeClient::consume_stream
streams assistant deltas live whenever emit_output is set, while 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, 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.fail on a partial line, which its line clear then
erased.

The change

TerminalTail is state the emitter records into as it writes. Both live writers
— the deltas in consume_stream and the tool-result blocks in
CliToolExecutor::execute — now push their 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. 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:

  • The animated spinner.tick frame is now gated on is_terminal(). That frame
    is 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.
  • The three run_prompt_* call sites pass emit_output: false and a detached
    tail, 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 body
    reaches stdout exactly once on the non-compact text path.
  • text_prompt_mode_emits_retried_body_once_after_auto_compact — on the
    auto-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 that
    streams 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:

"partial response before the stream failed\u{1b}[1G\u{1b}[2K\u{1b}[m✘ ❌ Request failed\n\u{1b}[0m"

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

  1. I did not implement the review comment's literal emit_output = false
    suggestion.
    emit_output is one flag doing two jobs: it gates whether
    deltas stream live, and it also gates whether tool result blocks are
    echoed by CliToolExecutor. Setting it false on the text path would silence
    tool-result rendering in the REPL, which is a regression, not a fix. The
    shared TerminalTail is what lets both writers keep rendering while the
    caller still learns what they left behind.

  2. While building the failure-arm test I hit a real client defect that this PR
    does not fix.
    SseParser::push returns Err from inside its frame loop:

    while let Some(frame) = self.next_frame() {
        if let Some(event) = self.parse_frame_with_context(&frame)? {
            events.push(event);
        }
    }

    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 the
    terminal 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 --check clean; cargo clippy clean for mock-anthropic-service
    and for the compact_output target.
  • Full suite run locally on the branch: the 11 failures are all pre-existing and
    reproduce at 971d350; the sets match exactly (17 at baseline = 11 + the 6
    HTTP-backed compact_output tests that only pass on this Windows box with a
    local SystemRoot/windir/SystemDrive re-export, which is not in the diff).
  • CI has not verified any of this. There are no check runs for this head —
    fork PRs on this repo sit behind action_required, so nothing has executed.

For the record, the review comment quoted above is from @1716775457damn, whose
author_association is NONE. It shaped this change, but it is not maintainer
sign-off.

nankingjing and others added 5 commits July 11, 2026 17:45
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.
@1716775457damn

Copy link
Copy Markdown

跟进得很干净:把 output-ownership 边界拆成独立 PR,并专门为 non-compact 场景补断言,比塞进 #3273 里容易 review 也容易回滚。等 #3273 合入后这个就能直接接上。

@1716775457damn

Copy link
Copy Markdown

顺带把 CI 状态也盯一下:#3304 叠在 971d350 上,而 971d350 目前 0 check runs / 0 workflow runs(#3273 里我提过同样问题)。合并前建议让 maintainer 批准 fork workflow,在 #3304 head 上真正跑一遍 CI,重点看新加的 non-compact 恰好一次与 auto-compact retry 两条断言;跑绿后再合 #3273 → 合 #3304,避免把未验证的构建带上 main。需要 triage run log 的话随时 ping 我。

@1716775457damn

Copy link
Copy Markdown

拆分后的 diff 职责很清晰:5c3a604 / 7116ddc / b6c140b 三处改动各管一件事,比塞在 #3273 里好 review。合并顺序建议严格按 #3273 → #3304,并且批准 fork workflow 后在 #3304 head 上跑一次完整 CI,重点盯 non-compact 恰好一次输出与 auto-compact retry 两条断言——尤其确认 emitter 上报的 output tail 在 compact 开关下语义一致。跑绿后我可以直接合并。

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.

2 participants