Repository navigation
streaming: a later message_delta that omits stop_reason resets it to None (container is guarded, the stop_* fields are not) #1940
Description
Summary
In accumulate_event's message_delta branch, stop_reason, stop_sequence and stop_details are assigned unconditionally from event.delta, while the neighbouring container assignment is guarded against None. All four fields are Optional[...] = None on Delta, so a message_delta that omits them resets a value an earlier delta had already set. The usage block immediately below is explicitly guarded for exactly this reason, and its comment states the rule.
Because both public accessors return that same accumulated object, the reset is visible to callers through MessageStream.current_message_snapshot and MessageStream.get_final_message().
Reproduction
Driven through the real parse path — RawMessageDeltaEvent.model_validate and the shipped accumulate_event, no mocks and no model_construct:
import anthropic.lib.streaming._messages as m
from anthropic.types import RawMessageStartEvent, RawMessageDeltaEvent
snap = m.accumulate_event(
event=RawMessageStartEvent.model_validate({
"type": "message_start",
"message": {"id": "msg_1", "type": "message", "role": "assistant",
"model": "claude-haiku-4-5", "content": [],
"stop_reason": None, "stop_sequence": None,
"usage": {"input_tokens": 10, "output_tokens": 0}},
}),
current_snapshot=None, json_bufs={},
)
# delta 1 reports the stop reason
snap = m.accumulate_event(
event=RawMessageDeltaEvent.model_validate({
"type": "message_delta",
"delta": {"stop_reason": "end_turn", "stop_sequence": None},
"usage": {"output_tokens": 5}}),
current_snapshot=snap, json_bufs={},
)
print(snap.stop_reason, snap.usage.output_tokens) # end_turn 5
# delta 2 carries usage only; Delta permits this, every field is Optional
snap = m.accumulate_event(
event=RawMessageDeltaEvent.model_validate({
"type": "message_delta", "delta": {}, "usage": {"output_tokens": 9}}),
current_snapshot=snap, json_bufs={},
)
print(snap.stop_reason, snap.usage.output_tokens) # None 9Output on anthropic==1.7.0, Python 3.11.15:
end_turn 5
None 9
output_tokens behaves as documented — a cumulative total that overwrites. stop_reason does not survive, and "end_turn" is gone from the final message rather than merely stale.
Where, and why it reads as unintended
src/anthropic/lib/streaming/_messages.py:515-519 on main at 0af01906 (version 1.7.0, which is what I ran):
current_snapshot.stop_reason = event.delta.stop_reason
current_snapshot.stop_sequence = event.delta.stop_sequence
current_snapshot.stop_details = event.delta.stop_details
if event.delta.container is not None:
current_snapshot.container = event.delta.containersrc/anthropic/types/raw_message_delta_event.py declares all four as optional with None defaults:
class Delta(BaseModel):
container: Optional[Container] = None
stop_details: Optional[RefusalStopDetails] = None
stop_reason: Optional[StopReason] = None
stop_sequence: Optional[str] = Noneand the usage block twenty lines below (:520-534) states the convention this violates:
# Usage counts on a message_delta are cumulative totals, so they overwrite rather
# than add; optional ones are omitted when not applicable, in which case the
# message_start value must survive.
if event.usage.input_tokens is not None:
...So container and all five usage fields are guarded on the reasoning that an omitted optional means "not applicable", while the three stop_* fields treat an omitted optional as an explicit None. The beta accumulator has the identical shape at src/anthropic/lib/streaming/_beta_messages.py:549-552.
Precedent
This is the same class of bug the project has fixed before, in this same branch:
get_final_message()does not propagatemessage_delta.containerinto aggregated Message (breaks code_execution continuation) #1424 → fix(streaming): propagate message_delta.container into final Message #1444 —get_final_message()did not propagatemessage_delta.container; the fix is thecontainerguard quoted above.- fix(streaming): propagate stop_details from message_delta into accumulated Message #1469 — propagate
stop_detailsfrommessage_deltainto the accumulated message. - fix(streaming): get_final_message() drops container, breaking code execution continuation #1776 —
get_final_message()dropscontainer, breaking code-execution continuation.
What I could not establish, stated plainly
I can prove the code path with schema-valid events. I cannot prove that api.anthropic.com ever emits a second message_delta that omits stop_reason after one that set it — I have no capture showing that, and if the API always sends stop_reason on the final delta and never afterwards, this stays latent rather than live. Two reasons I think it is still worth a decision:
Deltaaccepts{}, so any producer that emits a usage-only orcontainer-only orstop_details-only delta after the stop delta triggers it. Intermediaries do rewrite these streams — anthropic-sdk-csharp#202 is an open example of a proxy injecting frames the SDK does not expect.- The failure is silent and lands on a field callers branch on: refusal handling,
max_tokenscontinuation, and tool-loop termination all readstop_reasonfrom the final message. ANonethere is indistinguishable from "the stream never finished".
If the unconditional assignment is deliberate — for instance if a later delta omitting stop_reason is meant to clear it — please say so and I will drop this.
Proposed change
Mirroring the container guard, in both copies:
if event.delta.stop_reason is not None:
current_snapshot.stop_reason = event.delta.stop_reason
if event.delta.stop_sequence is not None:
current_snapshot.stop_sequence = event.delta.stop_sequence
if event.delta.stop_details is not None:
current_snapshot.stop_details = event.delta.stop_details
if event.delta.container is not None:
current_snapshot.container = event.delta.containerA regression test would drive two message_delta events through accumulate_event and assert the first delta's stop_reason survives the second, in both tests/lib/streaming/test_messages.py and test_beta_messages.py.
One coordination note: #1815 is currently editing the adjacent usage lines of this same method in both files, so whichever lands second will need a rebase. Happy to sequence behind it, and happy to open the PR if this is wanted — I have not opened one to avoid colliding with that in-flight change.
Environment
anthropic==1.7.0 (matches main at 0af01906 for these lines), Python 3.11.15, macOS arm64. Repro script run as written above; output copied verbatim.
Written with AI assistance. The reproduction and the line references above were run and checked against main rather than inferred.
Activity
feiiiiii5 commentedon Sep 18, 2026
Follow-up with stronger evidence, from the beta copy, which I checked after filing.
src/anthropic/lib/streaming/_beta_messages.py:548-578— the samemessage_deltabranch — makes fourteen assignments to the snapshot. Ten are guarded onis not None(container,context_management,input_transformations, and the seven usage fields at:565-578), one (usage.output_tokens,:554) is unconditional with a comment explaining that it is a cumulative total, and exactly three are unconditional with no such explanation:The
input_transformationscomment at:557-558is the clearest statement of the convention, and it is not usage-specific: a field a latermessage_deltamay omit must not overwrite the value already accumulated.container,context_management,input_transformationsand all seven usage fields follow it. The threestop_*assignments at:549-551are the only ones in the branch that don't — which is a narrower and better-defined exception than "the SDK is inconsistent about guards", and it's the same three lines I quoted in the stable copy.The asymmetry is therefore identical in both copies (stable
_messages.py:515-519, beta_beta_messages.py:549-553), so a fix should land in both, as #1444 and #1469 did.For completeness, since "apply it to every copy" only means something if the copies are otherwise in step: I diffed the two
accumulate_eventimplementations, and they differ only where beta-only features require it —request_headersfor fine-grained tool streaming, thefallbackcontent block relabellingmodel,compaction_delta,context_management,input_transformations,usage.iterationsandusage.fallback_credit. Thestop_*lines are byte-identical between them. So this isn't general drift between the copies; it's one branch where three assignments never got the guard their neighbours have.That doesn't change what I said about not being able to prove the API emits a second
message_deltaomittingstop_reason— the code-path repro in the top comment still stands on its own, and the question of whether the unguarded assignment is deliberate is still the one I can't answer from here.tomer-ant commentedon Oct 14, 2026
Closing as not planned. The API sends one message_delta per message, and it contains the stop fields. The report says it can't show the API sending a second one. If you hit this in real usage, reply with the details and we'll reopen.