claude-agent-sdk 0.19.1 → 0.20.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fc9c6b243b2a7ac982141db36c55d510d9a08cb18caeffb5dd2d9ed59fefc42d
4
- data.tar.gz: 7ce8124f1cebd923857fda75532caa8332a89d930564ff9c474e847e57fcb1d8
3
+ metadata.gz: 35da7de1ea5624dce1116d0c031a6141549e1d554434f3636cf47f5bcc81ca70
4
+ data.tar.gz: f9093848753fc2b583aed232d30013c8f328e36c6a958dc44f7d30c6ff319ae8
5
5
  SHA512:
6
- metadata.gz: 7c548159588586dd908b4a51173bdf89abac417ceab753d8eb8b313095463770dbf5fc44e7af9c90dce26df651f11ae5b25eca28d755a17e2c5b68984eff1ccb
7
- data.tar.gz: 0a46ae5342425411af912c98ab5f96607bdf4d8ec76f027a7388b5519c3dc7d425a79e03a4b82c7317e9f983dcd232c7375d91f23feb33ecb758289f57dcce3b
6
+ metadata.gz: f73779c80dfced4d4600384853900a68d658db1dcb9a73813949c49bced1593641a8b68085d83d6127178a533cb5a6daf2d045ffdaac4f526fc8d21f24db3636
7
+ data.tar.gz: 8e1022e4c20937b7464f46636636d2d9c6c160dfce9937617d1b56c5fae1c0abce401e6d9795fe4b566d2c59ba6e628c6c8dbf6e090278743eeac54a9a8a8489
data/CHANGELOG.md CHANGED
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.20.0] - 2026-07-03
11
+
12
+ Behavioral fix batch (Batch B) from the 2026-07-03 full-codebase audit (`AUDIT-2026-07-03.md`, PR #42). Every fix aligns code with a documented contract or Python SDK behavior and changes behavior only for inputs that previously produced wrong results, hangs, or crashes. Minor (not patch) because two fixes raise where the SDK previously reported success: a signal-killed CLI now raises `ProcessError`, and assistant messages without `message.model` now raise `MessageParseError`.
13
+
14
+ ### Fixed
15
+ - A CLI process killed by a signal (OOM-kill SIGKILL, SIGSEGV, ...) now raises `ProcessError` ("Command terminated by signal N", `exit_code: -N` — Python returncode parity) instead of reporting a **truncated** response as clean end-of-stream success.
16
+ - A trailing title-clearing entry (`{"customTitle":""}`, or whitespace-only) no longer silently drops the whole session from `list_sessions`/`get_session_info` disk listings: blank values now fall through the summary/title fallback chains exactly like the SessionStore path and Python (`or` semantics).
17
+ - `continue_conversation: true` with a SessionStore adapter that reports mtimes as Strings (the natural SQL-timestamp-through-JSON shape) no longer silently resumes the **oldest** session: String mtimes (ISO-8601 or numeric) are coerced and ordered chronologically, and mixed Integer/String lists no longer raise a bare `ArgumentError`. The conformance suite already pins the epoch-ms Numeric contract.
18
+ - One malformed line in a transcript head (non-Hash entry, string `message`, non-string `text`) no longer drops the whole session from disk listings — the guards ported from Python skip just the bad line. An assistant line whose tool_use input embeds `"type":"user"` can no longer donate its text as the session's first prompt.
19
+ - A user message block that leaks `StopIteration` (e.g. `.next` on an exhausted Enumerator) no longer silently ends message reception mid-turn — previously the `ResultMessage` was dropped and `receive_response`/`query()` returned as if the turn had completed; the error now propagates.
20
+ - Top-level `query()` now validates the prompt like `Client#query` and fails fast at the call site: a bare Hash (which would stream `[key, value]` garbage to the CLI) and non-String/non-`#each` prompts (which hung forever) raise `ArgumentError`.
21
+ - One-shot `query()` now sends `exclude_dynamic_sections` from a preset system prompt in the initialize request (it was silently dropped; `Client` and Python both send it).
22
+ - `ClaudeAgentOptions#dup_with` now deep-duplicates nested containers: mutating a derived variant (`variant.allowed_tools << 'Bash'`) no longer bleeds into the base options and every sibling variant — including the security-relevant allow/deny lists. Leaf objects (callbacks, SDK MCP server instances, store adapters) keep identity, mirroring `Configuration#default_options`. Container deep-dup (both here and in `Configuration#default_options` merging) now also preserves Hash/Array subclasses — a Rails `HashWithIndifferentAccess` config no longer flattens into a plain Hash whose symbol lookups silently return nil.
23
+ - Sandbox gating in command building now matches Python's `sandbox is not None`: `sandbox: true` (the boolean toggle) no longer crashes with `NoMethodError: undefined method 'empty?' for true`, and an explicit `sandbox: false` / `{}` is forwarded to the CLI instead of silently dropped — so `sandbox: false` can actually override a sandbox enabled in the settings JSON.
24
+ - `output_format: { type: 'json_schema' }` with a nil/absent schema no longer emits `--json-schema null` (which the CLI rejects at spawn); the flag is skipped, matching Python's `schema is not None` guard.
25
+ - A non-Hash `message` field in a user/assistant CLI message now raises the documented `MessageParseError` instead of a raw `TypeError`.
26
+ - Assistant messages missing `message.model` now raise `MessageParseError` (Python parity) instead of silently constructing `AssistantMessage(model: nil)`.
27
+
10
28
  ## [0.19.1] - 2026-07-03
11
29
 
12
30
  Zero-risk fix batch from the 2026-07-03 full-codebase audit (`AUDIT-2026-07-03.md`, PR #41).
data/README.md CHANGED
@@ -68,7 +68,7 @@ Add this line to your application's Gemfile:
68
68
  gem 'claude-agent-sdk', github: 'ya-luotao/claude-agent-sdk-ruby'
69
69
 
70
70
  # Or use a stable version from RubyGems
71
- gem 'claude-agent-sdk', '~> 0.19.1'
71
+ gem 'claude-agent-sdk', '~> 0.20.0'
72
72
  ```
73
73
 
74
74
  Then `bundle install`, or install directly: `gem install claude-agent-sdk`.
@@ -156,8 +156,13 @@ module ClaudeAgentSDK
156
156
  cmd.push("--resume-session-at", @options.resume_session_at.to_s)
157
157
  end
158
158
 
159
+ # Sandbox gating is `!nil?` throughout — Python's `sandbox is not None`.
160
+ # Booleans and {} are forwarded verbatim: an explicit `sandbox: false`
161
+ # must reach the CLI so it can override a sandbox enabled in the
162
+ # settings JSON (`sandbox: true`, the boolean toggle, used to crash on
163
+ # true.empty?; false and {} were silently dropped).
159
164
  def append_settings(cmd)
160
- return unless @options.settings || @options.sandbox
165
+ return unless @options.settings || !@options.sandbox.nil?
161
166
 
162
167
  settings_hash = {}
163
168
  settings_is_path = false
@@ -167,11 +172,11 @@ module ClaudeAgentSDK
167
172
  begin
168
173
  settings_hash = JSON.parse(@options.settings)
169
174
  rescue JSON::ParserError
170
- if @options.sandbox
171
- settings_hash = load_settings_file(@options.settings)
172
- else
175
+ if @options.sandbox.nil?
173
176
  settings_is_path = true
174
177
  cmd.push("--settings", @options.settings)
178
+ else
179
+ settings_hash = load_settings_file(@options.settings)
175
180
  end
176
181
  end
177
182
  elsif @options.settings.is_a?(Hash)
@@ -179,9 +184,8 @@ module ClaudeAgentSDK
179
184
  end
180
185
  end
181
186
 
182
- if !settings_is_path && @options.sandbox
183
- sandbox_hash = @options.sandbox.is_a?(SandboxSettings) ? @options.sandbox.to_h : @options.sandbox
184
- settings_hash[:sandbox] = sandbox_hash unless sandbox_hash.empty?
187
+ if !settings_is_path && !@options.sandbox.nil?
188
+ settings_hash[:sandbox] = @options.sandbox.is_a?(SandboxSettings) ? @options.sandbox.to_h : @options.sandbox
185
189
  end
186
190
 
187
191
  cmd.push("--settings", JSON.generate(settings_hash)) if !settings_is_path && !settings_hash.empty?
@@ -292,6 +296,11 @@ module ClaudeAgentSDK
292
296
  else
293
297
  @options.output_format
294
298
  end
299
+ # A json_schema output_format with a nil/absent schema must skip the
300
+ # flag — `--json-schema null` is rejected by the CLI (Python guards
301
+ # `schema is not None`).
302
+ return if schema.nil?
303
+
295
304
  schema_json = schema.is_a?(String) ? schema : JSON.generate(schema)
296
305
  cmd.push("--json-schema", schema_json)
297
306
  end
@@ -46,6 +46,9 @@ module ClaudeAgentSDK
46
46
  tool_use_result = data[:tool_use_result]
47
47
  message_data = data[:message]
48
48
  raise MessageParseError.new("Missing message field in user message", data: data) unless message_data
49
+ # A non-Hash message (malformed CLI output) raised a raw TypeError from
50
+ # message_data[:content] instead of the documented MessageParseError.
51
+ raise MessageParseError.new("Invalid message field in user message (expected Hash, got #{message_data.class})", data: data) unless message_data.is_a?(Hash)
49
52
 
50
53
  content = message_data[:content]
51
54
  raise MessageParseError.new("Missing content in user message", data: data) unless content
@@ -61,19 +64,27 @@ module ClaudeAgentSDK
61
64
  end
62
65
 
63
66
  def self.parse_assistant_message(data)
64
- content = data.dig(:message, :content)
67
+ message_data = data[:message]
68
+ # A non-Hash message (malformed CLI output) raised a raw TypeError from
69
+ # dig instead of the documented MessageParseError.
70
+ raise MessageParseError.new("Invalid message field in assistant message (expected Hash, got #{message_data.class})", data: data) unless message_data.is_a?(Hash)
71
+
72
+ content = message_data[:content]
65
73
  raise MessageParseError.new("Missing content in assistant message", data: data) unless content
66
74
  raise MessageParseError.new("Invalid assistant content (expected Array, got #{content.class})", data: data) unless content.is_a?(Array)
67
75
 
68
76
  content_blocks = parse_content_blocks(content, data)
69
77
  AssistantMessage.new(
70
78
  content: content_blocks,
71
- model: data.dig(:message, :model),
79
+ # model is required, like Python — fetch raises KeyError when absent,
80
+ # which parse's rescue wraps into MessageParseError, instead of
81
+ # silently constructing model: nil.
82
+ model: message_data.fetch(:model),
72
83
  parent_tool_use_id: data[:parent_tool_use_id],
73
84
  error: data[:error], # authentication_failed, billing_error, rate_limit, invalid_request, server_error, unknown
74
- usage: data.dig(:message, :usage),
75
- message_id: data.dig(:message, :id),
76
- stop_reason: data.dig(:message, :stop_reason),
85
+ usage: message_data[:usage],
86
+ message_id: message_data[:id],
87
+ stop_reason: message_data[:stop_reason],
77
88
  session_id: data[:session_id],
78
89
  uuid: data[:uuid]
79
90
  )
@@ -1139,7 +1139,11 @@ module ClaudeAgentSDK
1139
1139
  def receive_messages(&block)
1140
1140
  return enum_for(:receive_messages) unless block
1141
1141
 
1142
- loop do
1142
+ # NOT Kernel#loop: it rescues StopIteration, so a user block leaking one
1143
+ # (e.g. calling .next on an exhausted Enumerator) would silently end
1144
+ # reception — ResultMessage dropped, the query reported as complete, and
1145
+ # on_error never fired. `while` propagates it like any other error.
1146
+ while true # rubocop:disable Style/InfiniteLoop
1143
1147
  message = @message_queue.dequeue
1144
1148
  break if message[:type] == 'end'
1145
1149
  raise message[:error] if message[:type] == 'error'
@@ -151,7 +151,7 @@ module ClaudeAgentSDK
151
151
  end
152
152
  return nil if sessions.nil? || sessions.empty?
153
153
 
154
- sessions.sort_by { |s| -(s['mtime'] || 0) }.each do |cand|
154
+ sessions.sort_by { |s| -sortable_mtime(s['mtime']) }.each do |cand|
155
155
  sid = cand['session_id']
156
156
  next unless sid.is_a?(String) && sid.match?(Sessions::UUID_RE)
157
157
 
@@ -166,6 +166,22 @@ module ClaudeAgentSDK
166
166
  nil
167
167
  end
168
168
 
169
+ # Adapters contractually report mtime as an epoch-ms Numeric (the
170
+ # conformance suite asserts it), but SQL timestamps naturally arrive as
171
+ # ISO-8601 Strings through JSON. Unary minus on a String is String#-@
172
+ # (frozen-string dedup), so String mtimes sorted lexicographically
173
+ # ASCENDING — --continue silently resumed the OLDEST session — and mixed
174
+ # Integer/String lists raised a bare ArgumentError. Coerce defensively:
175
+ # numeric strings and ISO-8601 both order correctly; anything else sorts
176
+ # last rather than crashing resume.
177
+ def sortable_mtime(value)
178
+ case value
179
+ when Numeric then value
180
+ when String then Float(value, exception: false) || Sessions.parse_iso_timestamp_ms(value) || 0
181
+ else 0
182
+ end
183
+ end
184
+
169
185
  # Run a store call (user code) on a plain thread bounded by timeout_s,
170
186
  # re-raising failures/timeouts as RuntimeError with context. The thread hop
171
187
  # (FiberBoundary with a timeout always hops) both keeps the async scheduler
@@ -436,7 +452,7 @@ module ClaudeAgentSDK
436
452
  value && (!value.respond_to?(:empty?) || !value.empty?) ? value : nil
437
453
  end
438
454
 
439
- private_class_method :load_candidate, :resolve_continue_candidate, :with_timeout, :write_jsonl,
455
+ private_class_method :load_candidate, :resolve_continue_candidate, :sortable_mtime, :with_timeout, :write_jsonl,
440
456
  :copy_auth_files, :write_redacted_credentials, :read_keychain_credentials,
441
457
  :capture_with_timeout, :materialize_subkeys, :write_subagent_files,
442
458
  :resolve_dir, :read_file_if_present, :chmod_owner_only, :copy_if_present, :env_value
@@ -236,6 +236,19 @@ module ClaudeAgentSDK
236
236
  str
237
237
  end
238
238
 
239
+ # Python's `x or None` for the summary/title fallback chains: Ruby's ||
240
+ # treats "" as truthy, so a CLI title-clearing entry ({"customTitle":""})
241
+ # would win over a real first prompt and then fail the summary presence
242
+ # check — silently dropping the whole session from disk listings (the
243
+ # store path, SessionSummary.presence, already falls through correctly).
244
+ # Whitespace-only counts as blank because the final gate strips.
245
+ def presence(val)
246
+ return nil if val.nil?
247
+ return nil if val.is_a?(String) && val.strip.empty?
248
+
249
+ val
250
+ end
251
+
239
252
  # Extract the first meaningful user prompt from the head of a JSONL file
240
253
  def extract_first_prompt_from_head(head)
241
254
  command_fallback = nil
@@ -247,16 +260,8 @@ module ClaudeAgentSDK
247
260
  next if line.include?('"isCompactSummary":true') || line.include?('"isCompactSummary": true')
248
261
 
249
262
  entry = JSON.parse(line, symbolize_names: false)
250
- content = entry.dig('message', 'content')
251
- next unless content
252
-
253
- texts = if content.is_a?(String)
254
- [content]
255
- elsif content.is_a?(Array)
256
- content.filter_map { |block| block['text'] if block.is_a?(Hash) && block['type'] == 'text' }
257
- else
258
- next
259
- end
263
+ texts = user_entry_texts(entry)
264
+ next unless texts
260
265
 
261
266
  texts.each do |text|
262
267
  text = text.gsub(/\n+/, ' ').strip
@@ -278,6 +283,29 @@ module ClaudeAgentSDK
278
283
  command_fallback || ''
279
284
  end
280
285
 
286
+ # Text blocks of a genuine user entry, or nil when the line should be
287
+ # skipped. Shape guards ported from Python: the byte pre-filter can match
288
+ # `"type":"user"` nested inside a tool_use input on an assistant line, so
289
+ # the parsed type is rechecked; and a malformed head line (non-Hash entry,
290
+ # string `message`, non-string `text`) must skip just that line rather
291
+ # than blow up into read_session_lite's blanket rescue and silently drop
292
+ # the whole session from disk listings.
293
+ def user_entry_texts(entry)
294
+ return nil unless entry.is_a?(Hash) && entry['type'] == 'user'
295
+
296
+ message = entry['message']
297
+ return nil unless message.is_a?(Hash)
298
+
299
+ content = message['content']
300
+ if content.is_a?(String)
301
+ [content]
302
+ elsif content.is_a?(Array)
303
+ content.filter_map do |block|
304
+ block['text'] if block.is_a?(Hash) && block['type'] == 'text' && block['text'].is_a?(String)
305
+ end
306
+ end
307
+ end
308
+
281
309
  # Read a single session file with lite (head/tail) strategy
282
310
  def read_session_lite(file_path, project_path)
283
311
  stat = File.stat(file_path)
@@ -311,15 +339,17 @@ module ClaudeAgentSDK
311
339
  def build_session_info(file_path, head, tail, stat, project_path)
312
340
  # User-set title (customTitle) wins over AI-generated title (aiTitle).
313
341
  # Head fallback covers short sessions where the title entry may not be in tail.
314
- custom_title = extract_json_string_field(tail, 'customTitle', last: true) ||
315
- extract_json_string_field(head, 'customTitle', last: true) ||
316
- extract_json_string_field(tail, 'aiTitle', last: true) ||
317
- extract_json_string_field(head, 'aiTitle', last: true)
342
+ # Each candidate passes through presence so a blank value (e.g. a
343
+ # trailing title-clearing entry) falls through instead of short-circuiting.
344
+ custom_title = presence(extract_json_string_field(tail, 'customTitle', last: true)) ||
345
+ presence(extract_json_string_field(head, 'customTitle', last: true)) ||
346
+ presence(extract_json_string_field(tail, 'aiTitle', last: true)) ||
347
+ presence(extract_json_string_field(head, 'aiTitle', last: true))
318
348
  first_prompt = extract_first_prompt_from_head(head)
319
349
  # lastPrompt tail entry shows what the user was most recently doing.
320
350
  summary = custom_title ||
321
- extract_json_string_field(tail, 'lastPrompt', last: true) ||
322
- extract_json_string_field(tail, 'summary', last: true) ||
351
+ presence(extract_json_string_field(tail, 'lastPrompt', last: true)) ||
352
+ presence(extract_json_string_field(tail, 'summary', last: true)) ||
323
353
  first_prompt
324
354
  return nil if summary.nil? || summary.strip.empty?
325
355
 
@@ -1235,7 +1265,7 @@ module ClaudeAgentSDK
1235
1265
  :find_session_file, :stat_candidate, :resolve_subagents_dir,
1236
1266
  :collect_agent_files, :parse_jsonl_entries,
1237
1267
  :build_conversation_chain, :walk_to_leaf, :walk_to_root,
1238
- :filter_visible_messages, :read_head_tail, :build_session_info,
1268
+ :filter_visible_messages, :read_head_tail, :build_session_info, :presence, :user_entry_texts,
1239
1269
  :list_sessions_via_summaries, :paginate_resolving_gaps, :resolve_gap_slot,
1240
1270
  :derive_info_from_entries, :mtime_from_entries, :apply_sort_limit_offset,
1241
1271
  :filter_transcript_entries, :entries_to_messages,
@@ -556,9 +556,14 @@ module ClaudeAgentSDK
556
556
  # concurrently and reset it) or already waited on (Errno::ECHILD on
557
557
  # double-wait). Both are non-fatal — the message loop just exits.
558
558
  returncode = nil
559
+ termsig = nil
559
560
  begin
560
561
  status = @process&.value
562
+ # exitstatus is nil when the child died from a signal (OOM-kill
563
+ # SIGKILL, SIGSEGV, ...) — that end-of-stream is a TRUNCATED response,
564
+ # not a clean success. Python surfaces it as a negative returncode.
561
565
  returncode = status&.exitstatus
566
+ termsig = status.termsig if status&.signaled?
562
567
  rescue Errno::ECHILD
563
568
  # Process was already reaped (e.g., by close()); no exit status to surface.
564
569
  returncode = nil
@@ -571,7 +576,7 @@ module ClaudeAgentSDK
571
576
  # #close still sees @process (left set here) for its termination logic.
572
577
  self.class.deregister_active_process(@process)
573
578
 
574
- if returncode && returncode != 0
579
+ if termsig || (returncode && returncode != 0)
575
580
  # Wait briefly for stderr thread to finish draining
576
581
  @stderr_task&.join(1)
577
582
 
@@ -579,8 +584,9 @@ module ClaudeAgentSDK
579
584
  stderr_text = 'No stderr output captured' if stderr_text.empty?
580
585
 
581
586
  @exit_error = ProcessError.new(
582
- "Command failed with exit code #{returncode}",
583
- exit_code: returncode,
587
+ termsig ? "Command terminated by signal #{termsig}" : "Command failed with exit code #{returncode}",
588
+ # Negative-signal exit_code mirrors Python's subprocess returncode.
589
+ exit_code: termsig ? -termsig : returncode,
584
590
  stderr: stderr_text
585
591
  )
586
592
  raise @exit_error
@@ -1586,6 +1586,15 @@ module ClaudeAgentSDK
1586
1586
 
1587
1587
  def dup_with(**changes)
1588
1588
  new_options = self.dup
1589
+ # A shallow #dup shares nested containers, so mutating a derived copy
1590
+ # (e.g. `variant.allowed_tools << 'Bash'`) would bleed into the base and
1591
+ # every sibling — including the security-relevant allow/deny lists.
1592
+ # Deep-dup Hash/Array containers only; non-container values (procs,
1593
+ # SDK MCP server instances, store adapters) must keep their identity.
1594
+ new_options.instance_variables.each do |ivar|
1595
+ value = new_options.instance_variable_get(ivar)
1596
+ new_options.instance_variable_set(ivar, deep_dup_containers(value)) if value.is_a?(Hash) || value.is_a?(Array)
1597
+ end
1589
1598
  changes.each { |key, value| new_options[key] = value }
1590
1599
  new_options
1591
1600
  end
@@ -1687,10 +1696,20 @@ module ClaudeAgentSDK
1687
1696
 
1688
1697
  # Recurse ONLY into Hash/Array; leaves keep object identity (observer
1689
1698
  # factories, callbacks, SDK MCP server instances must not be duped).
1699
+ # Rebuild via dup.clear (never Hash#to_h / Array#map) to preserve
1700
+ # container SUBCLASSES: to_h flattens e.g. Rails'
1701
+ # HashWithIndifferentAccess into a plain Hash, silently breaking symbol
1702
+ # lookups on the copy (config[:type] == 'sdk' → nil).
1690
1703
  def deep_dup_containers(value)
1691
1704
  case value
1692
- when Hash then value.to_h { |k, v| [k, deep_dup_containers(v)] }
1693
- when Array then value.map { |v| deep_dup_containers(v) }
1705
+ when Hash
1706
+ copy = value.dup.clear
1707
+ value.each { |k, v| copy[k] = deep_dup_containers(v) }
1708
+ copy
1709
+ when Array
1710
+ copy = value.dup.clear
1711
+ value.each { |v| copy << deep_dup_containers(v) }
1712
+ copy
1694
1713
  else value
1695
1714
  end
1696
1715
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeAgentSDK
4
- VERSION = '0.19.1'
4
+ VERSION = '0.20.0'
5
5
  end
@@ -62,6 +62,23 @@ module ClaudeAgentSDK
62
62
  servers
63
63
  end
64
64
 
65
+ # Internal: pull exclude_dynamic_sections out of a preset system prompt for
66
+ # the initialize request (older CLIs ignore unknown initialize fields).
67
+ # Shared by Client#connect and the one-shot query() path.
68
+ def self.extract_exclude_dynamic_sections(system_prompt)
69
+ if system_prompt.is_a?(SystemPromptPreset)
70
+ eds = system_prompt.exclude_dynamic_sections
71
+ return eds if [true, false].include?(eds)
72
+ elsif system_prompt.is_a?(Hash)
73
+ type = system_prompt[:type] || system_prompt['type']
74
+ if type == 'preset'
75
+ eds = system_prompt.fetch(:exclude_dynamic_sections) { system_prompt['exclude_dynamic_sections'] }
76
+ return eds if [true, false].include?(eds)
77
+ end
78
+ end
79
+ nil
80
+ end
81
+
65
82
  # Safely call a method on each observer, suppressing any errors.
66
83
  # Each observer is invoked through FiberBoundary so that user code runs
67
84
  # on a plain thread (no Fiber scheduler) even when called from inside
@@ -371,6 +388,13 @@ module ClaudeAgentSDK
371
388
  # puts message
372
389
  # end
373
390
  def self.query(prompt:, options: nil, transport: nil, &block)
391
+ # Validate BEFORE the block-less enum_for return so a bad prompt fails at
392
+ # the call site, not on first iteration. Mirrors Client#query: a bare Hash
393
+ # responds to #each and would stream [key, value] pairs' to_s garbage to
394
+ # the CLI; nil/Integer would hang forever waiting for input.
395
+ raise ArgumentError, 'prompt must be a String or an Enumerable of message Hashes/JSONL Strings (got Hash)' if prompt.is_a?(Hash)
396
+ raise ArgumentError, "prompt must be a String or respond to #each (got #{prompt.class})" unless prompt.is_a?(String) || prompt.respond_to?(:each)
397
+
374
398
  return enum_for(:query, prompt: prompt, options: options, transport: transport) unless block
375
399
 
376
400
  options ||= ClaudeAgentOptions.new
@@ -451,6 +475,7 @@ module ClaudeAgentSDK
451
475
  hooks: hooks,
452
476
  agents: configured_options.agents,
453
477
  sdk_mcp_servers: sdk_mcp_servers,
478
+ exclude_dynamic_sections: ClaudeAgentSDK.extract_exclude_dynamic_sections(configured_options.system_prompt),
454
479
  skills: configured_options.skills
455
480
  )
456
481
 
@@ -935,7 +960,7 @@ module ClaudeAgentSDK
935
960
 
936
961
  # Extract exclude_dynamic_sections from preset system prompt for the
937
962
  # initialize request (older CLIs ignore unknown initialize fields)
938
- exclude_dynamic_sections = extract_exclude_dynamic_sections(configured_options.system_prompt)
963
+ exclude_dynamic_sections = ClaudeAgentSDK.extract_exclude_dynamic_sections(configured_options.system_prompt)
939
964
 
940
965
  # Create Query handler
941
966
  @query_handler = Query.new(
@@ -1058,20 +1083,6 @@ module ClaudeAgentSDK
1058
1083
  internal_hooks
1059
1084
  end
1060
1085
 
1061
- def extract_exclude_dynamic_sections(system_prompt)
1062
- if system_prompt.is_a?(SystemPromptPreset)
1063
- eds = system_prompt.exclude_dynamic_sections
1064
- return eds if [true, false].include?(eds)
1065
- elsif system_prompt.is_a?(Hash)
1066
- type = system_prompt[:type] || system_prompt['type']
1067
- if type == 'preset'
1068
- eds = system_prompt.fetch(:exclude_dynamic_sections) { system_prompt['exclude_dynamic_sections'] }
1069
- return eds if [true, false].include?(eds)
1070
- end
1071
- end
1072
- nil
1073
- end
1074
-
1075
1086
  def writeln(string)
1076
1087
  write string.end_with?("\n") ? string : "#{string}\n"
1077
1088
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: claude-agent-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.19.1
4
+ version: 0.20.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Community Contributors