require "test_helper" class Assistant::External::ClientTest < ActiveSupport::TestCase setup do @client = Assistant::External::Client.new( url: "http://localhost:18789/v1/chat", token: "test-token", agent_id: "test-agent" ) end test "streams text chunks from SSE response" do sse_body = <<~SSE data: {"id":"chatcmpl-1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}],"model":"test-agent"} data: {"id":"chatcmpl-1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"Your net worth"},"finish_reason":null}],"model":"test-agent"} data: {"id":"chatcmpl-1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":" is $124,200."},"finish_reason":null}],"model":"test-agent"} data: {"id":"chatcmpl-1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"model":"test-agent"} data: [DONE] SSE mock_http_streaming_response(sse_body) chunks = [] model = @client.chat(messages: [ { role: "user", content: "test" } ]) do |text| chunks << text end assert_equal [ "Your net worth", " is $124,200." ], chunks assert_equal "test-agent", model end test "raises on non-200 response" do mock_http_error_response(503, "Service Unavailable") assert_raises(Assistant::Error) do @client.chat(messages: [ { role: "user", content: "test" } ]) { |_| } end end test "retries transient errors then raises Assistant::Error" do Net::HTTP.any_instance.stubs(:request).raises(Net::OpenTimeout, "connection timed out") error = assert_raises(Assistant::Error) do @client.chat(messages: [ { role: "user", content: "test" } ]) { |_| } end assert_match(/temporarily unavailable/, error.message) end test "does not retry after streaming has started" do call_count = 0 # Custom response that yields one chunk then raises mid-stream mock_response = Object.new mock_response.define_singleton_method(:is_a?) { |klass| klass == Net::HTTPSuccess } mock_response.define_singleton_method(:read_body) do |&blk| blk.call("data: {\"choices\":[{\"delta\":{\"content\":\"partial\"}}],\"model\":\"m\"}\n\n") raise Errno::ECONNRESET, "connection reset mid-stream" end mock_http = stub("http") mock_http.stubs(:use_ssl=) mock_http.stubs(:open_timeout=) mock_http.stubs(:read_timeout=) mock_http.define_singleton_method(:request) do |_req, &blk| call_count += 1 blk.call(mock_response) end Net::HTTP.stubs(:new).returns(mock_http) chunks = [] error = assert_raises(Assistant::Error) do @client.chat(messages: [ { role: "user", content: "test" } ]) { |t| chunks << t } end assert_equal 1, call_count, "Should not retry after streaming started" assert_equal [ "partial" ], chunks assert_match(/connection was interrupted/, error.message) end test "builds correct request payload" do sse_body = "data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}],\"model\":\"m\"}\n\ndata: [DONE]\n\n" capture = mock_http_streaming_response(sse_body) @client.chat( messages: [ { role: "user", content: "Hello" }, { role: "assistant", content: "Hi there" }, { role: "user", content: "What is my balance?" } ], user: "sure-family-42" ) { |_| } body = JSON.parse(capture[0].body) assert_equal "test-agent", body["model"] assert_equal true, body["stream"] assert_equal 3, body["messages"].size assert_equal "sure-family-42", body["user"] end test "sets authorization header and agent_id header" do sse_body = "data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}],\"model\":\"m\"}\n\ndata: [DONE]\n\n" capture = mock_http_streaming_response(sse_body) @client.chat(messages: [ { role: "user", content: "test" } ]) { |_| } assert_equal "Bearer test-token", capture[0]["Authorization"] assert_equal "test-agent", capture[0]["X-Agent-Id"] assert_equal "agent:main:main", capture[0]["X-Session-Key"] assert_equal "text/event-stream", capture[0]["Accept"] assert_equal "application/json", capture[0]["Content-Type"] end test "omits user field when not provided" do sse_body = "data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}],\"model\":\"m\"}\n\ndata: [DONE]\n\n" capture = mock_http_streaming_response(sse_body) @client.chat(messages: [ { role: "user", content: "test" } ]) { |_| } body = JSON.parse(capture[0].body) assert_not body.key?("user") end test "handles malformed JSON in SSE data gracefully" do sse_body = "data: {not valid json}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\"OK\"}}],\"model\":\"m\"}\n\ndata: [DONE]\n\n" mock_http_streaming_response(sse_body) chunks = [] @client.chat(messages: [ { role: "user", content: "test" } ]) { |t| chunks << t } assert_equal [ "OK" ], chunks end test "handles SSE data: field without space after colon (spec-compliant)" do sse_body = "data:{\"choices\":[{\"delta\":{\"content\":\"no space\"}}],\"model\":\"m\"}\n\ndata:[DONE]\n\n" mock_http_streaming_response(sse_body) chunks = [] @client.chat(messages: [ { role: "user", content: "test" } ]) { |t| chunks << t } assert_equal [ "no space" ], chunks end test "handles chunked SSE data split across read_body calls" do chunk1 = "data: {\"choices\":[{\"delta\":{\"content\":\"Hel" chunk2 = "lo\"}}],\"model\":\"m\"}\n\ndata: [DONE]\n\n" mock_http_streaming_response_chunked([ chunk1, chunk2 ]) chunks = [] @client.chat(messages: [ { role: "user", content: "test" } ]) { |t| chunks << t } assert_equal [ "Hello" ], chunks end test "routes through HTTPS_PROXY when set" do sse_body = "data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}],\"model\":\"m\"}\n\ndata: [DONE]\n\n" mock_response = stub("response") mock_response.stubs(:code).returns("200") mock_response.stubs(:is_a?).with(Net::HTTPSuccess).returns(true) mock_response.stubs(:read_body).yields(sse_body) mock_http = stub("http") mock_http.stubs(:use_ssl=) mock_http.stubs(:open_timeout=) mock_http.stubs(:read_timeout=) mock_http.stubs(:request).yields(mock_response) captured_args = nil Net::HTTP.stubs(:new).with do |*args| captured_args = args true end.returns(mock_http) client = Assistant::External::Client.new( url: "https://example.com/v1/chat", token: "test-token" ) ClimateControl.modify(HTTPS_PROXY: "http://proxyuser:proxypass@proxy:8888") do client.chat(messages: [ { role: "user", content: "test" } ]) { |_| } end assert_equal "example.com", captured_args[0] assert_equal 443, captured_args[1] assert_equal "proxy", captured_args[2] assert_equal 8888, captured_args[3] assert_equal "proxyuser", captured_args[4] assert_equal "proxypass", captured_args[5] end test "skips proxy for hosts in NO_PROXY" do sse_body = "data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}],\"model\":\"m\"}\n\ndata: [DONE]\n\n" mock_response = stub("response") mock_response.stubs(:code).returns("200") mock_response.stubs(:is_a?).with(Net::HTTPSuccess).returns(true) mock_response.stubs(:read_body).yields(sse_body) mock_http = stub("http") mock_http.stubs(:use_ssl=) mock_http.stubs(:open_timeout=) mock_http.stubs(:read_timeout=) mock_http.stubs(:request).yields(mock_response) captured_args = nil Net::HTTP.stubs(:new).with do |*args| captured_args = args true end.returns(mock_http) client = Assistant::External::Client.new( url: "http://agent.internal.example.com:18789/v1/chat", token: "test-token" ) ClimateControl.modify(HTTP_PROXY: "http://proxy:8888", NO_PROXY: "localhost,.example.com") do client.chat(messages: [ { role: "user", content: "test" } ]) { |_| } end # Should NOT pass proxy args — only host and port assert_equal 2, captured_args.length end private def mock_http_streaming_response(sse_body) capture = [] mock_response = stub("response") mock_response.stubs(:code).returns("200") mock_response.stubs(:is_a?).with(Net::HTTPSuccess).returns(true) mock_response.stubs(:read_body).yields(sse_body) mock_http = stub("http") mock_http.stubs(:use_ssl=) mock_http.stubs(:open_timeout=) mock_http.stubs(:read_timeout=) mock_http.stubs(:request).with do |req| capture[0] = req true end.yields(mock_response) Net::HTTP.stubs(:new).returns(mock_http) capture end def mock_http_streaming_response_chunked(chunks) mock_response = stub("response") mock_response.stubs(:code).returns("200") mock_response.stubs(:is_a?).with(Net::HTTPSuccess).returns(true) mock_response.stubs(:read_body).multiple_yields(*chunks.map { |c| [ c ] }) mock_http = stub("http") mock_http.stubs(:use_ssl=) mock_http.stubs(:open_timeout=) mock_http.stubs(:read_timeout=) mock_http.stubs(:request).yields(mock_response) Net::HTTP.stubs(:new).returns(mock_http) end def mock_http_error_response(code, message) mock_response = stub("response") mock_response.stubs(:code).returns(code.to_s) mock_response.stubs(:is_a?).with(Net::HTTPSuccess).returns(false) mock_response.stubs(:body).returns(message) mock_http = stub("http") mock_http.stubs(:use_ssl=) mock_http.stubs(:open_timeout=) mock_http.stubs(:read_timeout=) mock_http.stubs(:request).yields(mock_response) Net::HTTP.stubs(:new).returns(mock_http) end end