- 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
377 lines
13 KiB
Swift
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 }
|
|
}
|