From 3526021d0472b860f3db28b88ba36e775809d4e8 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Tue, 6 Oct 2026 03:21:37 +0800 Subject: [PATCH 1/8] Characterize how option Hashes are read before moving the readers Seven options take a typed value or the equivalent Hash, and fifteen readers across CommandBuilder, the root extractors, the transport and Query each interpret such a Hash with a key rule of their own (truthy fallback, presence, tag-spelling precedence, any-true). The next commits move that interpretation into one private module without changing any of it, so the behaviour has to be pinned first, at the seams a caller can observe. option_forms_characterization_spec.rb records, on main: - the whole command line for every form of system_prompt, tools, sandbox, task_budget, thinking, output_format, mcp_servers and plugins, including the both-spellings and nil / false corner cases and the collision the configured-defaults merge produces - the class, message and raise site (command_builder.rb, file only) of the four build errors - that a process requiring only claude_agent_sdk/command_builder builds the default command line - the initialize request for agents and the system prompt metadata through a fake transport, the connect -> read_started -> error order of the strict agent error, and that the metadata path never checks a custom prompt's text - that an option Hash is stored and read back as the caller's object and re-read by every build sandbox_unavailable_warning_spec.rb gains the any-true cases of the warning predicate. No production code changes. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01KzJcBPQ8Vq4kjkxdm2VnYm --- .../option_forms_characterization_spec.rb | 810 ++++++++++++++++++ spec/unit/sandbox_unavailable_warning_spec.rb | 6 + 2 files changed, 816 insertions(+) create mode 100644 spec/unit/option_forms_characterization_spec.rb diff --git a/spec/unit/option_forms_characterization_spec.rb b/spec/unit/option_forms_characterization_spec.rb new file mode 100644 index 00000000..71d2f37b --- /dev/null +++ b/spec/unit/option_forms_characterization_spec.rb @@ -0,0 +1,810 @@ +# 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"'] + } + 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' }] } + }.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/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| From 407ae2d58a216c8726f489b4b8c2150a1fb29672 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Tue, 6 Oct 2026 03:31:01 +0800 Subject: [PATCH 2/8] Add OptionForms, the one module that reads an option Hash An option given as a Hash is interpreted in fifteen places today, each with its own rule for the spelling of a key and for nil / false under it. OptionForms gathers those rules behind stateless per-value functions: a caller hands over the value it holds and gets back a small frozen record (Prompt, Thinking, Plugin) or the value as it was given. Each function keeps the rule its reader had (truthy fallback, presence, tag-spelling precedence, any-true); nothing is unified, validated, cached or hydrated through a public constructor, and agents stay strict through AgentDefinition.new. Every read of a Hash key sits in the nested, private HashForm; the functions tell the forms apart by class and go there for the Hash one. That split also keeps the module inside Metrics/ModuleLength without an inline disable. The tools preset is answered as an object of its own, DEFAULT_TOOLS, rather than the Symbol :default: `tools: :default` is a value a caller can write, and it sends no flag today. Nothing calls the module yet. command_builder.rb requires it directly, next to its other requires, so that a process loading that file alone keeps building once the readers are switched. The module is @api private and has no signature. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01KzJcBPQ8Vq4kjkxdm2VnYm --- lib/claude_agent_sdk/command_builder.rb | 1 + lib/claude_agent_sdk/option_forms.rb | 350 ++++++++++++++++++ spec/unit/option_forms_spec.rb | 453 ++++++++++++++++++++++++ 3 files changed, 804 insertions(+) create mode 100644 lib/claude_agent_sdk/option_forms.rb create mode 100644 spec/unit/option_forms_spec.rb diff --git a/lib/claude_agent_sdk/command_builder.rb b/lib/claude_agent_sdk/command_builder.rb index e190cf82..e59f95bc 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. diff --git a/lib/claude_agent_sdk/option_forms.rb b/lib/claude_agent_sdk/option_forms.rb new file mode 100644 index 00000000..bc12701e --- /dev/null +++ b/lib/claude_agent_sdk/option_forms.rb @@ -0,0 +1,350 @@ +# frozen_string_literal: true + +require_relative 'types' + +module ClaudeAgentSDK + # Every rule the SDK applies to a Hash that stands for an option value, in + # one place. 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 record, or the value as it was given. + # + # 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, the one place a key of an option Hash is read, says for each + # key which of them applies. spec/unit/option_forms_characterization_spec.rb + # pins every one 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; + # +raw_type+ is the type as it was written, for an error message. + class Plugin + attr_reader :type_tag, :path, :raw_type + + def initialize(type_tag:, path:, raw_type:) + @type_tag = type_tag + @path = path + @raw_type = raw_type + freeze + end + end + + # Every read of a key of an option Hash, 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; nothing else in the SDK reads these keys. + 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+ and the type are truthy. + def self.plugin(hash) + path = hash[:path] || hash['path'] + raw_type = hash[:type] || hash['type'] + Plugin.new(type_tag: raw_type.to_s, path: path, raw_type: raw_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. The caller refuses a type that is no plugin type 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/spec/unit/option_forms_spec.rb b/spec/unit/option_forms_spec.rb new file mode 100644 index 00000000..a32ca580 --- /dev/null +++ b/spec/unit/option_forms_spec.rb @@ -0,0 +1,453 @@ +# 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 + + 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 From b21c68b5fd77d904a1649526aea98481ecf6be85 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Tue, 6 Oct 2026 03:33:59 +0800 Subject: [PATCH 3/8] Read option Hashes in CommandBuilder through OptionForms CommandBuilder interpreted a Hash-form option itself at eleven places: the type tag, the fields of a system prompt, thinking config, task budget and plugin, the tools preset, the json_schema wrapper, the sandbox section and the sdk MCP server entries. Each append stage now asks OptionForms what the option stands for and keeps what is its own: flag names, path conversion, CSV / JSON encoding and the four errors, which are still raised from this file (custom prompt without text, enabled thinking without a budget, unknown thinking config, unknown plugin type). #hash_type, #append_hash_system_prompt, #thinking_fields, #sandbox_section and #output_schema are gone, with the Hash arms of #append_tools, #append_mcp_servers, #append_plugins and #append_budget. The Hash branches that remain in #append_tools and #append_mcp_servers only choose an encoding. No behaviour changes: the characterization suite passes unchanged, including the exact command lines for the both-spellings and nil / false cases, the raise sites, the standalone require and the rebuild after a caller mutates its Hash. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01KzJcBPQ8Vq4kjkxdm2VnYm --- lib/claude_agent_sdk/command_builder.rb | 175 ++++++------------------ 1 file changed, 45 insertions(+), 130 deletions(-) diff --git a/lib/claude_agent_sdk/command_builder.rb b/lib/claude_agent_sdk/command_builder.rb index e59f95bc..ef141706 100644 --- a/lib/claude_agent_sdk/command_builder.rb +++ b/lib/claude_agent_sdk/command_builder.rb @@ -69,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 @@ -339,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? @@ -356,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 @@ -390,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 @@ -410,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. @@ -449,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`). @@ -480,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 @@ -503,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 @@ -540,17 +457,15 @@ 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) + 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 From fe23800cb5db12b76158c9a990b539a3c7f39ed5 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Tue, 6 Oct 2026 03:36:34 +0800 Subject: [PATCH 4/8] Delegate the remaining option-Hash readers to OptionForms Four readers outside CommandBuilder still interpreted an option Hash themselves, and one of them carried a comment saying it had to match CommandBuilder's rule by hand: - ClaudeAgentSDK.extract_sdk_mcp_servers, .extract_exclude_dynamic_sections and .extract_system_prompt_snapshot keep their signatures and their two call sites and become one-line delegates. The sdk entries whose live server a session registers and the ones whose instance the command line strips are now recognized by the same private helper. - Query#initialize_protocol builds an agent Hash through OptionForms.agent_definition, at the same line: still strict AgentDefinition.new, still after the read loop started and before anything is written. - SubprocessCLITransport#sandbox_requested? asks OptionForms.sandbox_requested?, the same shallow any-true read, so nothing new runs on the stderr threads. The root entry point requires option_forms itself, since it now calls it directly. No behaviour changes; the characterization suite and the wiring specs (agents_hash_option, option_hash_symbol_type, sdk_mcp_server, sandbox_unavailable_warning) pass unchanged. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01KzJcBPQ8Vq4kjkxdm2VnYm --- lib/claude_agent_sdk.rb | 38 ++----------------- lib/claude_agent_sdk/query.rb | 2 +- .../subprocess_cli_transport.rb | 7 +--- 3 files changed, 6 insertions(+), 41 deletions(-) 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/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..7d5ca19c 100644 --- a/lib/claude_agent_sdk/subprocess_cli_transport.rb +++ b/lib/claude_agent_sdk/subprocess_cli_transport.rb @@ -1296,12 +1296,7 @@ def warn_if_sandbox_unavailable(line) # SandboxSettings with `enabled` true, or a Hash with an `enabled` / # 'enabled' key that is true (Hashes are forwarded to the CLI verbatim). 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 From 4f336bf930448d5d603d56930e44dbbf04eed8c6 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Tue, 6 Oct 2026 03:39:44 +0800 Subject: [PATCH 5/8] Point the sandbox field walk at OptionForms and note the move The group in sandbox_hash_keys_spec.rb that walks every field of the three sandbox classes in every spelling (21 examples) built a whole command line and parsed its --settings JSON to compare one section. It now asserts on that section where it is made, OptionForms.sandbox, under the Symbol keys the typed classes write. No example is removed. The groups that pin the command line itself are unchanged: the same sandbox written three ways, and everything from the SandboxKeys tables to the end of the file (unmodeled keys, values that are not rewritten, both spellings, nil under a known key, rejected shapes, typed nested values, non-mutation). No example leaves command_builder_spec.rb either: none of its Hash-form examples repeats a row of the characterization table verbatim. CHANGELOG: one Internal line under Unreleased. Nothing a gem user would notice changed. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01KzJcBPQ8Vq4kjkxdm2VnYm --- CHANGELOG.md | 3 +++ spec/unit/sandbox_hash_keys_spec.rb | 28 +++++++++++++++++++--------- 2 files changed, 22 insertions(+), 9 deletions(-) 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/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 From 6c8b637cc0bf0fd3387ad19dfa89563fcfbe35fe Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Tue, 6 Oct 2026 04:06:29 +0800 Subject: [PATCH 6/8] Read a refused plugin's type a second time, as before Before the readers moved, CommandBuilder#append_plugins read a plugin entry in this order: the path, then the type once to recognize the plugin, and, only for a plugin it refused, the type again for the error message. OptionForms.plugin collapsed the two type reads into one and carried the value in the record. For a plain Hash literal that is the same thing; for a Hash that computes its values (a default proc) it is not: the message named the first reading instead of the second, and the entry was read once instead of twice. Such a Hash fits the signature of plugins, so this was a change of behaviour in a refactor that promised none (code review round 1, blocking 1). Plugin#raw_type is now a method that looks the type up in the entry each time it is asked for, through HashForm.plugin_type, and the record keeps the entry (the #to_h of a typed SdkPluginConfig, taken once) instead of a saved value. CommandBuilder asks for it only where it raises, so the sequence is the original one again and an accepted plugin still has its type read once. The raise stays in command_builder.rb. The characterization suite gains the cases that tell the two apart, all through CommandBuilder#build and green on main: a computing Hash that is refused (message names the second reading; lookups are path, type, type), the same with the String-key fallback, an accepted one (path, type) and an accepted one without a path, plus the raise site for a computing Hash. option_forms_spec.rb pins the lazy read and the single #to_h at the module. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01KzJcBPQ8Vq4kjkxdm2VnYm --- lib/claude_agent_sdk/command_builder.rb | 2 + lib/claude_agent_sdk/option_forms.rb | 33 ++++++++---- .../option_forms_characterization_spec.rb | 52 ++++++++++++++++++- spec/unit/option_forms_spec.rb | 23 ++++++++ 4 files changed, 99 insertions(+), 11 deletions(-) diff --git a/lib/claude_agent_sdk/command_builder.rb b/lib/claude_agent_sdk/command_builder.rb index ef141706..0e9cf217 100644 --- a/lib/claude_agent_sdk/command_builder.rb +++ b/lib/claude_agent_sdk/command_builder.rb @@ -461,6 +461,8 @@ def append_plugins(cmd) plugin = OptionForms.plugin(entry) 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 diff --git a/lib/claude_agent_sdk/option_forms.rb b/lib/claude_agent_sdk/option_forms.rb index bc12701e..a99ea9a7 100644 --- a/lib/claude_agent_sdk/option_forms.rb +++ b/lib/claude_agent_sdk/option_forms.rb @@ -74,17 +74,25 @@ def initialize(type: nil, budget_tokens: nil, display: nil) end end - # What .plugin answers. +type_tag+ is the type as a String, to compare; - # +raw_type+ is the type as it was written, for an error message. + # What .plugin answers. +type_tag+ is the type as a String, to compare. class Plugin - attr_reader :type_tag, :path, :raw_type + attr_reader :type_tag, :path - def initialize(type_tag:, path:, raw_type:) + def initialize(type_tag:, path:, config:) @type_tag = type_tag @path = path - @raw_type = raw_type + @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 # Every read of a key of an option Hash, each under the rule its option @@ -151,11 +159,15 @@ def self.total(hash) hash[:total] || hash['total'] end - # One plugin: +path+ and the type are truthy. + # One plugin: +path+ is truthy and read first, then the tag, once. def self.plugin(hash) path = hash[:path] || hash['path'] - raw_type = hash[:type] || hash['type'] - Plugin.new(type_tag: raw_type.to_s, path: path, raw_type: raw_type) + 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 @@ -262,8 +274,9 @@ def self.task_budget_total(value) end # One entry of +plugins+ (see Plugin); a typed SdkPluginConfig is read - # through its #to_h. The caller refuses a type that is no plugin type and - # skips an entry without a path. + # 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 diff --git a/spec/unit/option_forms_characterization_spec.rb b/spec/unit/option_forms_characterization_spec.rb index 71d2f37b..e7c2d29d 100644 --- a/spec/unit/option_forms_characterization_spec.rb +++ b/spec/unit/option_forms_characterization_spec.rb @@ -479,6 +479,55 @@ def self.it_refuses(option, rows, **metadata) '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 @@ -493,7 +542,8 @@ def self.it_refuses(option, rows, **metadata) '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 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) diff --git a/spec/unit/option_forms_spec.rb b/spec/unit/option_forms_spec.rb index a32ca580..04195d05 100644 --- a/spec/unit/option_forms_spec.rb +++ b/spec/unit/option_forms_spec.rb @@ -272,6 +272,29 @@ def fields(value) 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 }) From 2736c64a788eb83cd628bf210562ddbcf0af9c89 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Tue, 6 Oct 2026 04:42:45 +0800 Subject: [PATCH 7/8] Say what OptionForms and the sandbox predicate actually cover Comments only; no code changes. - SubprocessCLITransport#sandbox_requested? still said that sandbox Hashes "are forwarded to the CLI verbatim". That predates this branch and stopped being true when SandboxKeys began renaming a Hash's keys. The comment now says what the predicate is (the shallow any-true read of OptionForms.sandbox_requested?, made for the warning alone) and that the payload the CLI is sent is OptionForms.sandbox. - OptionForms and its nested HashForm claimed to be the one place a key of an option Hash is read, with nothing else in the SDK reading them. HashForm holds the direct field lookups that were moved out of the consumers. A sandbox Hash is still read and renamed by SandboxKeys.normalize (types/option_values.rb), which OptionForms.sandbox delegates to, and an agent Hash by the strict AgentDefinition.new and the Type machinery, which OptionForms.agent_definition delegates to. Both comments now say so. - The module comment also lists what the functions answer: a frozen record, a plain value, the value as given, or a new Hash. Only the records are frozen, never a value the caller owns. From the PR-level review (non-blocking 2 and blocking 4). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01KzJcBPQ8Vq4kjkxdm2VnYm --- lib/claude_agent_sdk/option_forms.rb | 40 +++++++++++++------ .../subprocess_cli_transport.rb | 10 +++-- 2 files changed, 35 insertions(+), 15 deletions(-) diff --git a/lib/claude_agent_sdk/option_forms.rb b/lib/claude_agent_sdk/option_forms.rb index a99ea9a7..90254d66 100644 --- a/lib/claude_agent_sdk/option_forms.rb +++ b/lib/claude_agent_sdk/option_forms.rb @@ -3,10 +3,12 @@ require_relative 'types' module ClaudeAgentSDK - # Every rule the SDK applies to a Hash that stands for an option value, in - # one place. 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 record, or the value as it was given. + # 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 @@ -27,10 +29,15 @@ module ClaudeAgentSDK # presence hash.fetch(:key) { hash['key'] }: a Symbol key that is there # is the one read, whatever it holds # - # and HashForm, the one place a key of an option Hash is read, says for each - # key which of them applies. spec/unit/option_forms_characterization_spec.rb - # pins every one at the command line and the initialize request; a change - # of rule is a change of behaviour, not a cleanup. + # 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 @@ -95,10 +102,19 @@ def raw_type end end - # Every read of a key of an option Hash, 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; nothing else in the SDK reads these keys. + # 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 diff --git a/lib/claude_agent_sdk/subprocess_cli_transport.rb b/lib/claude_agent_sdk/subprocess_cli_transport.rb index 7d5ca19c..c6f20610 100644 --- a/lib/claude_agent_sdk/subprocess_cli_transport.rb +++ b/lib/claude_agent_sdk/subprocess_cli_transport.rb @@ -1292,9 +1292,13 @@ 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) and, of two `enabled` keys, sends the later one. def sandbox_requested? OptionForms.sandbox_requested?(@options.sandbox) end From 981100c5a7b6cebdff2254ea1d7c20c44505e744 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Tue, 6 Oct 2026 05:07:52 +0800 Subject: [PATCH 8/8] Qualify the sandbox comment: the later non-nil enabled key is sent Comment only; no code changes. The comment added to SubprocessCLITransport#sandbox_requested? in 2736c64 said that SandboxKeys, of two `enabled` keys, sends the later one. SandboxKeys leaves out a known key that holds nil before it assigns it, so a later nil does not replace an earlier value: { enabled: true, 'enabled' => nil } sends true, and two nils send neither. The later key wins only among the ones that are not nil (true then false sends false, while the warning predicate still says true). The comment now says that. From PR review round 2 (blocking 1). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01KzJcBPQ8Vq4kjkxdm2VnYm --- lib/claude_agent_sdk/subprocess_cli_transport.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/claude_agent_sdk/subprocess_cli_transport.rb b/lib/claude_agent_sdk/subprocess_cli_transport.rb index c6f20610..0028923f 100644 --- a/lib/claude_agent_sdk/subprocess_cli_transport.rb +++ b/lib/claude_agent_sdk/subprocess_cli_transport.rb @@ -1298,7 +1298,9 @@ def warn_if_sandbox_unavailable(line) # 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) and, of two `enabled` keys, sends the later one. + # (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? OptionForms.sandbox_requested?(@options.sandbox) end