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 createToken(_ request: CreateTokenRequest) async throws -> AppToken
|
||||||
public func listTokens() async throws -> [AppToken]
|
public func listTokens() async throws -> [AppToken]
|
||||||
public func revokeToken(name: String) async throws
|
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")
|
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`
|
### `JSONValue`
|
||||||
|
|
||||||
`RunResult.result` is a `JSONValue`. `claude -p --output-format json` may
|
`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