diff options
Diffstat (limited to 'tests/test_session.cpp')
| -rw-r--r-- | tests/test_session.cpp | 1659 |
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; +} |