claude-agent-sdk 0.30.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -78,6 +78,28 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new(
78
78
 
79
79
  When set, the CLI strips per-user dynamic sections (working directory, auto-memory, git status) from the system prompt and re-injects them into the first user message instead. Older CLIs silently ignore this option.
80
80
 
81
+ ### System Prompt Snapshot
82
+
83
+ By default, Claude Code builds the system prompt on a session's first request, records it, and reuses it on every later request, including after you resume the session. A changed custom prompt, or changed `append` text on the `claude_code` preset, then has no effect until the session is compacted or you start a new session. To rebuild the prompt on every request instead, for example while you iterate on its wording, set `snapshot: false` on a `SystemPromptPreset` or on `SystemPromptCustom` (the object form of a String prompt, which exists so `snapshot` can be set alongside it):
84
+
85
+ ```ruby
86
+ options = ClaudeAgentSDK::ClaudeAgentOptions.new(
87
+ system_prompt: ClaudeAgentSDK::SystemPromptCustom.new(
88
+ prompt: 'You are a release bot.',
89
+ snapshot: false
90
+ )
91
+ )
92
+
93
+ # Hash forms work too:
94
+ options = ClaudeAgentSDK::ClaudeAgentOptions.new(
95
+ system_prompt: { type: 'preset', preset: 'claude_code', append: '...', snapshot: false }
96
+ )
97
+ ```
98
+
99
+ `snapshot` is sent on the control-protocol `initialize` request (never as a CLI flag), so it applies to both `query()` and `Client`. When omitted it acts as `true`, except in bare mode (`bare: true`), where it acts as `false`. A `SystemPromptFile` has no `snapshot`.
100
+
101
+ Requires Claude Code CLI 2.1.257 or later. Before 2.1.265, a session with an `append` or custom prompt recorded it only when `snapshot` was `true`. See [Modifying system prompts](https://code.claude.com/docs/en/agent-sdk/modifying-system-prompts#change-the-prompt-of-an-existing-session) for details.
102
+
81
103
  ## Budget Control
82
104
 
83
105
  ```ruby
@@ -223,6 +245,23 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new(
223
245
 
224
246
  See [examples/bare_mode_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/bare_mode_example.rb).
225
247
 
248
+ ## Forwarding Subagent Text
249
+
250
+ By default only `tool_use` / `tool_result` blocks from subagents (spawned via
251
+ the Agent tool) reach the message stream, as `AssistantMessage` /
252
+ `UserMessage` objects whose `parent_tool_use_id` is the spawning Agent
253
+ `tool_use` id — enough for a progress heartbeat. Set `forward_subagent_text`
254
+ to forward the subagent's **text and thinking** blocks the same way, so you
255
+ can render the full nested transcript:
256
+
257
+ ```ruby
258
+ options = ClaudeAgentSDK::ClaudeAgentOptions.new(forward_subagent_text: true)
259
+ ```
260
+
261
+ Matches the TypeScript SDK's `forwardSubagentText`. The capability is
262
+ negotiated on the control-protocol handshake, so it applies to both
263
+ `ClaudeAgentSDK.query` and `Client`.
264
+
226
265
  ## File Checkpointing & Rewind
227
266
 
228
267
  Enable file checkpointing to revert file changes to a previous state:
data/docs/errors.md CHANGED
@@ -31,6 +31,12 @@ rescue ClaudeAgentSDK::ControlRequestTimeoutError
31
31
  puts "Control protocol timed out — consider increasing the timeout"
32
32
  rescue ClaudeAgentSDK::CLINotFoundError
33
33
  puts "Please install Claude Code"
34
+ rescue ClaudeAgentSDK::ResultError => e
35
+ # More specific than ProcessError — must be rescued first.
36
+ case e.terminal_reason
37
+ when 'api_error' then puts "API failed (HTTP #{e.api_error_status}): #{e.result}"
38
+ else puts "Run failed (#{e.subtype}): #{e.errors.join('; ')}"
39
+ end
34
40
  rescue ClaudeAgentSDK::ProcessError => e
35
41
  puts "Process failed with exit code: #{e.exit_code}"
36
42
  rescue ClaudeAgentSDK::CLIJSONDecodeError => e
@@ -38,6 +44,40 @@ rescue ClaudeAgentSDK::CLIJSONDecodeError => e
38
44
  end
39
45
  ```
40
46
 
47
+ ## Terminal Error Results
48
+
49
+ When a run fails, the CLI emits a `result` message with `is_error: true` (which
50
+ you still receive as a `ResultMessage`) and *then* exits non-zero on purpose,
51
+ for shell-script consumers. That trailing process failure carries nothing
52
+ beyond "exit code 1", so the SDK replaces it with a `ResultError` carrying the
53
+ payload the CLI already reported — you can branch on *why* the run failed
54
+ without matching on strings:
55
+
56
+ ```ruby
57
+ begin
58
+ ClaudeAgentSDK.query(prompt: "...") { |m| handle(m) }
59
+ rescue ClaudeAgentSDK::ResultError => e
60
+ retry_later if e.terminal_reason == 'api_error' # overloaded / timeout
61
+ widen_budget if e.subtype == 'error_max_turns'
62
+ raise
63
+ end
64
+ ```
65
+
66
+ `ResultError` subclasses `ProcessError`, so existing `rescue ProcessError`
67
+ handlers keep working unchanged — but rescue `ResultError` **first** if you
68
+ want the structured fields.
69
+
70
+ The exception message prefers, in order: the CLI's `errors[]`, then `result`,
71
+ then a non-`success` `subtype`, then the HTTP status. A run that ends on an API
72
+ failure arrives as `subtype: "success"` with `is_error: true` and the prose in
73
+ `result`, which is why `subtype` alone is not used as the message.
74
+
75
+ A refused resume (a nonexistent session, or a `resume_drops_turn` guard
76
+ failure) reaches you the same way — including on a control request such as the
77
+ initial handshake that was still in flight when the CLI exited. Match on
78
+ `Resume rejected by --resume-drops-turn:` in the message and treat it as
79
+ deterministic: clear the fork target and resume plainly rather than retrying.
80
+
41
81
  ## Configuring Timeout
42
82
 
43
83
  The control request timeout defaults to **1200 seconds** (20 minutes) to accommodate long-running agent sessions. Override it via environment variable:
@@ -70,6 +110,19 @@ class ProcessError < ClaudeSDKError
70
110
  :stderr # String | nil
71
111
  end
72
112
 
113
+ # Raised when the CLI exits after reporting a terminal error result.
114
+ # Subclasses ProcessError, so existing `rescue ProcessError` keeps working.
115
+ class ResultError < ProcessError
116
+ attr_reader :subtype, # String | nil ('error_max_turns', 'error_during_execution', ...)
117
+ :errors, # Array<String> - error strings from the CLI (may be empty)
118
+ :result, # String | nil - result text; holds the "API Error: ..." prose
119
+ :api_error_status, # Integer | nil - HTTP status of the failing API call
120
+ :terminal_reason, # String | nil - why the run ended ('api_error', 'max_turns', ...)
121
+ :session_id, # String | nil
122
+ :data, # Hash - raw `result` payload as emitted by the CLI
123
+ :original_error # ProcessError | nil - the bare exit error this replaced
124
+ end
125
+
73
126
  # Raised when JSON parsing fails
74
127
  class CLIJSONDecodeError < ClaudeSDKError
75
128
  attr_reader :line, # String - The line that failed to parse
data/docs/sessions.md CHANGED
@@ -49,6 +49,8 @@ messages = ClaudeAgentSDK.get_subagent_messages(session_id: "uuid-here", agent_i
49
49
 
50
50
  With `directory:` given, only that project and its git worktrees are searched (no global fallback). Store-backed counterparts: `list_subagents_from_store` / `get_subagent_messages_from_store`.
51
51
 
52
+ > Each returned `SessionMessage` carries `parent_tool_use_id` — the id of the Agent `tool_use` block in the parent session that spawned this subagent — and `parent_agent_id`, the spawning subagent's id for nested subagents. Both are read from the `agent-<id>.meta.json` sidecar beside the transcript (or the `agent_metadata` entry in a `SessionStore`), and are `nil` when it is missing or unusable.
53
+
52
54
  ## Renaming a Session
53
55
 
54
56
  ```ruby
@@ -113,6 +115,46 @@ ClaudeAgentSDK.query(
113
115
 
114
116
  `resume_session_at` requires `resume`; the SDK raises `ArgumentError` from `CommandBuilder` when this constraint is violated, matching the underlying CLI's validation but surfacing it synchronously in the caller's stack.
115
117
 
118
+ ### Validating what the truncation discards
119
+
120
+ A bare `resume_session_at` silently drops everything after the fork point —
121
+ including a queued user message or a task notification the session absorbed
122
+ mid-turn that you never observed. `resume_drops_turn` names the user prompt
123
+ whose turn you *intend* to discard, and the CLI refuses the resume if anything
124
+ past the fork point is not attributable to that turn:
125
+
126
+ ```ruby
127
+ ClaudeAgentSDK.query(
128
+ prompt: 'try a different approach',
129
+ options: ClaudeAgentSDK::ClaudeAgentOptions.new(
130
+ resume: session_id,
131
+ resume_session_at: last_kept_entry_uuid,
132
+ resume_drops_turn: discarded_prompt_uuid
133
+ )
134
+ ) { |m| handle(m) }
135
+ ```
136
+
137
+ Rule of thumb: set `resume_session_at` to the **last transcript entry of the
138
+ turn you are keeping** (whatever its type), and `resume_drops_turn` to the
139
+ prompt UUID of the turn immediately after it — the next `SessionMessage` with
140
+ `type == 'user'` from `get_session_messages`, or the `uuid` you supplied on a
141
+ streamed user message.
142
+
143
+ With structured output (`output_format`) or end-turn MCP tools, a kept turn
144
+ ends on entries *after* its last assistant message, so forking at the assistant
145
+ UUID is refused by design.
146
+
147
+ A refusal surfaces as a `ResultError` whose message contains
148
+ `Resume rejected by --resume-drops-turn:`. Treat it as **deterministic** —
149
+ clear the pending fork target and resume plainly; do not retry the same
150
+ request. Leave `resume_drops_turn` unset to keep the unvalidated behavior.
151
+
152
+ Unlike `resume_session_at`, the SDK applies no combination validation to
153
+ `resume_drops_turn` and defers entirely to the CLI. An empty string is
154
+ forwarded rather than dropped, so the CLI rejects it as a malformed
155
+ declaration instead of the SDK silently disarming a guard you believe is
156
+ armed.
157
+
116
158
  ## Mirroring to a `SessionStore`
117
159
 
118
160
  By default Claude Code writes session transcripts to local disk under
data/docs/types.md CHANGED
@@ -6,7 +6,8 @@ See [lib/claude_agent_sdk/types.rb](https://github.com/ya-luotao/claude-agent-sd
6
6
 
7
7
  ```ruby
8
8
  # Union type of all possible messages
9
- Message = UserMessage | AssistantMessage | SystemMessage | ResultMessage
9
+ Message = UserMessage | AssistantMessage | SystemMessage | ResultMessage |
10
+ StreamEvent | RateLimitEvent | ConversationResetMessage
10
11
  ```
11
12
 
12
13
  ### UserMessage
@@ -18,7 +19,8 @@ class UserMessage
18
19
  attr_accessor :content, # String | Array<ContentBlock>
19
20
  :uuid, # String | nil - Unique ID for rewind support
20
21
  :parent_tool_use_id, # String | nil
21
- :tool_use_result # Hash | nil - Tool result data when message is a tool response
22
+ :tool_use_result, # Hash | nil - Tool result data when message is a tool response
23
+ :origin # Hash | nil - message provenance (see Message Origin below)
22
24
  end
23
25
  ```
24
26
 
@@ -91,7 +93,8 @@ class ResultMessage
91
93
  :uuid, # String | nil
92
94
  :fast_mode_state, # String | nil ('off', 'cooldown', 'on')
93
95
  :api_error_status, # Integer | nil (HTTP status on api_error subtype)
94
- :terminal_reason # String | nil (see below)
96
+ :terminal_reason, # String | nil (see below)
97
+ :origin # Hash | nil - origin of the triggering user message (see below)
95
98
  end
96
99
  ```
97
100
 
@@ -109,6 +112,71 @@ optional `canonicalModel` (canonical id used for the pricing lookup, which can
109
112
  differ from the raw model-string key for provider-specific ids/aliases) and
110
113
  `provider` (`'firstParty'`, `'bedrock'`, `'vertex'`, ...).
111
114
 
115
+ ## Message Origin
116
+
117
+ `UserMessage#origin` and `ResultMessage#origin` carry the provenance of a
118
+ user-role turn. In streaming/`Client` mode one connection interleaves the turns
119
+ your application sends with turns the session injects on its own — background
120
+ task notifications, fired scheduled-task prompts, MCP channel messages,
121
+ messages relayed from peer sessions. `origin` tells them apart:
122
+
123
+ ```ruby
124
+ if result.origin.nil? || result.origin[:kind] == 'human'
125
+ # a turn this application submitted
126
+ elsif result.origin[:kind] == 'task-notification'
127
+ # follow-up turn driven by a background task
128
+ end
129
+ ```
130
+
131
+ The Hash is passed through from the CLI **verbatim**, so:
132
+
133
+ - **Keys are Symbols**, and non-`kind` keys keep the CLI's camelCase spelling —
134
+ `origin[:fromSession]`, `origin[:senderTaskId]`, `origin[:verifiedPeerPid]`.
135
+ (The Python SDK's equivalent is string-keyed; do not port `origin["kind"]`
136
+ literally.)
137
+ - Keys this SDK version does not model still reach you, so newer CLI origin
138
+ kinds stay visible.
139
+ - Anything that is not an object with a String `kind` reads as `nil`.
140
+
141
+ Only `kind` is always present. Known kinds — treat anything unrecognized as
142
+ "not human":
143
+
144
+ `human`, `channel`, `peer`, `task-notification`, `coordinator`,
145
+ `unclassified`, `observer`, `auto-continuation`, `observer-activity`
146
+
147
+ For `kind == 'task-notification'`, `origin[:subkind]` may be
148
+ `scheduled-trigger` (a scheduled task's prompt fired) or `peer-send-message`
149
+ (a message from another of your sessions); it is absent for ordinary
150
+ background-task notifications.
151
+
152
+ `nil` means the CLI did not attribute the message. Prompts you send through
153
+ `ClaudeAgentSDK.query` or `Client#query` arrive that way unless you stamp
154
+ `origin: { kind: 'human' }` on the message Hash yourself — only the `human`
155
+ kind is honored from an SDK host. Tool-result messages never carry an origin.
156
+
157
+ ### ConversationResetMessage
158
+
159
+ Emitted when the session's conversation is replaced without ending the
160
+ connection — after `/clear`, or any other flow that discards the transcript
161
+ mid-session.
162
+
163
+ ```ruby
164
+ class ConversationResetMessage
165
+ attr_accessor :new_conversation_id, # String - id of the fresh conversation
166
+ :uuid, # String - unique ID of this message
167
+ :session_id # String - the session that was reset
168
+ end
169
+ ```
170
+
171
+ A reset clears the conversation history **and zeroes the running totals**
172
+ reported on subsequent `ResultMessage` objects (`total_cost_usd`, and the
173
+ rest). If you accumulate those across a long-lived session, snapshot them when
174
+ this message arrives.
175
+
176
+ `new_conversation_id` is **not** the `session_id` of subsequent messages — it
177
+ is an opaque id for keying an empty transcript in a UI (and for discarding a
178
+ cached session title). Read the new session id from the next message.
179
+
112
180
  ## Content Block Types
113
181
 
114
182
  ```ruby
@@ -199,7 +267,9 @@ end
199
267
  | `SandboxSettings` | Sandbox settings for isolated command execution |
200
268
  | `SandboxNetworkConfig` | Network configuration for sandbox |
201
269
  | `SandboxIgnoreViolations` | Configure which sandbox violations to ignore |
202
- | `SystemPromptPreset` | System prompt preset configuration |
270
+ | `SystemPromptPreset` | System prompt preset configuration (`preset`, `append`, `exclude_dynamic_sections`, `snapshot`) |
271
+ | `SystemPromptCustom` | Custom system prompt configuration — the object form of a String prompt, so `snapshot` can be set alongside it |
272
+ | `SystemPromptFile` | System prompt loaded from a file path |
203
273
  | `ToolsPreset` | Tools preset configuration for base tools selection |
204
274
 
205
275
  ## Constants
@@ -72,6 +72,10 @@ module ClaudeAgentSDK
72
72
  cmd.push("--system-prompt", @options.system_prompt)
73
73
  when SystemPromptFile
74
74
  cmd.push("--system-prompt-file", @options.system_prompt.path)
75
+ when SystemPromptCustom
76
+ # The object form of a String prompt; snapshot travels on the
77
+ # initialize request, not as a CLI flag.
78
+ cmd.push("--system-prompt", custom_prompt_text(@options.system_prompt.prompt))
75
79
  when SystemPromptPreset
76
80
  # Preset activates the default Claude Code system prompt by not passing --system-prompt ""
77
81
  # Only --append-system-prompt is passed if append text is provided
@@ -87,6 +91,9 @@ module ClaudeAgentSDK
87
91
  when "file"
88
92
  prompt_path = prompt_hash[:path] || prompt_hash["path"]
89
93
  cmd.push("--system-prompt-file", prompt_path) if prompt_path
94
+ when "custom"
95
+ prompt = prompt_hash.fetch(:prompt) { prompt_hash["prompt"] }
96
+ cmd.push("--system-prompt", custom_prompt_text(prompt))
90
97
  when "preset"
91
98
  append = prompt_hash[:append] || prompt_hash["append"]
92
99
  # Preset activates the default Claude Code system prompt by not passing --system-prompt ""
@@ -94,6 +101,17 @@ module ClaudeAgentSDK
94
101
  end
95
102
  end
96
103
 
104
+ # A custom prompt is always forwarded, even when empty (an empty String
105
+ # suppresses the default Claude Code prompt, exactly like a nil
106
+ # system_prompt). A missing prompt is rejected loudly rather than
107
+ # falling through and silently activating the default prompt — the
108
+ # Python SDK raises KeyError on the same input.
109
+ def custom_prompt_text(prompt)
110
+ raise ArgumentError, "system_prompt of type 'custom' requires a :prompt String" unless prompt.is_a?(String)
111
+
112
+ prompt
113
+ end
114
+
97
115
  def append_allowed_tools(cmd, allowed_tools)
98
116
  cmd.push("--allowedTools", allowed_tools.join(",")) unless allowed_tools.empty?
99
117
  end
@@ -222,6 +240,7 @@ module ClaudeAgentSDK
222
240
  # flags. The equals form always binds the value to its flag.
223
241
  cmd.push("--resume=#{@options.resume}") if @options.resume
224
242
  append_resume_session_at(cmd)
243
+ append_resume_drops_turn(cmd)
225
244
  cmd.push("--session-id=#{@options.session_id}") if @options.session_id
226
245
  end
227
246
 
@@ -240,6 +259,29 @@ module ClaudeAgentSDK
240
259
  cmd.push("--resume-session-at=#{@options.resume_session_at}")
241
260
  end
242
261
 
262
+ # `--resume-drops-turn=<prompt-uuid>` declares, alongside
263
+ # `--resume-session-at`, which user prompt's turn this truncating resume
264
+ # intends to discard. The CLI validates at load time that every transcript
265
+ # entry after the fork point is attributable to that turn and refuses the
266
+ # resume otherwise — so a caller can rewind to "before my last prompt"
267
+ # without silently dropping a queued message or task notification the
268
+ # session absorbed mid-turn that the caller never observed. A refusal
269
+ # surfaces as an `error_during_execution` result whose message starts with
270
+ # `Resume rejected by --resume-drops-turn:`.
271
+ #
272
+ # No SDK-side validation of the option combination (resume /
273
+ # resume_session_at): like the TypeScript and Python SDKs this defers to
274
+ # the CLI.
275
+ def append_resume_drops_turn(cmd)
276
+ # `.nil?`, not truthiness: an empty string is forwarded so the CLI
277
+ # rejects it as a malformed declaration. Dropping it here would silently
278
+ # disarm the guard the caller believes is armed.
279
+ return if @options.resume_drops_turn.nil?
280
+
281
+ # Equals form for the same reason as --resume above.
282
+ cmd.push("--resume-drops-turn=#{@options.resume_drops_turn}")
283
+ end
284
+
243
285
  # Sandbox gating is `!nil?` throughout — Python's `sandbox is not None`.
244
286
  # Booleans and {} are forwarded verbatim: an explicit `sandbox: false`
245
287
  # must reach the CLI so it can override a sandbox enabled in the
@@ -38,6 +38,153 @@ module ClaudeAgentSDK
38
38
  end
39
39
  end
40
40
 
41
+ # Raised when the CLI exits after reporting a terminal error result.
42
+ #
43
+ # The CLI ends a failed run by emitting a +result+ message with
44
+ # +is_error: true+ (yielded to you as a ResultMessage) and *then* exiting
45
+ # non-zero, on purpose, for shell-script consumers. This exception replaces
46
+ # the bare "exit code 1" ProcessError for that case and carries the
47
+ # result's payload, so callers can branch on *why* the run failed without
48
+ # string matching:
49
+ #
50
+ # begin
51
+ # ClaudeAgentSDK.query(prompt: '...') { |message| ... }
52
+ # rescue ClaudeAgentSDK::ResultError => e
53
+ # if e.terminal_reason == 'api_error' # e.g. overloaded / timeout
54
+ # retry_later
55
+ # elsif e.subtype == 'error_max_turns'
56
+ # ...
57
+ # end
58
+ # end
59
+ #
60
+ # It subclasses ProcessError, so existing +rescue ProcessError+ handlers
61
+ # keep working.
62
+ #
63
+ # Every structured field is type-narrowed: a payload whose +subtype+ is not
64
+ # a String (or whose +api_error_status+ is not an Integer, ...) reads back
65
+ # as nil rather than leaking the raw value, so callers can branch on these
66
+ # without re-validating. #data always holds the payload as the CLI sent it.
67
+ class ResultError < ProcessError
68
+ # The result subtype ("error_max_turns", "error_during_execution", ... —
69
+ # or "success" when the agent loop itself completed but the last turn was
70
+ # an API error).
71
+ attr_reader :subtype
72
+
73
+ # Error strings reported by the CLI (may be empty). Normalized the same
74
+ # way the exception text is built, so the two never disagree.
75
+ attr_reader :errors
76
+
77
+ # The result text, if any. For API failures this holds the
78
+ # "API Error: ..." prose.
79
+ attr_reader :result
80
+
81
+ # HTTP status of the failing API call, if any.
82
+ attr_reader :api_error_status
83
+
84
+ # Why the run ended (e.g. "api_error", "max_turns"), if reported.
85
+ attr_reader :terminal_reason
86
+
87
+ # Session the result belongs to, if reported.
88
+ attr_reader :session_id
89
+
90
+ # The raw +result+ message payload as emitted by the CLI.
91
+ attr_reader :data
92
+
93
+ # The ProcessError this replaced (the bare "exit code 1" exit).
94
+ #
95
+ # Ruby only populates #cause for an exception raised inside a rescue
96
+ # block; the read loop hands this one to the message queue instead of
97
+ # raising it there, so #cause is nil and the original exit error would be
98
+ # lost without an explicit accessor. Mirrors Python's __cause__ chaining.
99
+ attr_reader :original_error
100
+
101
+ # Reading a `result` payload: shared by the structured attributes below
102
+ # and by .error_text, which is the whole point — the exception's fields
103
+ # and its message are derived from the same normalization, so they can
104
+ # never disagree. Private to ResultError (Python keeps the equivalent
105
+ # helpers module-private as _normalize_result_errors); callers outside
106
+ # go through .error_text.
107
+ module Payload
108
+ module_function
109
+
110
+ # Normalize the +errors+ field of a +result+ frame to clean strings.
111
+ #
112
+ # The CLI emits an Array of Strings; tolerate a bare String (older or
113
+ # buggy emitters), treat anything else as empty, and drop non-String or
114
+ # blank entries.
115
+ def normalize_errors(raw)
116
+ raw = [raw] if raw.is_a?(String)
117
+ return [] unless raw.is_a?(Array)
118
+
119
+ raw.filter_map { |e| e.strip if e.is_a?(String) && !e.strip.empty? }
120
+ end
121
+
122
+ # Read a payload field, tolerating both key forms.
123
+ #
124
+ # Wire messages reach the SDK with symbolized keys, but a payload
125
+ # reconstructed by a caller (or replayed from JSON.parse without
126
+ # symbolize_names) uses Strings.
127
+ def field(data, key)
128
+ return nil unless data.is_a?(Hash)
129
+
130
+ data.key?(key) ? data[key] : data[key.to_s]
131
+ end
132
+ end
133
+ private_constant :Payload
134
+
135
+ # Pick the most informative text from a `result` frame with is_error.
136
+ #
137
+ # Terminal errors the CLI raises itself (error_max_turns,
138
+ # error_during_execution, ...) carry their prose in errors[]. A run that
139
+ # ends on an API failure instead arrives as subtype "success" with
140
+ # is_error true, an empty errors[] and the "API Error: ..." prose in
141
+ # `result` — falling back to the subtype there produced the self-
142
+ # contradictory "Claude Code returned an error result: success". Prefer
143
+ # errors[], then `result`, then a non-success subtype, then the HTTP
144
+ # status, mirroring the TypeScript SDK's choice of `result` for the
145
+ # `success` subtype.
146
+ #
147
+ # Public because the read loop builds the exception message from it, and
148
+ # because it is the documented way to get the same one-line summary out
149
+ # of a raw error result you already hold (an is_error ResultMessage the
150
+ # CLI emitted before exiting). Mirrors Python's _error_result_text.
151
+ def self.error_text(data)
152
+ errors = Payload.normalize_errors(Payload.field(data, :errors))
153
+ return errors.join('; ') unless errors.empty?
154
+
155
+ result = Payload.field(data, :result)
156
+ return result.strip if result.is_a?(String) && !result.strip.empty?
157
+
158
+ subtype = Payload.field(data, :subtype)
159
+ return subtype if subtype.is_a?(String) && !subtype.empty? && subtype != 'success'
160
+
161
+ status = Payload.field(data, :api_error_status)
162
+ return "API error (HTTP #{status})" unless status.nil?
163
+
164
+ 'unknown error'
165
+ end
166
+
167
+ def initialize(message, data: nil, exit_code: nil, stderr: nil, original_error: nil)
168
+ data = {} unless data.is_a?(Hash)
169
+ @data = data
170
+ @original_error = original_error
171
+
172
+ subtype = Payload.field(data, :subtype)
173
+ @subtype = subtype.is_a?(String) ? subtype : nil
174
+ @errors = Payload.normalize_errors(Payload.field(data, :errors))
175
+ result = Payload.field(data, :result)
176
+ @result = result.is_a?(String) ? result : nil
177
+ status = Payload.field(data, :api_error_status)
178
+ @api_error_status = status.is_a?(Integer) ? status : nil
179
+ reason = Payload.field(data, :terminal_reason)
180
+ @terminal_reason = reason.is_a?(String) ? reason : nil
181
+ session_id = Payload.field(data, :session_id)
182
+ @session_id = session_id.is_a?(String) ? session_id : nil
183
+
184
+ super(message, exit_code: exit_code, stderr: stderr)
185
+ end
186
+ end
187
+
41
188
  # Raised when unable to decode JSON from CLI output
42
189
  class CLIJSONDecodeError < ClaudeSDKError
43
190
  attr_reader :line, :original_error
@@ -25,6 +25,8 @@ module ClaudeAgentSDK
25
25
  parse_stream_event(data)
26
26
  when 'rate_limit_event'
27
27
  parse_rate_limit_event(data)
28
+ when 'conversation_reset'
29
+ parse_conversation_reset_message(data)
28
30
  when 'tool_progress'
29
31
  parse_tool_progress_message(data)
30
32
  when 'auth_status'
@@ -53,16 +55,32 @@ module ClaudeAgentSDK
53
55
  content = message_data[:content]
54
56
  raise MessageParseError.new("Missing content in user message", data: data) unless content
55
57
 
58
+ origin = parse_origin(data)
59
+
56
60
  if content.is_a?(Array)
57
61
  content_blocks = parse_content_blocks(content, data)
58
62
  UserMessage.new(content: content_blocks, uuid: uuid, parent_tool_use_id: parent_tool_use_id,
59
- tool_use_result: tool_use_result)
63
+ tool_use_result: tool_use_result, origin: origin)
60
64
  else
61
65
  UserMessage.new(content: content, uuid: uuid, parent_tool_use_id: parent_tool_use_id,
62
- tool_use_result: tool_use_result)
66
+ tool_use_result: tool_use_result, origin: origin)
63
67
  end
64
68
  end
65
69
 
70
+ # Returns `data[:origin]` when it is a well-formed origin object.
71
+ #
72
+ # Passed through as-is — including keys and kinds this SDK version doesn't
73
+ # model, and with the wire key spelling untouched (`:fromSession` stays
74
+ # camelCase) — so newer CLI origin kinds/fields stay visible to callers.
75
+ # Anything that is not a Hash with a String `:kind` is treated as absent.
76
+ def self.parse_origin(data)
77
+ origin = data[:origin]
78
+ return origin if origin.is_a?(Hash) && origin[:kind].is_a?(String)
79
+
80
+ nil
81
+ end
82
+ private_class_method :parse_origin
83
+
66
84
  def self.parse_assistant_message(data)
67
85
  message_data = data[:message]
68
86
  # A non-Hash message (malformed CLI output) raised a raw TypeError from
@@ -124,7 +142,10 @@ module ClaudeAgentSDK
124
142
  end
125
143
 
126
144
  def self.parse_result_message(data)
127
- ResultMessage.new(data)
145
+ # `origin` is overwritten with the validated value (or nil) rather than
146
+ # letting the base class assign the raw field: a malformed origin must
147
+ # read as absent, not be surfaced verbatim.
148
+ ResultMessage.new(data.merge(origin: parse_origin(data)))
128
149
  end
129
150
 
130
151
  def self.parse_stream_event(data)
@@ -135,6 +156,20 @@ module ClaudeAgentSDK
135
156
  RateLimitEvent.new(data.merge(raw_data: data))
136
157
  end
137
158
 
159
+ # `/clear` (or any other mid-session transcript discard) resets the
160
+ # conversation without ending the connection. Every field is required —
161
+ # a frame missing one is malformed CLI output, not a forward-compatible
162
+ # variant, so it raises rather than yielding a half-built message.
163
+ def self.parse_conversation_reset_message(data)
164
+ ConversationResetMessage.new(
165
+ new_conversation_id: data.fetch(:new_conversation_id),
166
+ uuid: data.fetch(:uuid),
167
+ session_id: data.fetch(:session_id)
168
+ )
169
+ rescue KeyError => e
170
+ raise MessageParseError.new("Missing required field in conversation_reset message: #{e.key}", data: data)
171
+ end
172
+
138
173
  def self.parse_tool_progress_message(data)
139
174
  ToolProgressMessage.new(data)
140
175
  end