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