diff --git a/clients/ruby/README.md b/clients/ruby/README.md index e3ea74c..1257c97 100644 --- a/clients/ruby/README.md +++ b/clients/ruby/README.md @@ -8,8 +8,9 @@ around that API. - **Stdlib only at runtime** — `net/http` + `json`. No `httparty`, no `faraday`. - **Ruby 3.0+** — keyword args, frozen string literals. -- **Five public methods** — `healthz`, `run`, `upload_file`, `create_token`, - `list_tokens`, `revoke_token`. That's it. +- **Stable public surface** — `healthz`, `run`, `upload_file`, `create_token`, + `list_tokens`, `revoke_token` (v0.1) plus `session` / `create_session` / + `list_sessions` / `get_session` for multi-turn (v0.2). ## Install @@ -62,6 +63,81 @@ ft = forge.upload_file("./recipe.png", ttl_secs: 3600) forge.run(prompt: "extract recipe data", files: [ft.file_token]) ``` +## Multi-turn / Sessions (v0.2) + +`/run` is single-turn — one prompt in, one result out. For follow-ups +("now look at auth flow", "try X… now try Y") use a session. Sessions are +backed server-side by ACPX and persist context across turns until you +close them (or the TTL sweeper does). + +### Block form (preferred) + +The block form auto-closes the session at block exit, even if the block +raised. This is the safest pattern. + +```ruby +forge.session(agent: "claude") do |s| + r1 = s.turn("Read README.md and summarize") + puts r1.text # convenience: concats all "text" events + + r2 = s.turn("Now look at the auth flow") + puts r2.text +end +# session is closed here, even on exception +``` + +### Manual form + +When you need the Session to outlive the call site (background worker, +long-lived handle), use `create_session` and close in `ensure`. + +```ruby +s = forge.create_session(agent: "claude") +begin + r = s.turn("hello") + puts r.text +ensure + s.close # idempotent — safe to call from ensure without rescue +end +``` + +`Session#close` is idempotent in two layers: + +1. **Client-side short-circuit** — second call returns immediately, no HTTP. +2. **Server-side idempotent DELETE** — re-closing a closed session returns + `{"ok": true, "already_closed": true}` and 200. + +If the close-time HTTP call raises (transport error, etc.), the +`@closed` flag is rolled back so the caller can retry. + +### Listing and inspecting sessions + +```ruby +forge.list_sessions # => Array +forge.list_sessions(include_closed: false) +forge.get_session(s.id) # => SessionState +``` + +### Tokens stay redacted + +`Session` holds a back-reference to the `Client` (which holds the bearer +token). `Session#inspect`, `#to_s`, and `pretty_print` all redact that +reference — the bearer never lands in logs, exception traces, irb output, +or `pp` dumps. Same pattern as `Client` and `AppToken`. + +```ruby +s.inspect +# => # +``` + +### Errors specific to sessions + +| Error | When | +|---|---| +| `Clawdforge::ClosedSessionError` | `s.turn(...)` after `s.close` (client-side gate). Subclass of `Clawdforge::Error`. | +| `Clawdforge::APIError(404)` | Unknown `session_id`, OR id belongs to another token (the server returns 404 not 403 to avoid leaking session existence across tokens). | +| `Clawdforge::APIError(410)` | Server has already closed the session (TTL swept it, container restarted, etc.). Open a new one. | + ## Public API ### `Clawdforge::Client.new(base_url:, token:, **opts)` @@ -103,6 +179,38 @@ Streams the file in 1 MiB chunks via `IO.copy_stream`. `FileToken` carries `AppToken`'s `token` field holds the plaintext bearer **only** at create time; on list responses it is `nil` (the server stores only a sha256 hash). +### Session methods (v0.2) + +- `#session(agent: "claude", meta: nil) { |s| ... }` — block form, auto-close. + Without a block, returns the open Session (manual lifecycle). +- `#create_session(agent: "claude", meta: nil) -> Session` — manual lifecycle. +- `#list_sessions(include_closed: true) -> Array` +- `#get_session(id) -> SessionState` + +`Session` instance methods: + +- `#turn(prompt, files: nil, timeout_secs: nil) -> TurnResult` +- `#state -> SessionState` +- `#close` — idempotent +- `#closed? -> Boolean` +- `#id`, `#agent`, `#created_at`, `#cwd` — readers + +`TurnResult` is a Struct with: + +- `ok` (Bool) +- `session_id` (String) +- `turn_index` (Integer) — 1-indexed turn count after this turn +- `events` (Array) +- `stop_reason` (String | nil) +- `duration_ms` (Integer) +- `#text -> String` — concatenates `content` of every `type: "text"` event + +`TurnEvent` shape (keyword-init Struct): + +- `type` — one of `"thinking"`, `"tool_call"`, `"text"`, … +- `content` — populated for `text` and `thinking` +- `name`, `args`, `result` — populated for `tool_call` + ## Errors ``` diff --git a/clients/ruby/lib/clawdforge.rb b/clients/ruby/lib/clawdforge.rb index 02a27a4..231b315 100644 --- a/clients/ruby/lib/clawdforge.rb +++ b/clients/ruby/lib/clawdforge.rb @@ -17,4 +17,5 @@ end require_relative "clawdforge/version" require_relative "clawdforge/errors" require_relative "clawdforge/models" +require_relative "clawdforge/session" require_relative "clawdforge/client" diff --git a/clients/ruby/lib/clawdforge/client.rb b/clients/ruby/lib/clawdforge/client.rb index ff6ffb1..b336d38 100644 --- a/clients/ruby/lib/clawdforge/client.rb +++ b/clients/ruby/lib/clawdforge/client.rb @@ -191,6 +191,99 @@ module Clawdforge payload["tokens"].map { |row| AppToken.from_list_row(row) } end + # POST /sessions — create a multi-turn session. (v0.2) + # + # Manual lifecycle. The caller is responsible for `s.close` — usually + # via `ensure`. Most callers should prefer the block form + # `forge.session(agent: ...) { |s| ... }` which auto-closes. + # + # @param agent [String] which agent the session binds to. Defaults to + # `"claude"`. + # @param meta [Hash, nil] optional caller metadata persisted server-side + # in the session ledger. + # @return [Session] + # @raise [APIError] on non-2xx. + def create_session(agent: "claude", meta: nil) + body = { "agent" => agent } + body["meta"] = meta unless meta.nil? + payload = request(:post, "/sessions", json_body: body, timeout: ADMIN_TIMEOUT_SECS) + raise Error, "unexpected /sessions response type: #{payload.class}" unless payload.is_a?(Hash) + + Session.new( + client: self, + id: payload.fetch("session_id"), + agent: payload["agent"] || agent, + created_at: payload["created_at"], + cwd: payload["cwd"], + ) + end + + # Block-form session helper. Creates a session, yields it, then + # auto-closes — even if the block raised. (v0.2) + # + # @example + # forge.session(agent: "claude") do |s| + # r = s.turn("hello") + # puts r.text + # end + # + # @param agent [String] which agent the session binds to. + # @param meta [Hash, nil] optional caller metadata. + # @yieldparam [Session] + # @return when a block is given: the block's return value. + # @return when no block is given: the open Session (manual lifecycle). + def session(agent: "claude", meta: nil) + s = create_session(agent: agent, meta: meta) + return s unless block_given? + + begin + yield s + ensure + # Swallow close-time errors — the block has already finished its + # work, and a network blip on close shouldn't mask the block's + # real result (or the exception currently unwinding). + begin + s.close + rescue StandardError + # noop — caller already exited + end + end + end + + # GET /sessions — list sessions for the calling token. (v0.2) + # + # @param include_closed [Boolean] include closed sessions. Defaults to + # true to mirror the server. + # @return [Array] + def list_sessions(include_closed: true) + path = include_closed ? "/sessions" : "/sessions?include_closed=false" + payload = request(:get, path, timeout: ADMIN_TIMEOUT_SECS) + unless payload.is_a?(Hash) && payload["sessions"].is_a?(Array) + raise Error, "unexpected /sessions response" + end + + payload["sessions"].map { |row| SessionState.from_response(row) } + end + + # GET /sessions/ — fetch state for one session. (v0.2) + # + # @param id [String] session id. + # @return [SessionState] + # @raise [APIError] 404 if the id is unknown OR belongs to another + # token (the server returns 404 not 403 to avoid leaking session + # existence across token boundaries). + def get_session(id) + raise ArgumentError, "session id must be non-empty" if id.nil? || id.empty? + + payload = request(:get, "/sessions/#{URI.encode_www_form_component(id)}", + timeout: ADMIN_TIMEOUT_SECS) + unless payload.is_a?(Hash) + raise Error, "unexpected /sessions/{id} response type: #{payload.class}" + end + + SessionState.from_response(payload) + end + # DELETE /admin/tokens/ — revoke a token. # @return [Boolean] true on success. Raises APIError(404) if missing. # @raise [ArgumentError] if `name` doesn't match `[a-z0-9_-]+`. Validating @@ -205,6 +298,33 @@ module Clawdforge private + # Internal: POST /sessions/{id}/turn. Reached only via `Session#turn`. + def session_turn_internal(id, prompt, files:, timeout_secs:) + raise ArgumentError, "prompt must be non-empty" if prompt.nil? || prompt.empty? + + body = { "prompt" => prompt } + body["files"] = Array(files) if files && !files.empty? + body["timeout_secs"] = timeout_secs unless timeout_secs.nil? + + effective_run_timeout = timeout_secs || @default_timeout + http_timeout = effective_run_timeout + @http_timeout_margin + + payload = request(:post, "/sessions/#{URI.encode_www_form_component(id)}/turn", + json_body: body, timeout: http_timeout) + unless payload.is_a?(Hash) + raise Error, "unexpected /sessions/{id}/turn response type: #{payload.class}" + end + + TurnResult.from_response(payload) + end + + # Internal: DELETE /sessions/{id}. Reached only via `Session#close`. + def session_close_internal(id) + request(:delete, "/sessions/#{URI.encode_www_form_component(id)}", + timeout: ADMIN_TIMEOUT_SECS) + nil + end + def validate_token_name!(name) unless name.is_a?(String) && name.match?(TOKEN_NAME_RE) raise ArgumentError, diff --git a/clients/ruby/lib/clawdforge/models.rb b/clients/ruby/lib/clawdforge/models.rb index f9c6fde..86c798f 100644 --- a/clients/ruby/lib/clawdforge/models.rb +++ b/clients/ruby/lib/clawdforge/models.rb @@ -29,6 +29,80 @@ module Clawdforge end end + # One event from a session turn's `events` list. Shape mirrors what the + # server emits per ACPX turn: + # + # {"type": "thinking", "content": "..."} + # {"type": "tool_call", "name": "Read", "args": {...}, "result": {...}} + # {"type": "text", "content": "..."} + # + # The Struct is keyword-init and tolerant of missing fields — different + # event types populate different members. Walk `type` to know which + # accessors are meaningful. + TurnEvent = Struct.new(:type, :content, :name, :args, :result, keyword_init: true) do + def self.from_hash(h) + new( + type: h["type"], + content: h["content"], + name: h["name"], + args: h["args"], + result: h["result"], + ) + end + end + + # Result of a successful `POST /sessions/{id}/turn`. + # + # `events` is the per-turn structured stream as TurnEvent rows. Use + # `#text` to grab just the model's text output (the common case). + TurnResult = Struct.new( + :ok, :session_id, :turn_index, :events, :stop_reason, :duration_ms, + keyword_init: true, + ) do + def self.from_response(payload) + new( + ok: payload.fetch("ok", true) ? true : false, + session_id: payload["session_id"], + turn_index: payload["turn_index"], + events: (payload["events"] || []).map { |e| TurnEvent.from_hash(e) }, + stop_reason: payload["stop_reason"], + duration_ms: payload.fetch("duration_ms", 0).to_i, + ) + end + + # Concatenate the `content` of every text-typed event in order. + # Skips non-text events (thinking, tool_call, …) and nil contents. + def text + events.select { |e| e.type == "text" }.map(&:content).compact.join("") + end + end + + # A row from `GET /sessions/{id}` or `GET /sessions`. Mirrors what the + # server returns — `cwd` and `meta` are additive over the spec's minimum. + SessionState = Struct.new( + :session_id, :agent, :app_name, :cwd, :created_at, :last_turn_at, + :turn_count, :closed_at, :meta, + keyword_init: true, + ) do + def self.from_response(payload) + new( + session_id: payload["session_id"], + agent: payload["agent"], + app_name: payload["app_name"], + cwd: payload["cwd"], + created_at: payload["created_at"], + last_turn_at: payload["last_turn_at"], + turn_count: payload["turn_count"], + closed_at: payload["closed_at"], + meta: payload["meta"], + ) + end + + def closed? + !closed_at.nil? + end + end + # A row from `GET /admin/tokens` or the result of `POST /admin/tokens`. # `token` is the plaintext bearer string, returned ONLY at create time. # On list responses it is nil; the server stores only a sha256 hash. diff --git a/clients/ruby/lib/clawdforge/session.rb b/clients/ruby/lib/clawdforge/session.rb new file mode 100644 index 0000000..137e3f6 --- /dev/null +++ b/clients/ruby/lib/clawdforge/session.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +module Clawdforge + # Handle to a server-side multi-turn session. + # + # Construct via `Client#create_session` (manual lifecycle) or + # `Client#session` with a block (auto-close — preferred). + # + # @example Block form (recommended) + # forge.session(agent: "claude") do |s| + # r = s.turn("hello") + # puts r.text + # end + # # session is closed here, even if the block raised + # + # @example Manual form + # s = forge.create_session(agent: "claude") + # begin + # s.turn("hello") + # ensure + # s.close + # end + # + # `#close` is idempotent: the second call short-circuits in memory + # (no HTTP round-trip) and the server's `DELETE /sessions/{id}` is + # itself idempotent (returns `{"ok": true, "already_closed": true}`). + # Safe to call from `ensure` blocks without a rescue wrapper. + class Session + attr_reader :id, :agent, :created_at, :cwd + + # Internal: don't construct directly. Use `Client#create_session` or + # `Client#session { |s| ... }`. + def initialize(client:, id:, agent:, created_at: nil, cwd: nil) + @client = client + @id = id + @agent = agent + @created_at = created_at + @cwd = cwd + @closed = false + end + + # Send one turn through this session. + # + # @param prompt [String] required, non-empty. + # @param files [Array, nil] `ff_...` file tokens previously + # returned from `Client#upload_file`. + # @param timeout_secs [Integer, nil] per-turn subprocess timeout. + # @return [TurnResult] + # @raise [ClosedSessionError] if `close` has already been called on this + # handle. + # @raise [ArgumentError] on empty prompt. + # @raise [APIError] on non-2xx (notably 404 for cross-token / unknown + # ids and 410 for sessions the server has already closed or evicted). + def turn(prompt, files: nil, timeout_secs: nil) + raise ClosedSessionError, "session #{@id} is closed" if @closed + + @client.send(:session_turn_internal, @id, prompt, + files: files, timeout_secs: timeout_secs) + end + + # Fetch fresh server-side state for this session. + # @return [SessionState] + def state + @client.get_session(@id) + end + + # Close this session server-side. Idempotent. + # + # The first call issues `DELETE /sessions/{id}`. Subsequent calls + # short-circuit in memory and do NOT hit the network. If the HTTP + # call fails, `@closed` is rolled back so a retry from the caller + # will re-issue the DELETE. + def close + return if @closed + + @closed = true + @client.send(:session_close_internal, @id) + rescue StandardError => e + @closed = false + raise e + end + + # @return [Boolean] true if this client has already closed the session. + def closed? + @closed + end + + # Custom inspect — never walk `@client` (which holds the bearer token). + # See AppToken / Client for the same pattern. + def inspect + "#<#{self.class.name} id=#{@id.inspect} agent=#{@agent.inspect} " \ + "closed=#{@closed} client=[REDACTED]>" + end + alias_method :to_s, :inspect + + # `pp` calls `pretty_print`, not `inspect`. Override too so PP doesn't + # bypass the redaction by walking ivars directly. + def pretty_print(pp) + pp.text(inspect) + end + end + + # Raised by `Session#turn` after the session has been closed locally. + # Distinct from `APIError(410)` (which is the server's view) — this one + # never leaves the client. + class ClosedSessionError < Error; end +end diff --git a/clients/ruby/test/test_session.rb b/clients/ruby/test/test_session.rb new file mode 100644 index 0000000..21a251d --- /dev/null +++ b/clients/ruby/test/test_session.rb @@ -0,0 +1,507 @@ +# 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