- 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
507 lines
15 KiB
Ruby
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
|