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
This commit is contained in:
Kayos 2026-04-29 06:46:52 -07:00
parent e0f161f18c
commit 99173353bb
6 changed files with 919 additions and 2 deletions

View file

@ -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<SessionState>
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
# => #<Clawdforge::Session id="sess_abc123" agent="claude" closed=false client=[REDACTED]>
```
### 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<SessionState>`
- `#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<TurnEvent>)
- `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
```

View file

@ -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"

View file

@ -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<SessionState>]
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/<id> — 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/<name> — 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,

View file

@ -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.

View file

@ -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<String>, 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

View file

@ -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