Skip to content

streaming: a later message_delta that omits stop_reason resets it to None (container is guarded, the stop_* fields are not) #1940

Description

@feiiiiii5

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:

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.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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions