clients/c: v0.2 multi-turn Session API
- cf_session_t (opaque), cf_session_options_t, cf_turn_options_t,
cf_turn_event_t, cf_turn_result_t, cf_session_state_t, cf_session_list_t
- cf_session_new / _turn / _close (idempotent) / _free, _state_get,
_list_get, plus cf_session_id / _agent accessors
- cf_turn_result_text concatenates type=="text" events into a single
string, lazily cached, OWNED by the result (caller must not free)
- Session-id allow-list ([A-Za-z0-9_-]+) validated client-side everywhere
it lands in the URL path — same pattern as cf_admin_revoke_token, no
reverse-proxy traversal foothold
- Bearer hygiene preserved: regression test covers cf_session_new /
state_get / list_get and asserts the bearer never lands in
cf_error_t.message
- v0.1 surface unchanged. Existing types (cf_client_t, cf_run_*, etc.)
are byte-identical. cJSON 1.7.18 preserved.
Tests (21 → 34, +13):
- session_turn_round_trip — full create + turn + close, event parsing
- session_close_idempotent — close × 3, DELETE on the wire ONCE
- session_turn_after_close_returns_error — CF_ERR_USAGE, no HTTP
- session_cross_token_returns_404 — CF_ERR_API + http_status==404
- session_validates_id_traversal — "a/../healthz" rejected pre-network
- session_list_get — 2-row mock parsed; null last_turn_at → -1
- session_state_get — full state shape parsed
- turn_result_text_concatenates — skips non-text + empty content
- session_free_idempotent — NULL-safe across all v0.2 freers
- turn_result_memory_clean — valgrind regression guard
- session_turn_with_files — files + timeout_secs serialised
- session_bearer_never_leaks — 3 fallible paths, bearer stays out
- session_null_arg_defenses — every entry point rejects NULLs
Verification:
- cmake --build build (Release, -Werror -Wall -Wextra -Wpedantic
-Wshadow): clean
- ctest --test-dir build: 34/34 pass
- valgrind --leak-check=full: 12,678 allocs == 12,678 frees, 0 errors,
0 leaks
- ASan build: 34/34 pass clean
- UBSan build (-fsanitize=undefined -fno-sanitize-recover=all): 34/34
pass clean
README adds "Multi-turn / Sessions (v0.2)" section: lifecycle example,
idempotent-close note, event/text ownership rules, state + listing,
memory ownership table, what's not in v0.2.
Spec: memory/spec-clawdforge-v0.2.md
Server core: 940861f
This commit is contained in:
parent
8479725513
commit
22e57e3dad
6 changed files with 1508 additions and 8 deletions
|
|
@ -24,7 +24,7 @@
|
||||||
|
|
||||||
cmake_minimum_required(VERSION 3.16)
|
cmake_minimum_required(VERSION 3.16)
|
||||||
project(clawdforge
|
project(clawdforge
|
||||||
VERSION 0.1.0
|
VERSION 0.2.0
|
||||||
DESCRIPTION "C SDK for the clawdforge HTTP service"
|
DESCRIPTION "C SDK for the clawdforge HTTP service"
|
||||||
LANGUAGES C)
|
LANGUAGES C)
|
||||||
|
|
||||||
|
|
@ -44,7 +44,7 @@ include(GNUInstallDirs)
|
||||||
find_package(CURL REQUIRED)
|
find_package(CURL REQUIRED)
|
||||||
|
|
||||||
# Common warning flags. Tests bump to -Werror.
|
# Common warning flags. Tests bump to -Werror.
|
||||||
set(CF_WARN_FLAGS -Wall -Wextra -Wpedantic)
|
set(CF_WARN_FLAGS -Wall -Wextra -Wpedantic -Wshadow)
|
||||||
|
|
||||||
if(CLAWDFORGE_ASAN)
|
if(CLAWDFORGE_ASAN)
|
||||||
list(APPEND CF_WARN_FLAGS -fsanitize=address -fno-omit-frame-pointer -g)
|
list(APPEND CF_WARN_FLAGS -fsanitize=address -fno-omit-frame-pointer -g)
|
||||||
|
|
@ -57,6 +57,7 @@ set(CF_SOURCES
|
||||||
src/client.c
|
src/client.c
|
||||||
src/http.c
|
src/http.c
|
||||||
src/json.c
|
src/json.c
|
||||||
|
src/session.c
|
||||||
src/cjson/cJSON.c)
|
src/cjson/cJSON.c)
|
||||||
|
|
||||||
# ----------------------------------------------------------------------------
|
# ----------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,14 @@
|
||||||
# clawdforge — C SDK
|
# clawdforge — C SDK
|
||||||
|
|
||||||
A small synchronous C client for the [clawdforge](../../README.md) HTTP service.
|
A small synchronous C client for the [clawdforge](../../README.md) HTTP service.
|
||||||
clawdforge wraps `claude -p` subprocess calls behind a bearer-token-gated REST
|
clawdforge wraps `claude -p` and ACPX (multi-turn) behind a bearer-token-gated
|
||||||
API; this SDK is what you reach for from C, or what you FFI into from any
|
REST API; this SDK is what you reach for from C, or what you FFI into from
|
||||||
language with a C ABI.
|
any language with a C ABI.
|
||||||
|
|
||||||
|
- **v0.1** surface (single-turn `cf_run`, file uploads, admin tokens) is
|
||||||
|
unchanged. Existing callers keep working byte-for-byte.
|
||||||
|
- **v0.2** adds multi-turn `cf_session_*` for context-preserving turns —
|
||||||
|
see [Multi-turn / Sessions (v0.2)](#multi-turn--sessions-v02) below.
|
||||||
|
|
||||||
- **C11 required.** The implementation uses C11 atomics (`<stdatomic.h>`)
|
- **C11 required.** The implementation uses C11 atomics (`<stdatomic.h>`)
|
||||||
for the shared libcurl-init refcount. The public header itself stays
|
for the shared libcurl-init refcount. The public header itself stays
|
||||||
|
|
@ -210,6 +215,129 @@ cf_status_t cf_admin_revoke_token(cf_client_t *c,
|
||||||
|
|
||||||
All require an admin token via `cf_client_set_admin_token` first.
|
All require an admin token via `cf_client_set_admin_token` first.
|
||||||
|
|
||||||
|
## Multi-turn / Sessions (v0.2)
|
||||||
|
|
||||||
|
v0.2 adds multi-turn `Session` support backed by the server's ACPX path.
|
||||||
|
The surface is **purely additive** — every v0.1 entry point keeps working
|
||||||
|
unchanged. Use `cf_run` for one-shot prompts; reach for sessions when you
|
||||||
|
need context across turns.
|
||||||
|
|
||||||
|
### Lifecycle
|
||||||
|
|
||||||
|
```c
|
||||||
|
#include <clawdforge.h>
|
||||||
|
|
||||||
|
cf_client_t *c = cf_client_new("http://192.168.0.5:8800", "cf_xxx");
|
||||||
|
cf_error_t err = {0};
|
||||||
|
|
||||||
|
/* 1. Create a session */
|
||||||
|
cf_session_t *s = NULL;
|
||||||
|
cf_session_options_t sopts = { .agent = "claude" };
|
||||||
|
if (cf_session_new(c, &sopts, &s, &err) != CF_OK) {
|
||||||
|
fprintf(stderr, "session_new: %s\n", cf_error_message(&err));
|
||||||
|
cf_error_free(&err);
|
||||||
|
cf_client_free(c);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
printf("session id: %s\n", cf_session_id(s));
|
||||||
|
|
||||||
|
/* 2. Send a turn */
|
||||||
|
cf_turn_options_t topts = { 0 };
|
||||||
|
cf_turn_result_t *r = NULL;
|
||||||
|
if (cf_session_turn(s, "Read README.md and summarize", &topts, &r, &err) != CF_OK) {
|
||||||
|
fprintf(stderr, "turn: %s\n", cf_error_message(&err));
|
||||||
|
cf_error_free(&err);
|
||||||
|
cf_session_free(s); /* implicit close */
|
||||||
|
cf_client_free(c);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
printf("text: %s\n", cf_turn_result_text(r)); /* owned by r — do not free */
|
||||||
|
printf("turn_index: %d, duration: %ld ms\n", r->turn_index, r->duration_ms);
|
||||||
|
cf_turn_result_free(r);
|
||||||
|
|
||||||
|
/* 3. Close (idempotent — safe to call multiple times) */
|
||||||
|
cf_session_close(s, &err);
|
||||||
|
cf_session_free(s);
|
||||||
|
|
||||||
|
cf_client_free(c);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Idempotent close
|
||||||
|
|
||||||
|
`cf_session_close` is **idempotent**: the first successful call issues
|
||||||
|
`DELETE /sessions/{id}`, and any subsequent call short-circuits and
|
||||||
|
returns `CF_OK` without hitting the wire. This means you can safely
|
||||||
|
call it from cleanup paths (e.g. wrapped in a goto-cleanup pattern)
|
||||||
|
without worrying about double-free. If you skip the explicit close,
|
||||||
|
`cf_session_free` makes a best-effort close internally before tearing
|
||||||
|
the handle down.
|
||||||
|
|
||||||
|
### Turn events and `cf_turn_result_text`
|
||||||
|
|
||||||
|
A `cf_turn_result_t` exposes the structured event log that came back
|
||||||
|
from the turn:
|
||||||
|
|
||||||
|
```c
|
||||||
|
typedef struct cf_turn_event {
|
||||||
|
char *type; /* "text", "tool_call", "thinking", ... */
|
||||||
|
char *content; /* nullable */
|
||||||
|
char *name; /* nullable */
|
||||||
|
char *args_json; /* nullable, raw JSON */
|
||||||
|
char *result_json; /* nullable, raw JSON */
|
||||||
|
} cf_turn_event_t;
|
||||||
|
```
|
||||||
|
|
||||||
|
If you only want the visible text reply, `cf_turn_result_text(r)`
|
||||||
|
concatenates every `type=="text"` event's content into a single string.
|
||||||
|
**The pointer it returns is owned by `r` — do not free it.** It's
|
||||||
|
computed lazily on first call and cached.
|
||||||
|
|
||||||
|
For tool-call events, `args_json` and `result_json` are raw JSON strings
|
||||||
|
that you can hand to `cJSON_Parse` if you need structured access.
|
||||||
|
|
||||||
|
### State + listing
|
||||||
|
|
||||||
|
```c
|
||||||
|
/* Look up state by id */
|
||||||
|
cf_session_state_t *st = NULL;
|
||||||
|
cf_session_state_get(c, "sess_abc", &st, &err);
|
||||||
|
printf("turn_count: %d, closed_at: %ld\n", st->turn_count, st->closed_at);
|
||||||
|
cf_session_state_free(st);
|
||||||
|
|
||||||
|
/* List the sessions visible to your token */
|
||||||
|
cf_session_list_t *list = NULL;
|
||||||
|
cf_session_list_get(c, &list, &err);
|
||||||
|
for (size_t i = 0; i < list->items_count; i++) {
|
||||||
|
printf("- %s (%d turns)\n",
|
||||||
|
list->items[i].session_id, list->items[i].turn_count);
|
||||||
|
}
|
||||||
|
cf_session_list_free(list);
|
||||||
|
```
|
||||||
|
|
||||||
|
`last_turn_at` and `closed_at` use **-1** to represent the JSON `null`
|
||||||
|
state (never had a turn / still open) — pick whichever sentinel feels
|
||||||
|
right for your call site.
|
||||||
|
|
||||||
|
### Memory ownership summary
|
||||||
|
|
||||||
|
| Producer | How to release |
|
||||||
|
| --- | --- |
|
||||||
|
| `cf_session_new` | `cf_session_free` |
|
||||||
|
| `cf_session_turn` | `cf_turn_result_free` |
|
||||||
|
| `cf_session_state_get` | `cf_session_state_free` |
|
||||||
|
| `cf_session_list_get` | `cf_session_list_free` |
|
||||||
|
| `cf_turn_result_text` | (do not free — owned by the result) |
|
||||||
|
| `cf_session_id` / `cf_session_agent` | (do not free — owned by the session) |
|
||||||
|
|
||||||
|
Every freer is `NULL`-safe.
|
||||||
|
|
||||||
|
### What's not in v0.2
|
||||||
|
|
||||||
|
- No streaming (turn returns the whole event batch when finished).
|
||||||
|
- No background close on session-handle drop — call
|
||||||
|
`cf_session_close` explicitly (or rely on the implicit close inside
|
||||||
|
`cf_session_free`).
|
||||||
|
|
||||||
## Build options
|
## Build options
|
||||||
|
|
||||||
| Option | Default | What it does |
|
| Option | Default | What it does |
|
||||||
|
|
|
||||||
|
|
@ -57,9 +57,9 @@ extern "C" {
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
#define CLAWDFORGE_VERSION_MAJOR 0
|
#define CLAWDFORGE_VERSION_MAJOR 0
|
||||||
#define CLAWDFORGE_VERSION_MINOR 1
|
#define CLAWDFORGE_VERSION_MINOR 2
|
||||||
#define CLAWDFORGE_VERSION_PATCH 0
|
#define CLAWDFORGE_VERSION_PATCH 0
|
||||||
#define CLAWDFORGE_VERSION_STRING "0.1.0"
|
#define CLAWDFORGE_VERSION_STRING "0.2.0"
|
||||||
|
|
||||||
/* Maximum HTTP response body size accepted by the SDK. A response
|
/* Maximum HTTP response body size accepted by the SDK. A response
|
||||||
* larger than this aborts mid-transfer with CF_ERR_TRANSPORT, which
|
* larger than this aborts mid-transfer with CF_ERR_TRANSPORT, which
|
||||||
|
|
@ -282,6 +282,139 @@ CF_API cf_status_t cf_admin_revoke_token(cf_client_t *c,
|
||||||
const char *name,
|
const char *name,
|
||||||
cf_error_t *err);
|
cf_error_t *err);
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* /sessions (v0.2 — multi-turn) */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* v0.2 adds multi-turn Sessions backed by the server's ACPX path.
|
||||||
|
* The surface is purely additive — v0.1 callers (cf_run / cf_upload_file
|
||||||
|
* / cf_admin_*) keep working unchanged.
|
||||||
|
*
|
||||||
|
* Lifecycle:
|
||||||
|
*
|
||||||
|
* cf_client_t *c = cf_client_new(...);
|
||||||
|
* cf_session_t *s = NULL;
|
||||||
|
* cf_session_options_t sopts = { .agent = "claude" };
|
||||||
|
* cf_session_new(c, &sopts, &s, &err);
|
||||||
|
*
|
||||||
|
* cf_turn_result_t *r = NULL;
|
||||||
|
* cf_turn_options_t topts = {0};
|
||||||
|
* cf_session_turn(s, "do the thing", &topts, &r, &err);
|
||||||
|
* printf("%s\n", cf_turn_result_text(r)); // pointer owned by r
|
||||||
|
* cf_turn_result_free(r);
|
||||||
|
*
|
||||||
|
* cf_session_close(s, &err); // idempotent
|
||||||
|
* cf_session_free(s); // also closes if needed
|
||||||
|
*
|
||||||
|
* cf_client_free(c);
|
||||||
|
*
|
||||||
|
* Threading: a cf_session_t inherits the cf_client_t's threading model.
|
||||||
|
* Don't share one across threads; one client + one session per thread,
|
||||||
|
* or guard with your own mutex.
|
||||||
|
*
|
||||||
|
* Errors: every fallible call takes a cf_error_t out-param. The bearer
|
||||||
|
* token is NEVER reflected in any error message or log output.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Opaque. */
|
||||||
|
typedef struct cf_session cf_session_t;
|
||||||
|
|
||||||
|
typedef struct cf_session_options {
|
||||||
|
const char *agent; /* default "claude"; NULL = server default */
|
||||||
|
/* Reserved for future expansion: meta_json. NULL for v0.2. */
|
||||||
|
const char *meta_json;
|
||||||
|
} cf_session_options_t;
|
||||||
|
|
||||||
|
typedef struct cf_turn_options {
|
||||||
|
const char *const *files; /* nullable; array of "ff_..." tokens */
|
||||||
|
size_t files_count;
|
||||||
|
int timeout_secs; /* 0 = use client default */
|
||||||
|
} cf_turn_options_t;
|
||||||
|
|
||||||
|
typedef struct cf_turn_event {
|
||||||
|
char *type; /* e.g. "text", "tool_call", "thinking" */
|
||||||
|
char *content; /* nullable */
|
||||||
|
char *name; /* nullable */
|
||||||
|
char *args_json; /* nullable, raw JSON */
|
||||||
|
char *result_json; /* nullable, raw JSON */
|
||||||
|
} cf_turn_event_t;
|
||||||
|
|
||||||
|
typedef struct cf_turn_result {
|
||||||
|
int ok; /* 0 / 1 */
|
||||||
|
char *session_id;
|
||||||
|
int turn_index;
|
||||||
|
cf_turn_event_t *events;
|
||||||
|
size_t events_count;
|
||||||
|
char *stop_reason; /* nullable */
|
||||||
|
long duration_ms;
|
||||||
|
/* Internal: lazy cache for cf_turn_result_text. Don't touch. */
|
||||||
|
char *_cached_text;
|
||||||
|
} cf_turn_result_t;
|
||||||
|
|
||||||
|
typedef struct cf_session_state {
|
||||||
|
char *session_id;
|
||||||
|
char *agent;
|
||||||
|
char *app_name;
|
||||||
|
long created_at;
|
||||||
|
long last_turn_at; /* -1 if null */
|
||||||
|
int turn_count;
|
||||||
|
long closed_at; /* -1 if null */
|
||||||
|
} cf_session_state_t;
|
||||||
|
|
||||||
|
typedef struct cf_session_list {
|
||||||
|
cf_session_state_t *items;
|
||||||
|
size_t items_count;
|
||||||
|
} cf_session_list_t;
|
||||||
|
|
||||||
|
/* Construct a new session. opts may be NULL for defaults. On CF_OK,
|
||||||
|
* *out is a fresh cf_session_t* — release with cf_session_free(). */
|
||||||
|
CF_API cf_status_t cf_session_new(cf_client_t *c,
|
||||||
|
const cf_session_options_t *opts,
|
||||||
|
cf_session_t **out,
|
||||||
|
cf_error_t *err);
|
||||||
|
|
||||||
|
/* Run a single turn. opts may be NULL for defaults. On CF_OK, *out is
|
||||||
|
* a fresh cf_turn_result_t* — release with cf_turn_result_free().
|
||||||
|
* Calling after cf_session_close returns CF_ERR_USAGE. */
|
||||||
|
CF_API cf_status_t cf_session_turn(cf_session_t *s,
|
||||||
|
const char *prompt,
|
||||||
|
const cf_turn_options_t *opts,
|
||||||
|
cf_turn_result_t **out,
|
||||||
|
cf_error_t *err);
|
||||||
|
|
||||||
|
/* Idempotent close. The first successful call issues DELETE /sessions/{id};
|
||||||
|
* subsequent calls short-circuit and return CF_OK with no HTTP traffic. */
|
||||||
|
CF_API cf_status_t cf_session_close(cf_session_t *s, cf_error_t *err);
|
||||||
|
|
||||||
|
/* Read-only accessors into the session handle. Strings live as long as
|
||||||
|
* the session; do not free. */
|
||||||
|
CF_API const char *cf_session_id(const cf_session_t *s);
|
||||||
|
CF_API const char *cf_session_agent(const cf_session_t *s);
|
||||||
|
|
||||||
|
/* Releases. Implicitly closes if the session is still open and the
|
||||||
|
* caller never called cf_session_close. Safe to pass NULL. */
|
||||||
|
CF_API void cf_session_free(cf_session_t *s);
|
||||||
|
|
||||||
|
/* Server lookups (per-token). */
|
||||||
|
CF_API cf_status_t cf_session_state_get(cf_client_t *c,
|
||||||
|
const char *session_id,
|
||||||
|
cf_session_state_t **out,
|
||||||
|
cf_error_t *err);
|
||||||
|
|
||||||
|
CF_API cf_status_t cf_session_list_get(cf_client_t *c,
|
||||||
|
cf_session_list_t **out,
|
||||||
|
cf_error_t *err);
|
||||||
|
|
||||||
|
/* Concatenate every event with type=="text" into a single string.
|
||||||
|
* Returns a pointer OWNED by the result — caller must NOT free.
|
||||||
|
* Computed lazily on first call. Returns "" if no text events. */
|
||||||
|
CF_API const char *cf_turn_result_text(cf_turn_result_t *r);
|
||||||
|
|
||||||
|
CF_API void cf_turn_result_free(cf_turn_result_t *r);
|
||||||
|
CF_API void cf_session_state_free(cf_session_state_t *s);
|
||||||
|
CF_API void cf_session_list_free(cf_session_list_t *l);
|
||||||
|
|
||||||
#ifdef __cplusplus
|
#ifdef __cplusplus
|
||||||
} /* extern "C" */
|
} /* extern "C" */
|
||||||
#endif
|
#endif
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,24 @@ struct cf_client {
|
||||||
long timeout_secs;
|
long timeout_secs;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* The opaque public session. v0.2.
|
||||||
|
*
|
||||||
|
* Field ownership:
|
||||||
|
* client — borrowed pointer, NOT owned. The caller is responsible
|
||||||
|
* for outliving the session with the client.
|
||||||
|
* id, agent — heap-owned, freed by cf_session_free.
|
||||||
|
* closed — 0 / 1; flipped to 1 after a successful DELETE so
|
||||||
|
* cf_session_close is idempotent and cf_session_turn
|
||||||
|
* short-circuits to CF_ERR_USAGE.
|
||||||
|
*/
|
||||||
|
struct cf_session {
|
||||||
|
cf_client_t *client;
|
||||||
|
char *id;
|
||||||
|
char *agent;
|
||||||
|
long created_at;
|
||||||
|
int closed;
|
||||||
|
};
|
||||||
|
|
||||||
/* Growable byte buffer used for response bodies. */
|
/* Growable byte buffer used for response bodies. */
|
||||||
typedef struct cf_buf {
|
typedef struct cf_buf {
|
||||||
char *data;
|
char *data;
|
||||||
|
|
|
||||||
667
clients/c/src/session.c
Normal file
667
clients/c/src/session.c
Normal file
|
|
@ -0,0 +1,667 @@
|
||||||
|
/* v0.2: multi-turn /sessions endpoints.
|
||||||
|
*
|
||||||
|
* The session API mirrors the v0.1 hygiene contract:
|
||||||
|
*
|
||||||
|
* - every malloc on a success path is paired with the matching free()
|
||||||
|
* in cf_session_free / cf_turn_result_free / cf_session_state_free /
|
||||||
|
* cf_session_list_free.
|
||||||
|
* - every malloc on an error path is unwound before returning.
|
||||||
|
* - the bearer token is NEVER reflected into a cf_error_t message,
|
||||||
|
* not even by accident: error strings are built from server-provided
|
||||||
|
* `detail` / `error` fields and HTTP status codes only.
|
||||||
|
*
|
||||||
|
* Session ID validation is mirrored from cf_admin_revoke_token: the
|
||||||
|
* server-side allow-list is [A-Za-z0-9_-]+, so we reject anything else
|
||||||
|
* client-side before interpolating into the URL. This stops path
|
||||||
|
* traversal ("a/../healthz" collapsing through a reverse proxy) and
|
||||||
|
* keeps the failure earlier, with a clearer error message, than the
|
||||||
|
* server's 404 would.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "internal.h"
|
||||||
|
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* shared helpers (small enough to duplicate vs. extern out of client.c) */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
static char *cf_strdup_or_null(const char *s) {
|
||||||
|
if (!s) return NULL;
|
||||||
|
size_t n = strlen(s) + 1;
|
||||||
|
char *p = (char *)malloc(n);
|
||||||
|
if (!p) return NULL;
|
||||||
|
memcpy(p, s, n);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Translate a non-2xx response to a populated cf_error_t. */
|
||||||
|
static cf_status_t check_http_status(const cf_http_response_t *r,
|
||||||
|
cf_error_t *err) {
|
||||||
|
if (r->http_status >= 200 && r->http_status < 300) return CF_OK;
|
||||||
|
cf_status_t code = cf_status_from_http(r->http_status);
|
||||||
|
char *detail = cf_json_extract_error_message(r->body.data, r->body.len);
|
||||||
|
cf_err_set(err, code, r->http_status,
|
||||||
|
"server returned %ld: %s",
|
||||||
|
r->http_status,
|
||||||
|
detail ? detail : "(no body)");
|
||||||
|
free(detail);
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mirror of admin_token_name_is_safe — same allow-list as the server's
|
||||||
|
* for session IDs. Returns 1 iff [A-Za-z0-9_-]+ and non-empty. */
|
||||||
|
static int session_id_is_safe(const char *id) {
|
||||||
|
if (!id || !*id) return 0;
|
||||||
|
for (const unsigned char *p = (const unsigned char *)id; *p; ++p) {
|
||||||
|
unsigned char ch = *p;
|
||||||
|
if ((ch >= 'a' && ch <= 'z') ||
|
||||||
|
(ch >= 'A' && ch <= 'Z') ||
|
||||||
|
(ch >= '0' && ch <= '9') ||
|
||||||
|
ch == '_' || ch == '-') continue;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* JSON parse helpers */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
static cf_status_t parse_session_state_object(cJSON *node,
|
||||||
|
cf_session_state_t *out,
|
||||||
|
cf_error_t *err) {
|
||||||
|
cJSON *jsid = cJSON_GetObjectItemCaseSensitive(node, "session_id");
|
||||||
|
cJSON *jagent = cJSON_GetObjectItemCaseSensitive(node, "agent");
|
||||||
|
cJSON *japp = cJSON_GetObjectItemCaseSensitive(node, "app_name");
|
||||||
|
cJSON *jcreate = cJSON_GetObjectItemCaseSensitive(node, "created_at");
|
||||||
|
cJSON *jlast = cJSON_GetObjectItemCaseSensitive(node, "last_turn_at");
|
||||||
|
cJSON *jturn = cJSON_GetObjectItemCaseSensitive(node, "turn_count");
|
||||||
|
cJSON *jclosed = cJSON_GetObjectItemCaseSensitive(node, "closed_at");
|
||||||
|
|
||||||
|
out->last_turn_at = -1;
|
||||||
|
out->closed_at = -1;
|
||||||
|
|
||||||
|
if (cJSON_IsString(jsid) && jsid->valuestring) {
|
||||||
|
out->session_id = cf_strdup_or_null(jsid->valuestring);
|
||||||
|
if (!out->session_id) goto oom;
|
||||||
|
}
|
||||||
|
if (cJSON_IsString(jagent) && jagent->valuestring) {
|
||||||
|
out->agent = cf_strdup_or_null(jagent->valuestring);
|
||||||
|
if (!out->agent) goto oom;
|
||||||
|
}
|
||||||
|
if (cJSON_IsString(japp) && japp->valuestring) {
|
||||||
|
out->app_name = cf_strdup_or_null(japp->valuestring);
|
||||||
|
if (!out->app_name) goto oom;
|
||||||
|
}
|
||||||
|
if (cJSON_IsNumber(jcreate)) out->created_at = (long)jcreate->valuedouble;
|
||||||
|
if (cJSON_IsNumber(jlast)) out->last_turn_at = (long)jlast->valuedouble;
|
||||||
|
if (cJSON_IsNumber(jturn)) out->turn_count = (int)jturn->valuedouble;
|
||||||
|
if (cJSON_IsNumber(jclosed)) out->closed_at = (long)jclosed->valuedouble;
|
||||||
|
|
||||||
|
return CF_OK;
|
||||||
|
|
||||||
|
oom:
|
||||||
|
free(out->session_id); out->session_id = NULL;
|
||||||
|
free(out->agent); out->agent = NULL;
|
||||||
|
free(out->app_name); out->app_name = NULL;
|
||||||
|
return cf_err_set(err, CF_ERR_OOM, 0, "out of memory");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Take a cJSON event object and populate a cf_turn_event_t. On OOM
|
||||||
|
* frees any partially-populated fields and returns -1. */
|
||||||
|
static int parse_turn_event(cJSON *e, cf_turn_event_t *out) {
|
||||||
|
if (!cJSON_IsObject(e)) {
|
||||||
|
/* Skip — leave the slot zeroed. The events_count was sized
|
||||||
|
* conservatively from the array length. */
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
cJSON *jtype = cJSON_GetObjectItemCaseSensitive(e, "type");
|
||||||
|
cJSON *jcontent = cJSON_GetObjectItemCaseSensitive(e, "content");
|
||||||
|
cJSON *jname = cJSON_GetObjectItemCaseSensitive(e, "name");
|
||||||
|
cJSON *jargs = cJSON_GetObjectItemCaseSensitive(e, "args");
|
||||||
|
cJSON *jresult = cJSON_GetObjectItemCaseSensitive(e, "result");
|
||||||
|
|
||||||
|
if (cJSON_IsString(jtype) && jtype->valuestring) {
|
||||||
|
out->type = cf_strdup_or_null(jtype->valuestring);
|
||||||
|
if (!out->type) goto oom;
|
||||||
|
}
|
||||||
|
if (cJSON_IsString(jcontent) && jcontent->valuestring) {
|
||||||
|
out->content = cf_strdup_or_null(jcontent->valuestring);
|
||||||
|
if (!out->content) goto oom;
|
||||||
|
}
|
||||||
|
if (cJSON_IsString(jname) && jname->valuestring) {
|
||||||
|
out->name = cf_strdup_or_null(jname->valuestring);
|
||||||
|
if (!out->name) goto oom;
|
||||||
|
}
|
||||||
|
if (jargs && !cJSON_IsNull(jargs)) {
|
||||||
|
char *txt = cJSON_PrintUnformatted(jargs);
|
||||||
|
if (!txt) goto oom;
|
||||||
|
out->args_json = cf_strdup_or_null(txt);
|
||||||
|
cJSON_free(txt);
|
||||||
|
if (!out->args_json) goto oom;
|
||||||
|
}
|
||||||
|
if (jresult && !cJSON_IsNull(jresult)) {
|
||||||
|
char *txt = cJSON_PrintUnformatted(jresult);
|
||||||
|
if (!txt) goto oom;
|
||||||
|
out->result_json = cf_strdup_or_null(txt);
|
||||||
|
cJSON_free(txt);
|
||||||
|
if (!out->result_json) goto oom;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
oom:
|
||||||
|
free(out->type); out->type = NULL;
|
||||||
|
free(out->content); out->content = NULL;
|
||||||
|
free(out->name); out->name = NULL;
|
||||||
|
free(out->args_json); out->args_json = NULL;
|
||||||
|
free(out->result_json); out->result_json = NULL;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* cf_session_new */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
cf_status_t cf_session_new(cf_client_t *c,
|
||||||
|
const cf_session_options_t *opts,
|
||||||
|
cf_session_t **out,
|
||||||
|
cf_error_t *err) {
|
||||||
|
if (!c || !out) return cf_err_set(err, CF_ERR_USAGE, 0, "null arg");
|
||||||
|
*out = NULL;
|
||||||
|
|
||||||
|
/* Build request body { "agent": ..., "meta": ... }. */
|
||||||
|
cJSON *body = cJSON_CreateObject();
|
||||||
|
if (!body) return cf_err_set(err, CF_ERR_OOM, 0, "out of memory");
|
||||||
|
|
||||||
|
const char *agent = (opts && opts->agent && *opts->agent) ? opts->agent : "claude";
|
||||||
|
if (!cJSON_AddStringToObject(body, "agent", agent)) {
|
||||||
|
cJSON_Delete(body);
|
||||||
|
return cf_err_set(err, CF_ERR_OOM, 0, "out of memory");
|
||||||
|
}
|
||||||
|
if (opts && opts->meta_json && *opts->meta_json) {
|
||||||
|
cJSON *meta = cJSON_Parse(opts->meta_json);
|
||||||
|
if (!meta) {
|
||||||
|
cJSON_Delete(body);
|
||||||
|
return cf_err_set(err, CF_ERR_USAGE, 0, "meta_json is not valid JSON");
|
||||||
|
}
|
||||||
|
cJSON_AddItemToObject(body, "meta", meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
char *body_str = cJSON_PrintUnformatted(body);
|
||||||
|
cJSON_Delete(body);
|
||||||
|
if (!body_str) return cf_err_set(err, CF_ERR_OOM, 0, "out of memory serialising");
|
||||||
|
|
||||||
|
cf_http_response_t resp;
|
||||||
|
cf_http_response_init(&resp);
|
||||||
|
cf_status_t s = cf_http_post_json(c, "/sessions", NULL, body_str, &resp, err);
|
||||||
|
cJSON_free(body_str);
|
||||||
|
if (s != CF_OK) {
|
||||||
|
cf_http_response_free(&resp);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
s = check_http_status(&resp, err);
|
||||||
|
if (s != CF_OK) {
|
||||||
|
cf_http_response_free(&resp);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
cJSON *node = NULL;
|
||||||
|
s = cf_json_parse(resp.body.data, resp.body.len, &node, err);
|
||||||
|
cf_http_response_free(&resp);
|
||||||
|
if (s != CF_OK) return s;
|
||||||
|
|
||||||
|
cJSON *jsid = cJSON_GetObjectItemCaseSensitive(node, "session_id");
|
||||||
|
cJSON *jagent = cJSON_GetObjectItemCaseSensitive(node, "agent");
|
||||||
|
cJSON *jcreate = cJSON_GetObjectItemCaseSensitive(node, "created_at");
|
||||||
|
|
||||||
|
if (!cJSON_IsString(jsid) || !jsid->valuestring) {
|
||||||
|
cJSON_Delete(node);
|
||||||
|
return cf_err_set(err, CF_ERR_PARSE, 0, "missing session_id in response");
|
||||||
|
}
|
||||||
|
|
||||||
|
cf_session_t *sess = (cf_session_t *)calloc(1, sizeof *sess);
|
||||||
|
if (!sess) {
|
||||||
|
cJSON_Delete(node);
|
||||||
|
return cf_err_set(err, CF_ERR_OOM, 0, "out of memory");
|
||||||
|
}
|
||||||
|
sess->client = c;
|
||||||
|
sess->id = cf_strdup_or_null(jsid->valuestring);
|
||||||
|
if (!sess->id) {
|
||||||
|
free(sess);
|
||||||
|
cJSON_Delete(node);
|
||||||
|
return cf_err_set(err, CF_ERR_OOM, 0, "out of memory");
|
||||||
|
}
|
||||||
|
if (cJSON_IsString(jagent) && jagent->valuestring) {
|
||||||
|
sess->agent = cf_strdup_or_null(jagent->valuestring);
|
||||||
|
if (!sess->agent) {
|
||||||
|
free(sess->id);
|
||||||
|
free(sess);
|
||||||
|
cJSON_Delete(node);
|
||||||
|
return cf_err_set(err, CF_ERR_OOM, 0, "out of memory");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sess->agent = cf_strdup_or_null(agent);
|
||||||
|
if (!sess->agent) {
|
||||||
|
free(sess->id);
|
||||||
|
free(sess);
|
||||||
|
cJSON_Delete(node);
|
||||||
|
return cf_err_set(err, CF_ERR_OOM, 0, "out of memory");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sess->created_at = cJSON_IsNumber(jcreate) ? (long)jcreate->valuedouble : 0;
|
||||||
|
sess->closed = 0;
|
||||||
|
|
||||||
|
cJSON_Delete(node);
|
||||||
|
*out = sess;
|
||||||
|
return CF_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* cf_session_turn */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
cf_status_t cf_session_turn(cf_session_t *s,
|
||||||
|
const char *prompt,
|
||||||
|
const cf_turn_options_t *opts,
|
||||||
|
cf_turn_result_t **out,
|
||||||
|
cf_error_t *err) {
|
||||||
|
if (!s || !s->client || !prompt || !out) {
|
||||||
|
return cf_err_set(err, CF_ERR_USAGE, 0, "null arg");
|
||||||
|
}
|
||||||
|
if (s->closed) {
|
||||||
|
return cf_err_set(err, CF_ERR_USAGE, 0, "session is closed");
|
||||||
|
}
|
||||||
|
if (!*prompt) {
|
||||||
|
return cf_err_set(err, CF_ERR_USAGE, 0, "prompt is required");
|
||||||
|
}
|
||||||
|
if (!session_id_is_safe(s->id)) {
|
||||||
|
return cf_err_set(err, CF_ERR_USAGE, 0,
|
||||||
|
"invalid session id; must match [A-Za-z0-9_-]+");
|
||||||
|
}
|
||||||
|
*out = NULL;
|
||||||
|
|
||||||
|
/* Build path. session id alphabet validated above. */
|
||||||
|
char path[320];
|
||||||
|
int n = snprintf(path, sizeof path, "/sessions/%s/turn", s->id);
|
||||||
|
if (n <= 0 || (size_t)n >= sizeof path) {
|
||||||
|
return cf_err_set(err, CF_ERR_USAGE, 0, "session id too long");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Build body. */
|
||||||
|
cJSON *body = cJSON_CreateObject();
|
||||||
|
if (!body) return cf_err_set(err, CF_ERR_OOM, 0, "out of memory");
|
||||||
|
if (!cJSON_AddStringToObject(body, "prompt", prompt)) {
|
||||||
|
cJSON_Delete(body);
|
||||||
|
return cf_err_set(err, CF_ERR_OOM, 0, "out of memory");
|
||||||
|
}
|
||||||
|
if (opts && opts->files && opts->files_count > 0) {
|
||||||
|
cJSON *arr = cJSON_AddArrayToObject(body, "files");
|
||||||
|
if (!arr) {
|
||||||
|
cJSON_Delete(body);
|
||||||
|
return cf_err_set(err, CF_ERR_OOM, 0, "out of memory");
|
||||||
|
}
|
||||||
|
for (size_t i = 0; i < opts->files_count; ++i) {
|
||||||
|
const char *f = opts->files[i];
|
||||||
|
if (!f) continue;
|
||||||
|
cJSON *str = cJSON_CreateString(f);
|
||||||
|
if (!str) {
|
||||||
|
cJSON_Delete(body);
|
||||||
|
return cf_err_set(err, CF_ERR_OOM, 0, "out of memory");
|
||||||
|
}
|
||||||
|
cJSON_AddItemToArray(arr, str);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (opts && opts->timeout_secs > 0) {
|
||||||
|
if (!cJSON_AddNumberToObject(body, "timeout_secs", opts->timeout_secs)) {
|
||||||
|
cJSON_Delete(body);
|
||||||
|
return cf_err_set(err, CF_ERR_OOM, 0, "out of memory");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
char *body_str = cJSON_PrintUnformatted(body);
|
||||||
|
cJSON_Delete(body);
|
||||||
|
if (!body_str) return cf_err_set(err, CF_ERR_OOM, 0, "out of memory");
|
||||||
|
|
||||||
|
cf_http_response_t resp;
|
||||||
|
cf_http_response_init(&resp);
|
||||||
|
cf_status_t st = cf_http_post_json(s->client, path, NULL, body_str, &resp, err);
|
||||||
|
cJSON_free(body_str);
|
||||||
|
if (st != CF_OK) {
|
||||||
|
cf_http_response_free(&resp);
|
||||||
|
return st;
|
||||||
|
}
|
||||||
|
st = check_http_status(&resp, err);
|
||||||
|
if (st != CF_OK) {
|
||||||
|
cf_http_response_free(&resp);
|
||||||
|
return st;
|
||||||
|
}
|
||||||
|
|
||||||
|
cJSON *node = NULL;
|
||||||
|
st = cf_json_parse(resp.body.data, resp.body.len, &node, err);
|
||||||
|
cf_http_response_free(&resp);
|
||||||
|
if (st != CF_OK) return st;
|
||||||
|
|
||||||
|
cJSON *jok = cJSON_GetObjectItemCaseSensitive(node, "ok");
|
||||||
|
cJSON *jsid = cJSON_GetObjectItemCaseSensitive(node, "session_id");
|
||||||
|
cJSON *jturn = cJSON_GetObjectItemCaseSensitive(node, "turn_index");
|
||||||
|
cJSON *jevs = cJSON_GetObjectItemCaseSensitive(node, "events");
|
||||||
|
cJSON *jstop = cJSON_GetObjectItemCaseSensitive(node, "stop_reason");
|
||||||
|
cJSON *jdur = cJSON_GetObjectItemCaseSensitive(node, "duration_ms");
|
||||||
|
|
||||||
|
cf_turn_result_t *r = (cf_turn_result_t *)calloc(1, sizeof *r);
|
||||||
|
if (!r) {
|
||||||
|
cJSON_Delete(node);
|
||||||
|
return cf_err_set(err, CF_ERR_OOM, 0, "out of memory");
|
||||||
|
}
|
||||||
|
r->ok = cJSON_IsTrue(jok) ? 1 : 0;
|
||||||
|
if (cJSON_IsString(jsid) && jsid->valuestring) {
|
||||||
|
r->session_id = cf_strdup_or_null(jsid->valuestring);
|
||||||
|
if (!r->session_id) {
|
||||||
|
cf_turn_result_free(r);
|
||||||
|
cJSON_Delete(node);
|
||||||
|
return cf_err_set(err, CF_ERR_OOM, 0, "out of memory");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cJSON_IsNumber(jturn)) r->turn_index = (int)jturn->valuedouble;
|
||||||
|
if (cJSON_IsString(jstop) && jstop->valuestring) {
|
||||||
|
r->stop_reason = cf_strdup_or_null(jstop->valuestring);
|
||||||
|
if (!r->stop_reason) {
|
||||||
|
cf_turn_result_free(r);
|
||||||
|
cJSON_Delete(node);
|
||||||
|
return cf_err_set(err, CF_ERR_OOM, 0, "out of memory");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cJSON_IsNumber(jdur)) r->duration_ms = (long)jdur->valuedouble;
|
||||||
|
|
||||||
|
if (cJSON_IsArray(jevs)) {
|
||||||
|
int ec = cJSON_GetArraySize(jevs);
|
||||||
|
if (ec > 0) {
|
||||||
|
r->events = (cf_turn_event_t *)calloc((size_t)ec, sizeof *r->events);
|
||||||
|
if (!r->events) {
|
||||||
|
cf_turn_result_free(r);
|
||||||
|
cJSON_Delete(node);
|
||||||
|
return cf_err_set(err, CF_ERR_OOM, 0, "out of memory");
|
||||||
|
}
|
||||||
|
for (int i = 0; i < ec; ++i) {
|
||||||
|
cJSON *e = cJSON_GetArrayItem(jevs, i);
|
||||||
|
if (parse_turn_event(e, &r->events[i]) != 0) {
|
||||||
|
/* OOM mid-event. Set count to what we successfully built
|
||||||
|
* so the freer doesn't walk uninitialised slots. */
|
||||||
|
r->events_count = (size_t)i;
|
||||||
|
cf_turn_result_free(r);
|
||||||
|
cJSON_Delete(node);
|
||||||
|
return cf_err_set(err, CF_ERR_OOM, 0, "out of memory");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r->events_count = (size_t)ec;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cJSON_Delete(node);
|
||||||
|
*out = r;
|
||||||
|
return CF_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* cf_session_close (idempotent) */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
cf_status_t cf_session_close(cf_session_t *s, cf_error_t *err) {
|
||||||
|
if (!s || !s->client) return cf_err_set(err, CF_ERR_USAGE, 0, "null arg");
|
||||||
|
if (s->closed) return CF_OK;
|
||||||
|
|
||||||
|
if (!session_id_is_safe(s->id)) {
|
||||||
|
return cf_err_set(err, CF_ERR_USAGE, 0,
|
||||||
|
"invalid session id; must match [A-Za-z0-9_-]+");
|
||||||
|
}
|
||||||
|
|
||||||
|
char path[320];
|
||||||
|
int n = snprintf(path, sizeof path, "/sessions/%s", s->id);
|
||||||
|
if (n <= 0 || (size_t)n >= sizeof path) {
|
||||||
|
return cf_err_set(err, CF_ERR_USAGE, 0, "session id too long");
|
||||||
|
}
|
||||||
|
|
||||||
|
cf_http_response_t resp;
|
||||||
|
cf_http_response_init(&resp);
|
||||||
|
cf_status_t st = cf_http_delete(s->client, path, NULL, &resp, err);
|
||||||
|
if (st != CF_OK) {
|
||||||
|
cf_http_response_free(&resp);
|
||||||
|
return st;
|
||||||
|
}
|
||||||
|
st = check_http_status(&resp, err);
|
||||||
|
cf_http_response_free(&resp);
|
||||||
|
if (st != CF_OK) return st;
|
||||||
|
|
||||||
|
s->closed = 1;
|
||||||
|
return CF_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* accessors */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const char *cf_session_id(const cf_session_t *s) {
|
||||||
|
if (!s) return NULL;
|
||||||
|
return s->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char *cf_session_agent(const cf_session_t *s) {
|
||||||
|
if (!s) return NULL;
|
||||||
|
return s->agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* cf_session_free */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
void cf_session_free(cf_session_t *s) {
|
||||||
|
if (!s) return;
|
||||||
|
/* Best-effort implicit close. Errors are intentionally swallowed —
|
||||||
|
* cf_session_free has a void return and should never partially leak,
|
||||||
|
* and the server-side close is idempotent. */
|
||||||
|
if (!s->closed && s->client && session_id_is_safe(s->id)) {
|
||||||
|
char path[320];
|
||||||
|
int n = snprintf(path, sizeof path, "/sessions/%s", s->id);
|
||||||
|
if (n > 0 && (size_t)n < sizeof path) {
|
||||||
|
cf_http_response_t resp;
|
||||||
|
cf_http_response_init(&resp);
|
||||||
|
cf_error_t scratch = {0};
|
||||||
|
(void)cf_http_delete(s->client, path, NULL, &resp, &scratch);
|
||||||
|
cf_http_response_free(&resp);
|
||||||
|
cf_error_free(&scratch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
free(s->id);
|
||||||
|
free(s->agent);
|
||||||
|
free(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* cf_session_state_get / cf_session_list_get */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
cf_status_t cf_session_state_get(cf_client_t *c,
|
||||||
|
const char *session_id,
|
||||||
|
cf_session_state_t **out,
|
||||||
|
cf_error_t *err) {
|
||||||
|
if (!c || !session_id || !out) return cf_err_set(err, CF_ERR_USAGE, 0, "null arg");
|
||||||
|
*out = NULL;
|
||||||
|
if (!session_id_is_safe(session_id)) {
|
||||||
|
return cf_err_set(err, CF_ERR_USAGE, 0,
|
||||||
|
"invalid session id; must match [A-Za-z0-9_-]+");
|
||||||
|
}
|
||||||
|
char path[320];
|
||||||
|
int n = snprintf(path, sizeof path, "/sessions/%s", session_id);
|
||||||
|
if (n <= 0 || (size_t)n >= sizeof path) {
|
||||||
|
return cf_err_set(err, CF_ERR_USAGE, 0, "session id too long");
|
||||||
|
}
|
||||||
|
|
||||||
|
cf_http_response_t resp;
|
||||||
|
cf_http_response_init(&resp);
|
||||||
|
cf_status_t s = cf_http_get(c, path, NULL, &resp, err);
|
||||||
|
if (s != CF_OK) { cf_http_response_free(&resp); return s; }
|
||||||
|
s = check_http_status(&resp, err);
|
||||||
|
if (s != CF_OK) { cf_http_response_free(&resp); return s; }
|
||||||
|
|
||||||
|
cJSON *node = NULL;
|
||||||
|
s = cf_json_parse(resp.body.data, resp.body.len, &node, err);
|
||||||
|
cf_http_response_free(&resp);
|
||||||
|
if (s != CF_OK) return s;
|
||||||
|
|
||||||
|
cf_session_state_t *st = (cf_session_state_t *)calloc(1, sizeof *st);
|
||||||
|
if (!st) {
|
||||||
|
cJSON_Delete(node);
|
||||||
|
return cf_err_set(err, CF_ERR_OOM, 0, "out of memory");
|
||||||
|
}
|
||||||
|
s = parse_session_state_object(node, st, err);
|
||||||
|
cJSON_Delete(node);
|
||||||
|
if (s != CF_OK) {
|
||||||
|
cf_session_state_free(st);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
*out = st;
|
||||||
|
return CF_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
cf_status_t cf_session_list_get(cf_client_t *c,
|
||||||
|
cf_session_list_t **out,
|
||||||
|
cf_error_t *err) {
|
||||||
|
if (!c || !out) return cf_err_set(err, CF_ERR_USAGE, 0, "null arg");
|
||||||
|
*out = NULL;
|
||||||
|
|
||||||
|
cf_http_response_t resp;
|
||||||
|
cf_http_response_init(&resp);
|
||||||
|
cf_status_t s = cf_http_get(c, "/sessions", NULL, &resp, err);
|
||||||
|
if (s != CF_OK) { cf_http_response_free(&resp); return s; }
|
||||||
|
s = check_http_status(&resp, err);
|
||||||
|
if (s != CF_OK) { cf_http_response_free(&resp); return s; }
|
||||||
|
|
||||||
|
cJSON *node = NULL;
|
||||||
|
s = cf_json_parse(resp.body.data, resp.body.len, &node, err);
|
||||||
|
cf_http_response_free(&resp);
|
||||||
|
if (s != CF_OK) return s;
|
||||||
|
|
||||||
|
cJSON *items = cJSON_GetObjectItemCaseSensitive(node, "sessions");
|
||||||
|
if (!cJSON_IsArray(items)) {
|
||||||
|
cJSON_Delete(node);
|
||||||
|
return cf_err_set(err, CF_ERR_PARSE, 0, "expected 'sessions' array");
|
||||||
|
}
|
||||||
|
|
||||||
|
cf_session_list_t *list = (cf_session_list_t *)calloc(1, sizeof *list);
|
||||||
|
if (!list) {
|
||||||
|
cJSON_Delete(node);
|
||||||
|
return cf_err_set(err, CF_ERR_OOM, 0, "out of memory");
|
||||||
|
}
|
||||||
|
|
||||||
|
int n = cJSON_GetArraySize(items);
|
||||||
|
if (n > 0) {
|
||||||
|
list->items = (cf_session_state_t *)calloc((size_t)n, sizeof *list->items);
|
||||||
|
if (!list->items) {
|
||||||
|
free(list);
|
||||||
|
cJSON_Delete(node);
|
||||||
|
return cf_err_set(err, CF_ERR_OOM, 0, "out of memory");
|
||||||
|
}
|
||||||
|
for (int i = 0; i < n; ++i) {
|
||||||
|
cJSON *e = cJSON_GetArrayItem(items, i);
|
||||||
|
if (!cJSON_IsObject(e)) continue;
|
||||||
|
cf_status_t ps = parse_session_state_object(e, &list->items[list->items_count], err);
|
||||||
|
if (ps != CF_OK) {
|
||||||
|
cJSON_Delete(node);
|
||||||
|
cf_session_list_free(list);
|
||||||
|
return ps;
|
||||||
|
}
|
||||||
|
list->items_count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cJSON_Delete(node);
|
||||||
|
*out = list;
|
||||||
|
return CF_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* cf_turn_result_text — lazy concat */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const char *cf_turn_result_text(cf_turn_result_t *r) {
|
||||||
|
if (!r) return "";
|
||||||
|
if (r->_cached_text) return r->_cached_text;
|
||||||
|
|
||||||
|
/* Compute total length first to avoid quadratic realloc. */
|
||||||
|
size_t total = 0;
|
||||||
|
for (size_t i = 0; i < r->events_count; ++i) {
|
||||||
|
const cf_turn_event_t *e = &r->events[i];
|
||||||
|
if (e->type && strcmp(e->type, "text") == 0 && e->content) {
|
||||||
|
size_t add = strlen(e->content);
|
||||||
|
if (add > SIZE_MAX - total - 1) {
|
||||||
|
/* Overflow — return empty rather than truncating. */
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
total += add;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
char *buf = (char *)malloc(total + 1);
|
||||||
|
if (!buf) return "";
|
||||||
|
size_t off = 0;
|
||||||
|
for (size_t i = 0; i < r->events_count; ++i) {
|
||||||
|
const cf_turn_event_t *e = &r->events[i];
|
||||||
|
if (e->type && strcmp(e->type, "text") == 0 && e->content) {
|
||||||
|
size_t add = strlen(e->content);
|
||||||
|
memcpy(buf + off, e->content, add);
|
||||||
|
off += add;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buf[off] = '\0';
|
||||||
|
r->_cached_text = buf;
|
||||||
|
return r->_cached_text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* freers */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
void cf_turn_result_free(cf_turn_result_t *r) {
|
||||||
|
if (!r) return;
|
||||||
|
free(r->session_id);
|
||||||
|
free(r->stop_reason);
|
||||||
|
free(r->_cached_text);
|
||||||
|
if (r->events) {
|
||||||
|
for (size_t i = 0; i < r->events_count; ++i) {
|
||||||
|
free(r->events[i].type);
|
||||||
|
free(r->events[i].content);
|
||||||
|
free(r->events[i].name);
|
||||||
|
free(r->events[i].args_json);
|
||||||
|
free(r->events[i].result_json);
|
||||||
|
}
|
||||||
|
free(r->events);
|
||||||
|
}
|
||||||
|
free(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Free the heap wrapper allocated by cf_session_state_get. Safe with
|
||||||
|
* NULL. */
|
||||||
|
void cf_session_state_free(cf_session_state_t *s) {
|
||||||
|
if (!s) return;
|
||||||
|
free(s->session_id);
|
||||||
|
free(s->agent);
|
||||||
|
free(s->app_name);
|
||||||
|
free(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Free the heap wrapper allocated by cf_session_list_get. Walks each
|
||||||
|
* embedded item and releases its strings before releasing the items
|
||||||
|
* array and the wrapper. Safe with NULL. */
|
||||||
|
void cf_session_list_free(cf_session_list_t *l) {
|
||||||
|
if (!l) return;
|
||||||
|
if (l->items) {
|
||||||
|
for (size_t i = 0; i < l->items_count; ++i) {
|
||||||
|
free(l->items[i].session_id);
|
||||||
|
free(l->items[i].agent);
|
||||||
|
free(l->items[i].app_name);
|
||||||
|
}
|
||||||
|
free(l->items);
|
||||||
|
}
|
||||||
|
free(l);
|
||||||
|
}
|
||||||
|
|
@ -417,7 +417,7 @@ static void reset_captures(void) {
|
||||||
|
|
||||||
static void t_version(void) {
|
static void t_version(void) {
|
||||||
TEST("version");
|
TEST("version");
|
||||||
CHECK_STR_EQ(cf_version(), "0.1.0");
|
CHECK_STR_EQ(cf_version(), "0.2.0");
|
||||||
CHECK_STR_EQ(cf_status_str(CF_OK), "CF_OK");
|
CHECK_STR_EQ(cf_status_str(CF_OK), "CF_OK");
|
||||||
CHECK_STR_EQ(cf_status_str(CF_ERR_PARSE), "CF_ERR_PARSE");
|
CHECK_STR_EQ(cf_status_str(CF_ERR_PARSE), "CF_ERR_PARSE");
|
||||||
}
|
}
|
||||||
|
|
@ -1003,6 +1003,544 @@ static void t_cjson_bump(void) {
|
||||||
CHECK(bad == NULL);
|
CHECK(bad == NULL);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* v0.2 — multi-turn / Sessions */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/* Helper — JSON body for "POST /sessions" success. */
|
||||||
|
static const char *SESS_CREATE_BODY =
|
||||||
|
"{\"ok\":true,"
|
||||||
|
"\"session_id\":\"sess_abc\","
|
||||||
|
"\"agent\":\"claude\","
|
||||||
|
"\"created_at\":1735000000,"
|
||||||
|
"\"cwd\":\"/tmp/sess_abc\"}";
|
||||||
|
|
||||||
|
static const char *SESS_CLOSE_BODY = "{\"ok\":true}";
|
||||||
|
|
||||||
|
/* Test — full create + turn round trip. */
|
||||||
|
static void t_session_turn_round_trip(void) {
|
||||||
|
TEST("session_turn_round_trip");
|
||||||
|
reset_captures();
|
||||||
|
|
||||||
|
/* 1) POST /sessions response */
|
||||||
|
enqueue_response(200, "application/json", SESS_CREATE_BODY);
|
||||||
|
/* 2) POST /sessions/<id>/turn response with structured events */
|
||||||
|
enqueue_response(200, "application/json",
|
||||||
|
"{\"ok\":true,"
|
||||||
|
"\"session_id\":\"sess_abc\","
|
||||||
|
"\"turn_index\":1,"
|
||||||
|
"\"events\":["
|
||||||
|
"{\"type\":\"thinking\",\"content\":\"hmm...\"},"
|
||||||
|
"{\"type\":\"tool_call\",\"name\":\"Read\",\"args\":{\"path\":\"README.md\"},\"result\":{\"chars\":42}},"
|
||||||
|
"{\"type\":\"text\",\"content\":\"hello, \"},"
|
||||||
|
"{\"type\":\"text\",\"content\":\"world\"}"
|
||||||
|
"],"
|
||||||
|
"\"stop_reason\":\"end_turn\","
|
||||||
|
"\"duration_ms\":4321}");
|
||||||
|
|
||||||
|
cf_client_t *c = cf_client_new(make_base_url(), "cf_apptok");
|
||||||
|
cf_session_t *s = NULL;
|
||||||
|
cf_error_t err = {0};
|
||||||
|
|
||||||
|
cf_session_options_t sopts = { .agent = "claude" };
|
||||||
|
cf_status_t rc = cf_session_new(c, &sopts, &s, &err);
|
||||||
|
CHECK(rc == CF_OK);
|
||||||
|
CHECK(s != NULL);
|
||||||
|
if (s) {
|
||||||
|
CHECK_STR_EQ(cf_session_id(s), "sess_abc");
|
||||||
|
CHECK_STR_EQ(cf_session_agent(s), "claude");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Verify the create POST */
|
||||||
|
CHECK_STR_EQ(captured[0].method, "POST");
|
||||||
|
CHECK_STR_EQ(captured[0].path, "/sessions");
|
||||||
|
CHECK_STR_EQ(captured[0].auth, "Bearer cf_apptok");
|
||||||
|
cJSON *sent = cJSON_Parse(captured[0].body);
|
||||||
|
CHECK(sent != NULL);
|
||||||
|
if (sent) {
|
||||||
|
cJSON *jagent = cJSON_GetObjectItemCaseSensitive(sent, "agent");
|
||||||
|
CHECK(cJSON_IsString(jagent));
|
||||||
|
if (cJSON_IsString(jagent)) CHECK_STR_EQ(jagent->valuestring, "claude");
|
||||||
|
cJSON_Delete(sent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Now run a turn */
|
||||||
|
cf_turn_options_t topts = {0};
|
||||||
|
cf_turn_result_t *r = NULL;
|
||||||
|
rc = cf_session_turn(s, "Read README.md and summarize", &topts, &r, &err);
|
||||||
|
CHECK(rc == CF_OK);
|
||||||
|
CHECK(r != NULL);
|
||||||
|
if (r) {
|
||||||
|
CHECK(r->ok == 1);
|
||||||
|
CHECK_STR_EQ(r->session_id, "sess_abc");
|
||||||
|
CHECK(r->turn_index == 1);
|
||||||
|
CHECK_STR_EQ(r->stop_reason, "end_turn");
|
||||||
|
CHECK(r->duration_ms == 4321);
|
||||||
|
CHECK(r->events_count == 4);
|
||||||
|
if (r->events_count == 4) {
|
||||||
|
CHECK_STR_EQ(r->events[0].type, "thinking");
|
||||||
|
CHECK_STR_EQ(r->events[0].content, "hmm...");
|
||||||
|
CHECK_STR_EQ(r->events[1].type, "tool_call");
|
||||||
|
CHECK_STR_EQ(r->events[1].name, "Read");
|
||||||
|
CHECK(r->events[1].args_json != NULL);
|
||||||
|
CHECK(r->events[1].result_json != NULL);
|
||||||
|
if (r->events[1].args_json) {
|
||||||
|
CHECK(strstr(r->events[1].args_json, "README.md") != NULL);
|
||||||
|
}
|
||||||
|
CHECK_STR_EQ(r->events[2].type, "text");
|
||||||
|
CHECK_STR_EQ(r->events[3].type, "text");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* cf_turn_result_text should concatenate text events. */
|
||||||
|
const char *text = cf_turn_result_text(r);
|
||||||
|
CHECK_STR_EQ(text, "hello, world");
|
||||||
|
/* Second call — must reuse the cached buffer. */
|
||||||
|
const char *text2 = cf_turn_result_text(r);
|
||||||
|
CHECK(text == text2);
|
||||||
|
|
||||||
|
cf_turn_result_free(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Verify the turn POST */
|
||||||
|
CHECK_STR_EQ(captured[1].method, "POST");
|
||||||
|
CHECK_STR_EQ(captured[1].path, "/sessions/sess_abc/turn");
|
||||||
|
CHECK_STR_EQ(captured[1].auth, "Bearer cf_apptok");
|
||||||
|
sent = cJSON_Parse(captured[1].body);
|
||||||
|
CHECK(sent != NULL);
|
||||||
|
if (sent) {
|
||||||
|
cJSON *jp = cJSON_GetObjectItemCaseSensitive(sent, "prompt");
|
||||||
|
CHECK(cJSON_IsString(jp));
|
||||||
|
if (cJSON_IsString(jp)) CHECK_STR_EQ(jp->valuestring, "Read README.md and summarize");
|
||||||
|
cJSON_Delete(sent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Now close the session — DELETE /sessions/<id> */
|
||||||
|
enqueue_response(200, "application/json", SESS_CLOSE_BODY);
|
||||||
|
rc = cf_session_close(s, &err);
|
||||||
|
CHECK(rc == CF_OK);
|
||||||
|
CHECK_STR_EQ(captured[2].method, "DELETE");
|
||||||
|
CHECK_STR_EQ(captured[2].path, "/sessions/sess_abc");
|
||||||
|
|
||||||
|
cf_session_free(s); /* already closed; no extra HTTP traffic */
|
||||||
|
|
||||||
|
cf_error_free(&err);
|
||||||
|
cf_client_free(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Test — close × 2 only hits DELETE once. */
|
||||||
|
static void t_session_close_idempotent(void) {
|
||||||
|
TEST("session_close_idempotent");
|
||||||
|
reset_captures();
|
||||||
|
|
||||||
|
enqueue_response(200, "application/json", SESS_CREATE_BODY);
|
||||||
|
enqueue_response(200, "application/json", SESS_CLOSE_BODY);
|
||||||
|
/* Note: NOT enqueueing a second DELETE response — the second close
|
||||||
|
* should short-circuit before hitting the wire. If it did go to
|
||||||
|
* the wire, the test server's fallback 500 would surface as a
|
||||||
|
* non-CF_OK from the second close. */
|
||||||
|
|
||||||
|
cf_client_t *c = cf_client_new(make_base_url(), "tok");
|
||||||
|
cf_session_t *s = NULL;
|
||||||
|
cf_error_t err = {0};
|
||||||
|
|
||||||
|
CHECK(cf_session_new(c, NULL, &s, &err) == CF_OK);
|
||||||
|
|
||||||
|
CHECK(cf_session_close(s, &err) == CF_OK);
|
||||||
|
CHECK(cf_session_close(s, &err) == CF_OK); /* idempotent — no HTTP */
|
||||||
|
CHECK(cf_session_close(s, &err) == CF_OK);
|
||||||
|
|
||||||
|
/* Captured: POST /sessions, then exactly ONE DELETE. */
|
||||||
|
CHECK(captured_count == 2);
|
||||||
|
if (captured_count >= 2) {
|
||||||
|
CHECK_STR_EQ(captured[0].method, "POST");
|
||||||
|
CHECK_STR_EQ(captured[1].method, "DELETE");
|
||||||
|
}
|
||||||
|
|
||||||
|
cf_session_free(s);
|
||||||
|
cf_error_free(&err);
|
||||||
|
cf_client_free(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Test — turn after close → CF_ERR_USAGE, no HTTP traffic. */
|
||||||
|
static void t_session_turn_after_close_returns_error(void) {
|
||||||
|
TEST("session_turn_after_close_returns_error");
|
||||||
|
reset_captures();
|
||||||
|
|
||||||
|
enqueue_response(200, "application/json", SESS_CREATE_BODY);
|
||||||
|
enqueue_response(200, "application/json", SESS_CLOSE_BODY);
|
||||||
|
|
||||||
|
cf_client_t *c = cf_client_new(make_base_url(), "tok");
|
||||||
|
cf_session_t *s = NULL;
|
||||||
|
cf_error_t err = {0};
|
||||||
|
|
||||||
|
CHECK(cf_session_new(c, NULL, &s, &err) == CF_OK);
|
||||||
|
CHECK(cf_session_close(s, &err) == CF_OK);
|
||||||
|
|
||||||
|
cf_turn_options_t topts = {0};
|
||||||
|
cf_turn_result_t *r = NULL;
|
||||||
|
cf_status_t rc = cf_session_turn(s, "hi", &topts, &r, &err);
|
||||||
|
CHECK(rc == CF_ERR_USAGE);
|
||||||
|
CHECK(r == NULL);
|
||||||
|
CHECK(err.message && strstr(err.message, "closed") != NULL);
|
||||||
|
|
||||||
|
/* And NO third HTTP request was made. */
|
||||||
|
CHECK(captured_count == 2);
|
||||||
|
|
||||||
|
cf_error_free(&err);
|
||||||
|
cf_session_free(s);
|
||||||
|
cf_client_free(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Test — server 404 on cross-token access surfaces as CF_ERR_API + 404. */
|
||||||
|
static void t_session_cross_token_returns_404(void) {
|
||||||
|
TEST("session_cross_token_returns_404");
|
||||||
|
reset_captures();
|
||||||
|
|
||||||
|
enqueue_response(404, "application/json", "{\"detail\":\"session not found\"}");
|
||||||
|
|
||||||
|
cf_client_t *c = cf_client_new(make_base_url(), "tok");
|
||||||
|
cf_session_state_t *state = NULL;
|
||||||
|
cf_error_t err = {0};
|
||||||
|
cf_status_t rc = cf_session_state_get(c, "sess_other", &state, &err);
|
||||||
|
CHECK(rc == CF_ERR_API);
|
||||||
|
CHECK(err.http_status == 404);
|
||||||
|
CHECK(state == NULL);
|
||||||
|
CHECK(err.message && strstr(err.message, "session not found") != NULL);
|
||||||
|
/* Bearer must NOT leak into the error. */
|
||||||
|
CHECK(err.message && strstr(err.message, "tok") == NULL);
|
||||||
|
CHECK(err.message && strstr(err.message, "Bearer") == NULL);
|
||||||
|
|
||||||
|
cf_error_free(&err);
|
||||||
|
cf_client_free(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Test — session_id with traversal characters → CF_ERR_USAGE pre-network. */
|
||||||
|
static void t_session_validates_id_traversal(void) {
|
||||||
|
TEST("session_validates_id_traversal");
|
||||||
|
cf_client_t *c = cf_client_new("http://127.0.0.1:1", "tok");
|
||||||
|
cf_error_t err = {0};
|
||||||
|
|
||||||
|
cf_session_state_t *state = NULL;
|
||||||
|
cf_status_t rc = cf_session_state_get(c, "a/../healthz", &state, &err);
|
||||||
|
CHECK(rc == CF_ERR_USAGE);
|
||||||
|
CHECK(state == NULL);
|
||||||
|
cf_error_free(&err);
|
||||||
|
|
||||||
|
rc = cf_session_state_get(c, "../etc/passwd", &state, &err);
|
||||||
|
CHECK(rc == CF_ERR_USAGE);
|
||||||
|
cf_error_free(&err);
|
||||||
|
|
||||||
|
rc = cf_session_state_get(c, "", &state, &err);
|
||||||
|
CHECK(rc == CF_ERR_USAGE);
|
||||||
|
cf_error_free(&err);
|
||||||
|
|
||||||
|
/* A valid id continues to the network and surfaces CF_ERR_TRANSPORT
|
||||||
|
* (no server listening at :1) — proving the validator passed. */
|
||||||
|
rc = cf_session_state_get(c, "valid_id-123", &state, &err);
|
||||||
|
CHECK(rc == CF_ERR_TRANSPORT);
|
||||||
|
cf_error_free(&err);
|
||||||
|
|
||||||
|
cf_client_free(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Test — cf_session_list_get parses 2 rows and surfaces fields. */
|
||||||
|
static void t_session_list_get(void) {
|
||||||
|
TEST("session_list_get");
|
||||||
|
reset_captures();
|
||||||
|
|
||||||
|
enqueue_response(200, "application/json",
|
||||||
|
"{\"ok\":true,\"count\":2,\"sessions\":["
|
||||||
|
"{\"session_id\":\"sess_a\",\"app_name\":\"app1\",\"agent\":\"claude\","
|
||||||
|
"\"created_at\":1700000000,\"last_turn_at\":1700000100,"
|
||||||
|
"\"turn_count\":3,\"closed_at\":null},"
|
||||||
|
"{\"session_id\":\"sess_b\",\"app_name\":\"app1\",\"agent\":\"claude\","
|
||||||
|
"\"created_at\":1700000200,\"last_turn_at\":null,"
|
||||||
|
"\"turn_count\":0,\"closed_at\":1700000300}"
|
||||||
|
"]}");
|
||||||
|
|
||||||
|
cf_client_t *c = cf_client_new(make_base_url(), "tok");
|
||||||
|
cf_session_list_t *list = NULL;
|
||||||
|
cf_error_t err = {0};
|
||||||
|
cf_status_t rc = cf_session_list_get(c, &list, &err);
|
||||||
|
CHECK(rc == CF_OK);
|
||||||
|
CHECK(list != NULL);
|
||||||
|
if (list) {
|
||||||
|
CHECK(list->items_count == 2);
|
||||||
|
if (list->items_count == 2) {
|
||||||
|
CHECK_STR_EQ(list->items[0].session_id, "sess_a");
|
||||||
|
CHECK_STR_EQ(list->items[0].app_name, "app1");
|
||||||
|
CHECK_STR_EQ(list->items[0].agent, "claude");
|
||||||
|
CHECK(list->items[0].created_at == 1700000000);
|
||||||
|
CHECK(list->items[0].last_turn_at == 1700000100);
|
||||||
|
CHECK(list->items[0].turn_count == 3);
|
||||||
|
CHECK(list->items[0].closed_at == -1);
|
||||||
|
|
||||||
|
CHECK_STR_EQ(list->items[1].session_id, "sess_b");
|
||||||
|
CHECK(list->items[1].last_turn_at == -1); /* null */
|
||||||
|
CHECK(list->items[1].closed_at == 1700000300);
|
||||||
|
}
|
||||||
|
cf_session_list_free(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
CHECK_STR_EQ(captured[0].method, "GET");
|
||||||
|
CHECK_STR_EQ(captured[0].path, "/sessions");
|
||||||
|
|
||||||
|
cf_error_free(&err);
|
||||||
|
cf_client_free(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Test — cf_session_state_get returns a populated state struct. */
|
||||||
|
static void t_session_state_get(void) {
|
||||||
|
TEST("session_state_get");
|
||||||
|
reset_captures();
|
||||||
|
enqueue_response(200, "application/json",
|
||||||
|
"{\"ok\":true,\"session_id\":\"sess_a\",\"app_name\":\"app1\","
|
||||||
|
"\"agent\":\"claude\",\"created_at\":1700000000,"
|
||||||
|
"\"last_turn_at\":1700000100,\"turn_count\":5,\"closed_at\":null}");
|
||||||
|
|
||||||
|
cf_client_t *c = cf_client_new(make_base_url(), "tok");
|
||||||
|
cf_session_state_t *state = NULL;
|
||||||
|
cf_error_t err = {0};
|
||||||
|
cf_status_t rc = cf_session_state_get(c, "sess_a", &state, &err);
|
||||||
|
CHECK(rc == CF_OK);
|
||||||
|
CHECK(state != NULL);
|
||||||
|
if (state) {
|
||||||
|
CHECK_STR_EQ(state->session_id, "sess_a");
|
||||||
|
CHECK_STR_EQ(state->app_name, "app1");
|
||||||
|
CHECK_STR_EQ(state->agent, "claude");
|
||||||
|
CHECK(state->created_at == 1700000000);
|
||||||
|
CHECK(state->last_turn_at == 1700000100);
|
||||||
|
CHECK(state->turn_count == 5);
|
||||||
|
CHECK(state->closed_at == -1); /* null */
|
||||||
|
cf_session_state_free(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
CHECK_STR_EQ(captured[0].method, "GET");
|
||||||
|
CHECK_STR_EQ(captured[0].path, "/sessions/sess_a");
|
||||||
|
|
||||||
|
cf_error_free(&err);
|
||||||
|
cf_client_free(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Test — text concatenation skips non-text events; cached on second call. */
|
||||||
|
static void t_turn_result_text_concatenates(void) {
|
||||||
|
TEST("turn_result_text_concatenates");
|
||||||
|
reset_captures();
|
||||||
|
enqueue_response(200, "application/json", SESS_CREATE_BODY);
|
||||||
|
enqueue_response(200, "application/json",
|
||||||
|
"{\"ok\":true,\"session_id\":\"sess_abc\",\"turn_index\":0,"
|
||||||
|
"\"events\":["
|
||||||
|
"{\"type\":\"text\",\"content\":\"alpha \"},"
|
||||||
|
"{\"type\":\"thinking\",\"content\":\"ignored\"},"
|
||||||
|
"{\"type\":\"text\",\"content\":\"beta\"},"
|
||||||
|
"{\"type\":\"text\"}," /* no content — skipped */
|
||||||
|
"{\"type\":\"text\",\"content\":\"\"}," /* empty — appends nothing */
|
||||||
|
"{\"type\":\"text\",\"content\":\" gamma\"}"
|
||||||
|
"],"
|
||||||
|
"\"stop_reason\":\"end_turn\",\"duration_ms\":1}");
|
||||||
|
enqueue_response(200, "application/json", SESS_CLOSE_BODY);
|
||||||
|
|
||||||
|
cf_client_t *c = cf_client_new(make_base_url(), "tok");
|
||||||
|
cf_session_t *s = NULL;
|
||||||
|
cf_error_t err = {0};
|
||||||
|
CHECK(cf_session_new(c, NULL, &s, &err) == CF_OK);
|
||||||
|
|
||||||
|
cf_turn_result_t *r = NULL;
|
||||||
|
cf_turn_options_t topts = {0};
|
||||||
|
CHECK(cf_session_turn(s, "go", &topts, &r, &err) == CF_OK);
|
||||||
|
if (r) {
|
||||||
|
CHECK_STR_EQ(cf_turn_result_text(r), "alpha beta gamma");
|
||||||
|
/* Empty-text edge case is handled. */
|
||||||
|
cf_turn_result_free(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
cf_session_close(s, &err);
|
||||||
|
cf_session_free(s);
|
||||||
|
cf_error_free(&err);
|
||||||
|
cf_client_free(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Test — cf_session_free(NULL) and double-free pattern is safe. */
|
||||||
|
static void t_session_free_idempotent(void) {
|
||||||
|
TEST("session_free_idempotent");
|
||||||
|
cf_session_free(NULL); /* must not crash */
|
||||||
|
|
||||||
|
/* For an open session, free should attempt close. With no server
|
||||||
|
* listening, the close fails internally but cf_session_free swallows
|
||||||
|
* it — must not crash, must not leak. */
|
||||||
|
cf_client_t *c = cf_client_new("http://127.0.0.1:1", "tok");
|
||||||
|
/* Verify the other v0.2 freers are also NULL-safe. */
|
||||||
|
cf_turn_result_free(NULL);
|
||||||
|
cf_session_state_free(NULL);
|
||||||
|
cf_session_list_free(NULL);
|
||||||
|
|
||||||
|
cf_client_free(c);
|
||||||
|
CHECK(1); /* survival = pass */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Test — turn-result memory is fully released (valgrind catches leaks). */
|
||||||
|
static void t_turn_result_memory_clean(void) {
|
||||||
|
TEST("turn_result_memory_clean");
|
||||||
|
reset_captures();
|
||||||
|
enqueue_response(200, "application/json", SESS_CREATE_BODY);
|
||||||
|
enqueue_response(200, "application/json",
|
||||||
|
"{\"ok\":true,\"session_id\":\"sess_abc\",\"turn_index\":7,"
|
||||||
|
"\"events\":["
|
||||||
|
"{\"type\":\"text\",\"content\":\"a\"},"
|
||||||
|
"{\"type\":\"tool_call\",\"name\":\"Bash\","
|
||||||
|
"\"args\":{\"cmd\":\"ls\"},\"result\":{\"out\":\"foo\\nbar\"}},"
|
||||||
|
"{\"type\":\"thinking\",\"content\":\"...\"}"
|
||||||
|
"],"
|
||||||
|
"\"stop_reason\":\"end_turn\",\"duration_ms\":99}");
|
||||||
|
enqueue_response(200, "application/json", SESS_CLOSE_BODY);
|
||||||
|
|
||||||
|
cf_client_t *c = cf_client_new(make_base_url(), "tok");
|
||||||
|
cf_session_t *s = NULL;
|
||||||
|
cf_error_t err = {0};
|
||||||
|
CHECK(cf_session_new(c, NULL, &s, &err) == CF_OK);
|
||||||
|
|
||||||
|
cf_turn_options_t topts = {0};
|
||||||
|
cf_turn_result_t *r = NULL;
|
||||||
|
CHECK(cf_session_turn(s, "x", &topts, &r, &err) == CF_OK);
|
||||||
|
/* Touch text twice to populate + reuse the cache. */
|
||||||
|
CHECK_STR_EQ(cf_turn_result_text(r), "a");
|
||||||
|
CHECK_STR_EQ(cf_turn_result_text(r), "a");
|
||||||
|
cf_turn_result_free(r);
|
||||||
|
|
||||||
|
cf_session_close(s, &err);
|
||||||
|
cf_session_free(s);
|
||||||
|
cf_error_free(&err);
|
||||||
|
cf_client_free(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Test — turn with files passes the array. */
|
||||||
|
static void t_session_turn_with_files(void) {
|
||||||
|
TEST("session_turn_with_files");
|
||||||
|
reset_captures();
|
||||||
|
enqueue_response(200, "application/json", SESS_CREATE_BODY);
|
||||||
|
enqueue_response(200, "application/json",
|
||||||
|
"{\"ok\":true,\"session_id\":\"sess_abc\",\"turn_index\":0,"
|
||||||
|
"\"events\":[],\"stop_reason\":\"end_turn\",\"duration_ms\":1}");
|
||||||
|
enqueue_response(200, "application/json", SESS_CLOSE_BODY);
|
||||||
|
|
||||||
|
cf_client_t *c = cf_client_new(make_base_url(), "tok");
|
||||||
|
cf_session_t *s = NULL;
|
||||||
|
cf_error_t err = {0};
|
||||||
|
CHECK(cf_session_new(c, NULL, &s, &err) == CF_OK);
|
||||||
|
|
||||||
|
const char *files[] = { "ff_aaa", "ff_bbb" };
|
||||||
|
cf_turn_options_t topts = {
|
||||||
|
.files = files,
|
||||||
|
.files_count = 2,
|
||||||
|
.timeout_secs = 45,
|
||||||
|
};
|
||||||
|
cf_turn_result_t *r = NULL;
|
||||||
|
CHECK(cf_session_turn(s, "process these", &topts, &r, &err) == CF_OK);
|
||||||
|
|
||||||
|
cJSON *sent = cJSON_Parse(captured[1].body);
|
||||||
|
CHECK(sent != NULL);
|
||||||
|
if (sent) {
|
||||||
|
cJSON *farr = cJSON_GetObjectItemCaseSensitive(sent, "files");
|
||||||
|
CHECK(cJSON_IsArray(farr));
|
||||||
|
if (cJSON_IsArray(farr)) {
|
||||||
|
CHECK(cJSON_GetArraySize(farr) == 2);
|
||||||
|
}
|
||||||
|
cJSON *jto = cJSON_GetObjectItemCaseSensitive(sent, "timeout_secs");
|
||||||
|
CHECK(cJSON_IsNumber(jto));
|
||||||
|
if (cJSON_IsNumber(jto)) CHECK((int)jto->valuedouble == 45);
|
||||||
|
cJSON_Delete(sent);
|
||||||
|
}
|
||||||
|
|
||||||
|
cf_turn_result_free(r);
|
||||||
|
cf_session_close(s, &err);
|
||||||
|
cf_session_free(s);
|
||||||
|
cf_error_free(&err);
|
||||||
|
cf_client_free(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Test — bearer token never appears in any cf_error_t.message. */
|
||||||
|
static void t_session_bearer_never_leaks(void) {
|
||||||
|
TEST("session_bearer_never_leaks");
|
||||||
|
/* Guard against a regression where a future refactor concatenates
|
||||||
|
* the auth header into a transport / parse error. We try every
|
||||||
|
* fallible session entry point with a distinctive bearer string
|
||||||
|
* and confirm the bearer never appears in the resulting message. */
|
||||||
|
static const char *SECRET = "BEARERMARKER_zzz_9999";
|
||||||
|
|
||||||
|
cf_client_t *c = cf_client_new("http://127.0.0.1:1", SECRET);
|
||||||
|
cf_error_t err = {0};
|
||||||
|
|
||||||
|
/* (a) cf_session_new — server unreachable */
|
||||||
|
cf_session_t *s = NULL;
|
||||||
|
cf_session_options_t sopts = { .agent = "claude" };
|
||||||
|
cf_session_new(c, &sopts, &s, &err);
|
||||||
|
CHECK(err.message != NULL);
|
||||||
|
if (err.message) CHECK(strstr(err.message, SECRET) == NULL);
|
||||||
|
cf_error_free(&err);
|
||||||
|
|
||||||
|
/* (b) cf_session_state_get — server returns 404 with detail field */
|
||||||
|
reset_captures();
|
||||||
|
/* Reset client to point at the mock server. */
|
||||||
|
cf_client_free(c);
|
||||||
|
c = cf_client_new(make_base_url(), SECRET);
|
||||||
|
enqueue_response(404, "application/json",
|
||||||
|
"{\"detail\":\"session not found\"}");
|
||||||
|
cf_session_state_t *state = NULL;
|
||||||
|
cf_session_state_get(c, "sess_xyz", &state, &err);
|
||||||
|
CHECK(err.message != NULL);
|
||||||
|
if (err.message) {
|
||||||
|
CHECK(strstr(err.message, SECRET) == NULL);
|
||||||
|
CHECK(strstr(err.message, "Bearer") == NULL);
|
||||||
|
}
|
||||||
|
cf_error_free(&err);
|
||||||
|
|
||||||
|
/* (c) cf_session_list_get — 401 with detail */
|
||||||
|
enqueue_response(401, "application/json",
|
||||||
|
"{\"detail\":\"invalid token\"}");
|
||||||
|
cf_session_list_t *list = NULL;
|
||||||
|
cf_session_list_get(c, &list, &err);
|
||||||
|
CHECK(err.message != NULL);
|
||||||
|
if (err.message) CHECK(strstr(err.message, SECRET) == NULL);
|
||||||
|
cf_error_free(&err);
|
||||||
|
|
||||||
|
cf_client_free(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Test — null-arg defenses on every session entry point. */
|
||||||
|
static void t_session_null_arg_defenses(void) {
|
||||||
|
TEST("session_null_arg_defenses");
|
||||||
|
cf_error_t err = {0};
|
||||||
|
cf_session_t *s = NULL;
|
||||||
|
cf_turn_result_t *r = NULL;
|
||||||
|
cf_session_state_t *state = NULL;
|
||||||
|
|
||||||
|
CHECK(cf_session_new(NULL, NULL, &s, &err) == CF_ERR_USAGE);
|
||||||
|
cf_error_free(&err);
|
||||||
|
|
||||||
|
cf_client_t *c = cf_client_new("http://127.0.0.1:1", "tok");
|
||||||
|
CHECK(cf_session_new(c, NULL, NULL, &err) == CF_ERR_USAGE);
|
||||||
|
cf_error_free(&err);
|
||||||
|
|
||||||
|
CHECK(cf_session_turn(NULL, "p", NULL, &r, &err) == CF_ERR_USAGE);
|
||||||
|
cf_error_free(&err);
|
||||||
|
|
||||||
|
CHECK(cf_session_close(NULL, &err) == CF_ERR_USAGE);
|
||||||
|
cf_error_free(&err);
|
||||||
|
|
||||||
|
CHECK(cf_session_state_get(c, NULL, &state, &err) == CF_ERR_USAGE);
|
||||||
|
cf_error_free(&err);
|
||||||
|
|
||||||
|
CHECK(cf_session_list_get(c, NULL, &err) == CF_ERR_USAGE);
|
||||||
|
cf_error_free(&err);
|
||||||
|
|
||||||
|
/* accessors safe on NULL */
|
||||||
|
CHECK(cf_session_id(NULL) == NULL);
|
||||||
|
CHECK(cf_session_agent(NULL) == NULL);
|
||||||
|
CHECK(strcmp(cf_turn_result_text(NULL), "") == 0);
|
||||||
|
|
||||||
|
cf_client_free(c);
|
||||||
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* main */
|
/* main */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
@ -1040,6 +1578,21 @@ int main(void) {
|
||||||
t_concurrent_client_init();
|
t_concurrent_client_init();
|
||||||
t_cjson_bump();
|
t_cjson_bump();
|
||||||
|
|
||||||
|
/* v0.2 — multi-turn / sessions */
|
||||||
|
t_session_turn_round_trip();
|
||||||
|
t_session_close_idempotent();
|
||||||
|
t_session_turn_after_close_returns_error();
|
||||||
|
t_session_cross_token_returns_404();
|
||||||
|
t_session_validates_id_traversal();
|
||||||
|
t_session_list_get();
|
||||||
|
t_session_state_get();
|
||||||
|
t_turn_result_text_concatenates();
|
||||||
|
t_session_free_idempotent();
|
||||||
|
t_turn_result_memory_clean();
|
||||||
|
t_session_turn_with_files();
|
||||||
|
t_session_bearer_never_leaks();
|
||||||
|
t_session_null_arg_defenses();
|
||||||
|
|
||||||
stop_server();
|
stop_server();
|
||||||
|
|
||||||
fprintf(stderr, "\n%d tests, %d failures\n", g_tests, g_failures);
|
fprintf(stderr, "\n%d tests, %d failures\n", g_tests, g_failures);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue