diff --git a/clients/swift/README.md b/clients/swift/README.md index a342dfe..c0ad8d0 100644 --- a/clients/swift/README.md +++ b/clients/swift/README.md @@ -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(_ 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 diff --git a/clients/swift/Sources/Clawdforge/ForgeClient+Sessions.swift b/clients/swift/Sources/Clawdforge/ForgeClient+Sessions.swift new file mode 100644 index 0000000..e556082 --- /dev/null +++ b/clients/swift/Sources/Clawdforge/ForgeClient+Sessions.swift @@ -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( + _ 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( + 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(_ 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) + } +} diff --git a/clients/swift/Sources/Clawdforge/Session.swift b/clients/swift/Sources/Clawdforge/Session.swift new file mode 100644 index 0000000..97f05be --- /dev/null +++ b/clients/swift/Sources/Clawdforge/Session.swift @@ -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 } +} diff --git a/clients/swift/Tests/ClawdforgeTests/SessionTests.swift b/clients/swift/Tests/ClawdforgeTests/SessionTests.swift new file mode 100644 index 0000000..6f1a36a --- /dev/null +++ b/clients/swift/Tests/ClawdforgeTests/SessionTests.swift @@ -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//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/ 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/ 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)") + } + } +}