summaryrefslogtreecommitdiffstats
path: root/tests/test_session.cpp
diff options
context:
space:
mode:
Diffstat (limited to 'tests/test_session.cpp')
-rw-r--r--tests/test_session.cpp1659
1 files changed, 1659 insertions, 0 deletions
diff --git a/tests/test_session.cpp b/tests/test_session.cpp
new file mode 100644
index 0000000..7b893f4
--- /dev/null
+++ b/tests/test_session.cpp
@@ -0,0 +1,1659 @@
+#include "session.h"
+
+#include <atomic>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+/* ------------------------------------------------------------------ */
+/* Harness */
+/* ------------------------------------------------------------------ */
+
+static int failures = 0;
+static int checks = 0;
+
+#define CHECK(cond, msg) \
+ do { \
+ checks++; \
+ if (!(cond)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, (msg)); \
+ } \
+ } while (0)
+
+#define CHECK_CONT(hay, needle, msg) \
+ do { \
+ checks++; \
+ if (!hay || !strstr(hay, needle)) { \
+ failures++; \
+ fprintf(stderr, \
+ "FAIL %s:%d: %s (got `%s`, want substring `%s`)\n", \
+ __FILE__, __LINE__, (msg), hay ? hay : "(null)", \
+ (needle)); \
+ } \
+ } while (0)
+
+#define CHECK_NOT_CONT(hay, needle, msg) \
+ do { \
+ checks++; \
+ if (hay && strstr(hay, needle)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: %s (found `%s` in `%s`)\n", \
+ __FILE__, __LINE__, (msg), (needle), hay); \
+ } \
+ } while (0)
+
+#define CHECK_EQ_U64(got, want, msg) \
+ do { \
+ checks++; \
+ if ((unsigned long long)(got) != (unsigned long long)(want)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: %s (got %llu, want %llu)\n", \
+ __FILE__, __LINE__, (msg), (unsigned long long)(got), \
+ (unsigned long long)(want)); \
+ } \
+ } while (0)
+
+/* ------------------------------------------------------------------ */
+/* Mock model */
+/* ------------------------------------------------------------------ */
+
+typedef struct mock_model {
+ llsh_model m;
+ /* stream */
+ int results[8];
+ size_t nresults;
+ size_t calls;
+ char *assistant;
+ unsigned long input_tokens;
+ int http_status;
+ int transport;
+ unsigned long retry_after;
+ char *errmsg;
+ /* complete */
+ char *reply;
+ int complete_fail;
+ char *complete_errmsg;
+ size_t complete_calls;
+} mock_model;
+
+static char *mock_complete(const llsh_model *mm, const char *prompt,
+ sess_error *err) {
+ (void)prompt;
+ mock_model *m = (mock_model *)mm;
+ m->complete_calls++;
+ if (m->complete_fail) {
+ if (err) {
+ err->message = xstrdup(m->complete_errmsg
+ ? m->complete_errmsg
+ : "boom");
+ err->http_status = 500;
+ err->transport = 0;
+ err->retry_after_secs = 0;
+ }
+ return NULL;
+ }
+ return xstrdup(m->reply ? m->reply : "");
+}
+
+static int mock_stream(const llsh_model *mm, const message *history,
+ size_t nhistory, const char *prompt, sess_emit_cb cb,
+ void *user, std::atomic<bool> *stop,
+ char **assistant_text, unsigned long *input_tokens,
+ message **new_messages, size_t *n_new, sess_error *err) {
+ (void)history;
+ (void)nhistory;
+ (void)prompt;
+ (void)stop;
+ mock_model *m = (mock_model *)mm;
+ size_t idx = m->calls < m->nresults ? m->calls : m->nresults - 1;
+ int result = m->results[idx];
+ m->calls++;
+ if (new_messages)
+ *new_messages = NULL;
+ if (n_new)
+ *n_new = 0;
+ if (result == SESS_STREAM_OK || result == SESS_STREAM_STOPPED) {
+ if (result == SESS_STREAM_OK) {
+ if (cb) {
+ sess_emit ev;
+ memset(&ev, 0, sizeof ev);
+ ev.kind = SESS_EMIT_TEXT;
+ ev.text = xstrdup(m->assistant ? m->assistant : "");
+ cb(&ev, user);
+ sess_emit_free(&ev);
+ }
+ if (assistant_text)
+ *assistant_text = xstrdup(m->assistant ? m->assistant : "");
+ if (input_tokens)
+ *input_tokens = m->input_tokens;
+ }
+ return result;
+ }
+ if (err) {
+ err->message = xstrdup(m->errmsg ? m->errmsg : "provider said no");
+ err->http_status = m->http_status;
+ err->transport = m->transport;
+ err->retry_after_secs = m->retry_after;
+ }
+ return result;
+}
+
+static void mock_destroy(llsh_model *self);
+
+static mock_model *mk_model(void) {
+ mock_model *m = (mock_model* ) xmalloc(sizeof *m);
+ memset(m, 0, sizeof *m);
+ m->m.complete = mock_complete;
+ m->m.stream = mock_stream;
+ m->m.destroy = mock_destroy;
+ return m;
+}
+
+static void mock_set_stream(mock_model *m, const int *results, size_t n,
+ const char *assistant, unsigned long input_tokens) {
+ size_t k = n < 8 ? n : 8;
+ for (size_t i = 0; i < k; i++)
+ m->results[i] = results[i];
+ m->nresults = k ? k : 1;
+ if (k == 0)
+ m->results[0] = SESS_STREAM_OK;
+ free(m->assistant);
+ m->assistant = xstrdup(assistant ? assistant : "");
+ m->input_tokens = input_tokens;
+}
+
+static mock_model *mk_complete(const char *reply, int fail,
+ const char *errmsg) {
+ mock_model *m = mk_model();
+ m->reply = xstrdup(reply ? reply : "");
+ m->complete_fail = fail;
+ m->complete_errmsg = xstrdup(errmsg ? errmsg : "boom");
+ return m;
+}
+
+static void mock_destroy(llsh_model *self) {
+ mock_model *m = (mock_model *)self;
+ free(m->assistant);
+ free(m->errmsg);
+ free(m->reply);
+ free(m->complete_errmsg);
+ free(m);
+}
+
+/* ------------------------------------------------------------------ */
+/* Fixtures */
+/* ------------------------------------------------------------------ */
+
+static config base_config(void) {
+ config c;
+ memset(&c, 0, sizeof c);
+ c.provider = PROV_OPENAI;
+ c.model = xstrdup("gpt-test");
+ c.api_key = xstrdup("sk-test");
+ c.max_retries = 3;
+ c.context_limit = 0; /* compaction off by default */
+ return c;
+}
+
+static dotagents empty_dot(void) {
+ dotagents d;
+ memset(&d, 0, sizeof d);
+ return d;
+}
+
+static dotagents dot_with_reviewer(void) {
+ dotagents d = empty_dot();
+ d.sub_agent_count = 1;
+ d.sub_agents = (sub_agent* ) xmalloc(sizeof(sub_agent));
+ memset(&d.sub_agents[0], 0, sizeof(sub_agent));
+ d.sub_agents[0].id = xstrdup("reviewer");
+ d.sub_agents[0].name = xstrdup("Reviewer");
+ d.sub_agents[0].description = xstrdup("Reviews code");
+ d.sub_agents[0].role = xstrdup("reviewer");
+ d.sub_agents[0].enabled = true;
+ d.sub_agents[0].body = xstrdup("You are a reviewer.");
+ d.sub_agents[0].layer = LAYER_WORKSPACE;
+ d.sub_agents[0].config = NULL;
+ return d;
+}
+
+static dotagents dot_with_profile_config(void) {
+ dotagents d = empty_dot();
+ d.sub_agent_count = 1;
+ d.sub_agents = (sub_agent* ) xmalloc(sizeof(sub_agent));
+ memset(&d.sub_agents[0], 0, sizeof(sub_agent));
+ d.sub_agents[0].id = xstrdup("rev");
+ d.sub_agents[0].name = xstrdup("Rev");
+ d.sub_agents[0].description = xstrdup("");
+ d.sub_agents[0].role = xstrdup("rev");
+ d.sub_agents[0].enabled = true;
+ d.sub_agents[0].body = xstrdup("You are a reviewer.");
+ d.sub_agents[0].layer = LAYER_WORKSPACE;
+ d.sub_agents[0].config = xstrdup(
+ "{\"model\":\"m-profile\",\"provider\":\"anthropic\",\"api_key\":\"pk\"}");
+ return d;
+}
+
+/* A profile that overrides the model/provider AND restricts the allowed
+ * built-in tools via its config JSON. */
+static dotagents dot_with_profile_tools(void) {
+ dotagents d = empty_dot();
+ d.sub_agent_count = 1;
+ d.sub_agents = (sub_agent* ) xmalloc(sizeof(sub_agent));
+ memset(&d.sub_agents[0], 0, sizeof(sub_agent));
+ d.sub_agents[0].id = xstrdup("rev");
+ d.sub_agents[0].name = xstrdup("Rev");
+ d.sub_agents[0].description = xstrdup("");
+ d.sub_agents[0].role = xstrdup("rev");
+ d.sub_agents[0].enabled = true;
+ d.sub_agents[0].body = xstrdup("You are a reviewer.");
+ d.sub_agents[0].layer = LAYER_WORKSPACE;
+ d.sub_agents[0].config = xstrdup(
+ "{\"model\":\"m-profile\",\"provider\":\"anthropic\",\"api_key\":\"pk\","
+ "\"tools\":[\"read_file\",\"grep\"]}");
+ return d;
+}
+
+static dotagents dot_with_presets(void) {
+ dotagents d = empty_dot();
+ d.has_models = true;
+ d.models.preset_count = 2;
+ d.models.presets = (model_preset* ) xmalloc(2 * sizeof(model_preset));
+ d.models.presets[0].name = xstrdup("fast");
+ d.models.presets[0].provider = xstrdup("openai");
+ d.models.presets[0].model = xstrdup("m-fast");
+ d.models.presets[0].base_url = xstrdup("https://fast.example");
+ d.models.presets[0].api_key = xstrdup("kfast");
+ d.models.presets[0].has_max_turns = false;
+ d.models.presets[0].has_context_limit = false;
+ d.models.presets[1].name = xstrdup("badprov");
+ d.models.presets[1].provider = xstrdup("wibbly");
+ d.models.presets[1].model = xstrdup("m-bad");
+ d.models.presets[1].base_url = xstrdup("https://x");
+ d.models.presets[1].api_key = xstrdup("kb");
+ d.models.presets[1].has_max_turns = false;
+ d.models.presets[1].has_context_limit = false;
+ return d;
+}
+
+/* Text of the first text-bearing part of a user/assistant message (or NULL). */
+static const char *first_text(const message *m) {
+ if (!m)
+ return NULL;
+ if (m->is_user() && !m->user().parts.empty()) {
+ const user_part &p = m->user().parts[0];
+ if (const user_text_part *t = std::get_if<user_text_part>(&p))
+ return t->text.c_str();
+ if (const user_tool_result_part *r =
+ std::get_if<user_tool_result_part>(&p))
+ if (!r->content.empty())
+ if (const trc_text *tt = std::get_if<trc_text>(&r->content[0]))
+ return tt->text.c_str();
+ } else if (m->is_assistant() && !m->assistant().parts.empty()) {
+ const assistant_part &p = m->assistant().parts[0];
+ if (const asst_text_part *t = std::get_if<asst_text_part>(&p))
+ return t->text.c_str();
+ if (const asst_reasoning_part *r = std::get_if<asst_reasoning_part>(&p))
+ return r->text.c_str();
+ }
+ return NULL;
+}
+
+/* A history with a stale tool result before a pure-text user boundary.
+ * layout: [user"first", user tool_result, user"now", assistant"b"] */
+static void push_compact_hist(session *s) {
+ session_push_history(s, msg_user_text("first"));
+ char *big = (char* ) xmalloc(600 + 1);
+ memset(big, 'x', 600);
+ big[600] = '\0';
+ message tr = msg_user_tool_result("c1", "read_file", big);
+ free(big);
+ session_push_history(s, tr);
+ session_push_history(s, msg_user_text("now"));
+ session_push_history(s, msg_assistant_text("b"));
+}
+
+/* ------------------------------------------------------------------ */
+/* Pure helpers */
+/* ------------------------------------------------------------------ */
+
+static void test_is_transient_status(void) {
+ CHECK(sess_is_transient_status(408), "408 transient");
+ CHECK(sess_is_transient_status(429), "429 transient");
+ CHECK(sess_is_transient_status(500), "500 transient");
+ CHECK(sess_is_transient_status(502), "502 transient");
+ CHECK(sess_is_transient_status(503), "503 transient");
+ CHECK(sess_is_transient_status(504), "504 transient");
+ CHECK(sess_is_transient_status(529), "529 transient");
+ CHECK(!sess_is_transient_status(400), "400 not transient");
+ CHECK(!sess_is_transient_status(401), "401 not transient");
+ CHECK(!sess_is_transient_status(403), "403 not transient");
+ CHECK(!sess_is_transient_status(404), "404 not transient");
+ CHECK(!sess_is_transient_status(418), "418 not transient");
+ CHECK(!sess_is_transient_status(0), "0 not transient");
+}
+
+static void test_backoff_secs(void) {
+ CHECK_EQ_U64(sess_backoff_secs(1), 1, "attempt 1 -> 1s");
+ CHECK_EQ_U64(sess_backoff_secs(2), 2, "attempt 2 -> 2s");
+ CHECK_EQ_U64(sess_backoff_secs(3), 4, "attempt 3 -> 4s");
+ CHECK_EQ_U64(sess_backoff_secs(4), 8, "attempt 4 -> 8s");
+ CHECK_EQ_U64(sess_backoff_secs(5), 16, "attempt 5 -> 16s");
+ CHECK_EQ_U64(sess_backoff_secs(6), 30, "attempt 6 -> 30s cap");
+ CHECK_EQ_U64(sess_backoff_secs(7), 30, "attempt 7 -> 30s cap");
+ CHECK_EQ_U64(sess_backoff_secs(10), 30, "attempt 10 -> 30s cap");
+}
+
+static void test_human_secs(void) {
+ CHECK_EQ_U64(sess_human_secs(0), 1, "0 -> 1");
+ CHECK_EQ_U64(sess_human_secs(1), 1, "1 -> 1");
+ CHECK_EQ_U64(sess_human_secs(5), 5, "5 -> 5");
+}
+
+static void test_classify_retry(void) {
+ sess_error e;
+
+ CHECK(sess_classify_retry(NULL) == NULL, "NULL -> no retry");
+
+ e.message = xstrdup("boom");
+ e.http_status = 429;
+ e.transport = 0;
+ e.retry_after_secs = 120;
+ sess_retry *r = sess_classify_retry(&e);
+ CHECK(r != NULL, "429 -> retryable");
+ CHECK_CONT(r->reason, "HTTP 429", "reason names the status");
+ CHECK_EQ_U64(r->retry_after_secs, 60, "retry-after capped at 60");
+ CHECK(r->has_retry_after, "retry-after present");
+ sess_retry_free(r);
+ free(e.message);
+
+ e.message = xstrdup("nope");
+ e.http_status = 400;
+ e.transport = 0;
+ e.retry_after_secs = 0;
+ CHECK(sess_classify_retry(&e) == NULL, "400 -> permanent");
+ free(e.message);
+
+ e.message = xstrdup("conn reset");
+ e.http_status = 0;
+ e.transport = 1;
+ e.retry_after_secs = 0;
+ sess_retry *r2 = sess_classify_retry(&e);
+ CHECK(r2 != NULL, "transport -> retryable");
+ CHECK_CONT(r2->reason, "transient connection error", "transport reason");
+ CHECK(!r2->has_retry_after, "transport has no retry-after");
+ sess_retry_free(r2);
+ free(e.message);
+
+ e.message = xstrdup("plain");
+ e.http_status = 0;
+ e.transport = 0;
+ e.retry_after_secs = 0;
+ CHECK(sess_classify_retry(&e) == NULL, "no status/transport -> no retry");
+ free(e.message);
+}
+
+static void test_context_overflow_detection(void) {
+ sess_error e;
+
+ e.message = xstrdup(
+ "request (143063 tokens) exceeds the available context size (131072 "
+ "tokens), try increasing it");
+ e.http_status = 400;
+ e.transport = 0;
+ CHECK(sess_error_is_context_overflow(&e),
+ "400 + `exceeds the available context size` -> overflow");
+ free(e.message);
+
+ e.message = xstrdup("maximum context length of 131072 tokens exceeded");
+ e.http_status = 400;
+ e.transport = 0;
+ CHECK(sess_error_is_context_overflow(&e),
+ "400 + `maximum context length` -> overflow");
+ free(e.message);
+
+ e.message = xstrdup("Request entity too large");
+ e.http_status = 413;
+ e.transport = 0;
+ CHECK(sess_error_is_context_overflow(&e), "413 -> overflow");
+ free(e.message);
+
+ e.message = xstrdup("bad request");
+ e.http_status = 400;
+ e.transport = 0;
+ CHECK(!sess_error_is_context_overflow(&e), "400 without hint -> not");
+ free(e.message);
+
+ e.message = xstrdup("exceeded budget");
+ e.http_status = 500;
+ e.transport = 0;
+ CHECK(!sess_error_is_context_overflow(&e), "5xx -> not overflow");
+ free(e.message);
+
+ e.message = xstrdup("connection reset");
+ e.http_status = 0;
+ e.transport = 1;
+ CHECK(!sess_error_is_context_overflow(&e), "transport -> not overflow");
+ free(e.message);
+
+ e.message = NULL;
+ e.http_status = 400;
+ e.transport = 0;
+ CHECK(!sess_error_is_context_overflow(&e), "no message -> not overflow");
+}
+
+static void test_assistant_emit(void) {
+ sess_emit ev;
+
+ CHECK(sess_assistant_emit("text", "hello", NULL, &ev) == 0, "text ok");
+ CHECK(ev.kind == SESS_EMIT_TEXT, "text kind");
+ CHECK(ev.text && strcmp(ev.text, "hello") == 0, "text body");
+ sess_emit_free(&ev);
+
+ CHECK(sess_assistant_emit("reasoning", "hmm", NULL, &ev) == 0,
+ "reasoning ok");
+ CHECK(ev.kind == SESS_EMIT_REASONING, "reasoning kind");
+ CHECK(ev.text && strcmp(ev.text, "hmm") == 0, "reasoning body");
+ sess_emit_free(&ev);
+
+ CHECK(sess_assistant_emit("tool_call", "run_shell",
+ "{\"command\":\"ls\"}", &ev) == 0,
+ "tool_call ok");
+ CHECK(ev.kind == SESS_EMIT_VERBATIM, "tool_call -> verbatim display");
+ CHECK_CONT(ev.text, "$ ", "run_shell renders as a shell line");
+ CHECK_CONT(ev.text, "ls", "shell line carries the command");
+ CHECK_NOT_CONT(ev.text, "# run_shell",
+ "no generic # name line for shell tools");
+ CHECK_NOT_CONT(ev.text, "command", "raw JSON key does not leak");
+ sess_emit_free(&ev);
+
+ CHECK(sess_assistant_emit("tool_call", "run_shell",
+ "{\"command\":\"\"}", &ev) == 0,
+ "run_shell empty command ok");
+ CHECK(ev.kind == SESS_EMIT_VERBATIM, "empty command -> fallback display");
+ CHECK_CONT(ev.text, "# run_shell", "fallback names the tool");
+ sess_emit_free(&ev);
+
+ CHECK(sess_assistant_emit("tool_call", "frobnicate", "{}",
+ &ev) == 0,
+ "tool_call unknown ok");
+ CHECK(ev.kind == SESS_EMIT_VERBATIM, "tool_call unknown -> verbatim");
+ CHECK_CONT(ev.text, "frobnicate", "verbatim names the tool");
+ sess_emit_free(&ev);
+
+ CHECK(sess_assistant_emit("blob", "x", NULL, &ev) == 1,
+ "unknown kind -> no emit");
+}
+
+static void test_tool_result_emit(void) {
+ sess_emit ev;
+
+ CHECK(sess_tool_result_emit("run_shell", NULL, &ev) == 1, "NULL -> no emit");
+ CHECK(sess_tool_result_emit("run_shell", "", &ev) == 1, "empty -> no emit");
+ CHECK(sess_tool_result_emit("run_shell", "ls", NULL) == 1,
+ "NULL out -> no emit");
+
+ CHECK(sess_tool_result_emit("read_file", "hello", &ev) == 0, "plain ok");
+ CHECK(ev.kind == SESS_EMIT_RESULT, "result kind");
+ CHECK(ev.from_shell, "read_file result marked shell-style");
+ CHECK(ev.text && strcmp(ev.text, "hello\n") == 0,
+ "body normalized to one trailing newline");
+ sess_emit_free(&ev);
+
+ CHECK(sess_tool_result_emit("run_shell", "a\nb\n\n\n", &ev) == 0,
+ "multiline ok");
+ CHECK(ev.from_shell, "run_shell result marked shell-style");
+ CHECK(ev.text && strcmp(ev.text, "a\nb\n") == 0,
+ "extra trailing newlines collapsed");
+ sess_emit_free(&ev);
+
+ CHECK(sess_tool_result_emit("search_memories", "hit", &ev) == 0,
+ "non-shell tool ok");
+ CHECK(!ev.from_shell, "search_memories not shell-style");
+ sess_emit_free(&ev);
+
+ CHECK(sess_tool_result_emit("run_shell", "out\n[stderr]\nboom\n[exit code 1]", &ev) == 0,
+ "shell-shaped ok");
+ CHECK(ev.from_shell, "run_shell result marked shell-style");
+ CHECK(ev.text &&
+ strcmp(ev.text, "out\n[stderr]\nboom\n[exit code 1]\n") == 0,
+ "body preserved verbatim");
+ sess_emit_free(&ev);
+}
+
+/* ------------------------------------------------------------------ */
+/* Preamble / preset / profile helpers */
+/* ------------------------------------------------------------------ */
+
+static void test_active_preset_from(void) {
+ dotagents d = empty_dot();
+ d.has_models = true;
+ d.models.default_name = xstrdup("fast");
+ char *p = session_active_preset_from(&d);
+ CHECK(p && strcmp(p, "fast") == 0, "default preset returned");
+ free(p);
+ dotagents_free(&d);
+
+ dotagents e = empty_dot();
+ CHECK(session_active_preset_from(&e) == NULL, "no models -> NULL");
+ dotagents_free(&e);
+}
+
+static void test_base_preamble_for(void) {
+ config c = base_config();
+ c.preamble = xstrdup("BASE PREAMBLE");
+
+ dotagents d = empty_dot();
+ d.system_prompt = xstrdup("SYS OVERRIDE");
+ char *r = sess_base_preamble_for(&c, &d);
+ CHECK_CONT(r, "SYS OVERRIDE", "system-prompt overrides base");
+ CHECK_NOT_CONT(r, "BASE PREAMBLE", "base not used when overridden");
+ free(r);
+ dotagents_free(&d);
+
+ dotagents e = empty_dot();
+ char *r2 = sess_base_preamble_for(&c, &e);
+ CHECK_CONT(r2, "BASE PREAMBLE", "config preamble used when no override");
+ free(r2);
+ dotagents_free(&e);
+
+ config_free(&c);
+}
+
+static void test_preamble_for_profile(void) {
+ config c = base_config();
+ c.preamble = xstrdup("BASE PREAMBLE");
+
+ dotagents d = dot_with_reviewer();
+ char *r = sess_preamble_for_profile(&d, &c, "reviewer");
+ CHECK_CONT(r, "You are a reviewer.", "profile body used");
+ free(r);
+
+ char *r2 = sess_preamble_for_profile(&d, &c, NULL);
+ CHECK_CONT(r2, "BASE PREAMBLE", "NULL profile -> base preamble");
+ free(r2);
+
+ dotagents_free(&d);
+ config_free(&c);
+}
+
+static void test_apply_overrides(void) {
+ config c; memset(&c, 0, sizeof c);
+ c.provider = PROV_OPENAI;
+ c.model = xstrdup("old");
+ char *err = NULL;
+ CHECK(sess_apply_overrides(&c, "m2", "anthropic", "https://bu", "key2",
+ true, &err) == 0,
+ "apply ok");
+ CHECK(c.model && strcmp(c.model, "m2") == 0, "model applied");
+ CHECK(c.provider == PROV_ANTHROPIC, "provider applied");
+ CHECK(c.base_url && strcmp(c.base_url, "https://bu") == 0, "base_url applied");
+ CHECK(c.api_key && strcmp(c.api_key, "key2") == 0, "api_key applied");
+ config_free(&c);
+
+ config c2; memset(&c2, 0, sizeof c2);
+ c2.provider = PROV_OPENAI;
+ err = NULL;
+ CHECK(sess_apply_overrides(&c2, "m", "wibbly", NULL, NULL, true,
+ &err) == -1,
+ "strict bad provider -> error");
+ CHECK(err && strstr(err, "unknown provider") != NULL, "strict err msg");
+ free(err);
+ config_free(&c2);
+
+ config c3; memset(&c3, 0, sizeof c3);
+ c3.provider = PROV_OPENAI;
+ c3.model = xstrdup("keep");
+ err = NULL;
+ CHECK(sess_apply_overrides(&c3, "m", "wibbly", NULL, NULL, false,
+ &err) == 0,
+ "non-strict bad provider -> skipped");
+ CHECK(c3.model && strcmp(c3.model, "m") == 0, "model still applied");
+ CHECK(c3.provider == PROV_OPENAI, "provider unchanged (skipped)");
+ config_free(&c3);
+}
+
+static void test_effective_for_profile(void) {
+ config base = base_config();
+ dotagents d = dot_with_profile_config();
+
+ config eff = sess_effective_for_profile(&base, &d, "rev");
+ CHECK(eff.model && strcmp(eff.model, "m-profile") == 0, "profile model");
+ CHECK(eff.provider == PROV_ANTHROPIC, "profile provider");
+ CHECK(eff.api_key && strcmp(eff.api_key, "pk") == 0, "profile api_key");
+ config_free(&eff);
+
+ config plain = sess_effective_for_profile(&base, &d, NULL);
+ CHECK(plain.model && strcmp(plain.model, "gpt-test") == 0,
+ "no profile -> base model");
+ CHECK(plain.provider == PROV_OPENAI, "no profile -> base provider");
+ CHECK(plain.api_key && strcmp(plain.api_key, "sk-test") == 0,
+ "no profile -> base api_key");
+ config_free(&plain);
+
+ dotagents_free(&d);
+ config_free(&base);
+}
+
+/* ------------------------------------------------------------------ */
+/* Turn */
+/* ------------------------------------------------------------------ */
+
+static void test_stop_before_start(void) {
+ config c = base_config();
+ dotagents d = empty_dot();
+ mock_model *m = mk_model();
+ int ok_results[] = {SESS_STREAM_OK};
+ mock_set_stream(m, ok_results, 1, "unused", 0);
+
+ session *s = session_new_with_model(&m->m, c, d);
+ std::atomic<bool> stop(true);
+ int result = -1;
+ char *err = NULL;
+ CHECK(session_run_turn(s, "hi", &stop, NULL, NULL, &err, &result) == 0,
+ "stopped turn returns 0");
+ CHECK(result == SESS_TURN_STOPPED, "result is Stopped");
+ CHECK_EQ_U64(session_history_len(s), 1, "prompt kept in context");
+ const message *u = session_history_at(s, 0);
+ CHECK(u && u->is_user() && u->user().parts.size() == 1 &&
+ std::get<user_text_part>(u->user().parts[0]).text == "hi",
+ "kept prompt is the user text");
+ CHECK(m->calls == 0, "model not called when stopped");
+ session_free(s);
+}
+
+static void test_completed_turn_updates_history(void) {
+ config c = base_config();
+ dotagents d = empty_dot();
+ mock_model *m = mk_model();
+ int ok_results[] = {SESS_STREAM_OK};
+ mock_set_stream(m, ok_results, 1, "hello there", 42);
+
+ session *s = session_new_with_model(&m->m, c, d);
+ std::atomic<bool> stop(false);
+ int result = -1;
+ char *err = NULL;
+ CHECK(session_run_turn(s, "hi", &stop, NULL, NULL, &err, &result) == 0,
+ "completed turn returns 0");
+ CHECK(result == SESS_TURN_COMPLETED, "result is Completed");
+ CHECK_EQ_U64(session_history_len(s), 2, "user + assistant recorded");
+ CHECK_EQ_U64(session_last_context_tokens(s), 42, "provider usage recorded");
+ const message *a = session_history_at(s, 1);
+ CHECK(a && a->is_assistant() && a->assistant().parts.size() == 1 &&
+ std::get<asst_text_part>(a->assistant().parts[0]).text ==
+ "hello there",
+ "assistant text recorded");
+ char *last = session_last_assistant_text(s);
+ CHECK(last && strcmp(last, "hello there") == 0, "last assistant text");
+ free(last);
+ session_free(s);
+}
+
+static void test_retry_then_success(void) {
+ config c = base_config();
+ c.max_retries = 3;
+ dotagents d = empty_dot();
+ mock_model *m = mk_model();
+ int seq[] = {SESS_STREAM_RETRYABLE, SESS_STREAM_OK};
+ mock_set_stream(m, seq, 2, "recovered", 10);
+ m->http_status = 429;
+ m->errmsg = xstrdup("rate limited");
+
+ session *s = session_new_with_model(&m->m, c, d);
+ std::atomic<bool> stop(false);
+ int result = -1;
+ char *err = NULL;
+ CHECK(session_run_turn(s, "hi", &stop, NULL, NULL, &err, &result) == 0,
+ "retry then success returns 0");
+ CHECK(result == SESS_TURN_COMPLETED, "result is Completed");
+ CHECK(m->calls == 2, "model called twice");
+ CHECK_EQ_U64(session_history_len(s), 2, "history updated after success");
+ CHECK(err == NULL, "no error surfaced");
+ session_free(s);
+}
+
+static void test_gives_up_after_retries(void) {
+ config c = base_config();
+ c.max_retries = 2;
+ dotagents d = empty_dot();
+ mock_model *m = mk_model();
+ int seq[] = {SESS_STREAM_RETRYABLE};
+ mock_set_stream(m, seq, 1, "never", 0);
+ m->http_status = 429;
+ m->errmsg = xstrdup("rate limited");
+
+ session *s = session_new_with_model(&m->m, c, d);
+ std::atomic<bool> stop(false);
+ int result = -1;
+ char *err = NULL;
+ CHECK(session_run_turn(s, "hi", &stop, NULL, NULL, &err, &result) == -1,
+ "exhausted retries returns -1");
+ CHECK(m->calls == 3, "2 retries + 1 initial = 3 calls");
+ CHECK(err && strstr(err, "provider error after 2 retries") != NULL,
+ "error names retry count");
+ CHECK(err && strstr(err, "rate limited") != NULL, "error carries cause");
+ free(err);
+ CHECK_EQ_U64(session_history_len(s), 0, "failed turn not committed");
+ session_free(s);
+}
+
+static void test_no_retry_on_permanent(void) {
+ config c = base_config();
+ c.max_retries = 3;
+ dotagents d = empty_dot();
+ mock_model *m = mk_model();
+ int seq[] = {SESS_STREAM_FAILED};
+ mock_set_stream(m, seq, 1, "never", 0);
+ m->http_status = 400;
+ m->errmsg = xstrdup("bad request");
+
+ session *s = session_new_with_model(&m->m, c, d);
+ std::atomic<bool> stop(false);
+ int result = -1;
+ char *err = NULL;
+ CHECK(session_run_turn(s, "hi", &stop, NULL, NULL, &err, &result) == -1,
+ "permanent failure returns -1");
+ CHECK(m->calls == 1, "no retries for permanent failure");
+ CHECK(err && strstr(err, "stream error") != NULL, "stream error message");
+ CHECK(err && strstr(err, "bad request") != NULL, "error carries cause");
+ free(err);
+ CHECK_EQ_U64(session_history_len(s), 0, "failed turn not committed");
+ session_free(s);
+}
+
+static void test_retry_disabled(void) {
+ config c = base_config();
+ c.max_retries = 0;
+ dotagents d = empty_dot();
+ mock_model *m = mk_model();
+ int seq[] = {SESS_STREAM_RETRYABLE};
+ mock_set_stream(m, seq, 1, "never", 0);
+ m->http_status = 429;
+ m->errmsg = xstrdup("rate limited");
+
+ session *s = session_new_with_model(&m->m, c, d);
+ std::atomic<bool> stop(false);
+ int result = -1;
+ char *err = NULL;
+ CHECK(session_run_turn(s, "hi", &stop, NULL, NULL, &err, &result) == -1,
+ "retry disabled returns -1");
+ CHECK(m->calls == 1, "only one attempt when retries disabled");
+ CHECK(err && strstr(err, "provider error after 0 retries") != NULL,
+ "error names 0 retries");
+ free(err);
+ session_free(s);
+}
+
+static void test_overflow_recover_by_compacting(void) {
+ config c = base_config();
+ c.context_limit = 1000;
+ c.compact_threshold = 0.5; /* budget 500 */
+ c.clear_threshold = 0.1;
+ dotagents d = empty_dot();
+ mock_model *main = mk_model();
+ mock_model *sum = mk_complete("EARLIER SUMMARY", 0, NULL);
+ session *s = session_new_with_model(&main->m, c, d);
+ s = session_with_summarizer(s, &sum->m);
+
+ push_compact_hist(s);
+ session_set_last_context_tokens(s, 143063);
+ int seq[] = {SESS_STREAM_FAILED, SESS_STREAM_OK};
+ mock_set_stream(main, seq, 2, "recovered", 42);
+ main->http_status = 400;
+ main->errmsg = xstrdup(
+ "request (143063 tokens) exceeds the available context size (131072 "
+ "tokens), try increasing it");
+
+ std::atomic<bool> stop(false);
+ int result = -1;
+ char *err = NULL;
+ CHECK(session_run_turn(s, "go", &stop, NULL, NULL, &err, &result) == 0,
+ "overflow -> compact -> retry succeeds");
+ CHECK(result == SESS_TURN_COMPLETED, "turn completed after recovery");
+ CHECK(main->calls == 2, "model called twice (fail + retry)");
+ CHECK_EQ_U64(sum->complete_calls, 1, "summarizer compacted the prefix");
+ CHECK(err == NULL, "no error surfaced");
+ CHECK_EQ_U64(session_history_len(s), 5, "summary + kept tail + new turn");
+ const message *sum0 = session_history_at(s, 0);
+ CHECK(sum0 && sum0->is_user(),
+ "first message is the summary user message");
+ CHECK_CONT(first_text(sum0), "EARLIER SUMMARY", "summary body present");
+ session_free(s);
+}
+
+static void test_overflow_fallback_clear_still_recovers(void) {
+ config c = base_config();
+ c.context_limit = 1000;
+ c.compact_threshold = 0.5;
+ c.clear_threshold = 0.1;
+ dotagents d = empty_dot();
+ mock_model *main = mk_model();
+ mock_model *sum = mk_complete(NULL, 1, "summarizer down");
+ session *s = session_new_with_model(&main->m, c, d);
+ s = session_with_summarizer(s, &sum->m);
+
+ push_compact_hist(s);
+ session_set_last_context_tokens(s, 143063);
+ int seq[] = {SESS_STREAM_FAILED, SESS_STREAM_OK};
+ mock_set_stream(main, seq, 2, "recovered", 42);
+ main->http_status = 400;
+ main->errmsg = xstrdup("context window exceeded");
+
+ std::atomic<bool> stop(false);
+ int result = -1;
+ char *err = NULL;
+ CHECK(session_run_turn(s, "go", &stop, NULL, NULL, &err, &result) == 0,
+ "overflow -> fallback clear -> retry succeeds");
+ CHECK(result == SESS_TURN_COMPLETED, "turn completed");
+ CHECK_EQ_U64(sum->complete_calls, 1, "summarizer attempted");
+ CHECK(err == NULL, "no error surfaced");
+ CHECK_EQ_U64(session_history_len(s), 6,
+ "stale cleared in place, kept tail + new turn retained");
+ const message *tr = session_history_at(s, 1);
+ CHECK(tr && tr->is_user() && first_text(tr) &&
+ strstr(first_text(tr), "tool output cleared") != NULL,
+ "stale result cleared by the fallback");
+ session_free(s);
+}
+
+static void test_overflow_retries_only_once(void) {
+ config c = base_config();
+ c.context_limit = 1000;
+ c.compact_threshold = 0.5;
+ c.clear_threshold = 0.1;
+ dotagents d = empty_dot();
+ mock_model *main = mk_model();
+ mock_model *sum = mk_complete("S", 0, NULL);
+ session *s = session_new_with_model(&main->m, c, d);
+ s = session_with_summarizer(s, &sum->m);
+ push_compact_hist(s);
+ session_set_last_context_tokens(s, 143063);
+ int seq[] = {SESS_STREAM_FAILED};
+ mock_set_stream(main, seq, 1, "never", 0);
+ main->http_status = 400;
+ main->errmsg = xstrdup("context window exceeded");
+
+ std::atomic<bool> stop(false);
+ int result = -1;
+ char *err = NULL;
+ CHECK(session_run_turn(s, "go", &stop, NULL, NULL, &err, &result) == -1,
+ "still overflowing after compact -> turn fails");
+ CHECK(main->calls == 2, "exactly one compact-and-retry");
+ CHECK(err && strstr(err, "stream error") != NULL, "error surfaced");
+ CHECK(err && strstr(err, "context window exceeded") != NULL,
+ "provider message preserved");
+ free(err);
+ session_free(s);
+}
+
+static void test_overflow_no_recovery_without_limit(void) {
+ config c = base_config();
+ c.context_limit = 0; /* compaction disabled */
+ dotagents d = empty_dot();
+ mock_model *m = mk_model();
+ int seq[] = {SESS_STREAM_FAILED};
+ mock_set_stream(m, seq, 1, "never", 0);
+ m->http_status = 400;
+ m->errmsg = xstrdup("context window exceeded");
+
+ session *s = session_new_with_model(&m->m, c, d);
+ push_compact_hist(s);
+ session_set_last_context_tokens(s, 143063);
+
+ std::atomic<bool> stop(false);
+ int result = -1;
+ char *err = NULL;
+ CHECK(session_run_turn(s, "go", &stop, NULL, NULL, &err, &result) == -1,
+ "no limit -> overflow surfaces as stream error");
+ CHECK(m->calls == 1, "no recovery attempt without a context limit");
+ CHECK(err && strstr(err, "context window exceeded") != NULL,
+ "provider message preserved");
+ free(err);
+ CHECK_EQ_U64(session_history_len(s), 4, "history untouched");
+ session_free(s);
+}
+
+static void test_overflow_no_stale_prefix_no_recovery(void) {
+ config c = base_config();
+ c.context_limit = 1000;
+ c.compact_threshold = 0.5;
+ c.clear_threshold = 0.1;
+ dotagents d = empty_dot();
+ mock_model *main = mk_model();
+ mock_model *sum = mk_complete("S", 0, NULL);
+ session *s = session_new_with_model(&main->m, c, d);
+ s = session_with_summarizer(s, &sum->m);
+ session_push_history(s, msg_user_text("only prompt"));
+ int seq[] = {SESS_STREAM_FAILED};
+ mock_set_stream(main, seq, 1, "never", 0);
+ main->http_status = 400;
+ main->errmsg = xstrdup("context window exceeded");
+
+ std::atomic<bool> stop(false);
+ int result = -1;
+ char *err = NULL;
+ CHECK(session_run_turn(s, "go", &stop, NULL, NULL, &err, &result) == -1,
+ "nothing to compact -> turn fails");
+ CHECK(main->calls == 2, "compact pass ran, retry still failed");
+ CHECK_EQ_U64(sum->complete_calls, 0,
+ "no stale prefix -> summarizer not called");
+ CHECK(err && strstr(err, "context window exceeded") != NULL,
+ "provider message preserved");
+ free(err);
+ session_free(s);
+}
+
+static void test_records_usage(void) {
+ config c = base_config();
+ dotagents d = empty_dot();
+ mock_model *m = mk_model();
+ int seq[] = {SESS_STREAM_OK};
+ mock_set_stream(m, seq, 1, "ok", 1234);
+
+ session *s = session_new_with_model(&m->m, c, d);
+ std::atomic<bool> stop(false);
+ int result = -1;
+ char *err = NULL;
+ CHECK(session_run_turn(s, "hi", &stop, NULL, NULL, &err, &result) == 0,
+ "turn ok");
+ CHECK_EQ_U64(session_last_context_tokens(s), 1234, "usage recorded");
+
+ /* zero input tokens should not clobber */
+ session_clear_history(s);
+ session_set_last_context_tokens(s, 0);
+ int seq0[] = {SESS_STREAM_OK};
+ mock_set_stream(m, seq0, 1, "ok", 0);
+ CHECK(session_run_turn(s, "again", &stop, NULL, NULL, &err, &result) == 0,
+ "turn 2 ok");
+ CHECK_EQ_U64(session_last_context_tokens(s), 0, "zero tokens -> stays 0");
+ session_free(s);
+}
+
+/* ------------------------------------------------------------------ */
+/* Model / profile */
+/* ------------------------------------------------------------------ */
+
+static void test_set_profile(void) {
+ config c = base_config();
+ dotagents d = dot_with_reviewer();
+ mock_model *m = mk_model();
+ session *s = session_new_with_model(&m->m, c, d);
+ char *err = NULL;
+
+ CHECK(session_set_profile(s, "reviewer", &err) == 0, "set known profile");
+ char *pn = session_profile_name(s);
+ CHECK(pn && strcmp(pn, "reviewer") == 0, "profile name set");
+ free(pn);
+
+ err = NULL;
+ CHECK(session_set_profile(s, "nope", &err) == -1, "unknown profile fails");
+ CHECK(err && strstr(err, "no enabled sub-agent `nope`") != NULL,
+ "unknown profile err names id");
+ free(err);
+
+ CHECK(session_set_profile(s, NULL, &err) == 0, "clear profile ok");
+ CHECK(session_profile_name(s) == NULL, "profile cleared");
+
+ session_free(s);
+}
+
+static void test_set_model_preset(void) {
+ config c = base_config();
+ dotagents d = dot_with_presets();
+ mock_model *m = mk_model();
+ session *s = session_new_with_model(&m->m, c, d);
+ char *err = NULL;
+
+ CHECK(session_set_model(s, "fast", &err) == 0, "apply preset");
+ CHECK(strcmp(session_config(s)->model, "m-fast") == 0, "preset model");
+ CHECK(strcmp(session_config(s)->base_url, "https://fast.example") == 0,
+ "preset base_url");
+ CHECK(strcmp(session_config(s)->api_key, "kfast") == 0, "preset api_key");
+ char *ap = session_active_preset(s);
+ CHECK(ap && strcmp(ap, "fast") == 0, "active preset set");
+ free(ap);
+
+ CHECK(session_set_model(s, "custom-model", &err) == 0, "raw model ok");
+ CHECK(strcmp(session_config(s)->model, "custom-model") == 0, "raw model set");
+ CHECK(session_active_preset(s) == NULL, "raw model clears preset");
+
+ err = NULL;
+ CHECK(session_set_model(s, "badprov", &err) == -1,
+ "preset with bad provider fails (strict)");
+ CHECK(err && strstr(err, "unknown provider") != NULL, "bad provider err");
+ free(err);
+
+ session_free(s);
+}
+
+static void test_current_preamble_switches_with_profile(void) {
+ config c = base_config();
+ c.preamble = xstrdup("BASE PREAMBLE");
+ dotagents d = dot_with_reviewer();
+ mock_model *m = mk_model();
+ session *s = session_new_with_model(&m->m, c, d);
+ char *err = NULL;
+
+ CHECK_CONT(session_current_preamble(s), "BASE PREAMBLE",
+ "base preamble before profile");
+
+ CHECK(session_set_profile(s, "reviewer", &err) == 0, "set profile");
+ CHECK_CONT(session_current_preamble(s), "You are a reviewer.",
+ "profile preamble after switch");
+
+ session_free(s);
+}
+
+/* The per-profile effective state (model, preamble, allowed tools) is
+ * cached on the session. Switching profiles must invalidate the cache so
+ * none of the three is served stale -- in either direction. */
+static void test_profile_cache_invalidates(void) {
+ config c = base_config();
+ c.preamble = xstrdup("BASE PREAMBLE");
+ dotagents d = dot_with_profile_tools();
+ mock_model *m = mk_model();
+ session *s = session_new_with_model(&m->m, c, d);
+ char *err = NULL;
+
+ /* Baseline (no profile): base model, base preamble, no tool restriction. */
+ char *model = session_model(s);
+ CHECK_CONT(model, "gpt-test", "base model before profile");
+ free(model);
+ CHECK_CONT(session_current_preamble(s), "BASE PREAMBLE",
+ "base preamble before profile");
+ CHECK(session_allowed_tools(s)->len == 0, "no tool restriction at base");
+
+ /* Activate the profile: all three must reflect the profile, not the
+ * previously cached base state. */
+ CHECK(session_set_profile(s, "rev", &err) == 0, "set profile");
+ model = session_model(s);
+ CHECK_CONT(model, "m-profile", "profile model after switch");
+ CHECK_NOT_CONT(model, "gpt-test", "base model not leaked");
+ free(model);
+ CHECK_CONT(session_current_preamble(s), "You are a reviewer.",
+ "profile preamble after switch");
+ CHECK_NOT_CONT(session_current_preamble(s), "BASE PREAMBLE",
+ "base preamble not leaked");
+ const strlist *allowed = session_allowed_tools(s);
+ CHECK(allowed->len == 2, "profile restricts tools to two");
+ CHECK(allowed->len == 2 &&
+ xstreq(allowed->items[0], "read_file") &&
+ xstreq(allowed->items[1], "grep"),
+ "restricted tool set is correct");
+
+ /* Clear the profile: back to the base state, not the stale profile. */
+ CHECK(session_set_profile(s, NULL, &err) == 0, "clear profile");
+ model = session_model(s);
+ CHECK_CONT(model, "gpt-test", "base model restored");
+ CHECK_NOT_CONT(model, "m-profile", "profile model not leaked");
+ free(model);
+ CHECK_CONT(session_current_preamble(s), "BASE PREAMBLE",
+ "base preamble restored");
+ CHECK_NOT_CONT(session_current_preamble(s), "You are a reviewer.",
+ "profile preamble not leaked");
+ CHECK(session_allowed_tools(s)->len == 0, "tool restriction lifted");
+
+ session_free(s);
+}
+
+/* ------------------------------------------------------------------ */
+/* Compaction */
+/* ------------------------------------------------------------------ */
+
+static void test_compact_disabled_at_zero_limit(void) {
+ config c = base_config();
+ c.context_limit = 0;
+ dotagents d = empty_dot();
+ mock_model *m = mk_model();
+ session *s = session_new_with_model(&m->m, c, d);
+ push_compact_hist(s);
+ compact_report report;
+ memset(&report, 0, sizeof report);
+ char *err = NULL;
+ CHECK(session_compact_if_needed(s, false, &report, &err) == 0,
+ "zero limit -> no pass");
+ CHECK(err == NULL, "no error");
+ CHECK_EQ_U64(session_history_len(s), 4, "history untouched");
+ session_free(s);
+}
+
+static void test_compact_below_thresholds(void) {
+ config c = base_config();
+ c.context_limit = 1000;
+ c.compact_threshold = 0.5; /* budget 500 */
+ c.clear_threshold = 0.1; /* budget 100 */
+ dotagents d = empty_dot();
+ mock_model *m = mk_model();
+ session *s = session_new_with_model(&m->m, c, d);
+ push_compact_hist(s);
+ session_set_last_context_tokens(s, 50); /* below both */
+ compact_report report;
+ memset(&report, 0, sizeof report);
+ char *err = NULL;
+ CHECK(session_compact_if_needed(s, false, &report, &err) == 0,
+ "below thresholds -> no pass");
+ CHECK_EQ_U64(session_history_len(s), 4, "history untouched");
+ session_free(s);
+}
+
+static void test_compact_clears_stale_tools(void) {
+ config c = base_config();
+ c.context_limit = 1000;
+ c.compact_threshold = 0.5; /* budget 500 */
+ c.clear_threshold = 0.1; /* budget 100 */
+ dotagents d = empty_dot();
+ mock_model *m = mk_model();
+ session *s = session_new_with_model(&m->m, c, d);
+ push_compact_hist(s);
+ session_set_last_context_tokens(s, 200); /* >=100, <500 */
+ compact_report report;
+ memset(&report, 0, sizeof report);
+ char *err = NULL;
+ CHECK(session_compact_if_needed(s, false, &report, &err) == 1,
+ "clear tier ran");
+ CHECK(report.tier == TIER_CLEARED, "tier is CLEARED");
+ CHECK_EQ_U64(report.cleared_tool_results, 1, "one stale result cleared");
+ CHECK(report.tokens_after < report.tokens_before, "tokens shrank");
+ CHECK_EQ_U64(session_last_context_tokens(s), 0, "usage reset after pass");
+ session_free(s);
+}
+
+static void test_compact_summarizes_at_threshold(void) {
+ config c = base_config();
+ c.context_limit = 1000;
+ c.compact_threshold = 0.5;
+ c.clear_threshold = 0.1;
+ dotagents d = empty_dot();
+ mock_model *main = mk_model();
+ mock_model *sum = mk_complete("EARLIER SUMMARY", 0, NULL);
+ session *s = session_new_with_model(&main->m, c, d);
+ s = session_with_summarizer(s, &sum->m);
+ push_compact_hist(s);
+ session_set_last_context_tokens(s, 600); /* >=500 */
+ compact_report report;
+ memset(&report, 0, sizeof report);
+ char *err = NULL;
+ CHECK(session_compact_if_needed(s, false, &report, &err) == 1,
+ "summarize tier ran");
+ CHECK(report.tier == TIER_SUMMARIZED, "tier is SUMMARIZED");
+ CHECK_EQ_U64(report.files_reinjected, 0, "no files listed -> none");
+ CHECK_EQ_U64(sum->complete_calls, 1, "summarizer called once");
+ CHECK_EQ_U64(session_history_len(s), 3, "summary + tail (2) = 3");
+ const message *sum0 = session_history_at(s, 0);
+ CHECK(sum0 && sum0->is_user(), "first is the summary user message");
+ const message *kept = session_history_at(s, 1);
+ CHECK(kept && kept->is_user() && kept->user().parts.size() == 1 &&
+ std::get<user_text_part>(kept->user().parts[0]).text == "now",
+ "tail preserved");
+ session_free(s);
+}
+
+static void test_forced_compact_reinjects_files(void) {
+ const char *path = "/tmp/opencode/llsh_reinject.txt";
+ char *content = xstrdup("REINJECTED FILE CONTENT");
+ CHECK(write_file_from_buf(path, content, strlen(content), NULL) == 0,
+ "wrote temp file");
+ free(content);
+
+ config c = base_config();
+ c.context_limit = 1000;
+ c.compact_threshold = 0.5;
+ c.clear_threshold = 0.1;
+ dotagents d = empty_dot();
+ mock_model *main = mk_model();
+ char *sumreply = xstrfmt("SUMMARY\n## Files\n- %s\n", path);
+ mock_model *sum = mk_complete(sumreply, 0, NULL);
+ free(sumreply);
+ session *s = session_new_with_model(&main->m, c, d);
+ s = session_with_summarizer(s, &sum->m);
+ push_compact_hist(s);
+ compact_report report;
+ memset(&report, 0, sizeof report);
+ char *err = NULL;
+ CHECK(session_compact_if_needed(s, true, &report, &err) == 1, "forced ran");
+ CHECK(report.tier == TIER_SUMMARIZED, "tier is SUMMARIZED");
+ CHECK_EQ_U64(report.files_reinjected, 1, "one file re-injected");
+ const message *sum0 = session_history_at(s, 0);
+ CHECK_CONT(first_text(sum0), "REINJECTED FILE CONTENT",
+ "file content inlined");
+ CHECK_CONT(first_text(sum0), path, "file path shown");
+ unlink(path);
+ session_free(s);
+}
+
+static void test_forced_compact_fallback_clear(void) {
+ config c = base_config();
+ c.context_limit = 1000;
+ c.compact_threshold = 0.5;
+ c.clear_threshold = 0.1;
+ dotagents d = empty_dot();
+ mock_model *main = mk_model();
+ mock_model *sum = mk_complete(NULL, 1, "summarizer exploded");
+ session *s = session_new_with_model(&main->m, c, d);
+ s = session_with_summarizer(s, &sum->m);
+ push_compact_hist(s);
+ compact_report report;
+ memset(&report, 0, sizeof report);
+ char *err = NULL;
+ CHECK(session_compact_if_needed(s, true, &report, &err) == 1,
+ "forced pass ran");
+ CHECK(report.tier == TIER_FALLBACK_CLEAR, "tier is FALLBACK_CLEAR");
+ CHECK_EQ_U64(sum->complete_calls, 1, "summarizer attempted");
+ CHECK_EQ_U64(session_history_len(s), 4, "history not rebuilt (in place)");
+ const message *tr = session_history_at(s, 1);
+ CHECK_CONT(first_text(tr), "tool output cleared",
+ "stale result replaced with placeholder");
+ session_free(s);
+}
+
+static void test_forced_compact_noop_no_stale(void) {
+ config c = base_config();
+ c.context_limit = 1000;
+ c.compact_threshold = 0.5;
+ c.clear_threshold = 0.1;
+ dotagents d = empty_dot();
+ mock_model *main = mk_model();
+ mock_model *sum = mk_complete("S", 0, NULL);
+ session *s = session_new_with_model(&main->m, c, d);
+ s = session_with_summarizer(s, &sum->m);
+ session_push_history(s, msg_user_text("first")); /* boundary would be 0 */
+ session_push_history(s, msg_assistant_text("b"));
+ compact_report report;
+ memset(&report, 0, sizeof report);
+ char *err = NULL;
+ CHECK(session_compact_if_needed(s, true, &report, &err) == 0,
+ "no stale prefix -> no pass");
+ CHECK_EQ_U64(sum->complete_calls, 0, "summarizer not called");
+ CHECK_EQ_U64(session_history_len(s), 2, "history untouched");
+ session_free(s);
+}
+
+static void test_undo_last_turn(void) {
+ config c = base_config();
+ dotagents d = empty_dot();
+ mock_model *m = mk_model();
+ session *s = session_new_with_model(&m->m, c, d);
+ session_push_history(s, msg_user_text("first"));
+ session_push_history(s, msg_assistant_text("a"));
+ session_push_history(s, msg_user_text("second"));
+ session_push_history(s, msg_assistant_text("b"));
+
+ CHECK(session_undo_last_turn(s) == 2, "removes most recent prompt + reply");
+ CHECK_EQ_U64(session_history_len(s), 2, "earlier turn retained");
+ const message *u0 = session_history_at(s, 0);
+ CHECK(u0 && u0->is_user() &&
+ std::get<user_text_part>(u0->user().parts[0]).text == "first",
+ "first prompt retained");
+
+ /* no pure-text user -> nothing to remove */
+ mock_model *m2 = mk_model();
+ session *s2 = session_new_with_model(&m2->m, base_config(), empty_dot());
+ session_push_history(s2, msg_assistant_text("a"));
+ session_push_history(s2,
+ msg_user_tool_result("c1", "read_file", "body"));
+ CHECK(session_undo_last_turn(s2) == -1, "no pure-text user -> -1");
+ CHECK_EQ_U64(session_history_len(s2), 2, "history untouched");
+ session_free(s2);
+
+ session_free(s);
+}
+
+/* ------------------------------------------------------------------ */
+/* Goal */
+/* ------------------------------------------------------------------ */
+
+static void test_goal_met(void) {
+ config c = base_config();
+ dotagents d = empty_dot();
+ mock_model *main = mk_model();
+ mock_model *eval = mk_complete("MET\nAll tests pass and are green.", 0,
+ NULL);
+ session *s = session_new_with_model(&main->m, c, d);
+ s = session_with_goal_evaluator(s, &eval->m);
+ goal_spec spec;
+ spec.criteria = xstrdup("all tests pass");
+ spec.max_tries = 5;
+ goal_verdict v;
+ char *err = NULL;
+ CHECK(session_evaluate_goal(s, &spec, "I ran the suite", &v, &err) == 0,
+ "evaluate ok");
+ CHECK(v.met, "verdict met");
+ CHECK_EQ_U64(eval->complete_calls, 1, "evaluator called once");
+ free(v.reason);
+ goal_spec_free(&spec);
+ session_free(s);
+}
+
+static void test_goal_not_met(void) {
+ config c = base_config();
+ dotagents d = empty_dot();
+ mock_model *main = mk_model();
+ mock_model *eval = mk_complete("NOT MET\nTwo tests still fail.", 0,
+ NULL);
+ session *s = session_new_with_model(&main->m, c, d);
+ s = session_with_goal_evaluator(s, &eval->m);
+ goal_spec spec;
+ spec.criteria = xstrdup("all tests pass");
+ spec.max_tries = 5;
+ goal_verdict v;
+ char *err = NULL;
+ CHECK(session_evaluate_goal(s, &spec, "I ran the suite", &v, &err) == 0,
+ "evaluate ok");
+ CHECK(!v.met, "verdict not met");
+ free(v.reason);
+ goal_spec_free(&spec);
+ session_free(s);
+}
+
+static void test_goal_no_evaluator(void) {
+ config c = base_config();
+ dotagents d = empty_dot();
+ mock_model *main = mk_model();
+ session *s = session_new_with_model(&main->m, c, d);
+ goal_spec spec;
+ spec.criteria = xstrdup("all tests pass");
+ spec.max_tries = 5;
+ goal_verdict v;
+ char *err = NULL;
+ CHECK(session_evaluate_goal(s, &spec, "done", &v, &err) == -1,
+ "no evaluator -> -1");
+ CHECK(err && strstr(err, "no goal evaluator") != NULL, "err names cause");
+ free(err);
+ goal_spec_free(&spec);
+ session_free(s);
+}
+
+static void test_goal_evaluator_error(void) {
+ config c = base_config();
+ dotagents d = empty_dot();
+ mock_model *main = mk_model();
+ mock_model *eval = mk_complete(NULL, 1, "evaluator exploded");
+ session *s = session_new_with_model(&main->m, c, d);
+ s = session_with_goal_evaluator(s, &eval->m);
+ goal_spec spec;
+ spec.criteria = xstrdup("all tests pass");
+ spec.max_tries = 5;
+ goal_verdict v;
+ char *err = NULL;
+ CHECK(session_evaluate_goal(s, &spec, "done", &v, &err) == -1,
+ "evaluator error -> -1");
+ CHECK(err && strstr(err, "asking the goal evaluator") != NULL,
+ "err names cause");
+ free(err);
+ goal_spec_free(&spec);
+ session_free(s);
+}
+
+/* ------------------------------------------------------------------ */
+/* Save / load / resume */
+/* ------------------------------------------------------------------ */
+
+static void test_save_resume_roundtrip(void) {
+ const char *path = "/tmp/opencode/llsh_save.json";
+ unlink(path);
+
+ config c = base_config();
+ free(c.model);
+ c.model = xstrdup("m1");
+ free(c.api_key);
+ c.api_key = xstrdup("k");
+ c.context_limit = 500;
+ dotagents d = dot_with_reviewer();
+ mock_model *m = mk_model();
+ session *s = session_new_with_model(&m->m, c, d);
+ char *err = NULL;
+ CHECK(session_set_profile(s, "reviewer", &err) == 0, "set profile");
+ session_push_history(s, msg_user_text("hi"));
+ session_push_history(s, msg_assistant_text("yo"));
+ session_set_last_context_tokens(s, 777);
+
+ CHECK(session_save(s, path, &err) == 0, "save ok");
+ CHECK(err == NULL, "save no error");
+
+ session_snapshot *snap = session_load(path, &err);
+ CHECK(snap != NULL, "load ok");
+ if (snap) {
+ CHECK(strcmp(snap->cfg.model, "m1") == 0, "config model restored");
+ CHECK(snap->cfg.provider == PROV_OPENAI, "config provider restored");
+ CHECK(strcmp(snap->cfg.api_key, "k") == 0, "config api_key restored");
+ CHECK_EQ_U64(snap->cfg.context_limit, 500,
+ "config context_limit restored");
+ CHECK_EQ_U64(snap->hist.msgs.size(), 2, "history length restored");
+ CHECK(snap->hist.msgs[0].is_user() &&
+ std::get<user_text_part>(
+ snap->hist.msgs[0].user().parts[0])
+ .text == "hi",
+ "user message restored");
+ CHECK(snap->hist.msgs[1].is_assistant() &&
+ std::get<asst_text_part>(
+ snap->hist.msgs[1].assistant().parts[0])
+ .text == "yo",
+ "assistant message restored");
+ CHECK(snap->profile && strcmp(snap->profile, "reviewer") == 0,
+ "profile restored");
+ CHECK_EQ_U64(snap->last_context_tokens, 777, "usage restored");
+
+ dotagents d2 = dot_with_reviewer();
+ mock_model *m2 = mk_model();
+ session *s2 = session_resume(snap, d2, &m2->m, &err);
+ CHECK(s2 != NULL, "resume ok");
+ if (s2) {
+ CHECK(strcmp(session_config(s2)->model, "m1") == 0,
+ "resumed config model");
+ char *pn = session_profile_name(s2);
+ CHECK(pn && strcmp(pn, "reviewer") == 0, "resumed profile");
+ free(pn);
+ CHECK_EQ_U64(session_history_len(s2), 2, "resumed history");
+ CHECK_EQ_U64(session_last_context_tokens(s2), 777,
+ "resumed usage");
+ session_free(s2);
+ }
+ }
+ unlink(path);
+ session_free(s);
+}
+
+static void test_resume_falls_back_to_main(void) {
+ const char *path = "/tmp/opencode/llsh_save_fb.json";
+ unlink(path);
+
+ config c = base_config();
+ dotagents d = dot_with_reviewer();
+ mock_model *m = mk_model();
+ session *s = session_new_with_model(&m->m, c, d);
+ char *err = NULL;
+ CHECK(session_set_profile(s, "reviewer", &err) == 0, "set profile");
+ session_push_history(s, msg_user_text("hi"));
+ CHECK(session_save(s, path, &err) == 0, "save ok");
+
+ session_snapshot *snap = session_load(path, &err);
+ CHECK(snap != NULL, "load ok");
+ if (snap) {
+ dotagents d2 = empty_dot(); /* no 'reviewer' here */
+ mock_model *m2 = mk_model();
+ session *s2 = session_resume(snap, d2, &m2->m, &err);
+ CHECK(s2 != NULL, "resume ok");
+ if (s2) {
+ CHECK(session_profile_name(s2) == NULL,
+ "profile fell back to main agent");
+ CHECK_EQ_U64(session_history_len(s2), 1, "history still restored");
+ session_free(s2);
+ }
+ }
+ unlink(path);
+ session_free(s);
+}
+
+static void test_load_rejects_bad_version(void) {
+ const char *path = "/tmp/opencode/llsh_badver.json";
+ const char *body =
+ "{\"version\": 999, \"history\": [], \"config\": {}}";
+ CHECK(write_file_from_buf(path, body, strlen(body), NULL) == 0,
+ "wrote bad-version file");
+ char *err = NULL;
+ session_snapshot *snap = session_load(path, &err);
+ CHECK(snap == NULL, "bad version rejected");
+ CHECK(err && strstr(err, "unsupported session version") != NULL,
+ "err names the problem");
+ free(err);
+ unlink(path);
+}
+
+static void test_load_missing_file(void) {
+ char *err = NULL;
+ session_snapshot *snap =
+ session_load("/tmp/opencode/llsh_does_not_exist_12345.json", &err);
+ CHECK(snap == NULL, "missing file -> NULL");
+ CHECK(err && strstr(err, "reading") != NULL, "err names the path action");
+ free(err);
+}
+
+static void test_debug_dump(void) {
+ const char *path = "/tmp/opencode/llsh_debug_test.json";
+ unlink(path);
+
+ config c = base_config();
+ dotagents d = empty_dot();
+ mock_model *m = mk_model();
+ int ok_results[] = {SESS_STREAM_OK};
+ mock_set_stream(m, ok_results, 1, "hello there", 42);
+
+ session *s = session_new_with_model(&m->m, c, d);
+
+ /* No debug path set: the dump is a no-op and creates nothing. */
+ char *err = NULL;
+ CHECK(session_debug_dump(s, &err) == 0, "dump with no path is a no-op");
+ CHECK(err == NULL, "no-op sets no error");
+ CHECK(access(path, R_OK) != 0, "no-op creates no file");
+
+ /* Enabled: every turn refreshes the whole-history file. */
+ session_set_debug_path(s, xstrdup(path));
+ std::atomic<bool> stop(false);
+ int result = -1;
+ CHECK(session_run_turn(s, "hi", &stop, NULL, NULL, &err, &result) == 0,
+ "turn completes");
+ CHECK(result == SESS_TURN_COMPLETED, "result is Completed");
+ CHECK(access(path, R_OK) == 0, "debug file written after turn");
+
+ char *raw = NULL;
+ size_t len = 0;
+ char *rerr = NULL;
+ CHECK(read_file_to_buf(path, &raw, &len, &rerr) == 0, "file readable");
+ free(rerr);
+ bool parsed = true;
+ nlohmann::json o;
+ try {
+ o = nlohmann::json::parse(raw);
+ } catch (...) {
+ parsed = false;
+ }
+ CHECK(parsed, "file is valid JSON");
+ if (parsed) {
+ CHECK(o.is_array() && o.size() == 2, "file is the whole conversation");
+ CHECK(o[0].at("role") == "user", "first is user");
+ CHECK(o[0].at("parts")[0].at("text") == "hi", "user text preserved");
+ CHECK(o[1].at("role") == "assistant", "second is assistant");
+ CHECK(o[1].at("parts")[0].at("text") == "hello there",
+ "assistant text preserved");
+ }
+ free(raw);
+
+ /* A second turn grows the history and the file tracks it. */
+ mock_set_stream(m, ok_results, 1, "again", 99);
+ CHECK(session_run_turn(s, "hi again", &stop, NULL, NULL, &err, &result) ==
+ 0,
+ "second turn completes");
+ raw = NULL;
+ rerr = NULL;
+ CHECK(read_file_to_buf(path, &raw, &len, &rerr) == 0,
+ "file readable again");
+ free(rerr);
+ bool parsed2 = true;
+ nlohmann::json o2;
+ try {
+ o2 = nlohmann::json::parse(raw);
+ } catch (...) {
+ parsed2 = false;
+ }
+ CHECK(parsed2, "second dump is valid JSON");
+ if (parsed2) {
+ CHECK(o2.is_array() && o2.size() == 4, "history grew to 4 messages");
+ CHECK(o2[3].at("parts")[0].at("text") == "again",
+ "latest assistant text");
+ }
+ free(raw);
+
+ /* Disabling again turns the dump off. */
+ session_set_debug_path(s, NULL);
+ unlink(path);
+ err = NULL;
+ CHECK(session_debug_dump(s, &err) == 0, "dump after disable is a no-op");
+ CHECK(err == NULL, "no error after disable");
+ CHECK(access(path, R_OK) != 0, "disable creates no file");
+
+ session_free(s);
+}
+
+/* ------------------------------------------------------------------ */
+/* main */
+/* ------------------------------------------------------------------ */
+
+int main(void) {
+ test_is_transient_status();
+ test_backoff_secs();
+ test_human_secs();
+ test_classify_retry();
+ test_context_overflow_detection();
+ test_assistant_emit();
+ test_tool_result_emit();
+
+ test_active_preset_from();
+ test_base_preamble_for();
+ test_preamble_for_profile();
+ test_apply_overrides();
+ test_effective_for_profile();
+
+ test_stop_before_start();
+ test_completed_turn_updates_history();
+ test_retry_then_success();
+ test_gives_up_after_retries();
+ test_no_retry_on_permanent();
+ test_retry_disabled();
+ test_overflow_recover_by_compacting();
+ test_overflow_fallback_clear_still_recovers();
+ test_overflow_retries_only_once();
+ test_overflow_no_recovery_without_limit();
+ test_overflow_no_stale_prefix_no_recovery();
+ test_records_usage();
+
+ test_set_profile();
+ test_set_model_preset();
+ test_current_preamble_switches_with_profile();
+ test_profile_cache_invalidates();
+
+ test_compact_disabled_at_zero_limit();
+ test_compact_below_thresholds();
+ test_compact_clears_stale_tools();
+ test_compact_summarizes_at_threshold();
+ test_forced_compact_reinjects_files();
+ test_forced_compact_fallback_clear();
+ test_forced_compact_noop_no_stale();
+ test_undo_last_turn();
+
+ test_goal_met();
+ test_goal_not_met();
+ test_goal_no_evaluator();
+ test_goal_evaluator_error();
+
+ test_save_resume_roundtrip();
+ test_resume_falls_back_to_main();
+ test_load_rejects_bad_version();
+ test_load_missing_file();
+
+ test_debug_dump();
+
+ printf("session: %d checks, %d failures\n", checks, failures);
+ return failures ? 1 : 0;
+}