#include "session.h" #include #include #include #include #include /* ------------------------------------------------------------------ */ /* 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 *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(&p)) return t->text.c_str(); if (const user_tool_result_part *r = std::get_if(&p)) if (!r->content.empty()) if (const trc_text *tt = std::get_if(&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(&p)) return t->text.c_str(); if (const asst_reasoning_part *r = std::get_if(&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 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(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 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(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 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 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 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 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 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 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 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 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 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 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(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(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( snap->hist.msgs[0].user().parts[0]) .text == "hi", "user message restored"); CHECK(snap->hist.msgs[1].is_assistant() && std::get( 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 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; }