clawdforge/clients/cpp/tests/test_session.cpp
Kayos 1f6606d3b9 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
2026-04-29 07:10:50 -07:00

607 lines
24 KiB
C++

// 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");
}