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:
parent
22e57e3dad
commit
1f6606d3b9
6 changed files with 1208 additions and 1 deletions
|
|
@ -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_;
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue