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:
parent
cb1d8c2c54
commit
0f091771d3
4 changed files with 1196 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
241
clients/swift/Sources/Clawdforge/ForgeClient+Sessions.swift
Normal file
241
clients/swift/Sources/Clawdforge/ForgeClient+Sessions.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
377
clients/swift/Sources/Clawdforge/Session.swift
Normal file
377
clients/swift/Sources/Clawdforge/Session.swift
Normal 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 }
|
||||
}
|
||||
455
clients/swift/Tests/ClawdforgeTests/SessionTests.swift
Normal file
455
clients/swift/Tests/ClawdforgeTests/SessionTests.swift
Normal 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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue