# frozen_string_literal: true require "minitest/autorun" require "webmock/minitest" require "json" require "pp" require "stringio" require "clawdforge" # Reuse BASE_URL/TOKEN/build_client from test_client.rb when both are loaded # in the same `rake test` run; redeclare defensively so this file also runs # standalone via `ruby -Ilib -Itest test/test_session.rb`. BASE_URL = "http://forge.test:8800" unless defined?(BASE_URL) TOKEN = "cf_test_token_xxxxxxxx" unless defined?(TOKEN) unless defined?(build_client) def build_client(**overrides) Clawdforge::Client.new( base_url: BASE_URL, token: TOKEN, default_timeout: 60, **overrides, ) end end # Helper: stub `POST /sessions` returning a freshly minted session id. def stub_create_session(session_id: "sess_abc123", agent: "claude", created_at: 1_700_000_000, cwd: "/tmp/cf-sess/abc123") stub_request(:post, "#{BASE_URL}/sessions").to_return( status: 200, headers: { "Content-Type" => "application/json" }, body: JSON.generate( "ok" => true, "session_id" => session_id, "agent" => agent, "created_at" => created_at, "cwd" => cwd, ), ) end def stub_close_session(session_id: "sess_abc123", already_closed: false) body = { "ok" => true } body["already_closed"] = true if already_closed stub_request(:delete, "#{BASE_URL}/sessions/#{session_id}").to_return( status: 200, headers: { "Content-Type" => "application/json" }, body: JSON.generate(body), ) end class SessionBlockFormTest < Minitest::Test def test_session_block_form_auto_closes stub_create_session stub_close_session yielded = nil out = build_client.session(agent: "claude") do |s| yielded = s assert_kind_of Clawdforge::Session, s assert_equal "sess_abc123", s.id assert_equal "claude", s.agent "block-return-value" end # Block return value flows through. assert_equal "block-return-value", out # Block-form auto-close fired. assert yielded.closed?, "session should be closed after block exit" assert_requested :post, "#{BASE_URL}/sessions", times: 1 assert_requested :delete, "#{BASE_URL}/sessions/sess_abc123", times: 1 end def test_session_block_closes_on_exception stub_create_session stub_close_session err = assert_raises(RuntimeError) do build_client.session do |_s| raise "boom" end end assert_equal "boom", err.message # DELETE fires even though the block raised. assert_requested :delete, "#{BASE_URL}/sessions/sess_abc123", times: 1 end def test_session_block_swallows_close_errors_to_let_exception_propagate stub_create_session stub_request(:delete, "#{BASE_URL}/sessions/sess_abc123").to_return( status: 500, headers: { "Content-Type" => "application/json" }, body: JSON.generate("detail" => "internal error during close"), ) # Block raised RuntimeError; close raised APIError. RuntimeError must # win — the close-time blip must NOT mask the block's real exception. err = assert_raises(RuntimeError) do build_client.session do |_s| raise "user-code-failure" end end assert_equal "user-code-failure", err.message end end class SessionManualFormTest < Minitest::Test def test_session_close_idempotent stub_create_session stub_close_session s = build_client.create_session(agent: "claude") assert_kind_of Clawdforge::Session, s refute s.closed? s.close assert s.closed? # Second close is a no-op — must NOT hit the network again. s.close s.close assert_requested :delete, "#{BASE_URL}/sessions/sess_abc123", times: 1 end def test_session_close_rolls_back_closed_flag_on_transport_failure stub_create_session stub_request(:delete, "#{BASE_URL}/sessions/sess_abc123") .to_raise(SocketError.new("getaddrinfo: nope")) s = build_client.create_session assert_raises(Clawdforge::TransportError) { s.close } # @closed rolled back so the caller can retry. refute s.closed?, "closed flag must roll back on transport failure" end def test_create_session_without_block_returns_handle stub_create_session s = build_client.session(agent: "claude") assert_kind_of Clawdforge::Session, s refute s.closed? # No DELETE expected — caller owns the lifecycle. refute_requested :delete, "#{BASE_URL}/sessions/sess_abc123" end def test_create_session_sends_meta_when_present captured = {} stub_request(:post, "#{BASE_URL}/sessions").with do |req| captured[:body] = JSON.parse(req.body) true end.to_return( status: 200, headers: { "Content-Type" => "application/json" }, body: JSON.generate( "ok" => true, "session_id" => "sess_meta", "agent" => "claude", "created_at" => 1, "cwd" => "/tmp/x", ), ) build_client.create_session(agent: "claude", meta: { "trace_id" => "abc" }) assert_equal "claude", captured[:body]["agent"] assert_equal({ "trace_id" => "abc" }, captured[:body]["meta"]) end def test_create_session_omits_meta_when_nil captured = {} stub_request(:post, "#{BASE_URL}/sessions").with do |req| captured[:body] = JSON.parse(req.body) true end.to_return( status: 200, headers: { "Content-Type" => "application/json" }, body: JSON.generate( "ok" => true, "session_id" => "x", "agent" => "claude", "created_at" => 1, "cwd" => "/tmp", ), ) build_client.create_session refute captured[:body].key?("meta") end end class SessionTurnTest < Minitest::Test def test_session_turn_round_trip stub_create_session stub_request(:post, "#{BASE_URL}/sessions/sess_abc123/turn").to_return( status: 200, headers: { "Content-Type" => "application/json" }, body: JSON.generate( "ok" => true, "session_id" => "sess_abc123", "turn_index" => 1, "events" => [ { "type" => "thinking", "content" => "let me think" }, { "type" => "text", "content" => "Hello, " }, { "type" => "text", "content" => "world!" }, ], "stop_reason" => "end_turn", "duration_ms" => 4321, ), ) stub_close_session forge = build_client forge.session do |s| r = s.turn("hi") assert_kind_of Clawdforge::TurnResult, r assert_equal true, r.ok assert_equal "sess_abc123", r.session_id assert_equal 1, r.turn_index assert_equal "end_turn", r.stop_reason assert_equal 4321, r.duration_ms assert_equal 3, r.events.length assert_kind_of Clawdforge::TurnEvent, r.events.first assert_equal "thinking", r.events.first.type assert_equal "let me think", r.events.first.content end end def test_session_turn_sends_files_and_timeout stub_create_session captured = {} stub_request(:post, "#{BASE_URL}/sessions/sess_abc123/turn").with do |req| captured[:auth] = req.headers["Authorization"] captured[:body] = JSON.parse(req.body) true end.to_return( status: 200, headers: { "Content-Type" => "application/json" }, body: JSON.generate( "ok" => true, "session_id" => "sess_abc123", "turn_index" => 2, "events" => [], "stop_reason" => "end_turn", "duration_ms" => 100, ), ) stub_close_session forge = build_client forge.session do |s| s.turn("look at this", files: ["ff_xyz"], timeout_secs: 90) end assert_equal "Bearer #{TOKEN}", captured[:auth] assert_equal "look at this", captured[:body]["prompt"] assert_equal ["ff_xyz"], captured[:body]["files"] assert_equal 90, captured[:body]["timeout_secs"] end def test_turn_after_close_raises_closed_session_error stub_create_session stub_close_session s = build_client.create_session s.close err = assert_raises(Clawdforge::ClosedSessionError) { s.turn("hi") } assert_match(/closed/, err.message) # ClosedSessionError is a Clawdforge::Error subclass — single-rescue friendly. assert_kind_of Clawdforge::Error, err end def test_turn_with_empty_prompt_raises_argument_error stub_create_session s = build_client.create_session assert_raises(ArgumentError) { s.turn("") } assert_raises(ArgumentError) { s.turn(nil) } end end class TurnResultTextTest < Minitest::Test def test_turn_result_text_concatenates_text_events_only payload = { "ok" => true, "session_id" => "x", "turn_index" => 1, "events" => [ { "type" => "thinking", "content" => "ignored" }, { "type" => "text", "content" => "Hello, " }, { "type" => "tool_call", "name" => "Read", "args" => {}, "result" => {} }, { "type" => "text", "content" => "world!" }, ], "stop_reason" => "end_turn", "duration_ms" => 1, } r = Clawdforge::TurnResult.from_response(payload) assert_equal "Hello, world!", r.text end def test_turn_result_text_empty_when_no_text_events r = Clawdforge::TurnResult.from_response( "ok" => true, "events" => [{ "type" => "thinking", "content" => "x" }], "duration_ms" => 1, ) assert_equal "", r.text end def test_turn_result_text_skips_nil_content r = Clawdforge::TurnResult.from_response( "ok" => true, "events" => [ { "type" => "text", "content" => nil }, { "type" => "text", "content" => "ok" }, ], "duration_ms" => 1, ) assert_equal "ok", r.text end end class SessionListAndStateTest < Minitest::Test def test_list_sessions_returns_states stub_request(:get, "#{BASE_URL}/sessions").to_return( status: 200, headers: { "Content-Type" => "application/json" }, body: JSON.generate( "ok" => true, "count" => 2, "sessions" => [ { "session_id" => "sess_a", "app_name" => "cauldron", "agent" => "claude", "cwd" => "/tmp/a", "created_at" => 100, "last_turn_at" => 200, "turn_count" => 3, "closed_at" => nil, "meta" => nil, }, { "session_id" => "sess_b", "app_name" => "cauldron", "agent" => "claude", "cwd" => "/tmp/b", "created_at" => 50, "last_turn_at" => nil, "turn_count" => 0, "closed_at" => 75, "meta" => { "trace" => "x" }, }, ], ), ) sessions = build_client.list_sessions assert_equal 2, sessions.length assert_kind_of Clawdforge::SessionState, sessions[0] assert_equal "sess_a", sessions[0].session_id assert_equal "cauldron", sessions[0].app_name assert_equal 3, sessions[0].turn_count assert_nil sessions[0].closed_at refute sessions[0].closed? assert sessions[1].closed? assert_equal({ "trace" => "x" }, sessions[1].meta) end def test_list_sessions_include_closed_false_passes_query_param stub_request(:get, "#{BASE_URL}/sessions?include_closed=false").to_return( status: 200, headers: { "Content-Type" => "application/json" }, body: JSON.generate("ok" => true, "count" => 0, "sessions" => []), ) out = build_client.list_sessions(include_closed: false) assert_equal [], out end def test_get_session_returns_state stub_request(:get, "#{BASE_URL}/sessions/sess_abc123").to_return( status: 200, headers: { "Content-Type" => "application/json" }, body: JSON.generate( "ok" => true, "session_id" => "sess_abc123", "agent" => "claude", "cwd" => "/tmp/x", "created_at" => 1, "last_turn_at" => 2, "turn_count" => 1, "closed_at" => nil, "live" => true, "meta" => nil, ), ) state = build_client.get_session("sess_abc123") assert_kind_of Clawdforge::SessionState, state assert_equal "sess_abc123", state.session_id assert_equal "claude", state.agent assert_equal 1, state.turn_count end def test_session_state_method_delegates_to_get_session stub_create_session stub_request(:get, "#{BASE_URL}/sessions/sess_abc123").to_return( status: 200, headers: { "Content-Type" => "application/json" }, body: JSON.generate( "ok" => true, "session_id" => "sess_abc123", "agent" => "claude", "cwd" => "/tmp", "created_at" => 1, "last_turn_at" => nil, "turn_count" => 0, "closed_at" => nil, "meta" => nil, ), ) s = build_client.create_session state = s.state assert_kind_of Clawdforge::SessionState, state assert_equal "sess_abc123", state.session_id end def test_get_session_with_empty_id_raises assert_raises(ArgumentError) { build_client.get_session("") } assert_raises(ArgumentError) { build_client.get_session(nil) } end def test_cross_token_404_is_api_error stub_request(:get, "#{BASE_URL}/sessions/sess_other").to_return( status: 404, headers: { "Content-Type" => "application/json" }, body: JSON.generate("detail" => "session not found"), ) err = assert_raises(Clawdforge::APIError) do build_client.get_session("sess_other") end assert_equal 404, err.status end def test_session_turn_410_when_server_already_closed stub_create_session stub_request(:post, "#{BASE_URL}/sessions/sess_abc123/turn").to_return( status: 410, headers: { "Content-Type" => "application/json" }, body: JSON.generate("detail" => "session is closed"), ) s = build_client.create_session err = assert_raises(Clawdforge::APIError) { s.turn("hi") } assert_equal 410, err.status end end class SessionInspectRedactionTest < Minitest::Test SECRET = "cf_supersecret_session_DEADBEEF_must_not_leak" def test_session_inspect_does_not_leak_token stub_create_session forge = Clawdforge::Client.new(base_url: BASE_URL, token: SECRET) s = forge.create_session out = s.inspect refute_includes out, SECRET, "Session#inspect leaked bearer" assert_includes out, "[REDACTED]" assert_includes out, "sess_abc123" end def test_session_to_s_does_not_leak_token stub_create_session forge = Clawdforge::Client.new(base_url: BASE_URL, token: SECRET) s = forge.create_session refute_includes s.to_s, SECRET end def test_pp_session_does_not_leak_token stub_create_session forge = Clawdforge::Client.new(base_url: BASE_URL, token: SECRET) s = forge.create_session buf = StringIO.new PP.pp(s, buf) refute_includes buf.string, SECRET, "PP.pp(session) leaked bearer" assert_includes buf.string, "[REDACTED]" end end class SessionV01RegressionTest < Minitest::Test # Smoke test: adding the v0.2 surface must NOT change v0.1's `#run` # request shape or response handling. def test_run_unchanged stub_request(:post, "#{BASE_URL}/run").to_return( status: 200, headers: { "Content-Type" => "application/json" }, body: JSON.generate( "ok" => true, "result" => { "hello" => "world" }, "duration_ms" => 1234, "stop_reason" => "end_turn", ), ) r = build_client.run(prompt: 'Reply with JSON: {"hello": "world"}') assert_kind_of Clawdforge::RunResult, r assert_equal({ "hello" => "world" }, r.result) assert_equal 1234, r.duration_ms assert_equal "end_turn", r.stop_reason end end