clawdforge/clients/swift/Sources/Clawdforge/Session.swift
Kayos 0f091771d3 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
2026-04-29 07:00:56 -07:00

377 lines
13 KiB
Swift

// 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 }
}