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:
parent
e0f161f18c
commit
99173353bb
6 changed files with 919 additions and 2 deletions
|
|
@ -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
|
||||
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
107
clients/ruby/lib/clawdforge/session.rb
Normal file
107
clients/ruby/lib/clawdforge/session.rb
Normal 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
|
||||
507
clients/ruby/test/test_session.rb
Normal file
507
clients/ruby/test/test_session.rb
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue