Skip to content

Surface message origin on UserMessage and ResultMessage - #1199

Merged
qing-ant merged 1 commit into
mainfrom
qing/message-origin
Aug 11, 2026
Merged

Surface message origin on UserMessage and ResultMessage#1199
qing-ant merged 1 commit into
mainfrom
qing/message-origin

Conversation

@qing-ant

Copy link
Copy Markdown
Contributor

Summary

TypeScript-parity gap (SDKMessageOrigin; the unclassified kind and the task-notification sub-kinds are the most recent additions there, but Python had none of it). In streaming-input mode one connection interleaves the turns the application sends with turns the session injects on its own — background-task notifications, fired scheduled-task prompts, MCP channel messages, messages relayed from peer sessions. The CLI attributes these with an origin object on user messages and forwards the triggering message's origin on each result, so a consumer can tell "this result answers my prompt" from a task-notification follow-up. The Python parser dropped the field entirely.

  • types.py: MessageOrigin TypedDict (functional form, since the wire key from is a keyword) with kind required and the per-kind keys optional and documented; MessageOriginKind = Literal["human", "channel", "peer", "task-notification", "coordinator", "unclassified", "observer", "auto-continuation", "observer-activity"]; TaskNotificationOriginSubkind = Literal["scheduled-trigger", "peer-send-message"]; new origin: MessageOrigin | None = None field (appended, defaulted) on UserMessage and ResultMessage.
  • message_parser.py: _parse_origin passes the CLI's dict through untouched when it is an object with a string kind — so newer kinds/fields stay visible — and treats anything else as absent.
  • __init__.py: exports.

Usage:

if result.origin is None or result.origin["kind"] == "human":
    ...  # a turn this application submitted
elif result.origin["kind"] == "task-notification":
    ...  # follow-up turn driven by a background task

Prompts sent through query() / ClaudeSDKClient.query() arrive unattributed (origin is None) unless the host stamps "origin": {"kind": "human"} on the message dict itself; only the human kind is honored from an SDK host.

Test plan

  • tests/test_message_parser.py: origin on user messages for both content shapes incl. pass-through of unmodeled keys; absent / non-object / kind-less origin → None; result origin for human, both task-notification sub-kinds, and unclassified.
  • e2e-tests/test_message_origin.py (runs in CI against the real CLI): stream a user message stamped {"kind": "human"} and then an unstamped one through ClaudeSDKClient; assert the results carry {"kind": "human"} and None respectively. Passed locally against CLI 2.1.227.
  • ruff check, ruff format --check, mypy src/, full pytest tests/ green locally; also checked that the public typing is usable under mypy/pyright (o["kind"] == "human", o.get("subkind")) on 3.10 (typing_extensions) and 3.13.

In streaming-input mode one connection interleaves the turns the
application sends with turns the session injects on its own --
background-task notifications, fired scheduled-task prompts, MCP channel
messages, messages relayed from peer sessions. The CLI attributes these
with an `origin` object on user messages and forwards the triggering
message's origin on each result, so a consumer can tell "this result
answers my prompt" from a task-notification follow-up. The TypeScript
SDK types it as SDKMessageOrigin (a union on `kind`: human, channel,
peer, task-notification with an optional scheduled-trigger /
peer-send-message subkind, coordinator, unclassified, observer,
auto-continuation, observer-activity). The Python parser dropped the
field entirely, so none of it was visible to Python apps.

Add a `MessageOrigin` TypedDict (functional form, since the wire key
`from` is a keyword) with `kind` required and the per-kind keys
optional, plus `MessageOriginKind` / `TaskNotificationOriginSubkind`
literals, and an `origin: MessageOrigin | None` field on UserMessage and
ResultMessage. The parser passes the CLI's dict through untouched when
it is an object with a string `kind` (so newer kinds and fields stay
visible) and treats anything else as absent.

Unit tests cover both user-content shapes, pass-through of unmodeled
keys, absent/malformed origin, and the result arms. An e2e test streams
a user message stamped `{"kind": "human"}` and an unstamped one and
asserts the results echo `{"kind": "human"}` and None respectively.
@qing-ant

Copy link
Copy Markdown
Contributor Author

@claude please review this PR for correctness, edge cases, and test coverage.

@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Claude finished @qing-ant's task in 1m 39s —— View job


Code Review: Surface message origin on UserMessage and ResultMessage

  • Gather context and read changed files
  • Review types.py changes
  • Review message_parser.py changes
  • Review __init__.py exports
  • Review unit tests
  • Review e2e tests
  • Post final review

Verified: ruff check, ruff format --check, mypy src/ scripts/, and pytest tests/ all pass cleanly (78/78 tests).

Summary

Clean, well-scoped PR that closes the TypeScript-parity gap for MessageOrigin. The implementation is correct and the design choices are sound. No blocking issues found.

types.py

Good choices:

  • Using the functional TypedDict(...) syntax to accommodate the from keyword as a dict key — the right call here.
  • total=False with Required[MessageOriginKind] on kind only — correctly models the wire format where only kind is guaranteed and everything else is per-kind.
  • Appending origin at the end of both dataclasses with = None default — backwards compatible, no positional breakage for existing consumers.
  • Docstrings on MessageOriginKind and MessageOrigin are thorough and include a usage example.

No issues found. The type is faithfully documented with per-kind field semantics in comments, and the Literal union covers all current CLI values while the docstring on MessageOriginKind correctly warns about future unrecognized kinds.

message_parser.py — _parse_origin

def _parse_origin(data: dict[str, Any]) -> MessageOrigin | None:
    origin = data.get("origin")
    if isinstance(origin, dict) and isinstance(origin.get("kind"), str):
        return cast(MessageOrigin, origin)
    return None

Correct and robust. This is the right level of validation — it guards against absent/non-object/kind-less payloads, while passing through unrecognized kinds and future fields. The cast is appropriate here since the dict is an opaque pass-through and runtime validation would add cost without benefit. Both UserMessage and ResultMessage parsing paths call it identically — good.

init.py exports

All three new types (MessageOrigin, MessageOriginKind, TaskNotificationOriginSubkind) are correctly added to both the from .types import block and __all__. Alphabetical ordering is maintained. No issues.

Unit tests (tests/test_message_parser.py)

Good coverage:

  • test_parse_user_message_origin: Tests both content shapes (string and list-of-blocks), verifies pass-through of unmodeled keys (someFutureField), and checks field access.
  • test_parse_user_message_origin_absent_or_malformed: Covers {} (no origin key), None, string value, and empty dict origin — all correctly parse to None.
  • test_parse_result_message_origin: Covers absent origin, human kind, both task-notification sub-kinds, and unclassified.

e2e test (e2e-tests/test_message_origin.py)

Well-structured round-trip test against the real CLI. The helper _user_message correctly constructs the wire-format user message dict with the **extra pattern for injecting origin. The assertion messages include CLI version requirements which will help debugging in CI.

Overall

This is a clean, minimal addition. The forward-compatibility story is well handled (pass-through semantics, cast rather than constructor, docstring warning about unrecognized kinds). LGTM.


@qing-ant
qing-ant enabled auto-merge (squash) August 11, 2026 22:59

@chrislloyd chrislloyd left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Stamp via #claude-code-shadow-stamps

@qing-ant
qing-ant merged commit d48fa33 into main Aug 11, 2026
9 checks passed
@qing-ant
qing-ant deleted the qing/message-origin branch August 11, 2026 23:07
Flohs added a commit to Flohs/claude-agent-sdk-go that referenced this pull request Aug 12, 2026
Port of Python SDK commit d48fa33 (anthropics/claude-agent-sdk-python#1199).

Fixes #583

Co-authored-by: Claude <noreply@anthropic.com>
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