diff --git a/CHANGELOG.md b/CHANGELOG.md index b7a39e04..6653b169 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Internal +- Hash-form option interpretation moved into one private module (`OptionForms`); no behaviour change. + ## [1.2.0] - 2026-10-03 Fixes from a review of the whole SDK, a reference for every option (`docs/options.md`), and Claude Code 2.1.288 as the pinned CLI. Settings the SDK used to drop without an error now take effect — hook outputs and `sandbox:` Hashes in Ruby spelling, a `tools:` String, option Hashes with a Symbol `type` — so read the entries in bold before upgrading: they say what changes for code that relied on the old behavior. diff --git a/lib/claude_agent_sdk.rb b/lib/claude_agent_sdk.rb index 81315bba..f5d12fec 100644 --- a/lib/claude_agent_sdk.rb +++ b/lib/claude_agent_sdk.rb @@ -19,6 +19,7 @@ require_relative 'claude_agent_sdk/session_resume' require_relative 'claude_agent_sdk/session_mutations' require_relative 'claude_agent_sdk/fiber_boundary' +require_relative 'claude_agent_sdk/option_forms' require_relative 'claude_agent_sdk/option_warnings' require_relative 'claude_agent_sdk/deprecation' # Rails apps only: Bundler.require runs after `require 'rails'`, so the @@ -66,16 +67,7 @@ def self.resolve_observers(observers) # strips the instance from exactly these entries. # @api private def self.extract_sdk_mcp_servers(mcp_servers) - return {} unless mcp_servers.is_a?(Hash) - - servers = {} - mcp_servers.each do |name, config| - config = config.to_h if config.is_a?(Type) - next unless config.is_a?(Hash) && (config[:type] || config['type']).to_s == 'sdk' - - servers[name] = config.key?(:instance) ? config[:instance] : config['instance'] - end - servers + OptionForms.sdk_mcp_servers(mcp_servers) end # Internal: normalize hook lists for the control protocol. An absent or @@ -138,18 +130,7 @@ def self.configure_can_use_tool(options) # Shared by Client#connect and the one-shot query() path. # @api private def self.extract_exclude_dynamic_sections(system_prompt) - if system_prompt.is_a?(SystemPromptPreset) - eds = system_prompt.exclude_dynamic_sections - return eds if [true, false].include?(eds) - elsif system_prompt.is_a?(Hash) - # The tag may be a Symbol (type: :preset), as CommandBuilder reads it. - type = (system_prompt[:type] || system_prompt['type']).to_s - if type == 'preset' - eds = system_prompt.fetch(:exclude_dynamic_sections) { system_prompt['exclude_dynamic_sections'] } - return eds if [true, false].include?(eds) - end - end - nil + OptionForms.exclude_dynamic_sections(system_prompt) end # Internal: pull snapshot out of a preset or custom system prompt for the @@ -159,18 +140,7 @@ def self.extract_exclude_dynamic_sections(system_prompt) # must not collapse it to nil. Shared by Client#connect and query(). # @api private def self.extract_system_prompt_snapshot(system_prompt) - case system_prompt - when SystemPromptPreset, SystemPromptCustom - snapshot = system_prompt.snapshot - return snapshot if [true, false].include?(snapshot) - when Hash - type = (system_prompt[:type] || system_prompt['type']).to_s - if %w[preset custom].include?(type) - snapshot = system_prompt.fetch(:snapshot) { system_prompt['snapshot'] } - return snapshot if [true, false].include?(snapshot) - end - end - nil + OptionForms.system_prompt_snapshot(system_prompt) end # Safely call a method on each observer, suppressing any errors. diff --git a/lib/claude_agent_sdk/command_builder.rb b/lib/claude_agent_sdk/command_builder.rb index e190cf82..0e9cf217 100644 --- a/lib/claude_agent_sdk/command_builder.rb +++ b/lib/claude_agent_sdk/command_builder.rb @@ -3,6 +3,7 @@ require 'json' require_relative 'errors' require_relative 'types' +require_relative 'option_forms' module ClaudeAgentSDK # Builds the CLI argv array from a ClaudeAgentOptions instance. @@ -68,57 +69,34 @@ def build private + # What the option stands for, typed or as a Hash, is OptionForms' to say; + # a kind that is none of these (a preset without an append, a Hash the + # SDK does not recognize, ...) sends no prompt flag at all. def append_system_prompt(cmd) - case @options.system_prompt - when nil + prompt = OptionForms.system_prompt(@options.system_prompt) + case prompt.kind + when :empty # When nil, pass empty string to ensure predictable behavior without default Claude Code system prompt cmd.push('--system-prompt', '') - when String - cmd.push('--system-prompt', @options.system_prompt) - when SystemPromptFile - cmd.push('--system-prompt-file', path_string(@options.system_prompt.path)) - when SystemPromptCustom - # The object form of a String prompt; snapshot travels on the + when :text + # A String, or its object form (custom); snapshot travels on the # initialize request, not as a CLI flag. - cmd.push('--system-prompt', custom_prompt_text(@options.system_prompt.prompt)) - when SystemPromptPreset + cmd.push('--system-prompt', custom_prompt_text(prompt.value)) + when :file + cmd.push('--system-prompt-file', path_string(prompt.value)) + when :append # Preset activates the default Claude Code system prompt by not passing --system-prompt "" # Only --append-system-prompt is passed if append text is provided - cmd.push('--append-system-prompt', @options.system_prompt.append) if @options.system_prompt.append - when Hash - append_hash_system_prompt(cmd, @options.system_prompt) + cmd.push('--append-system-prompt', prompt.value) end end - # The type tag of a Hash option, as a String: `type: :preset` is the - # natural Ruby spelling of `type: 'preset'`, and thinking and the MCP - # server configs already read it that way. A missing tag is '', which - # matches no branch, like any other tag the SDK does not know. - def hash_type(hash) - (hash[:type] || hash['type']).to_s - end - # A path as the String the command line takes. A Pathname (anything that # answers #to_path) is converted; every other value is returned as it is. def path_string(path) path.respond_to?(:to_path) ? path.to_path : path end - def append_hash_system_prompt(cmd, prompt_hash) - case hash_type(prompt_hash) - when 'file' - prompt_path = prompt_hash[:path] || prompt_hash['path'] - cmd.push('--system-prompt-file', path_string(prompt_path)) if prompt_path - when 'custom' - prompt = prompt_hash.fetch(:prompt) { prompt_hash['prompt'] } - cmd.push('--system-prompt', custom_prompt_text(prompt)) - when 'preset' - append = prompt_hash[:append] || prompt_hash['append'] - # Preset activates the default Claude Code system prompt by not passing --system-prompt "" - cmd.push('--append-system-prompt', append) if append - end - end - # A custom prompt is always forwarded, even when empty (an empty String # suppresses the default Claude Code prompt, exactly like a nil # system_prompt). A missing prompt is rejected loudly rather than @@ -338,7 +316,7 @@ def append_settings(cmd) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticCo # read from JSON spell that key as a String; left next to the Symbol # key below it would be written twice (json 3.x raises on that). settings_hash = settings_hash.reject { |key, _| key.to_s == 'sandbox' } - settings_hash[:sandbox] = sandbox_section(@options.sandbox) + settings_hash[:sandbox] = OptionForms.sandbox(@options.sandbox) end cmd.push('--settings', JSON.generate(settings_hash)) if !settings_is_path && !settings_hash.empty? @@ -355,29 +333,10 @@ def settings_file(cmd, path) [{}, true] end - # The sandbox section as the CLI reads it. A Hash stands for the - # SandboxSettings with the same fields: the CLI only knows the camelCase - # keys that class writes, and it ignores the others without an error, so - # a Hash in Ruby spelling (deny_read, denied_domains) is renamed like the - # typed value would be (SandboxKeys). Booleans go out as they are. - def sandbox_section(sandbox) - case sandbox - when SandboxSettings then sandbox.to_h - when Hash then SandboxKeys.normalize(sandbox) - else sandbox - end - end - def append_budget(cmd) cmd.push('--max-budget-usd', @options.max_budget_usd.to_s) if @options.max_budget_usd - return unless @options.task_budget - - total = if @options.task_budget.is_a?(TaskBudget) - @options.task_budget.total - else - @options.task_budget[:total] || @options.task_budget['total'] - end + total = OptionForms.task_budget_total(@options.task_budget) cmd.push('--task-budget', total.to_s) if total end @@ -389,16 +348,16 @@ def append_budget(cmd) # max_thinking_tokens fallback. def append_thinking(cmd) if @options.thinking - type, budget, display = thinking_fields(@options.thinking) - case type + thinking = OptionForms.thinking(@options.thinking) + case thinking.type when 'adaptive' cmd.push('--thinking', 'adaptive') - append_thinking_display(cmd, display) + append_thinking_display(cmd, thinking.display) when 'enabled' - raise ArgumentError, "thinking type 'enabled' requires budget_tokens" if budget.nil? + raise ArgumentError, "thinking type 'enabled' requires budget_tokens" if thinking.budget_tokens.nil? - cmd.push('--max-thinking-tokens', budget.to_s) - append_thinking_display(cmd, display) + cmd.push('--max-thinking-tokens', thinking.budget_tokens.to_s) + append_thinking_display(cmd, thinking.display) when 'disabled' cmd.push('--thinking', 'disabled') else @@ -409,20 +368,6 @@ def append_thinking(cmd) end end - # Explicit class dispatch — never respond_to? probes (Kernel#display - # exists on every object and PRINTS the receiver to $stdout). - def thinking_fields(thinking) - case thinking - when Hash - type = (thinking[:type] || thinking['type'])&.to_s - [type, thinking[:budget_tokens] || thinking['budget_tokens'], thinking[:display] || thinking['display']] - when ThinkingConfigAdaptive then [thinking.type, nil, thinking.display] - when ThinkingConfigEnabled then [thinking.type, thinking.budget_tokens, thinking.display] - when ThinkingConfigDisabled then [thinking.type, nil, nil] - else [nil, nil, nil] # falls into append_thinking's else -> ArgumentError - end - end - # `--thinking-display` toggles between `"summarized"` (visible thinking # text) and `"omitted"` (empty thinking, signature only). Current models default # to `"omitted"`, so pass `display: "summarized"` to see reasoning. @@ -448,28 +393,27 @@ def append_betas(cmd) def append_tools(cmd) return if @options.tools.nil? - case @options.tools + # The preset, typed or as a Hash, is OptionForms' to recognize; every + # other value comes back as it was given and is only encoded here. + tools = OptionForms.tools(@options.tools) + case tools + when OptionForms::DEFAULT_TOOLS + cmd.push('--tools', 'default') when Array - tools_value = @options.tools.empty? ? '' : @options.tools.join(',') + tools_value = tools.empty? ? '' : tools.join(',') cmd.push('--tools', tools_value) when String # The CLI's own syntax ("Read,Grep", "default", ""): passed as written. - cmd.push('--tools', @options.tools) - when ToolsPreset - cmd.push('--tools', 'default') + cmd.push('--tools', tools) when Hash - if hash_type(@options.tools) == 'preset' - cmd.push('--tools', 'default') - else - cmd.push('--tools', JSON.generate(@options.tools)) - end + cmd.push('--tools', JSON.generate(tools)) end end def append_output_format(cmd) return unless @options.output_format - schema = output_schema(@options.output_format) + schema = OptionForms.output_schema(@options.output_format) # A json_schema output_format with a nil/absent schema must skip the # flag — `--json-schema null` is rejected by the CLI (Python guards # `schema is not None`). @@ -479,22 +423,6 @@ def append_output_format(cmd) cmd.push('--json-schema', schema_json) end - # The schema of a { type: 'json_schema', schema: ... } output format; any - # other value is the schema itself. The tag may be a Symbol, and `schema` - # is read under the key style `type` was written in, or under the other - # one when that key is absent ({ 'type' => 'json_schema', schema: {...} }). - def output_schema(format) - return format unless format.is_a?(Hash) - - if format[:type].to_s == 'json_schema' - format.fetch(:schema) { format['schema'] } - elsif format['type'].to_s == 'json_schema' - format.fetch('schema') { format[:schema] } - else - format - end - end - def append_additional_dirs(cmd) (@options.add_dirs || []).each { |dir| cmd.push('--add-dir', dir.to_s) } end @@ -502,25 +430,15 @@ def append_additional_dirs(cmd) def append_mcp_servers(cmd) return unless @options.mcp_servers && !@options.mcp_servers.empty? - if @options.mcp_servers.is_a?(Hash) - servers_for_cli = {} - @options.mcp_servers.each do |name, config| - # Typed Mcp*ServerConfig objects serialize via their wire hash — - # without this they'd JSON-stringify as "#<...>" via to_s. - config = config.to_h if config.is_a?(Type) - # Same recognition rule as ClaudeAgentSDK.extract_sdk_mcp_servers: - # either key style (and a Symbol :sdk type). The live instance is - # never serialized — JSON.generate would raise on it or leak its - # #to_s onto the command line. - servers_for_cli[name] = if config.is_a?(Hash) && (config[:type] || config['type']).to_s == 'sdk' - config.except(:instance, 'instance') - else - config - end - end - cmd.push('--mcp-config', JSON.generate({ mcpServers: servers_for_cli })) unless servers_for_cli.empty? + # A Hash of servers comes back with typed configs as their wire Hashes + # and without the live instance of an SDK server, which is never + # serialized: JSON.generate would raise on it or leak its #to_s onto + # the command line. Any other value is a path or JSON text. + servers = OptionForms.mcp_servers(@options.mcp_servers) + if servers.is_a?(Hash) + cmd.push('--mcp-config', JSON.generate({ mcpServers: servers })) unless servers.empty? else - cmd.push('--mcp-config', @options.mcp_servers.to_s) + cmd.push('--mcp-config', servers.to_s) end end @@ -539,17 +457,17 @@ def append_boolean_flags(cmd) def append_plugins(cmd) return unless @options.plugins && !@options.plugins.empty? - @options.plugins.each do |plugin| - plugin_config = plugin.is_a?(SdkPluginConfig) ? plugin.to_h : plugin - plugin_path = plugin_config[:path] || plugin_config['path'] + @options.plugins.each do |entry| + plugin = OptionForms.plugin(entry) - unless %w[local plugin].include?(hash_type(plugin_config)) - plugin_type = plugin_config[:type] || plugin_config['type'] - raise ArgumentError, "Unsupported plugin type: #{plugin_type.inspect}" + unless %w[local plugin].include?(plugin.type_tag) + # raw_type looks the type up again, as it was written; asked for + # only here, so an accepted plugin has its type read once. + raise ArgumentError, "Unsupported plugin type: #{plugin.raw_type.inspect}" end - next unless plugin_path + next unless plugin.path - cmd.push('--plugin-dir', path_string(plugin_path)) + cmd.push('--plugin-dir', path_string(plugin.path)) end end diff --git a/lib/claude_agent_sdk/option_forms.rb b/lib/claude_agent_sdk/option_forms.rb new file mode 100644 index 00000000..90254d66 --- /dev/null +++ b/lib/claude_agent_sdk/option_forms.rb @@ -0,0 +1,379 @@ +# frozen_string_literal: true + +require_relative 'types' + +module ClaudeAgentSDK + # How the SDK reads a Hash that stands for an option value, behind one set + # of functions. Several options take a typed value "or the equivalent + # Hash"; each function here reads one such option in both forms and answers + # what its caller needs: a small frozen record (Prompt, Thinking, Plugin), + # a plain value, the value as it was given, or a new Hash made from it. + # Only the records are frozen, never a value the caller owns. + # + # The functions are stateless. They keep nothing, they do not change the + # value they are given, and they check nothing: a caller that raises on a + # bad value (CommandBuilder, for a custom prompt without text, an enabled + # thinking config without a budget, an unknown thinking or plugin type) + # still does so itself, from what a function answers. The one exception is + # .agent_definition, which builds a Hash through AgentDefinition.new, so a + # misspelled key raises there. Flag names, path conversion and JSON + # encoding stay with the caller as well. + # + # The readers were written one at a time, and each option kept the key rule + # its reader had. They are not one rule, on purpose: a Hash that carries + # both spellings of a key, or nil / false under one of them, reads as it + # always did. The rules are + # + # truthy hash[:key] || hash['key']: a nil or false Symbol key falls + # back to the String key + # presence hash.fetch(:key) { hash['key'] }: a Symbol key that is there + # is the one read, whatever it holds + # + # and HashForm, which holds the field lookups that used to sit in the + # consumers of these options, says for each key which of them applies. + # Two kinds of Hash are not read there but handed whole to the code that + # read them before, with rules of its own: a sandbox Hash to + # SandboxKeys.normalize (types/option_values.rb), by .sandbox, and an agent + # Hash to AgentDefinition.new and the Type attribute machinery, by + # .agent_definition. spec/unit/option_forms_characterization_spec.rb pins + # the rules at the command line and the initialize request; a change of + # rule is a change of behaviour, not a cleanup. + # + # @api private + module OptionForms + # What .system_prompt answers. +kind+ says what the command line gets: + # + # :empty --system-prompt '' (the option is nil) + # :none no prompt flag at all (see .system_prompt) + # :text --system-prompt value (value is whatever the custom + # form holds; the caller raises + # unless it is a String) + # :file --system-prompt-file value + # :append --append-system-prompt value + class Prompt + attr_reader :kind, :value + + def initialize(kind, value = nil) + @kind = kind + @value = value + freeze + end + + # +kind+ when there is a value to send, no prompt flag otherwise: a + # preset activates the default Claude Code prompt by sending no + # --system-prompt, with its append text only when it has any, and a + # file Hash names a file only when it has a path. + def self.optional(kind, value) + value ? new(kind, value) : new(:none) + end + end + + # What .thinking answers: the fields of a thinking config, typed or Hash. + # +type+ is nil for a value that is no thinking config. + class Thinking + attr_reader :type, :budget_tokens, :display + + def initialize(type: nil, budget_tokens: nil, display: nil) + @type = type + @budget_tokens = budget_tokens + @display = display + freeze + end + end + + # What .plugin answers. +type_tag+ is the type as a String, to compare. + class Plugin + attr_reader :type_tag, :path + + def initialize(type_tag:, path:, config:) + @type_tag = type_tag + @path = path + @config = config + freeze + end + + # The type as it was written, for the message of the caller that + # refuses the plugin. Read from the entry anew each time it is asked + # for, and not when the record is made: the reader this replaces looked + # the type up a second time for its message, and only once it had + # refused the plugin, which a Hash that computes its values can tell. + def raw_type + HashForm.plugin_type(@config) + end + end + + # The direct field lookups on an option Hash that were moved out of its + # consumers (CommandBuilder, the root extractors, the transport's sandbox + # warning), each under the rule its option has always had (truthy or + # presence, see OptionForms). The functions of OptionForms tell the forms + # of an option apart by class and come here for the Hash one. + # + # This is not every read of an option Hash in the SDK. The fields of a + # sandbox Hash are read and renamed by SandboxKeys.normalize + # (types/option_values.rb), which OptionForms.sandbox delegates to; only + # the `enabled` of the warning predicate is looked up here. The + # attributes of an agent Hash are assigned by the strict + # AgentDefinition.new, through the Type machinery, which + # OptionForms.agent_definition delegates to. + module HashForm + # The type tag, as a String: `type: :preset` is the natural Ruby + # spelling of `type: 'preset'`. Truthy, so a nil Symbol-keyed tag falls + # back to the String-keyed one. A missing tag is '', which is no tag + # the SDK knows. + def self.tag(hash) + (hash[:type] || hash['type']).to_s + end + + # system_prompt, by its tag: +path+ and +append+ are truthy, +prompt+ + # is by presence (a Symbol key holding nil is the prompt, and the + # caller refuses it). Any other tag, or none, is no prompt flag. + def self.system_prompt(hash) + case tag(hash) + when 'file' then Prompt.optional(:file, hash[:path] || hash['path']) + when 'custom' then Prompt.new(:text, hash.fetch(:prompt) { hash['prompt'] }) + when 'preset' then Prompt.optional(:append, hash[:append] || hash['append']) + else Prompt.new(:none) + end + end + + # +snapshot+ of a preset or custom system prompt, by presence. + def self.snapshot(hash) + hash.fetch(:snapshot) { hash['snapshot'] } if %w[preset custom].include?(tag(hash)) + end + + # +exclude_dynamic_sections+ of a preset system prompt, by presence. + def self.exclude_dynamic_sections(hash) + hash.fetch(:exclude_dynamic_sections) { hash['exclude_dynamic_sections'] } if tag(hash) == 'preset' + end + + # thinking: all three fields are truthy, so `budget_tokens: false` is + # no budget. The type stays nil when there is none; +display+ is not + # checked the way the typed classes check theirs. + def self.thinking(hash) + Thinking.new(type: (hash[:type] || hash['type'])&.to_s, + budget_tokens: hash[:budget_tokens] || hash['budget_tokens'], + display: hash[:display] || hash['display']) + end + + # output_format: the Symbol-keyed tag is looked at before the + # String-keyed one, and +schema+ is read by presence under the key + # style the matching tag was written in, or under the other one when + # that key is absent ({ 'type' => 'json_schema', schema: {...} }). A + # Hash that is not tagged json_schema is the schema itself. + def self.output_schema(hash) + if hash[:type].to_s == 'json_schema' + hash.fetch(:schema) { hash['schema'] } + elsif hash['type'].to_s == 'json_schema' + hash.fetch('schema') { hash[:schema] } + else + hash + end + end + + # task_budget: +total+ is truthy. + def self.total(hash) + hash[:total] || hash['total'] + end + + # One plugin: +path+ is truthy and read first, then the tag, once. + def self.plugin(hash) + path = hash[:path] || hash['path'] + Plugin.new(type_tag: tag(hash), path: path, config: hash) + end + + # The type of a plugin as it was written (truthy), for Plugin#raw_type. + def self.plugin_type(hash) + hash[:type] || hash['type'] + end + + # sandbox, for the warning alone (.sandbox_requested?): either key + # being true will do, whatever the other one holds. + def self.enabled?(hash) + hash[:enabled] == true || hash['enabled'] == true + end + + # The live server of an sdk MCP server config, by presence. + def self.instance(hash) + hash.key?(:instance) ? hash[:instance] : hash['instance'] + end + end + private_constant :HashForm + + # What .tools answers for the preset. An object of its own rather than a + # Symbol: a caller can write `tools: :default`, which is none of the forms + # of the option and sends no flag. + # + # @api private + DEFAULT_TOOLS = Object.new.freeze + + # The system prompt as the command line takes it (see Prompt). + # + # nil is the empty prompt, and it is the only value that is. No prompt + # flag at all, so that the CLI runs with its default prompt, is the answer + # for a preset without an append, a file Hash without a path, a Hash with + # an unknown tag or none, and a value that is none of the forms of the + # option. A typed SystemPromptFile is a file prompt whatever its path + # holds. + def self.system_prompt(value) + case value + when nil then Prompt.new(:empty) + when String then Prompt.new(:text, value) + when SystemPromptFile then Prompt.new(:file, value.path) + when SystemPromptCustom then Prompt.new(:text, value.prompt) + when SystemPromptPreset then Prompt.optional(:append, value.append) + when Hash then HashForm.system_prompt(value) + else Prompt.new(:none) + end + end + + # The +snapshot+ of a preset or custom system prompt, for the initialize + # request: true, false, or nil when there is none to send. Only a genuine + # true or false is answered (`snapshot: false` is the value callers set). + # The prompt text is not looked at: a session over a transport that + # builds no command line still gets its snapshot through. + def self.system_prompt_snapshot(value) + case value + when SystemPromptPreset, SystemPromptCustom then boolean(value.snapshot) + when Hash then boolean(HashForm.snapshot(value)) + end + end + + # The +exclude_dynamic_sections+ of a preset system prompt, for the + # initialize request: true, false, or nil when there is none to send. + def self.exclude_dynamic_sections(value) + case value + when SystemPromptPreset then boolean(value.exclude_dynamic_sections) + when Hash then boolean(HashForm.exclude_dynamic_sections(value)) + end + end + + # The fields of a thinking config (see Thinking). The typed classes are + # told apart by class, never by respond_to?: Kernel#display exists on + # every object and prints the receiver to $stdout. + def self.thinking(value) + case value + when Hash then HashForm.thinking(value) + when ThinkingConfigAdaptive then Thinking.new(type: value.type, display: value.display) + when ThinkingConfigEnabled + Thinking.new(type: value.type, budget_tokens: value.budget_tokens, display: value.display) + when ThinkingConfigDisabled then Thinking.new(type: value.type) + else Thinking.new + end + end + + # DEFAULT_TOOLS for the tools preset, typed or as a Hash tagged 'preset'; + # any other value as it was given (an Array of names, the CLI's own + # String syntax, a Hash the caller sends as JSON text). + def self.tools(value) + case value + when ToolsPreset then DEFAULT_TOOLS + when Hash then HashForm.tag(value) == 'preset' ? DEFAULT_TOOLS : value + else value + end + end + + # The schema of a { type: 'json_schema', schema: ... } output format; any + # other value is the schema itself. The value found is answered as it + # is, false included; only the caller leaves out nil. + def self.output_schema(value) + value.is_a?(Hash) ? HashForm.output_schema(value) : value + end + + # The total of a task budget, or nil when there is none. Any value that + # is not a TaskBudget is read as a Hash, which is how one that is no + # budget at all still fails loudly. + def self.task_budget_total(value) + return unless value + return value.total if value.is_a?(TaskBudget) + + HashForm.total(value) + end + + # One entry of +plugins+ (see Plugin); a typed SdkPluginConfig is read + # through its #to_h, taken once. The caller refuses a type that is no + # plugin type, asking for Plugin#raw_type only then, and skips an entry + # without a path. + def self.plugin(value) + HashForm.plugin(value.is_a?(SdkPluginConfig) ? value.to_h : value) + end + + # The sandbox section as the CLI reads it. A Hash stands for the + # SandboxSettings with the same fields: the CLI only knows the camelCase + # keys that class writes, and it ignores the others without an error, so + # a Hash in Ruby spelling (deny_read, denied_domains) is renamed like the + # typed value would be (SandboxKeys, which has key rules of its own). + # Every other value, booleans and nil included, is answered as it is. + def self.sandbox(value) + case value + when SandboxSettings then value.to_h + when Hash then SandboxKeys.normalize(value) + else value + end + end + + # True when the option enables the sandbox: `true`, a SandboxSettings + # with +enabled+ true, or a Hash that says so under either key, which is + # not what .sandbox sends for a Hash carrying both. Deliberately a + # shallow read and nothing else: the transport asks from its stderr + # threads, where walking the Hash or calling a #to_h a user can override + # has no place. + def self.sandbox_requested?(value) + case value + when SandboxSettings then value.enabled == true + when Hash then HashForm.enabled?(value) + else value == true + end + end + + # The MCP servers as the command line takes them. A Hash of servers is + # answered as a new Hash: a typed Mcp*ServerConfig as its wire Hash + # (as JSON it would otherwise read "#<...>"), and an sdk entry + # (.sdk_server?) without its +instance+, under either key: the live + # server is never serialized. Any other value (the path of a config + # file, JSON text) is answered as it was given. + def self.mcp_servers(value) + return value unless value.is_a?(Hash) + + servers = {} + value.each do |name, config| + config = config.to_h if config.is_a?(Type) + servers[name] = sdk_server?(config) ? config.except(:instance, 'instance') : config + end + servers + end + + # The live SDK MCP servers of an +mcp_servers+ Hash, by server name: the + # +instance+ of every sdk entry, typed or Hash. The entries are + # recognized exactly as .mcp_servers recognizes them, which strips the + # instance from the same ones. Empty for any other value. + def self.sdk_mcp_servers(value) + return {} unless value.is_a?(Hash) + + servers = {} + value.each do |name, config| + config = config.to_h if config.is_a?(Type) + servers[name] = HashForm.instance(config) if sdk_server?(config) + end + servers + end + + # One value of +agents+ as an AgentDefinition. A Hash stands for the + # AgentDefinition with the same attributes and is built through .new: + # the user wrote it, so a misspelled key raises as on the typed class. + def self.agent_definition(value) + value.is_a?(Hash) ? AgentDefinition.new(value) : value + end + + # An MCP server config (a typed one already as its wire Hash) that is an + # in-process SDK server: a Hash tagged 'sdk'. + def self.sdk_server?(config) + config.is_a?(Hash) && HashForm.tag(config) == 'sdk' + end + + def self.boolean(value) + value if [true, false].include?(value) + end + + private_class_method :sdk_server?, :boolean + end +end diff --git a/lib/claude_agent_sdk/query.rb b/lib/claude_agent_sdk/query.rb index 7d555933..89f919c6 100644 --- a/lib/claude_agent_sdk/query.rb +++ b/lib/claude_agent_sdk/query.rb @@ -294,7 +294,7 @@ def initialize_protocol # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticCom # A Hash stands for the AgentDefinition with the same attributes # (the options signature allows either). Built through .new: the # user wrote it, so a misspelled key raises as on the typed class. - agent_def = AgentDefinition.new(agent_def) if agent_def.is_a?(Hash) + agent_def = OptionForms.agent_definition(agent_def) { description: agent_def.description, prompt: agent_def.prompt, diff --git a/lib/claude_agent_sdk/subprocess_cli_transport.rb b/lib/claude_agent_sdk/subprocess_cli_transport.rb index 56bb27c9..0028923f 100644 --- a/lib/claude_agent_sdk/subprocess_cli_transport.rb +++ b/lib/claude_agent_sdk/subprocess_cli_transport.rb @@ -1292,16 +1292,17 @@ def warn_if_sandbox_unavailable(line) end end - # True when ClaudeAgentOptions#sandbox enables the sandbox: `true`, a - # SandboxSettings with `enabled` true, or a Hash with an `enabled` / - # 'enabled' key that is true (Hashes are forwarded to the CLI verbatim). + # True when ClaudeAgentOptions#sandbox enables the sandbox, as + # OptionForms.sandbox_requested? reads it: `true`, a SandboxSettings with + # `enabled` true, or a Hash whose :enabled or 'enabled' key is true, + # either one, whatever the other holds. A shallow read, made for this + # warning alone. It is not what the CLI is sent: that is + # OptionForms.sandbox, which writes a Hash under the CLI's keys + # (SandboxKeys). There, of two `enabled` keys, the later one that is not + # nil is sent and a nil one is left out: true then false sends false, + # while this predicate still says true. def sandbox_requested? - sandbox = @options.sandbox - case sandbox - when SandboxSettings then sandbox.enabled == true - when Hash then sandbox[:enabled] == true || sandbox['enabled'] == true - else sandbox == true - end + OptionForms.sandbox_requested?(@options.sandbox) end # The (parent's) home directory for the well-known install probes, or nil diff --git a/spec/unit/option_forms_characterization_spec.rb b/spec/unit/option_forms_characterization_spec.rb new file mode 100644 index 00000000..e7c2d29d --- /dev/null +++ b/spec/unit/option_forms_characterization_spec.rb @@ -0,0 +1,860 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'async' +require 'json' +require 'open3' +require 'pathname' +require 'rbconfig' + +# Several options take a typed value "or the equivalent Hash" (docs/options.md), +# and every reader of such a Hash has a rule of its own for the spelling of a +# key and for nil / false under it. This file records what those readers do, +# at the seams a caller can observe: +# +# - the whole command line CommandBuilder#build returns +# - the initialize request a session sends through its transport +# - the class, the message and the raise site of the errors +# - that an option Hash is stored, read back and re-read as the caller's +# own object +# +# It was written against the readers as they stood when each lived next to +# its flag, and it is the gate for moving them: an expected value below +# changes only when the behaviour of the SDK is meant to change. +RSpec.describe 'an option given as a Hash, as the SDK reads it' do + # The command line built for these options. + def argv(**options) + ClaudeAgentSDK::CommandBuilder.new('claude', ClaudeAgentSDK::ClaudeAgentOptions.new(**options)).build + end + + # The whole command line of a session that sets +option+ alone, given the + # flags that option becomes. An unset system_prompt is the empty prompt. + def command_line(option, flags) + prompt = option == :system_prompt ? [] : ['--system-prompt', ''] + ['claude', '--output-format', 'stream-json', '--verbose', *prompt, *flags, '--input-format', 'stream-json'] + end + + # What building the command line for these options raises (nil when it builds). + def build_error(**options) + argv(**options) + nil + rescue StandardError => e + e + end + + # One example per row of +rows+ (form => [value, the flags it becomes]), + # each asserting the whole command line. + def self.it_builds(option, rows, **metadata) + rows.each do |form, (value, flags)| + it "#{form}: #{flags.empty? ? 'no flag' : flags.map { |flag| flag || 'nil' }.join(' ')}", **metadata do + expect(argv(option => value)).to eq(command_line(option, flags)) + end + end + end + + # One example per row of +rows+ (form => [value, message]), each asserting + # the class and the message of what the build raises. + def self.it_refuses(option, rows, **metadata) + rows.each do |form, (value, message)| + it "#{form}: raises #{message.inspect}", **metadata do + expect(build_error(option => value)).to be_a(ArgumentError).and have_attributes(message: message) + end + end + end + + describe 'system_prompt' do + it_builds :system_prompt, { + 'nil' => [nil, ['--system-prompt', '']], + 'a String' => ['You are terse.', ['--system-prompt', 'You are terse.']], + 'an empty String' => ['', ['--system-prompt', '']], + 'a SystemPromptCustom' => [ClaudeAgentSDK::SystemPromptCustom.new(prompt: 'You are terse.'), ['--system-prompt', 'You are terse.']], + 'a SystemPromptFile' => [ClaudeAgentSDK::SystemPromptFile.new(path: 'prompts/a.md'), ['--system-prompt-file', 'prompts/a.md']], + 'a SystemPromptFile with a Pathname' => [ + ClaudeAgentSDK::SystemPromptFile.new(path: Pathname.new('prompts/a.md')), ['--system-prompt-file', 'prompts/a.md'] + ], + 'a SystemPromptPreset with an append' => [ + ClaudeAgentSDK::SystemPromptPreset.new(preset: 'claude_code', append: 'Be brief.'), ['--append-system-prompt', 'Be brief.'] + ], + 'a SystemPromptPreset without an append' => [ClaudeAgentSDK::SystemPromptPreset.new(preset: 'claude_code'), []] + } + + # The typed file prompt sends its flag whatever its path is; the Hash form + # below sends it only for a path. + it_builds :system_prompt, { + 'a SystemPromptFile without a path' => [ClaudeAgentSDK::SystemPromptFile.new, ['--system-prompt-file', nil]] + }, rbs_incompatible: 'the command line carries nil, outside the signature of CommandBuilder#build' + + describe 'as a custom Hash' do + it_builds :system_prompt, { + 'Symbol keys' => [{ type: 'custom', prompt: 'p' }, ['--system-prompt', 'p']], + 'String keys' => [{ 'type' => 'custom', 'prompt' => 'p' }, ['--system-prompt', 'p']], + 'a Symbol type' => [{ type: :custom, prompt: 'p' }, ['--system-prompt', 'p']], + 'a nil Symbol-keyed type next to a String-keyed one' => [{ type: nil, 'type' => 'custom', prompt: 'p' }, ['--system-prompt', 'p']], + 'an empty prompt' => [{ type: 'custom', prompt: '' }, ['--system-prompt', '']], + 'both prompt keys (the Symbol key is read)' => [ + { type: 'custom', 'prompt' => 'string key', prompt: 'symbol key' }, ['--system-prompt', 'symbol key'] + ] + } + + it_refuses :system_prompt, { + 'no prompt' => [{ type: 'custom' }, "system_prompt of type 'custom' requires a :prompt String"], + 'a prompt that is not a String' => [{ type: 'custom', prompt: 5 }, "system_prompt of type 'custom' requires a :prompt String"], + # Presence, not truthiness: a Symbol key holding nil is the prompt. + 'a nil Symbol-keyed prompt next to a String-keyed one' => [ + { type: 'custom', prompt: nil, 'prompt' => 'p' }, "system_prompt of type 'custom' requires a :prompt String" + ], + 'a SystemPromptCustom without a prompt' => [ + ClaudeAgentSDK::SystemPromptCustom.new, "system_prompt of type 'custom' requires a :prompt String" + ] + } + end + + describe 'as a file Hash' do + it_builds :system_prompt, { + 'Symbol keys' => [{ type: 'file', path: 'prompts/a.md' }, ['--system-prompt-file', 'prompts/a.md']], + 'String keys' => [{ 'type' => 'file', 'path' => 'prompts/a.md' }, ['--system-prompt-file', 'prompts/a.md']], + 'a Symbol type' => [{ type: :file, path: 'prompts/a.md' }, ['--system-prompt-file', 'prompts/a.md']], + 'a Pathname' => [{ type: 'file', path: Pathname.new('prompts/a.md') }, ['--system-prompt-file', 'prompts/a.md']], + 'no path' => [{ type: 'file' }, []], + 'a false path' => [{ type: 'file', path: false }, []], + # Truthiness, not presence: a nil Symbol key falls back to the String key. + 'a nil Symbol-keyed path next to a String-keyed one' => [ + { type: 'file', path: nil, 'path' => 'prompts/b.md' }, ['--system-prompt-file', 'prompts/b.md'] + ], + 'both path keys (the Symbol key is read)' => [ + { type: 'file', 'path' => 'string.md', path: 'symbol.md' }, ['--system-prompt-file', 'symbol.md'] + ] + } + end + + describe 'as a preset Hash' do + it_builds :system_prompt, { + 'Symbol keys' => [{ type: 'preset', preset: 'claude_code', append: 'Be brief.' }, ['--append-system-prompt', 'Be brief.']], + 'String keys' => [ + { 'type' => 'preset', 'preset' => 'claude_code', 'append' => 'Be brief.' }, ['--append-system-prompt', 'Be brief.'] + ], + 'a Symbol type' => [{ type: :preset, append: 'Be brief.' }, ['--append-system-prompt', 'Be brief.']], + 'no append' => [{ type: 'preset', preset: 'claude_code' }, []], + 'an empty append' => [{ type: 'preset', append: '' }, ['--append-system-prompt', '']], + # Truthiness, not presence. + 'a nil Symbol-keyed append next to a String-keyed one' => [ + { type: 'preset', append: nil, 'append' => 'x' }, ['--append-system-prompt', 'x'] + ], + 'a false Symbol-keyed append next to a String-keyed one' => [ + { type: 'preset', append: false, 'append' => 'x' }, ['--append-system-prompt', 'x'] + ], + 'both append keys (the Symbol key is read)' => [ + { type: 'preset', 'append' => 'string key', append: 'symbol key' }, ['--append-system-prompt', 'symbol key'] + ] + } + end + + # Not the empty prompt nil stands for: no prompt flag at all, so the CLI + # runs with its default prompt. + describe 'as a Hash the SDK does not recognize' do + it_builds :system_prompt, { + 'an unknown type' => [{ type: 'bogus', prompt: 'x' }, []], + 'an unknown Symbol type' => [{ type: :bogus, prompt: 'x' }, []], + 'no type' => [{ prompt: 'x' }, []], + 'a nil type' => [{ type: nil }, []], + 'an empty Hash' => [{}, []] + } + end + + describe 'as a value that is none of its forms' do + it_builds :system_prompt, { + 'false' => [false, []], + 'a Symbol' => [:preset, []], + 'an Integer' => [5, []] + }, rbs_incompatible: 'passes a system_prompt outside its signature' + end + + # options.rb merges a Hash given per call into a Hash configured as the + # default, so a Hash with both spellings of a key does not take a caller + # who wrote one twice. + describe 'as a per-call Hash merged into a configured default' do + after { ClaudeAgentSDK.reset_configuration } + + it 'reads the configured String-keyed append when the call passes append: nil' do + ClaudeAgentSDK.configure { |config| config.default_options = { system_prompt: { 'type' => 'preset', 'append' => 'x' } } } + options = ClaudeAgentSDK::ClaudeAgentOptions.new(system_prompt: { append: nil }) + + expect(options.system_prompt).to eq('type' => 'preset', 'append' => 'x', append: nil) + expect(ClaudeAgentSDK::CommandBuilder.new('claude', options).build) + .to eq(command_line(:system_prompt, ['--append-system-prompt', 'x'])) + end + end + end + + describe 'tools' do + it_builds :tools, { + 'nil' => [nil, []], + 'an Array' => [%w[Read Grep], ['--tools', 'Read,Grep']], + 'an empty Array' => [[], ['--tools', '']], + 'a String' => ['Read,Grep', ['--tools', 'Read,Grep']], + 'an empty String' => ['', ['--tools', '']], + 'a ToolsPreset' => [ClaudeAgentSDK::ToolsPreset.new(preset: 'claude_code'), ['--tools', 'default']], + 'a preset Hash with Symbol keys' => [{ type: 'preset', preset: 'claude_code' }, ['--tools', 'default']], + 'a preset Hash with String keys' => [{ 'type' => 'preset', 'preset' => 'claude_code' }, ['--tools', 'default']], + 'a preset Hash with a Symbol type' => [{ type: :preset, preset: :claude_code }, ['--tools', 'default']], + 'a preset Hash with a nil Symbol-keyed type next to a String-keyed one' => [{ type: nil, 'type' => 'preset' }, ['--tools', 'default']], + # Any other Hash is sent as JSON text, as it was written. + 'a Hash with an unknown type' => [{ type: 'bogus', names: ['Read'] }, ['--tools', '{"type":"bogus","names":["Read"]}']], + 'a Hash without a type' => [{ names: ['Read'] }, ['--tools', '{"names":["Read"]}']], + 'an empty Hash' => [{}, ['--tools', '{}']] + } + + describe 'as a value that is none of its forms' do + # In particular not the preset: `:default` is not a spelling of it. + it_builds :tools, { + 'the Symbol :default' => [:default, []], + 'an Integer' => [5, []], + 'true' => [true, []] + }, rbs_incompatible: 'passes tools outside its signature' + end + end + + describe 'sandbox' do + wire = '{"sandbox":{"enabled":true,"autoAllowBashIfSandboxed":false,"network":{"deniedDomains":["evil.example"]}}}' + + it_builds :sandbox, { + 'nil' => [nil, []], + 'true' => [true, ['--settings', '{"sandbox":true}']], + 'false' => [false, ['--settings', '{"sandbox":false}']], + 'an empty Hash' => [{}, ['--settings', '{"sandbox":{}}']], + 'a SandboxSettings' => [ + ClaudeAgentSDK::SandboxSettings.new( + enabled: true, auto_allow_bash_if_sandboxed: false, + network: ClaudeAgentSDK::SandboxNetworkConfig.new(denied_domains: ['evil.example']) + ), ['--settings', wire] + ], + 'a Hash with snake_case Symbol keys' => [ + { enabled: true, auto_allow_bash_if_sandboxed: false, network: { denied_domains: ['evil.example'] } }, ['--settings', wire] + ], + 'a Hash with snake_case String keys' => [ + { 'enabled' => true, 'auto_allow_bash_if_sandboxed' => false, 'network' => { 'denied_domains' => ['evil.example'] } }, + ['--settings', wire] + ], + 'a Hash with camelCase Symbol keys' => [ + { enabled: true, autoAllowBashIfSandboxed: false, network: { deniedDomains: ['evil.example'] } }, ['--settings', wire] + ], + 'a Hash with camelCase String keys' => [ + { 'enabled' => true, 'autoAllowBashIfSandboxed' => false, 'network' => { 'deniedDomains' => ['evil.example'] } }, + ['--settings', wire] + ], + 'a Hash holding a typed network' => [ + { enabled: true, auto_allow_bash_if_sandboxed: false, + network: ClaudeAgentSDK::SandboxNetworkConfig.new(denied_domains: ['evil.example']) }, ['--settings', wire] + ], + 'a Hash with a key the typed classes do not model' => [ + { enabled: true, allowAppleEvents: true }, ['--settings', '{"sandbox":{"enabled":true,"allowAppleEvents":true}}'] + ], + # Within one spelling the later key is the one sent. + 'a Hash with both enabled keys' => [{ enabled: true, 'enabled' => false }, ['--settings', '{"sandbox":{"enabled":false}}']] + } + end + + describe 'task_budget' do + it_builds :task_budget, { + 'nil' => [nil, []], + 'a TaskBudget' => [ClaudeAgentSDK::TaskBudget.new(total: 5000), ['--task-budget', '5000']], + 'a TaskBudget without a total' => [ClaudeAgentSDK::TaskBudget.new, []], + 'a Hash with a Symbol key' => [{ total: 5000 }, ['--task-budget', '5000']], + 'a Hash with a String key' => [{ 'total' => 5 }, ['--task-budget', '5']], + 'an empty Hash' => [{}, []], + # Truthiness, not presence. + 'a nil Symbol-keyed total next to a String-keyed one' => [{ total: nil, 'total' => 7 }, ['--task-budget', '7']], + 'a false Symbol-keyed total next to a String-keyed one' => [{ total: false, 'total' => 7 }, ['--task-budget', '7']], + 'both total keys (the Symbol key is read)' => [{ 'total' => 7, total: 3 }, ['--task-budget', '3']] + } + end + + describe 'thinking' do + it_builds :thinking, { + 'nil' => [nil, []], + 'a ThinkingConfigAdaptive' => [ClaudeAgentSDK::ThinkingConfigAdaptive.new, ['--thinking', 'adaptive']], + 'a ThinkingConfigAdaptive with a display' => [ + ClaudeAgentSDK::ThinkingConfigAdaptive.new(display: 'summarized'), ['--thinking', 'adaptive', '--thinking-display', 'summarized'] + ], + 'a ThinkingConfigEnabled' => [ClaudeAgentSDK::ThinkingConfigEnabled.new(budget_tokens: 2048), ['--max-thinking-tokens', '2048']], + 'a ThinkingConfigEnabled with a display' => [ + ClaudeAgentSDK::ThinkingConfigEnabled.new(budget_tokens: 2048, display: 'omitted'), + ['--max-thinking-tokens', '2048', '--thinking-display', 'omitted'] + ], + 'a ThinkingConfigDisabled' => [ClaudeAgentSDK::ThinkingConfigDisabled.new, ['--thinking', 'disabled']], + 'an adaptive Hash with a Symbol key' => [{ type: 'adaptive' }, ['--thinking', 'adaptive']], + 'an adaptive Hash with a String key' => [{ 'type' => 'adaptive' }, ['--thinking', 'adaptive']], + 'an adaptive Hash with a Symbol type' => [{ type: :adaptive }, ['--thinking', 'adaptive']], + 'an adaptive Hash with a display' => [{ type: 'adaptive', display: 'omitted' }, ['--thinking', 'adaptive', '--thinking-display', 'omitted']], + 'an adaptive Hash with a Symbol display' => [ + { 'type' => 'adaptive', 'display' => :summarized }, ['--thinking', 'adaptive', '--thinking-display', 'summarized'] + ], + 'an enabled Hash with Symbol keys' => [{ type: 'enabled', budget_tokens: 2048 }, ['--max-thinking-tokens', '2048']], + 'an enabled Hash with String keys' => [{ 'type' => :enabled, 'budget_tokens' => 2048 }, ['--max-thinking-tokens', '2048']], + # The typed classes check display; a Hash is forwarded as written. + 'an enabled Hash with a display the typed class refuses' => [ + { type: 'enabled', budget_tokens: 2048, display: 'bogus' }, ['--max-thinking-tokens', '2048', '--thinking-display', 'bogus'] + ], + # Truthiness, not presence, for every field. + 'a nil Symbol-keyed type next to a String-keyed one' => [{ type: nil, 'type' => 'disabled' }, ['--thinking', 'disabled']], + 'a nil Symbol-keyed budget next to a String-keyed one' => [ + { type: 'enabled', budget_tokens: nil, 'budget_tokens' => 4096 }, ['--max-thinking-tokens', '4096'] + ], + 'a false Symbol-keyed display next to a String-keyed one' => [ + { type: 'adaptive', display: false, 'display' => 'omitted' }, ['--thinking', 'adaptive', '--thinking-display', 'omitted'] + ], + 'a false display' => [{ type: 'adaptive', display: false }, ['--thinking', 'adaptive']], + 'both budget keys (the Symbol key is read)' => [ + { type: 'enabled', 'budget_tokens' => 4096, budget_tokens: 1024 }, ['--max-thinking-tokens', '1024'] + ], + 'a disabled Hash with a display (not sent)' => [{ type: 'disabled', display: 'summarized' }, ['--thinking', 'disabled']] + } + + it_refuses :thinking, { + 'an enabled Hash without a budget' => [{ type: 'enabled' }, "thinking type 'enabled' requires budget_tokens"], + 'an enabled Hash with a false budget' => [{ type: 'enabled', budget_tokens: false }, "thinking type 'enabled' requires budget_tokens"], + 'a ThinkingConfigEnabled without a budget' => [ + ClaudeAgentSDK::ThinkingConfigEnabled.new, "thinking type 'enabled' requires budget_tokens" + ], + 'a Hash with an unknown type' => [{ type: 'bogus' }, "unsupported thinking config: #{{ type: 'bogus' }.inspect}"], + 'a Hash without a type' => [{ budget_tokens: 2048 }, "unsupported thinking config: #{{ budget_tokens: 2048 }.inspect}"], + 'an empty Hash' => [{}, 'unsupported thinking config: {}'] + } + + it_refuses :thinking, { + 'a String' => ['adaptive', 'unsupported thinking config: "adaptive"'], + 'true' => [true, 'unsupported thinking config: true'] + }, rbs_incompatible: 'passes thinking outside its signature' + + it 'takes the place of max_thinking_tokens, also as a Hash' do + expect(argv(thinking: { type: 'adaptive' }, max_thinking_tokens: 512)).to eq(command_line(:thinking, ['--thinking', 'adaptive'])) + end + end + + describe 'output_format' do + schema = { type: 'object', properties: { verdict: { type: 'string' } } }.freeze + other = { type: 'array' }.freeze + schema_json = '{"type":"object","properties":{"verdict":{"type":"string"}}}' + + it_builds :output_format, { + 'nil' => [nil, []], + 'a json_schema Hash with Symbol keys' => [{ type: 'json_schema', schema: schema }, ['--json-schema', schema_json]], + 'a json_schema Hash with String keys' => [{ 'type' => 'json_schema', 'schema' => schema }, ['--json-schema', schema_json]], + 'a json_schema Hash with a Symbol type' => [{ type: :json_schema, schema: schema }, ['--json-schema', schema_json]], + 'a Symbol-keyed type and a String-keyed schema' => [{ type: 'json_schema', 'schema' => schema }, ['--json-schema', schema_json]], + 'a String-keyed type and a Symbol-keyed schema' => [{ 'type' => 'json_schema', schema: schema }, ['--json-schema', schema_json]], + # The schema is read under the spelling its type was written in. + 'a String-keyed type and both schema keys' => [ + { 'type' => 'json_schema', 'schema' => schema, schema: other }, ['--json-schema', schema_json] + ], + 'a Symbol-keyed type and both schema keys' => [ + { type: 'json_schema', 'schema' => other, schema: schema }, ['--json-schema', schema_json] + ], + 'both type keys, and the Symbol-keyed one is json_schema' => [ + { type: 'json_schema', 'type' => 'text', 'schema' => other, schema: schema }, ['--json-schema', schema_json] + ], + 'both type keys, and only the String-keyed one is json_schema' => [ + { type: 'text', 'type' => 'json_schema', 'schema' => schema, schema: other }, ['--json-schema', schema_json] + ], + # Presence, not truthiness: a nil schema in the type's spelling is no schema. + 'a nil Symbol-keyed schema next to a String-keyed one' => [{ type: 'json_schema', schema: nil, 'schema' => schema }, []], + 'no schema' => [{ type: 'json_schema' }, []], + # Only nil is left out. + 'a false schema' => [{ type: 'json_schema', schema: false }, ['--json-schema', 'false']], + 'a schema given as JSON text' => [{ type: 'json_schema', schema: '{"type":"object"}' }, ['--json-schema', '{"type":"object"}']], + # Any other value is the schema itself. + 'a Hash that is the schema' => [schema, ['--json-schema', schema_json]], + 'a Hash with another type under a String key' => [{ 'type' => 'text' }, ['--json-schema', '{"type":"text"}']], + 'an empty Hash' => [{}, ['--json-schema', '{}']], + 'a String' => ['{"type":"object"}', ['--json-schema', '{"type":"object"}']] + } + end + + describe 'mcp_servers' do + server = ClaudeAgentSDK.create_sdk_mcp_server(name: 'calc', tools: []).fetch(:instance) + sdk_wire = '{"mcpServers":{"calc":{"type":"sdk","name":"calc"}}}' + + it_builds :mcp_servers, { + 'nil' => [nil, []], + 'an empty Hash' => [{}, []], + 'a path' => ['config/mcp.json', ['--mcp-config', 'config/mcp.json']], + 'JSON text' => ['{"mcpServers":{}}', ['--mcp-config', '{"mcpServers":{}}']], + 'a Hash config' => [ + { 'files' => { type: 'stdio', command: 'node', args: ['server.js'] } }, + ['--mcp-config', '{"mcpServers":{"files":{"type":"stdio","command":"node","args":["server.js"]}}}'] + ], + 'a typed config' => [ + { files: ClaudeAgentSDK::McpStdioServerConfig.new(command: 'node', args: ['server.js']) }, + ['--mcp-config', '{"mcpServers":{"files":{"type":"stdio","command":"node","args":["server.js"]}}}'] + ], + # The live server of an sdk entry never reaches the command line. + 'an sdk Hash with Symbol keys' => [{ 'calc' => { type: 'sdk', name: 'calc', instance: server } }, ['--mcp-config', sdk_wire]], + 'an sdk Hash with String keys' => [ + { 'calc' => { 'type' => 'sdk', 'name' => 'calc', 'instance' => server } }, ['--mcp-config', sdk_wire] + ], + 'an sdk Hash with a Symbol type' => [{ 'calc' => { type: :sdk, name: 'calc', instance: server } }, ['--mcp-config', sdk_wire]], + 'an sdk Hash with both instance keys' => [ + { 'calc' => { type: 'sdk', name: 'calc', instance: server, 'instance' => server } }, ['--mcp-config', sdk_wire] + ], + 'a McpSdkServerConfig' => [ + { 'calc' => ClaudeAgentSDK::McpSdkServerConfig.new(name: 'calc', instance: server) }, ['--mcp-config', sdk_wire] + ], + # Only an sdk entry loses its instance key. + 'a Hash config of another type with an instance key' => [ + { 'files' => { type: 'stdio', command: 'node', instance: 'kept' } }, + ['--mcp-config', '{"mcpServers":{"files":{"type":"stdio","command":"node","instance":"kept"}}}'] + ] + } + + # The same recognition picks the live servers a session registers + # (ClaudeAgentSDK.extract_sdk_mcp_servers, called by Client and query). + describe 'the live servers of its sdk entries' do + it 'are found for a Hash in either key style, a Symbol type and the typed config' do + servers = ClaudeAgentSDK.extract_sdk_mcp_servers( + 'symbols' => { type: 'sdk', name: 'a', instance: server }, + 'strings' => { 'type' => 'sdk', 'name' => 'b', 'instance' => server }, + 'symbol_type' => { type: :sdk, instance: server }, + 'nil_symbol_type' => { type: nil, 'type' => 'sdk', 'instance' => server }, + 'typed' => ClaudeAgentSDK::McpSdkServerConfig.new(name: 'c', instance: server), + 'stdio_hash' => { type: 'stdio', command: 'node', instance: server }, + 'stdio_typed' => ClaudeAgentSDK::McpStdioServerConfig.new(command: 'node') + ) + + expect(servers.keys).to eq(%w[symbols strings symbol_type nil_symbol_type typed]) + expect(servers.values).to all(equal(server)) + end + + # Presence, not truthiness: a Symbol key holding nil is the instance. + it 'are read under the Symbol key when it is present, whatever it holds' do + servers = ClaudeAgentSDK.extract_sdk_mcp_servers( + 'nil_symbol_key' => { type: 'sdk', instance: nil, 'instance' => server }, + 'both_keys' => { type: 'sdk', 'instance' => nil, instance: server }, + 'no_instance' => { type: 'sdk' } + ) + + expect(servers.keys).to eq(%w[nil_symbol_key both_keys no_instance]) + expect(servers['nil_symbol_key']).to be_nil + expect(servers['both_keys']).to equal(server) + expect(servers['no_instance']).to be_nil + end + + it 'are none for a path or nil' do + expect([ClaudeAgentSDK.extract_sdk_mcp_servers('config/mcp.json'), ClaudeAgentSDK.extract_sdk_mcp_servers(nil)]).to eq([{}, {}]) + end + end + end + + describe 'plugins' do + it_builds :plugins, { + 'nil' => [nil, []], + 'an empty Array' => [[], []], + 'a SdkPluginConfig' => [[ClaudeAgentSDK::SdkPluginConfig.new(path: '/srv/plugins/review')], ['--plugin-dir', '/srv/plugins/review']], + 'a SdkPluginConfig with a Pathname' => [ + [ClaudeAgentSDK::SdkPluginConfig.new(path: Pathname.new('/srv/plugins/review'))], ['--plugin-dir', '/srv/plugins/review'] + ], + 'a SdkPluginConfig without a path' => [[ClaudeAgentSDK::SdkPluginConfig.new], []], + 'a Hash with Symbol keys' => [[{ type: 'local', path: '/srv/plugins/review' }], ['--plugin-dir', '/srv/plugins/review']], + 'a Hash with String keys' => [[{ 'type' => 'local', 'path' => '/srv/plugins/review' }], ['--plugin-dir', '/srv/plugins/review']], + 'a Hash with a Symbol type' => [[{ type: :local, path: '/srv/plugins/review' }], ['--plugin-dir', '/srv/plugins/review']], + 'a Hash with the older type "plugin"' => [[{ type: 'plugin', path: '/srv/plugins/review' }], ['--plugin-dir', '/srv/plugins/review']], + 'a Hash with a Pathname' => [[{ type: 'local', path: Pathname.new('/srv/plugins/review') }], ['--plugin-dir', '/srv/plugins/review']], + 'a Hash without a path' => [[{ type: 'local' }], []], + 'a Hash with a false path' => [[{ type: 'local', path: false }], []], + # Truthiness, not presence. + 'a nil Symbol-keyed path next to a String-keyed one' => [[{ type: 'local', path: nil, 'path' => '/b' }], ['--plugin-dir', '/b']], + 'a nil Symbol-keyed type next to a String-keyed one' => [[{ type: nil, 'type' => 'local', path: '/b' }], ['--plugin-dir', '/b']], + 'both path keys (the Symbol key is read)' => [[{ type: 'local', 'path' => '/string', path: '/symbol' }], ['--plugin-dir', '/symbol']], + 'several plugins, in the order given' => [ + [{ type: 'local', path: '/a' }, { type: 'local' }, ClaudeAgentSDK::SdkPluginConfig.new(path: '/c')], + ['--plugin-dir', '/a', '--plugin-dir', '/c'] + ] + } + + # The message names the type as it was written. + it_refuses :plugins, { + 'an unknown type' => [[{ type: 'remote', path: '/x' }], 'Unsupported plugin type: "remote"'], + 'an unknown Symbol type' => [[{ type: :remote, path: '/x' }], 'Unsupported plugin type: :remote'], + 'an unknown type under a String key' => [[{ 'type' => 'remote', 'path' => '/x' }], 'Unsupported plugin type: "remote"'], + 'no type' => [[{ path: '/x' }], 'Unsupported plugin type: nil'], + 'an unknown type without a path' => [[{ type: 'remote' }], 'Unsupported plugin type: "remote"'], + 'an unknown type after a valid plugin' => [[{ type: 'local', path: '/a' }, { type: 'remote' }], 'Unsupported plugin type: "remote"'] + } + + # How often a plugin Hash is read, and in which order, shows in a Hash + # that computes its values (a default proc): the path first, then the + # type, once, to recognize the plugin. A plugin that is refused has its + # type read once more, and that second reading is the one the message + # names. + describe 'as a Hash that computes its values' do + # A plugin Hash answering every key from +values+, the keys it was + # asked for (in order), and a counter of the times its type was read. + def computed_plugin(values) + lookups = [] + plugin = Hash.new do |_hash, key| + lookups << key + value = values[key] + value.respond_to?(:call) ? value.call(lookups.count(:type)) : value + end + [plugin, lookups] + end + + it 'reads the type of a refused plugin a second time, for the message' do + plugin, lookups = computed_plugin(path: '/srv/plugins/review', type: ->(reading) { "remote-#{reading}" }) + error = build_error(plugins: [plugin]) + + expect(error).to be_a(ArgumentError).and have_attributes(message: 'Unsupported plugin type: "remote-2"') + expect(lookups).to eq(%i[path type type]) + end + + it 'falls back to the String key in both readings of a refused plugin' do + plugin, lookups = computed_plugin('type' => :remote) + error = build_error(plugins: [plugin]) + + expect(error).to be_a(ArgumentError).and have_attributes(message: 'Unsupported plugin type: :remote') + expect(lookups).to eq([:path, 'path', :type, 'type', :type, 'type']) + end + + it 'reads the type of an accepted plugin once' do + plugin, lookups = computed_plugin(path: '/srv/plugins/review', type: ->(reading) { reading == 1 ? 'local' : "remote-#{reading}" }) + + expect(argv(plugins: [plugin])).to eq(command_line(:plugins, ['--plugin-dir', '/srv/plugins/review'])) + expect(lookups).to eq(%i[path type]) + end + + it 'reads the type of an accepted plugin without a path once, and sends nothing for it' do + plugin, lookups = computed_plugin('type' => 'plugin') + + expect(argv(plugins: [plugin])).to eq(command_line(:plugins, [])) + expect(lookups).to eq([:path, 'path', :type, 'type']) + end + end + end + + # The four errors a build raises for an option form are raised by + # CommandBuilder itself, whichever module reads the Hash: the first frame of + # the backtrace inside lib/ is command_builder.rb. (The file, not the line.) + describe 'the raise site of a build error' do + lib = "#{File.expand_path('../../lib', __dir__)}/" + + { + 'a custom system prompt Hash without a prompt' => { system_prompt: { type: 'custom' } }, + 'a SystemPromptCustom without a prompt' => { system_prompt: ClaudeAgentSDK::SystemPromptCustom.new }, + 'an enabled thinking Hash without a budget' => { thinking: { type: 'enabled' } }, + 'a ThinkingConfigEnabled without a budget' => { thinking: ClaudeAgentSDK::ThinkingConfigEnabled.new }, + 'a thinking Hash with an unknown type' => { thinking: { type: 'bogus' } }, + 'a plugin Hash with an unknown type' => { plugins: [{ type: 'remote', path: '/x' }] }, + 'a plugin Hash that computes an unknown type' => { plugins: [Hash.new { |_hash, key| key == :type ? 'remote' : nil }] } + }.each do |form, options| + it "is command_builder.rb for #{form}" do + error = build_error(**options) + frame = error.backtrace_locations.find { |location| location.absolute_path.to_s.start_with?(lib) } + + expect(error).to be_a(ArgumentError) + expect(File.basename(frame.absolute_path)).to eq('command_builder.rb') + end + end + end + + # CommandBuilder#build is public and signed, and command_builder.rb loads + # what it needs: a process that requires that file alone can build. + describe 'a process that requires only claude_agent_sdk/command_builder' do + it 'builds the default command line without the root entry point' do + script = <<~RUBY + require 'claude_agent_sdk/command_builder' + require 'json' + argv = ClaudeAgentSDK::CommandBuilder.new('claude', ClaudeAgentSDK::ClaudeAgentOptions.new).build + root_loaded = $LOADED_FEATURES.any? { |feature| feature.end_with?('/lib/claude_agent_sdk.rb') } + puts JSON.generate('argv' => argv, 'root_loaded' => root_loaded) + RUBY + + stdout, stderr, status = Open3.capture3(RbConfig.ruby, "-I#{File.expand_path('../../lib', __dir__)}", '-e', script) + + expect(status).to be_success, stderr + expect(JSON.parse(stdout)).to eq( + 'argv' => ['claude', '--output-format', 'stream-json', '--verbose', '--system-prompt', '', '--input-format', 'stream-json'], + 'root_loaded' => false + ) + end + end + + # agents, and the snapshot / exclude_dynamic_sections of a system prompt, + # travel on the initialize request. End to end through the real control + # protocol: a fake Transport records what happens to it, in order, and each + # control request as the CLI's JSON parser sees it, and answers the request. + # It builds no command line, like any transport that is not the subprocess + # one. + describe 'the initialize request' do + let(:transport_class) do + Class.new(ClaudeAgentSDK::Transport) do + def initialize(_options, events:, requests:) + super() + @events = events + @requests = requests + @queue = Thread::Queue.new + end + + def connect + @events << :connect + @ready = true + end + + def ready? = @ready + + # The CLI ends its output once its input ends. + def end_input = @queue.push(:eof) + + def close + @events << :close + @queue.push(:eof) + end + + def write(data) + message = JSON.parse(data, symbolize_names: true) + return unless message[:type] == 'control_request' + + @events << :write + @requests << JSON.parse(data).fetch('request') + response = { subtype: 'success', request_id: message[:request_id], response: {} } + @queue.push({ type: 'control_response', response: response }) + end + + def read_messages + @events << :read_started + loop do + message = @queue.pop + break if message == :eof + + yield message + end + end + end + end + + let(:events) { [] } + let(:requests) { [] } + + # Connects a Client with these options. What the connect raised (nil when + # it connected) is recorded as an :error event at the moment it reached + # the caller, and returned. + def connect_with(**options) + Sync do + client = ClaudeAgentSDK::Client.new( + options: ClaudeAgentSDK::ClaudeAgentOptions.new(**options), + transport_class: transport_class, transport_args: { events: events, requests: requests } + ) + client.connect + nil + rescue StandardError => e + events << :error + e + ensure + client&.disconnect + end + end + + # The initialize request of a session with these options. + def initialize_request(**options) + error = connect_with(**options) + raise error if error + + requests.find { |request| request['subtype'] == 'initialize' } + end + + def prompt_fields(system_prompt) + initialize_request(system_prompt: system_prompt).slice('excludeDynamicSections', 'systemPromptSnapshot') + end + + describe 'for agents' do + wire_agent = { 'description' => 'Reviews pull requests', 'prompt' => 'You review code.', 'disallowedTools' => ['Bash'], 'maxTurns' => 4 } + + { + 'an AgentDefinition' => ClaudeAgentSDK::AgentDefinition.new( + description: 'Reviews pull requests', prompt: 'You review code.', disallowed_tools: ['Bash'], max_turns: 4 + ), + 'a Hash with Symbol keys' => { description: 'Reviews pull requests', prompt: 'You review code.', disallowed_tools: ['Bash'], max_turns: 4 }, + 'a Hash with String keys' => { + 'description' => 'Reviews pull requests', 'prompt' => 'You review code.', 'disallowed_tools' => ['Bash'], 'max_turns' => 4 + }, + 'a Hash with camelCase keys' => wire_agent + }.each do |form, agent| + it "carries #{form} under its wire keys" do + expect(initialize_request(agents: { 'reviewer' => agent }).fetch('agents')).to eq('reviewer' => wire_agent) + end + end + + # A Hash is built through AgentDefinition.new, attribute by attribute in + # the order written: of two spellings of one attribute the later wins. + it 'reads the later of two spellings of one attribute' do + agent = { 'prompt' => 'first', prompt: 'second', description: 'd' } + + expect(initialize_request(agents: { reviewer: agent }).fetch('agents')).to eq('reviewer' => { 'description' => 'd', 'prompt' => 'second' }) + end + + it 'carries no agents when there are none' do + expect(initialize_request.fetch('agents')).to be_nil + end + + # The Hash is checked where the request is built: after the transport + # connected and the read loop started, before anything is written. + it 'refuses a misspelled key once the read loop runs, and writes nothing' do + error = connect_with(agents: { reviewer: { description: 'Reviews pull requests', promt: 'You review code.' } }) + + expect(error).to be_a(ArgumentError) + expect(error.message).to start_with('ClaudeAgentSDK::AgentDefinition: unknown attribute :promt (known: ') + expect(events.first(2)).to eq(%i[connect read_started]) + expect(events.index(:read_started)).to be < events.index(:error) + expect(events).to include(:close) + expect(events).not_to include(:write) + expect(requests).to be_empty + end + + it 'refuses it at the same point in a one-shot query' do + transport = transport_class.new(nil, events: events, requests: requests) + options = ClaudeAgentSDK::ClaudeAgentOptions.new(agents: { reviewer: { description: 'Reviews pull requests', promt: 'You review code.' } }) + + expect { ClaudeAgentSDK.query(prompt: 'Review this.', options: options, transport: transport) { |_message| nil } } + .to raise_error(ArgumentError, /\AClaudeAgentSDK::AgentDefinition: unknown attribute :promt \(known: /) + expect(events.first(2)).to eq(%i[connect read_started]) + expect(events).to include(:close) + expect(events).not_to include(:write) + expect(requests).to be_empty + end + end + + describe 'for the snapshot and exclude_dynamic_sections of a system prompt' do + both_false = { 'excludeDynamicSections' => false, 'systemPromptSnapshot' => false } + + { + 'a SystemPromptPreset' => [ + ClaudeAgentSDK::SystemPromptPreset.new(preset: 'claude_code', snapshot: false, exclude_dynamic_sections: false), both_false + ], + 'a preset Hash with Symbol keys' => [{ type: 'preset', snapshot: false, exclude_dynamic_sections: false }, both_false], + 'a preset Hash with String keys' => [{ 'type' => 'preset', 'snapshot' => false, 'exclude_dynamic_sections' => false }, both_false], + 'a preset Hash with a Symbol type' => [{ type: :preset, snapshot: false, exclude_dynamic_sections: false }, both_false], + 'a preset Hash with a nil Symbol-keyed type next to a String-keyed one' => [ + { type: nil, 'type' => 'preset', snapshot: false, exclude_dynamic_sections: false }, both_false + ], + 'a preset Hash with true values' => [ + { type: 'preset', snapshot: true, exclude_dynamic_sections: true }, { 'excludeDynamicSections' => true, 'systemPromptSnapshot' => true } + ], + 'a SystemPromptCustom' => [ClaudeAgentSDK::SystemPromptCustom.new(prompt: 'p', snapshot: false), { 'systemPromptSnapshot' => false }], + # exclude_dynamic_sections belongs to the preset alone. + 'a custom Hash' => [{ type: 'custom', prompt: 'p', snapshot: false, exclude_dynamic_sections: true }, { 'systemPromptSnapshot' => false }], + # Presence, not truthiness: the Symbol key is the one read when it is there. + 'both keys of each field (the Symbol keys are read)' => [ + { type: 'preset', 'snapshot' => true, snapshot: false, 'exclude_dynamic_sections' => true, exclude_dynamic_sections: false }, both_false + ], + 'nil Symbol keys next to String keys' => [ + { type: 'preset', snapshot: nil, 'snapshot' => false, exclude_dynamic_sections: nil, 'exclude_dynamic_sections' => true }, {} + ], + # Only true and false are sent. + 'values that are not booleans' => [{ type: 'preset', snapshot: 'yes', exclude_dynamic_sections: 1 }, {}], + 'a preset Hash without the fields' => [{ type: 'preset', append: 'x' }, {}], + 'a file Hash' => [{ type: 'file', path: 'prompts/a.md', snapshot: false, exclude_dynamic_sections: true }, {}], + 'a Hash with an unknown type' => [{ type: 'bogus', snapshot: false, exclude_dynamic_sections: true }, {}], + 'a Hash without a type' => [{ snapshot: false, exclude_dynamic_sections: true }, {}], + 'a String' => ['You are terse.', {}], + 'nil' => [nil, {}] + }.each do |form, (system_prompt, fields)| + it "carries #{fields.empty? ? 'neither field' : fields.inspect} for #{form}" do + expect(prompt_fields(system_prompt)).to eq(fields) + end + end + + # The command line is where a custom prompt's text is checked. A session + # over a transport that builds none still gets its snapshot through. + it 'does not check the text of a custom prompt' do + expect(prompt_fields({ type: 'custom', prompt: nil, snapshot: false })).to eq('systemPromptSnapshot' => false) + expect(events.first(3)).to eq(%i[connect read_started write]) + expect(events).not_to include(:error) + end + + it 'carries them in a one-shot query too' do + transport = transport_class.new(nil, events: events, requests: requests) + options = ClaudeAgentSDK::ClaudeAgentOptions.new( + system_prompt: { 'type' => :preset, 'snapshot' => false, 'exclude_dynamic_sections' => true }, + agents: { reviewer: { description: 'd', prompt: 'p' } } + ) + + ClaudeAgentSDK.query(prompt: 'Review this.', options: options, transport: transport) { |_message| nil } + + expect(requests.find { |request| request['subtype'] == 'initialize' }).to include( + 'agents' => { 'reviewer' => { 'description' => 'd', 'prompt' => 'p' } }, + 'excludeDynamicSections' => true, 'systemPromptSnapshot' => false + ) + end + end + end + + # ClaudeAgentOptions stores the Hash it was given, and every build reads it + # again: nothing derived from it is kept anywhere. + describe 'the Hash the caller passed' do + server = ClaudeAgentSDK.create_sdk_mcp_server(name: 'calc', tools: []).fetch(:instance) + + # option => [the Hash, what the caller then does to it, the flags before, the flags after] + { + sandbox: [ + -> { { enabled: true } }, ->(hash) { hash[:enabled] = false }, + ['--settings', '{"sandbox":{"enabled":true}}'], ['--settings', '{"sandbox":{"enabled":false}}'] + ], + system_prompt: [ + -> { { type: 'preset', append: 'first' } }, ->(hash) { hash[:append] = 'second' }, + ['--append-system-prompt', 'first'], ['--append-system-prompt', 'second'] + ], + output_format: [ + -> { { type: 'json_schema', schema: { type: 'object' } } }, ->(hash) { hash[:schema] = { type: 'array' } }, + ['--json-schema', '{"type":"object"}'], ['--json-schema', '{"type":"array"}'] + ], + tools: [ + -> { { type: 'preset', preset: 'claude_code' } }, ->(hash) { hash[:type] = 'list' }, + ['--tools', 'default'], ['--tools', '{"type":"list","preset":"claude_code"}'] + ], + mcp_servers: [ + -> { { 'calc' => { type: 'sdk', name: 'calc', instance: server } } }, ->(hash) { hash['calc'][:name] = 'renamed' }, + ['--mcp-config', '{"mcpServers":{"calc":{"type":"sdk","name":"calc"}}}'], + ['--mcp-config', '{"mcpServers":{"calc":{"type":"sdk","name":"renamed"}}}'] + ], + thinking: [ + -> { { type: 'enabled', budget_tokens: 1024 } }, ->(hash) { hash[:budget_tokens] = 2048 }, + ['--max-thinking-tokens', '1024'], ['--max-thinking-tokens', '2048'] + ], + task_budget: [ + -> { { total: 100 } }, ->(hash) { hash[:total] = 200 }, + ['--task-budget', '100'], ['--task-budget', '200'] + ], + plugins: [ + -> { [{ type: 'local', path: '/a' }] }, ->(list) { list.first[:path] = '/b' }, + ['--plugin-dir', '/a'], ['--plugin-dir', '/b'] + ] + }.each do |option, (given, change, flags_before, flags_after)| + it "is the #{option} the options hold, unchanged by a build and read again by the next" do + value = given.call + options = ClaudeAgentSDK::ClaudeAgentOptions.new(option => value) + builder = ClaudeAgentSDK::CommandBuilder.new('claude', options) + + expect(options.public_send(option)).to equal(value) + expect(builder.build).to eq(command_line(option, flags_before)) + expect(options.public_send(option)).to equal(value) + expect(value).to eq(given.call) + + change.call(value) + + expect(builder.build).to eq(command_line(option, flags_after)) + expect(ClaudeAgentSDK::CommandBuilder.new('claude', options).build).to eq(command_line(option, flags_after)) + end + end + + it 'is the mcp_servers Hash whose live server a session registers' do + mcp_servers = { 'calc' => { 'type' => 'sdk', 'name' => 'calc', 'instance' => server } } + options = ClaudeAgentSDK::ClaudeAgentOptions.new(mcp_servers: mcp_servers) + + expect(ClaudeAgentSDK.extract_sdk_mcp_servers(options.mcp_servers)).to eq('calc' => server) + expect(options.mcp_servers).to equal(mcp_servers) + expect(mcp_servers).to eq('calc' => { 'type' => 'sdk', 'name' => 'calc', 'instance' => server }) + + mcp_servers['calc']['type'] = 'stdio' + + expect(ClaudeAgentSDK.extract_sdk_mcp_servers(options.mcp_servers)).to eq({}) + end + end +end diff --git a/spec/unit/option_forms_spec.rb b/spec/unit/option_forms_spec.rb new file mode 100644 index 00000000..04195d05 --- /dev/null +++ b/spec/unit/option_forms_spec.rb @@ -0,0 +1,476 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'pathname' + +# OptionForms reads every option that takes a typed value or the equivalent +# Hash. One group per function, each naming the key rule the option has +# always had: +# +# truthy hash[:key] || hash['key'] +# presence hash.fetch(:key) { hash['key'] } +# +# option_forms_characterization_spec.rb pins the same rows where a caller +# sees them, on the command line and the initialize request. +RSpec.describe ClaudeAgentSDK::OptionForms do + describe '.system_prompt' do + # [kind, value] of the Prompt a system_prompt stands for. + def prompt(value) + result = described_class.system_prompt(value) + [result.kind, result.value] + end + + { + 'nil is the empty prompt' => [nil, [:empty, nil]], + 'a String is the text' => ['You are terse.', [:text, 'You are terse.']], + 'an empty String is a text, not the empty prompt' => ['', [:text, '']], + 'a SystemPromptCustom is its prompt' => [ClaudeAgentSDK::SystemPromptCustom.new(prompt: 'p'), [:text, 'p']], + 'a SystemPromptCustom without a prompt is a nil text, for the caller to refuse' => [ + ClaudeAgentSDK::SystemPromptCustom.new, [:text, nil] + ], + 'a SystemPromptFile is its path' => [ClaudeAgentSDK::SystemPromptFile.new(path: 'a.md'), [:file, 'a.md']], + 'a SystemPromptFile without a path is still a file' => [ClaudeAgentSDK::SystemPromptFile.new, [:file, nil]], + 'a SystemPromptPreset with an append is the append' => [ + ClaudeAgentSDK::SystemPromptPreset.new(preset: 'claude_code', append: 'x'), [:append, 'x'] + ], + 'a SystemPromptPreset without an append is no flag' => [ClaudeAgentSDK::SystemPromptPreset.new(preset: 'claude_code'), [:none, nil]], + 'custom Hash, Symbol keys' => [{ type: 'custom', prompt: 'p' }, [:text, 'p']], + 'custom Hash, String keys' => [{ 'type' => 'custom', 'prompt' => 'p' }, [:text, 'p']], + 'custom Hash, Symbol type' => [{ type: :custom, prompt: 'p' }, [:text, 'p']], + 'custom Hash, truthy tag: type nil then String key' => [{ type: nil, 'type' => 'custom', prompt: 'p' }, [:text, 'p']], + 'custom Hash, presence: prompt nil then String key is nil' => [{ type: 'custom', prompt: nil, 'prompt' => 'p' }, [:text, nil]], + 'custom Hash, presence: both prompt keys is the Symbol one' => [{ type: 'custom', 'prompt' => 's', prompt: 'p' }, [:text, 'p']], + 'custom Hash without a prompt is a nil text' => [{ type: 'custom' }, [:text, nil]], + 'custom Hash with a prompt that is no String keeps it' => [{ type: 'custom', prompt: 5 }, [:text, 5]], + 'file Hash, Symbol keys' => [{ type: 'file', path: 'a.md' }, [:file, 'a.md']], + 'file Hash, String keys' => [{ 'type' => :file, 'path' => 'a.md' }, [:file, 'a.md']], + 'file Hash, truthy fallback: path nil then String key' => [{ type: 'file', path: nil, 'path' => 'b.md' }, [:file, 'b.md']], + 'file Hash, truthy fallback: both path keys is the Symbol one' => [{ type: 'file', 'path' => 's.md', path: 'p.md' }, [:file, 'p.md']], + 'file Hash without a path is no flag' => [{ type: 'file' }, [:none, nil]], + 'file Hash with a false path is no flag' => [{ type: 'file', path: false }, [:none, nil]], + 'preset Hash, Symbol keys' => [{ type: 'preset', preset: 'claude_code', append: 'x' }, [:append, 'x']], + 'preset Hash, String keys' => [{ 'type' => 'preset', 'append' => 'x' }, [:append, 'x']], + 'preset Hash, truthy fallback: append nil then String key' => [{ type: 'preset', append: nil, 'append' => 'x' }, [:append, 'x']], + 'preset Hash, truthy fallback: append false then String key' => [{ type: 'preset', append: false, 'append' => 'x' }, [:append, 'x']], + 'preset Hash, truthy fallback: both append keys is the Symbol one' => [{ type: 'preset', 'append' => 's', append: 'x' }, [:append, 'x']], + 'preset Hash with an empty append sends it' => [{ type: 'preset', append: '' }, [:append, '']], + 'preset Hash without an append is no flag' => [{ type: 'preset' }, [:none, nil]], + 'a Hash with an unknown type is no flag' => [{ type: 'bogus', prompt: 'x' }, [:none, nil]], + 'a Hash without a type is no flag' => [{ prompt: 'x' }, [:none, nil]], + 'a Hash with a nil type is no flag' => [{ type: nil }, [:none, nil]], + 'an empty Hash is no flag' => [{}, [:none, nil]], + 'false is no flag, not the empty prompt' => [false, [:none, nil]], + 'a Symbol is no flag' => [:preset, [:none, nil]], + 'an Integer is no flag' => [5, [:none, nil]] + }.each do |rule, (value, expected)| + it(rule) { expect(prompt(value)).to eq(expected) } + end + + it 'hands a Pathname on as it is, typed or in a Hash (the caller converts it)' do + path = Pathname.new('prompts/a.md') + + expect(described_class.system_prompt(ClaudeAgentSDK::SystemPromptFile.new(path: path)).value).to equal(path) + expect(described_class.system_prompt({ type: 'file', path: path }).value).to equal(path) + end + + it 'answers a frozen record' do + expect([nil, 'p', { type: 'preset' }].map { |value| described_class.system_prompt(value) }).to all(be_frozen) + end + end + + describe '.system_prompt_snapshot' do + { + 'a SystemPromptPreset' => [ClaudeAgentSDK::SystemPromptPreset.new(snapshot: false), false], + 'a SystemPromptCustom' => [ClaudeAgentSDK::SystemPromptCustom.new(prompt: 'p', snapshot: true), true], + 'a SystemPromptPreset that leaves it unset' => [ClaudeAgentSDK::SystemPromptPreset.new, nil], + 'a SystemPromptFile' => [ClaudeAgentSDK::SystemPromptFile.new(path: 'a.md'), nil], + 'preset Hash, Symbol keys' => [{ type: 'preset', snapshot: false }, false], + 'preset Hash, String keys' => [{ 'type' => :preset, 'snapshot' => true }, true], + 'custom Hash' => [{ type: 'custom', prompt: 'p', snapshot: false }, false], + 'custom Hash whose prompt is no String (not looked at)' => [{ type: 'custom', prompt: nil, snapshot: false }, false], + 'truthy tag: type nil then String key' => [{ type: nil, 'type' => 'preset', snapshot: false }, false], + 'presence: snapshot nil then String key is nil' => [{ type: 'preset', snapshot: nil, 'snapshot' => false }, nil], + 'presence: both snapshot keys is the Symbol one' => [{ type: 'preset', 'snapshot' => true, snapshot: false }, false], + 'a value that is not a boolean' => [{ type: 'preset', snapshot: 'yes' }, nil], + 'file Hash' => [{ type: 'file', path: 'a.md', snapshot: false }, nil], + 'a Hash with an unknown type' => [{ type: 'bogus', snapshot: false }, nil], + 'a Hash without a type' => [{ snapshot: false }, nil], + 'a String' => ['You are terse.', nil], + 'nil' => [nil, nil] + }.each do |form, (value, expected)| + it("is #{expected.inspect} for #{form}") { expect(described_class.system_prompt_snapshot(value)).to eq(expected) } + end + end + + describe '.exclude_dynamic_sections' do + { + 'a SystemPromptPreset' => [ClaudeAgentSDK::SystemPromptPreset.new(exclude_dynamic_sections: false), false], + 'a SystemPromptPreset that leaves it unset' => [ClaudeAgentSDK::SystemPromptPreset.new, nil], + 'a SystemPromptCustom' => [ClaudeAgentSDK::SystemPromptCustom.new(prompt: 'p'), nil], + 'preset Hash, Symbol keys' => [{ type: 'preset', exclude_dynamic_sections: true }, true], + 'preset Hash, String keys' => [{ 'type' => :preset, 'exclude_dynamic_sections' => false }, false], + 'truthy tag: type nil then String key' => [{ type: nil, 'type' => 'preset', exclude_dynamic_sections: true }, true], + 'presence: nil then String key is nil' => [ + { type: 'preset', exclude_dynamic_sections: nil, 'exclude_dynamic_sections' => true }, nil + ], + 'presence: both keys is the Symbol one' => [ + { type: 'preset', 'exclude_dynamic_sections' => true, exclude_dynamic_sections: false }, false + ], + 'a value that is not a boolean' => [{ type: 'preset', exclude_dynamic_sections: 1 }, nil], + 'custom Hash (the preset alone carries it)' => [{ type: 'custom', prompt: 'p', exclude_dynamic_sections: true }, nil], + 'a Hash without a type' => [{ exclude_dynamic_sections: true }, nil], + 'a String' => ['You are terse.', nil], + 'nil' => [nil, nil] + }.each do |form, (value, expected)| + it("is #{expected.inspect} for #{form}") { expect(described_class.exclude_dynamic_sections(value)).to eq(expected) } + end + end + + describe '.thinking' do + # [type, budget_tokens, display] of the Thinking a value stands for. + def fields(value) + result = described_class.thinking(value) + [result.type, result.budget_tokens, result.display] + end + + { + 'a ThinkingConfigAdaptive' => [ClaudeAgentSDK::ThinkingConfigAdaptive.new(display: 'summarized'), ['adaptive', nil, 'summarized']], + 'a ThinkingConfigEnabled' => [ClaudeAgentSDK::ThinkingConfigEnabled.new(budget_tokens: 2048, display: 'omitted'), ['enabled', 2048, 'omitted']], + 'a ThinkingConfigEnabled without a budget' => [ClaudeAgentSDK::ThinkingConfigEnabled.new, ['enabled', nil, nil]], + 'a ThinkingConfigDisabled' => [ClaudeAgentSDK::ThinkingConfigDisabled.new, ['disabled', nil, nil]], + 'Hash, Symbol keys' => [{ type: 'enabled', budget_tokens: 2048, display: 'omitted' }, ['enabled', 2048, 'omitted']], + 'Hash, String keys' => [{ 'type' => 'adaptive', 'display' => :summarized }, ['adaptive', nil, :summarized]], + 'Hash, Symbol type as a String' => [{ type: :adaptive }, ['adaptive', nil, nil]], + 'Hash, truthy fallback: type nil then String key' => [{ type: nil, 'type' => 'disabled' }, ['disabled', nil, nil]], + 'Hash, truthy fallback: budget_tokens nil then String key' => [ + { type: 'enabled', budget_tokens: nil, 'budget_tokens' => 4096 }, ['enabled', 4096, nil] + ], + 'Hash, truthy fallback: budget_tokens false is no budget' => [{ type: 'enabled', budget_tokens: false }, ['enabled', nil, nil]], + 'Hash, truthy fallback: display false then String key' => [ + { type: 'adaptive', display: false, 'display' => 'omitted' }, ['adaptive', nil, 'omitted'] + ], + 'Hash, truthy fallback: both budget keys is the Symbol one' => [ + { type: 'enabled', 'budget_tokens' => 4096, budget_tokens: 1024 }, ['enabled', 1024, nil] + ], + 'Hash with a display the typed class refuses (not checked)' => [{ type: 'enabled', budget_tokens: 1, display: 'bogus' }, ['enabled', 1, 'bogus']], + 'Hash with a budget that is no Integer (not checked)' => [{ type: 'enabled', budget_tokens: '1' }, ['enabled', '1', nil]], + 'Hash with an unknown type keeps it, for the caller to refuse' => [{ type: 'bogus' }, ['bogus', nil, nil]], + 'Hash without a type has a nil type' => [{ budget_tokens: 2048 }, [nil, 2048, nil]], + 'a String has a nil type' => ['adaptive', [nil, nil, nil]], + 'true has a nil type' => [true, [nil, nil, nil]] + }.each do |form, (value, expected)| + it("reads #{form}") { expect(fields(value)).to eq(expected) } + end + + it 'answers a frozen record' do + expect(described_class.thinking({ type: 'adaptive' })).to be_frozen + end + end + + describe '.tools' do + it 'is DEFAULT_TOOLS for the preset, typed or as a Hash in any spelling' do + presets = [ + ClaudeAgentSDK::ToolsPreset.new(preset: 'claude_code'), { type: 'preset', preset: 'claude_code' }, + { 'type' => 'preset' }, { type: :preset }, { type: nil, 'type' => 'preset' } + ] + + expect(presets.map { |value| described_class.tools(value) }).to all(equal(described_class::DEFAULT_TOOLS)) + end + + it 'is the value as it was given for every other one' do + values = [%w[Read Grep], [], 'Read,Grep', '', { type: 'bogus', names: ['Read'] }, { names: ['Read'] }, {}, nil, 5, true] + + values.each { |value| expect(described_class.tools(value)).to equal(value) } + end + + # `tools: :default` sends no flag, so the preset is not a Symbol. + it 'does not take the Symbol :default for the preset' do + expect(described_class.tools(:default)).to equal(:default) + expect(described_class::DEFAULT_TOOLS).not_to eq(:default) + end + end + + describe '.output_schema' do + schema = { type: 'object' }.freeze + other = { type: 'array' }.freeze + + { + 'Symbol keys' => [{ type: 'json_schema', schema: schema }, schema], + 'String keys' => [{ 'type' => 'json_schema', 'schema' => schema }, schema], + 'a Symbol type' => [{ type: :json_schema, schema: schema }, schema], + 'a Symbol-keyed type and a String-keyed schema' => [{ type: 'json_schema', 'schema' => schema }, schema], + 'a String-keyed type and a Symbol-keyed schema' => [{ 'type' => 'json_schema', schema: schema }, schema], + 'tag spelling: a String-keyed type reads the String-keyed schema first' => [ + { 'type' => 'json_schema', 'schema' => schema, schema: other }, schema + ], + 'tag spelling: a Symbol-keyed type reads the Symbol-keyed schema first' => [ + { type: 'json_schema', 'schema' => other, schema: schema }, schema + ], + 'tag spelling: the Symbol-keyed type is looked at first' => [ + { type: 'json_schema', 'type' => 'text', 'schema' => other, schema: schema }, schema + ], + 'tag spelling: the String-keyed type is looked at when the Symbol-keyed one is another' => [ + { type: 'text', 'type' => 'json_schema', 'schema' => schema, schema: other }, schema + ], + 'presence: schema nil then String key is nil' => [{ type: 'json_schema', schema: nil, 'schema' => schema }, nil], + 'no schema is nil' => [{ type: 'json_schema' }, nil], + 'a false schema is false' => [{ type: 'json_schema', schema: false }, false], + 'a schema given as JSON text' => [{ type: 'json_schema', schema: '{}' }, '{}'] + }.each do |rule, (value, expected)| + it(rule) { expect(described_class.output_schema(value)).to equal(expected) } + end + + it 'is the value itself when it is not tagged json_schema' do + [schema, { 'type' => 'text' }, {}, '{"type":"object"}', nil, false, :json].each do |value| + expect(described_class.output_schema(value)).to equal(value) + end + end + end + + describe '.task_budget_total' do + { + 'a TaskBudget' => [ClaudeAgentSDK::TaskBudget.new(total: 5000), 5000], + 'a TaskBudget without a total' => [ClaudeAgentSDK::TaskBudget.new, nil], + 'Hash, Symbol key' => [{ total: 5000 }, 5000], + 'Hash, String key' => [{ 'total' => 5 }, 5], + 'truthy fallback: total nil then String key' => [{ total: nil, 'total' => 7 }, 7], + 'truthy fallback: total false then String key' => [{ total: false, 'total' => 7 }, 7], + 'truthy fallback: both total keys is the Symbol one' => [{ 'total' => 7, total: 3 }, 3], + 'an empty Hash' => [{}, nil], + 'nil' => [nil, nil], + 'false' => [false, nil] + }.each do |form, (value, expected)| + it("is #{expected.inspect} for #{form}") { expect(described_class.task_budget_total(value)).to eq(expected) } + end + + # Read as a Hash, like every value that is not a TaskBudget. + it 'fails loudly for a value that is no budget at all' do + expect { described_class.task_budget_total(5) }.to raise_error(TypeError) + end + end + + describe '.plugin' do + # [type_tag, path, raw_type] of the Plugin an entry stands for. + def fields(value) + result = described_class.plugin(value) + [result.type_tag, result.path, result.raw_type] + end + + { + 'a SdkPluginConfig' => [ClaudeAgentSDK::SdkPluginConfig.new(path: '/p'), ['local', '/p', 'local']], + 'a SdkPluginConfig without a path' => [ClaudeAgentSDK::SdkPluginConfig.new, ['local', nil, 'local']], + 'Hash, Symbol keys' => [{ type: 'local', path: '/p' }, ['local', '/p', 'local']], + 'Hash, String keys' => [{ 'type' => 'plugin', 'path' => '/p' }, ['plugin', '/p', 'plugin']], + 'Hash, Symbol type: the tag is a String, the raw type as written' => [{ type: :local, path: '/p' }, ['local', '/p', :local]], + 'Hash, truthy fallback: path nil then String key' => [{ type: 'local', path: nil, 'path' => '/b' }, ['local', '/b', 'local']], + 'Hash, truthy fallback: path false is no path' => [{ type: 'local', path: false }, ['local', nil, 'local']], + 'Hash, truthy fallback: type nil then String key' => [{ type: nil, 'type' => 'local', path: '/p' }, ['local', '/p', 'local']], + 'Hash, truthy fallback: both path keys is the Symbol one' => [{ type: 'local', 'path' => '/s', path: '/p' }, ['local', '/p', 'local']], + 'Hash with an unknown type keeps it, for the caller to refuse' => [{ type: :remote, path: '/x' }, ['remote', '/x', :remote]], + 'Hash without a type' => [{ path: '/x' }, ['', '/x', nil]] + }.each do |form, (value, expected)| + it("reads #{form}") { expect(fields(value)).to eq(expected) } + end + + # CommandBuilder asks for raw_type only when it refuses the plugin; a + # Hash that computes its values sees each reading. + it 'reads the type once for the tag, and anew each time raw_type is asked for' do + readings = 0 + entry = Hash.new { |_hash, key| key == :type ? "remote-#{readings += 1}" : nil } + entry[:path] = '/p' + + result = described_class.plugin(entry) + + expect([result.type_tag, result.path, readings]).to eq(['remote-1', '/p', 1]) + expect([result.raw_type, result.raw_type, readings]).to eq(['remote-2', 'remote-3', 3]) + end + + it 'takes the #to_h of a SdkPluginConfig once, for the tag and for raw_type alike' do + config = ClaudeAgentSDK::SdkPluginConfig.new(path: '/p') + calls = 0 + config.define_singleton_method(:to_h) { { type: "local-#{calls += 1}", path: '/p' } } + + result = described_class.plugin(config) + + expect([result.type_tag, result.raw_type, calls]).to eq(['local-1', 'local-1', 1]) + end + + it 'hands a Pathname on as it is and answers a frozen record' do + path = Pathname.new('/srv/plugins/review') + result = described_class.plugin({ type: 'local', path: path }) + + expect(result.path).to equal(path) + expect(result).to be_frozen + expect(path).not_to be_frozen + end + end + + describe '.sandbox' do + wire = { enabled: true, autoAllowBashIfSandboxed: false, network: { deniedDomains: ['evil.example'] } } + + { + 'a SandboxSettings is its #to_h' => ClaudeAgentSDK::SandboxSettings.new( + enabled: true, auto_allow_bash_if_sandboxed: false, network: { denied_domains: ['evil.example'] } + ), + 'a snake_case Hash is renamed like the typed value' => { + enabled: true, auto_allow_bash_if_sandboxed: false, network: { denied_domains: ['evil.example'] } + }, + 'a snake_case Hash with String keys' => { + 'enabled' => true, 'auto_allow_bash_if_sandboxed' => false, 'network' => { 'denied_domains' => ['evil.example'] } + }, + 'a camelCase Hash' => { 'enabled' => true, 'autoAllowBashIfSandboxed' => false, 'network' => { 'deniedDomains' => ['evil.example'] } }, + 'a Hash holding a typed network' => { + enabled: true, auto_allow_bash_if_sandboxed: false, + network: ClaudeAgentSDK::SandboxNetworkConfig.new(denied_domains: ['evil.example']) + } + }.each do |rule, value| + it(rule) { expect(described_class.sandbox(value)).to eq(wire) } + end + + it 'sends the later of the two enabled keys of a Hash' do + expect(described_class.sandbox({ enabled: true, 'enabled' => false })).to eq(enabled: false) + end + + it 'is the value itself for a boolean, nil and anything else' do + [true, false, nil, 'yes', [1]].each { |value| expect(described_class.sandbox(value)).to equal(value) } + end + + it 'leaves the Hash it was given as it was' do + given = { enabled: true, network: { denied_domains: ['evil.example'] } } + + described_class.sandbox(given) + + expect(given).to eq(enabled: true, network: { denied_domains: ['evil.example'] }) + end + end + + describe '.sandbox_requested?' do + { + 'true' => [true, true], + 'false' => [false, false], + 'nil' => [nil, false], + 'a SandboxSettings with enabled true' => [ClaudeAgentSDK::SandboxSettings.new(enabled: true), true], + 'a SandboxSettings with enabled false' => [ClaudeAgentSDK::SandboxSettings.new(enabled: false), false], + 'a SandboxSettings that leaves enabled unset' => [ClaudeAgentSDK::SandboxSettings.new, false], + 'Hash, Symbol key' => [{ enabled: true }, true], + 'Hash, String key' => [{ 'enabled' => true }, true], + 'any-true: Symbol key true, String key false' => [{ enabled: true, 'enabled' => false }, true], + 'any-true: Symbol key false, String key true' => [{ enabled: false, 'enabled' => true }, true], + 'Hash with enabled false' => [{ enabled: false }, false], + 'Hash whose enabled is truthy but not true' => [{ enabled: 'true', 'enabled' => 1 }, false], + 'an empty Hash' => [{}, false], + 'a truthy value that is not true' => ['true', false] + }.each do |form, (value, expected)| + it("is #{expected} for #{form}") { expect(described_class.sandbox_requested?(value)).to be(expected) } + end + + # The transport asks from its stderr threads: nothing but the one read. + it 'does not build the wire form of a typed value' do + settings = ClaudeAgentSDK::SandboxSettings.new(enabled: true) + allow(settings).to receive(:to_h).and_raise('to_h called') + + expect(described_class.sandbox_requested?(settings)).to be(true) + end + end + + describe '.mcp_servers and .sdk_mcp_servers' do + server = ClaudeAgentSDK.create_sdk_mcp_server(name: 'calc', tools: []).fetch(:instance) + + it 'strip the instance from the sdk entries whose instance they collect' do + servers = { + 'symbols' => { type: 'sdk', name: 'a', instance: server }, + 'strings' => { 'type' => 'sdk', 'name' => 'b', 'instance' => server }, + 'symbol_type' => { type: :sdk, name: 'c', instance: server }, + 'both_keys' => { type: 'sdk', name: 'd', instance: server, 'instance' => server }, + 'typed' => ClaudeAgentSDK::McpSdkServerConfig.new(name: 'e', instance: server), + 'stdio_hash' => { type: 'stdio', command: 'node', instance: 'kept' }, + 'stdio_typed' => ClaudeAgentSDK::McpStdioServerConfig.new(command: 'node') + } + + expect(described_class.mcp_servers(servers)).to eq( + 'symbols' => { type: 'sdk', name: 'a' }, + 'strings' => { 'type' => 'sdk', 'name' => 'b' }, + 'symbol_type' => { type: :sdk, name: 'c' }, + 'both_keys' => { type: 'sdk', name: 'd' }, + 'typed' => { type: 'sdk', name: 'e' }, + 'stdio_hash' => { type: 'stdio', command: 'node', instance: 'kept' }, + 'stdio_typed' => { type: 'stdio', command: 'node' } + ) + expect(described_class.sdk_mcp_servers(servers).keys).to eq(%w[symbols strings symbol_type both_keys typed]) + expect(described_class.sdk_mcp_servers(servers).values).to all(equal(server)) + end + + it 'recognize an sdk entry by a truthy tag: type nil then String key' do + servers = { 'calc' => { type: nil, 'type' => 'sdk', 'instance' => server } } + + expect(described_class.mcp_servers(servers)).to eq('calc' => { type: nil, 'type' => 'sdk' }) + expect(described_class.sdk_mcp_servers(servers)).to eq('calc' => server) + end + + it 'read the instance by presence: nil under the Symbol key is the instance' do + servers = { + 'nil_symbol_key' => { type: 'sdk', instance: nil, 'instance' => server }, + 'both_keys' => { type: 'sdk', 'instance' => nil, instance: server }, + 'no_instance' => { type: 'sdk' } + } + + expect(described_class.sdk_mcp_servers(servers)).to eq('nil_symbol_key' => nil, 'both_keys' => server, 'no_instance' => nil) + end + + it 'hand a config that is neither typed nor a Hash on as it is' do + expect(described_class.mcp_servers({ 'a' => 'x' })).to eq('a' => 'x') + expect(described_class.sdk_mcp_servers({ 'a' => 'x' })).to eq({}) + end + + it 'answer a new Hash and leave the given one, its configs and its live server as they were' do + config = { type: 'sdk', name: 'calc', instance: server } + servers = { 'calc' => config } + + result = described_class.mcp_servers(servers) + + expect(result).not_to equal(servers) + expect(servers).to eq('calc' => { type: 'sdk', name: 'calc', instance: server }) + expect(servers['calc']).to equal(config) + expect(described_class.sdk_mcp_servers(servers)['calc']).to equal(server) + end + + it 'hand a path, JSON text or nil on as they are, with no live servers' do + ['config/mcp.json', '{"mcpServers":{}}', nil].each do |value| + expect(described_class.mcp_servers(value)).to equal(value) + expect(described_class.sdk_mcp_servers(value)).to eq({}) + end + end + end + + describe '.agent_definition' do + it 'is the AgentDefinition itself' do + agent = ClaudeAgentSDK::AgentDefinition.new(description: 'd', prompt: 'p') + + expect(described_class.agent_definition(agent)).to equal(agent) + end + + it 'builds a Hash through AgentDefinition.new, in any key style' do + agents = [ + { description: 'd', prompt: 'p', max_turns: 4 }, { 'description' => 'd', 'prompt' => 'p', 'max_turns' => 4 }, + { 'description' => 'd', 'prompt' => 'p', 'maxTurns' => 4 } + ].map { |hash| described_class.agent_definition(hash) } + + expect(agents).to all(be_a(ClaudeAgentSDK::AgentDefinition)) + expect(agents.map { |agent| [agent.description, agent.prompt, agent.max_turns] }).to eq([['d', 'p', 4]] * 3) + end + + # Attribute by attribute, in the order written. + it 'reads the later of two spellings of one attribute' do + expect(described_class.agent_definition({ 'prompt' => 'first', prompt: 'second' }).prompt).to eq('second') + end + + it 'raises the strict-attribute error for a misspelled key' do + expect { described_class.agent_definition({ description: 'd', promt: 'p' }) } + .to raise_error(ArgumentError, /\AClaudeAgentSDK::AgentDefinition: unknown attribute :promt \(known: /) + end + end + + it 'keeps its Hash reader to itself' do + expect { described_class::HashForm }.to raise_error(NameError, /private constant/) + end +end diff --git a/spec/unit/sandbox_hash_keys_spec.rb b/spec/unit/sandbox_hash_keys_spec.rb index cb234b20..b05fc444 100644 --- a/spec/unit/sandbox_hash_keys_spec.rb +++ b/spec/unit/sandbox_hash_keys_spec.rb @@ -12,7 +12,9 @@ # applied: the session ran with a weaker sandbox than the one written. # # The examples assert on the --settings argument the CLI is started with (and -# on SandboxSettings#to_h, the public form of the same section). +# on SandboxSettings#to_h, the public form of the same section). The one +# group that walks every field in every spelling asserts on the section +# OptionForms.sandbox hands CommandBuilder for that argument instead. RSpec.describe 'sandbox settings written as a Hash' do # The --settings argument built for this `sandbox:` option. def settings_argument(sandbox, **options) @@ -128,7 +130,6 @@ def on_the_wire(value) 'camelCase String keys' => ->(fields) { fields.to_h { |_name, (wire, value)| [wire, value] } } }.freeze attributes_of = spellings.fetch('snake_case Symbol keys') - wire_form_of = spellings.fetch('camelCase String keys') # The top-level fields around one network and one filesystem section. around_sections = lambda do |network, filesystem| @@ -194,11 +195,20 @@ def on_the_wire(value) 'SandboxFilesystemConfig' => ->(fields) { { enabled: true, filesystem: fields } } }.freeze + # Asserted where the section is made, not on the command line: OptionForms + # is the one reader of a sandbox given either way, and CommandBuilder puts + # what it answers under "sandbox" in --settings as it is (the groups above + # and below follow it there). describe 'every field of every sandbox class' do - # The section the CLI must receive, whichever way it was written. - wire = wire_form_of.call( - around_sections.call(wire_form_of.call(network_fields), wire_form_of.call(filesystem_fields)) - ) + # The section the CLI must receive, whichever way it was written, under + # the Symbol keys the typed classes write. + wire_keys = spellings.fetch('camelCase Symbol keys') + wire = wire_keys.call(around_sections.call(wire_keys.call(network_fields), wire_keys.call(filesystem_fields))) + + # The sandbox section read out of this `sandbox:` option. + def section_read_from(sandbox) + ClaudeAgentSDK::OptionForms.sandbox(sandbox) + end it 'has a field for every attribute' do expect( @@ -222,7 +232,7 @@ def on_the_wire(value) ) ) - expect(sandbox_section(typed)).to eq(wire) + expect(section_read_from(typed)).to eq(wire) end spellings.each do |inner_spelling, inner| @@ -231,14 +241,14 @@ def on_the_wire(value) attributes_of.call(around_sections.call(inner.call(network_fields), inner.call(filesystem_fields))) ) - expect(sandbox_section(sandbox)).to eq(wire) + expect(section_read_from(sandbox)).to eq(wire) end spellings.each do |outer_spelling, outer| it "is written the same way from a Hash with #{outer_spelling} holding Hashes with #{inner_spelling}" do sandbox = outer.call(around_sections.call(inner.call(network_fields), inner.call(filesystem_fields))) - expect(sandbox_section(sandbox)).to eq(wire) + expect(section_read_from(sandbox)).to eq(wire) end end end diff --git a/spec/unit/sandbox_unavailable_warning_spec.rb b/spec/unit/sandbox_unavailable_warning_spec.rb index 182d574c..ce85758a 100644 --- a/spec/unit/sandbox_unavailable_warning_spec.rb +++ b/spec/unit/sandbox_unavailable_warning_spec.rb @@ -86,6 +86,10 @@ def transport_with(**values) 'a SandboxSettings with enabled: true' => -> { ClaudeAgentSDK::SandboxSettings.new(enabled: true) }, 'a Hash with enabled: true' => -> { { enabled: true } }, 'a Hash with a String "enabled" key' => -> { { 'enabled' => true } }, + # Either key saying true is a request, whatever the other one says (the + # command line carries the later of the two). + 'a Hash whose Symbol key says true and whose String key says false' => -> { { enabled: true, 'enabled' => false } }, + 'a Hash whose String key says true and whose Symbol key says false' => -> { { enabled: false, 'enabled' => true } }, 'sandbox: true' => -> { true } }.each do |description, sandbox| it "warns once when the sandbox was requested by #{description}" do @@ -111,6 +115,8 @@ def transport_with(**values) 'sandbox: false' => -> { false }, 'a Hash with enabled: false' => -> { { enabled: false } }, 'an empty Hash' => -> { {} }, + # Only true is a request. + 'a Hash whose enabled is truthy but not true' => -> { { enabled: 'true', 'enabled' => 1 } }, 'a SandboxSettings with enabled: false' => -> { ClaudeAgentSDK::SandboxSettings.new(enabled: false) }, 'a SandboxSettings that leaves enabled unset' => -> { ClaudeAgentSDK::SandboxSettings.new } }.each do |description, sandbox|