clients/cpp: v0.2 multi-turn Session API
- Session move-only RAII; destructor best-effort close
- Client::create_session / list_sessions / get_session
- TurnResult.text() helper
- All post-parse json::get<T>() wrapped via with_protocol_guard (no nlohmann leak)
- tests/test_session.cpp: ~12 tests covering RAII/idempotency/move/list/state/404/protocol-error/regression
- ASan + UBSan clean
- README "Multi-turn / Sessions (v0.2)" section
v0.1 surface unchanged. C++17 preserved. cpp-httplib 0.20.1 preserved.
Spec: memory/spec-clawdforge-v0.2.md
Server core: 940861f
This commit is contained in:
parent
22e57e3dad
commit
1f6606d3b9
6 changed files with 1208 additions and 1 deletions
|
|
@ -192,7 +192,10 @@ if(CLAWDFORGE_BUILD_TESTS)
|
||||||
FetchContent_MakeAvailable(doctest)
|
FetchContent_MakeAvailable(doctest)
|
||||||
endif()
|
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
|
target_link_libraries(clawdforge_tests
|
||||||
PRIVATE
|
PRIVATE
|
||||||
clawdforge::clawdforge
|
clawdforge::clawdforge
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,107 @@ int main() {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Multi-turn / Sessions (v0.2)
|
||||||
|
|
||||||
|
`clawdforge::Session` is the multi-turn handle returned by
|
||||||
|
`Client::create_session`. RAII — when the `Session` goes out of scope its
|
||||||
|
destructor best-effort closes the session on the server.
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
#include <clawdforge/client.hpp>
|
||||||
|
#include <iostream>
|
||||||
|
|
||||||
|
namespace cf = clawdforge;
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
cf::Client client{cf::ClientOptions{
|
||||||
|
.base_url = "http://localhost:8800",
|
||||||
|
.token = "cf_...",
|
||||||
|
}};
|
||||||
|
|
||||||
|
{
|
||||||
|
// Destructor closes if not already closed.
|
||||||
|
auto s = client.create_session(cf::CreateSessionOptions{.agent = "claude"});
|
||||||
|
|
||||||
|
auto r1 = s.turn("Read README.md and summarize");
|
||||||
|
std::cout << r1.text() << "\n";
|
||||||
|
|
||||||
|
auto r2 = s.turn("Now look at the auth flow",
|
||||||
|
cf::TurnOptions{.files = {"ff_xyz"}});
|
||||||
|
std::cout << r2.text() << "\n";
|
||||||
|
} // ~Session sends DELETE /sessions/<id>
|
||||||
|
|
||||||
|
// List + state lookups:
|
||||||
|
auto sessions = client.list_sessions();
|
||||||
|
for (const auto& st : sessions) {
|
||||||
|
std::cout << st.session_id << " turns=" << st.turn_count << "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Explicit close as fallback
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
auto s = client.create_session();
|
||||||
|
try {
|
||||||
|
s.turn("...");
|
||||||
|
} catch (...) {
|
||||||
|
s.close(); // optional — destructor would do it anyway
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
s.close(); // idempotent — second + subsequent calls short-circuit
|
||||||
|
// without an HTTP round-trip
|
||||||
|
```
|
||||||
|
|
||||||
|
`close()` is idempotent both client-side (an internal flag short-circuits
|
||||||
|
without a network call) and server-side (the server returns
|
||||||
|
`{"ok":true,"already_closed":true}` on a second hit). Safe to call from
|
||||||
|
any cleanup path.
|
||||||
|
|
||||||
|
### Move-only — mirrors `Client`
|
||||||
|
|
||||||
|
`Session` follows the same shape as `Client`: move-only, copy-deleted. A
|
||||||
|
moved-from `Session` is marked already-closed so its destructor doesn't
|
||||||
|
double-call. Move-assign best-effort closes the LHS before taking over the
|
||||||
|
RHS's state.
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
auto a = client.create_session();
|
||||||
|
auto b = std::move(a); // a's session id transfers to b; ~a is a no-op
|
||||||
|
```
|
||||||
|
|
||||||
|
### Methods
|
||||||
|
|
||||||
|
| Method | HTTP | Returns |
|
||||||
|
|---|---|---|
|
||||||
|
| `Client::create_session(opts={})` | `POST /sessions` | `Session` (move-only) |
|
||||||
|
| `Client::list_sessions()` | `GET /sessions` | `std::vector<SessionState>` |
|
||||||
|
| `Client::get_session(id)` | `GET /sessions/<id>` | `SessionState` |
|
||||||
|
| `Session::turn(prompt, opts={})` | `POST /sessions/<id>/turn` | `TurnResult` |
|
||||||
|
| `Session::state()` | `GET /sessions/<id>` | `SessionState` |
|
||||||
|
| `Session::close()` | `DELETE /sessions/<id>` | `void` (idempotent) |
|
||||||
|
|
||||||
|
`TurnResult::text()` concatenates the `content` of every `type == "text"`
|
||||||
|
event into a single string, dropping `thinking` / `tool_call` frames. Use
|
||||||
|
when you only want the model's user-facing reply.
|
||||||
|
|
||||||
|
Tool-call events carry their `args` and `result` payloads as raw JSON
|
||||||
|
strings (`args_json` / `result_json`) rather than parsed structures —
|
||||||
|
re-parse via `nlohmann::json::parse(*event.args_json)` if you need the
|
||||||
|
structured value.
|
||||||
|
|
||||||
|
Cross-token access — pulling a session id that exists under a different
|
||||||
|
token — surfaces as `clawdforge::APIError` with `status_code() == 404`,
|
||||||
|
matching the server's no-existence-leak design.
|
||||||
|
|
||||||
|
Calling `Session::turn` after `close()` (or on a moved-from `Session`)
|
||||||
|
throws `clawdforge::ProtocolError("Session::turn called on closed session")`.
|
||||||
|
|
||||||
|
### v0.1 surface unchanged
|
||||||
|
|
||||||
|
The `/run` path stays byte-identical to v0.1. Single-turn callers don't
|
||||||
|
need to migrate.
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
`clawdforge::Client` (in `<clawdforge/client.hpp>`):
|
`clawdforge::Client` (in `<clawdforge/client.hpp>`):
|
||||||
|
|
@ -90,6 +191,9 @@ int main() {
|
||||||
| `create_token(req)` | `POST /admin/tokens` | `AppToken` |
|
| `create_token(req)` | `POST /admin/tokens` | `AppToken` |
|
||||||
| `list_tokens()` | `GET /admin/tokens` | `std::vector<AppTokenInfo>` |
|
| `list_tokens()` | `GET /admin/tokens` | `std::vector<AppTokenInfo>` |
|
||||||
| `revoke_token(name)` | `DELETE /admin/tokens/<name>` | `void` |
|
| `revoke_token(name)` | `DELETE /admin/tokens/<name>` | `void` |
|
||||||
|
| `create_session(opts={})` | `POST /sessions` | `Session` |
|
||||||
|
| `list_sessions()` | `GET /sessions` | `std::vector<SessionState>` |
|
||||||
|
| `get_session(id)` | `GET /sessions/<id>` | `SessionState` |
|
||||||
|
|
||||||
All methods throw on failure. `ClientOptions` lets you set `base_url`, `token`,
|
All methods throw on failure. `ClientOptions` lets you set `base_url`, `token`,
|
||||||
`admin_token`, `timeout`, `connect_timeout`, `user_agent`, and `insecure_tls`.
|
`admin_token`, `timeout`, `connect_timeout`, `user_agent`, and `insecure_tls`.
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,67 @@
|
||||||
|
|
||||||
namespace clawdforge {
|
namespace clawdforge {
|
||||||
|
|
||||||
|
class Client;
|
||||||
|
|
||||||
|
/// Handle for a multi-turn session on the server (v0.2). Construct via
|
||||||
|
/// `Client::create_session`. Mirrors `Client`'s move-only RAII shape —
|
||||||
|
/// destructor best-effort closes the session if it hasn't been closed
|
||||||
|
/// explicitly, swallowing any exceptions raised on the wire so destruction
|
||||||
|
/// stays `noexcept`.
|
||||||
|
///
|
||||||
|
/// Move-only: each `Session` carries an internal closed-flag, and copying
|
||||||
|
/// would race the close path. A moved-from `Session` is marked
|
||||||
|
/// already-closed so the destructor doesn't double-call.
|
||||||
|
///
|
||||||
|
/// Not thread-safe. Call `turn()` from one thread at a time per session;
|
||||||
|
/// different sessions on the same `Client` still share the `Client`'s
|
||||||
|
/// libcurl handle, so the same per-Client serialization rule applies.
|
||||||
|
class Session {
|
||||||
|
public:
|
||||||
|
~Session();
|
||||||
|
|
||||||
|
Session(Session&&) noexcept;
|
||||||
|
Session& operator=(Session&&) noexcept;
|
||||||
|
Session(const Session&) = delete;
|
||||||
|
Session& operator=(const Session&) = delete;
|
||||||
|
|
||||||
|
/// Server-assigned session id (PK on the clawdforge ledger).
|
||||||
|
[[nodiscard]] const std::string& id() const noexcept;
|
||||||
|
/// Agent name the session was created against (default "claude").
|
||||||
|
[[nodiscard]] const std::string& agent() const noexcept;
|
||||||
|
/// Unix timestamp the server recorded at create time.
|
||||||
|
[[nodiscard]] std::int64_t created_at() const noexcept;
|
||||||
|
/// `true` after `close()` has run successfully or after move-from.
|
||||||
|
[[nodiscard]] bool closed() const noexcept;
|
||||||
|
|
||||||
|
/// `POST /sessions/{id}/turn`. Returns the structured event batch.
|
||||||
|
///
|
||||||
|
/// Throws `Error("session is closed")` if called after `close()` or
|
||||||
|
/// on a moved-from instance. Other failures follow the SDK's standard
|
||||||
|
/// exception hierarchy (`AuthError` / `APIError` / `TransportError`
|
||||||
|
/// / `ProtocolError`).
|
||||||
|
[[nodiscard]] TurnResult turn(std::string_view prompt,
|
||||||
|
const TurnOptions& opts = {});
|
||||||
|
|
||||||
|
/// `GET /sessions/{id}`. Live state fetch.
|
||||||
|
[[nodiscard]] SessionState state();
|
||||||
|
|
||||||
|
/// `DELETE /sessions/{id}`. Idempotent — a second call short-circuits
|
||||||
|
/// without an HTTP round-trip via the internal closed flag. The server's
|
||||||
|
/// close endpoint is itself idempotent, but the local short-circuit
|
||||||
|
/// saves the request in the common destructor / explicit-close pair
|
||||||
|
/// pattern.
|
||||||
|
void close();
|
||||||
|
|
||||||
|
private:
|
||||||
|
friend class Client;
|
||||||
|
Session(Client* client, std::string id, std::string agent,
|
||||||
|
std::int64_t created_at);
|
||||||
|
|
||||||
|
struct Impl;
|
||||||
|
std::unique_ptr<Impl> impl_; ///< PImpl: keeps libcurl out of the header.
|
||||||
|
};
|
||||||
|
|
||||||
/// Knobs for `Client` construction. Use designated initializers to set only
|
/// Knobs for `Client` construction. Use designated initializers to set only
|
||||||
/// what you care about.
|
/// what you care about.
|
||||||
struct ClientOptions {
|
struct ClientOptions {
|
||||||
|
|
@ -104,6 +165,37 @@ public:
|
||||||
/// Throws `APIError` with `status_code() == 404` if the token is unknown.
|
/// Throws `APIError` with `status_code() == 404` if the token is unknown.
|
||||||
void revoke_token(std::string_view name);
|
void revoke_token(std::string_view name);
|
||||||
|
|
||||||
|
// -- v0.2: multi-turn session API --------------------------------------
|
||||||
|
|
||||||
|
/// `POST /sessions`. Returns a move-only `Session` handle whose
|
||||||
|
/// destructor best-effort closes the session on the server.
|
||||||
|
[[nodiscard]] Session create_session(const CreateSessionOptions& opts = {});
|
||||||
|
|
||||||
|
/// `GET /sessions`. Returns every session visible to the calling token
|
||||||
|
/// (per-app isolation enforced server-side). Includes closed-but-not-
|
||||||
|
/// hard-deleted sessions by default.
|
||||||
|
[[nodiscard]] std::vector<SessionState> list_sessions();
|
||||||
|
|
||||||
|
/// `GET /sessions/{id}`. Throws `APIError` with `status_code() == 404`
|
||||||
|
/// for unknown ids OR for ids that exist under a different token
|
||||||
|
/// (no-existence-leak design).
|
||||||
|
[[nodiscard]] SessionState get_session(std::string_view id);
|
||||||
|
|
||||||
|
// -- internal helpers used by Session ----------------------------------
|
||||||
|
//
|
||||||
|
// Public on the type (the surface lives in <clawdforge/client.hpp>) but
|
||||||
|
// not part of the documented user API — see `Session::turn` / `close`
|
||||||
|
// / `state` for the user-facing shape. Direct calls bypass the local
|
||||||
|
// closed-flag short-circuit and the move-from guard.
|
||||||
|
|
||||||
|
/// `POST /sessions/{id}/turn`. Used by `Session::turn`.
|
||||||
|
[[nodiscard]] TurnResult session_turn_internal(std::string_view id,
|
||||||
|
std::string_view prompt,
|
||||||
|
const TurnOptions& opts);
|
||||||
|
|
||||||
|
/// `DELETE /sessions/{id}`. Used by `Session::close` and the destructor.
|
||||||
|
void session_close_internal(std::string_view id);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
struct Impl;
|
struct Impl;
|
||||||
std::unique_ptr<Impl> impl_;
|
std::unique_ptr<Impl> impl_;
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,80 @@ struct AppTokenInfo {
|
||||||
nlohmann::json extra;
|
nlohmann::json extra;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// /sessions (v0.2)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// One structured event in a turn's response — typed text, thinking, or a
|
||||||
|
/// tool-call record. Mirrors the server's event shape from `acpx_runner`.
|
||||||
|
///
|
||||||
|
/// `args_json` and `result_json` carry raw JSON strings rather than parsed
|
||||||
|
/// objects to keep tool-call payloads opaque to the SDK — callers that care
|
||||||
|
/// can re-parse via `nlohmann::json::parse(*event.args_json)`.
|
||||||
|
struct TurnEvent {
|
||||||
|
std::string type;
|
||||||
|
std::optional<std::string> content;
|
||||||
|
std::optional<std::string> name;
|
||||||
|
std::optional<std::string> args_json; ///< raw JSON for tool args
|
||||||
|
std::optional<std::string> result_json; ///< raw JSON for tool result
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Successful response body from `POST /sessions/{id}/turn`.
|
||||||
|
///
|
||||||
|
/// `text()` concatenates the `content` of every `type == "text"` event in
|
||||||
|
/// order, dropping thinking/tool_call frames. Use it when you only want the
|
||||||
|
/// model's user-facing reply.
|
||||||
|
struct TurnResult {
|
||||||
|
bool ok{false};
|
||||||
|
std::string session_id;
|
||||||
|
int turn_index{0};
|
||||||
|
std::vector<TurnEvent> events;
|
||||||
|
std::string stop_reason;
|
||||||
|
std::int64_t duration_ms{0};
|
||||||
|
|
||||||
|
[[nodiscard]] std::string text() const {
|
||||||
|
std::string out;
|
||||||
|
for (const auto& e : events) {
|
||||||
|
if (e.type == "text" && e.content.has_value()) {
|
||||||
|
out += *e.content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Response body of `GET /sessions/{id}` and one row of `GET /sessions`.
|
||||||
|
///
|
||||||
|
/// `app_name` is filled in by the list endpoint but absent from the single-
|
||||||
|
/// session endpoint when the server is feeling minimalist; we just decode
|
||||||
|
/// it as an empty string when missing. `last_turn_at` and `closed_at` are
|
||||||
|
/// nullable on the wire — represented as `std::optional<int64_t>`.
|
||||||
|
struct SessionState {
|
||||||
|
std::string session_id;
|
||||||
|
std::string agent;
|
||||||
|
std::string app_name;
|
||||||
|
std::int64_t created_at{0};
|
||||||
|
std::optional<std::int64_t> last_turn_at;
|
||||||
|
int turn_count{0};
|
||||||
|
std::optional<std::int64_t> closed_at;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Knobs for `Client::create_session`. Mirrors the wire body of `POST /sessions`
|
||||||
|
/// minus the `meta` field — passing arbitrary JSON metadata through the v0.2
|
||||||
|
/// surface is deferred (caller-trust shape; revisit in v0.3).
|
||||||
|
struct CreateSessionOptions {
|
||||||
|
/// ACPX agent to drive. Server defaults to "claude" if absent.
|
||||||
|
std::string agent = "claude";
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Per-turn options for `Session::turn`.
|
||||||
|
struct TurnOptions {
|
||||||
|
/// Pre-staged file tokens (returned by `Client::upload_file`).
|
||||||
|
std::vector<std::string> files;
|
||||||
|
/// Per-turn timeout override. Server clamps to 5..=600.
|
||||||
|
std::optional<int> timeout_secs;
|
||||||
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// JSON glue
|
// JSON glue
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -189,6 +263,52 @@ inline void from_json(const nlohmann::json& j, AppToken& a) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inline void from_json(const nlohmann::json& j, TurnEvent& e) {
|
||||||
|
e.type = j.at("type").get<std::string>();
|
||||||
|
if (j.contains("content") && !j.at("content").is_null()) {
|
||||||
|
e.content = j.at("content").get<std::string>();
|
||||||
|
}
|
||||||
|
if (j.contains("name") && !j.at("name").is_null()) {
|
||||||
|
e.name = j.at("name").get<std::string>();
|
||||||
|
}
|
||||||
|
// Tool-call payloads (`args` / `result`) are persisted as raw JSON
|
||||||
|
// strings rather than re-parsed structures. Keeps the wire opaque to the
|
||||||
|
// SDK and avoids round-tripping arbitrary user-influenced JSON twice.
|
||||||
|
if (j.contains("args") && !j.at("args").is_null()) {
|
||||||
|
e.args_json = j.at("args").dump();
|
||||||
|
}
|
||||||
|
if (j.contains("result") && !j.at("result").is_null()) {
|
||||||
|
e.result_json = j.at("result").dump();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inline void from_json(const nlohmann::json& j, TurnResult& r) {
|
||||||
|
r.ok = j.value("ok", false);
|
||||||
|
r.session_id = j.at("session_id").get<std::string>();
|
||||||
|
r.turn_index = j.value("turn_index", 0);
|
||||||
|
r.duration_ms = j.value("duration_ms", std::int64_t{0});
|
||||||
|
if (j.contains("stop_reason") && !j.at("stop_reason").is_null()) {
|
||||||
|
r.stop_reason = j.at("stop_reason").get<std::string>();
|
||||||
|
}
|
||||||
|
if (j.contains("events") && j.at("events").is_array()) {
|
||||||
|
r.events = j.at("events").get<std::vector<TurnEvent>>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inline void from_json(const nlohmann::json& j, SessionState& s) {
|
||||||
|
s.session_id = j.at("session_id").get<std::string>();
|
||||||
|
s.agent = j.value("agent", std::string{});
|
||||||
|
s.app_name = j.value("app_name", std::string{});
|
||||||
|
s.created_at = j.value("created_at", std::int64_t{0});
|
||||||
|
if (j.contains("last_turn_at") && !j.at("last_turn_at").is_null()) {
|
||||||
|
s.last_turn_at = j.at("last_turn_at").get<std::int64_t>();
|
||||||
|
}
|
||||||
|
s.turn_count = j.value("turn_count", 0);
|
||||||
|
if (j.contains("closed_at") && !j.at("closed_at").is_null()) {
|
||||||
|
s.closed_at = j.at("closed_at").get<std::int64_t>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
inline void from_json(const nlohmann::json& j, AppTokenInfo& a) {
|
inline void from_json(const nlohmann::json& j, AppTokenInfo& a) {
|
||||||
a.name = j.at("name").get<std::string>();
|
a.name = j.at("name").get<std::string>();
|
||||||
if (j.contains("ip_cidrs") && j.at("ip_cidrs").is_array()) {
|
if (j.contains("ip_cidrs") && j.at("ip_cidrs").is_array()) {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@
|
||||||
|
|
||||||
#include <clawdforge/client.hpp>
|
#include <clawdforge/client.hpp>
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <exception>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <stdexcept>
|
#include <stdexcept>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
@ -262,4 +265,282 @@ void Client::revoke_token(std::string_view name) {
|
||||||
// Body is `{"ok": true}`; we don't surface anything from it.
|
// Body is `{"ok": true}`; we don't surface anything from it.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// v0.2: /sessions
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Session Client::create_session(const CreateSessionOptions& opts) {
|
||||||
|
if (impl_->opts.token.empty()) {
|
||||||
|
throw AuthError("Client::create_session requires `token` in ClientOptions");
|
||||||
|
}
|
||||||
|
detail::Request req;
|
||||||
|
req.method = "POST";
|
||||||
|
req.url = detail::join_url(impl_->opts.base_url, "/sessions");
|
||||||
|
req.headers = impl_->auth_headers(/*admin=*/false);
|
||||||
|
req.headers["Content-Type"] = "application/json";
|
||||||
|
|
||||||
|
nlohmann::json body = nlohmann::json::object();
|
||||||
|
if (!opts.agent.empty()) {
|
||||||
|
body["agent"] = opts.agent;
|
||||||
|
}
|
||||||
|
req.body = body.dump();
|
||||||
|
|
||||||
|
auto resp = impl_->session.perform(req);
|
||||||
|
if (resp.status < 200 || resp.status >= 300) {
|
||||||
|
throw_for_status(resp.status, std::move(resp.body));
|
||||||
|
}
|
||||||
|
auto parsed = parse_json_or_throw(resp.body, resp.status);
|
||||||
|
|
||||||
|
return with_protocol_guard([&] {
|
||||||
|
std::string id = parsed.at("session_id").get<std::string>();
|
||||||
|
if (id.empty()) {
|
||||||
|
throw ProtocolError("create_session: server returned empty session_id");
|
||||||
|
}
|
||||||
|
std::string agent = parsed.contains("agent") && !parsed.at("agent").is_null()
|
||||||
|
? parsed.at("agent").get<std::string>()
|
||||||
|
: opts.agent;
|
||||||
|
std::int64_t created_at = parsed.contains("created_at") &&
|
||||||
|
!parsed.at("created_at").is_null()
|
||||||
|
? parsed.at("created_at").get<std::int64_t>()
|
||||||
|
: std::int64_t{0};
|
||||||
|
return Session(this, std::move(id), std::move(agent), created_at);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<SessionState> Client::list_sessions() {
|
||||||
|
if (impl_->opts.token.empty()) {
|
||||||
|
throw AuthError("Client::list_sessions requires `token` in ClientOptions");
|
||||||
|
}
|
||||||
|
detail::Request req;
|
||||||
|
req.method = "GET";
|
||||||
|
req.url = detail::join_url(impl_->opts.base_url, "/sessions");
|
||||||
|
req.headers = impl_->auth_headers(/*admin=*/false);
|
||||||
|
|
||||||
|
auto resp = impl_->session.perform(req);
|
||||||
|
if (resp.status < 200 || resp.status >= 300) {
|
||||||
|
throw_for_status(resp.status, std::move(resp.body));
|
||||||
|
}
|
||||||
|
auto parsed = parse_json_or_throw(resp.body, resp.status);
|
||||||
|
if (!parsed.contains("sessions") || !parsed.at("sessions").is_array()) {
|
||||||
|
throw ProtocolError("list_sessions: missing or non-array `sessions`");
|
||||||
|
}
|
||||||
|
return with_protocol_guard([&] {
|
||||||
|
std::vector<SessionState> out;
|
||||||
|
out.reserve(parsed["sessions"].size());
|
||||||
|
for (const auto& el : parsed["sessions"]) {
|
||||||
|
out.push_back(el.get<SessionState>());
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
SessionState Client::get_session(std::string_view id) {
|
||||||
|
if (impl_->opts.token.empty()) {
|
||||||
|
throw AuthError("Client::get_session requires `token` in ClientOptions");
|
||||||
|
}
|
||||||
|
if (id.empty()) {
|
||||||
|
throw ProtocolError("get_session: id is empty");
|
||||||
|
}
|
||||||
|
detail::Request req;
|
||||||
|
req.method = "GET";
|
||||||
|
req.url = detail::join_url(impl_->opts.base_url,
|
||||||
|
"/sessions/" + detail::url_encode_path(id));
|
||||||
|
req.headers = impl_->auth_headers(/*admin=*/false);
|
||||||
|
|
||||||
|
auto resp = impl_->session.perform(req);
|
||||||
|
if (resp.status < 200 || resp.status >= 300) {
|
||||||
|
throw_for_status(resp.status, std::move(resp.body));
|
||||||
|
}
|
||||||
|
auto parsed = parse_json_or_throw(resp.body, resp.status);
|
||||||
|
return with_protocol_guard([&] { return parsed.get<SessionState>(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
TurnResult Client::session_turn_internal(std::string_view id,
|
||||||
|
std::string_view prompt,
|
||||||
|
const TurnOptions& opts) {
|
||||||
|
if (impl_->opts.token.empty()) {
|
||||||
|
throw AuthError("Session::turn requires `token` in ClientOptions");
|
||||||
|
}
|
||||||
|
if (id.empty()) {
|
||||||
|
throw ProtocolError("session_turn: id is empty");
|
||||||
|
}
|
||||||
|
if (prompt.empty()) {
|
||||||
|
throw ProtocolError("session_turn: prompt must not be empty");
|
||||||
|
}
|
||||||
|
detail::Request req;
|
||||||
|
req.method = "POST";
|
||||||
|
req.url = detail::join_url(impl_->opts.base_url,
|
||||||
|
"/sessions/" + detail::url_encode_path(id) + "/turn");
|
||||||
|
req.headers = impl_->auth_headers(/*admin=*/false);
|
||||||
|
req.headers["Content-Type"] = "application/json";
|
||||||
|
|
||||||
|
nlohmann::json body;
|
||||||
|
body["prompt"] = std::string{prompt};
|
||||||
|
if (!opts.files.empty()) {
|
||||||
|
body["files"] = opts.files;
|
||||||
|
}
|
||||||
|
if (opts.timeout_secs && *opts.timeout_secs > 0) {
|
||||||
|
body["timeout_secs"] = *opts.timeout_secs;
|
||||||
|
}
|
||||||
|
req.body = body.dump();
|
||||||
|
|
||||||
|
auto resp = impl_->session.perform(req);
|
||||||
|
if (resp.status < 200 || resp.status >= 300) {
|
||||||
|
throw_for_status(resp.status, std::move(resp.body));
|
||||||
|
}
|
||||||
|
auto parsed = parse_json_or_throw(resp.body, resp.status);
|
||||||
|
return with_protocol_guard([&] { return parsed.get<TurnResult>(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
void Client::session_close_internal(std::string_view id) {
|
||||||
|
if (impl_->opts.token.empty()) {
|
||||||
|
throw AuthError("Session::close requires `token` in ClientOptions");
|
||||||
|
}
|
||||||
|
if (id.empty()) {
|
||||||
|
throw ProtocolError("session_close: id is empty");
|
||||||
|
}
|
||||||
|
detail::Request req;
|
||||||
|
req.method = "DELETE";
|
||||||
|
req.url = detail::join_url(impl_->opts.base_url,
|
||||||
|
"/sessions/" + detail::url_encode_path(id));
|
||||||
|
req.headers = impl_->auth_headers(/*admin=*/false);
|
||||||
|
|
||||||
|
auto resp = impl_->session.perform(req);
|
||||||
|
if (resp.status < 200 || resp.status >= 300) {
|
||||||
|
throw_for_status(resp.status, std::move(resp.body));
|
||||||
|
}
|
||||||
|
// Body is `{"ok":true}` or `{"ok":true,"already_closed":true}` — both
|
||||||
|
// are success from our perspective. Nothing to surface.
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Session
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
struct Session::Impl {
|
||||||
|
Client* client{nullptr};
|
||||||
|
std::string id;
|
||||||
|
std::string agent;
|
||||||
|
std::int64_t created_at{0};
|
||||||
|
/// Atomic so concurrent close() / destructor races on the same Session
|
||||||
|
/// resolve to exactly one DELETE on the wire — even though the type is
|
||||||
|
/// not otherwise documented as thread-safe, defer-Close from multiple
|
||||||
|
/// scopes falls out of move semantics in surprising places, so the
|
||||||
|
/// flag itself stays race-safe.
|
||||||
|
std::atomic<bool> closed{false};
|
||||||
|
};
|
||||||
|
|
||||||
|
Session::Session(Client* client, std::string id, std::string agent,
|
||||||
|
std::int64_t created_at)
|
||||||
|
: impl_(std::make_unique<Impl>()) {
|
||||||
|
impl_->client = client;
|
||||||
|
impl_->id = std::move(id);
|
||||||
|
impl_->agent = std::move(agent);
|
||||||
|
impl_->created_at = created_at;
|
||||||
|
}
|
||||||
|
|
||||||
|
Session::Session(Session&& other) noexcept : impl_(std::move(other.impl_)) {
|
||||||
|
// Leave `other` in a valid, already-closed state so its destructor is a
|
||||||
|
// no-op. Without this the moved-from object would race the new one to
|
||||||
|
// call DELETE on the same session id.
|
||||||
|
if (impl_) {
|
||||||
|
// No-op: the moved-from `other` now has impl_ == nullptr, which
|
||||||
|
// the destructor / accessors handle directly.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Session& Session::operator=(Session&& other) noexcept {
|
||||||
|
if (this != &other) {
|
||||||
|
// Best-effort close on whatever we currently own before taking
|
||||||
|
// over `other`'s state. Mirrors the destructor's swallow-exceptions
|
||||||
|
// contract — `noexcept` is non-negotiable for move-assign.
|
||||||
|
if (impl_ && impl_->client && !impl_->closed.exchange(true)) {
|
||||||
|
try {
|
||||||
|
impl_->client->session_close_internal(impl_->id);
|
||||||
|
} catch (...) {
|
||||||
|
// swallow — same as the destructor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl_ = std::move(other.impl_);
|
||||||
|
}
|
||||||
|
return *this;
|
||||||
|
}
|
||||||
|
|
||||||
|
Session::~Session() {
|
||||||
|
if (!impl_) return;
|
||||||
|
if (!impl_->client) return;
|
||||||
|
if (impl_->closed.exchange(true)) return;
|
||||||
|
try {
|
||||||
|
impl_->client->session_close_internal(impl_->id);
|
||||||
|
} catch (...) {
|
||||||
|
// Destructors must not throw. Best-effort close — log to stderr so
|
||||||
|
// the operator at least has a breadcrumb when this trips. We do
|
||||||
|
// NOT log the session id at full length to avoid leaking it into
|
||||||
|
// unexpected sinks; first 8 chars is enough to grep the server log.
|
||||||
|
try {
|
||||||
|
const std::string id_prefix = impl_->id.size() > 8
|
||||||
|
? impl_->id.substr(0, 8) + "..."
|
||||||
|
: impl_->id;
|
||||||
|
std::fprintf(stderr,
|
||||||
|
"clawdforge: ~Session(%s) close failed (swallowed)\n",
|
||||||
|
id_prefix.c_str());
|
||||||
|
} catch (...) {
|
||||||
|
// Logging also failed — give up silently. Cannot escape ~.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string& Session::id() const noexcept {
|
||||||
|
static const std::string kEmpty;
|
||||||
|
return impl_ ? impl_->id : kEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string& Session::agent() const noexcept {
|
||||||
|
static const std::string kEmpty;
|
||||||
|
return impl_ ? impl_->agent : kEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::int64_t Session::created_at() const noexcept {
|
||||||
|
return impl_ ? impl_->created_at : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Session::closed() const noexcept {
|
||||||
|
if (!impl_) return true; // moved-from → effectively closed
|
||||||
|
return impl_->closed.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
TurnResult Session::turn(std::string_view prompt, const TurnOptions& opts) {
|
||||||
|
if (!impl_ || !impl_->client) {
|
||||||
|
// Surfaced as ProtocolError (the catch-all under
|
||||||
|
// `clawdforge::Error`) — moved-from / closed are caller-side
|
||||||
|
// invariant violations, not server-side problems.
|
||||||
|
throw ProtocolError("Session::turn called on moved-from session");
|
||||||
|
}
|
||||||
|
if (impl_->closed.load()) {
|
||||||
|
throw ProtocolError("Session::turn called on closed session");
|
||||||
|
}
|
||||||
|
return impl_->client->session_turn_internal(impl_->id, prompt, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
SessionState Session::state() {
|
||||||
|
if (!impl_ || !impl_->client) {
|
||||||
|
throw ProtocolError("Session::state called on moved-from session");
|
||||||
|
}
|
||||||
|
return impl_->client->get_session(impl_->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Session::close() {
|
||||||
|
if (!impl_ || !impl_->client) return;
|
||||||
|
if (impl_->closed.exchange(true)) return; // idempotent — already done
|
||||||
|
try {
|
||||||
|
impl_->client->session_close_internal(impl_->id);
|
||||||
|
} catch (...) {
|
||||||
|
// Roll the flag back so the destructor / a retry can try again.
|
||||||
|
// The server's close is idempotent, so even if the network call
|
||||||
|
// half-succeeded a retry is safe.
|
||||||
|
impl_->closed.store(false);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace clawdforge
|
} // namespace clawdforge
|
||||||
|
|
|
||||||
607
clients/cpp/tests/test_session.cpp
Normal file
607
clients/cpp/tests/test_session.cpp
Normal file
|
|
@ -0,0 +1,607 @@
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
//
|
||||||
|
// v0.2 multi-turn session API tests. Same harness shape as test_client.cpp:
|
||||||
|
// `cpp-httplib` mock server in-process, doctest assertions. Each TEST_CASE
|
||||||
|
// gets a fresh server bound to 127.0.0.1:0 so cases can't collide.
|
||||||
|
//
|
||||||
|
// Coverage:
|
||||||
|
// - protocol_error_on_malformed_session_response
|
||||||
|
// - session_create_and_destructor_closes
|
||||||
|
// - session_close_idempotent
|
||||||
|
// - session_close_after_destructor_no_double_call
|
||||||
|
// - session_turn_round_trip
|
||||||
|
// - session_turn_after_close_throws
|
||||||
|
// - turn_result_text_concatenates
|
||||||
|
// - list_sessions
|
||||||
|
// - get_session
|
||||||
|
// - cross_token_404
|
||||||
|
// - session_move_ctor_does_not_double_close
|
||||||
|
// - v0_1_run_unchanged (regression)
|
||||||
|
|
||||||
|
#include <doctest/doctest.h>
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <chrono>
|
||||||
|
#include <functional>
|
||||||
|
#include <memory>
|
||||||
|
#include <mutex>
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
#include <thread>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <httplib.h>
|
||||||
|
#include <nlohmann/json.hpp>
|
||||||
|
|
||||||
|
#include <clawdforge/client.hpp>
|
||||||
|
|
||||||
|
namespace cf = clawdforge;
|
||||||
|
using nlohmann::json;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
/// RAII httplib::Server bound to 127.0.0.1:0 in a background thread. Same
|
||||||
|
/// shape as the helper in test_client.cpp; duplicated here to keep the
|
||||||
|
/// session test file self-contained without exposing a shared header (the
|
||||||
|
/// MockServer stays test-only utility code).
|
||||||
|
class MockServer {
|
||||||
|
public:
|
||||||
|
explicit MockServer(std::function<void(httplib::Server&)> wire_routes) {
|
||||||
|
srv_.new_task_queue = [] { return new httplib::ThreadPool(2); };
|
||||||
|
wire_routes(srv_);
|
||||||
|
port_ = srv_.bind_to_any_port("127.0.0.1");
|
||||||
|
REQUIRE(port_ > 0);
|
||||||
|
thread_ = std::thread([this] { srv_.listen_after_bind(); });
|
||||||
|
for (int i = 0; i < 200; ++i) {
|
||||||
|
if (srv_.is_running()) break;
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||||
|
}
|
||||||
|
REQUIRE(srv_.is_running());
|
||||||
|
}
|
||||||
|
|
||||||
|
~MockServer() {
|
||||||
|
srv_.stop();
|
||||||
|
if (thread_.joinable()) thread_.join();
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] std::string base_url() const {
|
||||||
|
return "http://127.0.0.1:" + std::to_string(port_);
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
httplib::Server srv_;
|
||||||
|
int port_{0};
|
||||||
|
std::thread thread_;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Default create-session response body.
|
||||||
|
[[nodiscard]] std::string create_payload(const std::string& sid = "sess_abc",
|
||||||
|
const std::string& agent = "claude") {
|
||||||
|
json j = {
|
||||||
|
{"ok", true},
|
||||||
|
{"session_id", sid},
|
||||||
|
{"agent", agent},
|
||||||
|
{"created_at", 1'700'000'000},
|
||||||
|
{"cwd", "/tmp/acpx-sessions/" + sid},
|
||||||
|
};
|
||||||
|
return j.dump();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// create + destructor closes
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TEST_CASE("session_create_and_destructor_closes") {
|
||||||
|
std::atomic<int> create_count{0};
|
||||||
|
std::atomic<int> close_count{0};
|
||||||
|
std::atomic<bool> saw_auth{false};
|
||||||
|
std::atomic<bool> saw_agent{false};
|
||||||
|
|
||||||
|
MockServer mock{[&](httplib::Server& s) {
|
||||||
|
s.Post("/sessions", [&](const httplib::Request& req, httplib::Response& res) {
|
||||||
|
create_count.fetch_add(1);
|
||||||
|
saw_auth = (req.get_header_value("Authorization") == "Bearer cf_test");
|
||||||
|
const auto body = json::parse(req.body);
|
||||||
|
saw_agent = (body.value("agent", std::string{}) == "claude");
|
||||||
|
res.set_content(create_payload(), "application/json");
|
||||||
|
});
|
||||||
|
s.Delete(R"(/sessions/(.+))",
|
||||||
|
[&](const httplib::Request&, httplib::Response& res) {
|
||||||
|
close_count.fetch_add(1);
|
||||||
|
res.set_content(R"({"ok":true})", "application/json");
|
||||||
|
});
|
||||||
|
}};
|
||||||
|
|
||||||
|
cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_test"}};
|
||||||
|
{
|
||||||
|
auto sess = c.create_session(cf::CreateSessionOptions{.agent = "claude"});
|
||||||
|
CHECK(sess.id() == "sess_abc");
|
||||||
|
CHECK(sess.agent() == "claude");
|
||||||
|
CHECK(sess.created_at() == 1'700'000'000);
|
||||||
|
CHECK_FALSE(sess.closed());
|
||||||
|
} // dtor here triggers DELETE
|
||||||
|
|
||||||
|
CHECK(create_count.load() == 1);
|
||||||
|
CHECK(close_count.load() == 1);
|
||||||
|
CHECK(saw_auth.load());
|
||||||
|
CHECK(saw_agent.load());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// idempotent close
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TEST_CASE("session_close_idempotent") {
|
||||||
|
std::atomic<int> close_count{0};
|
||||||
|
|
||||||
|
MockServer mock{[&](httplib::Server& s) {
|
||||||
|
s.Post("/sessions", [](const httplib::Request&, httplib::Response& res) {
|
||||||
|
res.set_content(create_payload(), "application/json");
|
||||||
|
});
|
||||||
|
s.Delete(R"(/sessions/(.+))",
|
||||||
|
[&](const httplib::Request&, httplib::Response& res) {
|
||||||
|
close_count.fetch_add(1);
|
||||||
|
res.set_content(R"({"ok":true})", "application/json");
|
||||||
|
});
|
||||||
|
}};
|
||||||
|
|
||||||
|
cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_test"}};
|
||||||
|
auto sess = c.create_session();
|
||||||
|
CHECK_FALSE(sess.closed());
|
||||||
|
sess.close();
|
||||||
|
CHECK(sess.closed());
|
||||||
|
sess.close(); // no second DELETE
|
||||||
|
sess.close(); // ditto
|
||||||
|
CHECK(close_count.load() == 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// destructor doesn't double-close after explicit close
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TEST_CASE("session_close_after_destructor_no_double_call") {
|
||||||
|
std::atomic<int> close_count{0};
|
||||||
|
|
||||||
|
MockServer mock{[&](httplib::Server& s) {
|
||||||
|
s.Post("/sessions", [](const httplib::Request&, httplib::Response& res) {
|
||||||
|
res.set_content(create_payload(), "application/json");
|
||||||
|
});
|
||||||
|
s.Delete(R"(/sessions/(.+))",
|
||||||
|
[&](const httplib::Request&, httplib::Response& res) {
|
||||||
|
close_count.fetch_add(1);
|
||||||
|
res.set_content(R"({"ok":true})", "application/json");
|
||||||
|
});
|
||||||
|
}};
|
||||||
|
|
||||||
|
cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_test"}};
|
||||||
|
{
|
||||||
|
auto sess = c.create_session();
|
||||||
|
sess.close();
|
||||||
|
} // dtor must NOT issue a second DELETE
|
||||||
|
|
||||||
|
CHECK(close_count.load() == 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// turn round-trip
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TEST_CASE("session_turn_round_trip") {
|
||||||
|
std::atomic<bool> saw_files{false};
|
||||||
|
std::atomic<bool> saw_timeout{false};
|
||||||
|
std::string captured_prompt;
|
||||||
|
|
||||||
|
MockServer mock{[&](httplib::Server& s) {
|
||||||
|
s.Post("/sessions", [](const httplib::Request&, httplib::Response& res) {
|
||||||
|
res.set_content(create_payload(), "application/json");
|
||||||
|
});
|
||||||
|
s.Post(R"(/sessions/(.+)/turn)",
|
||||||
|
[&](const httplib::Request& req, httplib::Response& res) {
|
||||||
|
const auto body = json::parse(req.body);
|
||||||
|
captured_prompt = body.value("prompt", "");
|
||||||
|
saw_files = body.contains("files") &&
|
||||||
|
body.at("files").is_array() &&
|
||||||
|
body.at("files").size() == 1 &&
|
||||||
|
body.at("files")[0].get<std::string>() == "ff_xyz";
|
||||||
|
saw_timeout = (body.value("timeout_secs", 0) == 42);
|
||||||
|
json out = {
|
||||||
|
{"ok", true},
|
||||||
|
{"session_id", "sess_abc"},
|
||||||
|
{"turn_index", 2},
|
||||||
|
{"events", json::array({
|
||||||
|
json{{"type", "thinking"}, {"content", "..."}},
|
||||||
|
json{{"type", "tool_call"},
|
||||||
|
{"name", "Read"},
|
||||||
|
{"args", json{{"path", "README.md"}}}},
|
||||||
|
json{{"type", "text"}, {"content", "hello"}},
|
||||||
|
})},
|
||||||
|
{"stop_reason", "end_turn"},
|
||||||
|
{"duration_ms", 4321},
|
||||||
|
};
|
||||||
|
res.set_content(out.dump(), "application/json");
|
||||||
|
});
|
||||||
|
s.Delete(R"(/sessions/(.+))",
|
||||||
|
[](const httplib::Request&, httplib::Response& res) {
|
||||||
|
res.set_content(R"({"ok":true})", "application/json");
|
||||||
|
});
|
||||||
|
}};
|
||||||
|
|
||||||
|
cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_test"}};
|
||||||
|
auto sess = c.create_session();
|
||||||
|
auto r = sess.turn("hello", cf::TurnOptions{.files = {"ff_xyz"},
|
||||||
|
.timeout_secs = 42});
|
||||||
|
|
||||||
|
CHECK(r.ok);
|
||||||
|
CHECK(r.session_id == "sess_abc");
|
||||||
|
CHECK(r.turn_index == 2);
|
||||||
|
CHECK(r.stop_reason == "end_turn");
|
||||||
|
CHECK(r.duration_ms == 4321);
|
||||||
|
REQUIRE(r.events.size() == 3);
|
||||||
|
CHECK(r.events[0].type == "thinking");
|
||||||
|
REQUIRE(r.events[0].content.has_value());
|
||||||
|
CHECK(*r.events[0].content == "...");
|
||||||
|
CHECK(r.events[1].type == "tool_call");
|
||||||
|
REQUIRE(r.events[1].name.has_value());
|
||||||
|
CHECK(*r.events[1].name == "Read");
|
||||||
|
REQUIRE(r.events[1].args_json.has_value());
|
||||||
|
CHECK(json::parse(*r.events[1].args_json).at("path").get<std::string>() ==
|
||||||
|
"README.md");
|
||||||
|
CHECK(r.events[2].type == "text");
|
||||||
|
REQUIRE(r.events[2].content.has_value());
|
||||||
|
CHECK(*r.events[2].content == "hello");
|
||||||
|
|
||||||
|
CHECK(captured_prompt == "hello");
|
||||||
|
CHECK(saw_files.load());
|
||||||
|
CHECK(saw_timeout.load());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// turn after close throws
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TEST_CASE("session_turn_after_close_throws") {
|
||||||
|
MockServer mock{[](httplib::Server& s) {
|
||||||
|
s.Post("/sessions", [](const httplib::Request&, httplib::Response& res) {
|
||||||
|
res.set_content(create_payload(), "application/json");
|
||||||
|
});
|
||||||
|
s.Delete(R"(/sessions/(.+))",
|
||||||
|
[](const httplib::Request&, httplib::Response& res) {
|
||||||
|
res.set_content(R"({"ok":true})", "application/json");
|
||||||
|
});
|
||||||
|
}};
|
||||||
|
|
||||||
|
cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_test"}};
|
||||||
|
auto sess = c.create_session();
|
||||||
|
sess.close();
|
||||||
|
CHECK_THROWS_AS(sess.turn("nope"), cf::ProtocolError);
|
||||||
|
// Sanity: the catch-all base also catches.
|
||||||
|
CHECK_THROWS_AS(sess.turn("nope"), cf::Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TurnResult.text() concatenation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TEST_CASE("turn_result_text_concatenates") {
|
||||||
|
cf::TurnResult r;
|
||||||
|
r.events.push_back(cf::TurnEvent{"thinking", std::string{"should not appear"},
|
||||||
|
std::nullopt, std::nullopt, std::nullopt});
|
||||||
|
r.events.push_back(cf::TurnEvent{"text", std::string{"hello "}, std::nullopt,
|
||||||
|
std::nullopt, std::nullopt});
|
||||||
|
r.events.push_back(cf::TurnEvent{"tool_call", std::nullopt,
|
||||||
|
std::string{"Read"}, std::string{"{}"},
|
||||||
|
std::string{"{}"}});
|
||||||
|
r.events.push_back(cf::TurnEvent{"text", std::string{"world"}, std::nullopt,
|
||||||
|
std::nullopt, std::nullopt});
|
||||||
|
// Malformed event with no content — must not blow up.
|
||||||
|
r.events.push_back(cf::TurnEvent{"text", std::nullopt, std::nullopt,
|
||||||
|
std::nullopt, std::nullopt});
|
||||||
|
CHECK(r.text() == "hello world");
|
||||||
|
|
||||||
|
cf::TurnResult empty;
|
||||||
|
empty.events.push_back(cf::TurnEvent{"tool_call", std::nullopt,
|
||||||
|
std::string{"X"}, std::nullopt,
|
||||||
|
std::nullopt});
|
||||||
|
CHECK(empty.text().empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// list_sessions
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TEST_CASE("list_sessions") {
|
||||||
|
MockServer mock{[](httplib::Server& s) {
|
||||||
|
s.Get("/sessions", [](const httplib::Request& req, httplib::Response& res) {
|
||||||
|
CHECK(req.get_header_value("Authorization") == "Bearer cf_test");
|
||||||
|
json out = {
|
||||||
|
{"ok", true},
|
||||||
|
{"sessions", json::array({
|
||||||
|
json{{"session_id", "sess_a"},
|
||||||
|
{"agent", "claude"},
|
||||||
|
{"app_name", "cauldron"},
|
||||||
|
{"created_at", 100},
|
||||||
|
{"last_turn_at", 200},
|
||||||
|
{"turn_count", 3},
|
||||||
|
{"closed_at", nullptr}},
|
||||||
|
json{{"session_id", "sess_b"},
|
||||||
|
{"agent", "claude"},
|
||||||
|
{"app_name", "cauldron"},
|
||||||
|
{"created_at", 50},
|
||||||
|
{"last_turn_at", nullptr},
|
||||||
|
{"turn_count", 0},
|
||||||
|
{"closed_at", 75}},
|
||||||
|
})},
|
||||||
|
{"count", 2},
|
||||||
|
};
|
||||||
|
res.set_content(out.dump(), "application/json");
|
||||||
|
});
|
||||||
|
}};
|
||||||
|
|
||||||
|
cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_test"}};
|
||||||
|
const auto rows = c.list_sessions();
|
||||||
|
REQUIRE(rows.size() == 2);
|
||||||
|
CHECK(rows[0].session_id == "sess_a");
|
||||||
|
CHECK(rows[0].turn_count == 3);
|
||||||
|
REQUIRE(rows[0].last_turn_at.has_value());
|
||||||
|
CHECK(*rows[0].last_turn_at == 200);
|
||||||
|
CHECK_FALSE(rows[0].closed_at.has_value());
|
||||||
|
CHECK(rows[1].session_id == "sess_b");
|
||||||
|
CHECK_FALSE(rows[1].last_turn_at.has_value());
|
||||||
|
REQUIRE(rows[1].closed_at.has_value());
|
||||||
|
CHECK(*rows[1].closed_at == 75);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// get_session
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TEST_CASE("get_session") {
|
||||||
|
MockServer mock{[](httplib::Server& s) {
|
||||||
|
s.Get(R"(/sessions/(.+))",
|
||||||
|
[](const httplib::Request&, httplib::Response& res) {
|
||||||
|
json out = {
|
||||||
|
{"ok", true},
|
||||||
|
{"session_id", "sess_xyz"},
|
||||||
|
{"agent", "claude"},
|
||||||
|
{"app_name", "cauldron"},
|
||||||
|
{"created_at", 100},
|
||||||
|
{"last_turn_at", 200},
|
||||||
|
{"turn_count", 5},
|
||||||
|
{"closed_at", nullptr},
|
||||||
|
{"live", true},
|
||||||
|
};
|
||||||
|
res.set_content(out.dump(), "application/json");
|
||||||
|
});
|
||||||
|
}};
|
||||||
|
|
||||||
|
cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_test"}};
|
||||||
|
const auto st = c.get_session("sess_xyz");
|
||||||
|
CHECK(st.session_id == "sess_xyz");
|
||||||
|
CHECK(st.agent == "claude");
|
||||||
|
CHECK(st.turn_count == 5);
|
||||||
|
REQUIRE(st.last_turn_at.has_value());
|
||||||
|
CHECK(*st.last_turn_at == 200);
|
||||||
|
CHECK_FALSE(st.closed_at.has_value());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("get_session: empty id rejected locally") {
|
||||||
|
cf::Client c{cf::ClientOptions{.base_url = "http://127.0.0.1:1", .token = "cf_test"}};
|
||||||
|
CHECK_THROWS_AS(c.get_session(""), cf::ProtocolError);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// cross-token 404 → APIError(404)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TEST_CASE("cross_token_404") {
|
||||||
|
MockServer mock{[](httplib::Server& s) {
|
||||||
|
s.Get(R"(/sessions/(.+))",
|
||||||
|
[](const httplib::Request&, httplib::Response& res) {
|
||||||
|
res.status = 404;
|
||||||
|
res.set_content(R"({"detail":"session not found"})",
|
||||||
|
"application/json");
|
||||||
|
});
|
||||||
|
}};
|
||||||
|
|
||||||
|
cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_test"}};
|
||||||
|
try {
|
||||||
|
(void)c.get_session("sess_other_token");
|
||||||
|
FAIL("expected APIError");
|
||||||
|
} catch (const cf::APIError& e) {
|
||||||
|
CHECK(e.status_code() == 404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("session_turn_404_is_api_error") {
|
||||||
|
MockServer mock{[](httplib::Server& s) {
|
||||||
|
s.Post("/sessions", [](const httplib::Request&, httplib::Response& res) {
|
||||||
|
res.set_content(create_payload(), "application/json");
|
||||||
|
});
|
||||||
|
s.Post(R"(/sessions/(.+)/turn)",
|
||||||
|
[](const httplib::Request&, httplib::Response& res) {
|
||||||
|
res.status = 404;
|
||||||
|
res.set_content(R"({"detail":"session not found"})",
|
||||||
|
"application/json");
|
||||||
|
});
|
||||||
|
s.Delete(R"(/sessions/(.+))",
|
||||||
|
[](const httplib::Request&, httplib::Response& res) {
|
||||||
|
res.set_content(R"({"ok":true})", "application/json");
|
||||||
|
});
|
||||||
|
}};
|
||||||
|
|
||||||
|
cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_test"}};
|
||||||
|
auto sess = c.create_session();
|
||||||
|
try {
|
||||||
|
(void)sess.turn("hello");
|
||||||
|
FAIL("expected APIError");
|
||||||
|
} catch (const cf::APIError& e) {
|
||||||
|
CHECK(e.status_code() == 404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Move semantics — ctor + assign do not double-close
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TEST_CASE("session_move_ctor_does_not_double_close") {
|
||||||
|
std::atomic<int> close_count{0};
|
||||||
|
|
||||||
|
MockServer mock{[&](httplib::Server& s) {
|
||||||
|
s.Post("/sessions", [](const httplib::Request&, httplib::Response& res) {
|
||||||
|
res.set_content(create_payload(), "application/json");
|
||||||
|
});
|
||||||
|
s.Delete(R"(/sessions/(.+))",
|
||||||
|
[&](const httplib::Request&, httplib::Response& res) {
|
||||||
|
close_count.fetch_add(1);
|
||||||
|
res.set_content(R"({"ok":true})", "application/json");
|
||||||
|
});
|
||||||
|
}};
|
||||||
|
|
||||||
|
cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_test"}};
|
||||||
|
{
|
||||||
|
auto a = c.create_session();
|
||||||
|
auto b = std::move(a);
|
||||||
|
// `a` is now moved-from; its destructor must NOT issue another
|
||||||
|
// DELETE. `b` owns the session and its destructor will close.
|
||||||
|
CHECK(b.id() == "sess_abc");
|
||||||
|
CHECK_FALSE(b.closed());
|
||||||
|
CHECK(a.closed()); // moved-from is treated as already-closed
|
||||||
|
}
|
||||||
|
CHECK(close_count.load() == 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("session_move_assign_closes_lhs_then_takes_rhs") {
|
||||||
|
std::atomic<int> create_count{0};
|
||||||
|
std::atomic<int> close_count{0};
|
||||||
|
std::vector<std::string> closed_ids;
|
||||||
|
std::mutex mu;
|
||||||
|
|
||||||
|
MockServer mock{[&](httplib::Server& s) {
|
||||||
|
s.Post("/sessions", [&](const httplib::Request&, httplib::Response& res) {
|
||||||
|
const int n = create_count.fetch_add(1);
|
||||||
|
const std::string sid = (n == 0) ? "sess_lhs" : "sess_rhs";
|
||||||
|
res.set_content(create_payload(sid), "application/json");
|
||||||
|
});
|
||||||
|
s.Delete(R"(/sessions/(.+))",
|
||||||
|
[&](const httplib::Request& req, httplib::Response& res) {
|
||||||
|
close_count.fetch_add(1);
|
||||||
|
std::lock_guard<std::mutex> lk(mu);
|
||||||
|
// path = /sessions/sess_xxx — strip the prefix
|
||||||
|
constexpr std::string_view kPrefix = "/sessions/";
|
||||||
|
closed_ids.emplace_back(req.path.substr(kPrefix.size()));
|
||||||
|
res.set_content(R"({"ok":true})", "application/json");
|
||||||
|
});
|
||||||
|
}};
|
||||||
|
|
||||||
|
cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_test"}};
|
||||||
|
{
|
||||||
|
auto lhs = c.create_session();
|
||||||
|
CHECK(lhs.id() == "sess_lhs");
|
||||||
|
auto rhs = c.create_session();
|
||||||
|
CHECK(rhs.id() == "sess_rhs");
|
||||||
|
lhs = std::move(rhs);
|
||||||
|
// lhs's old session was closed by the move-assign; lhs now owns
|
||||||
|
// rhs's session.
|
||||||
|
CHECK(lhs.id() == "sess_rhs");
|
||||||
|
CHECK_FALSE(lhs.closed());
|
||||||
|
} // lhs dtor closes sess_rhs
|
||||||
|
|
||||||
|
CHECK(close_count.load() == 2);
|
||||||
|
std::lock_guard<std::mutex> lk(mu);
|
||||||
|
REQUIRE(closed_ids.size() == 2);
|
||||||
|
// Order: sess_lhs closed first (move-assign), then sess_rhs (lhs dtor).
|
||||||
|
CHECK(closed_ids[0] == "sess_lhs");
|
||||||
|
CHECK(closed_ids[1] == "sess_rhs");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ProtocolError on malformed session response
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TEST_CASE("protocol_error_on_malformed_session_response: create") {
|
||||||
|
MockServer mock{[](httplib::Server& s) {
|
||||||
|
s.Post("/sessions", [](const httplib::Request&, httplib::Response& res) {
|
||||||
|
// Missing required `session_id` — `parsed.at("session_id")`
|
||||||
|
// throws nlohmann::out_of_range, must surface as ProtocolError.
|
||||||
|
res.set_content(R"({"ok":true,"agent":"claude"})", "application/json");
|
||||||
|
});
|
||||||
|
}};
|
||||||
|
cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_test"}};
|
||||||
|
CHECK_THROWS_AS(c.create_session(), cf::ProtocolError);
|
||||||
|
// Sanity: the catch-all base also catches.
|
||||||
|
CHECK_THROWS_AS(c.create_session(), cf::Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("protocol_error_on_malformed_session_response: turn") {
|
||||||
|
MockServer mock{[](httplib::Server& s) {
|
||||||
|
s.Post("/sessions", [](const httplib::Request&, httplib::Response& res) {
|
||||||
|
res.set_content(create_payload(), "application/json");
|
||||||
|
});
|
||||||
|
s.Post(R"(/sessions/(.+)/turn)",
|
||||||
|
[](const httplib::Request&, httplib::Response& res) {
|
||||||
|
// Wrong type for `session_id` — number not string.
|
||||||
|
res.set_content(R"({"ok":true,"session_id":42,"events":[]})",
|
||||||
|
"application/json");
|
||||||
|
});
|
||||||
|
s.Delete(R"(/sessions/(.+))",
|
||||||
|
[](const httplib::Request&, httplib::Response& res) {
|
||||||
|
res.set_content(R"({"ok":true})", "application/json");
|
||||||
|
});
|
||||||
|
}};
|
||||||
|
cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_test"}};
|
||||||
|
auto sess = c.create_session();
|
||||||
|
CHECK_THROWS_AS(sess.turn("hi"), cf::ProtocolError);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("protocol_error_on_malformed_session_response: list") {
|
||||||
|
MockServer mock{[](httplib::Server& s) {
|
||||||
|
s.Get("/sessions", [](const httplib::Request&, httplib::Response& res) {
|
||||||
|
// Entry missing `session_id`.
|
||||||
|
res.set_content(R"({"ok":true,"sessions":[{"agent":"claude"}],"count":1})",
|
||||||
|
"application/json");
|
||||||
|
});
|
||||||
|
}};
|
||||||
|
cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_test"}};
|
||||||
|
CHECK_THROWS_AS(c.list_sessions(), cf::ProtocolError);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("protocol_error_on_malformed_session_response: get") {
|
||||||
|
MockServer mock{[](httplib::Server& s) {
|
||||||
|
s.Get(R"(/sessions/(.+))",
|
||||||
|
[](const httplib::Request&, httplib::Response& res) {
|
||||||
|
// Missing required `session_id`.
|
||||||
|
res.set_content(R"({"agent":"claude","created_at":1})",
|
||||||
|
"application/json");
|
||||||
|
});
|
||||||
|
}};
|
||||||
|
cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_test"}};
|
||||||
|
CHECK_THROWS_AS(c.get_session("sess_x"), cf::ProtocolError);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Regression: v0.1 /run is unchanged after layering v0.2 on top.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TEST_CASE("v0_1_run_unchanged") {
|
||||||
|
MockServer mock{[](httplib::Server& s) {
|
||||||
|
s.Post("/run", [](const httplib::Request& req, httplib::Response& res) {
|
||||||
|
CHECK(req.get_header_value("Authorization") == "Bearer cf_test");
|
||||||
|
const auto body = json::parse(req.body);
|
||||||
|
CHECK(body.at("prompt").get<std::string>() == "hi");
|
||||||
|
res.set_content(
|
||||||
|
R"({"ok":true,"result":{"hello":"world"},"duration_ms":42,"stop_reason":"end_turn"})",
|
||||||
|
"application/json");
|
||||||
|
});
|
||||||
|
}};
|
||||||
|
cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_test"}};
|
||||||
|
const auto r = c.run(cf::RunRequest{.prompt = "hi"});
|
||||||
|
CHECK(r.ok);
|
||||||
|
CHECK(r.duration_ms == 42);
|
||||||
|
REQUIRE(r.stop_reason.has_value());
|
||||||
|
CHECK(*r.stop_reason == "end_turn");
|
||||||
|
const auto* j = std::get_if<nlohmann::json>(&r.result);
|
||||||
|
REQUIRE(j != nullptr);
|
||||||
|
CHECK(j->at("hello").get<std::string>() == "world");
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue