diff --git a/clients/cpp/CMakeLists.txt b/clients/cpp/CMakeLists.txt index b146b2f..087d2b5 100644 --- a/clients/cpp/CMakeLists.txt +++ b/clients/cpp/CMakeLists.txt @@ -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 diff --git a/clients/cpp/README.md b/clients/cpp/README.md index f6d4a41..93b9545 100644 --- a/clients/cpp/README.md +++ b/clients/cpp/README.md @@ -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 +#include + +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/ + + // 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` | +| `Client::get_session(id)` | `GET /sessions/` | `SessionState` | +| `Session::turn(prompt, opts={})` | `POST /sessions//turn` | `TurnResult` | +| `Session::state()` | `GET /sessions/` | `SessionState` | +| `Session::close()` | `DELETE /sessions/` | `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 ``): @@ -90,6 +191,9 @@ int main() { | `create_token(req)` | `POST /admin/tokens` | `AppToken` | | `list_tokens()` | `GET /admin/tokens` | `std::vector` | | `revoke_token(name)` | `DELETE /admin/tokens/` | `void` | +| `create_session(opts={})` | `POST /sessions` | `Session` | +| `list_sessions()` | `GET /sessions` | `std::vector` | +| `get_session(id)` | `GET /sessions/` | `SessionState` | All methods throw on failure. `ClientOptions` lets you set `base_url`, `token`, `admin_token`, `timeout`, `connect_timeout`, `user_agent`, and `insecure_tls`. diff --git a/clients/cpp/include/clawdforge/client.hpp b/clients/cpp/include/clawdforge/client.hpp index 3eb858d..d31c036 100644 --- a/clients/cpp/include/clawdforge/client.hpp +++ b/clients/cpp/include/clawdforge/client.hpp @@ -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_; ///< 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 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 ) 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_; diff --git a/clients/cpp/include/clawdforge/types.hpp b/clients/cpp/include/clawdforge/types.hpp index 1e1cd24..1adc688 100644 --- a/clients/cpp/include/clawdforge/types.hpp +++ b/clients/cpp/include/clawdforge/types.hpp @@ -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 content; + std::optional name; + std::optional args_json; ///< raw JSON for tool args + std::optional 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 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`. +struct SessionState { + std::string session_id; + std::string agent; + std::string app_name; + std::int64_t created_at{0}; + std::optional last_turn_at; + int turn_count{0}; + std::optional 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 files; + /// Per-turn timeout override. Server clamps to 5..=600. + std::optional 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(); + if (j.contains("content") && !j.at("content").is_null()) { + e.content = j.at("content").get(); + } + if (j.contains("name") && !j.at("name").is_null()) { + e.name = j.at("name").get(); + } + // 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(); + 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(); + } + if (j.contains("events") && j.at("events").is_array()) { + r.events = j.at("events").get>(); + } +} + +inline void from_json(const nlohmann::json& j, SessionState& s) { + s.session_id = j.at("session_id").get(); + 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(); + } + 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(); + } +} + inline void from_json(const nlohmann::json& j, AppTokenInfo& a) { a.name = j.at("name").get(); if (j.contains("ip_cidrs") && j.at("ip_cidrs").is_array()) { diff --git a/clients/cpp/src/client.cpp b/clients/cpp/src/client.cpp index e431425..c585a95 100644 --- a/clients/cpp/src/client.cpp +++ b/clients/cpp/src/client.cpp @@ -2,6 +2,9 @@ #include +#include +#include +#include #include #include #include @@ -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(); + 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() + : 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{0}; + return Session(this, std::move(id), std::move(agent), created_at); + }); +} + +std::vector 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 out; + out.reserve(parsed["sessions"].size()); + for (const auto& el : parsed["sessions"]) { + out.push_back(el.get()); + } + 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(); }); +} + +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(); }); +} + +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 closed{false}; +}; + +Session::Session(Client* client, std::string id, std::string agent, + std::int64_t created_at) + : impl_(std::make_unique()) { + 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 diff --git a/clients/cpp/tests/test_session.cpp b/clients/cpp/tests/test_session.cpp new file mode 100644 index 0000000..6166c14 --- /dev/null +++ b/clients/cpp/tests/test_session.cpp @@ -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 + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include + +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 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 create_count{0}; + std::atomic close_count{0}; + std::atomic saw_auth{false}; + std::atomic 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 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 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 saw_files{false}; + std::atomic 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() == "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() == + "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 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 create_count{0}; + std::atomic close_count{0}; + std::vector 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 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 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() == "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(&r.result); + REQUIRE(j != nullptr); + CHECK(j->at("hello").get() == "world"); +}