clients/swift: v0.2 multi-turn Session API

- Session actor with isolated mutable closed flag; nonisolated id/agent/createdAt
- withSession(opts) { s in ... } scoped helper auto-closes on success/throw
- createSession / listSessions / getSession on ForgeClient (extension)
- TurnEvent / TurnResult / SessionState / CreateSessionOptions models, snake_case CodingKeys
- TurnResult.text() helper concatenates "text" events
- Public memberwise inits on TurnEvent / TurnResult / SessionState (audit lesson)
- Session.description redacts client (no bearer leak via String(describing:))
- Session id URL-encoded with strict RFC 3986 unreserved set (audit lesson)
- Tests/SessionTests.swift: 13 tests covering create/turn/close-idempotent/
  withSession-on-success/withSession-on-throw/turn-after-close/text-concat/
  list/get/cross-token-404/redaction/v0.1-regression/empty-prompt
- README "Multi-turn / Sessions (v0.2)" section, withSession shown first

v0.1 surface unchanged; v0.2 is purely additive.

Spec: memory/spec-clawdforge-v0.2.md
Server core: 940861f
This commit is contained in:
Kayos 2026-04-29 07:00:34 -07:00
parent cb1d8c2c54
commit 0f091771d3
4 changed files with 1196 additions and 0 deletions

View file

@ -90,6 +90,13 @@ public struct ForgeClient: Sendable {
public func createToken(_ request: CreateTokenRequest) async throws -> AppToken
public func listTokens() async throws -> [AppToken]
public func revokeToken(name: String) async throws
// v0.2 multi-turn — see "Multi-turn / Sessions (v0.2)" below.
public func createSession(_ opts: CreateSessionOptions = .init()) async throws -> Session
public func withSession<T>(_ opts: CreateSessionOptions = .init(),
_ block: (Session) async throws -> T) async throws -> T
public func listSessions(includeClosed: Bool = true) async throws -> [SessionState]
public func getSession(_ id: String) async throws -> SessionState
}
```
@ -124,6 +131,122 @@ let all = try await admin.listTokens()
try await admin.revokeToken(name: "myapp")
```
## Multi-turn / Sessions (v0.2)
v0.2 adds a parallel `/sessions/*` surface for multi-turn conversations
backed by [ACPX]. Single-turn `client.run(...)` is unchanged — the v0.2
surface is purely additive.
A `Session` is an `actor` (Swift 5.5+ structured concurrency). All methods
on it are `async` because the only mutable state — the `closed` flag — is
guarded by actor isolation. There is no shared lock to forget, no race
window between `turn()` and `close()`.
### Scoped session — recommended
`withSession(_:_:)` is the canonical pattern. The closure runs against a
fresh server-side session and the session is **always** closed when the
closure returns — on success and on throw. Same shape as Python's
`with forge.session() as s:` or Go's `defer s.Close(ctx)`.
```swift
import Clawdforge
let summary: String = try await client.withSession(.init(agent: "claude")) { s in
let r1 = try await s.turn("Read README.md and summarize the architecture")
let r2 = try await s.turn("Now describe the auth flow", files: [ft.fileToken])
return r1.text() + "\n\n" + r2.text()
}
// Session is closed here — even if either turn() above had thrown.
```
### Manual lifecycle
If you want the session to outlive a single function (e.g. backing a chat
view-model), construct it directly and close it explicitly. `close()` is
**idempotent** — safe to call from `defer` blocks even if you also call it
on the success path.
```swift
let s = try await client.createSession(.init(agent: "claude"))
defer { Task { try? await s.close() } } // safe in defer; idempotent
let r1 = try await s.turn("Read README.md and summarize")
print(r1.text())
let r2 = try await s.turn("Now look at the auth flow", files: [ft.fileToken])
print(r2.text())
try await s.close() // explicit close on success
```
### Inspecting + listing sessions
```swift
// State of a specific session (404 on cross-token access).
let st = try await client.getSession(s.id)
print("turns so far:", st.turnCount)
// All sessions for the calling token (cross-token sessions are
// filtered server-side, not returned here).
let mine = try await client.listSessions()
for row in mine where row.closedAt == nil {
print("open:", row.sessionId, "turns:", row.turnCount)
}
```
### Turn results
`TurnResult` mirrors the server's structured event batch:
```swift
public struct TurnResult: Codable, Sendable {
public let ok: Bool
public let sessionId: String
public let turnIndex: Int
public let events: [TurnEvent]
public let stopReason: String
public let durationMs: Int64
/// Concatenate every "text" event's content. Use for one-shot
/// "what did the model say?" extraction.
public func text() -> String
}
public struct TurnEvent: Codable, Sendable {
public let type: String // "text", "thinking", "tool_call", ...
public let content: String? // text/thinking events
public let name: String? // tool_call: tool name
public let args: JSONValue? // tool_call: arguments
public let result: JSONValue? // tool_call: result
}
```
For tool-call inspection, walk `events` directly and switch on `event.type`.
### Errors
Session calls reuse `ForgeError`. The interesting cases:
| Server reply | Throws |
|---|---|
| 404 (unknown id, cross-token id) | `.api(statusCode: 404, body: ...)` |
| 410 (already closed server-side) | `.api(statusCode: 410, body: ...)` |
| 502 (ACPX subprocess failure) | `.api(statusCode: 502, body: ...)` |
| Calling `turn` on an already-closed `Session` | `.invalidArgument("session ... is closed")` |
| `close()` after a prior successful `close()` | nothing — idempotent |
### Description / debug
`Session.description` and `String(reflecting:)` deliberately exclude the
embedded `ForgeClient` (and therefore the bearer token). They also exclude
the `closed` flag because reading it requires `await` and a stale snapshot
in `print(s)` would mislead more than help. Use `await s.isClosed()` for
the live answer or `try await s.state()` for the authoritative server-side
view.
[ACPX]: https://github.com/openclaw/acpx
### `JSONValue`
`RunResult.result` is a `JSONValue`. `claude -p --output-format json` may

View file

@ -0,0 +1,241 @@
// ForgeClient+Sessions.swift
//
// v0.2 multi-turn session endpoints on `ForgeClient`. These extensions are
// purely additive v0.1 callers (`run`, `uploadFile`, `createToken`,
// `listTokens`, `revokeToken`, `healthz`) keep their byte-identical wire
// behavior.
//
// Endpoints wired here:
//
// POST /sessions createSession(_:)
// POST /sessions/{id}/turn sessionTurnInternal(...) (used by Session.turn)
// GET /sessions/{id} getSession(_:) (used by Session.state)
// DELETE /sessions/{id} sessionCloseInternal(...) (used by Session.close)
// GET /sessions listSessions()
//
// `createSession` returns an `actor Session` handle; the convenience
// `withSession(_:_:)` wraps create + work + auto-close (on success and on
// throw).
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
extension ForgeClient {
// MARK: Session lifecycle
/// `POST /sessions`. Open a new server-side multi-turn session.
///
/// The returned ``Session`` is an `actor` every method on it is
/// `async`. The session stays open server-side until ``Session/close()``
/// is called (or the TTL sweeper closes it after `CLAWDFORGE_SESSION_TTL_SECS`).
///
/// ```swift
/// let s = try await forge.createSession(.init(agent: "claude"))
/// defer { Task { try? await s.close() } }
/// _ = try await s.turn("hello")
/// ```
public func createSession(
_ opts: CreateSessionOptions = .init()
) async throws -> Session {
let body = CreateSessionWireBody(agent: opts.agent, meta: opts.meta)
let req = try makeSessionRequest(method: "POST", path: "/sessions", jsonBody: body)
let resp: CreateSessionResponse = try await sendSession(req)
return Session(
client: self,
id: resp.sessionId,
agent: resp.agent,
createdAt: resp.createdAt
)
}
/// Scoped session helper. Creates a session, runs `block`, and closes
/// the session on success **and** on throw the same auto-cleanup
/// shape as Python's `with`, Rust's `Drop`, or Go's `defer`.
///
/// The block's return value is forwarded; close errors during cleanup
/// are silently swallowed so they don't mask a real error from the
/// block. (This matches the behavior of the Python / Rust / Go SDKs.)
///
/// ```swift
/// let summary = try await forge.withSession { s in
/// try await s.turn("Read README.md and summarize").text()
/// }
/// ```
public func withSession<T>(
_ opts: CreateSessionOptions = .init(),
_ block: (Session) async throws -> T
) async throws -> T {
let s = try await createSession(opts)
do {
let result = try await block(s)
try? await s.close()
return result
} catch {
try? await s.close()
throw error
}
}
/// `GET /sessions`. List all sessions visible to the calling token.
///
/// Cross-token sessions are filtered server-side, so callers see only
/// their own. Closed sessions are included by default; pass
/// `includeClosed: false` to filter them out (server respects the
/// `include_closed` query param).
public func listSessions(includeClosed: Bool = true) async throws -> [SessionState] {
let path = "/sessions" + (includeClosed ? "?include_closed=true" : "?include_closed=false")
let req = try makeSessionRequest(method: "GET", path: path)
let envelope: SessionListResponse = try await sendSession(req)
return envelope.sessions
}
/// `GET /sessions/{id}`. Fetch a session's current state.
///
/// Returns 404 (mapped to ``ForgeError/api(statusCode:body:)``) for
/// unknown ids and for ids owned by a different token the server
/// deliberately does not distinguish, to avoid leaking existence.
public func getSession(_ id: String) async throws -> SessionState {
let path = "/sessions/\(Self.encodeSessionId(id))"
let req = try makeSessionRequest(method: "GET", path: path)
return try await sendSession(req)
}
// MARK: Internal helpers (used by Session)
/// Internal entry point for ``Session/turn(_:files:timeoutSecs:)``.
/// `internal` access (the default) so it's visible across the module
/// but not part of the SDK's public surface.
func sessionTurnInternal(
id: String,
prompt: String,
files: [String]?,
timeoutSecs: Int?
) async throws -> TurnResult {
let body = TurnRequestBody(prompt: prompt, files: files, timeoutSecs: timeoutSecs)
let path = "/sessions/\(Self.encodeSessionId(id))/turn"
let req = try makeSessionRequest(method: "POST", path: path, jsonBody: body)
return try await sendSession(req)
}
/// Internal entry point for ``Session/close()``. The `DELETE
/// /sessions/{id}` body is `{ok: true, already_closed?: bool}`. We
/// decode it for protocol-correctness but discard the result close is
/// `Void` on success.
func sessionCloseInternal(id: String) async throws {
let path = "/sessions/\(Self.encodeSessionId(id))"
let req = try makeSessionRequest(method: "DELETE", path: path)
let _: CloseSessionResponse = try await sendSession(req)
}
// MARK: Private request building + transport
//
// We intentionally re-implement the small request-building helpers here
// rather than expose the v0.1 private ones via `internal`, so the v0.1
// surface stays mechanically untouched. Both code paths share the same
// `URLSession` and JSON encoder/decoder configuration.
/// Percent-encode a session id for inclusion in a URL path. Server-side
/// session ids are UUIDv4 strings, but we treat them as opaque and
/// escape per `urlPathAllowed` minus a few extra characters that
/// `urlPathAllowed` leaves through (`/`, `;`, `?`, `#`). Audit lesson
/// from `revokeToken` relying on `urlPathAllowed` alone leaves
/// path-injection vectors open.
static func encodeSessionId(_ id: String) -> String {
// Build a CharacterSet limited to RFC 3986 path-segment safe chars:
// unreserved (A-Z a-z 0-9 - . _ ~) plus a small set of sub-delims
// that won't reinterpret the URL structure.
var allowed = CharacterSet()
allowed.insert(charactersIn: "abcdefghijklmnopqrstuvwxyz")
allowed.insert(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZ")
allowed.insert(charactersIn: "0123456789")
allowed.insert(charactersIn: "-._~")
return id.addingPercentEncoding(withAllowedCharacters: allowed) ?? id
}
/// Build a `URLRequest` for a JSON-bodied session call.
fileprivate func makeSessionRequest<Body: Encodable>(
method: String,
path: String,
jsonBody: Body
) throws -> URLRequest {
var req = try makeSessionRequest(method: method, path: path)
let encoder = JSONEncoder()
encoder.outputFormatting = [.withoutEscapingSlashes]
do {
req.httpBody = try encoder.encode(jsonBody)
} catch {
throw ForgeError.invalidArgument("failed to encode request body: \(error)")
}
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
return req
}
/// Build a header-only `URLRequest` (auth + accept).
fileprivate func makeSessionRequest(method: String, path: String) throws -> URLRequest {
guard let url = URL(string: baseURL.absoluteString + path) else {
throw ForgeError.invalidArgument("could not form URL for path \(path)")
}
var req = URLRequest(url: url)
req.httpMethod = method
if !token.isEmpty {
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
req.setValue("application/json", forHTTPHeaderField: "Accept")
return req
}
/// Execute a request and decode JSON. Cancellation-safe; maps non-2xx
/// responses into ``ForgeError`` cases the same way the v0.1 path does
/// so callers see one consistent error vocabulary.
fileprivate func sendSession<T: Decodable>(_ req: URLRequest) async throws -> T {
try Task.checkCancellation()
let (data, response): (Data, URLResponse)
do {
(data, response) = try await session.data(for: req)
} catch let urlError as URLError {
throw ForgeError.transport(urlError)
} catch {
throw ForgeError.transport(URLError(.unknown))
}
try Self.ensure2xxSession(data: data, response: response)
let decoder = JSONDecoder()
do {
return try decoder.decode(T.self, from: data)
} catch let decErr as DecodingError {
throw ForgeError.decoding(decErr)
} catch {
throw ForgeError.api(
statusCode: -1,
body: String(data: data, encoding: .utf8) ?? ""
)
}
}
/// Mirror of the v0.1 `ensure2xx` kept on the type as a static so the
/// session helpers can share it without reaching into the v0.1
/// instance-private one.
fileprivate static func ensure2xxSession(data: Data, response: URLResponse) throws {
guard let http = response as? HTTPURLResponse else {
throw ForgeError.api(statusCode: -1, body: "non-HTTP response")
}
let code = http.statusCode
guard !(200..<300).contains(code) else { return }
// Cap body capture at 8 MiB so a misbehaving server can't burn RAM.
let cap = 8 * 1024 * 1024
let bodyData = data.count > cap ? data.prefix(cap) : data
let body = String(data: bodyData, encoding: .utf8) ?? ""
if code == 401 || code == 403 {
throw ForgeError.auth(statusCode: code, body: body)
}
throw ForgeError.api(statusCode: code, body: body)
}
}

View file

@ -0,0 +1,377 @@
// Session.swift
//
// Multi-turn session API (clawdforge v0.2).
//
// v0.2 adds a parallel `/sessions/*` surface to clawdforge backed by ACPX. A
// `Session` is a handle to one server-side multi-turn session: each call to
// `turn(_:)` issues a single prompt+files round-trip and returns the
// structured event batch. v0.1's single-turn `ForgeClient.run(_:)` is
// unchanged the v0.2 surface is purely additive.
//
// Idiomatic shape (Swift 5.9+ structured concurrency):
//
// // Manual lifecycle:
// let s = try await forge.createSession(.init(agent: "claude"))
// defer { Task { try? await s.close() } }
// let r = try await s.turn("hello")
//
// // Scoped lifecycle (auto-close on success OR throw):
// try await forge.withSession { s in
// _ = try await s.turn("hello")
// }
//
// `Session` is an `actor`: the `closed` flag is mutable shared state and
// actor isolation gives us thread-safety without ad-hoc locks. All methods
// are therefore `async`. `description` / `debugDescription` are
// `nonisolated` and deliberately exclude any reference to the embedded
// `ForgeClient` so calling `String(describing:)` or `print(s)` cannot leak
// the bearer token (audit lesson from the v0.1 P2 redaction work).
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
// MARK: - Public types
/// Options accepted by ``ForgeClient/createSession(_:)`` and
/// ``ForgeClient/withSession(_:_:)``.
///
/// Defaults: `agent = "claude"`, `meta = nil`. Construct with `.init()` for
/// the defaults, or pass values explicitly:
///
/// ```swift
/// let s = try await forge.createSession(.init(agent: "claude"))
/// ```
public struct CreateSessionOptions: Sendable {
/// Agent slug to dispatch to. Defaults to `"claude"`.
public var agent: String
/// Free-form metadata stored alongside the session ledger row.
public var meta: JSONValue?
public init(agent: String = "claude", meta: JSONValue? = nil) {
self.agent = agent
self.meta = meta
}
}
/// One event in a turn's structured output.
///
/// `type` is one of `"thinking"`, `"text"`, `"tool_call"`, etc. the server
/// is the authority on the set of discriminator strings. Optional fields are
/// populated according to the event type:
///
/// - `"text"` / `"thinking"` ``content``
/// - `"tool_call"` ``name``, ``args``, ``result``
public struct TurnEvent: Codable, Sendable, Equatable {
/// Event discriminator (`"text"`, `"thinking"`, `"tool_call"`, ...).
public let type: String
/// Text payload for `"text"` and `"thinking"` events.
public let content: String?
/// Tool name for `"tool_call"` events.
public let name: String?
/// Tool arguments for `"tool_call"` events.
public let args: JSONValue?
/// Tool result for `"tool_call"` events.
public let result: JSONValue?
/// Public memberwise init so callers can construct ``TurnEvent`` values
/// in tests, fixtures, or replay tooling without round-tripping through
/// JSON. Audit lesson: synthesized memberwise inits on `public` structs
/// are `internal`, which silently breaks downstream consumers the v0.1
/// `RunFailure` got the same treatment.
public init(
type: String,
content: String? = nil,
name: String? = nil,
args: JSONValue? = nil,
result: JSONValue? = nil
) {
self.type = type
self.content = content
self.name = name
self.args = args
self.result = result
}
}
/// Successful response body from `POST /sessions/{id}/turn` (HTTP 200).
public struct TurnResult: Codable, Sendable, Equatable {
/// Always `true` on a 200 reply.
public let ok: Bool
/// The session this turn belongs to.
public let sessionId: String
/// 1-based index of this turn within the session.
public let turnIndex: Int
/// Structured events emitted during the turn.
public let events: [TurnEvent]
/// Reason the agent stopped (`"end_turn"`, `"max_tokens"`, ...).
public let stopReason: String
/// Wall-clock duration of the turn.
public let durationMs: Int64
public init(
ok: Bool,
sessionId: String,
turnIndex: Int,
events: [TurnEvent],
stopReason: String,
durationMs: Int64
) {
self.ok = ok
self.sessionId = sessionId
self.turnIndex = turnIndex
self.events = events
self.stopReason = stopReason
self.durationMs = durationMs
}
private enum CodingKeys: String, CodingKey {
case ok
case sessionId = "session_id"
case turnIndex = "turn_index"
case events
case stopReason = "stop_reason"
case durationMs = "duration_ms"
}
/// Concatenate all `"text"` event contents into a single string.
///
/// Non-text events (`thinking`, `tool_call`, ...) are skipped. `"text"`
/// events with `nil` content contribute the empty string.
public func text() -> String {
events
.filter { $0.type == "text" }
.compactMap { $0.content }
.joined()
}
}
/// Reply body from `GET /sessions/{id}` and entries in `GET /sessions`.
public struct SessionState: Codable, Sendable, Equatable {
/// Server-issued session id.
public let sessionId: String
/// Agent slug bound to the session.
public let agent: String
/// App / consumer name that owns the session. Always equal to the
/// calling token's name cross-token state is hidden behind 404.
public let appName: String
/// Unix epoch seconds when the session was created.
public let createdAt: Int64
/// Unix epoch seconds of the last successful turn (or `nil` if zero
/// turns have been dispatched yet).
public let lastTurnAt: Int64?
/// Number of turns dispatched.
public let turnCount: Int
/// Unix epoch seconds when closed (or `nil` if still open).
public let closedAt: Int64?
public init(
sessionId: String,
agent: String,
appName: String,
createdAt: Int64,
lastTurnAt: Int64? = nil,
turnCount: Int,
closedAt: Int64? = nil
) {
self.sessionId = sessionId
self.agent = agent
self.appName = appName
self.createdAt = createdAt
self.lastTurnAt = lastTurnAt
self.turnCount = turnCount
self.closedAt = closedAt
}
private enum CodingKeys: String, CodingKey {
case sessionId = "session_id"
case agent
case appName = "app_name"
case createdAt = "created_at"
case lastTurnAt = "last_turn_at"
case turnCount = "turn_count"
case closedAt = "closed_at"
}
}
// MARK: - Wire envelopes (internal)
/// Wire shape of `POST /sessions` request body.
struct CreateSessionWireBody: Encodable {
let agent: String?
let meta: JSONValue?
}
/// Wire shape of `POST /sessions` response body. The server returns more
/// than `id`/`agent`/`created_at` (e.g. `cwd`), but those are the only
/// fields the SDK promotes onto ``Session``.
struct CreateSessionResponse: Decodable {
let ok: Bool?
let sessionId: String
let agent: String
let createdAt: Int64
private enum CodingKeys: String, CodingKey {
case ok
case sessionId = "session_id"
case agent
case createdAt = "created_at"
}
}
/// Wire shape of `POST /sessions/{id}/turn` request body.
struct TurnRequestBody: Encodable {
let prompt: String
let files: [String]?
let timeoutSecs: Int?
private enum CodingKeys: String, CodingKey {
case prompt
case files
case timeoutSecs = "timeout_secs"
}
}
/// Wire shape of `GET /sessions` response.
struct SessionListResponse: Decodable {
let ok: Bool?
let sessions: [SessionState]
let count: Int?
}
/// Wire shape of `DELETE /sessions/{id}` response. The body is decoded but
/// not surfaced to callers; ``Session/close()`` is `Void` on success.
struct CloseSessionResponse: Decodable {
let ok: Bool
let alreadyClosed: Bool?
private enum CodingKeys: String, CodingKey {
case ok
case alreadyClosed = "already_closed"
}
}
// MARK: - Session actor
/// A handle to one server-side multi-turn session.
///
/// Construct via ``ForgeClient/createSession(_:)`` or scope to a block via
/// ``ForgeClient/withSession(_:_:)``. All methods are `async` because
/// `Session` is an actor the only mutable state (`closed`) is guarded by
/// actor isolation.
///
/// `close()` is **idempotent** and safe to call from a `defer` block:
///
/// ```swift
/// let s = try await forge.createSession()
/// defer { Task { try? await s.close() } }
/// // ... use s.turn(...) ...
/// ```
///
/// The `description` / `debugDescription` of a `Session` is deliberately
/// minimal and excludes the embedded ``ForgeClient`` `String(describing:)`
/// and `print(s)` cannot leak the bearer token.
public actor Session {
/// Server-issued session id.
public nonisolated let id: String
/// Agent slug the server bound to this session.
public nonisolated let agent: String
/// Unix epoch seconds when the session was created server-side.
public nonisolated let createdAt: Int64
/// The client used to talk to the server. Held strongly so a `Session`
/// can outlive its lexical creator scope (e.g. stored on a view model).
private let client: ForgeClient
/// Whether ``close()`` has run successfully. Mutating-state lives inside
/// the actor so concurrent `turn()` + `close()` calls serialize on the
/// actor's executor.
private var closed: Bool = false
/// Internal init. Callers go through ``ForgeClient/createSession(_:)``.
init(client: ForgeClient, id: String, agent: String, createdAt: Int64) {
self.client = client
self.id = id
self.agent = agent
self.createdAt = createdAt
}
/// Send one turn to the session.
///
/// - Parameters:
/// - prompt: User prompt text. Must be non-empty.
/// - files: Optional `ff_` tokens previously returned by
/// ``ForgeClient/uploadFile(at:ttlSecs:)``.
/// - timeoutSecs: Per-turn timeout. Server clamps to its configured
/// range. `nil` lets the server use its default.
/// - Throws: ``ForgeError/invalidArgument(_:)`` if the session has
/// already been closed; ``ForgeError/api(statusCode:body:)`` for
/// non-2xx replies (404 = unknown id / cross-token, 410 = closed
/// server-side, 502 = ACPX failure with body matching ``RunFailure``);
/// ``ForgeError/transport(_:)`` on connect/EOF.
public func turn(
_ prompt: String,
files: [String]? = nil,
timeoutSecs: Int? = nil
) async throws -> TurnResult {
guard !closed else {
throw ForgeError.invalidArgument("session \(id) is closed")
}
guard !prompt.isEmpty else {
throw ForgeError.invalidArgument("prompt must not be empty")
}
return try await client.sessionTurnInternal(
id: id,
prompt: prompt,
files: files,
timeoutSecs: timeoutSecs
)
}
/// Fetch the latest server-side state of this session.
public func state() async throws -> SessionState {
try await client.getSession(id)
}
/// Close the server-side session. Idempotent calling close twice (or
/// from multiple `defer` blocks) issues the DELETE only once and the
/// second call short-circuits.
///
/// On transport failure the closed flag is rolled back, so a retry has
/// the chance to actually drop the server-side slot.
public func close() async throws {
guard !closed else { return }
closed = true
do {
try await client.sessionCloseInternal(id: id)
} catch {
closed = false
throw error
}
}
/// Whether ``close()`` has run successfully on this handle. This is the
/// in-memory view: a session may also be closed by the server-side TTL
/// sweeper without this flag flipping. Use ``state()`` for the
/// authoritative server-side answer.
public func isClosed() -> Bool { closed }
}
// MARK: - Redacted reflection
extension Session: CustomStringConvertible, CustomDebugStringConvertible {
/// Deliberately omits the embedded `ForgeClient` (and its bearer token)
/// AND the actor-isolated `closed` flag. The `closed` flag would require
/// `await` to read `description` is `nonisolated` and synchronous
/// and printing a stale value would be more misleading than omitting it.
public nonisolated var description: String {
"Session(id: \(id), agent: \(agent))"
}
public nonisolated var debugDescription: String { description }
}

View file

@ -0,0 +1,455 @@
// SessionTests.swift
//
// XCTest suite for the v0.2 multi-turn `Session` API. Like
// `ForgeClientTests`, all HTTP I/O is stubbed via `URLProtocolMock` so tests
// run hermetically on macOS, iOS simulator, and Linux.
//
// The mock class itself is defined in `ForgeClientTests.swift` and reused
// here both test files compile into the same `ClawdforgeTests` target.
import XCTest
@testable import Clawdforge
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
// MARK: - Helpers
private func makeSessionClient() throws -> ForgeClient {
let config = URLSessionConfiguration.ephemeral
config.protocolClasses = [URLProtocolMock.self]
let session = URLSession(configuration: config)
return try ForgeClient(
baseURL: URL(string: "http://forge.test")!,
token: "cf_test_session_token",
session: session
)
}
private func sessionResponse(
url: URL,
status: Int,
headers: [String: String] = ["Content-Type": "application/json"]
) -> HTTPURLResponse {
HTTPURLResponse(url: url, statusCode: status, httpVersion: "HTTP/1.1", headerFields: headers)!
}
/// Synchronous, lock-protected hit counter. We deliberately use an
/// `NSLock`-backed class rather than an `actor` because the mock URLProtocol
/// `startLoading()` runs in a synchronous context bumping an actor from
/// there would require dispatching into a Task and reading back with
/// `await`, introducing a sleep-then-check race. The lock keeps assertions
/// deterministic.
private final class RouteHits: @unchecked Sendable {
private let lock = NSLock()
private var hits: [String: Int] = [:]
func bump(_ key: String) {
lock.lock(); defer { lock.unlock() }
hits[key, default: 0] += 1
}
func count(_ key: String) -> Int {
lock.lock(); defer { lock.unlock() }
return hits[key] ?? 0
}
}
// MARK: - Tests
final class SessionTests: XCTestCase {
override func setUp() {
super.setUp()
URLProtocolMock.reset()
}
override func tearDown() {
URLProtocolMock.reset()
super.tearDown()
}
// 1. Create POST /sessions returns Session actor with promoted fields.
func testCreateSessionReturnsActor() async throws {
URLProtocolMock.handler = { req in
XCTAssertEqual(req.url?.path, "/sessions")
XCTAssertEqual(req.httpMethod, "POST")
XCTAssertEqual(req.value(forHTTPHeaderField: "Content-Type"), "application/json")
XCTAssertEqual(req.value(forHTTPHeaderField: "Authorization"), "Bearer cf_test_session_token")
let body = URLProtocolMock.lastBody ?? Data()
let json = try JSONSerialization.jsonObject(with: body) as! [String: Any]
XCTAssertEqual(json["agent"] as? String, "claude")
let resp = #"""
{"ok": true, "session_id": "sess_abc123", "agent": "claude", "created_at": 1700000000, "cwd": "/tmp/x"}
"""#
return (sessionResponse(url: req.url!, status: 200), Data(resp.utf8))
}
let client = try makeSessionClient()
let s = try await client.createSession(.init(agent: "claude"))
XCTAssertEqual(s.id, "sess_abc123")
XCTAssertEqual(s.agent, "claude")
XCTAssertEqual(s.createdAt, 1_700_000_000)
let isClosed = await s.isClosed()
XCTAssertFalse(isClosed)
}
// 2. Turn POST /sessions/<id>/turn returns TurnResult, snake_case decoded.
func testTurnRoundTrip() async throws {
let hits = RouteHits()
URLProtocolMock.handler = { req in
let path = req.url?.path ?? ""
if path == "/sessions" {
hits.bump("create")
let resp = #"""
{"ok": true, "session_id": "sess_xyz", "agent": "claude", "created_at": 1700000001, "cwd": "/tmp/y"}
"""#
return (sessionResponse(url: req.url!, status: 200), Data(resp.utf8))
}
if path == "/sessions/sess_xyz/turn" {
hits.bump("turn")
XCTAssertEqual(req.httpMethod, "POST")
let body = URLProtocolMock.lastBody ?? Data()
let json = try JSONSerialization.jsonObject(with: body) as! [String: Any]
XCTAssertEqual(json["prompt"] as? String, "hello there")
XCTAssertEqual(json["files"] as? [String], ["ff_a", "ff_b"])
XCTAssertEqual(json["timeout_secs"] as? Int, 30)
let resp = #"""
{
"ok": true,
"session_id": "sess_xyz",
"turn_index": 1,
"events": [
{"type": "thinking", "content": "let me see"},
{"type": "tool_call", "name": "Read", "args": {"path": "x"}, "result": {"len": 42}},
{"type": "text", "content": "hi "},
{"type": "text", "content": "there"}
],
"stop_reason": "end_turn",
"duration_ms": 1234
}
"""#
return (sessionResponse(url: req.url!, status: 200), Data(resp.utf8))
}
XCTFail("unexpected path: \(path)")
return (sessionResponse(url: req.url!, status: 404), Data())
}
let client = try makeSessionClient()
let s = try await client.createSession()
let r = try await s.turn("hello there", files: ["ff_a", "ff_b"], timeoutSecs: 30)
XCTAssertTrue(r.ok)
XCTAssertEqual(r.sessionId, "sess_xyz")
XCTAssertEqual(r.turnIndex, 1)
XCTAssertEqual(r.stopReason, "end_turn")
XCTAssertEqual(r.durationMs, 1234)
XCTAssertEqual(r.events.count, 4)
XCTAssertEqual(r.events[0].type, "thinking")
XCTAssertEqual(r.events[0].content, "let me see")
XCTAssertEqual(r.events[1].name, "Read")
XCTAssertEqual(r.events[1].args?.objectValue?["path"]?.stringValue, "x")
XCTAssertEqual(r.events[1].result?.objectValue?["len"]?.numberValue, 42)
}
// 3. close() is idempotent DELETE hit ONCE even after close × 2.
func testCloseIdempotent() async throws {
let hits = RouteHits()
URLProtocolMock.handler = { req in
let path = req.url?.path ?? ""
if path == "/sessions", req.httpMethod == "POST" {
let resp = #"""
{"ok": true, "session_id": "sess_idem", "agent": "claude", "created_at": 1700000002}
"""#
return (sessionResponse(url: req.url!, status: 200), Data(resp.utf8))
}
if path == "/sessions/sess_idem", req.httpMethod == "DELETE" {
hits.bump("delete")
return (sessionResponse(url: req.url!, status: 200), Data(#"{"ok": true}"#.utf8))
}
XCTFail("unexpected: \(req.httpMethod ?? "?") \(path)")
return (sessionResponse(url: req.url!, status: 404), Data())
}
let client = try makeSessionClient()
let s = try await client.createSession()
try await s.close()
try await s.close() // second call must short-circuit
let isClosed = await s.isClosed()
XCTAssertTrue(isClosed)
XCTAssertEqual(hits.count("delete"), 1,
"DELETE /sessions/<id> should fire exactly once across two close() calls")
}
// 4. withSession DELETE fires at end of block.
func testWithSessionAutoCloses() async throws {
let hits = RouteHits()
var capturedId: String?
URLProtocolMock.handler = { req in
let path = req.url?.path ?? ""
if path == "/sessions", req.httpMethod == "POST" {
let resp = #"""
{"ok": true, "session_id": "sess_scoped", "agent": "claude", "created_at": 1700000003}
"""#
return (sessionResponse(url: req.url!, status: 200), Data(resp.utf8))
}
if path == "/sessions/sess_scoped/turn" {
let resp = #"""
{"ok": true, "session_id": "sess_scoped", "turn_index": 1, "events": [{"type": "text", "content": "ok"}], "stop_reason": "end_turn", "duration_ms": 5}
"""#
return (sessionResponse(url: req.url!, status: 200), Data(resp.utf8))
}
if path == "/sessions/sess_scoped", req.httpMethod == "DELETE" {
hits.bump("delete")
return (sessionResponse(url: req.url!, status: 200), Data(#"{"ok": true}"#.utf8))
}
XCTFail("unexpected: \(req.httpMethod ?? "?") \(path)")
return (sessionResponse(url: req.url!, status: 404), Data())
}
let client = try makeSessionClient()
let result: String = try await client.withSession { s in
capturedId = s.id
let r = try await s.turn("hi")
return r.text()
}
XCTAssertEqual(result, "ok")
XCTAssertEqual(capturedId, "sess_scoped")
XCTAssertEqual(hits.count("delete"), 1, "withSession must close on success")
}
// 5. withSession on throw DELETE still fires.
func testWithSessionClosesOnThrow() async throws {
struct Boom: Error {}
let hits = RouteHits()
URLProtocolMock.handler = { req in
let path = req.url?.path ?? ""
if path == "/sessions", req.httpMethod == "POST" {
let resp = #"""
{"ok": true, "session_id": "sess_throws", "agent": "claude", "created_at": 1700000004}
"""#
return (sessionResponse(url: req.url!, status: 200), Data(resp.utf8))
}
if path == "/sessions/sess_throws", req.httpMethod == "DELETE" {
hits.bump("delete")
return (sessionResponse(url: req.url!, status: 200), Data(#"{"ok": true}"#.utf8))
}
XCTFail("unexpected: \(req.httpMethod ?? "?") \(path)")
return (sessionResponse(url: req.url!, status: 404), Data())
}
let client = try makeSessionClient()
do {
try await client.withSession { _ in
throw Boom()
}
XCTFail("expected Boom to escape")
} catch is Boom {
// expected
}
XCTAssertEqual(hits.count("delete"), 1, "withSession must close on throw too")
}
// 6. turn() after close() ForgeError.invalidArgument, no network hit.
func testTurnAfterCloseThrows() async throws {
URLProtocolMock.handler = { req in
let path = req.url?.path ?? ""
if path == "/sessions", req.httpMethod == "POST" {
let resp = #"""
{"ok": true, "session_id": "sess_after_close", "agent": "claude", "created_at": 1700000005}
"""#
return (sessionResponse(url: req.url!, status: 200), Data(resp.utf8))
}
if path == "/sessions/sess_after_close", req.httpMethod == "DELETE" {
return (sessionResponse(url: req.url!, status: 200), Data(#"{"ok": true}"#.utf8))
}
// /turn must NOT be reached after close
if path.hasSuffix("/turn") {
XCTFail("turn endpoint must not be called after close")
}
return (sessionResponse(url: req.url!, status: 404), Data())
}
let client = try makeSessionClient()
let s = try await client.createSession()
try await s.close()
do {
_ = try await s.turn("anything")
XCTFail("expected throw")
} catch ForgeError.invalidArgument(let msg) {
XCTAssertTrue(msg.contains("closed"))
} catch {
XCTFail("wrong error: \(error)")
}
}
// 7. TurnResult.text() concatenates only "text" events in order.
func testTurnResultTextConcatenates() {
let r = TurnResult(
ok: true,
sessionId: "s",
turnIndex: 1,
events: [
TurnEvent(type: "thinking", content: "internal"),
TurnEvent(type: "text", content: "Hello, "),
TurnEvent(type: "tool_call", name: "Read"),
TurnEvent(type: "text", content: "world"),
TurnEvent(type: "text"), // nil content skipped
TurnEvent(type: "text", content: "!"),
],
stopReason: "end_turn",
durationMs: 1
)
XCTAssertEqual(r.text(), "Hello, world!")
}
// 8. listSessions GET /sessions decodes envelope.
func testListSessions() async throws {
URLProtocolMock.handler = { req in
XCTAssertEqual(req.url?.path, "/sessions")
XCTAssertEqual(req.httpMethod, "GET")
// include_closed default = true present in query.
XCTAssertTrue(req.url?.absoluteString.contains("include_closed=true") ?? false)
let resp = #"""
{
"ok": true,
"count": 2,
"sessions": [
{"session_id": "s1", "agent": "claude", "app_name": "cauldron", "created_at": 1700000010, "last_turn_at": 1700000020, "turn_count": 3, "closed_at": null},
{"session_id": "s2", "agent": "claude", "app_name": "cauldron", "created_at": 1700000030, "last_turn_at": null, "turn_count": 0, "closed_at": 1700000040}
]
}
"""#
return (sessionResponse(url: req.url!, status: 200), Data(resp.utf8))
}
let client = try makeSessionClient()
let list = try await client.listSessions()
XCTAssertEqual(list.count, 2)
XCTAssertEqual(list[0].sessionId, "s1")
XCTAssertEqual(list[0].turnCount, 3)
XCTAssertEqual(list[0].lastTurnAt, 1_700_000_020)
XCTAssertNil(list[0].closedAt)
XCTAssertEqual(list[1].closedAt, 1_700_000_040)
XCTAssertNil(list[1].lastTurnAt)
}
// 9. getSession GET /sessions/<id> decodes one row.
func testGetSession() async throws {
URLProtocolMock.handler = { req in
XCTAssertEqual(req.httpMethod, "GET")
XCTAssertEqual(req.url?.path, "/sessions/sess_get")
let resp = #"""
{
"session_id": "sess_get",
"agent": "claude",
"app_name": "cauldron",
"created_at": 1700000100,
"last_turn_at": 1700000200,
"turn_count": 1,
"closed_at": null
}
"""#
return (sessionResponse(url: req.url!, status: 200), Data(resp.utf8))
}
let client = try makeSessionClient()
let st = try await client.getSession("sess_get")
XCTAssertEqual(st.sessionId, "sess_get")
XCTAssertEqual(st.turnCount, 1)
XCTAssertEqual(st.appName, "cauldron")
}
// 10. Cross-token / unknown-id 404 surfaces as ForgeError.api(404, ...).
// The server uses 404 (not 403) on cross-token deliberately to avoid
// leaking existence; the SDK's job is to forward that as-is.
func testCrossTokenIs404() async throws {
URLProtocolMock.handler = { req in
let body = #"{"detail": "session not found"}"#
return (sessionResponse(url: req.url!, status: 404), Data(body.utf8))
}
let client = try makeSessionClient()
do {
_ = try await client.getSession("sess_belongs_to_someone_else")
XCTFail("expected throw")
} catch ForgeError.api(let code, let body) {
XCTAssertEqual(code, 404)
XCTAssertTrue(body.contains("not found"))
} catch {
XCTFail("wrong error: \(error)")
}
}
// 11. String(describing: session) MUST NOT leak the bearer or any
// ForgeClient state. The closed flag is also omitted (actor-isolated).
func testSessionDescriptionDoesNotLeakToken() async throws {
URLProtocolMock.handler = { req in
let resp = #"""
{"ok": true, "session_id": "sess_print", "agent": "claude", "created_at": 1700000200}
"""#
return (sessionResponse(url: req.url!, status: 200), Data(resp.utf8))
}
let client = try makeSessionClient()
let s = try await client.createSession()
let s1 = String(describing: s)
let s2 = String(reflecting: s)
// Bearer token from makeSessionClient() must not appear.
XCTAssertFalse(s1.contains("cf_test_session_token"))
XCTAssertFalse(s2.contains("cf_test_session_token"))
// Useful identity fields ARE present.
XCTAssertTrue(s1.contains("sess_print"))
XCTAssertTrue(s1.contains("claude"))
}
// 12. v0.1 path regression run() shape is byte-identical pre/post v0.2.
// Protects against accidental changes in shared decoder/encoder config.
func testV1RunUnchanged() async throws {
URLProtocolMock.handler = { req in
XCTAssertEqual(req.url?.path, "/run")
XCTAssertEqual(req.httpMethod, "POST")
let body = URLProtocolMock.lastBody ?? Data()
let json = try JSONSerialization.jsonObject(with: body) as! [String: Any]
XCTAssertEqual(json["prompt"] as? String, "v0.1 still works")
XCTAssertEqual(json["timeout_secs"] as? Int, 5)
let resp = #"""
{"ok": true, "result": "yes it does", "duration_ms": 7, "stop_reason": "end_turn"}
"""#
return (sessionResponse(url: req.url!, status: 200), Data(resp.utf8))
}
let client = try makeSessionClient()
let r = try await client.run(RunRequest(prompt: "v0.1 still works", timeoutSecs: 5))
XCTAssertTrue(r.ok)
XCTAssertEqual(r.durationMs, 7)
XCTAssertEqual(r.result.stringValue, "yes it does")
}
// 13. Empty prompt is rejected client-side, no network hit.
func testTurnEmptyPromptRejected() async throws {
URLProtocolMock.handler = { req in
let path = req.url?.path ?? ""
if path == "/sessions", req.httpMethod == "POST" {
let resp = #"""
{"ok": true, "session_id": "sess_empty", "agent": "claude", "created_at": 1700000300}
"""#
return (sessionResponse(url: req.url!, status: 200), Data(resp.utf8))
}
if path.hasSuffix("/turn") {
XCTFail("turn endpoint must not be called with empty prompt")
}
return (sessionResponse(url: req.url!, status: 404), Data())
}
let client = try makeSessionClient()
let s = try await client.createSession()
do {
_ = try await s.turn("")
XCTFail("expected throw")
} catch ForgeError.invalidArgument {
// expected
} catch {
XCTFail("wrong error: \(error)")
}
}
}