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

@ -192,7 +192,10 @@ if(CLAWDFORGE_BUILD_TESTS)
FetchContent_MakeAvailable(doctest)
endif()
add_executable(clawdforge_tests tests/test_client.cpp)
add_executable(clawdforge_tests
tests/test_client.cpp
tests/test_session.cpp
)
target_link_libraries(clawdforge_tests
PRIVATE
clawdforge::clawdforge

View file

@ -78,6 +78,107 @@ int main() {
}
```
## Multi-turn / Sessions (v0.2)
`clawdforge::Session` is the multi-turn handle returned by
`Client::create_session`. RAII — when the `Session` goes out of scope its
destructor best-effort closes the session on the server.
```cpp
#include <clawdforge/client.hpp>
#include <iostream>
namespace cf = clawdforge;
int main() {
cf::Client client{cf::ClientOptions{
.base_url = "http://localhost:8800",
.token = "cf_...",
}};
{
// Destructor closes if not already closed.
auto s = client.create_session(cf::CreateSessionOptions{.agent = "claude"});
auto r1 = s.turn("Read README.md and summarize");
std::cout << r1.text() << "\n";
auto r2 = s.turn("Now look at the auth flow",
cf::TurnOptions{.files = {"ff_xyz"}});
std::cout << r2.text() << "\n";
} // ~Session sends DELETE /sessions/<id>
// List + state lookups:
auto sessions = client.list_sessions();
for (const auto& st : sessions) {
std::cout << st.session_id << " turns=" << st.turn_count << "\n";
}
}
```
### Explicit close as fallback
```cpp
auto s = client.create_session();
try {
s.turn("...");
} catch (...) {
s.close(); // optional — destructor would do it anyway
throw;
}
s.close(); // idempotent — second + subsequent calls short-circuit
// without an HTTP round-trip
```
`close()` is idempotent both client-side (an internal flag short-circuits
without a network call) and server-side (the server returns
`{"ok":true,"already_closed":true}` on a second hit). Safe to call from
any cleanup path.
### Move-only — mirrors `Client`
`Session` follows the same shape as `Client`: move-only, copy-deleted. A
moved-from `Session` is marked already-closed so its destructor doesn't
double-call. Move-assign best-effort closes the LHS before taking over the
RHS's state.
```cpp
auto a = client.create_session();
auto b = std::move(a); // a's session id transfers to b; ~a is a no-op
```
### Methods
| Method | HTTP | Returns |
|---|---|---|
| `Client::create_session(opts={})` | `POST /sessions` | `Session` (move-only) |
| `Client::list_sessions()` | `GET /sessions` | `std::vector<SessionState>` |
| `Client::get_session(id)` | `GET /sessions/<id>` | `SessionState` |
| `Session::turn(prompt, opts={})` | `POST /sessions/<id>/turn` | `TurnResult` |
| `Session::state()` | `GET /sessions/<id>` | `SessionState` |
| `Session::close()` | `DELETE /sessions/<id>` | `void` (idempotent) |
`TurnResult::text()` concatenates the `content` of every `type == "text"`
event into a single string, dropping `thinking` / `tool_call` frames. Use
when you only want the model's user-facing reply.
Tool-call events carry their `args` and `result` payloads as raw JSON
strings (`args_json` / `result_json`) rather than parsed structures —
re-parse via `nlohmann::json::parse(*event.args_json)` if you need the
structured value.
Cross-token access — pulling a session id that exists under a different
token — surfaces as `clawdforge::APIError` with `status_code() == 404`,
matching the server's no-existence-leak design.
Calling `Session::turn` after `close()` (or on a moved-from `Session`)
throws `clawdforge::ProtocolError("Session::turn called on closed session")`.
### v0.1 surface unchanged
The `/run` path stays byte-identical to v0.1. Single-turn callers don't
need to migrate.
## API
`clawdforge::Client` (in `<clawdforge/client.hpp>`):
@ -90,6 +191,9 @@ int main() {
| `create_token(req)` | `POST /admin/tokens` | `AppToken` |
| `list_tokens()` | `GET /admin/tokens` | `std::vector<AppTokenInfo>` |
| `revoke_token(name)` | `DELETE /admin/tokens/<name>` | `void` |
| `create_session(opts={})` | `POST /sessions` | `Session` |
| `list_sessions()` | `GET /sessions` | `std::vector<SessionState>` |
| `get_session(id)` | `GET /sessions/<id>` | `SessionState` |
All methods throw on failure. `ClientOptions` lets you set `base_url`, `token`,
`admin_token`, `timeout`, `connect_timeout`, `user_agent`, and `insecure_tls`.

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()) {

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

View file

@ -0,0 +1,607 @@
// SPDX-License-Identifier: MIT
//
// v0.2 multi-turn session API tests. Same harness shape as test_client.cpp:
// `cpp-httplib` mock server in-process, doctest assertions. Each TEST_CASE
// gets a fresh server bound to 127.0.0.1:0 so cases can't collide.
//
// Coverage:
// - protocol_error_on_malformed_session_response
// - session_create_and_destructor_closes
// - session_close_idempotent
// - session_close_after_destructor_no_double_call
// - session_turn_round_trip
// - session_turn_after_close_throws
// - turn_result_text_concatenates
// - list_sessions
// - get_session
// - cross_token_404
// - session_move_ctor_does_not_double_close
// - v0_1_run_unchanged (regression)
#include <doctest/doctest.h>
#include <atomic>
#include <chrono>
#include <functional>
#include <memory>
#include <mutex>
#include <string>
#include <string_view>
#include <thread>
#include <vector>
#include <httplib.h>
#include <nlohmann/json.hpp>
#include <clawdforge/client.hpp>
namespace cf = clawdforge;
using nlohmann::json;
namespace {
/// RAII httplib::Server bound to 127.0.0.1:0 in a background thread. Same
/// shape as the helper in test_client.cpp; duplicated here to keep the
/// session test file self-contained without exposing a shared header (the
/// MockServer stays test-only utility code).
class MockServer {
public:
explicit MockServer(std::function<void(httplib::Server&)> wire_routes) {
srv_.new_task_queue = [] { return new httplib::ThreadPool(2); };
wire_routes(srv_);
port_ = srv_.bind_to_any_port("127.0.0.1");
REQUIRE(port_ > 0);
thread_ = std::thread([this] { srv_.listen_after_bind(); });
for (int i = 0; i < 200; ++i) {
if (srv_.is_running()) break;
std::this_thread::sleep_for(std::chrono::milliseconds(5));
}
REQUIRE(srv_.is_running());
}
~MockServer() {
srv_.stop();
if (thread_.joinable()) thread_.join();
}
[[nodiscard]] std::string base_url() const {
return "http://127.0.0.1:" + std::to_string(port_);
}
private:
httplib::Server srv_;
int port_{0};
std::thread thread_;
};
/// Default create-session response body.
[[nodiscard]] std::string create_payload(const std::string& sid = "sess_abc",
const std::string& agent = "claude") {
json j = {
{"ok", true},
{"session_id", sid},
{"agent", agent},
{"created_at", 1'700'000'000},
{"cwd", "/tmp/acpx-sessions/" + sid},
};
return j.dump();
}
} // namespace
// ---------------------------------------------------------------------------
// create + destructor closes
// ---------------------------------------------------------------------------
TEST_CASE("session_create_and_destructor_closes") {
std::atomic<int> create_count{0};
std::atomic<int> close_count{0};
std::atomic<bool> saw_auth{false};
std::atomic<bool> saw_agent{false};
MockServer mock{[&](httplib::Server& s) {
s.Post("/sessions", [&](const httplib::Request& req, httplib::Response& res) {
create_count.fetch_add(1);
saw_auth = (req.get_header_value("Authorization") == "Bearer cf_test");
const auto body = json::parse(req.body);
saw_agent = (body.value("agent", std::string{}) == "claude");
res.set_content(create_payload(), "application/json");
});
s.Delete(R"(/sessions/(.+))",
[&](const httplib::Request&, httplib::Response& res) {
close_count.fetch_add(1);
res.set_content(R"({"ok":true})", "application/json");
});
}};
cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_test"}};
{
auto sess = c.create_session(cf::CreateSessionOptions{.agent = "claude"});
CHECK(sess.id() == "sess_abc");
CHECK(sess.agent() == "claude");
CHECK(sess.created_at() == 1'700'000'000);
CHECK_FALSE(sess.closed());
} // dtor here triggers DELETE
CHECK(create_count.load() == 1);
CHECK(close_count.load() == 1);
CHECK(saw_auth.load());
CHECK(saw_agent.load());
}
// ---------------------------------------------------------------------------
// idempotent close
// ---------------------------------------------------------------------------
TEST_CASE("session_close_idempotent") {
std::atomic<int> close_count{0};
MockServer mock{[&](httplib::Server& s) {
s.Post("/sessions", [](const httplib::Request&, httplib::Response& res) {
res.set_content(create_payload(), "application/json");
});
s.Delete(R"(/sessions/(.+))",
[&](const httplib::Request&, httplib::Response& res) {
close_count.fetch_add(1);
res.set_content(R"({"ok":true})", "application/json");
});
}};
cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_test"}};
auto sess = c.create_session();
CHECK_FALSE(sess.closed());
sess.close();
CHECK(sess.closed());
sess.close(); // no second DELETE
sess.close(); // ditto
CHECK(close_count.load() == 1);
}
// ---------------------------------------------------------------------------
// destructor doesn't double-close after explicit close
// ---------------------------------------------------------------------------
TEST_CASE("session_close_after_destructor_no_double_call") {
std::atomic<int> close_count{0};
MockServer mock{[&](httplib::Server& s) {
s.Post("/sessions", [](const httplib::Request&, httplib::Response& res) {
res.set_content(create_payload(), "application/json");
});
s.Delete(R"(/sessions/(.+))",
[&](const httplib::Request&, httplib::Response& res) {
close_count.fetch_add(1);
res.set_content(R"({"ok":true})", "application/json");
});
}};
cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_test"}};
{
auto sess = c.create_session();
sess.close();
} // dtor must NOT issue a second DELETE
CHECK(close_count.load() == 1);
}
// ---------------------------------------------------------------------------
// turn round-trip
// ---------------------------------------------------------------------------
TEST_CASE("session_turn_round_trip") {
std::atomic<bool> saw_files{false};
std::atomic<bool> saw_timeout{false};
std::string captured_prompt;
MockServer mock{[&](httplib::Server& s) {
s.Post("/sessions", [](const httplib::Request&, httplib::Response& res) {
res.set_content(create_payload(), "application/json");
});
s.Post(R"(/sessions/(.+)/turn)",
[&](const httplib::Request& req, httplib::Response& res) {
const auto body = json::parse(req.body);
captured_prompt = body.value("prompt", "");
saw_files = body.contains("files") &&
body.at("files").is_array() &&
body.at("files").size() == 1 &&
body.at("files")[0].get<std::string>() == "ff_xyz";
saw_timeout = (body.value("timeout_secs", 0) == 42);
json out = {
{"ok", true},
{"session_id", "sess_abc"},
{"turn_index", 2},
{"events", json::array({
json{{"type", "thinking"}, {"content", "..."}},
json{{"type", "tool_call"},
{"name", "Read"},
{"args", json{{"path", "README.md"}}}},
json{{"type", "text"}, {"content", "hello"}},
})},
{"stop_reason", "end_turn"},
{"duration_ms", 4321},
};
res.set_content(out.dump(), "application/json");
});
s.Delete(R"(/sessions/(.+))",
[](const httplib::Request&, httplib::Response& res) {
res.set_content(R"({"ok":true})", "application/json");
});
}};
cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_test"}};
auto sess = c.create_session();
auto r = sess.turn("hello", cf::TurnOptions{.files = {"ff_xyz"},
.timeout_secs = 42});
CHECK(r.ok);
CHECK(r.session_id == "sess_abc");
CHECK(r.turn_index == 2);
CHECK(r.stop_reason == "end_turn");
CHECK(r.duration_ms == 4321);
REQUIRE(r.events.size() == 3);
CHECK(r.events[0].type == "thinking");
REQUIRE(r.events[0].content.has_value());
CHECK(*r.events[0].content == "...");
CHECK(r.events[1].type == "tool_call");
REQUIRE(r.events[1].name.has_value());
CHECK(*r.events[1].name == "Read");
REQUIRE(r.events[1].args_json.has_value());
CHECK(json::parse(*r.events[1].args_json).at("path").get<std::string>() ==
"README.md");
CHECK(r.events[2].type == "text");
REQUIRE(r.events[2].content.has_value());
CHECK(*r.events[2].content == "hello");
CHECK(captured_prompt == "hello");
CHECK(saw_files.load());
CHECK(saw_timeout.load());
}
// ---------------------------------------------------------------------------
// turn after close throws
// ---------------------------------------------------------------------------
TEST_CASE("session_turn_after_close_throws") {
MockServer mock{[](httplib::Server& s) {
s.Post("/sessions", [](const httplib::Request&, httplib::Response& res) {
res.set_content(create_payload(), "application/json");
});
s.Delete(R"(/sessions/(.+))",
[](const httplib::Request&, httplib::Response& res) {
res.set_content(R"({"ok":true})", "application/json");
});
}};
cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_test"}};
auto sess = c.create_session();
sess.close();
CHECK_THROWS_AS(sess.turn("nope"), cf::ProtocolError);
// Sanity: the catch-all base also catches.
CHECK_THROWS_AS(sess.turn("nope"), cf::Error);
}
// ---------------------------------------------------------------------------
// TurnResult.text() concatenation
// ---------------------------------------------------------------------------
TEST_CASE("turn_result_text_concatenates") {
cf::TurnResult r;
r.events.push_back(cf::TurnEvent{"thinking", std::string{"should not appear"},
std::nullopt, std::nullopt, std::nullopt});
r.events.push_back(cf::TurnEvent{"text", std::string{"hello "}, std::nullopt,
std::nullopt, std::nullopt});
r.events.push_back(cf::TurnEvent{"tool_call", std::nullopt,
std::string{"Read"}, std::string{"{}"},
std::string{"{}"}});
r.events.push_back(cf::TurnEvent{"text", std::string{"world"}, std::nullopt,
std::nullopt, std::nullopt});
// Malformed event with no content — must not blow up.
r.events.push_back(cf::TurnEvent{"text", std::nullopt, std::nullopt,
std::nullopt, std::nullopt});
CHECK(r.text() == "hello world");
cf::TurnResult empty;
empty.events.push_back(cf::TurnEvent{"tool_call", std::nullopt,
std::string{"X"}, std::nullopt,
std::nullopt});
CHECK(empty.text().empty());
}
// ---------------------------------------------------------------------------
// list_sessions
// ---------------------------------------------------------------------------
TEST_CASE("list_sessions") {
MockServer mock{[](httplib::Server& s) {
s.Get("/sessions", [](const httplib::Request& req, httplib::Response& res) {
CHECK(req.get_header_value("Authorization") == "Bearer cf_test");
json out = {
{"ok", true},
{"sessions", json::array({
json{{"session_id", "sess_a"},
{"agent", "claude"},
{"app_name", "cauldron"},
{"created_at", 100},
{"last_turn_at", 200},
{"turn_count", 3},
{"closed_at", nullptr}},
json{{"session_id", "sess_b"},
{"agent", "claude"},
{"app_name", "cauldron"},
{"created_at", 50},
{"last_turn_at", nullptr},
{"turn_count", 0},
{"closed_at", 75}},
})},
{"count", 2},
};
res.set_content(out.dump(), "application/json");
});
}};
cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_test"}};
const auto rows = c.list_sessions();
REQUIRE(rows.size() == 2);
CHECK(rows[0].session_id == "sess_a");
CHECK(rows[0].turn_count == 3);
REQUIRE(rows[0].last_turn_at.has_value());
CHECK(*rows[0].last_turn_at == 200);
CHECK_FALSE(rows[0].closed_at.has_value());
CHECK(rows[1].session_id == "sess_b");
CHECK_FALSE(rows[1].last_turn_at.has_value());
REQUIRE(rows[1].closed_at.has_value());
CHECK(*rows[1].closed_at == 75);
}
// ---------------------------------------------------------------------------
// get_session
// ---------------------------------------------------------------------------
TEST_CASE("get_session") {
MockServer mock{[](httplib::Server& s) {
s.Get(R"(/sessions/(.+))",
[](const httplib::Request&, httplib::Response& res) {
json out = {
{"ok", true},
{"session_id", "sess_xyz"},
{"agent", "claude"},
{"app_name", "cauldron"},
{"created_at", 100},
{"last_turn_at", 200},
{"turn_count", 5},
{"closed_at", nullptr},
{"live", true},
};
res.set_content(out.dump(), "application/json");
});
}};
cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_test"}};
const auto st = c.get_session("sess_xyz");
CHECK(st.session_id == "sess_xyz");
CHECK(st.agent == "claude");
CHECK(st.turn_count == 5);
REQUIRE(st.last_turn_at.has_value());
CHECK(*st.last_turn_at == 200);
CHECK_FALSE(st.closed_at.has_value());
}
TEST_CASE("get_session: empty id rejected locally") {
cf::Client c{cf::ClientOptions{.base_url = "http://127.0.0.1:1", .token = "cf_test"}};
CHECK_THROWS_AS(c.get_session(""), cf::ProtocolError);
}
// ---------------------------------------------------------------------------
// cross-token 404 → APIError(404)
// ---------------------------------------------------------------------------
TEST_CASE("cross_token_404") {
MockServer mock{[](httplib::Server& s) {
s.Get(R"(/sessions/(.+))",
[](const httplib::Request&, httplib::Response& res) {
res.status = 404;
res.set_content(R"({"detail":"session not found"})",
"application/json");
});
}};
cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_test"}};
try {
(void)c.get_session("sess_other_token");
FAIL("expected APIError");
} catch (const cf::APIError& e) {
CHECK(e.status_code() == 404);
}
}
TEST_CASE("session_turn_404_is_api_error") {
MockServer mock{[](httplib::Server& s) {
s.Post("/sessions", [](const httplib::Request&, httplib::Response& res) {
res.set_content(create_payload(), "application/json");
});
s.Post(R"(/sessions/(.+)/turn)",
[](const httplib::Request&, httplib::Response& res) {
res.status = 404;
res.set_content(R"({"detail":"session not found"})",
"application/json");
});
s.Delete(R"(/sessions/(.+))",
[](const httplib::Request&, httplib::Response& res) {
res.set_content(R"({"ok":true})", "application/json");
});
}};
cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_test"}};
auto sess = c.create_session();
try {
(void)sess.turn("hello");
FAIL("expected APIError");
} catch (const cf::APIError& e) {
CHECK(e.status_code() == 404);
}
}
// ---------------------------------------------------------------------------
// Move semantics — ctor + assign do not double-close
// ---------------------------------------------------------------------------
TEST_CASE("session_move_ctor_does_not_double_close") {
std::atomic<int> close_count{0};
MockServer mock{[&](httplib::Server& s) {
s.Post("/sessions", [](const httplib::Request&, httplib::Response& res) {
res.set_content(create_payload(), "application/json");
});
s.Delete(R"(/sessions/(.+))",
[&](const httplib::Request&, httplib::Response& res) {
close_count.fetch_add(1);
res.set_content(R"({"ok":true})", "application/json");
});
}};
cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_test"}};
{
auto a = c.create_session();
auto b = std::move(a);
// `a` is now moved-from; its destructor must NOT issue another
// DELETE. `b` owns the session and its destructor will close.
CHECK(b.id() == "sess_abc");
CHECK_FALSE(b.closed());
CHECK(a.closed()); // moved-from is treated as already-closed
}
CHECK(close_count.load() == 1);
}
TEST_CASE("session_move_assign_closes_lhs_then_takes_rhs") {
std::atomic<int> create_count{0};
std::atomic<int> close_count{0};
std::vector<std::string> closed_ids;
std::mutex mu;
MockServer mock{[&](httplib::Server& s) {
s.Post("/sessions", [&](const httplib::Request&, httplib::Response& res) {
const int n = create_count.fetch_add(1);
const std::string sid = (n == 0) ? "sess_lhs" : "sess_rhs";
res.set_content(create_payload(sid), "application/json");
});
s.Delete(R"(/sessions/(.+))",
[&](const httplib::Request& req, httplib::Response& res) {
close_count.fetch_add(1);
std::lock_guard<std::mutex> lk(mu);
// path = /sessions/sess_xxx — strip the prefix
constexpr std::string_view kPrefix = "/sessions/";
closed_ids.emplace_back(req.path.substr(kPrefix.size()));
res.set_content(R"({"ok":true})", "application/json");
});
}};
cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_test"}};
{
auto lhs = c.create_session();
CHECK(lhs.id() == "sess_lhs");
auto rhs = c.create_session();
CHECK(rhs.id() == "sess_rhs");
lhs = std::move(rhs);
// lhs's old session was closed by the move-assign; lhs now owns
// rhs's session.
CHECK(lhs.id() == "sess_rhs");
CHECK_FALSE(lhs.closed());
} // lhs dtor closes sess_rhs
CHECK(close_count.load() == 2);
std::lock_guard<std::mutex> lk(mu);
REQUIRE(closed_ids.size() == 2);
// Order: sess_lhs closed first (move-assign), then sess_rhs (lhs dtor).
CHECK(closed_ids[0] == "sess_lhs");
CHECK(closed_ids[1] == "sess_rhs");
}
// ---------------------------------------------------------------------------
// ProtocolError on malformed session response
// ---------------------------------------------------------------------------
TEST_CASE("protocol_error_on_malformed_session_response: create") {
MockServer mock{[](httplib::Server& s) {
s.Post("/sessions", [](const httplib::Request&, httplib::Response& res) {
// Missing required `session_id` — `parsed.at("session_id")`
// throws nlohmann::out_of_range, must surface as ProtocolError.
res.set_content(R"({"ok":true,"agent":"claude"})", "application/json");
});
}};
cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_test"}};
CHECK_THROWS_AS(c.create_session(), cf::ProtocolError);
// Sanity: the catch-all base also catches.
CHECK_THROWS_AS(c.create_session(), cf::Error);
}
TEST_CASE("protocol_error_on_malformed_session_response: turn") {
MockServer mock{[](httplib::Server& s) {
s.Post("/sessions", [](const httplib::Request&, httplib::Response& res) {
res.set_content(create_payload(), "application/json");
});
s.Post(R"(/sessions/(.+)/turn)",
[](const httplib::Request&, httplib::Response& res) {
// Wrong type for `session_id` — number not string.
res.set_content(R"({"ok":true,"session_id":42,"events":[]})",
"application/json");
});
s.Delete(R"(/sessions/(.+))",
[](const httplib::Request&, httplib::Response& res) {
res.set_content(R"({"ok":true})", "application/json");
});
}};
cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_test"}};
auto sess = c.create_session();
CHECK_THROWS_AS(sess.turn("hi"), cf::ProtocolError);
}
TEST_CASE("protocol_error_on_malformed_session_response: list") {
MockServer mock{[](httplib::Server& s) {
s.Get("/sessions", [](const httplib::Request&, httplib::Response& res) {
// Entry missing `session_id`.
res.set_content(R"({"ok":true,"sessions":[{"agent":"claude"}],"count":1})",
"application/json");
});
}};
cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_test"}};
CHECK_THROWS_AS(c.list_sessions(), cf::ProtocolError);
}
TEST_CASE("protocol_error_on_malformed_session_response: get") {
MockServer mock{[](httplib::Server& s) {
s.Get(R"(/sessions/(.+))",
[](const httplib::Request&, httplib::Response& res) {
// Missing required `session_id`.
res.set_content(R"({"agent":"claude","created_at":1})",
"application/json");
});
}};
cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_test"}};
CHECK_THROWS_AS(c.get_session("sess_x"), cf::ProtocolError);
}
// ---------------------------------------------------------------------------
// Regression: v0.1 /run is unchanged after layering v0.2 on top.
// ---------------------------------------------------------------------------
TEST_CASE("v0_1_run_unchanged") {
MockServer mock{[](httplib::Server& s) {
s.Post("/run", [](const httplib::Request& req, httplib::Response& res) {
CHECK(req.get_header_value("Authorization") == "Bearer cf_test");
const auto body = json::parse(req.body);
CHECK(body.at("prompt").get<std::string>() == "hi");
res.set_content(
R"({"ok":true,"result":{"hello":"world"},"duration_ms":42,"stop_reason":"end_turn"})",
"application/json");
});
}};
cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_test"}};
const auto r = c.run(cf::RunRequest{.prompt = "hi"});
CHECK(r.ok);
CHECK(r.duration_ms == 42);
REQUIRE(r.stop_reason.has_value());
CHECK(*r.stop_reason == "end_turn");
const auto* j = std::get_if<nlohmann::json>(&r.result);
REQUIRE(j != nullptr);
CHECK(j->at("hello").get<std::string>() == "world");
}