diff --git a/CHANGELOG.md b/CHANGELOG.md index cae88f91..a0d50915 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- Internal: `ClaudeAgentSDK.query` and `Client` now share one implementation of session setup, prompt writes, message delivery and teardown (`SessionAssembly`, `@api private`) instead of each carrying its own copy, and a session's observers, `callback_scheduling` and `callback_wrapper` are held in one object (`Dispatch`, `@api private`) instead of being passed at every call site; `Client` still captures scheduling and wrapper when it is constructed and resolves its observers on each `connect`. Public signatures and the documented differences between the two entry points (what observers are told when a session cannot be opened, the `session_id` on the wire, when stdin is closed, which error wins when a close raises) are preserved, and are now pinned by specs that run both side by side. One cleanup difference: when a `Client` is reconnected while its previous `disconnect` is still removing the materialized directory of a store-backed resume, that `disconnect`, once it finishes, no longer clears the new session, so the new session's resume directory is removed by its own `disconnect` (it used to be left behind). ### Internal - The decision of when `query()` may close stdin moved out of `Query` into one private class, `Query::RunLifecycle` (`lib/claude_agent_sdk/query/run_lifecycle.rb`): the per-run end, the task ledger, the session state and the ceiling timer, with the orderings between them. `Query` hands it five events, including frames, and one wait, and supplies the timer. No behavior change; nothing public moved. Its specs now go through that interface (`spec/unit/query/run_lifecycle_spec.rb`) instead of reaching into `Query`'s instance variables. - Hash-form option interpretation moved into one private module (`OptionForms`); no behaviour change. diff --git a/CLAUDE.md b/CLAUDE.md index 318857a5..e8ec05d2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,6 +45,8 @@ User code - **`Client`** — Full-featured bidirectional sessions. Accepts optional `transport_class` (defaults to `SubprocessCLITransport`) and `transport_args` for custom transports. Creates transport, instantiates a `Query` handler that runs `read_messages` in an async task, routes control messages internally, and exposes SDK messages via `Async::Queue`. Supports hooks, permission callbacks, SDK MCP servers, interrupt, model switching, and file rewind. +Both are lifecycle adapters over the `@api private` `SessionAssembly` (`lib/claude_agent_sdk/session_assembly.rb`; `Dispatch`, in the same file, holds a session's observers / `callback_scheduling` / `callback_wrapper` so no call site passes them by hand — `Client` keeps one for its lifetime and puts the observers it resolves in on each connect, so each notification reads the current ones): the one shared implementation of acquisition (materialize a store-backed resume → repoint the options → construct and connect the transport → the only `Query.new` → transcript mirror → `start` → handshake), prompt writes, delivery (the only receive loop, `#deliver`) and disposal (`#close_resources`), which never notifies `on_error` or `on_close` (it does dispatch `on_message`, in `#deliver`) and rescues nothing — when those two fire, which error wins and whether stdin closes stay in the two adapters, and so does `Client`'s state across a teardown (`#close_resources` yields once both closes are behind and before the temp dir is removed, where `Client` marks itself disconnected; a session whose teardown raised is kept until a disconnect completes; `spec/unit/client_teardown_interleavings_spec.rb`) (`spec/unit/entry_point_lifecycle_spec.rb` pins those differences; `spec/unit/entry_point_parity_spec.rb` runs a set of scenarios through both and pins them as identical). + ### Run Lifecycle (when stdin may close) `Query::RunLifecycle` (`lib/claude_agent_sdk/query/run_lifecycle.rb`, `@api private`) owns the one decision *when may stdin close?*: the CLI's `session_state_changed` `idle` after a result (the transport sets `CLAUDE_CODE_SDK_READS_SESSION_STATE=1`, and `Query` drops the `sdk_host_only` frames that brings), bounded between turns by `CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS` (10 min; a main-thread turn, `requires_action` or a tracked agent stops the clock); from a CLI that sends no state, the first result with no tracked task in flight (the #1088 ledger). @@ -118,7 +120,7 @@ The public functions in `lib/claude_agent_sdk.rb` (`list_sessions`, `rename_sess ### FiberBoundary (fiber safety) -`async` installs a Fiber scheduler, but most Ruby libraries (pg, mysql2, ActiveRecord pools) key state on `Thread.current` and are thread-safe, not fiber-safe. `FiberBoundary.invoke` (`lib/claude_agent_sdk/fiber_boundary.rb`) hops every user-supplied callback (tool handlers, hooks, permission callbacks, message blocks, observers) to a plain thread before invoking it. Control-request callbacks (hooks, `can_use_tool`, SDK MCP tool/resource/prompt handlers) go through `FiberBoundary.invoke_callback`: a `SystemExit` / `SignalException` raised by the callback is carried out of the hop, `Query#handle_control_request` writes the normal error response, then re-raises the original — the CLI always gets its answer and process termination is never swallowed (#119). Consequence: the thread hop severs `break`/`return`/`next` from the surrounding method — SDK loops yielding user callbacks must keep loop control outside the invoked block (see `Client#receive_response`; user `break` is bridged via `.invoke_iteration`). +`async` installs a Fiber scheduler, but most Ruby libraries (pg, mysql2, ActiveRecord pools) key state on `Thread.current` and are thread-safe, not fiber-safe. `FiberBoundary.invoke` (`lib/claude_agent_sdk/fiber_boundary.rb`) hops every user-supplied callback (tool handlers, hooks, permission callbacks, message blocks, observers) to a plain thread before invoking it. Control-request callbacks (hooks, `can_use_tool`, SDK MCP tool/resource/prompt handlers) go through `FiberBoundary.invoke_callback`: a `SystemExit` / `SignalException` raised by the callback is carried out of the hop, `Query#handle_control_request` writes the normal error response, then re-raises the original — the CLI always gets its answer and process termination is never swallowed (#119). Consequence: the thread hop severs `break`/`return`/`next` from the surrounding method — SDK loops yielding user callbacks must keep loop control outside the invoked block (see `SessionAssembly#deliver`, the receive loop behind `query()` and `Client#receive_messages` / `#receive_response`; user `break` is bridged via `.invoke_iteration`). Opt-in `ClaudeAgentOptions#callback_scheduling: :inline` (issue #47) skips the hop and runs callbacks in place on the reactor fiber — for hosts that are fiber-isolated end to end (solid_queue fiber workers with `IsolatedExecutionState.isolation_level = :fiber`). The mode is plumbed per-call (Query kwarg; the SDK-MCP dispatch path carries it via fiber storage — `Fiber[FiberBoundary::DISPATCH_KEY]` — so a server shared by concurrent sessions is never mutated), never via a thread-local. Timeout-bounded store-adapter calls hop by default regardless of session mode — unless the ADAPTER declares itself fiber-native via an optional `callback_scheduling` method returning `:inline` (probed by `SessionStores.store_callback_scheduling` at batcher construction / resume-materialization entry; issue #47 phase 3), in which case they run in place under a cooperative timeout that still surfaces as `JoinTimeout`. Inline hook timeouts are cooperative (`with_timeout`). Public `ClaudeAgentSDK.offload { }` hops one heavy block manually. `ClaudeAgentOptions#callback_wrapper` (issue #47 phase 2) is optional middleware composed around every user-callback dispatch BEFORE the hop, so it runs on the callback's own execution context (worker thread in `:thread`, reactor fiber in `:inline`) — in Rails, `ClaudeAgentSDK::Railtie.callback_wrapper` (see Rails Integration; a bare `executor.wrap` lambda deadlocks under dev code reloading); it travels the SDK-MCP dispatch path inside the same fiber-storage scope as the mode, and also composes around timeout-bounded store-adapter dispatch (batcher appends, resume-materialization calls) inside the bound. `SdkMcpServer#callback_scheduling=` / `#callback_wrapper=` validate like their `ClaudeAgentOptions` counterparts (String coerced, invalid values raise). diff --git a/lib/claude_agent_sdk.rb b/lib/claude_agent_sdk.rb index f5d12fec..bdac0626 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/session_assembly' require_relative 'claude_agent_sdk/option_forms' require_relative 'claude_agent_sdk/option_warnings' require_relative 'claude_agent_sdk/deprecation' @@ -689,19 +690,30 @@ def self.query(prompt:, options: nil, transport: nil, &block) # rubocop:disable # Fail fast on invalid session_store combinations before spawning the CLI. SessionStores.validate_session_store_options(configured_options) - # Resolve callable observers into fresh instances (thread-safe for global defaults) - resolved_observers = ClaudeAgentSDK.resolve_observers(configured_options.observers) - - # Where user callbacks run (see ClaudeAgentOptions#callback_scheduling) - # and the middleware wrapped around them (#callback_wrapper). - callback_scheduling = configured_options.callback_scheduling || :thread - callback_wrapper = configured_options.callback_wrapper - ClaudeAgentSDK.check_inline_isolation(callback_scheduling) + # Resolve callable observers into fresh instances (thread-safe for global + # defaults), bound to where user callbacks run (see + # ClaudeAgentOptions#callback_scheduling) and the middleware wrapped + # around them (#callback_wrapper). + dispatch = Dispatch.new(ClaudeAgentSDK.resolve_observers(configured_options.observers), + scheduling: configured_options.callback_scheduling || :thread, + wrapper: configured_options.callback_wrapper) + ClaudeAgentSDK.check_inline_isolation(dispatch.scheduling) if transport && !transport.respond_to?(:connect) raise ArgumentError, 'transport must respond to #connect (see ClaudeAgentSDK::Transport)' end + # Acquires nothing yet. An injected transport is used as it is: it was + # built before a store-backed resume could be materialized for it, so + # none is (Python parity: client.py skips materialization when a + # transport is supplied). Without one the session constructs the + # subprocess transport itself, after materialization. + session = SessionAssembly.new( + configured_options, + dispatch: dispatch, + transport_source: transport ? { instance: transport } : { class: SubprocessCLITransport, args: {} } + ) + # finished: false tells Async that a waiter handles this task's failure # (the .wait at the end re-raises it), as Kernel#Sync does for its own # task. Without it a task that fails before anyone waits for it — always @@ -709,151 +721,54 @@ def self.query(prompt:, options: nil, transport: nil, &block) # rubocop:disable # returns — is also logged as "Task may have ended with unhandled # exception", message and backtrace included, although the caller gets # the same error raised and may well rescue it. - Async(finished: false, &FiberBoundary.capture_otel_context do # rubocop:disable Metrics/BlockLength -- the reactor task body of query() - materialized = nil - query_handler = nil - begin - if transport.nil? - # Resume-from-store: when a session_store is set and resume/continue - # is requested, load the session into a temp CLAUDE_CONFIG_DIR and - # repoint options at it (env + --resume) BEFORE spawning. Returns - # options unchanged when no materialization applies. Skipped - # entirely for an injected transport — the materialized - # env/--resume only apply to the CLI subprocess (Python parity: - # client.py skips materialization when a transport is supplied). - materialized = SessionResume.materialize_resume_session(configured_options) - if materialized - configured_options = SessionResume.apply_materialized_options(configured_options, materialized) - end - - # Always use streaming mode with control protocol (matches Python - # SDK). This sends agents via initialize request instead of CLI - # args, avoiding OS ARG_MAX limits. - transport = SubprocessCLITransport.new(configured_options) - end - # Deliberate deviation from Python: the ensure below also closes an - # injected transport whose #connect raised (Python leaves it - # unclosed); Transport#close must be idempotent. - transport.connect - - # Extract SDK MCP servers - sdk_mcp_servers = extract_sdk_mcp_servers(configured_options.mcp_servers) - - hooks = convert_hooks_to_internal_format(configured_options.hooks) - - # Create Query handler for control protocol - query_handler = Query.new( - transport: transport, - is_streaming_mode: true, - can_use_tool: configured_options.can_use_tool, - hooks: hooks, - agents: configured_options.agents, - sdk_mcp_servers: sdk_mcp_servers, - exclude_dynamic_sections: ClaudeAgentSDK.extract_exclude_dynamic_sections(configured_options.system_prompt), - system_prompt_snapshot: ClaudeAgentSDK.extract_system_prompt_snapshot(configured_options.system_prompt), - skills: configured_options.skills, - forward_subagent_text: configured_options.forward_subagent_text?, - agent_progress_summaries: configured_options.agent_progress_summaries, - callback_scheduling: callback_scheduling, - callback_wrapper: callback_wrapper, - verbatim_prompts: configured_options.verbatim_prompts?, - run_end_ceiling_ms: Query.run_end_ceiling_ms(configured_options.env) - ) - - # Mirror transcripts to the session_store, if configured. Installed - # before #start so the read loop captures transcript_mirror frames. - if configured_options.session_store - query_handler.set_transcript_mirror_batcher( - SessionResume.build_mirror_batcher( - store: configured_options.session_store, - env: configured_options.env, - on_error: ->(key, message) { query_handler.report_mirror_error(key, message) }, - eager: configured_options.session_store_flush.to_s == 'eager', - callback_wrapper: callback_wrapper - ) - ) - end - - # Start reading messages in background - query_handler.start - - # Initialize the control protocol (sends agents) - query_handler.initialize_protocol - - # Send prompt(s) as user messages, then close stdin - if prompt.is_a?(String) - ClaudeAgentSDK.notify_observers(resolved_observers, :on_user_prompt, prompt, - scheduling: callback_scheduling, wrapper: callback_wrapper) - message = { - type: 'user', - message: { role: 'user', content: prompt }, - parent_tool_use_id: nil, - session_id: '' - } - transport.write("#{Query.serialize_user_message(message, configured_options.verbatim_prompts?)}\n") - # Background-spawn so messages stream to the user block while stdin - # close waits (without timeout) for the first result; a synchronous - # call would defer all delivery until the turn completes (mirrors - # Python's query.spawn_task(query.wait_for_result_and_end_input())). - query_handler.spawn_task { query_handler.wait_for_result_and_end_input } - elsif prompt.is_a?(Enumerator) || prompt.respond_to?(:each) - # Tracked on the Query so close() stops it; an untracked Async task - # here kept the root reactor alive forever when the read loop died - # while the user enumerator was still blocked (matches Python's - # query.spawn_task(query.stream_input(prompt))). - observed_prompt = ClaudeAgentSDK.observing_prompt_stream( - prompt, resolved_observers, scheduling: callback_scheduling, wrapper: callback_wrapper - ) - query_handler.spawn_task { query_handler.stream_input(observed_prompt) } - end - - # Read and yield messages from the query handler (filters out control messages). - # User block is invoked through FiberBoundary so ActiveRecord / PG calls - # inside it don't see the async gem's Fiber scheduler (default :thread - # mode; :inline runs it in place on the reactor fiber). - query_handler.receive_messages do |data| - message = MessageParser.parse(data) - next unless message - - ClaudeAgentSDK.notify_observers(resolved_observers, :on_message, message, - scheduling: callback_scheduling, wrapper: callback_wrapper) - signal = FiberBoundary.invoke_iteration(block, message, scheduling: callback_scheduling, - wrapper: callback_wrapper) - break signal.value if signal.is_a?(FiberBoundary::Break) - end - rescue StandardError => e - # One notify point for every error surfacing from query() — transport - # connect, initialize, stream errors re-raised from the message queue, - # parse errors, and user-block errors. StandardError only: Async::Stop - # is cancellation, not an error. Bare raise preserves the backtrace; - # the ensure below still fires on_close after on_error. - ClaudeAgentSDK.notify_observers(resolved_observers, :on_error, e, - scheduling: callback_scheduling, wrapper: callback_wrapper) - raise - ensure - ClaudeAgentSDK.notify_observers(resolved_observers, :on_close, - scheduling: callback_scheduling, wrapper: callback_wrapper) - # query_handler.close stops the background read task and closes the - # transport (flushing the mirror batcher first). Fall back to a bare - # transport close when the handler was never built. - begin - if query_handler - query_handler.close - elsif transport - transport.close - end - ensure - # Remove the materialized resume temp dir (which holds a redacted - # .credentials.json copy) AFTER the subprocess has exited, even when - # close itself raises — unless the mirror dropped batches: the store - # copy is then incomplete and the temp dir holds the only copy of - # the dropped turns, so it is preserved (scrubbed of credentials) - # with a warning instead of deleted. - if materialized - query_handler&.mirror_batches_dropped? ? materialized.preserve_transcripts : materialized.cleanup - end - end + Async(finished: false, &FiberBoundary.capture_otel_context do + # Resume materialization, the transport's connect and the handshake + # all happen in here: inside the notified region. + session.connect + + # Send prompt(s) as user messages, then close stdin + if prompt.is_a?(String) + dispatch.notify(:on_user_prompt, prompt) + # verbatim_prompts is read now, after the notification, from the + # options the session runs on. + session.write_prompt(prompt, session_id: '', verbatim: :current) + # Background-spawn so messages stream to the user block while stdin + # close waits (without timeout) for the first result; a synchronous + # call would defer all delivery until the turn completes (mirrors + # Python's query.spawn_task(query.wait_for_result_and_end_input())). + query_handler = session.query_handler + query_handler.spawn_task { query_handler.wait_for_result_and_end_input } + elsif prompt.is_a?(Enumerator) || prompt.respond_to?(:each) + # Tracked on the Query so close() stops it; an untracked Async task + # here kept the root reactor alive forever when the read loop died + # while the user enumerator was still blocked (matches Python's + # query.spawn_task(query.stream_input(prompt))). + session.stream_prompt_in_background(prompt) end + + # Read and yield messages from the query handler (filters out control messages). + # User block is invoked through FiberBoundary so ActiveRecord / PG calls + # inside it don't see the async gem's Fiber scheduler (default :thread + # mode; :inline runs it in place on the reactor fiber). + session.deliver(block) + rescue StandardError => e + # One notify point for every error surfacing from query() — transport + # connect, initialize, stream errors re-raised from the message queue, + # parse errors, and user-block errors. StandardError only: Async::Stop + # is cancellation, not an error. Bare raise preserves the backtrace; + # the ensure below still fires on_close after on_error. + dispatch.notify(:on_error, e) + raise + ensure + dispatch.notify(:on_close) + # Closing the query handler stops the background read task and closes + # the transport (flushing the mirror batcher first); a transport + # without a handler — its #connect raised, or the handshake never got + # that far — is closed on its own. That includes an injected one: a + # deliberate deviation from Python, which leaves it unclosed + # (Transport#close must be idempotent). The materialized resume temp + # dir is dealt with after that, even when the close raised. + session.close_resources(always_close_transport: false) end).wait end @@ -935,11 +850,13 @@ def self.ask(prompt, options: nil, transport: nil, &block) # } # ) # client = ClaudeAgentSDK::Client.new(options: options) - class Client # rubocop:disable Metrics/ClassLength -- public session API: lifecycle, control methods and their Ruby aliases + class Client # The session's control-protocol handler (nil until #connect). # # @api private - attr_reader :query_handler + def query_handler + @session&.query_handler + end # @param options [ClaudeAgentOptions, nil] Configuration options # @param transport_class [Class] Transport class to use (must implement Transport interface). @@ -947,14 +864,17 @@ class Client # rubocop:disable Metrics/ClassLength -- public session API: lifecy # @param transport_args [Hash] Additional keyword arguments passed to transport_class.new(options, **transport_args) def initialize(options: nil, transport_class: SubprocessCLITransport, transport_args: {}) @options = options || ClaudeAgentOptions.new - @callback_scheduling = @options.callback_scheduling || :thread - @callback_wrapper = @options.callback_wrapper + # One for the life of the client. Scheduling and wrapper are captured + # here; the observers are resolved, and put in, on each #connect. + @dispatch = Dispatch.new([], scheduling: @options.callback_scheduling || :thread, + wrapper: @options.callback_wrapper) @transport_class = transport_class @transport_args = transport_args - @transport = nil - @query_handler = nil + # Owns the transport, the query handler and the materialized resume dir + # of the current connection; nil while there is none. Kept after a + # disconnect that raised, until one completes (see #disconnect). + @session = nil @connected = false - @materialized = nil end # Block-scoped Client lifecycle, mirroring Python's @@ -1023,22 +943,29 @@ def connect(prompt = nil) # Resolve observers before the first failable runtime step so # connect-phase failures (including resume materialization) can be # notified via on_error. - @resolved_observers = ClaudeAgentSDK.resolve_observers(@options.observers) + @dispatch.observers = ClaudeAgentSDK.resolve_observers(@options.observers) - ClaudeAgentSDK.check_inline_isolation(@callback_scheduling) + ClaudeAgentSDK.check_inline_isolation(@dispatch.scheduling) + + # Acquires nothing yet. Assigned before the begin, so the disconnect in + # the rescue below finds whatever the session went on to acquire. The + # transport is constructed by the session, after materialization. + @session = SessionAssembly.new(configured_options, dispatch: @dispatch, + transport_source: { class: @transport_class, + args: @transport_args }) # If anything from materialization onward fails, tear down (closes the # subprocess and removes the materialized temp config dir) before # surfacing the error, so a partial connect never leaks a temp dir # holding a credential copy. begin - # Resume-from-store: materialize the session from the store into a - # temp CLAUDE_CONFIG_DIR BEFORE spawn, then repoint options at it. - # Inside the instrumented begin so store IO failures fire on_error - # (matching the one-shot query() path) and disconnect cleans up. - configured_options = materialize_resume(configured_options) + # Resume materialization (store IO) happens in here too: inside the + # instrumented begin, so its failures fire on_error (matching the + # one-shot query() path) and disconnect cleans up. + @session.connect + @connected = true - connect_inner(configured_options, prompt) + send_initial_prompt(prompt) rescue Exception => e # rubocop:disable Lint/RescueException # Pre-handshake failures (@connected still false) are notified here; # post-handshake String-prompt send failures were already notified by @@ -1047,14 +974,14 @@ def connect(prompt = nil) # out of connect.) No on_close follows for pre-handshake failures # (disconnect gates it on @connected): the session never opened. begin - notify_error(e) if e.is_a?(StandardError) && !@connected + @dispatch.notify(:on_error, e) if e.is_a?(StandardError) && !@connected ensure # Tear down the partial connect, but never let a cleanup failure (e.g. a # custom transport whose #close raises) mask the original connect error. # Rescue Exception (not StandardError) so reactor cancellation - # (Async::Stop < Exception) after materialize_resume set @materialized - # still runs disconnect -> @materialized.cleanup, never leaking the temp - # CLAUDE_CONFIG_DIR that holds the redacted .credentials.json copy. + # (Async::Stop < Exception) after the resume was materialized still + # runs disconnect, which removes the temp CLAUDE_CONFIG_DIR holding + # the redacted .credentials.json copy. # In an ensure: an exception raised into this fiber while it waits # for the on_error observer (a caller's deadline) must not skip it. begin @@ -1086,15 +1013,8 @@ def query(prompt, session_id: 'default') begin if prompt.is_a?(String) - ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, prompt, - scheduling: @callback_scheduling, wrapper: @callback_wrapper) - message = { - type: 'user', - message: { role: 'user', content: prompt }, - parent_tool_use_id: nil, - session_id: session_id - } - writeln(Query.serialize_user_message(message, @verbatim_prompts)) + @dispatch.notify(:on_user_prompt, prompt) + @session.write_prompt(prompt, session_id: session_id) elsif prompt.respond_to?(:each) # Inline iteration on the caller, Python client.py parity — NOT # Query#stream_input, whose ensure always ends input after @@ -1106,7 +1026,7 @@ def query(prompt, session_id: 'default') raise ArgumentError, "prompt must be a String or respond to #each (got #{prompt.class})" end rescue StandardError => e - notify_error(e) + @dispatch.notify(:on_error, e) raise end end @@ -1124,18 +1044,9 @@ def receive_messages(&block) raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected begin - @query_handler.receive_messages do |data| - message = MessageParser.parse(data) - next unless message - - ClaudeAgentSDK.notify_observers(@resolved_observers, :on_message, message, - scheduling: @callback_scheduling, wrapper: @callback_wrapper) - signal = FiberBoundary.invoke_iteration(block, message, scheduling: @callback_scheduling, - wrapper: @callback_wrapper) - break signal.value if signal.is_a?(FiberBoundary::Break) - end + @session.deliver(block) rescue StandardError => e - notify_error(e) + @dispatch.notify(:on_error, e) raise end end @@ -1147,24 +1058,10 @@ def receive_response(&block) raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected - # Keep loop control on the same fiber as the underlying dequeue: both - # the SDK's ResultMessage break and the user's translated break happen - # here, never inside the FiberBoundary hop (break in a proc on a - # foreign thread raises LocalJumpError). begin - @query_handler.receive_messages do |data| - message = MessageParser.parse(data) - next unless message - - ClaudeAgentSDK.notify_observers(@resolved_observers, :on_message, message, - scheduling: @callback_scheduling, wrapper: @callback_wrapper) - signal = FiberBoundary.invoke_iteration(block, message, scheduling: @callback_scheduling, - wrapper: @callback_wrapper) - break signal.value if signal.is_a?(FiberBoundary::Break) - break if message.is_a?(ResultMessage) - end + @session.deliver(block, until_result: true) rescue StandardError => e - notify_error(e) + @dispatch.notify(:on_error, e) raise end end @@ -1173,7 +1070,7 @@ def receive_response(&block) def interrupt raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected - @query_handler.interrupt + query_handler.interrupt end # Change permission mode during conversation @@ -1181,7 +1078,7 @@ def interrupt def set_permission_mode(mode) raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected - @query_handler.set_permission_mode(mode) + query_handler.set_permission_mode(mode) end # Ruby-style spelling of #set_permission_mode: `client.permission_mode = 'plan'`. @@ -1195,7 +1092,7 @@ def permission_mode=(mode) def set_model(model) raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected - @query_handler.set_model(model) + query_handler.set_model(model) end # Ruby-style spelling of #set_model: `client.model = 'claude-opus-5'`. @@ -1208,7 +1105,7 @@ def model=(model) def reconnect_mcp_server(server_name) raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected - @query_handler.reconnect_mcp_server(server_name) + query_handler.reconnect_mcp_server(server_name) end # Enable or disable an MCP server @@ -1217,7 +1114,7 @@ def reconnect_mcp_server(server_name) def toggle_mcp_server(server_name, enabled) raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected - @query_handler.toggle_mcp_server(server_name, enabled) + query_handler.toggle_mcp_server(server_name, enabled) end # Stop a running background task @@ -1225,7 +1122,7 @@ def toggle_mcp_server(server_name, enabled) def stop_task(task_id) raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected - @query_handler.stop_task(task_id) + query_handler.stop_task(task_id) end # Background in-flight foreground tasks (Bash commands and subagents) — the @@ -1251,7 +1148,7 @@ def stop_task(task_id) def background_tasks(tool_use_id: nil) raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected - @query_handler.background_tasks(tool_use_id: tool_use_id) + query_handler.background_tasks(tool_use_id: tool_use_id) end # Rewind files to a previous checkpoint (v0.1.15+) @@ -1261,13 +1158,13 @@ def background_tasks(tool_use_id: nil) def rewind_files(user_message_uuid) raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected - @query_handler.rewind_files(user_message_uuid) + query_handler.rewind_files(user_message_uuid) end # Get server initialization info # @return [Hash, nil] Server info or nil def server_info - @query_handler&.initialization_result + query_handler&.initialization_result end # Get a breakdown of current context window usage by category. @@ -1277,7 +1174,7 @@ def server_info def get_context_usage raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected - @query_handler.get_context_usage + query_handler.get_context_usage end # Ruby-style spelling of #get_context_usage. @@ -1291,7 +1188,7 @@ def context_usage def get_mcp_status raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected - @query_handler.get_mcp_status + query_handler.get_mcp_status end # Ruby-style spelling of #get_mcp_status. @@ -1328,125 +1225,48 @@ def get_server_info # teardown, then lets the interruption propagate. def disconnect # Tear down whatever exists — robust to a partial/failed connect, where - # @connected is still false but a transport and/or materialized temp dir - # were already created. #close on the query handler also closes the - # transport (flushing the mirror batcher first); the extra @transport - # close covers a failure before the query handler was built (idempotent). + # @connected is still false but the session already holds a transport + # and/or a materialized temp dir. # - # The nested ensures guarantee that even a raising close (e.g. a custom - # transport whose #close raises) still runs the transport close, resets - # state, and removes the materialized temp dir (which holds a redacted - # .credentials.json copy) — so disconnect can never leave the client - # half-open or leak the temp dir. The original error still propagates. - # Keep a handle on the query handler past the nil-out below: whether the - # mirror dropped batches is only final AFTER #close ran its last flush, - # and the materialized-dir decision at the bottom needs to ask it. - query_handler = @query_handler - begin - # Notified inside the begin: an exception raised into this fiber while - # it waits for an observer (a caller's deadline) propagates, and must - # not skip the teardown below. - if @connected - ClaudeAgentSDK.notify_observers(@resolved_observers || [], :on_close, - scheduling: @callback_scheduling, wrapper: @callback_wrapper) - end - ensure - begin - @query_handler&.close - ensure - @query_handler = nil - begin - @transport&.close - ensure - @transport = nil - @connected = false - # Remove the materialized resume temp dir AFTER the subprocess - # exited — unless the mirror dropped batches: the store copy is then - # incomplete and the temp dir holds the only copy of the dropped - # turns, so it is preserved (scrubbed of credentials) with a warning - # instead of deleted. - if @materialized - if query_handler&.mirror_batches_dropped? - @materialized.preserve_transcripts - else - @materialized.cleanup - end - @materialized = nil - end - end - end + # Notified inside the method body, with the teardown in its ensure: an + # exception raised into this fiber while it waits for an observer (a + # caller's deadline) propagates, and must not skip the teardown. + @dispatch.notify(:on_close) if @connected + ensure + # SessionAssembly#close_resources closes the query handler (which + # closes the transport, flushing the mirror batcher first), closes the + # transport in an ensure of its own (it covers a failure before the + # handler was built, and a handler whose #close raised) and decides + # what happens to the materialized temp dir, which holds a redacted + # .credentials.json copy. Whatever one of those raises, the others + # still run — so disconnect can never leave the client half-open. The + # original error still propagates. + # + # The client is disconnected as soon as both closes are behind, before + # the temp dir is dealt with: its removal can take a while and lets + # other tasks run, and what they call meanwhile has to be refused as + # "Not connected" (and a disconnect of theirs must not notify on_close + # again) rather than reach a session whose resources are gone. + # + # The session is let go of only once all of it was disposed of. When + # the teardown raises — a close failed, or the removal of the temp dir + # was cut short by the caller's deadline or a stop — the session stays, + # holding whatever it could not dispose of, so that the next disconnect + # finishes the job (what was already closed is not closed again). It + # is not the client's session any more once a reconnect replaced it. + session = @session + if session + session.close_resources(always_close_transport: true) { @connected = false } + @session = nil if @session.equal?(session) + else + @connected = false end end private - # Resume-from-store: when a session_store is set (and a subprocess transport - # is in use), materialize the session into a temp CLAUDE_CONFIG_DIR and - # return options repointed at it (env + --resume). Returns the options - # unchanged when no materialization applies. Skipped for non-subprocess - # transports — the materialized env/--resume only affect the CLI subprocess. - # Ancestry (<=), not identity: a SubprocessCLITransport subclass spawns the - # CLI with the same env/--resume semantics, and the transport is constructed - # AFTER materialization, so the repointed options do reach it. - def materialize_resume(options) - subprocess_transport = @transport_class.is_a?(Class) && @transport_class <= SubprocessCLITransport - return options unless options.session_store && subprocess_transport - - @materialized = SessionResume.materialize_resume_session(options) - @materialized ? SessionResume.apply_materialized_options(options, @materialized) : options - end - - # The connect body, wrapped by #connect so a failure triggers cleanup. - def connect_inner(configured_options, prompt) # rubocop:disable Metrics/MethodLength -- connect sequence kept in order; #connect wraps it for cleanup - # Client always uses streaming mode; keep stdin open for bidirectional - # communication. Observers were already resolved by #connect. - @transport = @transport_class.new(configured_options, **@transport_args) - @transport.connect - - # Extract SDK MCP servers - sdk_mcp_servers = ClaudeAgentSDK.extract_sdk_mcp_servers(configured_options.mcp_servers) - - # Convert hooks to internal format - hooks = ClaudeAgentSDK.convert_hooks_to_internal_format(configured_options.hooks) - - # Extract exclude_dynamic_sections and snapshot from the system prompt - # for the initialize request (older CLIs ignore unknown initialize fields) - exclude_dynamic_sections = ClaudeAgentSDK.extract_exclude_dynamic_sections(configured_options.system_prompt) - system_prompt_snapshot = ClaudeAgentSDK.extract_system_prompt_snapshot(configured_options.system_prompt) - - # Captured once, so String and streamed prompts in one session are - # stamped alike (the Query stamps the streamed ones with this value). - @verbatim_prompts = configured_options.verbatim_prompts? - - # Create Query handler - @query_handler = Query.new( - transport: @transport, - is_streaming_mode: true, - can_use_tool: configured_options.can_use_tool, - hooks: hooks, - sdk_mcp_servers: sdk_mcp_servers, - agents: configured_options.agents, - exclude_dynamic_sections: exclude_dynamic_sections, - system_prompt_snapshot: system_prompt_snapshot, - skills: configured_options.skills, - forward_subagent_text: configured_options.forward_subagent_text?, - agent_progress_summaries: configured_options.agent_progress_summaries, - callback_scheduling: @callback_scheduling, - callback_wrapper: @callback_wrapper, - verbatim_prompts: @verbatim_prompts, - run_end_ceiling_ms: Query.run_end_ceiling_ms(configured_options.env) - ) - - # Mirror transcripts to the session_store, if configured. - install_transcript_mirror(configured_options) - - # Start query handler and initialize - @query_handler.start - @query_handler.initialize_protocol - - @connected = true - - # Optionally send initial prompt/messages after connection is ready. + # Optionally send the initial prompt/messages once the connection is ready. + def send_initial_prompt(prompt) case prompt when nil nil @@ -1465,9 +1285,7 @@ def connect_inner(configured_options, prompt) # rubocop:disable Metrics/MethodLe # Observer#on_error contract; notifying a swallowed error would mark # a still-live OTel trace as failed). Same behavior as query()'s # streaming path. - observed = ClaudeAgentSDK.observing_prompt_stream(prompt, @resolved_observers, - scheduling: @callback_scheduling, wrapper: @callback_wrapper) - @query_handler.spawn_task { @query_handler.stream_input(observed) } + @session.stream_prompt_in_background(prompt) end end @@ -1479,22 +1297,22 @@ def connect_inner(configured_options, prompt) # rubocop:disable Metrics/MethodLe # parse-stamp-regenerate, which would block the reactor on huge frames), # except that verbatim_prompts must mark them `client_composed`, so with # that option on they are parsed and re-serialized (Query.stamp_user_message). + # Every message is stamped with the value captured at connect, like the + # String prompts of the same session. def stream_query_messages(prompt, session_id) prompt.each do |msg| case msg when Hash msg = msg.merge(session_id: session_id) unless msg.key?(:session_id) || msg.key?('session_id') if (text = ClaudeAgentSDK.extract_user_prompt_text(msg)) - ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, text, - scheduling: @callback_scheduling, wrapper: @callback_wrapper) + @dispatch.notify(:on_user_prompt, text) end - writeln(Query.serialize_user_message(msg, @verbatim_prompts)) + @session.write_message(msg) when String if (text = ClaudeAgentSDK.extract_user_prompt_text(msg)) - ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, text, - scheduling: @callback_scheduling, wrapper: @callback_wrapper) + @dispatch.notify(:on_user_prompt, text) end - writeln(Query.serialize_user_message(msg, @verbatim_prompts)) + @session.write_message(msg) else # No to_s fallback — silently serializing arbitrary objects is the # exact inspect-garbage bug class this method exists to prevent. @@ -1502,36 +1320,5 @@ def stream_query_messages(prompt, session_id) end end end - - # Notify observers of an error surfacing to the consumer. `|| []` keeps a - # mis-scoped call before connect harmless instead of NoMethodError on nil. - def notify_error(error) - ClaudeAgentSDK.notify_observers(@resolved_observers || [], :on_error, error, - scheduling: @callback_scheduling, wrapper: @callback_wrapper) - end - - # Build and install the transcript-mirror batcher on the query handler when - # a session_store is configured, via the shared SessionResume helper (also - # used by the one-shot query() path). - def install_transcript_mirror(options) - return unless options.session_store - - batcher = SessionResume.build_mirror_batcher( - store: options.session_store, - env: options.env, - on_error: ->(key, message) { @query_handler.report_mirror_error(key, message) }, - eager: options.session_store_flush.to_s == 'eager', - callback_wrapper: @callback_wrapper - ) - @query_handler.set_transcript_mirror_batcher(batcher) - end - - def writeln(string) - write string.end_with?("\n") ? string : "#{string}\n" - end - - def write(string) - @transport.write(string) - end end end diff --git a/lib/claude_agent_sdk/session_assembly.rb b/lib/claude_agent_sdk/session_assembly.rb new file mode 100644 index 00000000..3885b1fb --- /dev/null +++ b/lib/claude_agent_sdk/session_assembly.rb @@ -0,0 +1,311 @@ +# frozen_string_literal: true + +require_relative 'fiber_boundary' +require_relative 'message_parser' +require_relative 'query' +require_relative 'session_resume' +require_relative 'subprocess_cli_transport' + +module ClaudeAgentSDK + # The three things every dispatch of a session's user code needs — its + # resolved observers, where callbacks run (callback_scheduling) and the + # middleware around them (callback_wrapper) — held in one place, so that + # no call site threads them by hand. A context holder, nothing deeper: each + # method is the module function it names, called with the triple. + # + # Scheduling and wrapper are fixed for the life of the object. The + # observers can be replaced, and each call below reads the ones there are + # at that moment: query() never replaces them; Client keeps one Dispatch + # for its lifetime and puts in the observers it resolves on each connect, + # so a receive loop still running from before a reconnect notifies the + # current ones. + # + # @api private + class Dispatch + # For what takes the pair as its own arguments (Query, the transcript + # mirror batcher). + attr_reader :scheduling, :wrapper + + # Replace the observers, with a new Array: the one being replaced may be + # in the middle of a notification, and is never changed in place. + attr_writer :observers + + def initialize(observers, scheduling:, wrapper:) + @observers = observers + @scheduling = scheduling + @wrapper = wrapper + end + + # See ClaudeAgentSDK.notify_observers. + def notify(method, *) + ClaudeAgentSDK.notify_observers(@observers, method, *, scheduling: @scheduling, wrapper: @wrapper) + end + + # See ClaudeAgentSDK.observing_prompt_stream. + def observing_stream(prompt) + ClaudeAgentSDK.observing_prompt_stream(prompt, @observers, scheduling: @scheduling, wrapper: @wrapper) + end + + # See FiberBoundary.invoke_iteration. + def invoke_iteration(block, message) + FiberBoundary.invoke_iteration(block, message, scheduling: @scheduling, wrapper: @wrapper) + end + end + + # The resources of one session and what is done with them: acquiring the + # transport, the Query on top of it and (for a store-backed resume) the + # materialized config dir; writing prompts; delivering messages; disposing + # of all three. + # + # This is implementation shared by the two entry points, ClaudeAgentSDK.query + # and Client, not a lifecycle of its own. When to connect, what observers are + # told, which error wins and whether stdin is closed after a prompt differ + # between the two and stay with them; so nothing here notifies on_error or + # on_close, and nothing here rescues. + # + # @api private + class SessionAssembly + # For #write_prompt: stamp with the verbatim_prompts value #connect captured. + CAPTURED = :captured + + # The session's control-protocol handler: nil before #connect and after + # #close_resources. The only state that is read from outside. + attr_reader :query_handler + + # Acquires nothing. +transport_source+ says where the transport comes from: + # + # { instance: transport } one the caller built (query(transport:)) + # { class: klass, args: kwargs } one to construct as klass.new(options, **kwargs) + # + # +configured_options+ are the options after can_use_tool routing and + # validation; #connect may replace them with a copy repointed at a + # materialized resume. + def initialize(configured_options, dispatch:, transport_source:) + @options = configured_options + @dispatch = dispatch + @transport_source = transport_source + @transport = nil + @query_handler = nil + @materialized = nil + @verbatim_prompts = false + end + + # Acquire, in order: the materialized resume (when it applies) and the + # options repointed at it, the transport, its connection, the Query, the + # transcript mirror (before the read loop starts, so that it sees every + # transcript_mirror frame), the read loop, the handshake. + # + # Each resource is recorded before the next step that can fail and nothing + # is rolled back here: whatever a step raises propagates, and the caller's + # own rescue calls #close_resources. + def connect + @transport = acquire_transport + @transport.connect + + # Read once, here: after the transport connected (its #connect may still + # change the options it was given) and before the Query is built. The + # Query stamps streamed prompts with this value, and #write_prompt / + # #write_message stamp theirs with it too unless told otherwise, so one + # session marks all of them alike. + @verbatim_prompts = @options.verbatim_prompts? + @query_handler = build_query_handler + install_transcript_mirror + + @query_handler.start + @query_handler.initialize_protocol + end + + # Write a String prompt as one user message. The caller notifies + # on_user_prompt first: the order is its own, and so is what may happen to + # the options in between. + # + # verbatim: CAPTURED the value #connect captured (Client) + # verbatim: :current read from the options the session runs on, now + # (query(): after its on_user_prompt notification) + # verbatim: true / false that value + # + # The options the session runs on are the repointed copy when a + # store-backed resume was materialized, the configured ones otherwise. + def write_prompt(prompt, session_id:, verbatim: CAPTURED) + message = { + type: 'user', + message: { role: 'user', content: prompt }, + parent_tool_use_id: nil, + session_id: session_id + } + writeln(Query.serialize_user_message(message, verbatim_value(verbatim))) + end + + # Write one user message that already has its shape (a Hash, or a JSONL + # String), stamped with the captured verbatim_prompts value. Serialized + # first: a message that cannot be marked raises before anything is written. + def write_message(message) + writeln(Query.serialize_user_message(message, @verbatim_prompts)) + end + + # Stream an Enumerable prompt as the session's input, in the background. + # The task is tracked on the Query, so closing it stops the stream; + # Query#stream_input closes stdin once the stream is exhausted and its + # last run is over, and swallows the stream's own errors with a warning. + # Observers get on_user_prompt for each user message before it is written. + def stream_prompt_in_background(prompt) + handler = @query_handler + observed = @dispatch.observing_stream(prompt) + handler.spawn_task { handler.stream_input(observed) } + end + + # Deliver the session's messages to +block+: parse each frame, notify + # on_message, then invoke the block across the FiberBoundary. Returns the + # value of the block's `break` when it broke. With until_result: true the + # ResultMessage is the last message delivered. + # + # Loop control stays on this fiber, the one that dequeues: both breaks + # happen here, never inside the hop (a `break` in a proc on another thread + # raises LocalJumpError; Dispatch#invoke_iteration hands it back as a + # Break). Errors propagate; notifying on_error is the caller's. + def deliver(block, until_result: false) + @query_handler.receive_messages do |data| + message = MessageParser.parse(data) + next unless message + + @dispatch.notify(:on_message, message) + signal = @dispatch.invoke_iteration(block, message) + break signal.value if signal.is_a?(FiberBoundary::Break) + break if until_result && message.is_a?(ResultMessage) + end + end + + # Dispose of whatever #connect acquired, however far it got: close the + # Query (which flushes the transcript mirror and closes the transport), + # close the transport, decide what happens to the materialized resume dir. + # + # always_close_transport: true the transport is closed in an ensure of + # its own, also when closing the Query + # raised (Client) + # always_close_transport: false the transport is closed only when no + # Query was built (query()) + # + # Transport#close is idempotent, so the second close is harmless. The + # nested ensures run every later step when an earlier one raises, and the + # last error raised is the one that propagates. + # + # A block, when given, is called once both closes are behind and before + # the materialized dir is dealt with — also when a close raised. From + # there on nothing of the session can be used any more, while removing + # the directory can still take a while (it retries, sleeping, when the + # directory is busy) and lets other tasks run meanwhile: the caller marks + # itself disconnected in the block, so that calls made in that window are + # refused instead of reaching a session that is half gone. + def close_resources(always_close_transport:) + # Kept past the nil-out below: whether the mirror dropped batches is + # final only after #close ran its last flush. + handler = @query_handler + begin + handler&.close + ensure + @query_handler = nil + begin + @transport&.close if always_close_transport || handler.nil? + ensure + @transport = nil + yield if block_given? + dispose_of_materialized_resume(handler) + end + end + end + + private + + # An injected transport was built before any repointing could reach it, + # so nothing is materialized for it. A class is constructed after + # materialization, with the repointed options. + def acquire_transport + return @transport_source.fetch(:instance) if @transport_source.key?(:instance) + + transport_class = @transport_source.fetch(:class) + materialize_resume if @options.session_store && spawns_cli_locally?(transport_class) + transport_class.new(@options, **@transport_source.fetch(:args)) + end + + # The materialized config dir and --resume reach a transport only through + # the options it is constructed with, and mean something only to one that + # spawns the CLI on this host with them: SubprocessCLITransport or a + # subclass of it (ancestry, not identity). The source may be a duck-typed + # factory rather than a Class. + def spawns_cli_locally?(transport_class) + transport_class.is_a?(Class) && transport_class <= SubprocessCLITransport + end + + # Resume-from-store: load the session from the store into a temp + # CLAUDE_CONFIG_DIR and repoint the options at it (env + --resume). The + # directory is recorded before the repointing, so a failure there still + # leaves it to #close_resources. + def materialize_resume + @materialized = SessionResume.materialize_resume_session(@options) + @options = SessionResume.apply_materialized_options(@options, @materialized) if @materialized + end + + # The one place a Query is built from options. Streaming mode with the + # control protocol, always (as in the Python SDK): agents travel in the + # initialize request rather than in CLI arguments, clear of ARG_MAX. + def build_query_handler + Query.new( + transport: @transport, + is_streaming_mode: true, + can_use_tool: @options.can_use_tool, + hooks: ClaudeAgentSDK.convert_hooks_to_internal_format(@options.hooks), + sdk_mcp_servers: ClaudeAgentSDK.extract_sdk_mcp_servers(@options.mcp_servers), + agents: @options.agents, + exclude_dynamic_sections: ClaudeAgentSDK.extract_exclude_dynamic_sections(@options.system_prompt), + system_prompt_snapshot: ClaudeAgentSDK.extract_system_prompt_snapshot(@options.system_prompt), + skills: @options.skills, + forward_subagent_text: @options.forward_subagent_text?, + agent_progress_summaries: @options.agent_progress_summaries, + callback_scheduling: @dispatch.scheduling, + callback_wrapper: @dispatch.wrapper, + verbatim_prompts: @verbatim_prompts, + run_end_ceiling_ms: Query.run_end_ceiling_ms(@options.env) + ) + end + + # Mirror transcripts to the session_store, if one is configured. + def install_transcript_mirror + return unless @options.session_store + + handler = @query_handler + handler.set_transcript_mirror_batcher( + SessionResume.build_mirror_batcher( + store: @options.session_store, + env: @options.env, + on_error: ->(key, message) { handler.report_mirror_error(key, message) }, + eager: @options.session_store_flush.to_s == 'eager', + callback_wrapper: @dispatch.wrapper + ) + ) + end + + def verbatim_value(verbatim) + case verbatim + when CAPTURED then @verbatim_prompts + when :current then @options.verbatim_prompts? + else verbatim + end + end + + def writeln(string) + @transport.write(string.end_with?("\n") ? string : "#{string}\n") + end + + # The materialized resume dir holds a redacted .credentials.json copy, so + # it is removed once the subprocess has exited — unless the mirror dropped + # batches: the store copy is then incomplete and the dir holds the only + # copy of the dropped turns, so it is preserved (scrubbed of credentials) + # with a warning instead. + def dispose_of_materialized_resume(handler) + return unless @materialized + + handler&.mirror_batches_dropped? ? @materialized.preserve_transcripts : @materialized.cleanup + @materialized = nil + end + end +end diff --git a/lib/claude_agent_sdk/session_resume.rb b/lib/claude_agent_sdk/session_resume.rb index c9f0a123..db6709ac 100644 --- a/lib/claude_agent_sdk/session_resume.rb +++ b/lib/claude_agent_sdk/session_resume.rb @@ -240,10 +240,10 @@ def apply_materialized_options(options, materialized) ) end - # Build a TranscriptMirrorBatcher for a configured session_store. Shared by - # both entry points (Client#install_transcript_mirror and the one-shot - # query()) so projects_dir resolution and the eager/batched threshold choice - # live in one place. +env+ supplies the CLAUDE_CONFIG_DIR / HOME overrides + # Build a TranscriptMirrorBatcher for a configured session_store. Called + # wherever a session is assembled (Client and the one-shot query()), so + # projects_dir resolution and the eager/batched threshold choice live in + # one place. +env+ supplies the CLAUDE_CONFIG_DIR / HOME overrides # used to locate the projects dir (already repointed at the temp dir when # resuming from a store). Eager flush mode zeroes the buffer thresholds so every # transcript_mirror frame triggers a background flush. diff --git a/spec/support/entry_point_harness.rb b/spec/support/entry_point_harness.rb new file mode 100644 index 00000000..0c8cdcec --- /dev/null +++ b/spec/support/entry_point_harness.rb @@ -0,0 +1,240 @@ +# frozen_string_literal: true + +require 'json' + +# Fixtures for the specs that drive ClaudeAgentSDK.query and +# ClaudeAgentSDK::Client through their public API only: an in-memory stand-in +# for the CLI process, an observer that records what it was told, and a way to +# deliver a caller's deadline at a chosen moment. +module EntryPointHarness + ASSISTANT_FRAME = { + type: 'assistant', + message: { role: 'assistant', model: 'claude-sonnet-4', content: [{ type: 'text', text: 'done' }] } + }.freeze + + RESULT_FRAME = { + type: 'result', subtype: 'success', is_error: false, duration_ms: 1, duration_api_ms: 1, + num_turns: 1, session_id: 's', total_cost_usd: 0 + }.freeze + + # In-memory stand-in for the CLI process, used as a transport. + # + # It answers `initialize` the way the CLI answers control requests (payload + # trimmed) or rejects it, replies to every user message with +replies+, and + # ends its output when stdin is closed (#end_input) or the transport is + # closed — as the CLI does in stream-json mode. Everything written to it is + # kept, parsed, in #writes. + # + # replies: frames sent after each user message; an Exception in + # the list is raised from the read loop instead + # hang_up_after: end the output after that many user messages + # reject_initialize: answer the handshake with an error + # connect_error: raised by #connect + # close_errors: raised by #close, one per call, in order + # on_connect: called with the transport at the start of #connect + # on_write: called with each parsed frame and the transport before + # the frame is accepted; what it raises fails the write + class FakeCLI + attr_reader :options, :writes, :lines, :close_calls + + # A transport class the SDK constructs itself and treats as a CLI + # subprocess (so a store-backed resume is materialized for it). No + # subprocess is spawned: .new hands back a FakeCLI, also pushed on +created+. + def self.subprocess_class(created = [], **switches) + factory(ClaudeAgentSDK::SubprocessCLITransport, created, switches) + end + + # A transport class the SDK constructs itself and knows nothing about. + def self.foreign_class(created = [], **switches) + factory(Object, created, switches) + end + + def self.factory(superclass, created, switches) + fake = self + Class.new(superclass) do + define_singleton_method(:new) do |options, **kwargs| + fake.new(options, **switches, **kwargs).tap { |cli| created << cli } + end + end + end + private_class_method :factory + + def initialize(options = nil, replies: [ASSISTANT_FRAME, RESULT_FRAME], hang_up_after: nil, + reject_initialize: false, connect_error: nil, close_errors: [], on_connect: nil, on_write: nil) + @options = options + @replies = replies + @hang_up_after = hang_up_after + @reject_initialize = reject_initialize + @connect_error = connect_error + @close_errors = close_errors.dup + @on_connect = on_connect + @on_write = on_write + @stdout = Thread::Queue.new + @writes = [] + @lines = [] + @close_calls = 0 + @closed = false + @input_ended = false + @read_loop_ended = false + end + + def connect + @on_connect&.call(self) + raise @connect_error if @connect_error + end + + def ready? + !@closed + end + + def write(line) + raise ClaudeAgentSDK::CLIConnectionError, 'transport closed' if @closed + + frame = JSON.parse(line, symbolize_names: true) + @on_write&.call(frame, self) + @lines << line + @writes << frame + case frame[:type] + when 'control_request' then answer_initialize(frame) if frame.dig(:request, :subtype) == 'initialize' + when 'user' then reply_to_user_message + end + end + + def read_messages + while (frame = @stdout.pop) != :eof + raise frame if frame.is_a?(Exception) + + yield frame + end + ensure + @read_loop_ended = true + end + + def end_input + @input_ended = true + hang_up + end + + # A close that raises still ends the output; it does not count as closed. + def close + @close_calls += 1 + hang_up + error = @close_errors.shift + raise error if error + + @closed = true + end + + # Put a frame on the output, as if the CLI had sent it. + def inject(frame) + @stdout << frame + end + + def closed? + @closed + end + + def input_ended? + @input_ended + end + + def read_loop_ended? + @read_loop_ended + end + + def user_writes + @writes.select { |frame| frame[:type] == 'user' } + end + + # The CLAUDE_CONFIG_DIR the transport was constructed with. + def config_dir + @options&.env&.fetch('CLAUDE_CONFIG_DIR', nil) + end + + def inspect + "#" + end + + private + + def answer_initialize(frame) + inject(type: 'control_response', response: initialize_response(frame[:request_id])) + end + + def initialize_response(request_id) + if @reject_initialize + { subtype: 'error', request_id: request_id, error: 'Invalid initialize request' } + else + { subtype: 'success', request_id: request_id, + response: { commands: [], agents: [], output_style: 'default', models: [], pid: 4242, + session_state: 'idle', capabilities: [] } } + end + end + + def reply_to_user_message + @replies.each { |frame| inject(frame.is_a?(Exception) ? frame : JSON.parse(JSON.generate(frame), symbolize_names: true)) } + hang_up if @hang_up_after && user_writes.length >= @hang_up_after + end + + def hang_up + @stdout << :eof + end + end + + # An observer that records every notification as [name, payload], in order. + # +reactions+ maps a notification name to a callable run after recording + # (given the payload), on whatever the SDK runs the observer on. + class RecordingObserver + include ClaudeAgentSDK::Observer + + def initialize(**reactions) + @reactions = reactions + @events = [] + @lock = Mutex.new + end + + def on_user_prompt(prompt) + record(:on_user_prompt, prompt) + end + + def on_message(message) + record(:on_message, message) + end + + def on_error(error) + record(:on_error, error) + end + + def on_close + record(:on_close, nil) + end + + def events + @lock.synchronize { @events.dup } + end + + def names + events.map(&:first) + end + + def payloads(name) + events.select { |event, _| event == name }.map(&:last) + end + + private + + def record(name, payload) + @lock.synchronize { @events << [name, payload] } + @reactions[name]&.call(payload) + end + end + + # Deliver a caller's deadline to +task+ now. An expired task.with_timeout is + # the scheduler raising the timeout's exception into the fiber that armed + # it; this raises it the same way, at a moment the example chose (once an + # observer has signalled that it is running) instead of after a duration + # that slow setup could use up. + def self.expire_deadline_on(task, exception_class = Async::TimeoutError) + Fiber.scheduler.raise(task.fiber, exception_class, 'execution expired') + end +end diff --git a/spec/unit/client_spec.rb b/spec/unit/client_spec.rb index 9c872c6a..5b330a00 100644 --- a/spec/unit/client_spec.rb +++ b/spec/unit/client_spec.rb @@ -23,24 +23,40 @@ expect(received_options.env).not_to have_key('CLAUDE_CODE_ENTRYPOINT') end - it 'passes forward_subagent_text through to the Query handler' do - transport = instance_double(ClaudeAgentSDK::SubprocessCLITransport, connect: true, write: nil) - query_handler = instance_double(ClaudeAgentSDK::Query, start: true, initialize_protocol: true) - allow(ClaudeAgentSDK::SubprocessCLITransport).to receive(:new).and_return(transport) - - [true, false].each do |enabled| - captured = nil - allow(ClaudeAgentSDK::Query).to receive(:new) do |**kwargs| - captured = kwargs - query_handler - end - - described_class.new( - options: ClaudeAgentSDK::ClaudeAgentOptions.new(forward_subagent_text: enabled) - ).connect + # The one example here that runs a real Query: what Client is given + # reaches the control protocol and the wire, and disconnect tears the + # session down. Which option becomes which Query keyword is spelled out + # once, for both entry points, in session_assembly_spec.rb. + it 'hands its options to the control protocol, writes the prompt and tears the session down' do + hook_fn = ->(_input, _tool_use_id, _context) { {} } + options = ClaudeAgentSDK::ClaudeAgentOptions.new( + hooks: { 'PostToolUse' => nil, 'Stop' => [], + PreToolUse: [ClaudeAgentSDK::HookMatcher.new(matcher: 'Bash', hooks: [hook_fn], timeout: 30)] }, + agents: { 'reviewer' => ClaudeAgentSDK::AgentDefinition.new(description: 'Reviews', prompt: 'Review it') }, + system_prompt: { type: 'preset', preset: 'claude_code', exclude_dynamic_sections: true, snapshot: false }, + skills: %w[pdf], forward_subagent_text: true, agent_progress_summaries: false + ) + created = [] + messages = [] - expect(captured[:forward_subagent_text]).to be(enabled) + described_class.open(options: options, transport_class: EntryPointHarness::FakeCLI.foreign_class(created)) do |client| + client.query('hello') + client.receive_response { |message| messages << message } end + + cli = created.fetch(0) + expect(cli.writes.first[:request]).to eq( + subtype: 'initialize', + hooks: { PreToolUse: [{ matcher: 'Bash', hookCallbackIds: ['hook_0'], timeout: 30 }] }, + agents: { reviewer: { description: 'Reviews', prompt: 'Review it' } }, + excludeDynamicSections: true, systemPromptSnapshot: false, skills: ['pdf'], forwardSubagentText: true, + agentProgressSummaries: false + ) + prompt = { type: 'user', message: { role: 'user', content: 'hello' }, parent_tool_use_id: nil, session_id: 'default' } + expect(cli.lines.last).to eq("#{JSON.generate(prompt)}\n") + expect(messages.map(&:class)).to eq([ClaudeAgentSDK::AssistantMessage, ClaudeAgentSDK::ResultMessage]) + expect(cli).to be_closed + expect(cli).not_to be_input_ended end it 'sends an initial String prompt as a user message after connecting' do @@ -71,36 +87,6 @@ .to raise_error(ArgumentError, /got Hash/) end - describe 'hook normalization on connect' do - let(:query_handler) { instance_double(ClaudeAgentSDK::Query, start: true, initialize_protocol: true) } - - before do - transport = instance_double(ClaudeAgentSDK::SubprocessCLITransport, connect: true, close: nil) - allow(ClaudeAgentSDK::SubprocessCLITransport).to receive(:new).and_return(transport) - allow(ClaudeAgentSDK::Query).to receive(:new).and_return(query_handler) - end - - it 'omits nil and empty lists while preserving active matcher callbacks and timeouts' do - callback = ->(*) { {} } - matcher = ClaudeAgentSDK::HookMatcher.new(matcher: 'Bash', hooks: [callback], timeout: 7) - hooks = { 'PostToolUse' => nil, 'Stop' => [], PreToolUse: [matcher] } - described_class.new(options: ClaudeAgentSDK::ClaudeAgentOptions.new(hooks: hooks)).connect - - expect(ClaudeAgentSDK::Query).to have_received(:new).with(hash_including( - hooks: { 'PreToolUse' => [{ matcher: 'Bash', hooks: [callback], timeout: 7 }] } - )) - expect(hooks).to eq('PostToolUse' => nil, 'Stop' => [], PreToolUse: [matcher]) - end - - it 'passes nil when no active hook lists remain' do - described_class.new(options: ClaudeAgentSDK::ClaudeAgentOptions.new( - hooks: { 'PreToolUse' => nil, 'PostToolUse' => [] } - )).connect - - expect(ClaudeAgentSDK::Query).to have_received(:new).with(hash_including(hooks: nil)) - end - end - it 'streams an initial Enumerator prompt in the background via Query#stream_input' do transport = instance_double(ClaudeAgentSDK::SubprocessCLITransport, connect: true, write: nil) query_handler = instance_double(ClaudeAgentSDK::Query, start: true, initialize_protocol: true) @@ -249,27 +235,6 @@ expect(query_handler).to have_received(:toggle_mcp_server).with('my-server', false) end - it 'passes agent_progress_summaries through to the Query handler, preserving nil vs false' do - transport = instance_double(ClaudeAgentSDK::SubprocessCLITransport, connect: true, write: nil) - query_handler = instance_double(ClaudeAgentSDK::Query, start: true, initialize_protocol: true) - allow(ClaudeAgentSDK::SubprocessCLITransport).to receive(:new).and_return(transport) - - [true, false, nil].each do |value| - captured = nil - allow(ClaudeAgentSDK::Query).to receive(:new) do |**kwargs| - captured = kwargs - query_handler - end - - described_class.new( - options: ClaudeAgentSDK::ClaudeAgentOptions.new(agent_progress_summaries: value) - ).connect - - expect(captured).to have_key(:agent_progress_summaries) - expect(captured[:agent_progress_summaries]).to be(value) - end - end - it 'raises when backgrounding tasks while not connected' do client = described_class.new expect { client.background_tasks }.to raise_error(ClaudeAgentSDK::CLIConnectionError) @@ -553,126 +518,6 @@ def build_transport_class(&on_initialize) end end - context 'with exclude_dynamic_sections' do - let(:transport) { instance_double(ClaudeAgentSDK::SubprocessCLITransport, connect: true, write: nil) } - let(:query_handler) { instance_double(ClaudeAgentSDK::Query, start: true, initialize_protocol: true) } - - before do - allow(ClaudeAgentSDK::SubprocessCLITransport).to receive(:new).and_return(transport) - end - - it 'passes exclude_dynamic_sections from SystemPromptPreset to Query' do - received_kwargs = nil - allow(ClaudeAgentSDK::Query).to receive(:new) do |**kwargs| - received_kwargs = kwargs - query_handler - end - - preset = ClaudeAgentSDK::SystemPromptPreset.new(preset: 'claude_code', exclude_dynamic_sections: true) - options = ClaudeAgentSDK::ClaudeAgentOptions.new(system_prompt: preset) - client = described_class.new(options: options) - client.connect - - expect(received_kwargs[:exclude_dynamic_sections]).to eq(true) - end - - it 'passes exclude_dynamic_sections from Hash with symbol keys to Query' do - received_kwargs = nil - allow(ClaudeAgentSDK::Query).to receive(:new) do |**kwargs| - received_kwargs = kwargs - query_handler - end - - options = ClaudeAgentSDK::ClaudeAgentOptions.new( - system_prompt: { type: 'preset', preset: 'claude_code', exclude_dynamic_sections: true } - ) - client = described_class.new(options: options) - client.connect - - expect(received_kwargs[:exclude_dynamic_sections]).to eq(true) - end - - it 'handles false correctly from Hash with symbol keys' do - received_kwargs = nil - allow(ClaudeAgentSDK::Query).to receive(:new) do |**kwargs| - received_kwargs = kwargs - query_handler - end - - options = ClaudeAgentSDK::ClaudeAgentOptions.new( - system_prompt: { type: 'preset', preset: 'claude_code', exclude_dynamic_sections: false } - ) - client = described_class.new(options: options) - client.connect - - expect(received_kwargs[:exclude_dynamic_sections]).to eq(false) - end - - it 'passes nil when system_prompt is a plain string' do - received_kwargs = nil - allow(ClaudeAgentSDK::Query).to receive(:new) do |**kwargs| - received_kwargs = kwargs - query_handler - end - - options = ClaudeAgentSDK::ClaudeAgentOptions.new(system_prompt: 'You are a helper') - client = described_class.new(options: options) - client.connect - - expect(received_kwargs[:exclude_dynamic_sections]).to be_nil - end - end - - # Python #1268: connect() hands the system prompt's snapshot to Query only - # for the preset and custom forms; String and file prompts have none. - context 'with system prompt snapshot' do - let(:transport) { instance_double(ClaudeAgentSDK::SubprocessCLITransport, connect: true, write: nil) } - let(:query_handler) { instance_double(ClaudeAgentSDK::Query, start: true, initialize_protocol: true) } - - before do - allow(ClaudeAgentSDK::SubprocessCLITransport).to receive(:new).and_return(transport) - end - - def snapshot_passed_to_query(system_prompt) - received_kwargs = nil - allow(ClaudeAgentSDK::Query).to receive(:new) do |**kwargs| - received_kwargs = kwargs - query_handler - end - - client = described_class.new(options: ClaudeAgentSDK::ClaudeAgentOptions.new(system_prompt: system_prompt)) - client.connect - received_kwargs.fetch(:system_prompt_snapshot) - end - - { - 'custom Hash with snapshot false' => [{ type: 'custom', prompt: 'Be helpful', snapshot: false }, false], - 'preset Hash with snapshot true' => [{ type: 'preset', preset: 'claude_code', snapshot: true }, true], - 'preset Hash with string keys and snapshot false' => - [{ 'type' => 'preset', 'preset' => 'claude_code', 'snapshot' => false }, false], - 'preset Hash without snapshot' => [{ type: 'preset', preset: 'claude_code' }, nil], - 'file Hash (snapshot ignored)' => [{ type: 'file', path: '/p.md', snapshot: false }, nil], - 'plain String' => ['Be helpful', nil], - 'nil system_prompt' => [nil, nil] - }.each do |label, (system_prompt, expected)| - it "passes #{expected.inspect} for a #{label}" do - expect(snapshot_passed_to_query(system_prompt)).to be(expected) - end - end - - it 'passes snapshot from SystemPromptCustom and SystemPromptPreset objects' do - custom = ClaudeAgentSDK::SystemPromptCustom.new(prompt: 'Be helpful', snapshot: false) - preset = ClaudeAgentSDK::SystemPromptPreset.new(preset: 'claude_code', snapshot: true) - - expect(snapshot_passed_to_query(custom)).to be(false) - expect(snapshot_passed_to_query(preset)).to be(true) - end - - it 'ignores a non-boolean snapshot' do - expect(snapshot_passed_to_query({ type: 'custom', prompt: 'x', snapshot: 'yes' })).to be_nil - end - end - context 'with default configuration' do after { ClaudeAgentSDK.reset_configuration } @@ -686,9 +531,25 @@ def snapshot_passed_to_query(system_prompt) end end + # The options a Client runs on are the ones it hands the transport it + # constructs. + def options_handed_to_transport(client) + transport = instance_double(ClaudeAgentSDK::SubprocessCLITransport, connect: true, write: nil) + query_handler = instance_double(ClaudeAgentSDK::Query, start: true, initialize_protocol: true) + + received_options = nil + allow(ClaudeAgentSDK::SubprocessCLITransport).to receive(:new) do |options| + received_options = options + transport + end + allow(ClaudeAgentSDK::Query).to receive(:new).and_return(query_handler) + + client.connect + received_options + end + it 'uses configured defaults when no options provided' do - client = described_class.new - options = client.instance_variable_get(:@options) + options = options_handed_to_transport(described_class.new) expect(options.model).to eq('sonnet') expect(options.permission_mode).to eq('bypassPermissions') @@ -700,32 +561,13 @@ def snapshot_passed_to_query(system_prompt) model: 'opus', env: { 'OVERRIDE_KEY' => 'override_value' } ) - client = described_class.new(options: override_options) - options = client.instance_variable_get(:@options) + options = options_handed_to_transport(described_class.new(options: override_options)) expect(options.model).to eq('opus') # override expect(options.permission_mode).to eq('bypassPermissions') # from default expect(options.env['API_KEY']).to eq('configured_key') # from default expect(options.env['OVERRIDE_KEY']).to eq('override_value') # from provided end - - it 'passes merged options to transport' do - transport = instance_double(ClaudeAgentSDK::SubprocessCLITransport, connect: true, write: nil) - query_handler = instance_double(ClaudeAgentSDK::Query, start: true, initialize_protocol: true) - - received_options = nil - allow(ClaudeAgentSDK::SubprocessCLITransport).to receive(:new) do |options| - received_options = options - transport - end - allow(ClaudeAgentSDK::Query).to receive(:new).and_return(query_handler) - - client = described_class.new - client.connect - - expect(received_options.model).to eq('sonnet') - expect(received_options.permission_mode).to eq('bypassPermissions') - end end describe 'Client#query with an iterable (F7)' do def connected_client_capturing(writes) @@ -1015,32 +857,53 @@ def on_error(error) = @sink << error # materialized temp dir holds the only copy of those turns — disconnect must # preserve it (scrubbed of credentials) instead of deleting it. describe '#disconnect with a materialized resume' do - def client_with(handler, materialized) - client = described_class.new - client.instance_variable_set(:@query_handler, handler) - client.instance_variable_set(:@materialized, materialized) - client + let(:materialized) do + instance_double(ClaudeAgentSDK::MaterializedResume, cleanup: nil, preserve_transcripts: nil, + config_dir: '/nonexistent/claude-resume-x', + resume_session_id: SecureRandom.uuid) + end + let(:transport) { instance_double(ClaudeAgentSDK::SubprocessCLITransport, connect: true, close: nil) } + + # A client that connected with a store-backed resume: the session was + # materialized, and the query handler reports +dropped+ for its mirror. + def connected_client(dropped:) + handler = instance_double(ClaudeAgentSDK::Query, start: true, initialize_protocol: true, close: nil, + set_transcript_mirror_batcher: nil, + mirror_batches_dropped?: dropped) + allow(ClaudeAgentSDK::SubprocessCLITransport).to receive(:new).and_return(transport) + allow(ClaudeAgentSDK::Query).to receive(:new).and_return(handler) + allow(ClaudeAgentSDK::SessionResume).to receive(:materialize_resume_session).and_return(materialized) + options = ClaudeAgentSDK::ClaudeAgentOptions.new(session_store: ClaudeAgentSDK::InMemorySessionStore.new, + resume: materialized.resume_session_id) + [described_class.new(options: options).tap(&:connect), handler] end it 'preserves the temp dir when the mirror dropped batches' do - handler = instance_double(ClaudeAgentSDK::Query, close: nil, mirror_batches_dropped?: true) - materialized = instance_double(ClaudeAgentSDK::MaterializedResume, cleanup: nil, preserve_transcripts: nil) + client, handler = connected_client(dropped: true) - client_with(handler, materialized).disconnect + client.disconnect - expect(handler).to have_received(:close) - expect(materialized).to have_received(:preserve_transcripts) + expect(handler).to have_received(:close).ordered + expect(transport).to have_received(:close).ordered + expect(materialized).to have_received(:preserve_transcripts).ordered expect(materialized).not_to have_received(:cleanup) end it 'cleans up the temp dir when no batches were dropped' do - handler = instance_double(ClaudeAgentSDK::Query, close: nil, mirror_batches_dropped?: false) - materialized = instance_double(ClaudeAgentSDK::MaterializedResume, cleanup: nil, preserve_transcripts: nil) + client, = connected_client(dropped: false) - client_with(handler, materialized).disconnect + client.disconnect expect(materialized).to have_received(:cleanup) expect(materialized).not_to have_received(:preserve_transcripts) end + + it 'decides once: a second disconnect leaves the temp dir alone' do + client, = connected_client(dropped: false) + + 2.times { client.disconnect } + + expect(materialized).to have_received(:cleanup).once + end end end diff --git a/spec/unit/client_teardown_interleavings_spec.rb b/spec/unit/client_teardown_interleavings_spec.rb new file mode 100644 index 00000000..2b3fb2a2 --- /dev/null +++ b/spec/unit/client_teardown_interleavings_spec.rb @@ -0,0 +1,263 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'async' +require 'fileutils' +require 'securerandom' +require 'timeout' +require 'tmpdir' + +# What a Client does when a teardown interleaves with its other calls: calls +# made while a disconnect is under way, a disconnect after one that was cut +# short, a receive still running across a disconnect and a reconnect. All of +# it through the public API, with a real Query over an in-memory CLI and a +# really materialized store-backed resume; the only thing stubbed is the +# removal of the materialized directory, to park it or to make it fail. +# +# Client#disconnect closes the query handler and the transport, and then +# removes the materialized resume dir. That removal can take a while (it +# retries, sleeping, when the directory is busy), and the reactor runs other +# tasks meanwhile. +RSpec.describe ClaudeAgentSDK::Client, 'when a teardown interleaves with its other calls' do + let(:created) { [] } + let(:cwd) { Dir.mktmpdir('client-interleavings-cwd-') } + let(:user_config_dir) { Dir.mktmpdir('client-interleavings-config-') } # keeps the developer's own config out of it + let(:session_id) { SecureRandom.uuid } + let(:store) do + ClaudeAgentSDK::InMemorySessionStore.new.tap do |store| + store.append({ 'project_key' => ClaudeAgentSDK.project_key_for_directory(cwd), 'session_id' => session_id }, + [{ 'type' => 'user', 'uuid' => SecureRandom.uuid, 'message' => { 'content' => 'hi' } }]) + end + end + let(:observer) { EntryPointHarness::RecordingObserver.new } + let(:wrapped) { [] } + let(:wrapper) do + lambda do |invocation| + wrapped << true + invocation.call + end + end + + # A regression here tends to be a wait that never ends; fail instead. + around do |example| + Timeout.timeout(30) { example.run } + end + + after do + [cwd, user_config_dir, *created.map(&:config_dir)].each { |dir| FileUtils.rm_rf(dir) if dir } + end + + # A client connected with a materialized store-backed resume, and the + # in-memory CLI it talks to (its config_dir is the materialized directory). + def connected_client(scheduling) + options = ClaudeAgentSDK::ClaudeAgentOptions.new( + session_store: store, resume: session_id, cwd: cwd, env: { 'CLAUDE_CONFIG_DIR' => user_config_dir }, + observers: [observer], callback_scheduling: scheduling, callback_wrapper: wrapper + ) + client = described_class.new(options: options, transport_class: EntryPointHarness::FakeCLI.subprocess_class(created)) + client.connect + [client, created.fetch(0)] + end + + # Every removal of +directory+ goes through the block first, with the + # number of the attempt; any other removal is left alone. + def on_removal_of(directory) + attempts = 0 + allow(FileUtils).to receive(:remove_entry).and_wrap_original do |original, path, *rest| + yield(attempts += 1) if path == directory + original.call(path, *rest) + end + end + + def not_connected + raise_error(ClaudeAgentSDK::CLIConnectionError, 'Not connected. Call connect() first') + end + + %i[thread inline].each do |scheduling| + context "with callback_scheduling: #{scheduling} and a callback_wrapper" do + # The handler and the transport are closed by then: the client is no + # longer connected, and says so, instead of running into the session + # it has just taken apart. + describe 'calls made while the materialized resume dir is being removed' do + it 'are refused as not connected, notify no observer, and a second disconnect does not close again' do + client, cli = Sync do |task| + client, cli = connected_client(scheduling) + entered = Thread::Queue.new + release = Thread::Queue.new + on_removal_of(cli.config_dir) do |attempt| + next unless attempt == 1 + + entered << true + release.pop + end + + disconnecting = task.async { client.disconnect } + entered.pop # both closes ran; the first removal is parked + + aggregate_failures do + expect(cli).to be_closed + expect(observer.names).to eq(%i[on_close]) + expect { client.query('racing') }.to not_connected + expect { client.receive_messages { |_message| nil } }.to not_connected + expect { client.receive_response { |_message| nil } }.to not_connected + expect { client.interrupt }.to not_connected + expect { client.disconnect }.not_to raise_error + expect(observer.names).to eq(%i[on_close]) # nothing was notified, and on_close only once + expect(cli.user_writes).to be_empty + end + [client, cli] + ensure + release&.close + disconnecting&.wait + end + + expect(File.exist?(cli.config_dir)).to be(false) + expect(client.query_handler).to be_nil + expect(wrapped).not_to be_empty + end + end + + # The directory holds a copy of the credentials and the settings. A + # removal that was cut short (the directory was busy, and the caller's + # deadline expired or its task was stopped during the wait before the + # retry) must stay within reach of the next disconnect. + describe 'a disconnect interrupted while it removes the materialized resume dir' do + { + 'an expired deadline' => Async::TimeoutError, + 'a cancellation that is not a StandardError' => Class.new(ClaudeAgentSDK::FiberBoundary::InlineCancellation) + }.each do |label, cancellation| + it "raises the interruption and leaves the dir to the next disconnect, which removes it (#{label})" do + attempts = [] + + Sync do |task| + client, cli = connected_client(scheduling) + entered = Thread::Queue.new + on_removal_of(cli.config_dir) do |attempt| + attempts << attempt + next unless attempt == 1 + + entered << true + raise Errno::EBUSY, cli.config_dir # retried after a sleep, which is where the interruption lands + end + + disconnecting = task.async { expect { client.disconnect }.to raise_error(cancellation) } + entered.pop + EntryPointHarness.expire_deadline_on(disconnecting, cancellation) + disconnecting.wait + + aggregate_failures do + expect(attempts).to eq([1]) + expect(File.directory?(cli.config_dir)).to be(true) + expect(cli).to be_closed # the rest of the teardown was done + expect(client.query_handler).to be_nil + expect { client.query('again') }.to not_connected + + expect { client.disconnect }.not_to raise_error + expect(attempts).to eq([1, 2]) + expect(File.exist?(cli.config_dir)).to be(false) + + client.disconnect # nothing is left to do + expect(attempts).to eq([1, 2]) + expect(observer.names).to eq(%i[on_close]) + end + end + end + end + end + + # The client is disconnected from the moment the directory's removal + # starts, so it can be connected again while that removal is still + # going. The disconnect that then finishes owns the old session only: + # it must not let go of the new one, whose own resume dir would + # otherwise be out of reach of the disconnect that ends it. + describe 'a reconnect while the old materialized resume dir is still being removed' do + it 'leaves the new session usable, and its own disconnect removes its own resume dir' do + delivered = [] + + Sync do |task| + client, old_cli = connected_client(scheduling) + entered = Thread::Queue.new + release = Thread::Queue.new + on_removal_of(old_cli.config_dir) do |attempt| + next unless attempt == 1 + + entered << true + release.pop + end + + disconnecting = task.async { client.disconnect } + entered.pop # the old session is closed; the removal of its dir is parked + client.connect + new_cli = created.fetch(1) + release << true + disconnecting.wait # the old disconnect finishes after the reconnect + + aggregate_failures do + expect(File.exist?(old_cli.config_dir)).to be(false) + expect(new_cli.config_dir).not_to eq(old_cli.config_dir) + expect(File.directory?(new_cli.config_dir)).to be(true) + expect(client.query_handler).not_to be_nil + + client.query('on the new connection') + client.receive_response { |message| delivered << message.class } + client.disconnect + + expect(delivered).to eq([ClaudeAgentSDK::AssistantMessage, ClaudeAgentSDK::ResultMessage]) + expect(new_cli.user_writes.map { |frame| frame.dig(:message, :content) }).to eq(['on the new connection']) + expect(File.exist?(new_cli.config_dir)).to be(false) + expect(created.map(&:closed?)).to eq([true, true]) + expect(client.query_handler).to be_nil + expect(observer.names).to eq(%i[on_close on_user_prompt on_message on_message on_close]) + end + ensure + release&.close + end + end + end + + # Observers are resolved on each connect, and a receive loop asks the + # client for them message by message: one that is still running from + # before a reconnect tells the observers of the connection there is + # now, not the ones it started with. + describe 'a receive still running across a disconnect and a reconnect' do + it 'notifies the observers of the current connection of what it delivers from then on' do + resolved = [] + factory = -> { EntryPointHarness::RecordingObserver.new.tap { |built| resolved << built } } + options = ClaudeAgentSDK::ClaudeAgentOptions.new(observers: [factory], callback_scheduling: scheduling, + callback_wrapper: wrapper) + client = described_class.new(options: options, + transport_class: EntryPointHarness::FakeCLI.foreign_class(created, hang_up_after: 1)) + entered = Thread::Queue.new + release = Thread::Queue.new + delivered = [] + + Sync do |task| + client.connect + client.query('first session') + receiving = task.async do + client.receive_messages do |message| + delivered << message.class + next unless delivered.length == 1 + + entered << true + release.pop # parked in the block, with the result already read + end + end + entered.pop + client.disconnect + client.connect + release << true + receiving.wait + client.disconnect + ensure + release.close + end + + expect(delivered).to eq([ClaudeAgentSDK::AssistantMessage, ClaudeAgentSDK::ResultMessage]) + expect(resolved.map(&:names)).to eq([%i[on_user_prompt on_message on_close], %i[on_message on_close]]) + expect(created.map(&:closed?)).to eq([true, true]) + end + end + end + end +end diff --git a/spec/unit/client_teardown_under_deadline_spec.rb b/spec/unit/client_teardown_under_deadline_spec.rb index 830a7000..e4fc48f5 100644 --- a/spec/unit/client_teardown_under_deadline_spec.rb +++ b/spec/unit/client_teardown_under_deadline_spec.rb @@ -23,9 +23,12 @@ # # The observers block on a queue until the example releases them, so the # deadlines can only expire while they wait. disconnect reaches its observer -# without suspending. connect suspends before it reaches on_error (resume -# materialization, the handshake), so there the real task.with_timeout is -# armed only around the observer notification: no slow setup can use it up. +# without suspending, so a task.with_timeout around it is enough. connect +# suspends before it reaches on_error (resume materialization, the handshake), +# and a timeout around all of it could be used up by slow setup; so there the +# deadline is delivered once the observer has signalled that it is running +# with the handshake rejection, the way an expired task.with_timeout delivers +# it (EntryPointHarness.expire_deadline_on). RSpec.describe ClaudeAgentSDK::Client, 'teardown when the caller is interrupted during an observer' do # In-memory stand-in for the CLI process. It answers `initialize` the way # CLI 2.1.286 answers control requests (payload trimmed), or rejects it @@ -241,12 +244,18 @@ def connected_client(scheduling) end describe 'a failing Client#connect while on_error runs' do - # Arms a real task.with_timeout on the connecting fiber for exactly the - # time Client spends notifying on_error. - def arm_deadline_around_on_error(client, seconds, *exception_class) - allow(client).to receive(:notify_error).and_wrap_original do |notify, error| - Async::Task.current.with_timeout(seconds, *exception_class) { notify.call(error) } - end + # Runs connect on a task of its own and expires the caller's deadline on + # it once on_error is running with the handshake rejection (the observer + # signals it; handed anything else it never does, and this fails). Returns + # what connect did. + def connect_interrupted_during_on_error(task, client, exception_class = Async::TimeoutError) + outcome = nil + caller_task = task.async { outcome = outcome_of { client.connect } } + raise 'on_error was never entered with the handshake rejection' unless entered.pop(timeout: 5) + + EntryPointHarness.expire_deadline_on(caller_task, exception_class) + caller_task.wait + outcome end # The gate of every example here: on_error ran, with the handshake rejection. @@ -255,10 +264,9 @@ def expect_on_error_reached end it 'tears the partial session down, then raises the expired deadline (:thread)' do - Sync do + Sync do |task| client, created = client_for(:on_error, scheduling: :thread, reject_initialize: true) - arm_deadline_around_on_error(client, 0.05) - outcome = outcome_of { client.connect } + outcome = connect_interrupted_during_on_error(task, client) expect_on_error_reached expect(outcome).to be_a(Async::TimeoutError) @@ -270,11 +278,10 @@ def expect_on_error_reached end it 'tears the partial session down, then raises an inline cooperative cancellation (:inline)' do - Sync do + Sync do |task| client, created = client_for(:on_error, scheduling: :inline, reject_initialize: true) cancellation = inline_cancellation - arm_deadline_around_on_error(client, 0.05, cancellation) - outcome = outcome_of { client.connect } + outcome = connect_interrupted_during_on_error(task, client, cancellation) expect_on_error_reached expect(outcome).to be_a(cancellation) @@ -303,10 +310,9 @@ def expect_on_error_reached it 'tears the partial session down and raises the connect error while the observer contains a deadline ' \ '(:inline, residue)' do - Sync do + Sync do |task| client, created = client_for(:on_error, scheduling: :inline, reject_initialize: true) - arm_deadline_around_on_error(client, 0.05) - outcome = outcome_of { client.connect } + outcome = connect_interrupted_during_on_error(task, client) expect_on_error_reached expect(outcome).to be_a(StandardError).and(have_attributes(message: 'Invalid initialize request')) diff --git a/spec/unit/close_from_callback_spec.rb b/spec/unit/close_from_callback_spec.rb index 44148f88..ba57af95 100644 --- a/spec/unit/close_from_callback_spec.rb +++ b/spec/unit/close_from_callback_spec.rb @@ -294,6 +294,14 @@ def connect_and_inject(client, created) end.wait end + # The client let go of its session: no query handler any more (its + # transport is the recording one, asserted closed by each example), and + # the public API reports it as not connected. + def expect_disconnected(client) + expect(client.query_handler).to be_nil + expect { client.query('again') }.to raise_error(ClaudeAgentSDK::CLIConnectionError, /Not connected/) + end + it 'in :inline mode completes the query teardown before the callback unwinds and leaves nothing running' do created = [] klass = transport_class @@ -308,7 +316,7 @@ def connect_and_inject(client, created) observed = nil done = Thread::Queue.new can_use_tool = lambda do |_tool, _input, _context| - query = client.instance_variable_get(:@query_handler) + query = client.query_handler begin client.disconnect observed = snapshot(query, created.first, nil) @@ -329,8 +337,7 @@ def connect_and_inject(client, created) expect(observed[:raised]).to be_a(Async::Stop) expect(observed[:transport_closed]).to be(true) expect(observed[:close_requests_closed]).to be(true) - expect(client.instance_variable_get(:@connected)).to be(false) - expect(client.instance_variable_get(:@transport)).to be_nil + expect_disconnected(client) end # Same bug from the other side of the tree: a streaming-input enumerator @@ -358,7 +365,7 @@ def connect_and_inject(client, created) stream = Enumerator.new do |y| y << { type: 'user', message: { role: 'user', content: 'hi' }, session_id: 'default' } gate.pop # scheduler-aware: parks the stream task, connect returns and registers it - query = client.instance_variable_get(:@query_handler) + query = client.query_handler begin client.disconnect observed = snapshot(query, created.first, nil) @@ -374,7 +381,7 @@ def connect_and_inject(client, created) Async do client.connect(stream) - expect(client.instance_variable_get(:@query_handler).instance_variable_get(:@child_tasks).size).to eq(1) + expect(client.query_handler.instance_variable_get(:@child_tasks).size).to eq(1) gate << true end.wait # exits only when the read task, the stream task and the watcher are all done await_callback(done) @@ -383,7 +390,7 @@ def connect_and_inject(client, created) expect(observed[:raised]).to be_a(Async::Stop) expect(observed[:transport_closed]).to be(true) expect(observed[:close_requests_closed]).to be(true) - expect(client.instance_variable_get(:@connected)).to be(false) + expect_disconnected(client) sent = created.first.writes.select { |w| w[:type] == 'user' }.map { |w| w.dig(:message, :content) } expect(sent).to eq(['hi']) end @@ -402,7 +409,7 @@ def connect_and_inject(client, created) observed = nil done = Thread::Queue.new can_use_tool = lambda do |_tool, _input, _context| - query = client.instance_variable_get(:@query_handler) + query = client.query_handler begin client.disconnect observed = snapshot(query, created.first, nil) @@ -423,7 +430,7 @@ def connect_and_inject(client, created) expect(observed[:raised]).to be_nil expect(observed[:transport_closed]).to be(true) expect(observed[:close_requests_closed]).to be(true) - expect(client.instance_variable_get(:@connected)).to be(false) + expect_disconnected(client) end end end diff --git a/spec/unit/entry_point_lifecycle_spec.rb b/spec/unit/entry_point_lifecycle_spec.rb new file mode 100644 index 00000000..22ebe505 --- /dev/null +++ b/spec/unit/entry_point_lifecycle_spec.rb @@ -0,0 +1,1008 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'async' +require 'fileutils' +require 'securerandom' +require 'timeout' +require 'tmpdir' + +# What ClaudeAgentSDK.query and ClaudeAgentSDK::Client do around a session: +# the lifecycle differences between the two (L1-L8), what observers are told +# on the failure paths, which error wins, what goes on the wire, when +# verbatim_prompts is read, and what is disposed of after a partial setup. +# +# Everything here goes through the public API, over an in-memory CLI +# (EntryPointHarness::FakeCLI) and a recording observer. It pins behaviour, +# not structure: none of it depends on which object inside the SDK acquires +# the transport, builds the Query or cleans up. +RSpec.describe 'entry point lifecycle' do + let(:fake_cli) { EntryPointHarness::FakeCLI } + let(:client_class) { ClaudeAgentSDK::Client } + let(:created) { [] } + let(:observer) { EntryPointHarness::RecordingObserver.new } + let(:options) { ClaudeAgentSDK::ClaudeAgentOptions.new(observers: [observer]) } + + # A store-backed resume: the session is in the store, not on disk, so the + # SDK materializes it into a temp CLAUDE_CONFIG_DIR for a CLI it spawns. + let(:cwd) { Dir.mktmpdir('entry-point-cwd-') } + let(:user_config_dir) { Dir.mktmpdir('entry-point-config-') } # keeps the developer's own config out of it + let(:session_id) { SecureRandom.uuid } + let(:store) { ClaudeAgentSDK::InMemorySessionStore.new } + + # A regression here tends to be a wait that never ends; fail instead. + around do |example| + Timeout.timeout(30) { example.run } + end + + after do + [cwd, user_config_dir].each { |dir| FileUtils.remove_entry(dir) if File.directory?(dir) } + end + + # The transport query() constructs when none is injected, and the class + # Client constructs by default: a FakeCLI instead of a subprocess. + def stub_default_transport(**switches) + allow(ClaudeAgentSDK::SubprocessCLITransport).to receive(:new) do |transport_options, **kwargs| + fake_cli.new(transport_options, **switches, **kwargs).tap { |cli| created << cli } + end + end + + def run_query(prompt = 'hello', query_options: options, **kwargs) + messages = [] + ClaudeAgentSDK.query(prompt: prompt, options: query_options, **kwargs) { |message| messages << message } + messages + end + + def user_message(content) + { type: 'user', message: { role: 'user', content: content }, parent_tool_use_id: nil } + end + + def seed_store + store.append({ 'project_key' => ClaudeAgentSDK.project_key_for_directory(cwd), 'session_id' => session_id }, + [{ 'type' => 'user', 'uuid' => SecureRandom.uuid, 'message' => { 'content' => 'hi' } }]) + end + + def resume_options(**extra) + ClaudeAgentSDK::ClaudeAgentOptions.new( + session_store: store, resume: session_id, cwd: cwd, env: { 'CLAUDE_CONFIG_DIR' => user_config_dir }, + observers: [observer], **extra + ) + end + + def materialized_dir?(dir) + dir != user_config_dir && File.basename(dir.to_s).start_with?('claude-resume-') + end + + # What the caller got from the call: :returned, or the exception it raised. + def outcome_of + yield + :returned + rescue Exception => e # rubocop:disable Lint/RescueException -- a deadline or a cancellation may be a bare Exception + e + end + + def expect_not_connected(client) + expect { client.query('again') }.to raise_error(ClaudeAgentSDK::CLIConnectionError, /Not connected/) + end + + describe 'L1: query() runs in a reactor of its own' do + it 'works with no reactor around it and inside one' do + stub_default_transport + + outside = run_query + inside = Sync { run_query } + + expected = [ClaudeAgentSDK::AssistantMessage, ClaudeAgentSDK::ResultMessage] + expect([outside.map(&:class), inside.map(&:class)]).to eq([expected, expected]) + end + + it 'Client.open brings a reactor of its own as well' do + stub_default_transport + seen = [] + + client_class.open(options: options) do |client| + client.query('hello') + client.receive_response { |message| seen << message.class } + end + + expect(seen).to eq([ClaudeAgentSDK::AssistantMessage, ClaudeAgentSDK::ResultMessage]) + expect(created.fetch(0)).to be_closed + end + end + + describe 'L2: a String prompt' do + it "query() writes one user message with session_id '' and closes stdin when the run is over" do + cli = fake_cli.new + + run_query('hello', transport: cli) + + expect(cli.user_writes).to eq([user_message('hello').merge(session_id: '')]) + expect(cli.lines.last).to end_with("}\n") + expect(cli).to be_input_ended + end + + it "Client#query writes session_id 'default', or the one it is given, and never closes stdin" do + stub_default_transport + + Sync do + client = client_class.new(options: options) + client.connect('first') + client.query('second') + client.query('third', session_id: 'abc') + client.receive_response { |_message| nil } + cli = created.fetch(0) + + expect(cli.user_writes).to eq([user_message('first').merge(session_id: 'default'), + user_message('second').merge(session_id: 'default'), + user_message('third').merge(session_id: 'abc')]) + expect(cli.lines.last(3)).to all(end_with("}\n")) + expect(cli).not_to be_input_ended + ensure + client&.disconnect + end + end + end + + describe 'L3: an Enumerable prompt' do + let(:stream) { [user_message('one').merge(session_id: ''), JSON.generate(user_message('two').merge(session_id: ''))] } + + it 'query() streams it in the background and closes stdin once it is exhausted' do + cli = fake_cli.new + + messages = run_query(stream.each, transport: cli) + + expect(cli.user_writes.map { |frame| frame.dig(:message, :content) }).to eq(%w[one two]) + expect(cli).to be_input_ended + expect(messages.grep(ClaudeAgentSDK::ResultMessage).length).to eq(2) + expect(observer.payloads(:on_user_prompt)).to eq(%w[one two]) + end + + it 'Client#connect streams it in the background and closes stdin once it is exhausted' do + stub_default_transport + + Sync do + client = client_class.new(options: options) + client.connect(stream.each) + client.receive_messages { |_message| nil } # the output ends with stdin + cli = created.fetch(0) + + expect(cli.user_writes.map { |frame| frame.dig(:message, :content) }).to eq(%w[one two]) + expect(cli).to be_input_ended + expect(observer.payloads(:on_user_prompt)).to eq(%w[one two]) + ensure + client&.disconnect + end + end + + it 'Client#query streams it inline, stamps the session_id on Hashes that lack one and leaves stdin open' do + stub_default_transport + + Sync do + client = client_class.new(options: options) + client.connect + client.query([user_message('one'), JSON.generate(user_message('two').merge(session_id: 'kept'))], session_id: 'abc') + cli = created.fetch(0) + + expect(cli.user_writes.map { |frame| [frame.dig(:message, :content), frame[:session_id]] }) + .to eq([%w[one abc], %w[two kept]]) + expect(cli).not_to be_input_ended + expect(observer.payloads(:on_user_prompt)).to eq(%w[one two]) + ensure + client&.disconnect + end + end + end + + describe 'L4: query() is one run' do + it 'writes the handshake and the prompt, and no other control request' do + cli = fake_cli.new + + run_query('hello', transport: cli) + + expect(cli.writes.map { |frame| frame.dig(:request, :subtype) || frame[:type] }).to eq(%w[initialize user]) + end + end + + describe 'L5: ask' do + def result_frame(turns) + EntryPointHarness::RESULT_FRAME.merge(num_turns: turns) + end + + it 'returns the last ResultMessage of the run' do + cli = fake_cli.new(replies: [EntryPointHarness::ASSISTANT_FRAME, result_frame(1), result_frame(2)]) + seen = [] + + result = ClaudeAgentSDK.ask('hello', options: options, transport: cli) { |message| seen << message.class } + + expect(result).to be_a(ClaudeAgentSDK::ResultMessage).and(have_attributes(num_turns: 2)) + expect(seen).to eq([ClaudeAgentSDK::AssistantMessage, ClaudeAgentSDK::ResultMessage, ClaudeAgentSDK::ResultMessage]) + end + + it 'raises CLIConnectionError when the run ends without one' do + cli = fake_cli.new(replies: [EntryPointHarness::ASSISTANT_FRAME]) + + expect { ClaudeAgentSDK.ask('hello', options: options, transport: cli) } + .to raise_error(ClaudeAgentSDK::CLIConnectionError, /without a result message/) + end + end + + describe 'L6: an injected transport' do + it 'is closed by query() when the run is over' do + cli = fake_cli.new + + run_query('hello', transport: cli) + + expect(cli).to be_closed + end + + it 'is closed by query() even when its #connect raised' do + cli = fake_cli.new(connect_error: IOError.new('no route')) + + expect { run_query('hello', transport: cli) }.to raise_error(IOError, 'no route') + expect(cli).to be_closed + end + end + + describe 'L7: query() without a block' do + it 'returns an Enumerator that runs the query when it is iterated', + rbs_incompatible: 'the checker samples the returned Enumerator, which runs the query before the example does' do + cli = fake_cli.new + + enumerator = ClaudeAgentSDK.query(prompt: 'hello', options: options, transport: cli) + + expect(enumerator).to be_an(Enumerator) + expect(cli.writes).to be_empty + expect(enumerator.map(&:class)).to eq([ClaudeAgentSDK::AssistantMessage, ClaudeAgentSDK::ResultMessage]) + end + + it 'validates the prompt before it returns', rbs_incompatible: 'passes out-of-signature input to test its rejection' do + expect { ClaudeAgentSDK.query(prompt: 42) }.to raise_error(ArgumentError, /got Integer/) + end + end + + describe 'L8: what observers are told when the session cannot be opened' do + it 'query() notifies on_error, then on_close, when the transport cannot connect' do + cli = fake_cli.new(connect_error: IOError.new('no route')) + + expect { run_query('hello', transport: cli) }.to raise_error(IOError, 'no route') + expect(observer.names).to eq(%i[on_error on_close]) + expect(observer.payloads(:on_error)).to match([have_attributes(class: IOError, message: 'no route')]) + end + + it 'query() notifies on_error, then on_close, when the handshake is rejected' do + cli = fake_cli.new(reject_initialize: true) + + expect { run_query('hello', transport: cli) }.to raise_error(StandardError, 'Invalid initialize request') + expect(observer.names).to eq(%i[on_error on_close]) + end + + it 'Client#connect notifies on_error alone when the transport cannot connect' do + client = client_class.new(options: options, + transport_class: fake_cli.foreign_class(created, connect_error: IOError.new('no route'))) + + Sync { expect { client.connect }.to raise_error(IOError, 'no route') } + expect(observer.names).to eq(%i[on_error]) + end + + it 'Client#connect notifies on_error alone when the handshake is rejected' do + client = client_class.new(options: options, transport_class: fake_cli.foreign_class(created, reject_initialize: true)) + + Sync { expect { client.connect }.to raise_error(StandardError, 'Invalid initialize request') } + expect(observer.names).to eq(%i[on_error]) + expect(created.fetch(0)).to be_closed + end + + it 'both notify on_close once the session did open' do + stub_default_transport + + run_query + Sync do + client = client_class.new(options: options) + client.connect + client.disconnect + end + + expect(observer.names).to eq(%i[on_user_prompt on_message on_message on_close on_close]) + end + end + + describe 'which error wins when the cleanup after a failed connect fails too' do + let(:connect_error) { IOError.new('no route') } + let(:close_error) { ArgumentError.new('close failed') } + + it 'Client#connect raises the connect error and warns about the cleanup error' do + transport_class = fake_cli.foreign_class(created, connect_error: connect_error, close_errors: [close_error]) + client = client_class.new(options: options, transport_class: transport_class) + + expect { Sync { expect { client.connect }.to raise_error(connect_error) } } + .to output(/cleanup after failed connect raised: close failed/).to_stderr + expect(observer.payloads(:on_error)).to eq([connect_error]) + expect_not_connected(client) + end + + it 'query() raises the cleanup error' do + cli = fake_cli.new(connect_error: connect_error, close_errors: [close_error]) + + expect { run_query('hello', transport: cli) }.to raise_error(close_error) + expect(observer.events).to eq([[:on_error, connect_error], [:on_close, nil]]) + end + end + + describe 'Client#disconnect when closing the query handler raises' do + it 'still closes the transport, resets the client and removes the materialized resume dir' do + seed_store + close_error = IOError.new('reap failed') + stub_default_transport(close_errors: [close_error]) # the first close is the handler's + + Sync do + client = client_class.new(options: resume_options) + client.connect + cli = created.fetch(0) + materialized = cli.config_dir + + expect(materialized_dir?(materialized)).to be(true) + expect { client.disconnect }.to raise_error(close_error) + expect(cli.close_calls).to eq(2) + expect(cli).to be_closed + expect(client.query_handler).to be_nil + expect_not_connected(client) + expect(File.exist?(materialized)).to be(false) + end + end + end + + # ClaudeAgentOptions#verbatim_prompts has a public writer, and the two entry + # points read it at different moments. Client reads it once while it + # connects: after the transport connected, before the Query is built. + # query() reads it there too (for the Query, which stamps streamed + # prompts), and once more for its String prompt, after on_user_prompt. + describe 'when verbatim_prompts is read' do + %i[thread inline].each do |scheduling| + context "with callback_scheduling: #{scheduling} and a callback_wrapper" do + let(:wrapped) { [] } + let(:wrapper) do + lambda do |invocation| + wrapped << true + invocation.call + end + end + let(:target) { {} } + # Turns the option on, on the options the caller passed, as soon as it + # is told about the prompt. + let(:flipping_observer) do + EntryPointHarness::RecordingObserver.new(on_user_prompt: ->(_prompt) { target.fetch(:options).verbatim_prompts = true }) + end + let(:flip_on_connect) { ->(cli) { cli.options.verbatim_prompts = true } } + + define_method(:mode_options) do |observers: [observer], **extra| + built = ClaudeAgentSDK::ClaudeAgentOptions.new(observers: observers, callback_scheduling: scheduling, + callback_wrapper: wrapper, **extra) + target[:options] = built + end + + def client_writes(client_options, connect_with: nil) + Sync do + client = client_class.new(options: client_options) + client.connect(connect_with) + yield client if block_given? + client.receive_response { |_message| nil } + created.fetch(0).user_writes + ensure + client&.disconnect + end + end + + it 'query() marks its String prompt when an observer turns the option on in on_user_prompt' do + stub_default_transport + + run_query('hello', query_options: mode_options(observers: [flipping_observer])) + + expect(created.fetch(0).user_writes).to match([a_hash_including(client_composed: true)]) + expect(wrapped).not_to be_empty + end + + it 'Client#query does not mark its String prompt when an observer turns the option on in on_user_prompt' do + stub_default_transport + + writes = client_writes(mode_options(observers: [flipping_observer])) { |client| client.query('hello') } + + expect(writes.length).to eq(1) + expect(writes.first).not_to have_key(:client_composed) + expect(wrapped).not_to be_empty + end + + it 'both mark their String prompt when the transport turns the option on while it connects' do + stub_default_transport(on_connect: flip_on_connect) + + run_query('hello', query_options: mode_options) + writes = client_writes(mode_options) { |client| client.query('hello') } + + expect(created.fetch(0).user_writes).to match([a_hash_including(client_composed: true)]) + expect(writes).to match([a_hash_including(client_composed: true)]) + end + + it 'both mark a streamed prompt when the transport turns the option on while it connects' do + stub_default_transport(on_connect: flip_on_connect) + + run_query([user_message('one')].each, query_options: mode_options) + Sync do + client = client_class.new(options: mode_options) + client.connect([user_message('one')].each) + client.receive_messages { |_message| nil } + ensure + client&.disconnect + end + + expect(created.map(&:user_writes)).to all(match([a_hash_including(client_composed: true)])) + end + + it 'neither marks a streamed prompt when an observer turns the option on in on_user_prompt' do + stub_default_transport + + run_query([user_message('one')].each, query_options: mode_options(observers: [flipping_observer])) + Sync do + client = client_class.new(options: mode_options(observers: [flipping_observer])) + client.connect([user_message('one')].each) + client.receive_messages { |_message| nil } + ensure + client&.disconnect + end + + expect(created.flat_map(&:user_writes).length).to eq(2) + expect(created.flat_map(&:user_writes)).to all(satisfy { |frame| !frame.key?(:client_composed) }) + end + + it 'neither marks its String prompt when the option is turned on after that' do + stub_default_transport + query_options = mode_options + late = EntryPointHarness::RecordingObserver.new(on_message: ->(_message) { query_options.verbatim_prompts = true }) + query_options.observers = [late] + + run_query('hello', query_options: query_options) + client_options = mode_options + writes = client_writes(client_options) do |client| + client_options.verbatim_prompts = true + client.query('hello') + end + + expect(query_options.verbatim_prompts).to be(true) + expect(created.fetch(0).user_writes.first).not_to have_key(:client_composed) + expect(writes.first).not_to have_key(:client_composed) + end + + # The session of a materialized store-backed resume runs on a copy of + # the options (repointed at the temp config dir), so what an observer + # does to the caller's options no longer reaches it. + context 'with a store-backed resume' do + define_method(:mode_resume_options) do |observers: [observer]| + resume_options(callback_scheduling: scheduling, callback_wrapper: wrapper) + .tap { |built| built.observers = observers } + .tap { |built| target[:options] = built } + end + + it 'query() does not mark its String prompt for an observer that turns the option on, once materialized' do + seed_store + stub_default_transport + + run_query('hello', query_options: mode_resume_options(observers: [flipping_observer])) + + cli = created.fetch(0) + expect(materialized_dir?(cli.config_dir)).to be(true) + expect(target.fetch(:options).verbatim_prompts).to be(true) + expect(cli.user_writes.length).to eq(1) + expect(cli.user_writes.first).not_to have_key(:client_composed) + expect(wrapped).not_to be_empty + end + + it 'query() marks it in the same run when nothing was materialized' do + stub_default_transport # the store has no such session: nothing to materialize + + run_query('hello', query_options: mode_resume_options(observers: [flipping_observer])) + + cli = created.fetch(0) + expect(cli.config_dir).to eq(user_config_dir) + expect(cli.user_writes).to match([a_hash_including(client_composed: true)]) + end + + it 'both mark their String prompt when the transport turns the option on, on the options it was given' do + seed_store + stub_default_transport(on_connect: flip_on_connect) + + run_query('hello', query_options: mode_resume_options) + writes = client_writes(mode_resume_options) { |client| client.query('hello') } + + expect(created.map { |cli| materialized_dir?(cli.config_dir) }).to eq([true, true]) + expect(target.fetch(:options).verbatim_prompts).to be(false) # the caller's options were not touched + expect(created.fetch(0).user_writes).to match([a_hash_including(client_composed: true)]) + expect(writes).to match([a_hash_including(client_composed: true)]) + end + end + end + end + end + + describe 'a setup that fails part-way' do + let(:connect_error) { IOError.new('no route') } + + it 'query() closes the transport it constructed when #connect raises' do + stub_default_transport(connect_error: connect_error) + + expect { run_query }.to raise_error(connect_error) + expect(created.fetch(0)).to be_closed + end + + it 'Client#connect closes the transport it constructed when #connect raises, and stays disconnected' do + stub_default_transport(connect_error: connect_error) + client = client_class.new(options: options) + + Sync { expect { client.connect }.to raise_error(connect_error) } + expect(created.fetch(0)).to be_closed + expect(client.query_handler).to be_nil + expect_not_connected(client) + end + + it 'query() removes the materialized resume dir when the transport cannot connect' do + seed_store + stub_default_transport(connect_error: connect_error) + + expect { run_query('hello', query_options: resume_options) }.to raise_error(connect_error) + expect(materialized_dir?(created.fetch(0).config_dir)).to be(true) + expect(File.exist?(created.fetch(0).config_dir)).to be(false) + end + + it 'Client#connect removes the materialized resume dir when the transport cannot connect' do + seed_store + stub_default_transport(connect_error: connect_error) + client = client_class.new(options: resume_options) + + Sync { expect { client.connect }.to raise_error(connect_error) } + expect(materialized_dir?(created.fetch(0).config_dir)).to be(true) + expect(File.exist?(created.fetch(0).config_dir)).to be(false) + end + + context 'when repointing the options at the materialized dir raises' do + let(:materialized_dirs) { [] } + + before do + seed_store + stub_default_transport + allow(ClaudeAgentSDK::SessionResume).to receive(:apply_materialized_options) do |_options, materialized| + materialized_dirs << materialized.config_dir + raise ArgumentError, 'cannot repoint' + end + end + + it 'query() removes the dir and constructs no transport' do + expect { run_query('hello', query_options: resume_options) }.to raise_error(ArgumentError, 'cannot repoint') + expect(materialized_dirs.length).to eq(1) + expect(File.exist?(materialized_dirs.first)).to be(false) + expect(created).to be_empty + expect(observer.names).to eq(%i[on_error on_close]) + end + + it 'Client#connect removes the dir and constructs no transport' do + client = client_class.new(options: resume_options) + + Sync { expect { client.connect }.to raise_error(ArgumentError, 'cannot repoint') } + expect(materialized_dirs.length).to eq(1) + expect(File.exist?(materialized_dirs.first)).to be(false) + expect(created).to be_empty + expect(observer.names).to eq(%i[on_error]) + end + end + end + + describe 'which transports get a materialized store-backed resume' do + before { seed_store } + + it 'the transport query() constructs: materialized, repointed, and removed afterwards' do + stub_default_transport + query_options = resume_options + + run_query('hello', query_options: query_options) + + cli = created.fetch(0) + expect(materialized_dir?(cli.config_dir)).to be(true) + expect(cli.options.resume).to eq(session_id) + expect(File.exist?(cli.config_dir)).to be(false) + expect(query_options.env).to eq('CLAUDE_CONFIG_DIR' => user_config_dir) # the caller's options are left alone + end + + it 'a SubprocessCLITransport (sub)class Client constructs: materialized, repointed, and removed on disconnect' do + client_options = resume_options + client = client_class.new(options: client_options, transport_class: fake_cli.subprocess_class(created)) + + Sync do + client.connect + cli = created.fetch(0) + + expect(materialized_dir?(cli.config_dir)).to be(true) + expect(cli.options.resume).to eq(session_id) + expect(File.directory?(cli.config_dir)).to be(true) + client.disconnect + expect(File.exist?(cli.config_dir)).to be(false) + expect(client_options.env).to eq('CLAUDE_CONFIG_DIR' => user_config_dir) + end + end + + it 'any other transport class Client constructs: not materialized, options handed over as they are' do + client_options = resume_options + client = client_class.new(options: client_options, transport_class: fake_cli.foreign_class(created)) + + Sync do + client.connect + expect(created.fetch(0).options).to be(client_options) + expect(created.fetch(0).config_dir).to eq(user_config_dir) + ensure + client.disconnect + end + end + + it 'a transport injected into query(): not materialized' do + cli = fake_cli.new + + expect(ClaudeAgentSDK::SessionResume).not_to receive(:materialize_resume_session) + run_query('hello', query_options: resume_options, transport: cli) + end + + # Async::Stop (reactor cancellation) is an Exception, not a StandardError. + it 'Client#connect removes the materialized dir when the transport #connect is cancelled, without notifying on_error' do + cancellation = Class.new(Exception) # rubocop:disable Lint/InheritException -- what a cancellation looks like + client = client_class.new(options: resume_options, + transport_class: fake_cli.subprocess_class(created, connect_error: cancellation.new)) + + Sync { expect { client.connect }.to raise_error(cancellation) } + expect(materialized_dir?(created.fetch(0).config_dir)).to be(true) + expect(File.exist?(created.fetch(0).config_dir)).to be(false) + expect(created.fetch(0)).to be_closed + expect(observer.names).to be_empty + expect_not_connected(client) + end + + # ClaudeAgentOptions fills env and load_timeout_ms with their defaults in + # the constructor only; set back to nil, they read as those defaults. + it 'Client#connect materializes with env and load_timeout_ms set back to nil' do + previous = ENV.fetch('CLAUDE_CONFIG_DIR', nil) + ENV['CLAUDE_CONFIG_DIR'] = user_config_dir + cleared = resume_options.dup_with(env: nil, load_timeout_ms: nil) + client = client_class.new(options: cleared, transport_class: fake_cli.subprocess_class(created)) + + Sync do + client.connect + cli = created.fetch(0) + + expect(materialized_dir?(cli.config_dir)).to be(true) + expect(cli.options.env).to eq('CLAUDE_CONFIG_DIR' => cli.config_dir) + expect(cli.options.resume).to eq(session_id) + expect(cleared.env).to be_nil + ensure + client.disconnect + end + ensure + previous.nil? ? ENV.delete('CLAUDE_CONFIG_DIR') : (ENV['CLAUDE_CONFIG_DIR'] = previous) + end + end + + # A dropped mirror batch means the store copy is incomplete and the + # materialized temp dir holds the only copy of those turns: it is kept + # (scrubbed of credentials) instead of deleted. + describe 'Client#disconnect and the materialized resume dir' do + let(:materialized) do + instance_double(ClaudeAgentSDK::MaterializedResume, cleanup: nil, preserve_transcripts: nil, + config_dir: '/nonexistent/claude-resume-x', + resume_session_id: session_id) + end + + def disconnect_after_connecting(dropped:) + handler = instance_double(ClaudeAgentSDK::Query, start: nil, initialize_protocol: nil, close: nil, + set_transcript_mirror_batcher: nil, + mirror_batches_dropped?: dropped) + allow(ClaudeAgentSDK::Query).to receive(:new).and_return(handler) + allow(ClaudeAgentSDK::SessionResume).to receive(:materialize_resume_session).and_return(materialized) + stub_default_transport + client = client_class.new(options: resume_options) + client.connect + client.disconnect + handler + end + + it 'preserves it when the mirror dropped batches' do + handler = disconnect_after_connecting(dropped: true) + + expect(handler).to have_received(:close).ordered + expect(materialized).to have_received(:preserve_transcripts).ordered + expect(materialized).not_to have_received(:cleanup) + expect(created.fetch(0).config_dir).to eq('/nonexistent/claude-resume-x') + end + + it 'removes it when none were dropped' do + disconnect_after_connecting(dropped: false) + + expect(materialized).to have_received(:cleanup) + expect(materialized).not_to have_received(:preserve_transcripts) + end + end + + describe 'Client: connecting again after a disconnect' do + it 'opens a new session on a new transport, with observers resolved again' do + stub_default_transport + resolved = [] + factory = -> { EntryPointHarness::RecordingObserver.new.tap { |built| resolved << built } } + client = client_class.new(options: ClaudeAgentSDK::ClaudeAgentOptions.new(observers: [factory])) + + Sync do + 2.times do |round| + client.connect + client.query("round #{round}") + client.receive_response { |_message| nil } + client.disconnect + expect(client.query_handler).to be_nil + end + end + + expect(created.map { |cli| cli.user_writes.map { |frame| frame.dig(:message, :content) } }) + .to eq([['round 0'], ['round 1']]) + expect(created).to all(be_closed) + expect(resolved.map(&:names)).to eq([%i[on_user_prompt on_message on_message on_close]] * 2) + end + + it 'does nothing when it is already connected' do + stub_default_transport + + Sync do + client = client_class.new(options: options) + client.connect + handler = client.query_handler + client.connect + + expect(created.length).to eq(1) + expect(client.query_handler).to be(handler) + ensure + client&.disconnect + end + end + end + + describe 'Client#disconnect from inside a callback' do + %i[thread inline].each do |scheduling| + it "from the message block ends the session; what was already read is delivered, then the iteration ends (#{scheduling})" do + stub_default_transport + client = client_class.new(options: ClaudeAgentSDK::ClaudeAgentOptions.new(observers: [observer], + callback_scheduling: scheduling)) + seen = [] + + Sync do + client.connect + client.query('hello') + client.receive_messages do |message| + seen << [message.class, client.query_handler.nil?] + client.disconnect if seen.length == 1 + end + end + + expect(seen).to eq([[ClaudeAgentSDK::AssistantMessage, false], [ClaudeAgentSDK::ResultMessage, true]]) + expect(created.fetch(0)).to be_closed + expect(client.query_handler).to be_nil + expect_not_connected(client) + expect(observer.names).to eq(%i[on_user_prompt on_message on_close on_message]) + end + end + + def permission_request + { type: 'control_request', request_id: 'req_close_probe', + request: { subtype: 'can_use_tool', tool_name: 'Bash', input: { command: 'true' } } } + end + + # What the callback saw the moment its disconnect returned or raised. + def teardown_seen_by(client, cli, handler, raised) + { raised: raised, transport_closed: cli.closed?, read_loop_ended: cli.read_loop_ended?, + handler_was_set: !handler.nil?, handler_now: client.query_handler } + end + + def disconnecting_can_use_tool(seen, done, client_ref) + lambda do |_tool, _input, _context| + client = client_ref.fetch(:client) + handler = client.query_handler + begin + client.disconnect + seen.merge!(teardown_seen_by(client, created.fetch(0), handler, nil)) + rescue Exception => e # rubocop:disable Lint/RescueException -- Async::Stop is not a StandardError + seen.merge!(teardown_seen_by(client, created.fetch(0), handler, e)) + ensure + done << true + end + ClaudeAgentSDK::PermissionResultAllow.new + end + end + + def run_permission_disconnect(scheduling) + seen = {} + done = Thread::Queue.new + client_ref = {} + client_options = ClaudeAgentSDK::ClaudeAgentOptions.new( + callback_scheduling: scheduling, can_use_tool: disconnecting_can_use_tool(seen, done, client_ref) + ) + client = client_ref[:client] = client_class.new(options: client_options, + transport_class: fake_cli.foreign_class(created)) + Async do + client.connect + created.fetch(0).inject(permission_request) + end.wait # ends only once the read loop and the callback's task are done + expect(done.pop(timeout: 5)).to be(true), 'the permission callback did not finish' + [client, seen] + end + + it 'from can_use_tool returns to the callback with the teardown complete (:thread)' do + client, seen = run_permission_disconnect(:thread) + + expect(seen).to eq(raised: nil, transport_closed: true, read_loop_ended: true, handler_was_set: true, + handler_now: nil) + expect(client.query_handler).to be_nil + expect_not_connected(client) + end + + it 'from can_use_tool completes the teardown, then unwinds the callback with Async::Stop (:inline)' do + client, seen = run_permission_disconnect(:inline) + + expect(seen).to match(raised: an_instance_of(Async::Stop), transport_closed: true, read_loop_ended: true, + handler_was_set: true, handler_now: nil) + expect(client.query_handler).to be_nil + expect_not_connected(client) + end + + it 'from a parked streaming-input enumerator completes the teardown before the stream unwinds' do + seen = {} + done = Thread::Queue.new + gate = Thread::Queue.new + client = client_class.new(options: ClaudeAgentSDK::ClaudeAgentOptions.new, + transport_class: fake_cli.foreign_class(created)) + stream = Enumerator.new do |yielder| + yielder << user_message('hi').merge(session_id: 'default') + gate.pop # parks the stream's task; connect returns + handler = client.query_handler + begin + client.disconnect + seen.merge!(teardown_seen_by(client, created.fetch(0), handler, nil)) + rescue Exception => e # rubocop:disable Lint/RescueException -- Async::Stop is not a StandardError + seen.merge!(teardown_seen_by(client, created.fetch(0), handler, e)) + ensure + done << true + end + yielder << user_message('never sent').merge(session_id: 'default') + end + + Async do + client.connect(stream) + gate << true + end.wait # ends only once the read loop and the stream's task are done + expect(done.pop(timeout: 5)).to be(true), 'the stream never reached disconnect' + + expect(seen).to match(raised: an_instance_of(Async::Stop), transport_closed: true, read_loop_ended: true, + handler_was_set: true, handler_now: nil) + expect(created.fetch(0).user_writes.map { |frame| frame.dig(:message, :content) }).to eq(['hi']) + expect_not_connected(client) + end + end + + # on_error is notified per failing operation, not once per session: a + # Client#query whose write fails inside a receive_messages block is notified + # by #query, and again by the #receive_messages it then unwinds. + describe 'Client: an error raised by #query inside a #receive_messages block' do + it 'notifies on_error twice, with the same error' do + write_error = ClaudeAgentSDK::CLIConnectionError.new('stdin is gone') + fail_second = ->(frame, cli) { raise write_error if frame[:type] == 'user' && cli.user_writes.length == 1 } + stub_default_transport(on_write: fail_second) + + Sync do + client = client_class.new(options: options) + client.connect + client.query('hello') + + expect { client.receive_messages { |_message| client.query('again') } }.to raise_error(write_error) + expect(observer.payloads(:on_error)).to eq([write_error, write_error]) + ensure + client&.disconnect + end + end + end + + # A failing Client#connect notifies on_error BEFORE it tears the partial + # session down. A deadline of the caller's that expires while the observer + # runs must not skip the teardown. What reaches the caller differs by + # scheduling: with :thread the caller's fiber waits for the observer's + # thread, and the expiry propagates; with :inline the observer runs on the + # caller's fiber, so an ordinary expiry lands inside the observer and is + # contained with the observer's own failures, and only a cancellation that + # is not a StandardError gets through. + # + # The observer signals that it is running with the handshake rejection, and + # only then is the deadline delivered: slow setup cannot use it up. + describe 'a failing Client#connect when the caller is interrupted during on_error' do + let(:entered) { Thread::Queue.new } + let(:release) { Thread::Queue.new } + let(:blocking_observer) do + EntryPointHarness::RecordingObserver.new( + on_error: lambda do |error| + next unless error.message.include?('Invalid initialize request') + + entered << true + release.pop + end + ) + end + + after { release.close } + + def failing_client(scheduling) + seed_store + client_options = resume_options(callback_scheduling: scheduling) + client_options.observers = [blocking_observer] + client_class.new(options: client_options, transport_class: fake_cli.subprocess_class(created, reject_initialize: true)) + end + + # Runs connect on a task of its own and delivers the deadline to it once + # on_error is running. Returns what connect did. + def interrupted_connect(client, exception_class) + Sync do |task| + outcome = nil + caller_task = task.async { outcome = outcome_of { client.connect } } + entered.pop + EntryPointHarness.expire_deadline_on(caller_task, exception_class) + caller_task.wait + outcome + ensure + release.close + end + end + + # The gate of every example here: on_error ran, with the handshake rejection. + def expect_on_error_reached + expect(blocking_observer.events).to match([[:on_error, have_attributes(class: StandardError, + message: 'Invalid initialize request')]]) + end + + def expect_torn_down(client) + cli = created.fetch(0) + aggregate_failures do + expect(cli).to be_closed + expect(cli).to be_read_loop_ended + expect(materialized_dir?(cli.config_dir)).to be(true) + expect(File.exist?(cli.config_dir)).to be(false) + expect(client.query_handler).to be_nil + expect_not_connected(client) + end + end + + it 'tears the partial session down, then raises the expired deadline (:thread)' do + client = failing_client(:thread) + + outcome = interrupted_connect(client, Async::TimeoutError) + + expect_on_error_reached + expect(outcome).to be_a(Async::TimeoutError) + expect_torn_down(client) + end + + it 'tears the partial session down, then raises an inline cooperative cancellation (:inline)' do + client = failing_client(:inline) + cancellation = Class.new(ClaudeAgentSDK::FiberBoundary::InlineCancellation) + + outcome = interrupted_connect(client, cancellation) + + expect_on_error_reached + expect(outcome).to be_a(cancellation) + expect_torn_down(client) + end + + it 'tears the partial session down and raises the connect error while the observer contains the deadline ' \ + '(:inline, residue)' do + client = failing_client(:inline) + + outcome = interrupted_connect(client, Async::TimeoutError) + + expect_on_error_reached + expect(outcome).to be_a(StandardError).and(have_attributes(message: 'Invalid initialize request')) + expect_torn_down(client) + end + end +end diff --git a/spec/unit/entry_point_parity_spec.rb b/spec/unit/entry_point_parity_spec.rb new file mode 100644 index 00000000..5bbdd237 --- /dev/null +++ b/spec/unit/entry_point_parity_spec.rb @@ -0,0 +1,392 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'async' +require 'fileutils' +require 'securerandom' +require 'timeout' +require 'tmpdir' + +# ClaudeAgentSDK.query and ClaudeAgentSDK::Client run the same session: the +# difference between them is lifecycle, not protocol. This runs one scenario +# through both — query() on one side, Client.open + #query + #receive_messages +# + disconnect on the other — against the same in-memory CLI, the same +# store-backed resume and the same kind of recording observer, and compares +# what each did: what the caller got, what the block was given, what the +# observer was told and what became of the materialized resume dir. +# +# They must agree on all of it, in every scenario, under both callback +# scheduling modes. Where they are meant to differ is spelled out at the end, +# and nowhere else. +RSpec.describe 'entry point parity' do + let(:fake_cli) { EntryPointHarness::FakeCLI } + let(:created) { [] } + let(:disposals) { [] } + let(:preserved) { [] } + let(:wrapped) { [] } + let(:session_tasks) { [] } + let(:block_error) { Class.new(StandardError) } + + let(:cwd) { Dir.mktmpdir('parity-cwd-') } + let(:user_config_dir) { Dir.mktmpdir('parity-config-') } # keeps the developer's own config out of it + let(:session_id) { SecureRandom.uuid } + let(:store) do + ClaudeAgentSDK::InMemorySessionStore.new.tap do |store| + store.append({ 'project_key' => ClaudeAgentSDK.project_key_for_directory(cwd), 'session_id' => session_id }, + [{ 'type' => 'user', 'uuid' => SecureRandom.uuid, 'message' => { 'content' => 'hi' } }]) + end + end + + # A regression here tends to be a wait that never ends; fail instead. + around do |example| + Timeout.timeout(30) { example.run } + end + + # Record what becomes of every materialized resume dir: removed (:cleanup) + # or kept because the mirror dropped batches (:preserve; not carried out). + before do + allow(ClaudeAgentSDK::SessionResume).to receive(:materialize_resume_session).and_wrap_original do |original, options| + original.call(options).tap do |materialized| + allow(materialized).to receive(:cleanup).and_wrap_original do |cleanup| + disposals << :cleanup + cleanup.call + end + allow(materialized).to receive(:preserve_transcripts) do + disposals << :preserve + preserved << materialized.config_dir + end + end + end + end + + after do + [cwd, user_config_dir, *preserved].each { |dir| FileUtils.remove_entry(dir) if File.directory?(dir) } + end + + # Both entry points construct their transport from SubprocessCLITransport + # (so both materialize the resume): hand them a FakeCLI instead. + def stub_transport(**switches) + note_session_task = ->(_cli) { session_tasks << Async::Task.current } + allow(ClaudeAgentSDK::SubprocessCLITransport).to receive(:new) do |transport_options| + fake_cli.new(transport_options, on_connect: note_session_task, **switches).tap { |cli| created << cli } + end + end + + def result_frame(turns) + EntryPointHarness::RESULT_FRAME.merge(num_turns: turns) + end + + # What the caller got from the call: [:returned, value], or the error it + # raised (every scenario here fails with a StandardError, the deadline's + # Async::TimeoutError included). + def outcome_of + [:returned, yield] + rescue StandardError => e + e + end + + shared_examples 'the same session through both entry points' do |scheduling| + let(:wrapper) do + lambda do |invocation| + wrapped << true + invocation.call + end + end + + define_method(:options_with) do |observer| + ClaudeAgentSDK::ClaudeAgentOptions.new( + session_store: store, resume: session_id, cwd: cwd, env: { 'CLAUDE_CONFIG_DIR' => user_config_dir }, + observers: [observer], callback_scheduling: scheduling, callback_wrapper: wrapper + ) + end + + # One run through an entry point, reduced to what must not differ. + def trace(observer, delivered, outcome) + { outcome: outcome.is_a?(Exception) ? [outcome.class, outcome.message] : outcome, + delivered: delivered.map(&:class), + notified: observer.names, + on_message: observer.payloads(:on_message).map(&:class), + errors: observer.payloads(:on_error).map(&:class), + disposal: disposals.last } + end + + # query(), with a block that reacts to each message with +reaction+. + def one_shot(reaction = :continue, **observer_reactions) + observer = EntryPointHarness::RecordingObserver.new(**observer_reactions) + delivered = [] + error = block_error + outcome = outcome_of do + ClaudeAgentSDK.query(prompt: 'hello', options: options_with(observer)) do |message| + delivered << message + raise error, 'block boom' if reaction == :raise + break :stopped if reaction == :break + end + end + trace(observer, delivered, outcome) + end + + # Client.open + #query + #receive_messages (+ the disconnect of .open), + # with the same block. + def session(reaction = :continue, **observer_reactions) + observer = EntryPointHarness::RecordingObserver.new(**observer_reactions) + delivered = [] + error = block_error + outcome = outcome_of do + ClaudeAgentSDK::Client.open(options: options_with(observer)) do |client| + client.query('hello') + client.receive_messages do |message| + delivered << message + raise error, 'block boom' if reaction == :raise + break :stopped if reaction == :break + end + end + end + trace(observer, delivered, outcome) + end + + it 'a run that completes' do + stub_transport(hang_up_after: 1) + + expect(session).to eq(one_shot) + expect(one_shot).to include( + outcome: [:returned, nil], + delivered: [ClaudeAgentSDK::AssistantMessage, ClaudeAgentSDK::ResultMessage], + notified: %i[on_user_prompt on_message on_message on_close], + on_message: [ClaudeAgentSDK::AssistantMessage, ClaudeAgentSDK::ResultMessage], + disposal: :cleanup + ) + expect(created).to all(be_closed) + expect(wrapped).not_to be_empty + end + + it 'a run with several results: every message is delivered' do + stub_transport(replies: [EntryPointHarness::ASSISTANT_FRAME, result_frame(1), EntryPointHarness::ASSISTANT_FRAME, + result_frame(2)], hang_up_after: 1) + + expect(session).to eq(one_shot) + expect(one_shot[:delivered]).to eq([ClaudeAgentSDK::AssistantMessage, ClaudeAgentSDK::ResultMessage] * 2) + end + + it 'on_user_prompt is notified before the prompt is written' do + order = [] + stub_transport(hang_up_after: 1, on_write: ->(frame, _cli) { order << :written if frame[:type] == 'user' }) + noting = { on_user_prompt: ->(_prompt) { order << :notified } } + + one_shot(**noting) + session(**noting) + + expect(order).to eq(%i[notified written notified written]) + end + + it 'a stream that dies after one message' do + stub_transport(replies: [EntryPointHarness::ASSISTANT_FRAME, ClaudeAgentSDK::CLIConnectionError.new('CLI crashed')]) + + expect(session).to eq(one_shot) + expect(one_shot).to include( + outcome: [ClaudeAgentSDK::CLIConnectionError, 'CLI crashed'], + delivered: [ClaudeAgentSDK::AssistantMessage], + notified: %i[on_user_prompt on_message on_error on_close], + errors: [ClaudeAgentSDK::CLIConnectionError], + disposal: :cleanup + ) + end + + it 'a block that raises' do + stub_transport(hang_up_after: 1) + + expect(session(:raise)).to eq(one_shot(:raise)) + expect(one_shot(:raise)).to include( + outcome: [block_error, 'block boom'], + delivered: [ClaudeAgentSDK::AssistantMessage], + notified: %i[on_user_prompt on_message on_error on_close], + errors: [block_error], + disposal: :cleanup + ) + end + + it 'a block that breaks: the value of the break is returned, and no error is notified' do + stub_transport(hang_up_after: 1) + + one_shot_trace = one_shot(:break) + session_trace = session(:break) + + expect(session_trace.except(:outcome)).to eq(one_shot_trace.except(:outcome)) + expect(one_shot_trace).to include(outcome: %i[returned stopped], delivered: [ClaudeAgentSDK::AssistantMessage], + notified: %i[on_user_prompt on_message on_close], disposal: :cleanup) + # Client.open returns the value of its own block: #receive_messages' value. + expect(session_trace[:outcome]).to eq(%i[returned stopped]) + end + + it 'a mirror that dropped batches: the materialized resume dir is preserved' do + stub_transport(hang_up_after: 1) + dropping = Struct.new(:dropped) do + def enqueue(*); end + def flush; end + def close; end + def batches_dropped? = dropped + end + allow(ClaudeAgentSDK::SessionResume).to receive(:build_mirror_batcher).and_return(dropping.new(true)) + + expect(session).to eq(one_shot) + expect(disposals).to eq(%i[preserve preserve]) + end + + # A deadline that expires on the task driving the session while it is in + # on_message. Delivered once the observer has signalled that it is + # running, the way an expired task.with_timeout delivers it. With :thread + # the task waits for the observer's thread and the expiry surfaces as an + # error of the run; with :inline it lands inside the observer, which + # contains it, and the run carries on. + describe 'an observer interrupted by a deadline' do + def interrupted + entered = Thread::Queue.new + release = Thread::Queue.new + blocking = { on_message: lambda do |_message| + next if entered.closed? + + entered << true + entered.close + release.pop + end } + Sync do |task| + result = nil + runner = task.async { result = yield blocking } + raise 'on_message was never entered' unless entered.pop(timeout: 5) + + EntryPointHarness.expire_deadline_on(session_tasks.last) + runner.wait + result + ensure + release.close + end + end + + it 'does the same in both' do + stub_transport(hang_up_after: 1) + + one_shot_trace = interrupted { |blocking| one_shot(**blocking) } + session_trace = interrupted { |blocking| session(**blocking) } + + expect(session_trace).to eq(one_shot_trace) + expect(one_shot_trace).to include(disposal: :cleanup) + expect(created).to all(be_closed) + if scheduling == :thread + expect(one_shot_trace).to include(outcome: [Async::TimeoutError, 'execution expired'], + delivered: [], + notified: %i[on_user_prompt on_message on_error on_close], + errors: [Async::TimeoutError]) + else + expect(one_shot_trace).to include(outcome: [:returned, nil], + delivered: [ClaudeAgentSDK::AssistantMessage, ClaudeAgentSDK::ResultMessage], + notified: %i[on_user_prompt on_message on_message on_close], + errors: []) + end + end + end + + # The differences that are meant to be there: the lifecycle each entry + # point documents. Everything above holds apart from these. + describe 'the expected differences' do + it "session_id on the wire: '' from query(), 'default' from Client#query" do + stub_transport(hang_up_after: 1) + + one_shot + session + + expect(created.map { |cli| cli.user_writes.map { |frame| frame[:session_id] } }).to eq([[''], ['default']]) + end + + it 'L2: stdin is closed after the prompt by query() only' do + stub_transport(hang_up_after: 1) + + one_shot + session + + expect(created.map(&:input_ended?)).to eq([true, false]) + end + + it 'L8: on_close follows on_error after a failed connect from query() only' do + stub_transport(connect_error: IOError.new('no route')) + + one_shot_trace = one_shot + session_trace = session + + expect(one_shot_trace).to include(outcome: [IOError, 'no route'], notified: %i[on_error on_close]) + expect(session_trace).to include(outcome: [IOError, 'no route'], notified: %i[on_error]) + expect(session_trace.except(:notified)).to eq(one_shot_trace.except(:notified)) + expect(disposals).to eq(%i[cleanup cleanup]) + end + + it 'a close that raises after a failed connect: query() raises the close error, Client the connect error' do + allow(ClaudeAgentSDK::SubprocessCLITransport).to receive(:new) do |transport_options| + fake_cli.new(transport_options, connect_error: IOError.new('no route'), + close_errors: [ArgumentError.new('close failed')]).tap { |cli| created << cli } + end + + one_shot_trace = one_shot + session_trace = nil + expect { session_trace = session }.to output(/cleanup after failed connect raised: close failed/).to_stderr + + expect(one_shot_trace[:outcome]).to eq([ArgumentError, 'close failed']) + expect(session_trace[:outcome]).to eq([IOError, 'no route']) + expect(disposals).to eq(%i[cleanup cleanup]) + end + + it 'a query handler whose close raises: both raise it, and only Client closes the transport again' do + allow(ClaudeAgentSDK::SubprocessCLITransport).to receive(:new) do |transport_options| + fake_cli.new(transport_options, hang_up_after: 1, close_errors: [IOError.new('reap failed')]) + .tap { |cli| created << cli } + end + + one_shot_trace = one_shot + session_trace = session + + expect(session_trace).to eq(one_shot_trace) + expect(one_shot_trace).to include(outcome: [IOError, 'reap failed'], disposal: :cleanup) + expect(created.map(&:close_calls)).to eq([1, 2]) + expect(created.map(&:closed?)).to eq([false, true]) + end + end + end + + %i[thread inline].each do |scheduling| + context "with callback_scheduling: #{scheduling} and a callback_wrapper" do + it_behaves_like 'the same session through both entry points', scheduling + end + end + + # Client#receive_response and ask are not counterparts of anything on the + # other side: one stops at the first result, the other returns the last. + describe 'the two ways to wait for a result' do + let(:options) { ClaudeAgentSDK::ClaudeAgentOptions.new } + + before do + stub_transport(replies: [EntryPointHarness::ASSISTANT_FRAME, result_frame(1), EntryPointHarness::ASSISTANT_FRAME, + result_frame(2)], hang_up_after: 1) + end + + it 'Client#receive_response stops at the first ResultMessage, and the rest is still there to receive' do + first = [] + rest = [] + + ClaudeAgentSDK::Client.open(options: options) do |client| + client.query('hello') + client.receive_response { |message| first << message } + client.receive_messages { |message| rest << message } + end + + expect(first.map(&:class)).to eq([ClaudeAgentSDK::AssistantMessage, ClaudeAgentSDK::ResultMessage]) + expect(first.last.num_turns).to eq(1) + expect(rest.map(&:class)).to eq([ClaudeAgentSDK::AssistantMessage, ClaudeAgentSDK::ResultMessage]) + end + + it 'ask consumes the whole run and returns the last ResultMessage' do + seen = [] + + result = ClaudeAgentSDK.ask('hello', options: options) { |message| seen << message.class } + + expect(result.num_turns).to eq(2) + expect(seen).to eq([ClaudeAgentSDK::AssistantMessage, ClaudeAgentSDK::ResultMessage] * 2) + end + end +end diff --git a/spec/unit/query_api_spec.rb b/spec/unit/query_api_spec.rb index 8d6cd900..2d9c2c9a 100644 --- a/spec/unit/query_api_spec.rb +++ b/spec/unit/query_api_spec.rb @@ -55,120 +55,35 @@ end end - it 'passes hooks into the control protocol for one-shot queries' do + # The one example here that runs a real Query: what query() is given + # reaches the control protocol and the wire, and the run is torn down. + # Which option becomes which Query keyword is spelled out once, for both + # entry points, in session_assembly_spec.rb. + it 'hands its options to the control protocol, writes the prompt and tears the session down' do hook_fn = ->(_input, _tool_use_id, _context) { {} } - matcher = ClaudeAgentSDK::HookMatcher.new(matcher: 'Bash', hooks: [hook_fn], timeout: 30) options = ClaudeAgentSDK::ClaudeAgentOptions.new( - hooks: { 'PostToolUse' => nil, 'Stop' => [], PreToolUse: [matcher] } + hooks: { 'PostToolUse' => nil, 'Stop' => [], + PreToolUse: [ClaudeAgentSDK::HookMatcher.new(matcher: 'Bash', hooks: [hook_fn], timeout: 30)] }, + agents: { 'reviewer' => ClaudeAgentSDK::AgentDefinition.new(description: 'Reviews', prompt: 'Review it') }, + system_prompt: { type: 'preset', preset: 'claude_code', exclude_dynamic_sections: true, snapshot: false }, + skills: %w[pdf], forward_subagent_text: true, agent_progress_summaries: false ) + cli = EntryPointHarness::FakeCLI.new + messages = [] - writes = [] - transport = instance_double(ClaudeAgentSDK::SubprocessCLITransport, connect: true, close: nil, end_input: nil) - allow(transport).to receive(:write) { |payload| writes << JSON.parse(payload, symbolize_names: true) } - - captured_query_args = nil - query_handler = instance_double( - ClaudeAgentSDK::Query, - start: true, - initialize_protocol: nil, - wait_for_result_and_end_input: nil, - close: nil - ) - allow(query_handler).to receive(:receive_messages) - allow(query_handler).to receive(:spawn_task) { |&blk| blk.call } - - allow(ClaudeAgentSDK::SubprocessCLITransport).to receive(:new).and_return(transport) - allow(ClaudeAgentSDK::Query).to receive(:new) do |**kwargs| - captured_query_args = kwargs - query_handler - end - - Async do - described_class.query(prompt: 'hello', options: options) { |_message| nil } - end.wait - - expect(captured_query_args[:hooks]).to eq( - 'PreToolUse' => [ - { - matcher: 'Bash', - hooks: [hook_fn], - timeout: 30 - } - ] - ) - expect(query_handler).to have_received(:wait_for_result_and_end_input) - expect(writes.first[:session_id]).to eq('') - end - - it 'passes nil hooks when all matcher lists are empty' do - options = ClaudeAgentSDK::ClaudeAgentOptions.new( - hooks: { 'PreToolUse' => [], 'PostToolUse' => nil } - ) - - captured_query_args = nil - transport = instance_double(ClaudeAgentSDK::SubprocessCLITransport, connect: true, close: nil, end_input: nil) - allow(transport).to receive(:write) - - query_handler = instance_double( - ClaudeAgentSDK::Query, - start: true, - initialize_protocol: nil, - wait_for_result_and_end_input: nil, - close: nil - ) - allow(query_handler).to receive(:receive_messages) - allow(query_handler).to receive(:spawn_task) { |&blk| blk.call } - - allow(ClaudeAgentSDK::SubprocessCLITransport).to receive(:new).and_return(transport) - allow(ClaudeAgentSDK::Query).to receive(:new) do |**kwargs| - captured_query_args = kwargs - query_handler - end - - Async do - described_class.query(prompt: 'hello', options: options) { |_message| nil } - end.wait - - expect(captured_query_args[:hooks]).to be_nil - end - - it 'configures can_use_tool for streaming one-shot queries' do - callback = ->(_tool_name, _input, _context) { ClaudeAgentSDK::PermissionResultAllow.new } - options = ClaudeAgentSDK::ClaudeAgentOptions.new(can_use_tool: callback) - prompt = [ClaudeAgentSDK::Streaming.user_message('hello')].to_enum - - captured_options = nil - captured_query_args = nil - - transport = instance_double(ClaudeAgentSDK::SubprocessCLITransport, connect: true, close: nil, end_input: nil) - allow(transport).to receive(:write) + described_class.query(prompt: 'hello', options: options, transport: cli) { |message| messages << message } - query_handler = instance_double( - ClaudeAgentSDK::Query, - start: true, - initialize_protocol: nil, - stream_input: nil, - close: nil + expect(cli.writes.first[:request]).to eq( + subtype: 'initialize', + hooks: { PreToolUse: [{ matcher: 'Bash', hookCallbackIds: ['hook_0'], timeout: 30 }] }, + agents: { reviewer: { description: 'Reviews', prompt: 'Review it' } }, + excludeDynamicSections: true, systemPromptSnapshot: false, skills: ['pdf'], forwardSubagentText: true, + agentProgressSummaries: false ) - allow(query_handler).to receive(:receive_messages) - allow(query_handler).to receive(:spawn_task) { |&blk| blk.call } - - allow(ClaudeAgentSDK::SubprocessCLITransport).to receive(:new) do |opts| - captured_options = opts - transport - end - allow(ClaudeAgentSDK::Query).to receive(:new) do |**kwargs| - captured_query_args = kwargs - query_handler - end - - Async do - described_class.query(prompt: prompt, options: options) { |_message| nil } - end.wait - - expect(captured_options.permission_prompt_tool_name).to eq('stdio') - expect(captured_query_args[:can_use_tool]).to eq(callback) - expect(query_handler).to have_received(:stream_input).with(prompt) + prompt = { type: 'user', message: { role: 'user', content: 'hello' }, parent_tool_use_id: nil, session_id: '' } + expect(cli.lines.last).to eq("#{JSON.generate(prompt)}\n") + expect(messages.map(&:class)).to eq([ClaudeAgentSDK::AssistantMessage, ClaudeAgentSDK::ResultMessage]) + expect(cli).to be_input_ended.and(be_closed) end it 'propagates a read-loop failure instead of hanging when streaming input is still blocked' do @@ -251,63 +166,18 @@ def read_messages end context 'with a custom transport' do - def fake_streaming_transport(writes) - Class.new do - define_method(:initialize) do - @incoming = Async::Queue.new - @writes = writes - end - def connect; end - def end_input; end - - def close - @closed = true - end - - def closed? - !!@closed - end - - def write(data) - @writes << data - msg = JSON.parse(data, symbolize_names: true) - return unless msg[:type] == 'control_request' && msg.dig(:request, :subtype) == 'initialize' - - @incoming.enqueue( - type: 'control_response', - response: { subtype: 'success', request_id: msg[:request_id], response: {} } - ) - @incoming.enqueue(type: 'result', subtype: 'success', is_error: false, duration_ms: 1, - duration_api_ms: 1, num_turns: 1, session_id: 's', total_cost_usd: 0) - @incoming.enqueue(:end) - end - - def read_messages - loop do - msg = @incoming.dequeue - break if msg == :end - - yield msg - end - end - end.new - end - it 'uses the injected transport and never constructs SubprocessCLITransport' do - writes = [] - fake = fake_streaming_transport(writes) + fake = EntryPointHarness::FakeCLI.new expect(ClaudeAgentSDK::SubprocessCLITransport).not_to receive(:new) described_class.query(prompt: 'hello', transport: fake) { |_m| nil } expect(fake.closed?).to be(true) - user_frame = writes.map { |w| JSON.parse(w, symbolize_names: true) }.find { |m| m[:type] == 'user' } - expect(user_frame.dig(:message, :content)).to eq('hello') + expect(fake.user_writes.map { |frame| frame.dig(:message, :content) }).to eq(['hello']) end it 'skips resume materialization when a transport is injected' do - writes = [] - fake = fake_streaming_transport(writes) + fake = EntryPointHarness::FakeCLI.new expect(ClaudeAgentSDK::SessionResume).not_to receive(:materialize_resume_session) options = ClaudeAgentSDK::ClaudeAgentOptions.new( @@ -505,142 +375,4 @@ def permission_verdicts(state) .to raise_error(ArgumentError, /got NilClass/) end end - - # Regression (M7): query() built its Query handler without the - # exclude_dynamic_sections kwarg, so excludeDynamicSections never reached - # the initialize request — Client and Python both send it. - it 'passes exclude_dynamic_sections from a preset system prompt to the control protocol' do - options = ClaudeAgentSDK::ClaudeAgentOptions.new( - system_prompt: { type: 'preset', preset: 'claude_code', exclude_dynamic_sections: true } - ) - - captured_query_args = nil - transport = instance_double(ClaudeAgentSDK::SubprocessCLITransport, connect: true, close: nil, end_input: nil) - allow(transport).to receive(:write) - - query_handler = instance_double( - ClaudeAgentSDK::Query, - start: true, - initialize_protocol: nil, - wait_for_result_and_end_input: nil, - close: nil - ) - allow(query_handler).to receive(:receive_messages) - allow(query_handler).to receive(:spawn_task) { |&blk| blk.call } - - allow(ClaudeAgentSDK::SubprocessCLITransport).to receive(:new).and_return(transport) - allow(ClaudeAgentSDK::Query).to receive(:new) do |**kwargs| - captured_query_args = kwargs - query_handler - end - - Async do - described_class.query(prompt: 'hello', options: options) { |_message| nil } - end.wait - - expect(captured_query_args[:exclude_dynamic_sections]).to be(true) - end - - # Python #1268: query() hands snapshot to Query only for the preset and - # custom forms, and a false value survives the trip. - { - [{ type: 'custom', prompt: 'Be helpful', snapshot: false }] => false, - [{ type: 'preset', preset: 'claude_code', snapshot: true }] => true, - [{ type: 'preset', preset: 'claude_code' }] => nil, - [{ type: 'file', path: '/p.md', snapshot: false }] => nil, - ['Be helpful'] => nil - }.each do |(system_prompt), expected| - it "passes system_prompt_snapshot #{expected.inspect} for #{system_prompt.inspect} to the control protocol" do - options = ClaudeAgentSDK::ClaudeAgentOptions.new(system_prompt: system_prompt) - - captured_query_args = nil - transport = instance_double(ClaudeAgentSDK::SubprocessCLITransport, connect: true, close: nil, end_input: nil) - allow(transport).to receive(:write) - - query_handler = instance_double( - ClaudeAgentSDK::Query, - start: true, - initialize_protocol: nil, - wait_for_result_and_end_input: nil, - close: nil - ) - allow(query_handler).to receive(:receive_messages) - allow(query_handler).to receive(:spawn_task) { |&blk| blk.call } - - allow(ClaudeAgentSDK::SubprocessCLITransport).to receive(:new).and_return(transport) - allow(ClaudeAgentSDK::Query).to receive(:new) do |**kwargs| - captured_query_args = kwargs - query_handler - end - - Async do - described_class.query(prompt: 'hello', options: options) { |_message| nil } - end.wait - - expect(captured_query_args.fetch(:system_prompt_snapshot)).to be(expected) - end - end - - it 'passes forward_subagent_text from the options to the control protocol' do - transport = instance_double(ClaudeAgentSDK::SubprocessCLITransport, connect: true, close: nil, end_input: nil) - allow(transport).to receive(:write) - - query_handler = instance_double( - ClaudeAgentSDK::Query, - start: true, - initialize_protocol: nil, - wait_for_result_and_end_input: nil, - close: nil - ) - allow(query_handler).to receive(:receive_messages) - allow(query_handler).to receive(:spawn_task) { |&blk| blk.call } - allow(ClaudeAgentSDK::SubprocessCLITransport).to receive(:new).and_return(transport) - - [true, false].each do |enabled| - captured_query_args = nil - allow(ClaudeAgentSDK::Query).to receive(:new) do |**kwargs| - captured_query_args = kwargs - query_handler - end - - options = ClaudeAgentSDK::ClaudeAgentOptions.new(forward_subagent_text: enabled) - Async do - described_class.query(prompt: 'hello', options: options) { |_message| nil } - end.wait - - expect(captured_query_args[:forward_subagent_text]).to be(enabled) - end - end - - it 'passes agent_progress_summaries from the options to the control protocol, preserving nil vs false' do - transport = instance_double(ClaudeAgentSDK::SubprocessCLITransport, connect: true, close: nil, end_input: nil) - allow(transport).to receive(:write) - - query_handler = instance_double( - ClaudeAgentSDK::Query, - start: true, - initialize_protocol: nil, - wait_for_result_and_end_input: nil, - close: nil - ) - allow(query_handler).to receive(:receive_messages) - allow(query_handler).to receive(:spawn_task) { |&blk| blk.call } - allow(ClaudeAgentSDK::SubprocessCLITransport).to receive(:new).and_return(transport) - - [true, false, nil].each do |value| - captured_query_args = nil - allow(ClaudeAgentSDK::Query).to receive(:new) do |**kwargs| - captured_query_args = kwargs - query_handler - end - - options = ClaudeAgentSDK::ClaudeAgentOptions.new(agent_progress_summaries: value) - Async do - described_class.query(prompt: 'hello', options: options) { |_message| nil } - end.wait - - expect(captured_query_args).to have_key(:agent_progress_summaries) - expect(captured_query_args[:agent_progress_summaries]).to be(value) - end - end end diff --git a/spec/unit/session_assembly_spec.rb b/spec/unit/session_assembly_spec.rb new file mode 100644 index 00000000..a12691ea --- /dev/null +++ b/spec/unit/session_assembly_spec.rb @@ -0,0 +1,878 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'async' +require 'securerandom' + +# SessionAssembly is the implementation ClaudeAgentSDK.query and Client share +# for acquiring a session's resources, writing prompts, delivering messages +# and disposing of the resources. These examples are the one place the +# mapping from options to the Query is spelled out in full, and the one place +# each step of acquisition and disposal is failed in turn. +# +# Only its own methods are called; what it did is read off the transport, the +# Query and the materialized resume it was given. +RSpec.describe ClaudeAgentSDK::SessionAssembly do + let(:observer) { EntryPointHarness::RecordingObserver.new } + let(:dispatch) { ClaudeAgentSDK::Dispatch.new([observer], scheduling: :thread, wrapper: nil) } + let(:cli) { EntryPointHarness::FakeCLI.new } + let(:handler) do + instance_double(ClaudeAgentSDK::Query, start: nil, initialize_protocol: nil, close: nil, + set_transcript_mirror_batcher: nil, mirror_batches_dropped?: false) + end + let(:query_kwargs) { [] } + let(:materialized) do + instance_double(ClaudeAgentSDK::MaterializedResume, cleanup: nil, preserve_transcripts: nil, + config_dir: '/nonexistent/claude-resume-x', + resume_session_id: SecureRandom.uuid) + end + let(:store) { ClaudeAgentSDK::InMemorySessionStore.new } + + def options_with(**attributes) + ClaudeAgentSDK::ClaudeAgentOptions.new(**attributes) + end + + def assembly(options = options_with, source: { instance: cli }, with: dispatch) + described_class.new(options, dispatch: with, transport_source: source) + end + + # Query.new hands back the +handler+ double and records what it was given. + def stub_query + allow(ClaudeAgentSDK::Query).to receive(:new) do |**kwargs| + query_kwargs << kwargs + handler + end + end + + # The keywords one #connect built its Query with. + def kwargs_for(options = options_with, **assembly_args) + stub_query + assembly(options, **assembly_args).connect + query_kwargs.fetch(-1) + end + + def resume_options(**extra) + options_with(session_store: store, resume: materialized.resume_session_id, **extra) + end + + def stub_materialization(result = materialized) + allow(ClaudeAgentSDK::SessionResume).to receive(:materialize_resume_session).and_return(result) + end + + describe '#initialize' do + it 'acquires nothing' do + created = [] + stub_materialization + session = assembly(resume_options, source: { class: EntryPointHarness::FakeCLI.subprocess_class(created), args: {} }) + + expect(session.query_handler).to be_nil + expect(created).to be_empty + expect(ClaudeAgentSDK::SessionResume).not_to have_received(:materialize_resume_session) + end + end + + describe '#connect: the Query it builds' do + it 'builds exactly one, with these fifteen keywords, on the transport' do + kwargs = kwargs_for + + expect(query_kwargs.length).to eq(1) + expect(kwargs.keys).to contain_exactly( + :transport, :is_streaming_mode, :can_use_tool, :hooks, :sdk_mcp_servers, :agents, :exclude_dynamic_sections, + :system_prompt_snapshot, :skills, :forward_subagent_text, :agent_progress_summaries, :callback_scheduling, + :callback_wrapper, :verbatim_prompts, :run_end_ceiling_ms + ) + expect(kwargs).to include(transport: cli, is_streaming_mode: true) + end + + it 'makes it the query_handler' do + stub_query + session = assembly + + expect { session.connect }.to change(session, :query_handler).from(nil).to(handler) + end + + it 'passes can_use_tool as it is, and nil without one' do + callback = ->(_tool_name, _input, _context) { ClaudeAgentSDK::PermissionResultAllow.new } + + expect(kwargs_for(options_with(can_use_tool: callback, permission_prompt_tool_name: 'stdio'))[:can_use_tool]).to be(callback) + expect(kwargs_for.fetch(:can_use_tool)).to be_nil + end + + describe 'hooks' do + let(:callback) { ->(*) { {} } } + + it 'omits nil and empty lists, keeps matcher, callbacks and timeout, and leaves the caller Hash alone' do + matcher = ClaudeAgentSDK::HookMatcher.new(matcher: 'Bash', hooks: [callback], timeout: 7) + hooks = { 'PostToolUse' => nil, 'Stop' => [], PreToolUse: [matcher] } + + expect(kwargs_for(options_with(hooks: hooks))[:hooks]) + .to eq('PreToolUse' => [{ matcher: 'Bash', hooks: [callback], timeout: 7 }]) + expect(hooks).to eq('PostToolUse' => nil, 'Stop' => [], PreToolUse: [matcher]) + end + + it 'passes nil when no active hook list remains, and when there are no hooks' do + expect(kwargs_for(options_with(hooks: { 'PreToolUse' => [], 'PostToolUse' => nil })).fetch(:hooks)).to be_nil + expect(kwargs_for.fetch(:hooks)).to be_nil + end + end + + it 'passes the live SDK MCP server instances, by name, and none of the other servers' do + server = ClaudeAgentSDK.create_sdk_mcp_server(name: 'calc', tools: []) + mcp_servers = { 'calc' => server, 'files' => { type: 'stdio', command: 'files-server' } } + + expect(kwargs_for(options_with(mcp_servers: mcp_servers))[:sdk_mcp_servers]).to eq('calc' => server[:instance]) + expect(kwargs_for.fetch(:sdk_mcp_servers)).to eq({}) + end + + it 'passes agents and skills as they are' do + agents = { 'reviewer' => ClaudeAgentSDK::AgentDefinition.new(description: 'Reviews', prompt: 'Review it') } + kwargs = kwargs_for(options_with(agents: agents, skills: %w[pdf docx])) + + expect(kwargs[:agents]).to be(agents) + expect(kwargs[:skills]).to eq(%w[pdf docx]) + expect(kwargs_for).to include(agents: nil, skills: nil) + end + + # Only a genuine true/false of a preset system prompt is forwarded. + { + 'a SystemPromptPreset with the flag on' => + [-> { ClaudeAgentSDK::SystemPromptPreset.new(preset: 'claude_code', exclude_dynamic_sections: true) }, true], + 'a preset Hash with the flag on' => + [-> { { type: 'preset', preset: 'claude_code', exclude_dynamic_sections: true } }, true], + 'a preset Hash with the flag off' => + [-> { { type: 'preset', preset: 'claude_code', exclude_dynamic_sections: false } }, false], + 'a preset Hash without the flag' => [-> { { type: 'preset', preset: 'claude_code' } }, nil], + 'a plain String' => [-> { 'You are a helper' }, nil], + 'no system prompt' => [-> {}, nil] + }.each do |label, (system_prompt, expected)| + it "passes exclude_dynamic_sections #{expected.inspect} for #{label}" do + expect(kwargs_for(options_with(system_prompt: system_prompt.call)).fetch(:exclude_dynamic_sections)).to be(expected) + end + end + + # Python #1268: the snapshot travels only for the preset and custom + # forms, and a false value survives the trip. + { + 'a custom Hash with snapshot false' => [-> { { type: 'custom', prompt: 'Be helpful', snapshot: false } }, false], + 'a preset Hash with snapshot true' => [-> { { type: 'preset', preset: 'claude_code', snapshot: true } }, true], + 'a preset Hash with String keys and snapshot false' => + [-> { { 'type' => 'preset', 'preset' => 'claude_code', 'snapshot' => false } }, false], + 'a preset Hash without snapshot' => [-> { { type: 'preset', preset: 'claude_code' } }, nil], + 'a file Hash (snapshot ignored)' => [-> { { type: 'file', path: '/p.md', snapshot: false } }, nil], + 'a custom Hash with a snapshot that is not a Boolean' => + [-> { { type: 'custom', prompt: 'x', snapshot: 'yes' } }, nil], + 'a SystemPromptCustom with snapshot false' => + [-> { ClaudeAgentSDK::SystemPromptCustom.new(prompt: 'Be helpful', snapshot: false) }, false], + 'a SystemPromptPreset with snapshot true' => + [-> { ClaudeAgentSDK::SystemPromptPreset.new(preset: 'claude_code', snapshot: true) }, true], + 'a plain String' => [-> { 'Be helpful' }, nil], + 'no system prompt' => [-> {}, nil] + }.each do |label, (system_prompt, expected)| + it "passes system_prompt_snapshot #{expected.inspect} for #{label}" do + expect(kwargs_for(options_with(system_prompt: system_prompt.call)).fetch(:system_prompt_snapshot)).to be(expected) + end + end + + it 'passes forward_subagent_text as a Boolean' do + expect([true, false].map { |enabled| kwargs_for(options_with(forward_subagent_text: enabled))[:forward_subagent_text] }) + .to eq([true, false]) + end + + it 'passes agent_progress_summaries, telling nil from false' do + [true, false, nil].each do |value| + expect(kwargs_for(options_with(agent_progress_summaries: value)).fetch(:agent_progress_summaries)).to be(value) + end + end + + it 'passes the scheduling and the wrapper of its dispatch, not those of the options' do + wrapper = lambda(&:call) + inline = ClaudeAgentSDK::Dispatch.new([], scheduling: :inline, wrapper: wrapper) + + expect(kwargs_for(options_with(callback_scheduling: :thread), with: inline)) + .to include(callback_scheduling: :inline, callback_wrapper: wrapper) + expect(kwargs_for).to include(callback_scheduling: :thread, callback_wrapper: nil) + end + + it 'passes verbatim_prompts as it reads after the transport connected' do + flipped = options_with + late = EntryPointHarness::FakeCLI.new(flipped, on_connect: ->(transport) { transport.options.verbatim_prompts = true }) + + expect(kwargs_for(flipped, source: { instance: late })[:verbatim_prompts]).to be(true) + expect(kwargs_for(options_with(verbatim_prompts: true))[:verbatim_prompts]).to be(true) + expect(kwargs_for[:verbatim_prompts]).to be(false) + end + + it 'passes the run-end ceiling the CLI will get from the options env, or its default' do + ceiling = ClaudeAgentSDK::Query::RUN_END_CEILING_ENV_VAR + + expect(kwargs_for(options_with(env: { ceiling => '1500' }))[:run_end_ceiling_ms]).to eq(1500) + expect(kwargs_for(options_with(env: { ceiling => 'soon' }))[:run_end_ceiling_ms]) + .to eq(ClaudeAgentSDK::Query::DEFAULT_RUN_END_CEILING_MS) + end + end + + describe '#connect: the order of acquisition' do + it 'connects the transport, builds the Query, then starts it and performs the handshake' do + steps = [] + transport = instance_double(ClaudeAgentSDK::SubprocessCLITransport) + allow(transport).to receive(:connect) { steps << :transport_connect } + allow(ClaudeAgentSDK::Query).to receive(:new) do + steps << :query_new + handler + end + allow(handler).to receive(:start) { steps << :start } + allow(handler).to receive(:initialize_protocol) { steps << :initialize_protocol } + + assembly(source: { instance: transport }).connect + + expect(steps).to eq(%i[transport_connect query_new start initialize_protocol]) + end + + it 'installs the transcript mirror before the read loop starts, when a session_store is configured' do + steps = [] + stub_query + allow(handler).to receive(:set_transcript_mirror_batcher) { |batcher| steps << batcher.class } + allow(handler).to receive(:start) { steps << :start } + + assembly(options_with(session_store: store)).connect + + expect(steps).to eq([ClaudeAgentSDK::TranscriptMirrorBatcher, :start]) + end + + it 'builds the mirror from the store, the env, the flush mode and the wrapper, reporting errors to the Query' do + wrapper = lambda(&:call) + stub_query + allow(handler).to receive(:report_mirror_error) + allow(ClaudeAgentSDK::SessionResume).to receive(:build_mirror_batcher).and_call_original + options = options_with(session_store: store, session_store_flush: 'eager', env: { 'CLAUDE_CONFIG_DIR' => '/cfg' }) + + assembly(options, with: ClaudeAgentSDK::Dispatch.new([], scheduling: :thread, wrapper: wrapper)).connect + + expect(ClaudeAgentSDK::SessionResume).to have_received(:build_mirror_batcher) do |**kwargs| + expect(kwargs).to include(store: store, env: { 'CLAUDE_CONFIG_DIR' => '/cfg' }, eager: true, callback_wrapper: wrapper) + kwargs.fetch(:on_error).call({ 'session_id' => 's' }, 'append failed') + end + expect(handler).to have_received(:report_mirror_error).with({ 'session_id' => 's' }, 'append failed') + end + + it 'installs no mirror without a session_store' do + stub_query + + assembly.connect + + expect(handler).not_to have_received(:set_transcript_mirror_batcher) + end + end + + # A store-backed resume is materialized only when the repointed options + # will reach a transport that spawns the CLI on this host with them: one the + # assembly constructs itself, from SubprocessCLITransport or a subclass. + describe '#connect: which transports get a materialized resume' do + let(:created) { [] } + + before do + stub_query + stub_materialization + end + + def connect_with(source, options = resume_options) + assembly(options, source: source).connect + options + end + + it 'none for an injected transport' do + connect_with({ instance: cli }) + + expect(ClaudeAgentSDK::SessionResume).not_to have_received(:materialize_resume_session) + end + + it 'one for SubprocessCLITransport itself, constructed with the repointed options' do + allow(ClaudeAgentSDK::SubprocessCLITransport).to receive(:new) do |transport_options| + EntryPointHarness::FakeCLI.new(transport_options).tap { |transport| created << transport } + end + + options = connect_with({ class: ClaudeAgentSDK::SubprocessCLITransport, args: {} }) + + handed = created.fetch(0).options + expect(ClaudeAgentSDK::SessionResume).to have_received(:materialize_resume_session).with(options).once + expect(handed).not_to be(options) + expect(handed).to have_attributes(env: { 'CLAUDE_CONFIG_DIR' => '/nonexistent/claude-resume-x' }, + resume: materialized.resume_session_id, continue_conversation: false) + expect(options.env).to eq({}) # the caller's options are left alone + end + + it 'one for a subclass of it' do + connect_with({ class: EntryPointHarness::FakeCLI.subprocess_class(created), args: {} }) + + expect(created.fetch(0).config_dir).to eq('/nonexistent/claude-resume-x') + end + + it 'none for any other class, which gets the options as they are' do + options = connect_with({ class: EntryPointHarness::FakeCLI.foreign_class(created), args: {} }) + + expect(ClaudeAgentSDK::SessionResume).not_to have_received(:materialize_resume_session) + expect(created.fetch(0).options).to be(options) + end + + it 'none for a factory that is not a Class' do + factory = Object.new + factory.define_singleton_method(:new) { |transport_options, **| EntryPointHarness::FakeCLI.new(transport_options) } + + connect_with({ class: factory, args: {} }) + + expect(ClaudeAgentSDK::SessionResume).not_to have_received(:materialize_resume_session) + end + + it 'none without a session_store' do + options = connect_with({ class: EntryPointHarness::FakeCLI.subprocess_class(created), args: {} }, options_with) + + expect(ClaudeAgentSDK::SessionResume).not_to have_received(:materialize_resume_session) + expect(created.fetch(0).options).to be(options) + end + + it 'the options as they are when the store has nothing to materialize' do + stub_materialization(nil) + + options = connect_with({ class: EntryPointHarness::FakeCLI.subprocess_class(created), args: {} }) + + expect(created.fetch(0).options).to be(options) + end + + it 'constructs a class with the args as keywords' do + received = nil + transport_class = Class.new do + define_singleton_method(:new) do |_options, **kwargs| + received = kwargs + EntryPointHarness::FakeCLI.new + end + end + + connect_with({ class: transport_class, args: { sandbox: 'box-1', retries: 2 } }) + + expect(received).to eq(sandbox: 'box-1', retries: 2) + end + end + + # Nothing is rolled back by #connect: the error propagates as it was + # raised, and #close_resources disposes of what was acquired before it. + describe '#connect: a step that fails' do + let(:created) { [] } + let(:failure) { IOError.new('step failed') } + let(:transport) { instance_double(ClaudeAgentSDK::SubprocessCLITransport, connect: nil, close: nil) } + let(:source) do + doubled = transport + { class: Class.new(ClaudeAgentSDK::SubprocessCLITransport) { define_singleton_method(:new) { |_options, **| doubled } }, + args: {} } + end + + before do + stub_query + stub_materialization + end + + def fail_connect(session) + expect { session.connect }.to raise_error(failure) + session.close_resources(always_close_transport: true) + end + + it 'repointing the options: the materialized dir is removed, and no transport was constructed' do + allow(ClaudeAgentSDK::SessionResume).to receive(:apply_materialized_options).and_raise(failure) + + fail_connect(assembly(resume_options, source: { class: EntryPointHarness::FakeCLI.subprocess_class(created), args: {} })) + + expect(materialized).to have_received(:cleanup) + expect(created).to be_empty + end + + it 'constructing the transport: the materialized dir is removed' do + raising = Class.new(ClaudeAgentSDK::SubprocessCLITransport) + error = failure + raising.define_singleton_method(:new) { |_options, **| raise error } + + fail_connect(assembly(resume_options, source: { class: raising, args: {} })) + + expect(materialized).to have_received(:cleanup) + end + + it 'connecting the transport: it is closed, the materialized dir is removed, and no Query was built' do + allow(transport).to receive(:connect).and_raise(failure) + + fail_connect(assembly(resume_options, source: source)) + + expect(transport).to have_received(:close) + expect(materialized).to have_received(:cleanup) + expect(ClaudeAgentSDK::Query).not_to have_received(:new) + end + + it 'building the Query: the transport is closed and the materialized dir is removed' do + allow(ClaudeAgentSDK::Query).to receive(:new).and_raise(failure) + + fail_connect(assembly(resume_options, source: source)) + + expect(transport).to have_received(:close) + expect(materialized).to have_received(:cleanup) + end + + { + 'installing the mirror' => :set_transcript_mirror_batcher, + 'starting the read loop' => :start, + 'the handshake' => :initialize_protocol + }.each do |label, step| + it "#{label}: the Query and the transport are closed and the materialized dir is removed" do + allow(handler).to receive(step).and_raise(failure) + session = assembly(resume_options, source: source) + + fail_connect(session) + + expect(handler).to have_received(:close) + expect(transport).to have_received(:close) + expect(materialized).to have_received(:cleanup) + expect(session.query_handler).to be_nil + end + end + + it 'notifies no observer' do + allow(handler).to receive(:initialize_protocol).and_raise(failure) + + fail_connect(assembly(resume_options, source: source)) + + expect(observer.events).to be_empty + end + end + + describe 'writing prompts' do + let(:user_message) { { type: 'user', message: { role: 'user', content: 'hi' }, parent_tool_use_id: nil } } + + def connected(options = options_with, source: nil) + stub_query + assembly(options, source: source || { instance: cli }).tap(&:connect) + end + + describe '#write_prompt' do + it 'writes one user message with the given session_id, as one line' do + connected.write_prompt('hello', session_id: 'abc') + + expect(cli.writes).to eq([{ type: 'user', message: { role: 'user', content: 'hello' }, parent_tool_use_id: nil, + session_id: 'abc' }]) + expect(cli.lines).to match([a_string_ending_with("}\n")]) + end + + it 'requires the session_id' do + expect { connected.write_prompt('hello') }.to raise_error(ArgumentError, /session_id/) + expect(cli.writes).to be_empty + end + + it 'notifies no observer: on_user_prompt is the caller\'s' do + connected.write_prompt('hello', session_id: '') + + expect(observer.events).to be_empty + end + + it 'stamps with the value captured in #connect by default, whatever the options say later' do + on = options_with(verbatim_prompts: true) + session = connected(on) + on.verbatim_prompts = false + session.write_prompt('one', session_id: '') + session.write_prompt('two', session_id: '', verbatim: described_class::CAPTURED) + + expect(cli.writes).to all(include(client_composed: true)) + end + + it 'does not mark by default when the option was off at #connect and is turned on later' do + late = options_with + session = connected(late) + late.verbatim_prompts = true + session.write_prompt('hello', session_id: '') + + expect(cli.writes.first).not_to have_key(:client_composed) + end + + it 'reads the options at write time with verbatim: :current' do + late = options_with + session = connected(late) + session.write_prompt('before', session_id: '', verbatim: :current) + late.verbatim_prompts = true + session.write_prompt('after', session_id: '', verbatim: :current) + + expect(cli.writes.map { |frame| frame[:client_composed] }).to eq([nil, true]) + end + + it 'reads the repointed copy, not the configured options, with verbatim: :current once materialized' do + created = [] + stub_materialization + configured = resume_options + stub_query + session = assembly(configured, source: { class: EntryPointHarness::FakeCLI.subprocess_class(created), args: {} }) + session.connect + repointed = created.fetch(0).options + + configured.verbatim_prompts = true + session.write_prompt('caller flipped', session_id: '', verbatim: :current) + repointed.verbatim_prompts = true + session.write_prompt('copy flipped', session_id: '', verbatim: :current) + + expect(repointed).not_to be(configured) + expect(created.fetch(0).writes.map { |frame| frame[:client_composed] }).to eq([nil, true]) + end + + it 'takes an explicit Boolean over both' do + session = connected(options_with(verbatim_prompts: true)) + session.write_prompt('unmarked', session_id: '', verbatim: false) + plain = connected(options_with) + plain.write_prompt('marked', session_id: '', verbatim: true) + + expect(cli.writes.map { |frame| frame[:client_composed] }).to eq([nil, true]) + end + end + + describe '#write_message' do + it 'writes a Hash as one JSON line' do + connected.write_message(user_message.merge(session_id: 'abc')) + + expect(cli.writes).to eq([user_message.merge(session_id: 'abc')]) + expect(cli.lines).to match([a_string_ending_with("}\n")]) + end + + it 'writes a JSONL String as it is, adding the newline only when it lacks one' do + line = JSON.generate(user_message) + session = connected + session.write_message(line) + session.write_message("#{line}\n") + + expect(cli.lines).to eq(["#{line}\n", "#{line}\n"]) + end + + it 'stamps with the value captured in #connect' do + on = options_with(verbatim_prompts: true) + session = connected(on) + on.verbatim_prompts = false + session.write_message(user_message) + session.write_message(JSON.generate(user_message)) + + expect(cli.writes).to all(include(client_composed: true)) + end + + it 'raises for a String it cannot mark, before writing it' do + session = connected(options_with(verbatim_prompts: true)) + + expect { session.write_message('not json') }.to raise_error(ArgumentError, /one JSON object/) + expect(cli.writes).to be_empty + end + + it 'notifies no observer' do + connected.write_message(user_message) + + expect(observer.events).to be_empty + end + end + end + + describe '#stream_prompt_in_background' do + it 'streams through Query#stream_input on a task the Query tracks, notifying on_user_prompt per user message' do + session = assembly + stream = [{ type: 'user', message: { role: 'user', content: 'one' } }, + JSON.generate(type: 'user', message: { role: 'user', content: 'two' })] + + Sync do + session.connect + task = session.stream_prompt_in_background(stream.each) + task.wait + + expect(task).to be_a(Async::Task) + expect(cli.user_writes.map { |frame| frame.dig(:message, :content) }).to eq(%w[one two]) + expect(cli).to be_input_ended + expect(observer.payloads(:on_user_prompt)).to eq(%w[one two]) + ensure + session.close_resources(always_close_transport: true) + end + end + end + + describe '#deliver' do + let(:assistant) { EntryPointHarness::ASSISTANT_FRAME } + let(:result) { EntryPointHarness::RESULT_FRAME } + let(:frames) { [assistant, { type: 'a_frame_from_the_future' }, result, assistant] } + + def delivering(frames, with: dispatch) + stub_query + allow(handler).to receive(:receive_messages) do |&block| + frames.each { |frame| frame.is_a?(Exception) ? raise(frame) : block.call(frame) } + nil + end + assembly(with: with).tap(&:connect) + end + + it 'parses each frame, notifies on_message, then calls the block, and skips frames that parse to nothing' do + log = [] + logging = EntryPointHarness::RecordingObserver.new(on_message: ->(message) { log << [:on_message, message.class] }) + session = delivering(frames, with: ClaudeAgentSDK::Dispatch.new([logging], scheduling: :thread, wrapper: nil)) + + returned = session.deliver(proc { |message| log << [:block, message.class] }) + + expect(log).to eq([ClaudeAgentSDK::AssistantMessage, ClaudeAgentSDK::ResultMessage, ClaudeAgentSDK::AssistantMessage] + .flat_map { |klass| [[:on_message, klass], [:block, klass]] }) + expect(returned).to be_nil + end + + it 'notifies the observers its dispatch holds when each message arrives, not the ones it started with' do + later = EntryPointHarness::RecordingObserver.new + session = delivering(frames) + + session.deliver(proc { |message| dispatch.observers = [later] if message.is_a?(ClaudeAgentSDK::ResultMessage) }) + + expect(observer.payloads(:on_message).map(&:class)).to eq([ClaudeAgentSDK::AssistantMessage, ClaudeAgentSDK::ResultMessage]) + expect(later.payloads(:on_message).map(&:class)).to eq([ClaudeAgentSDK::AssistantMessage]) + end + + it 'stops after the ResultMessage with until_result: true' do + seen = [] + + delivering(frames).deliver(proc { |message| seen << message.class }, until_result: true) + + expect(seen).to eq([ClaudeAgentSDK::AssistantMessage, ClaudeAgentSDK::ResultMessage]) + end + + %i[thread inline].each do |scheduling| + it "returns the value of the block's break and delivers nothing after it (#{scheduling}, with a wrapper)" do + wrapped = [] + wrapper = lambda do |invocation| + wrapped << true + invocation.call + end + mode = ClaudeAgentSDK::Dispatch.new([observer], scheduling: scheduling, wrapper: wrapper) + seen = [] + block = proc do |message| + seen << message.class + break :stopped + end + + returned = Sync { delivering(frames, with: mode).deliver(block) } + + expect(returned).to eq(:stopped) + expect(seen).to eq([ClaudeAgentSDK::AssistantMessage]) + expect(observer.names).to eq(%i[on_message]) + expect(wrapped.length).to eq(2) # the observer and the block + end + end + + it 'prefers the break value over the ResultMessage stop' do + returned = delivering([result, assistant]).deliver(proc { break :mine }, until_result: true) + + expect(returned).to eq(:mine) + end + + it 'lets an error of the block propagate, and notifies no on_error' do + expect { delivering(frames).deliver(proc { raise ArgumentError, 'block boom' }) } + .to raise_error(ArgumentError, 'block boom') + expect(observer.names).to eq(%i[on_message]) + end + + it 'lets an error of the stream propagate, and notifies no on_error' do + crash = ClaudeAgentSDK::ProcessError.new('Command failed', exit_code: 1) + seen = [] + + expect { delivering([assistant, crash]).deliver(proc { |message| seen << message.class }) }.to raise_error(crash) + expect(seen).to eq([ClaudeAgentSDK::AssistantMessage]) + expect(observer.names).to eq(%i[on_message]) + end + end + + describe '#close_resources' do + let(:transport) { instance_double(ClaudeAgentSDK::SubprocessCLITransport, connect: nil, close: nil) } + let(:source) do + doubled = transport + { class: Class.new(ClaudeAgentSDK::SubprocessCLITransport) { define_singleton_method(:new) { |_options, **| doubled } }, + args: {} } + end + + # Connected with a materialized resume. + def connected + stub_query + stub_materialization + assembly(resume_options, source: source).tap(&:connect) + end + + # Got as far as a transport whose #connect raised: no Query was built. + def without_handler + stub_query + stub_materialization + allow(transport).to receive(:connect).and_raise(IOError, 'no route') + assembly(resume_options, source: source).tap do |session| + expect { session.connect }.to raise_error(IOError, 'no route') + end + end + + it 'does nothing before #connect' do + session = assembly + + expect { session.close_resources(always_close_transport: true) }.not_to raise_error + expect(cli.close_calls).to eq(0) + end + + context 'with always_close_transport: true' do + it 'closes the Query, then the transport, then removes the materialized dir' do + session = connected + + session.close_resources(always_close_transport: true) + + expect(handler).to have_received(:close).ordered + expect(transport).to have_received(:close).ordered + expect(materialized).to have_received(:cleanup).ordered + expect(session.query_handler).to be_nil + end + + it 'still closes the transport and removes the dir when closing the Query raises, then raises that error' do + session = connected + allow(handler).to receive(:close).and_raise(IOError, 'reap failed') + + expect { session.close_resources(always_close_transport: true) }.to raise_error(IOError, 'reap failed') + expect(transport).to have_received(:close) + expect(materialized).to have_received(:cleanup) + expect(session.query_handler).to be_nil + end + + it 'still removes the dir when closing the transport raises, then raises that error' do + session = connected + allow(transport).to receive(:close).and_raise(IOError, 'pipe') + + expect { session.close_resources(always_close_transport: true) }.to raise_error(IOError, 'pipe') + expect(materialized).to have_received(:cleanup) + end + + it 'raises the later error, the one of the transport, when both closes raise' do + session = connected + allow(handler).to receive(:close).and_raise(IOError, 'reap failed') + allow(transport).to receive(:close).and_raise(ArgumentError, 'pipe') + + expect { session.close_resources(always_close_transport: true) }.to raise_error(ArgumentError, 'pipe') + expect(materialized).to have_received(:cleanup) + end + + it 'closes a transport that never got a Query' do + session = without_handler + + session.close_resources(always_close_transport: true) + + expect(transport).to have_received(:close) + expect(materialized).to have_received(:cleanup) + end + end + + context 'with always_close_transport: false' do + it 'closes the Query and removes the materialized dir, leaving the transport to the Query' do + session = connected + + session.close_resources(always_close_transport: false) + + expect(handler).to have_received(:close).ordered + expect(materialized).to have_received(:cleanup).ordered + expect(transport).not_to have_received(:close) + expect(session.query_handler).to be_nil + end + + it 'does not close the transport when closing the Query raises, removes the dir, then raises that error' do + session = connected + allow(handler).to receive(:close).and_raise(IOError, 'reap failed') + + expect { session.close_resources(always_close_transport: false) }.to raise_error(IOError, 'reap failed') + expect(transport).not_to have_received(:close) + expect(materialized).to have_received(:cleanup) + end + + it 'closes a transport that never got a Query' do + session = without_handler + + session.close_resources(always_close_transport: false) + + expect(transport).to have_received(:close) + expect(materialized).to have_received(:cleanup) + end + + it 'still removes the dir when closing that transport raises, then raises that error' do + session = without_handler + allow(transport).to receive(:close).and_raise(ArgumentError, 'pipe') + + expect { session.close_resources(always_close_transport: false) }.to raise_error(ArgumentError, 'pipe') + expect(materialized).to have_received(:cleanup) + end + end + + it 'keeps a materialized dir whose removal was cut short, for the next call to remove' do + session = connected + attempts = 0 + allow(materialized).to receive(:cleanup) do + attempts += 1 + raise Async::TimeoutError, 'execution expired' if attempts == 1 + end + + expect { session.close_resources(always_close_transport: true) }.to raise_error(Async::TimeoutError) + session.close_resources(always_close_transport: true) + session.close_resources(always_close_transport: true) + + expect(attempts).to eq(2) + expect(handler).to have_received(:close).once + expect(transport).to have_received(:close).once + end + + # The block is where the caller marks itself disconnected: after both + # closes, before the directory removal, which can take a while. + describe 'the block it is given' do + it 'is called once both closes are behind and before the materialized dir is dealt with' do + steps = [] + session = connected + allow(handler).to receive(:close) { steps << :query_close } + allow(transport).to receive(:close) { steps << :transport_close } + allow(materialized).to receive(:cleanup) { steps << :cleanup } + + session.close_resources(always_close_transport: true) { steps << [:block, session.query_handler] } + + expect(steps).to eq([:query_close, :transport_close, [:block, nil], :cleanup]) + end + + it 'is called when closing the Query raises, and when closing the transport raises' do + calls = 0 + session = connected + allow(handler).to receive(:close).and_raise(IOError, 'reap failed') + allow(transport).to receive(:close).and_raise(ArgumentError, 'pipe') + + expect { session.close_resources(always_close_transport: true) { calls += 1 } } + .to raise_error(ArgumentError, 'pipe') + expect(calls).to eq(1) + expect(materialized).to have_received(:cleanup) + end + + it 'is called when there is nothing to close and no materialized dir' do + calls = 0 + + assembly.close_resources(always_close_transport: false) { calls += 1 } + + expect(calls).to eq(1) + end + end + + # A dropped mirror batch means the store copy is incomplete: the dir + # holds the only copy of those turns. + [true, false].each do |always| + it "preserves the materialized dir when the mirror dropped batches (always_close_transport: #{always})" do + session = connected + allow(handler).to receive(:mirror_batches_dropped?).and_return(true) + + session.close_resources(always_close_transport: always) + + expect(handler).to have_received(:close).ordered + expect(handler).to have_received(:mirror_batches_dropped?).ordered # asked after the final flush + expect(materialized).to have_received(:preserve_transcripts) + expect(materialized).not_to have_received(:cleanup) + end + + it "disposes of everything once: a second call does nothing (always_close_transport: #{always})" do + session = connected + + 2.times { session.close_resources(always_close_transport: always) } + + expect(handler).to have_received(:close).once + expect(materialized).to have_received(:cleanup).once + end + end + end +end diff --git a/spec/unit/session_resume_nil_options_spec.rb b/spec/unit/session_resume_nil_options_spec.rb index 85eef26a..7afe03cf 100644 --- a/spec/unit/session_resume_nil_options_spec.rb +++ b/spec/unit/session_resume_nil_options_spec.rb @@ -75,12 +75,22 @@ def materialize(options) it 'gets Client past materialization with both set back to nil' do cleared = options.dup_with(env: nil, load_timeout_ms: nil) - client = ClaudeAgentSDK::Client.new(options: cleared) - - applied = client.send(:materialize_resume, cleared) - materializations << client.instance_variable_get(:@materialized) - - expect(applied.env).to eq('CLAUDE_CONFIG_DIR' => materializations.first.config_dir) - expect(applied.resume).to eq(sid) + created = [] + client = ClaudeAgentSDK::Client.new(options: cleared, + transport_class: EntryPointHarness::FakeCLI.subprocess_class(created)) + + Sync do + client.connect + applied = created.fetch(0).options # what the transport was constructed with + materialized_dir = applied.env.fetch('CLAUDE_CONFIG_DIR') + + expect(applied.env).to eq('CLAUDE_CONFIG_DIR' => materialized_dir) + expect(materialized_dir).not_to eq(caller_config_dir) + expect(Dir.glob(File.join(materialized_dir, 'projects', '*', "#{sid}.jsonl")).length).to eq(1) + expect(applied.resume).to eq(sid) + expect(cleared.env).to be_nil # the caller's options are left as they were + ensure + client.disconnect # removes the materialized dir + end end end diff --git a/spec/unit/session_resume_spec.rb b/spec/unit/session_resume_spec.rb index a4ed5783..09fd07e9 100644 --- a/spec/unit/session_resume_spec.rb +++ b/spec/unit/session_resume_spec.rb @@ -766,42 +766,67 @@ def main_jsonl(mat, session_id) store.append({ 'project_key' => project_key, 'session_id' => sid }, [entry('hi')]) end + # The transport Client constructs is the witness: the options it is + # handed are the repointed ones when the resume was materialized, the + # caller's own otherwise. + let(:created) { [] } + + def materialized_dirs + Dir.glob(File.join(Dir.tmpdir, 'claude-resume-*')) + end + it 'materializes for the default subprocess transport' do + allow(ClaudeAgentSDK::SubprocessCLITransport).to receive(:new) do |transport_options| + EntryPointHarness::FakeCLI.new(transport_options).tap { |cli| created << cli } + end client = ClaudeAgentSDK::Client.new(options: options) - result = client.send(:materialize_resume, options) - materialized = client.instance_variable_get(:@materialized) - begin - expect(materialized).to be_a(ClaudeAgentSDK::MaterializedResume) - expect(result.resume).to eq(sid) - expect(result.env['CLAUDE_CONFIG_DIR']).to eq(materialized.config_dir.to_s) + before = materialized_dirs + + Sync do + client.connect + handed = created.fetch(0).options + + expect(handed.resume).to eq(sid) + expect(materialized_dirs - before).to eq([handed.env['CLAUDE_CONFIG_DIR']]) + expect(File.file?(File.join(handed.env['CLAUDE_CONFIG_DIR'], 'projects', project_key, "#{sid}.jsonl"))).to be(true) ensure - materialized&.cleanup + client.disconnect end + expect(materialized_dirs - before).to eq([]) # removed by disconnect end it 'skips materialization for a custom transport class' do - custom = Class.new(ClaudeAgentSDK::Transport) - client = ClaudeAgentSDK::Client.new(options: options, transport_class: custom) - result = client.send(:materialize_resume, options) - expect(result).to be(options) # unchanged - expect(client.instance_variable_get(:@materialized)).to be_nil + client = ClaudeAgentSDK::Client.new(options: options, + transport_class: EntryPointHarness::FakeCLI.foreign_class(created)) + before = materialized_dirs + + Sync do + client.connect + expect(created.fetch(0).options).to be(options) # unchanged + expect(materialized_dirs - before).to eq([]) + ensure + client.disconnect + end end it 'cleans up the materialized temp dir when connect fails with a non-StandardError' do # Async::Stop (reactor cancellation) is an Exception, NOT a StandardError. # `rescue StandardError` would let it skip disconnect and leak the temp dir # (with its .credentials.json copy); `rescue Exception` cleans it up. - client = ClaudeAgentSDK::Client.new(options: options) + # # Intentionally NOT a StandardError: this is what reactor cancellation # (Async::Stop) looks like, the exact case `rescue StandardError` misses. + # It is raised by the transport's #connect, after materialization. cancellation = Class.new(Exception) # rubocop:disable Lint/InheritException - allow(client).to receive(:connect_inner).and_raise(cancellation) + transport_class = EntryPointHarness::FakeCLI.subprocess_class(created, connect_error: cancellation.new) + client = ClaudeAgentSDK::Client.new(options: options, transport_class: transport_class) - before = Dir.glob(File.join(Dir.tmpdir, 'claude-resume-*')) + before = materialized_dirs expect { client.connect }.to raise_error(cancellation) - leaked = Dir.glob(File.join(Dir.tmpdir, 'claude-resume-*')) - before + expect(created.fetch(0).options.env['CLAUDE_CONFIG_DIR']).to start_with(File.join(Dir.tmpdir, 'claude-resume-')) + leaked = materialized_dirs - before expect(leaked).to eq([]) # materialized temp dir removed despite the non-StandardError - expect(client.instance_variable_get(:@materialized)).to be_nil + expect(client.query_handler).to be_nil end end