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 9
Output on anthropic==1.7.0, Python 3.11.15:
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.container
src/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] = None
and 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:
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:
Delta accepts {}, so any producer that emits a usage-only or container-only or stop_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_tokens continuation, and tool-loop termination all read stop_reason from the final message. A None there 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.container
A 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.
Summary
In
accumulate_event'smessage_deltabranch,stop_reason,stop_sequenceandstop_detailsare assigned unconditionally fromevent.delta, while the neighbouringcontainerassignment is guarded againstNone. All four fields areOptional[...] = NoneonDelta, so amessage_deltathat 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_snapshotandMessageStream.get_final_message().Reproduction
Driven through the real parse path —
RawMessageDeltaEvent.model_validateand the shippedaccumulate_event, no mocks and nomodel_construct:Output on
anthropic==1.7.0, Python 3.11.15:output_tokensbehaves as documented — a cumulative total that overwrites.stop_reasondoes 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-519onmainat0af01906(version 1.7.0, which is what I ran):src/anthropic/types/raw_message_delta_event.pydeclares all four as optional withNonedefaults:and the usage block twenty lines below (
:520-534) states the convention this violates:So
containerand all five usage fields are guarded on the reasoning that an omitted optional means "not applicable", while the threestop_*fields treat an omitted optional as an explicitNone. The beta accumulator has the identical shape atsrc/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.stop_detailsfrommessage_deltainto the accumulated message.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.comever emits a secondmessage_deltathat omitsstop_reasonafter one that set it — I have no capture showing that, and if the API always sendsstop_reasonon 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.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_reasonis meant to clear it — please say so and I will drop this.Proposed change
Mirroring the
containerguard, in both copies:A regression test would drive two
message_deltaevents throughaccumulate_eventand assert the first delta'sstop_reasonsurvives the second, in bothtests/lib/streaming/test_messages.pyandtest_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(matchesmainat0af01906for 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
mainrather than inferred.