Skip to content

Commit 07b46c6

Browse files
feat: surface terminal_reason on ResultMessage (#1142)
Surfaces `ResultMessage.terminal_reason` on the Python SDK. The CLI already emits `terminal_reason` on the result frame (`"completed"`, `"max_turns"`, `"aborted_streaming"`, `"aborted_tools"`, …); the Python SDK was dropping it. A value of `"aborted_streaming"` / `"aborted_tools"` indicates the turn was cancelled via `ClaudeSDKClient.interrupt()`, giving callers an explicit cancelled marker without a new result subtype. Mirrors the TypeScript SDK's `SDKResultMessage.terminal_reason`. Companion docs PR: anthropics/claude-code-docs#4360 (should land no earlier than the SDK release that ships this). CHANGELOG entry (for the release cutter to place under the version header — kept out of this diff because `tests/test_changelog.py` rejects `## Unreleased`): ``` ### Features - **terminal_reason on ResultMessage**: `ResultMessage.terminal_reason` now surfaces why the query loop ended (`"completed"`, `"max_turns"`, `"aborted_streaming"`, `"aborted_tools"`, etc.). A value of `"aborted_streaming"` or `"aborted_tools"` means the turn was cancelled via `ClaudeSDKClient.interrupt()`. Mirrors the TypeScript SDK's `SDKResultMessage.terminal_reason`. ``` **Verification:** ruff check + format clean; `pytest tests/test_message_parser.py` 75 passed (2 new); full `pytest tests/` 1302 passed, 4 skipped. Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: benlehrburger-ant <benlehrburger-ant@users.noreply.github.com>
1 parent ab3b09c commit 07b46c6

3 files changed

Lines changed: 42 additions & 0 deletions

File tree

src/claude_agent_sdk/_internal/message_parser.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,7 @@ def parse_message(data: dict[str, Any]) -> Message | None:
314314
errors=data.get("errors"),
315315
api_error_status=data.get("api_error_status"),
316316
uuid=data.get("uuid"),
317+
terminal_reason=data.get("terminal_reason"),
317318
)
318319
except KeyError as e:
319320
raise MessageParseError(

src/claude_agent_sdk/types.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1221,6 +1221,15 @@ class ResultMessage:
12211221
# Emitted by the CLI since v2.1.110. Safe to log (no message content).
12221222
api_error_status: int | None = None
12231223
uuid: str | None = None
1224+
terminal_reason: str | None = None
1225+
"""Why the query loop terminated (e.g. ``"completed"``, ``"max_turns"``,
1226+
``"aborted_streaming"``). A value of ``"aborted_streaming"`` or
1227+
``"aborted_tools"`` indicates the turn was cancelled (via
1228+
:meth:`ClaudeSDKClient.interrupt` or an ``interrupt`` control request).
1229+
``None`` when the CLI did not report a terminal reason (older CLI
1230+
versions, or a result that bypassed the query loop such as a local
1231+
slash command). Mirrors the TypeScript SDK's
1232+
``SDKResultMessage.terminal_reason``."""
12241233

12251234

12261235
@dataclass

tests/test_message_parser.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -834,6 +834,38 @@ def test_parse_result_message_with_null_stop_reason(self):
834834
assert isinstance(message, ResultMessage)
835835
assert message.stop_reason is None
836836

837+
def test_parse_result_message_with_terminal_reason(self):
838+
"""Test parsing a result message with terminal_reason field."""
839+
data = {
840+
"type": "result",
841+
"subtype": "success",
842+
"duration_ms": 1000,
843+
"duration_api_ms": 500,
844+
"is_error": False,
845+
"num_turns": 2,
846+
"session_id": "session_123",
847+
"result": "",
848+
"terminal_reason": "aborted_tools",
849+
}
850+
message = parse_message(data)
851+
assert isinstance(message, ResultMessage)
852+
assert message.terminal_reason == "aborted_tools"
853+
854+
def test_parse_result_message_missing_terminal_reason_is_none(self):
855+
"""A result message without terminal_reason parses to None."""
856+
data = {
857+
"type": "result",
858+
"subtype": "success",
859+
"duration_ms": 1000,
860+
"duration_api_ms": 500,
861+
"is_error": False,
862+
"num_turns": 2,
863+
"session_id": "session_123",
864+
}
865+
message = parse_message(data)
866+
assert isinstance(message, ResultMessage)
867+
assert message.terminal_reason is None
868+
837869
def test_parse_rate_limit_event(self):
838870
"""Test parsing a rate_limit_event into a typed RateLimitEvent."""
839871
data = {

0 commit comments

Comments
 (0)