# frozen_string_literal: true require "webmock/rspec" RSpec.describe Dispatch::Adapter::Claude, "strict-tool fallback" do let(:model_id) { "claude-sonnet-4-6" } let(:api_key) { "sk-ant-api03-test" } let(:base_url) { "https://api.anthropic.com" } subject(:adapter) do described_class.new( model: model_id, api_key: api_key, base_url: base_url ) end let(:messages) do [Dispatch::Adapter::Message.new( role: "user", content: [Dispatch::Adapter::TextBlock.new(text: "Hello")] )] end before { WebMock.disable_net_connect! } after { WebMock.reset! } # ── Fixtures ──────────────────────────────────────────────────────────────── let(:grammar_error_body) do JSON.generate( "type" => "error", "error" => { "type" => "invalid_request_error", "message" => "The compiled grammar is too large. Please simplify your tool schema." } ) end let(:complex_schema_error_body) do JSON.generate( "type" => "error", "error" => { "type" => "invalid_request_error", "message" => "Schema is too complex to compil for this request." } ) end let(:unrelated_400_body) do JSON.generate( "type" => "error", "error" => { "type" => "invalid_request_error", "message" => "messages: roles must alternate between \"user\" and \"assistant\"" } ) end let(:success_response) do { "id" => "msg_01", "type" => "message", "role" => "assistant", "model" => model_id, "stop_reason" => "end_turn", "content" => [{ "type" => "text", "text" => "Hello!" }], "usage" => { "input_tokens" => 10, "output_tokens" => 5 } } end # ── Non-streaming: grammar-too-large fallback ──────────────────────────────── describe "#chat (non-streaming) — grammar error → fallback" do context "when the first request returns 'compiled grammar too large'" do before do stub_request(:post, "#{base_url}/v1/messages") .to_return( { status: 400, body: grammar_error_body, headers: { "Content-Type" => "application/json" } }, { status: 200, body: JSON.generate(success_response), headers: { "Content-Type" => "application/json" } } ) end it "retries and returns a successful Response" do response = adapter.chat(messages) expect(response).to be_a(Dispatch::Adapter::Response) expect(response.stop_reason).to eq(:end_turn) end it "sets @strict_disabled to true after the error" do adapter.chat(messages) expect(adapter.instance_variable_get(:@strict_disabled)).to be(true) end it "makes exactly two HTTP requests" do adapter.chat(messages) expect(WebMock).to have_requested(:post, "#{base_url}/v1/messages").twice end end context "when the first request returns 'schema too complex to compil'" do before do stub_request(:post, "#{base_url}/v1/messages") .to_return( { status: 400, body: complex_schema_error_body, headers: { "Content-Type" => "application/json" } }, { status: 200, body: JSON.generate(success_response), headers: { "Content-Type" => "application/json" } } ) end it "retries and returns a successful Response" do response = adapter.chat(messages) expect(response).to be_a(Dispatch::Adapter::Response) end it "sets @strict_disabled to true" do adapter.chat(messages) expect(adapter.instance_variable_get(:@strict_disabled)).to be(true) end end context "when both first and retry requests fail with grammar error" do before do stub_request(:post, "#{base_url}/v1/messages") .to_return( status: 400, body: grammar_error_body, headers: { "Content-Type" => "application/json" } ) end it "raises RequestError after the retry also fails" do expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::RequestError) end end end # ── Non-streaming: @strict_disabled persists for subsequent calls ──────────── describe "#chat (non-streaming) — @strict_disabled persists" do before do # First call: 400 grammar error → retry → success # Second call: success immediately (strict already disabled) stub_request(:post, "#{base_url}/v1/messages") .to_return( { status: 400, body: grammar_error_body, headers: { "Content-Type" => "application/json" } }, { status: 200, body: JSON.generate(success_response), headers: { "Content-Type" => "application/json" } }, { status: 200, body: JSON.generate(success_response), headers: { "Content-Type" => "application/json" } } ) end it "@strict_disabled remains true after the first fallback call" do adapter.chat(messages) # triggers fallback → sets @strict_disabled = true expect(adapter.instance_variable_get(:@strict_disabled)).to be(true) adapter.chat(messages) # subsequent call — should still be true expect(adapter.instance_variable_get(:@strict_disabled)).to be(true) end it "does not re-trigger the grammar fallback on the second call (only 3 requests total)" do adapter.chat(messages) adapter.chat(messages) # 1st call: [400 → retry 200] = 2 requests; 2nd call: [200] = 1 request → 3 total expect(WebMock).to have_requested(:post, "#{base_url}/v1/messages").times(3) end end # ── Non-streaming: non-grammar 400 errors are NOT retried ─────────────────── describe "#chat (non-streaming) — unrelated 400 is NOT retried" do before do stub_request(:post, "#{base_url}/v1/messages") .to_return( status: 400, body: unrelated_400_body, headers: { "Content-Type" => "application/json" } ) end it "raises RequestError without retrying" do expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::RequestError) end it "makes only one HTTP request" do adapter.chat(messages) rescue Dispatch::Adapter::RequestError nil ensure expect(WebMock).to have_requested(:post, "#{base_url}/v1/messages").once end it "does NOT set @strict_disabled" do begin adapter.chat(messages) rescue Dispatch::Adapter::RequestError nil end expect(adapter.instance_variable_get(:@strict_disabled)).to be(false) end end # ── Streaming: grammar-too-large fallback ──────────────────────────────────── describe "#chat (streaming) — grammar error → fallback" do let(:complete_sse_stream) do <<~SSE event: message_start data: {"type":"message_start","message":{"id":"msg_01","model":"#{model_id}","usage":{"input_tokens":10,"output_tokens":0}}} event: content_block_start data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} event: content_block_delta data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello!"}} event: content_block_stop data: {"type":"content_block_stop","index":0} event: message_delta data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}} event: message_stop data: {"type":"message_stop"} SSE end before do allow(adapter).to receive(:sleep) # suppress real sleeps stub_request(:post, "#{base_url}/v1/messages") .to_return( { status: 400, body: grammar_error_body, headers: { "Content-Type" => "application/json" } }, { status: 200, body: complete_sse_stream, headers: { "Content-Type" => "text/event-stream" } } ) end it "retries and returns a successful Response" do response = adapter.chat(messages, stream: true) expect(response).to be_a(Dispatch::Adapter::Response) expect(response.stop_reason).to eq(:end_turn) end it "sets @strict_disabled to true" do adapter.chat(messages, stream: true) expect(adapter.instance_variable_get(:@strict_disabled)).to be(true) end it "makes exactly two HTTP requests" do adapter.chat(messages, stream: true) expect(WebMock).to have_requested(:post, "#{base_url}/v1/messages").twice end end # ── Streaming: non-grammar 400 is NOT retried ──────────────────────────────── describe "#chat (streaming) — unrelated 400 is NOT retried" do before do allow(adapter).to receive(:sleep) stub_request(:post, "#{base_url}/v1/messages") .to_return( status: 400, body: unrelated_400_body, headers: { "Content-Type" => "application/json" } ) end it "raises RequestError without retrying" do expect { adapter.chat(messages, stream: true) }.to raise_error(Dispatch::Adapter::RequestError) end it "does NOT set @strict_disabled" do begin adapter.chat(messages, stream: true) rescue Dispatch::Adapter::RequestError nil end expect(adapter.instance_variable_get(:@strict_disabled)).to be(false) end end # ── strict_grammar_error? unit tests ───────────────────────────────────────── describe "#strict_grammar_error? (private)" do def grammar_error(msg) Dispatch::Adapter::RequestError.new(msg, status_code: 400, provider: "Anthropic (Claude)") end it "returns true for 'compiled grammar ... too large'" do err = grammar_error("The compiled grammar is too large.") expect(adapter.send(:strict_grammar_error?, err)).to be(true) end it "returns true for 'schema ... too complex ... compil'" do err = grammar_error("Schema is too complex to compil for this tool.") expect(adapter.send(:strict_grammar_error?, err)).to be(true) end it "returns false for an unrelated 400" do err = grammar_error("roles must alternate between user and assistant") expect(adapter.send(:strict_grammar_error?, err)).to be(false) end it "returns false for a non-400 RequestError" do err = Dispatch::Adapter::RequestError.new("Bad request", status_code: 422, provider: "Anthropic (Claude)") expect(adapter.send(:strict_grammar_error?, err)).to be(false) end it "returns false for a non-RequestError exception" do expect(adapter.send(:strict_grammar_error?, RuntimeError.new("boom"))).to be(false) end it "returns false for nil" do expect(adapter.send(:strict_grammar_error?, nil)).to be(false) end end end