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

@ -2,6 +2,9 @@
#include <clawdforge/client.hpp>
#include <atomic>
#include <cstdio>
#include <exception>
#include <filesystem>
#include <stdexcept>
#include <string>
@ -262,4 +265,282 @@ void Client::revoke_token(std::string_view name) {
// Body is `{"ok": true}`; we don't surface anything from it.
}
// ---------------------------------------------------------------------------
// v0.2: /sessions
// ---------------------------------------------------------------------------
Session Client::create_session(const CreateSessionOptions& opts) {
if (impl_->opts.token.empty()) {
throw AuthError("Client::create_session requires `token` in ClientOptions");
}
detail::Request req;
req.method = "POST";
req.url = detail::join_url(impl_->opts.base_url, "/sessions");
req.headers = impl_->auth_headers(/*admin=*/false);
req.headers["Content-Type"] = "application/json";
nlohmann::json body = nlohmann::json::object();
if (!opts.agent.empty()) {
body["agent"] = opts.agent;
}
req.body = body.dump();
auto resp = impl_->session.perform(req);
if (resp.status < 200 || resp.status >= 300) {
throw_for_status(resp.status, std::move(resp.body));
}
auto parsed = parse_json_or_throw(resp.body, resp.status);
return with_protocol_guard([&] {
std::string id = parsed.at("session_id").get<std::string>();
if (id.empty()) {
throw ProtocolError("create_session: server returned empty session_id");
}
std::string agent = parsed.contains("agent") && !parsed.at("agent").is_null()
? parsed.at("agent").get<std::string>()
: opts.agent;
std::int64_t created_at = parsed.contains("created_at") &&
!parsed.at("created_at").is_null()
? parsed.at("created_at").get<std::int64_t>()
: std::int64_t{0};
return Session(this, std::move(id), std::move(agent), created_at);
});
}
std::vector<SessionState> Client::list_sessions() {
if (impl_->opts.token.empty()) {
throw AuthError("Client::list_sessions requires `token` in ClientOptions");
}
detail::Request req;
req.method = "GET";
req.url = detail::join_url(impl_->opts.base_url, "/sessions");
req.headers = impl_->auth_headers(/*admin=*/false);
auto resp = impl_->session.perform(req);
if (resp.status < 200 || resp.status >= 300) {
throw_for_status(resp.status, std::move(resp.body));
}
auto parsed = parse_json_or_throw(resp.body, resp.status);
if (!parsed.contains("sessions") || !parsed.at("sessions").is_array()) {
throw ProtocolError("list_sessions: missing or non-array `sessions`");
}
return with_protocol_guard([&] {
std::vector<SessionState> out;
out.reserve(parsed["sessions"].size());
for (const auto& el : parsed["sessions"]) {
out.push_back(el.get<SessionState>());
}
return out;
});
}
SessionState Client::get_session(std::string_view id) {
if (impl_->opts.token.empty()) {
throw AuthError("Client::get_session requires `token` in ClientOptions");
}
if (id.empty()) {
throw ProtocolError("get_session: id is empty");
}
detail::Request req;
req.method = "GET";
req.url = detail::join_url(impl_->opts.base_url,
"/sessions/" + detail::url_encode_path(id));
req.headers = impl_->auth_headers(/*admin=*/false);
auto resp = impl_->session.perform(req);
if (resp.status < 200 || resp.status >= 300) {
throw_for_status(resp.status, std::move(resp.body));
}
auto parsed = parse_json_or_throw(resp.body, resp.status);
return with_protocol_guard([&] { return parsed.get<SessionState>(); });
}
TurnResult Client::session_turn_internal(std::string_view id,
std::string_view prompt,
const TurnOptions& opts) {
if (impl_->opts.token.empty()) {
throw AuthError("Session::turn requires `token` in ClientOptions");
}
if (id.empty()) {
throw ProtocolError("session_turn: id is empty");
}
if (prompt.empty()) {
throw ProtocolError("session_turn: prompt must not be empty");
}
detail::Request req;
req.method = "POST";
req.url = detail::join_url(impl_->opts.base_url,
"/sessions/" + detail::url_encode_path(id) + "/turn");
req.headers = impl_->auth_headers(/*admin=*/false);
req.headers["Content-Type"] = "application/json";
nlohmann::json body;
body["prompt"] = std::string{prompt};
if (!opts.files.empty()) {
body["files"] = opts.files;
}
if (opts.timeout_secs && *opts.timeout_secs > 0) {
body["timeout_secs"] = *opts.timeout_secs;
}
req.body = body.dump();
auto resp = impl_->session.perform(req);
if (resp.status < 200 || resp.status >= 300) {
throw_for_status(resp.status, std::move(resp.body));
}
auto parsed = parse_json_or_throw(resp.body, resp.status);
return with_protocol_guard([&] { return parsed.get<TurnResult>(); });
}
void Client::session_close_internal(std::string_view id) {
if (impl_->opts.token.empty()) {
throw AuthError("Session::close requires `token` in ClientOptions");
}
if (id.empty()) {
throw ProtocolError("session_close: id is empty");
}
detail::Request req;
req.method = "DELETE";
req.url = detail::join_url(impl_->opts.base_url,
"/sessions/" + detail::url_encode_path(id));
req.headers = impl_->auth_headers(/*admin=*/false);
auto resp = impl_->session.perform(req);
if (resp.status < 200 || resp.status >= 300) {
throw_for_status(resp.status, std::move(resp.body));
}
// Body is `{"ok":true}` or `{"ok":true,"already_closed":true}` — both
// are success from our perspective. Nothing to surface.
}
// ---------------------------------------------------------------------------
// Session
// ---------------------------------------------------------------------------
struct Session::Impl {
Client* client{nullptr};
std::string id;
std::string agent;
std::int64_t created_at{0};
/// Atomic so concurrent close() / destructor races on the same Session
/// resolve to exactly one DELETE on the wire — even though the type is
/// not otherwise documented as thread-safe, defer-Close from multiple
/// scopes falls out of move semantics in surprising places, so the
/// flag itself stays race-safe.
std::atomic<bool> closed{false};
};
Session::Session(Client* client, std::string id, std::string agent,
std::int64_t created_at)
: impl_(std::make_unique<Impl>()) {
impl_->client = client;
impl_->id = std::move(id);
impl_->agent = std::move(agent);
impl_->created_at = created_at;
}
Session::Session(Session&& other) noexcept : impl_(std::move(other.impl_)) {
// Leave `other` in a valid, already-closed state so its destructor is a
// no-op. Without this the moved-from object would race the new one to
// call DELETE on the same session id.
if (impl_) {
// No-op: the moved-from `other` now has impl_ == nullptr, which
// the destructor / accessors handle directly.
}
}
Session& Session::operator=(Session&& other) noexcept {
if (this != &other) {
// Best-effort close on whatever we currently own before taking
// over `other`'s state. Mirrors the destructor's swallow-exceptions
// contract — `noexcept` is non-negotiable for move-assign.
if (impl_ && impl_->client && !impl_->closed.exchange(true)) {
try {
impl_->client->session_close_internal(impl_->id);
} catch (...) {
// swallow — same as the destructor
}
}
impl_ = std::move(other.impl_);
}
return *this;
}
Session::~Session() {
if (!impl_) return;
if (!impl_->client) return;
if (impl_->closed.exchange(true)) return;
try {
impl_->client->session_close_internal(impl_->id);
} catch (...) {
// Destructors must not throw. Best-effort close — log to stderr so
// the operator at least has a breadcrumb when this trips. We do
// NOT log the session id at full length to avoid leaking it into
// unexpected sinks; first 8 chars is enough to grep the server log.
try {
const std::string id_prefix = impl_->id.size() > 8
? impl_->id.substr(0, 8) + "..."
: impl_->id;
std::fprintf(stderr,
"clawdforge: ~Session(%s) close failed (swallowed)\n",
id_prefix.c_str());
} catch (...) {
// Logging also failed — give up silently. Cannot escape ~.
}
}
}
const std::string& Session::id() const noexcept {
static const std::string kEmpty;
return impl_ ? impl_->id : kEmpty;
}
const std::string& Session::agent() const noexcept {
static const std::string kEmpty;
return impl_ ? impl_->agent : kEmpty;
}
std::int64_t Session::created_at() const noexcept {
return impl_ ? impl_->created_at : 0;
}
bool Session::closed() const noexcept {
if (!impl_) return true; // moved-from → effectively closed
return impl_->closed.load();
}
TurnResult Session::turn(std::string_view prompt, const TurnOptions& opts) {
if (!impl_ || !impl_->client) {
// Surfaced as ProtocolError (the catch-all under
// `clawdforge::Error`) — moved-from / closed are caller-side
// invariant violations, not server-side problems.
throw ProtocolError("Session::turn called on moved-from session");
}
if (impl_->closed.load()) {
throw ProtocolError("Session::turn called on closed session");
}
return impl_->client->session_turn_internal(impl_->id, prompt, opts);
}
SessionState Session::state() {
if (!impl_ || !impl_->client) {
throw ProtocolError("Session::state called on moved-from session");
}
return impl_->client->get_session(impl_->id);
}
void Session::close() {
if (!impl_ || !impl_->client) return;
if (impl_->closed.exchange(true)) return; // idempotent — already done
try {
impl_->client->session_close_internal(impl_->id);
} catch (...) {
// Roll the flag back so the destructor / a retry can try again.
// The server's close is idempotent, so even if the network call
// half-succeeded a retry is safe.
impl_->closed.store(false);
throw;
}
}
} // namespace clawdforge