- 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
607 lines
24 KiB
C++
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");
|
|
}
|