# frozen_string_literal: true require "webmock/rspec" CHAT_STREAMING_SSE_DIR = File.expand_path("../../../fixtures/sse", __dir__) RSpec.describe Dispatch::Adapter::Claude, "#chat (streaming integration)" do let(:model_id) { "claude-sonnet-4-5-20250929" } let(:base_url) { "https://api.anthropic.com" } subject(:adapter) do described_class.new( model: model_id, api_key: "sk-ant-api03-test", base_url: base_url ) end let(:messages) do [Dispatch::Adapter::Message.new( role: "user", content: [Dispatch::Adapter::TextBlock.new(text: "Hello")] )] end before do WebMock.disable_net_connect! allow(adapter).to receive(:sleep) end after { WebMock.reset! } def load_sse(filename) File.read(File.join(CHAT_STREAMING_SSE_DIR, filename)) end def stub_sse(body, status: 200) stub_request(:post, "#{base_url}/v1/messages") .to_return( status: status, body: body, headers: { "Content-Type" => "text/event-stream" } ) end # ── Scenario 1: text-only stream ───────────────────────────────────────── describe "scenario 1: text-only stream (text-only.sse)" do before { stub_sse(load_sse("text-only.sse")) } it "returns a Response" do expect(adapter.chat(messages, stream: true)).to be_a(Dispatch::Adapter::Response) end it "yields :text_start, :text_delta, :text_delta, :text_end events in order" do deltas = [] adapter.chat(messages, stream: true) { |d| deltas << d } expect(deltas.map(&:type)).to eq(%i[text_start text_delta text_delta text_end]) end it "text deltas carry the expected text fragments" do deltas = [] adapter.chat(messages, stream: true) { |d| deltas << d } text_delta_texts = deltas.select { |d| d.type == :text_delta }.map(&:text) expect(text_delta_texts).to eq(["Hello, ", "world!"]) end it "concatenated text delta content equals the full response text" do deltas = [] adapter.chat(messages, stream: true) { |d| deltas << d } full_text = deltas.select { |d| d.type == :text_delta }.map(&:text).join expect(full_text).to eq("Hello, world!") end it "Response has stop_reason :end_turn" do expect(adapter.chat(messages, stream: true).stop_reason).to eq(:end_turn) end it "Response usage.cost is a UsageCost" do cost = adapter.chat(messages, stream: true).usage.cost expect(cost).to be_a(Dispatch::Adapter::UsageCost) end it "Response usage.cost.total is positive" do cost = adapter.chat(messages, stream: true).usage.cost expect(cost.total).to be > 0 end it "Response usage.input_tokens matches the fixture" do expect(adapter.chat(messages, stream: true).usage.input_tokens).to eq(15) end it "Response usage.output_tokens matches the fixture" do expect(adapter.chat(messages, stream: true).usage.output_tokens).to eq(8) end end # ── Scenario 2: tool_use stream ─────────────────────────────────────────── describe "scenario 2: tool_use stream (tool-use.sse)" do before { stub_sse(load_sse("tool-use.sse")) } it "yields :tool_use_start, :tool_use_delta × 2, :tool_use_end in order" do deltas = [] adapter.chat(messages, stream: true) { |d| deltas << d } expect(deltas.map(&:type)).to eq(%i[tool_use_start tool_use_delta tool_use_delta tool_use_end]) end it ":tool_use_start delta carries the correct id and name" do deltas = [] adapter.chat(messages, stream: true) { |d| deltas << d } start_delta = deltas.find { |d| d.type == :tool_use_start } expect(start_delta.tool_call_id).to eq("toolu_stream01") expect(start_delta.tool_name).to eq("bash") end it ":tool_use_delta events carry the correct argument fragments" do deltas = [] adapter.chat(messages, stream: true) { |d| deltas << d } json_deltas = deltas.select { |d| d.type == :tool_use_delta } expect(json_deltas.map(&:argument_delta).join).to eq('{"command":"ls -la"}') end it "Response has stop_reason :tool_use" do expect(adapter.chat(messages, stream: true).stop_reason).to eq(:tool_use) end it "Response.tool_calls has exactly one entry" do response = adapter.chat(messages, stream: true) expect(response.tool_calls.length).to eq(1) end it "Response.tool_calls[0] is a ToolUseBlock" do tc = adapter.chat(messages, stream: true).tool_calls.first expect(tc).to be_a(Dispatch::Adapter::ToolUseBlock) end it "Response.tool_calls[0].arguments matches the parsed JSON" do tc = adapter.chat(messages, stream: true).tool_calls.first expect(tc.arguments).to eq({ "command" => "ls -la" }) end it "Response.tool_calls[0].name is 'bash'" do tc = adapter.chat(messages, stream: true).tool_calls.first expect(tc.name).to eq("bash") end it "Response usage.cost.total is positive" do expect(adapter.chat(messages, stream: true).usage.cost.total).to be > 0 end end # ── Scenario 3: thinking-then-text stream ──────────────────────────────── describe "scenario 3: thinking-then-text stream (thinking-then-text.sse)" do before { stub_sse(load_sse("thinking-then-text.sse")) } it "yields :thinking_start, :thinking_delta, :thinking_end, :text_start, :text_delta, :text_end" do deltas = [] adapter.chat(messages, stream: true) { |d| deltas << d } expect(deltas.map(&:type)).to eq( %i[thinking_start thinking_delta thinking_end text_start text_delta text_end] ) end it ":thinking_delta carries the expected thinking text" do deltas = [] adapter.chat(messages, stream: true) { |d| deltas << d } thinking_delta = deltas.find { |d| d.type == :thinking_delta } expect(thinking_delta.text).to eq("Let me think about this...") end it ":text_delta carries the expected text" do deltas = [] adapter.chat(messages, stream: true) { |d| deltas << d } text_delta = deltas.find { |d| d.type == :text_delta } expect(text_delta.text).to eq("The answer is 42.") end it "Response has stop_reason :end_turn" do expect(adapter.chat(messages, stream: true).stop_reason).to eq(:end_turn) end it "Response.content has 2 items (thinking + text)" do expect(adapter.chat(messages, stream: true).content.length).to eq(2) end it "Response.content[0] is a ThinkingBlock" do content = adapter.chat(messages, stream: true).content expect(content[0]).to be_a(Dispatch::Adapter::ThinkingBlock) end it "Response.content[0].thinking has the correct text" do content = adapter.chat(messages, stream: true).content expect(content[0].thinking).to eq("Let me think about this...") end it "Response.content[1] is a TextBlock" do content = adapter.chat(messages, stream: true).content expect(content[1]).to be_a(Dispatch::Adapter::TextBlock) end it "Response.content[1].text has the correct text" do content = adapter.chat(messages, stream: true).content expect(content[1].text).to eq("The answer is 42.") end it "Response usage.cost.total is positive" do expect(adapter.chat(messages, stream: true).usage.cost.total).to be > 0 end end # ── Scenario 4: truncated before message_start ─────────────────────────── describe "scenario 4: truncated before message_start (truncated-before-message-start.sse)" do before do truncated_body = load_sse("truncated-before-message-start.sse") stub_request(:post, "#{base_url}/v1/messages") .to_return( status: 200, body: truncated_body, headers: { "Content-Type" => "text/event-stream" } ).times(4) # initial + 3 retries = 4 total, all fail before message_start end it "returns a Response with stop_reason :error after exhausting retries" do response = adapter.chat(messages, stream: true) expect(response.stop_reason).to eq(:error) end it "does not raise" do expect { adapter.chat(messages, stream: true) }.not_to raise_error end it "calls sleep exactly 3 times (one per retry)" do adapter.chat(messages, stream: true) expect(adapter).to have_received(:sleep).exactly(3).times end it "uses exponential back-off delays (2s, 4s, 8s)" do delays = [] allow(adapter).to receive(:sleep) { |s| delays << s } adapter.chat(messages, stream: true) expect(delays).to eq([2.0, 4.0, 8.0]) end end # ── Scenario 5: truncated mid-text ─────────────────────────────────────── describe "scenario 5: truncated mid-text (truncated-mid-text.sse)" do before { stub_sse(load_sse("truncated-mid-text.sse")) } it "raises a RequestError (stream truncated after partial output)" do expect { adapter.chat(messages, stream: true) } .to raise_error(Dispatch::Adapter::RequestError, /invalid JSON|incomplete frame/i) end it "does NOT call sleep (no retry because consumer output was emitted)" do begin adapter.chat(messages, stream: true) rescue Dispatch::Adapter::RequestError nil end expect(adapter).not_to have_received(:sleep) end it "yields :text_start and :text_delta events before raising" do deltas = [] begin adapter.chat(messages, stream: true) { |d| deltas << d } rescue Dispatch::Adapter::RequestError nil end types = deltas.map(&:type) expect(types).to include(:text_start, :text_delta) end end end