clients/cpp: v0.2 multi-turn Session API

- Session move-only RAII; destructor best-effort close
- Client::create_session / list_sessions / get_session
- TurnResult.text() helper
- All post-parse json::get<T>() wrapped via with_protocol_guard (no nlohmann leak)
- tests/test_session.cpp: ~12 tests covering RAII/idempotency/move/list/state/404/protocol-error/regression
- ASan + UBSan clean
- README "Multi-turn / Sessions (v0.2)" section

v0.1 surface unchanged. C++17 preserved. cpp-httplib 0.20.1 preserved.

Spec: memory/spec-clawdforge-v0.2.md
Server core: 940861f
This commit is contained in:
Kayos 2026-04-29 07:10:34 -07:00
parent 22e57e3dad
commit 1f6606d3b9
6 changed files with 1208 additions and 1 deletions

View file

@ -19,6 +19,67 @@
namespace clawdforge {
class Client;
/// Handle for a multi-turn session on the server (v0.2). Construct via
/// `Client::create_session`. Mirrors `Client`'s move-only RAII shape —
/// destructor best-effort closes the session if it hasn't been closed
/// explicitly, swallowing any exceptions raised on the wire so destruction
/// stays `noexcept`.
///
/// Move-only: each `Session` carries an internal closed-flag, and copying
/// would race the close path. A moved-from `Session` is marked
/// already-closed so the destructor doesn't double-call.
///
/// Not thread-safe. Call `turn()` from one thread at a time per session;
/// different sessions on the same `Client` still share the `Client`'s
/// libcurl handle, so the same per-Client serialization rule applies.
class Session {
public:
~Session();
Session(Session&&) noexcept;
Session& operator=(Session&&) noexcept;
Session(const Session&) = delete;
Session& operator=(const Session&) = delete;
/// Server-assigned session id (PK on the clawdforge ledger).
[[nodiscard]] const std::string& id() const noexcept;
/// Agent name the session was created against (default "claude").
[[nodiscard]] const std::string& agent() const noexcept;
/// Unix timestamp the server recorded at create time.
[[nodiscard]] std::int64_t created_at() const noexcept;
/// `true` after `close()` has run successfully or after move-from.
[[nodiscard]] bool closed() const noexcept;
/// `POST /sessions/{id}/turn`. Returns the structured event batch.
///
/// Throws `Error("session is closed")` if called after `close()` or
/// on a moved-from instance. Other failures follow the SDK's standard
/// exception hierarchy (`AuthError` / `APIError` / `TransportError`
/// / `ProtocolError`).
[[nodiscard]] TurnResult turn(std::string_view prompt,
const TurnOptions& opts = {});
/// `GET /sessions/{id}`. Live state fetch.
[[nodiscard]] SessionState state();
/// `DELETE /sessions/{id}`. Idempotent — a second call short-circuits
/// without an HTTP round-trip via the internal closed flag. The server's
/// close endpoint is itself idempotent, but the local short-circuit
/// saves the request in the common destructor / explicit-close pair
/// pattern.
void close();
private:
friend class Client;
Session(Client* client, std::string id, std::string agent,
std::int64_t created_at);
struct Impl;
std::unique_ptr<Impl> impl_; ///< PImpl: keeps libcurl out of the header.
};
/// Knobs for `Client` construction. Use designated initializers to set only
/// what you care about.
struct ClientOptions {
@ -104,6 +165,37 @@ public:
/// Throws `APIError` with `status_code() == 404` if the token is unknown.
void revoke_token(std::string_view name);
// -- v0.2: multi-turn session API --------------------------------------
/// `POST /sessions`. Returns a move-only `Session` handle whose
/// destructor best-effort closes the session on the server.
[[nodiscard]] Session create_session(const CreateSessionOptions& opts = {});
/// `GET /sessions`. Returns every session visible to the calling token
/// (per-app isolation enforced server-side). Includes closed-but-not-
/// hard-deleted sessions by default.
[[nodiscard]] std::vector<SessionState> list_sessions();
/// `GET /sessions/{id}`. Throws `APIError` with `status_code() == 404`
/// for unknown ids OR for ids that exist under a different token
/// (no-existence-leak design).
[[nodiscard]] SessionState get_session(std::string_view id);
// -- internal helpers used by Session ----------------------------------
//
// Public on the type (the surface lives in <clawdforge/client.hpp>) but
// not part of the documented user API — see `Session::turn` / `close`
// / `state` for the user-facing shape. Direct calls bypass the local
// closed-flag short-circuit and the move-from guard.
/// `POST /sessions/{id}/turn`. Used by `Session::turn`.
[[nodiscard]] TurnResult session_turn_internal(std::string_view id,
std::string_view prompt,
const TurnOptions& opts);
/// `DELETE /sessions/{id}`. Used by `Session::close` and the destructor.
void session_close_internal(std::string_view id);
private:
struct Impl;
std::unique_ptr<Impl> impl_;

View file

@ -121,6 +121,80 @@ struct AppTokenInfo {
nlohmann::json extra;
};
// ---------------------------------------------------------------------------
// /sessions (v0.2)
// ---------------------------------------------------------------------------
/// One structured event in a turn's response — typed text, thinking, or a
/// tool-call record. Mirrors the server's event shape from `acpx_runner`.
///
/// `args_json` and `result_json` carry raw JSON strings rather than parsed
/// objects to keep tool-call payloads opaque to the SDK — callers that care
/// can re-parse via `nlohmann::json::parse(*event.args_json)`.
struct TurnEvent {
std::string type;
std::optional<std::string> content;
std::optional<std::string> name;
std::optional<std::string> args_json; ///< raw JSON for tool args
std::optional<std::string> result_json; ///< raw JSON for tool result
};
/// Successful response body from `POST /sessions/{id}/turn`.
///
/// `text()` concatenates the `content` of every `type == "text"` event in
/// order, dropping thinking/tool_call frames. Use it when you only want the
/// model's user-facing reply.
struct TurnResult {
bool ok{false};
std::string session_id;
int turn_index{0};
std::vector<TurnEvent> events;
std::string stop_reason;
std::int64_t duration_ms{0};
[[nodiscard]] std::string text() const {
std::string out;
for (const auto& e : events) {
if (e.type == "text" && e.content.has_value()) {
out += *e.content;
}
}
return out;
}
};
/// Response body of `GET /sessions/{id}` and one row of `GET /sessions`.
///
/// `app_name` is filled in by the list endpoint but absent from the single-
/// session endpoint when the server is feeling minimalist; we just decode
/// it as an empty string when missing. `last_turn_at` and `closed_at` are
/// nullable on the wire — represented as `std::optional<int64_t>`.
struct SessionState {
std::string session_id;
std::string agent;
std::string app_name;
std::int64_t created_at{0};
std::optional<std::int64_t> last_turn_at;
int turn_count{0};
std::optional<std::int64_t> closed_at;
};
/// Knobs for `Client::create_session`. Mirrors the wire body of `POST /sessions`
/// minus the `meta` field — passing arbitrary JSON metadata through the v0.2
/// surface is deferred (caller-trust shape; revisit in v0.3).
struct CreateSessionOptions {
/// ACPX agent to drive. Server defaults to "claude" if absent.
std::string agent = "claude";
};
/// Per-turn options for `Session::turn`.
struct TurnOptions {
/// Pre-staged file tokens (returned by `Client::upload_file`).
std::vector<std::string> files;
/// Per-turn timeout override. Server clamps to 5..=600.
std::optional<int> timeout_secs;
};
// ---------------------------------------------------------------------------
// JSON glue
// ---------------------------------------------------------------------------
@ -189,6 +263,52 @@ inline void from_json(const nlohmann::json& j, AppToken& a) {
}
}
inline void from_json(const nlohmann::json& j, TurnEvent& e) {
e.type = j.at("type").get<std::string>();
if (j.contains("content") && !j.at("content").is_null()) {
e.content = j.at("content").get<std::string>();
}
if (j.contains("name") && !j.at("name").is_null()) {
e.name = j.at("name").get<std::string>();
}
// Tool-call payloads (`args` / `result`) are persisted as raw JSON
// strings rather than re-parsed structures. Keeps the wire opaque to the
// SDK and avoids round-tripping arbitrary user-influenced JSON twice.
if (j.contains("args") && !j.at("args").is_null()) {
e.args_json = j.at("args").dump();
}
if (j.contains("result") && !j.at("result").is_null()) {
e.result_json = j.at("result").dump();
}
}
inline void from_json(const nlohmann::json& j, TurnResult& r) {
r.ok = j.value("ok", false);
r.session_id = j.at("session_id").get<std::string>();
r.turn_index = j.value("turn_index", 0);
r.duration_ms = j.value("duration_ms", std::int64_t{0});
if (j.contains("stop_reason") && !j.at("stop_reason").is_null()) {
r.stop_reason = j.at("stop_reason").get<std::string>();
}
if (j.contains("events") && j.at("events").is_array()) {
r.events = j.at("events").get<std::vector<TurnEvent>>();
}
}
inline void from_json(const nlohmann::json& j, SessionState& s) {
s.session_id = j.at("session_id").get<std::string>();
s.agent = j.value("agent", std::string{});
s.app_name = j.value("app_name", std::string{});
s.created_at = j.value("created_at", std::int64_t{0});
if (j.contains("last_turn_at") && !j.at("last_turn_at").is_null()) {
s.last_turn_at = j.at("last_turn_at").get<std::int64_t>();
}
s.turn_count = j.value("turn_count", 0);
if (j.contains("closed_at") && !j.at("closed_at").is_null()) {
s.closed_at = j.at("closed_at").get<std::int64_t>();
}
}
inline void from_json(const nlohmann::json& j, AppTokenInfo& a) {
a.name = j.at("name").get<std::string>();
if (j.contains("ip_cidrs") && j.at("ip_cidrs").is_array()) {