From e5b2e7363f31ad4260db13851de09dc223c3cc56 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Tue, 6 Oct 2026 03:31:35 +0800 Subject: [PATCH 01/12] Characterize the entry-point lifecycle through the public API query() and Client run one lifecycle whose implementation is written twice. Before any of it moves, pin what both do today, through the public API only, over an in-memory CLI and a recording observer: - the lifecycle differences L1-L8 (own reactor, String and Enumerable prompts, one run, ask, injected transports, the block-less Enumerator, what observers are told when the session cannot be opened); - which error wins when the cleanup after a failed connect fails too; - the session ids on the wire; - the two moments verbatim_prompts is read, including that a materialized store-backed resume reads the repointed copy of the options; - what is disposed of after a setup that fails part-way; - which transports get a materialized resume, and what happens to the temp dir on disconnect; - reconnecting, disconnecting from inside a callback, the double on_error of a query nested in a receive block; - a caller's deadline expiring while on_error runs, per scheduling mode. The deadline is delivered once the observer signals that it is running with the handshake rejection, the way an expired task.with_timeout delivers it (the scheduler raising into the waiting fiber), so no private method has to be wrapped to arm it and slow setup cannot use it up. None of these examples reads an instance variable or calls a private method, so they stay as they are while the implementation is extracted. No lib change. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01KzJcBPQ8Vq4kjkxdm2VnYm --- spec/support/entry_point_harness.rb | 240 ++++++ spec/unit/entry_point_lifecycle_spec.rb | 1008 +++++++++++++++++++++++ 2 files changed, 1248 insertions(+) create mode 100644 spec/support/entry_point_harness.rb create mode 100644 spec/unit/entry_point_lifecycle_spec.rb 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/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 From e21086f807799baa0138cdad0c6dd853387ab359 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Tue, 6 Oct 2026 03:35:25 +0800 Subject: [PATCH 02/12] Bind the observer dispatch triple once in Dispatch Every notification and every user-block invocation in query() and Client passed (observers, scheduling:, wrapper:) by hand: eleven notify_observers calls, two observing_prompt_stream calls and three invoke_iteration calls, with Client holding the triple as three instance variables and query() as three locals. A call site that forgot one of the two keywords would run a callback on the wrong execution context without failing anything. Dispatch holds the triple and offers the three calls. It is a context holder: each method is the module function it names, called with the triple, so notify_observers' containment and the Break bridging are untouched. Client builds an empty one in its constructor (scheduling and wrapper are captured there, as before) and swaps in the observers it resolves on each connect. Client#notify_error stays for now as a one-line delegate; a spec still wraps it, and both go together with the ownership change. No behaviour change. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01KzJcBPQ8Vq4kjkxdm2VnYm --- lib/claude_agent_sdk.rb | 94 ++++++++++-------------- lib/claude_agent_sdk/session_assembly.rb | 46 ++++++++++++ 2 files changed, 85 insertions(+), 55 deletions(-) create mode 100644 lib/claude_agent_sdk/session_assembly.rb diff --git a/lib/claude_agent_sdk.rb b/lib/claude_agent_sdk.rb index 81315bba..913bf1c3 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_warnings' require_relative 'claude_agent_sdk/deprecation' # Rails apps only: Bundler.require runs after `require 'rails'`, so the @@ -719,14 +720,14 @@ 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)' @@ -784,8 +785,8 @@ def self.query(prompt:, options: nil, transport: nil, &block) # rubocop:disable 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, + callback_scheduling: dispatch.scheduling, + callback_wrapper: dispatch.wrapper, verbatim_prompts: configured_options.verbatim_prompts?, run_end_ceiling_ms: Query.run_end_ceiling_ms(configured_options.env) ) @@ -799,7 +800,7 @@ def self.query(prompt:, options: nil, transport: nil, &block) # rubocop:disable 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 + callback_wrapper: dispatch.wrapper ) ) end @@ -812,8 +813,7 @@ def self.query(prompt:, options: nil, transport: nil, &block) # rubocop:disable # 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) + dispatch.notify(:on_user_prompt, prompt) message = { type: 'user', message: { role: 'user', content: prompt }, @@ -831,9 +831,7 @@ def self.query(prompt:, options: nil, transport: nil, &block) # rubocop:disable # 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 - ) + observed_prompt = dispatch.observing_stream(prompt) query_handler.spawn_task { query_handler.stream_input(observed_prompt) } end @@ -845,10 +843,8 @@ def self.query(prompt:, options: nil, transport: nil, &block) # rubocop:disable 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) + dispatch.notify(:on_message, message) + signal = dispatch.invoke_iteration(block, message) break signal.value if signal.is_a?(FiberBoundary::Break) end rescue StandardError => e @@ -857,12 +853,10 @@ def self.query(prompt:, options: nil, transport: nil, &block) # rubocop:disable # 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) + dispatch.notify(:on_error, e) raise ensure - ClaudeAgentSDK.notify_observers(resolved_observers, :on_close, - scheduling: callback_scheduling, wrapper: callback_wrapper) + dispatch.notify(:on_close) # 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. @@ -977,8 +971,10 @@ 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 + # Scheduling and wrapper are captured here; the observers are resolved + # 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 @@ -1053,9 +1049,9 @@ 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 = @dispatch.with_observers(ClaudeAgentSDK.resolve_observers(@options.observers)) - ClaudeAgentSDK.check_inline_isolation(@callback_scheduling) + ClaudeAgentSDK.check_inline_isolation(@dispatch.scheduling) # If anything from materialization onward fails, tear down (closes the # subprocess and removes the materialized temp config dir) before @@ -1116,8 +1112,7 @@ 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) + @dispatch.notify(:on_user_prompt, prompt) message = { type: 'user', message: { role: 'user', content: prompt }, @@ -1158,10 +1153,8 @@ def receive_messages(&block) 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) + @dispatch.notify(:on_message, message) + signal = @dispatch.invoke_iteration(block, message) break signal.value if signal.is_a?(FiberBoundary::Break) end rescue StandardError => e @@ -1186,10 +1179,8 @@ def receive_response(&block) 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) + @dispatch.notify(:on_message, message) + signal = @dispatch.invoke_iteration(block, message) break signal.value if signal.is_a?(FiberBoundary::Break) break if message.is_a?(ResultMessage) end @@ -1376,10 +1367,7 @@ def disconnect # 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 + @dispatch.notify(:on_close) if @connected ensure begin @query_handler&.close @@ -1461,8 +1449,8 @@ def connect_inner(configured_options, prompt) # rubocop:disable Metrics/MethodLe 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, + callback_scheduling: @dispatch.scheduling, + callback_wrapper: @dispatch.wrapper, verbatim_prompts: @verbatim_prompts, run_end_ceiling_ms: Query.run_end_ceiling_ms(configured_options.env) ) @@ -1495,8 +1483,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) + observed = @dispatch.observing_stream(prompt) @query_handler.spawn_task { @query_handler.stream_input(observed) } end end @@ -1515,14 +1502,12 @@ def stream_query_messages(prompt, session_id) 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)) 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)) else @@ -1533,11 +1518,10 @@ def stream_query_messages(prompt, session_id) end end - # Notify observers of an error surfacing to the consumer. `|| []` keeps a - # mis-scoped call before connect harmless instead of NoMethodError on nil. + # Notify observers of an error surfacing to the consumer. Before the + # first connect there are none to notify. def notify_error(error) - ClaudeAgentSDK.notify_observers(@resolved_observers || [], :on_error, error, - scheduling: @callback_scheduling, wrapper: @callback_wrapper) + @dispatch.notify(:on_error, error) end # Build and install the transcript-mirror batcher on the query handler when @@ -1551,7 +1535,7 @@ def install_transcript_mirror(options) 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 + callback_wrapper: @dispatch.wrapper ) @query_handler.set_transcript_mirror_batcher(batcher) 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..0c5ed5dc --- /dev/null +++ b/lib/claude_agent_sdk/session_assembly.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require_relative 'fiber_boundary' + +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) — bound once, 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. + # + # @api private + class Dispatch + # For what takes the pair as its own arguments (Query, the transcript + # mirror batcher). + attr_reader :scheduling, :wrapper + + def initialize(observers, scheduling:, wrapper:) + @observers = observers + @scheduling = scheduling + @wrapper = wrapper + end + + # The same scheduling and wrapper around other observers. Client captures + # the pair when it is constructed and resolves its observers on each + # connect. + def with_observers(observers) + self.class.new(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 +end From f75684b5bbb107f5e40390f9bef2fa945ab69feb Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Tue, 6 Oct 2026 03:52:06 +0800 Subject: [PATCH 03/12] Move Client's session resources into SessionAssembly Client acquired its transport, built its Query, installed the transcript mirror, wrote prompts, ran the receive loop (twice) and tore everything down through six private helpers and four instance variables. query() does the same things with its own copy of each, and every lifecycle fix so far had to be made twice or was made once. SessionAssembly now owns the transport, the Query and the materialized resume dir of one session, and what is done with them: - #connect acquires in the documented order, recording each resource before the next step that can fail, with no rollback of its own; - #write_prompt / #write_message serialize and write user messages, stamped with the verbatim_prompts value #connect captured (after the transport connected, before the Query is built: Client's timing); - #stream_prompt_in_background spawns Query#stream_input; - #deliver is the receive loop, with until_result: for receive_response; - #close_resources(always_close_transport:) closes the Query, the transport and decides the temp dir's fate under nested ensures. It is shared implementation, not a lifecycle: it notifies nothing and rescues nothing. Client keeps every rescue, every notification and its connect-error precedence, and delegates inside them. Client#query_handler becomes a delegating method with the same nil-until-connect meaning. Everything moves in one commit so that there is no state in which disconnect cannot find a resource or a prompt has no writer: connect_inner, install_transcript_mirror, materialize_resume, notify_error, writeln and write are deleted here, together with the last caller of each. The specs that reached the old state move with it, through the public API: - client_teardown_under_deadline_spec delivers the deadline once on_error is running, instead of wrapping notify_error; - close_from_callback_spec takes the handler from Client#query_handler before disconnecting and asserts the outcome through the recording transport and the public API; - session_resume_spec, session_resume_nil_options_spec and client_spec drive materialization and its disposal through #connect / #disconnect with a recording transport, instead of calling materialize_resume or planting instance variables; - client_spec reads the options a Client runs on from the transport it constructs. Client is now under the class-length limit, so its Metrics/ClassLength exemption goes (RuboCop reports it as redundant). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01KzJcBPQ8Vq4kjkxdm2VnYm --- lib/claude_agent_sdk.rb | 266 +++++------------- lib/claude_agent_sdk/session_assembly.rb | 252 +++++++++++++++++ lib/claude_agent_sdk/session_resume.rb | 8 +- spec/unit/client_spec.rb | 88 +++--- .../client_teardown_under_deadline_spec.rb | 42 +-- spec/unit/close_from_callback_spec.rb | 23 +- spec/unit/session_resume_nil_options_spec.rb | 24 +- spec/unit/session_resume_spec.rb | 59 ++-- 8 files changed, 474 insertions(+), 288 deletions(-) diff --git a/lib/claude_agent_sdk.rb b/lib/claude_agent_sdk.rb index 913bf1c3..5ce7e538 100644 --- a/lib/claude_agent_sdk.rb +++ b/lib/claude_agent_sdk.rb @@ -959,11 +959,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). @@ -977,10 +979,10 @@ def initialize(options: nil, transport_class: SubprocessCLITransport, transport_ 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. + @session = nil @connected = false - @materialized = nil end # Block-scoped Client lifecycle, mirroring Python's @@ -1053,18 +1055,25 @@ def connect(prompt = nil) 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 @@ -1073,14 +1082,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 @@ -1113,13 +1122,7 @@ def query(prompt, session_id: 'default') begin if prompt.is_a?(String) @dispatch.notify(:on_user_prompt, prompt) - 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)) + @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 @@ -1131,7 +1134,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 @@ -1149,16 +1152,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 - - @dispatch.notify(:on_message, message) - signal = @dispatch.invoke_iteration(block, message) - 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 @@ -1170,22 +1166,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 - - @dispatch.notify(:on_message, message) - signal = @dispatch.invoke_iteration(block, message) - 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 @@ -1194,7 +1178,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 @@ -1202,7 +1186,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'`. @@ -1216,7 +1200,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'`. @@ -1229,7 +1213,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 @@ -1238,7 +1222,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 @@ -1246,7 +1230,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 @@ -1272,7 +1256,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+) @@ -1282,13 +1266,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. @@ -1298,7 +1282,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. @@ -1312,7 +1296,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. @@ -1349,122 +1333,35 @@ 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 + # 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 and the state below is reset — so disconnect can never + # leave the client half-open or leak the temp dir. The original error + # still propagates. 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. - @dispatch.notify(:on_close) if @connected + @session&.close_resources(always_close_transport: true) 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 + @session = nil + @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: @dispatch.scheduling, - callback_wrapper: @dispatch.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 @@ -1483,8 +1380,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 = @dispatch.observing_stream(prompt) - @query_handler.spawn_task { @query_handler.stream_input(observed) } + @session.stream_prompt_in_background(prompt) end end @@ -1496,6 +1392,8 @@ 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 @@ -1504,12 +1402,12 @@ def stream_query_messages(prompt, session_id) if (text = ClaudeAgentSDK.extract_user_prompt_text(msg)) @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)) @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. @@ -1517,35 +1415,5 @@ def stream_query_messages(prompt, session_id) end end end - - # Notify observers of an error surfacing to the consumer. Before the - # first connect there are none to notify. - def notify_error(error) - @dispatch.notify(:on_error, error) - 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: @dispatch.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 index 0c5ed5dc..6dd2c26b 100644 --- a/lib/claude_agent_sdk/session_assembly.rb +++ b/lib/claude_agent_sdk/session_assembly.rb @@ -1,6 +1,10 @@ # 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 @@ -43,4 +47,252 @@ 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; the + # first error still propagates. + 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 + 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/unit/client_spec.rb b/spec/unit/client_spec.rb index 9c872c6a..928df1da 100644 --- a/spec/unit/client_spec.rb +++ b/spec/unit/client_spec.rb @@ -686,9 +686,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 +716,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 +1012,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_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/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 From bf8bb81654937b70dda81ff7244ea7a3cc29beac Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Tue, 6 Oct 2026 03:54:45 +0800 Subject: [PATCH 04/12] Rebuild query() on SessionAssembly query() still carried its own copy of everything Client now gets from SessionAssembly: resume materialization and repointing, transport construction, the fifteen-keyword Query.new, the transcript mirror install, the receive loop and the preserve-or-clean-up decision. A new Query keyword or a teardown fix had to be written in both places. query() is now the one-shot adapter over the same object. What makes it one-shot stays in it, inline and in the same order: - prompt validation, option configuration and observer resolution before the reactor task, and the Async(finished: false) task itself; - on_user_prompt, then write_prompt(..., session_id: '', verbatim: :current): the option is read after the notification, from the options the session runs on (the repointed copy once a store-backed resume was materialized), exactly where the old code read it; - the background wait_for_result_and_end_input for a String prompt; - rescue StandardError -> on_error, and an ensure that fires on_close for connect-phase failures too, then close_resources(always_close_transport: false): the transport is closed on its own only when no Query was built, and a raising close still replaces the original error. Giving query() Client's stronger close is a behaviour change for a follow-up. An injected transport is passed as { instance: } and gets no materialization; without one the session constructs SubprocessCLITransport after materializing, as before. The characterization spec passes unchanged. There is now one Query.new, one receive loop, one mirror install and one temp-dir decision in lib/. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01KzJcBPQ8Vq4kjkxdm2VnYm --- lib/claude_agent_sdk.rb | 195 ++++++++++++---------------------------- 1 file changed, 58 insertions(+), 137 deletions(-) diff --git a/lib/claude_agent_sdk.rb b/lib/claude_agent_sdk.rb index 5ce7e538..91378a50 100644 --- a/lib/claude_agent_sdk.rb +++ b/lib/claude_agent_sdk.rb @@ -733,6 +733,17 @@ def self.query(prompt:, options: nil, transport: nil, &block) # rubocop:disable 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 @@ -740,144 +751,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: dispatch.scheduling, - callback_wrapper: dispatch.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: dispatch.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) - dispatch.notify(:on_user_prompt, prompt) - 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 = dispatch.observing_stream(prompt) - 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 - - dispatch.notify(:on_message, message) - signal = dispatch.invoke_iteration(block, message) - 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. - dispatch.notify(:on_error, e) - raise - ensure - dispatch.notify(:on_close) - # 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 From 7b3b856b9e3b278b9b3a9f1fd091af35c47c73a4 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Tue, 6 Oct 2026 04:04:19 +0800 Subject: [PATCH 05/12] Test the session assembly at its interface, and both entry points side by side The mapping from options to the Query, the order of acquisition and the disposal rules were each tested twice, once through query() and once through Client, by capturing Query.new keywords behind doubles: twenty-six examples in two files that had to be kept in step by hand, and that said nothing about whether the two entry points agree. session_assembly_spec.rb is now the one place where - every one of the fifteen Query keywords is derived from options (hook normalization, SDK MCP instances, exclude_dynamic_sections and snapshot with false told from nil, the run-end ceiling, the dispatch pair); - each acquisition step is failed in turn and what was acquired before it is shown to be disposed of; - the eligibility rule for a materialized resume is spelled out per transport source; - the three verbatim modes of write_prompt, the receive loop and both shapes of close_resources are pinned. It calls only SessionAssembly's own methods. entry_point_parity_spec.rb runs one scenario through query() and through Client.open + query + receive_messages, against the same in-memory CLI and store-backed resume, under both scheduling modes with a wrapper, and compares what the caller got, what the block was given, what the observer was told and what became of the temp dir: a completed run, several results, a stream that dies, a block that raises or breaks, a mirror that dropped batches, a deadline during on_message. The differences that are meant to exist (session_id, stdin close, on_close after a failed connect, which error wins on a raising close) are listed as such, and nowhere else. It also passes against the lib of the commit before the extraction. The duplicated keyword examples go from query_api_spec and client_spec; each keeps one example that runs a real Query and reads the initialize request and the prompt off the wire. query_api_spec's inline streaming transport is replaced by the shared FakeCLI. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01KzJcBPQ8Vq4kjkxdm2VnYm --- spec/unit/client_spec.rb | 219 ++----- spec/unit/entry_point_parity_spec.rb | 390 +++++++++++++ spec/unit/query_api_spec.rb | 320 +---------- spec/unit/session_assembly_spec.rb | 815 +++++++++++++++++++++++++++ 4 files changed, 1263 insertions(+), 481 deletions(-) create mode 100644 spec/unit/entry_point_parity_spec.rb create mode 100644 spec/unit/session_assembly_spec.rb diff --git a/spec/unit/client_spec.rb b/spec/unit/client_spec.rb index 928df1da..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 } diff --git a/spec/unit/entry_point_parity_spec.rb b/spec/unit/entry_point_parity_spec.rb new file mode 100644 index 00000000..2bb6a65a --- /dev/null +++ b/spec/unit/entry_point_parity_spec.rb @@ -0,0 +1,390 @@ +# 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 exception. + def outcome_of + [:returned, yield] + rescue Exception => e # rubocop:disable Lint/RescueException -- a deadline may be a bare Exception + 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..0604e493 --- /dev/null +++ b/spec/unit/session_assembly_spec.rb @@ -0,0 +1,815 @@ +# 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 '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 + + # 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 From 1e84553e48b1840c5c78c51dae8e7a4c4ab73281 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Tue, 6 Oct 2026 04:06:55 +0800 Subject: [PATCH 06/12] Document SessionAssembly as the shared session implementation CLAUDE.md's "Two API Entry Points" said the difference between query() and Client is lifecycle, not protocol, but nothing said where the part they share lives. One sentence now names SessionAssembly (and Dispatch) as the single implementation of acquisition, prompt writes, delivery and disposal, states that it notifies and rescues nothing, and points at the two specs that pin the differences and the sameness. The FiberBoundary section's pointer to the loop that keeps `break` on the dequeue fiber follows the loop to SessionAssembly#deliver. CHANGELOG: an Unreleased "Changed" entry marked internal. No public API, signature or behaviour changed; sig/ is untouched. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01KzJcBPQ8Vq4kjkxdm2VnYm --- CHANGELOG.md | 3 +++ CLAUDE.md | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7a39e04..653a4840 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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 the observers / `callback_scheduling` / `callback_wrapper` of a session are bound once (`Dispatch`, `@api private`) instead of being passed at every call site. No public API, signature or behavior change: 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 kept as they were and are now pinned by specs that run both side by side. + ## [1.2.0] - 2026-10-03 Fixes from a review of the whole SDK, a reference for every option (`docs/options.md`), and Claude Code 2.1.288 as the pinned CLI. Settings the SDK used to drop without an error now take effect — hook outputs and `sandbox:` Hashes in Ruby spelling, a `tools:` String, option Hashes with a Symbol `type` — so read the entries in bold before upgrading: they say what changes for code that relied on the old behavior. diff --git a/CLAUDE.md b/CLAUDE.md index 062d7845..befbe6ed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,6 +44,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, binds a session's observers / `callback_scheduling` / `callback_wrapper` once): 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 notifies nothing and rescues nothing — what observers are told, which error wins and whether stdin closes stay in the two adapters (`spec/unit/entry_point_lifecycle_spec.rb` pins those differences, `spec/unit/entry_point_parity_spec.rb` everything else as identical). + ### CLI Discovery / Installer `SubprocessCLITransport#find_cli` probes, in order: `CLAUDE_CLI_PATH` (absolutized against the process cwd before use — the CLI is later spawned with `chdir: options.cwd`, where a relative path would name a different file) → `CLIInstaller.installed_path` (project-local vendored binary, deliberately ahead of `PATH` so a pinned install is hermetic) → `claude` on the process `PATH`, searched by the SDK itself (`#executable_on_path`; no `which` — it would be found through `PATH` too, relative entries included, and its answer would have to be believed; with no `PATH` at all, `DEFAULT_DISCOVERY_SEARCH_PATH`, Ruby's default without its `.`) → common install locations. @@ -109,7 +111,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). From 84d8f8217e677f642742b825b3968f7a59748b64 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Tue, 6 Oct 2026 04:49:14 +0800 Subject: [PATCH 07/12] Mark Client disconnected before the materialized dir is removed Client#disconnect used to reset its connected state after the transport close and before it dealt with the materialized resume dir. When the teardown moved into SessionAssembly#close_resources, the reset moved to after the whole of it. Removing the directory can take a while (it retries, sleeping, when the directory is busy) and the reactor runs other tasks meanwhile, so in that window the client still claimed to be connected while its handler and transport were already gone: - query, receive_messages, receive_response and the control methods got past the "Not connected" guard and raised NoMethodError on nil, where they raised CLIConnectionError before, and the first three notified on_user_prompt / on_error for a session that was closed; - a second disconnect notified on_close a second time. close_resources now takes a block and calls it once both closes are behind, before the directory is dealt with, also when a close raised. Client marks itself disconnected there: the old boundary. query() passes no block and is unchanged. The reproduction from review round 1 is pinned through the public API (client_teardown_interleavings_spec): a real Query and a really materialized resume, the directory's removal parked, the calls made from another task, under both scheduling modes with a wrapper. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01KzJcBPQ8Vq4kjkxdm2VnYm --- lib/claude_agent_sdk.rb | 18 ++- lib/claude_agent_sdk/session_assembly.rb | 13 +- .../client_teardown_interleavings_spec.rb | 120 ++++++++++++++++++ spec/unit/session_assembly_spec.rb | 36 ++++++ 4 files changed, 180 insertions(+), 7 deletions(-) create mode 100644 spec/unit/client_teardown_interleavings_spec.rb diff --git a/lib/claude_agent_sdk.rb b/lib/claude_agent_sdk.rb index 91378a50..ecb75d3c 100644 --- a/lib/claude_agent_sdk.rb +++ b/lib/claude_agent_sdk.rb @@ -1268,14 +1268,22 @@ def disconnect # 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 and the state below is reset — so disconnect can never - # leave the client half-open or leak the temp dir. The original error - # still propagates. + # still run — so disconnect can never leave the client half-open or + # leak the temp dir. 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. begin - @session&.close_resources(always_close_transport: true) + if @session + @session.close_resources(always_close_transport: true) { @connected = false } + else + @connected = false + end ensure @session = nil - @connected = false end end diff --git a/lib/claude_agent_sdk/session_assembly.rb b/lib/claude_agent_sdk/session_assembly.rb index 6dd2c26b..cf87f6a3 100644 --- a/lib/claude_agent_sdk/session_assembly.rb +++ b/lib/claude_agent_sdk/session_assembly.rb @@ -182,8 +182,16 @@ def deliver(block, until_result: false) # 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; the - # first error still propagates. + # 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. @@ -196,6 +204,7 @@ def close_resources(always_close_transport:) @transport&.close if always_close_transport || handler.nil? ensure @transport = nil + yield if block_given? dispose_of_materialized_resume(handler) 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..b9857167 --- /dev/null +++ b/spec/unit/client_teardown_interleavings_spec.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'async' +require 'fileutils' +require 'securerandom' +require 'timeout' +require 'tmpdir' + +# What other callers of a Client see while one of its teardowns is under way, +# or after one was cut short. 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, 'while a teardown is under way, or after one was cut short' 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 + end + end +end diff --git a/spec/unit/session_assembly_spec.rb b/spec/unit/session_assembly_spec.rb index 0604e493..bb9fe6ee 100644 --- a/spec/unit/session_assembly_spec.rb +++ b/spec/unit/session_assembly_spec.rb @@ -787,6 +787,42 @@ def without_handler end 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| From 34d409f8cec1ae49b5bcd18e842199d8256fc1be Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Tue, 6 Oct 2026 04:52:00 +0800 Subject: [PATCH 08/12] Keep a session whose teardown raised, so the next disconnect finishes it Client#disconnect dropped its SessionAssembly in an unconditional ensure. The assembly clears its materialized resume dir only after disposing of it, precisely so that a disposal that was cut short can be retried; but with the owner gone, nothing could reach it any more. When the removal of the directory was interrupted (the directory busy, and the caller's deadline expiring or its task being stopped during the wait before the retry), the next disconnect returned normally and left the directory, and the copy of the credentials and settings in it, behind. Before the extraction the client kept @materialized in that case and the second disconnect removed it. The session is now let go of only when close_resources returned. When it raised, the session stays: what was closed is not closed again (the handler and the transport are nil-ed inside), and what was left is disposed of by the next disconnect. The disconnected state is still reset inside the teardown, whatever it raises, and the error that propagates is the same. A session that a reconnect has replaced in the meantime is not cleared. Pinned through the public API with the reproduction from review round 1: a really materialized directory whose first removal fails with EBUSY, an interruption delivered during the retry sleep (an expired deadline, and a cancellation that is not a StandardError), then a second disconnect that removes the directory; both scheduling modes. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01KzJcBPQ8Vq4kjkxdm2VnYm --- lib/claude_agent_sdk.rb | 28 ++++++----- .../client_teardown_interleavings_spec.rb | 48 +++++++++++++++++++ spec/unit/session_assembly_spec.rb | 17 +++++++ 3 files changed, 82 insertions(+), 11 deletions(-) diff --git a/lib/claude_agent_sdk.rb b/lib/claude_agent_sdk.rb index ecb75d3c..61c20dfc 100644 --- a/lib/claude_agent_sdk.rb +++ b/lib/claude_agent_sdk.rb @@ -901,7 +901,8 @@ def initialize(options: nil, transport_class: SubprocessCLITransport, transport_ @transport_class = transport_class @transport_args = transport_args # Owns the transport, the query handler and the materialized resume dir - # of the current connection; nil while there is none. + # of the current connection; nil while there is none. Kept after a + # disconnect that raised, until one completes (see #disconnect). @session = nil @connected = false end @@ -1268,22 +1269,27 @@ def disconnect # 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 or - # leak the temp dir. The original error still propagates. + # 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. - begin - if @session - @session.close_resources(always_close_transport: true) { @connected = false } - else - @connected = false - end - ensure - @session = nil + # + # 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 diff --git a/spec/unit/client_teardown_interleavings_spec.rb b/spec/unit/client_teardown_interleavings_spec.rb index b9857167..f8b152e9 100644 --- a/spec/unit/client_teardown_interleavings_spec.rb +++ b/spec/unit/client_teardown_interleavings_spec.rb @@ -115,6 +115,54 @@ def not_connected 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 end end end diff --git a/spec/unit/session_assembly_spec.rb b/spec/unit/session_assembly_spec.rb index bb9fe6ee..3a899e7f 100644 --- a/spec/unit/session_assembly_spec.rb +++ b/spec/unit/session_assembly_spec.rb @@ -787,6 +787,23 @@ def without_handler 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 From 616450a271eb51cd1a9e02c9ff02ef251372551f Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Tue, 6 Oct 2026 04:54:40 +0800 Subject: [PATCH 09/12] Let a running receive notify the observers of the current connection Client resolves its observers on each connect, and its receive loop used to ask the client for them message by message. SessionAssembly was handed a Dispatch per connection instead (Client replaced its own on connect), so a receive that was still running when the client was disconnected and reconnected kept notifying the observers it had started with. With observer factories that is visible: the first connection's observer got an on_message after its on_close, and the new connection's observer never heard of the message. Client now keeps one Dispatch for its lifetime and puts the observers it resolves in on each connect; scheduling and wrapper stay as captured by the constructor. Each notification reads the observers there are at that moment, as an Array that is replaced and never changed in place, so a notification under way keeps iterating the one it began with. The session holds that same Dispatch. query() builds its own and never replaces its observers. Dispatch#with_observers, which had no other caller, is gone. Pinned through the public API with the reproduction from review round 1: a message block parked across disconnect and connect, observers from a factory, both scheduling modes with a wrapper. CLAUDE.md's sentence on SessionAssembly now says what stays with Client across a teardown, and names the spec. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01KzJcBPQ8Vq4kjkxdm2VnYm --- CLAUDE.md | 2 +- lib/claude_agent_sdk.rb | 6 +- lib/claude_agent_sdk/session_assembly.rb | 24 ++++---- .../client_teardown_interleavings_spec.rb | 57 +++++++++++++++++-- spec/unit/session_assembly_spec.rb | 10 ++++ 5 files changed, 79 insertions(+), 20 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index befbe6ed..5924d1a0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,7 +44,7 @@ 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, binds a session's observers / `callback_scheduling` / `callback_wrapper` once): 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 notifies nothing and rescues nothing — what observers are told, which error wins and whether stdin closes stay in the two adapters (`spec/unit/entry_point_lifecycle_spec.rb` pins those differences, `spec/unit/entry_point_parity_spec.rb` everything else as identical). +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 notifies nothing and rescues nothing — what observers are told, 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` everything else as identical). ### CLI Discovery / Installer diff --git a/lib/claude_agent_sdk.rb b/lib/claude_agent_sdk.rb index 61c20dfc..5bdb4d2f 100644 --- a/lib/claude_agent_sdk.rb +++ b/lib/claude_agent_sdk.rb @@ -894,8 +894,8 @@ def query_handler # @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 - # Scheduling and wrapper are captured here; the observers are resolved - # on each #connect. + # 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 @@ -973,7 +973,7 @@ 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. - @dispatch = @dispatch.with_observers(ClaudeAgentSDK.resolve_observers(@options.observers)) + @dispatch.observers = ClaudeAgentSDK.resolve_observers(@options.observers) ClaudeAgentSDK.check_inline_isolation(@dispatch.scheduling) diff --git a/lib/claude_agent_sdk/session_assembly.rb b/lib/claude_agent_sdk/session_assembly.rb index cf87f6a3..3885b1fb 100644 --- a/lib/claude_agent_sdk/session_assembly.rb +++ b/lib/claude_agent_sdk/session_assembly.rb @@ -9,9 +9,16 @@ 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) — bound once, 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. + # 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 @@ -19,19 +26,16 @@ class Dispatch # 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 - # The same scheduling and wrapper around other observers. Client captures - # the pair when it is constructed and resolves its observers on each - # connect. - def with_observers(observers) - self.class.new(observers, scheduling: @scheduling, wrapper: @wrapper) - end - # See ClaudeAgentSDK.notify_observers. def notify(method, *) ClaudeAgentSDK.notify_observers(@observers, method, *, scheduling: @scheduling, wrapper: @wrapper) diff --git a/spec/unit/client_teardown_interleavings_spec.rb b/spec/unit/client_teardown_interleavings_spec.rb index f8b152e9..d0e7d994 100644 --- a/spec/unit/client_teardown_interleavings_spec.rb +++ b/spec/unit/client_teardown_interleavings_spec.rb @@ -7,17 +7,18 @@ require 'timeout' require 'tmpdir' -# What other callers of a Client see while one of its teardowns is under way, -# or after one was cut short. 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. +# 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, 'while a teardown is under way, or after one was cut short' do +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 @@ -163,6 +164,50 @@ def not_connected 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/session_assembly_spec.rb b/spec/unit/session_assembly_spec.rb index 3a899e7f..a12691ea 100644 --- a/spec/unit/session_assembly_spec.rb +++ b/spec/unit/session_assembly_spec.rb @@ -614,6 +614,16 @@ def delivering(frames, with: dispatch) 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 = [] From e7248ead265f1a95fa39818ae4901a19b2329599 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Tue, 6 Oct 2026 04:56:49 +0800 Subject: [PATCH 10/12] Rescue StandardError in the parity spec's outcome helper The helper rescued Exception under a Lint/RescueException exemption "in case a deadline is a bare Exception", but every scenario of the parity spec fails with a StandardError, the deadline's Async::TimeoutError included. Rescuing what the scenarios raise needs no exemption, and one of the five RuboCop directives the branch added to spec/ goes with it. The other four are in entry_point_lifecycle_spec.rb, which rescues Async::Stop and builds a cancellation that is not a StandardError; that file is the characterization that has to stay byte-identical, so they stay. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01KzJcBPQ8Vq4kjkxdm2VnYm --- spec/unit/entry_point_parity_spec.rb | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/spec/unit/entry_point_parity_spec.rb b/spec/unit/entry_point_parity_spec.rb index 2bb6a65a..5bbdd237 100644 --- a/spec/unit/entry_point_parity_spec.rb +++ b/spec/unit/entry_point_parity_spec.rb @@ -76,10 +76,12 @@ def result_frame(turns) EntryPointHarness::RESULT_FRAME.merge(num_turns: turns) end - # What the caller got from the call: [:returned, value], or the exception. + # 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 Exception => e # rubocop:disable Lint/RescueException -- a deadline may be a bare Exception + rescue StandardError => e e end From c5a8c1ac94151a48ff6e1897c74ea8bad5fb974c Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Tue, 6 Oct 2026 05:25:27 +0800 Subject: [PATCH 11/12] Pin a reconnect while the old resume dir is still being removed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Client is disconnected from the moment the removal of its materialized resume dir starts, so it can be connected again while that removal is still going. The disconnect that finishes afterwards owns the old session only: Client#disconnect clears @session only if it is still the session it tore down. Without that check the late disconnect let go of the new session — the client then claimed to be connected with nothing behind it, and the new session's own resume dir, with its credentials copy, was out of reach of the disconnect that ended it. Nothing pinned that check: review round 2 found the interleaving with a probe, not in the committed examples. It is now a public-API example next to the other teardown interleavings, under both scheduling modes: park the old directory's removal, reconnect, let the old disconnect finish, use the new session, disconnect it; the messages, the observer's notifications and both transport closes are as expected and the new resume dir is removed. It fails when the check is taken out. Test only. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01KzJcBPQ8Vq4kjkxdm2VnYm --- .../client_teardown_interleavings_spec.rb | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/spec/unit/client_teardown_interleavings_spec.rb b/spec/unit/client_teardown_interleavings_spec.rb index d0e7d994..2b3fb2a2 100644 --- a/spec/unit/client_teardown_interleavings_spec.rb +++ b/spec/unit/client_teardown_interleavings_spec.rb @@ -165,6 +165,56 @@ def not_connected 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 From ad8808fd69cce5826147193534d07a515fafd043 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Tue, 6 Oct 2026 05:43:32 +0800 Subject: [PATCH 12/12] Qualify the CHANGELOG's behaviour claim for the session assembly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Unreleased entry said "No public API, signature or behavior change". The reviews established one difference from main, and an absolute claim hides it: when a Client is reconnected while its previous disconnect is still removing the materialized directory of a store-backed resume, the old disconnect used to clear the new connection's hold on its own resume directory when it finished, and that directory was then left behind. The old disconnect now leaves the new session alone, so the new session's directory is removed by its own disconnect (client_teardown_interleavings_spec pins it). The entry now says what is preserved — public signatures and the documented differences between the two entry points — and names that one cleanup difference. It makes no claim about retrying an interrupted removal: a later disconnect finishing it is what main already did. Two wordings the PR-level review flagged as imprecise are tightened with it. The entry said the observers are "bound once"; scheduling and wrapper are captured by Client's constructor, the observers are resolved on each connect. CLAUDE.md said SessionAssembly "notifies nothing"; it never notifies on_error or on_close, and it does dispatch on_message. CLAUDE.md also no longer says the parity spec pins "everything else" as identical: it pins the scenarios it runs. Docs only. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01KzJcBPQ8Vq4kjkxdm2VnYm --- CHANGELOG.md | 2 +- CLAUDE.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 653a4840..987a6a9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ 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 the observers / `callback_scheduling` / `callback_wrapper` of a session are bound once (`Dispatch`, `@api private`) instead of being passed at every call site. No public API, signature or behavior change: 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 kept as they were and are now pinned by specs that run both side by side. +- 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). ## [1.2.0] - 2026-10-03 diff --git a/CLAUDE.md b/CLAUDE.md index 5924d1a0..16b4b4fc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,7 +44,7 @@ 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 notifies nothing and rescues nothing — what observers are told, 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` everything else as identical). +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). ### CLI Discovery / Installer