streaming: a later message_delta that omits stop_reason resets it to None (container is guarded, the stop_* fields are not) #1940
Description
Activity
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:elif event.type == "message_delta": current_snapshot.stop_reason = event.delta.stop_reason # :549 unguarded current_snapshot.stop_sequence = event.delta.stop_sequence # :550 unguarded current_snapshot.stop_details = event.delta.stop_details # :551 unguarded if event.delta.container is not None: # :552 guarded current_snapshot.container = event.delta.container current_snapshot.usage.output_tokens = event.usage.output_tokens # :554 documented cumulative total if event.context_management is not None: # :555 guarded current_snapshot.context_management = event.context_management # only sent on `message_delta` after a mid-stream fallback, in which case it # replaces the `message_start` value; otherwise that value must survive if event.input_transformations is not None: # :559 guarded, rule written down current_snapshot.input_transformations = event.input_transformations # ...and all seven usage fields below are guarded the same way (:565-578)
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.
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.