Skip to content

fix(stream): report upstream interruption instead of faking success - #1

Merged
lzhs1995 merged 2 commits into
feat/opus5-upstream-driven-models-20260725from
feat/stream-interrupt-no-fake-success-20260726
Jul 26, 2026
Merged

fix(stream): report upstream interruption instead of faking success#1
lzhs1995 merged 2 commits into
feat/opus5-upstream-driven-models-20260725from
feat/stream-interrupt-no-fake-success-20260726

Conversation

@lzhs1995

Copy link
Copy Markdown
Owner

Problem

When the upstream stream breaks mid-response, both SSE paths called the normal
completion routine:

  • streaming (create_sse_stream, Some(Err(e))) -> ctx.generate_final_events()
  • buffered (create_buffered_sse_stream, Some(Err(e))) -> ctx.finish_and_get_all_events()

Both emit message_delta with stop_reason: end_turn plus message_stop. The trace
was already recorded as interrupted, but the client saw a fully successful turn.

Consequence for Claude Code CLI: a truncated response is treated as a finished turn,
so the client does not retry. Half-written tool arguments are silently dropped and
multi-step tasks "complete" in the middle.

Fix

New interruption path that closes any open content blocks and emits an Anthropic
error event with type: "overloaded_error" (a retryable type), and never
emits message_delta / message_stop:

  • SseStateManager::generate_interrupted_events() - closes open blocks, sets the
    message_delta_sent / message_ended idempotency flags so no later path can
    append a normal completion.
  • StreamContext::generate_interrupted_events(message) - the above plus the error
    event. Deliberately performs no flush of thinking / invoke-sniff / XML-filter
    residue and no tool-JSON accumulator finish: that residue is incomplete data, and
    flushing it would dress up half content as valid content.
  • BufferedStreamContext::interrupt_and_get_all_events(message) - emits the buffered
    events (useful for diagnosis) with the same error tail and the same usage
    correction on message_start as the success path.

Err only ever comes from a transport failure; normal completion is None (EOF),
which still goes through the unchanged success path.

Tests

6 new tests in src/anthropic/stream.rs:

test locks
interrupted_stream_emits_error_and_no_normal_completion exactly one overloaded_error, it is last, open blocks closed, no message_delta/message_stop
interrupted_stream_cannot_regress_to_fake_success a later generate_final_events() cannot append a normal completion
interrupted_stream_does_not_flush_truncated_tool_json half-written tool args never surface as a tool_use block
buffered_interrupted_stream_emits_error_and_no_normal_completion same contract in buffered mode, buffered events retained
buffered_interrupted_stream_cannot_regress_to_fake_success same anti-regression for buffered mode
normal_completion_still_emits_message_stop reverse control: success path unchanged (end_turn + message_stop, no error)

Full suite: cargo test --locked --all-targets --no-default-features -> 577 passed, 0 failed.
rustfmt --edition 2024 --check clean on the CI gate file list.

CI

  • Candidate workflow trigger extended to this branch.
  • src/anthropic/stream.rs added to the rustfmt gate (it is now a candidate file).
  • Clippy stays diagnostic-only; the 179 findings on this tree are the pre-existing
    upstream baseline and none are in the two files touched here.

Attribution

Behaviour reference: the overloaded_error-on-interruption approach in
dmcyfelix/kiro2cc-proxy@50bd8d0. No code copied.

Not in scope

stopReason / metadataEvent parsing (Quorinex/Kiro-Go#141) was dropped: raw
EventStream captures against our own upstream endpoint show zero stopReason
fields and no metadataEvent frames, so there is nothing to parse.

上游断流时,流式与缓冲两条路径都走正常收尾(generate_final_events /
finish_and_get_all_events),客户端因此收到 `message_delta`
(stop_reason=end_turn) + `message_stop`——一次失败被伪装成一次完整成功的
回合。Claude Code CLI 据此认为本轮已完成:不重试、继续往下走,于是半截的
工具调用参数被丢弃、多步任务在中途"正常"结束。这是 CLI 侧观察到的
「工具调用不严格执行」的一个真因。

改为在断流时只关闭已打开的内容块,并下发 Anthropic `overloaded_error`
事件,客户端 SDK / CLI 对该类型走重试路径。断流收尾时不做任何残留 flush
(thinking / invoke 嗅探 / XML 过滤器 / 工具 JSON 累积器),那些残留本身
就是不完整数据,flush 出去只会把半截内容伪装成有效内容。

同时置位 message_delta_sent / message_ended 幂等位,使断流之后任何路径
再调 generate_final_events 都无法补出正常收尾(防回归)。

trace 侧行为不变:仍记 interrupted + STREAM_INTERRUPTED,用量仍记 error。

新增 6 个测试锁定契约:流式 / 缓冲各自「必须报错且无正常收尾」与「事后
不可回退成伪造成功」、半截工具 JSON 不得作为 tool_use 发出,以及反向对照
「正常收尾不受影响,仍发 message_delta + message_stop」。

CI:把 stream.rs 纳入候选分支的 rustfmt 闸门,并把本分支加入触发列表。
Copilot AI review requested due to automatic review settings July 25, 2026 21:56

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR changes the Anthropic SSE streaming implementation so that an upstream transport interruption is surfaced to clients as an explicit Anthropic error event (retryable overloaded_error) instead of emitting a normal successful completion (message_delta with stop_reason: end_turn + message_stop). This prevents clients (notably Claude Code CLI) from treating truncated responses as completed turns and silently continuing without retrying.

Changes:

  • Add an explicit “interrupted” termination path that closes open content blocks, marks completion idempotency flags, and emits a terminal error event (no message_delta / message_stop).
  • Wire the new interruption path into both streaming and buffered SSE handlers for Some(Err(_)) upstream read failures.
  • Extend the document-candidate workflow trigger and rustfmt gate list to include the touched stream file.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
src/anthropic/stream.rs Introduces interrupted termination event generation and adds regression tests ensuring interruptions don’t fake success.
src/anthropic/handlers.rs Routes upstream read errors to the new interrupted termination path in both streaming and buffered modes.
.github/workflows/kiro-document-candidate.yml Updates branch trigger list and adds stream.rs to the rustfmt gate file list.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/anthropic/stream.rs
Comment on lines +2399 to +2412
pub fn generate_interrupted_events(&mut self, message: &str) -> Vec<SseEvent> {
let mut events = self.state_manager.generate_interrupted_events();
events.push(SseEvent::new(
"error",
json!({
"type": "error",
"error": {
"type": "overloaded_error",
"message": message
}
}),
));
events
}
conversationId 已经从 metadata.user_id 的 session UUID 派生,但
agentContinuationId 仍是每请求 Uuid::new_v4()。上游因此把同一个客户端会话
的每一轮当成一条全新的 agent 任务线,多步任务(工具调用链、长任务续写)在
上游侧没有连续性。

改为用同一个会话锚点派生 continuation(domain 前缀 + SHA-256,保持与
conversationId 一一对应但互不相等)。拿不到锚点时仍退回随机值,保持旧行为
——宁可丢连续性,不可乱绑任务线。

Tests: 585 passed (新增 5 个派生级 + 3 个端到端)
@lzhs1995
lzhs1995 merged commit 4226290 into feat/opus5-upstream-driven-models-20260725 Jul 26, 2026
2 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.

2 participants