clawdforge/clients/ruby/test/test_session.rb
Kayos 99173353bb clients/ruby: v0.2 multi-turn Session API
- Session class wrapping a session_id; block form `forge.session do |s|`
- Idempotent Session#close (in-memory short-circuit + server is also
  idempotent server-side; @closed rolled back on transport failure so
  retry is safe)
- Client#create_session / #list_sessions / #get_session
- TurnResult#text helper concatenates text events (skips thinking,
  tool_call, and nil contents)
- Session#inspect / #to_s / #pretty_print redact the embedded client
  reference so the bearer never leaks via logs / pp / irb / backtraces
- test/test_session.rb: 26 tests covering block-form auto-close on
  return + on exception + with-failing-close-must-not-mask-block-error,
  manual lifecycle, close idempotency (single DELETE on N closes),
  transport-failure rollback, turn round-trip + files/timeout/auth,
  closed-session guard, empty-prompt guard, TurnResult#text edge cases,
  list/get state, cross-token 404, server 410, redaction in inspect /
  to_s / PP, and a v0.1 #run regression smoke

v0.1 surface (#run, #upload_file, #create_token, #list_tokens,
#revoke_token, #healthz) is byte-identical — purely additive.

Spec: memory/spec-clawdforge-v0.2.md
Server core: 940861f
2026-04-29 06:46:52 -07:00

507 lines
15 KiB
Ruby

# 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