summaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/test_agent.cpp260
-rw-r--r--tests/test_clio.c884
-rw-r--r--tests/test_compact.cpp517
-rw-r--r--tests/test_config.cpp654
-rw-r--r--tests/test_dotagents.c618
-rw-r--r--tests/test_frontmatter.c236
-rw-r--r--tests/test_goal.cpp230
-rw-r--r--tests/test_input.cpp1263
-rw-r--r--tests/test_limits.cpp379
-rw-r--r--tests/test_mcp.cpp579
-rw-r--r--tests/test_provider.cpp647
-rw-r--r--tests/test_quine.c27
-rw-r--r--tests/test_session.cpp1659
-rw-r--r--tests/test_shell.cpp131
-rw-r--r--tests/test_tools.cpp1174
15 files changed, 9258 insertions, 0 deletions
diff --git a/tests/test_agent.cpp b/tests/test_agent.cpp
new file mode 100644
index 0000000..c5212ae
--- /dev/null
+++ b/tests/test_agent.cpp
@@ -0,0 +1,260 @@
+#include "agent.h"
+
+#include <cjson/cJSON.h>
+#include <curl/curl.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+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_EQ_STR(a, b, msg) \
+ do { \
+ checks++; \
+ const char *_a = (a), *_b = (b); \
+ if (!_a || (_b && strcmp(_a, _b) != 0)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: %s (got `%s`, want `%s`)\n", \
+ __FILE__, __LINE__, (msg), _a ? _a : "(null)", \
+ _b ? _b : "(null)"); \
+ } \
+ } while (0)
+
+static config mkcfg(provider_kind p, const char *model, const char *base,
+ const char *key) {
+ config c;
+ memset(&c, 0, sizeof c);
+ c.provider = p;
+ c.model = xstrdup(model);
+ c.base_url = base ? xstrdup(base) : NULL;
+ c.api_key = key ? xstrdup(key) : NULL;
+ return c;
+}
+
+static void freecfg(config *c) {
+ free(c->model);
+ free(c->base_url);
+ free(c->api_key);
+}
+
+static void test_extract_openai_context_length(void) {
+ config c = mkcfg(PROV_OPENAI, "llm", NULL, NULL);
+ cJSON *j = cJSON_Parse("{\"id\":\"llm\",\"object\":\"model\","
+ "\"context_length\":262144}");
+ CHECK(extract_context_limit(j, &c) == 262144, "openai top-level limit");
+ cJSON_Delete(j);
+ j = cJSON_Parse("{\"id\":\"llm\",\"object\":\"model\"}");
+ CHECK(extract_context_limit(j, &c) == 0, "openai missing limit");
+ cJSON_Delete(j);
+ j = cJSON_Parse("{\"object\":\"list\",\"data\":["
+ "{\"id\":\"llm\",\"aliases\":[\"llm\"],"
+ "\"owned_by\":\"llamacpp\",\"meta\":{\"n_ctx\":131072}},"
+ "{\"id\":\"other\",\"meta\":{\"n_ctx\":8192}}]}");
+ CHECK(extract_context_limit(j, &c) == 131072,
+ "llama.cpp meta.n_ctx for matching model");
+ cJSON_Delete(j);
+ freecfg(&c);
+}
+
+static void test_openai_model_present(void) {
+ CHECK(openai_model_present("{\"data\":[{\"id\":\"llm\"},{\"id\":\"other\"}]}",
+ "llm"),
+ "id match in data[]");
+ CHECK(!openai_model_present("{\"data\":[{\"id\":\"llm\"},{\"id\":\"other\"}]}",
+ "nope"),
+ "id miss");
+ CHECK(openai_model_present(
+ "{\"models\":[{\"name\":\"llm\"}],"
+ "\"data\":[{\"id\":\"x\",\"aliases\":[\"llm\"]}]}",
+ "llm"),
+ "models[].name and data[].aliases variants");
+ CHECK(!openai_model_present("{\"data\":[]}", "llm"), "empty data[]");
+ CHECK(!openai_model_present("not json", "llm"), "malformed body");
+ /* parity: a non-array `data` blocks the `models` fallback (Rust
+ * get("data").or_else(|| get("models"))) */
+ CHECK(!openai_model_present(
+ "{\"data\":{\"id\":\"x\"},\"models\":[{\"id\":\"llm\"}]}", "llm"),
+ "non-array data does not fall back to models");
+}
+
+static void test_extract_ollama_context_length(void) {
+ config c = mkcfg(PROV_OLLAMA, "llama3.2", NULL, NULL);
+ cJSON *j = cJSON_Parse("{\"name\":\"llama3.2\","
+ "\"model_details\":{\"family\":\"llama\","
+ "\"context_length\":8192},"
+ "\"modelfiles\":[{\"parameters\":"
+ "{\"context_length\":4096}}]}");
+ CHECK(extract_context_limit(j, &c) == 8192, "ollama model_details");
+ cJSON_Delete(j);
+ j = cJSON_Parse("{\"modelfiles\":[{\"parameters\":"
+ "{\"context_length\":4096}}]}");
+ CHECK(extract_context_limit(j, &c) == 4096, "ollama modelfiles fallback");
+ cJSON_Delete(j);
+ j = cJSON_Parse("{\"name\":\"llama3.2\",\"model_details\":{}}");
+ CHECK(extract_context_limit(j, &c) == 0, "ollama absent");
+ cJSON_Delete(j);
+ freecfg(&c);
+}
+
+static void test_extract_anthropic_max_tokens(void) {
+ cJSON *j = cJSON_Parse("{\"data\":["
+ "{\"id\":\"claude-haiku\",\"max_tokens\":8192},"
+ "{\"id\":\"claude-sonnet-4-6\",\"max_tokens\":64000}]}");
+ config c = mkcfg(PROV_ANTHROPIC, "claude-sonnet-4-6", NULL, NULL);
+ CHECK(extract_context_limit(j, &c) == 64000, "matching model wins");
+ freecfg(&c);
+ c = mkcfg(PROV_ANTHROPIC, "unknown-model", NULL, NULL);
+ CHECK(extract_context_limit(j, &c) == 8192,
+ "unknown model falls back to the first entry");
+ freecfg(&c);
+ cJSON_Delete(j);
+ j = cJSON_Parse("{\"data\":[]}");
+ c = mkcfg(PROV_ANTHROPIC, "unknown-model", NULL, NULL);
+ CHECK(extract_context_limit(j, &c) == 0, "empty data[] -> none");
+ cJSON_Delete(j);
+ freecfg(&c);
+}
+
+static void test_anthropic_model_present(void) {
+ const char *body =
+ "{\"data\":[{\"id\":\"claude-haiku\"},{\"id\":\"claude-sonnet-4-6\"}]}";
+ CHECK(anthropic_model_present(body, "claude-sonnet-4-6"), "id match");
+ CHECK(!anthropic_model_present(body, "claude-unknown"), "id miss");
+ CHECK(!anthropic_model_present("not json", "claude-haiku"),
+ "malformed body never present");
+ CHECK(!anthropic_model_present("{\"data\":[]}", "claude-haiku"),
+ "empty list");
+ CHECK(!anthropic_model_present("{\"data\":{\"id\":\"x\"}}", "x"),
+ "data not an array");
+}
+
+static void test_probe_helpers(void) {
+ provider_probe ok = {true, xstrdup("model `llm` reachable at http://x")};
+ CHECK(ok.ok, "ok flag");
+ CHECK_EQ_STR(ok.detail, "model `llm` reachable at http://x", "ok detail");
+ provider_probe_free(&ok);
+ CHECK(ok.detail == NULL, "probe_free releases detail");
+ provider_probe bad = {false, xstrdup("could not reach http://x "
+ "(connection: timeout)")};
+ CHECK(!bad.ok, "fail flag");
+ CHECK(strstr(bad.detail, "timeout") != NULL, "fail detail content");
+ provider_probe_free(&bad);
+ CHECK(bad.detail == NULL, "probe_free releases failed detail");
+}
+
+static void test_default_base_urls(void) {
+ CHECK_EQ_STR(agent_default_base_url(PROV_OPENAI), "https://api.openai.com/v1",
+ "openai default base");
+ CHECK_EQ_STR(agent_default_base_url(PROV_ANTHROPIC),
+ "https://api.anthropic.com", "anthropic default base");
+ CHECK_EQ_STR(agent_default_base_url(PROV_OLLAMA), "http://localhost:11434",
+ "ollama default base");
+}
+
+static void test_models_url(void) {
+ config c = mkcfg(PROV_OPENAI, "llm", NULL, NULL);
+ char *u = agent_models_url(&c);
+ CHECK_EQ_STR(u, "https://api.openai.com/v1/models", "openai default url");
+ free(u);
+ freecfg(&c);
+
+ c = mkcfg(PROV_OPENAI, "llm", "http://localhost:8080/v1/", "k");
+ u = agent_models_url(&c);
+ CHECK_EQ_STR(u, "http://localhost:8080/v1/models",
+ "custom base, trailing slash trimmed");
+ free(u);
+ freecfg(&c);
+
+ c = mkcfg(PROV_ANTHROPIC, "claude-sonnet-4-6", NULL, "k");
+ u = agent_models_url(&c);
+ CHECK_EQ_STR(u, "https://api.anthropic.com/v1/models?limit=100",
+ "anthropic url");
+ free(u);
+ freecfg(&c);
+
+ c = mkcfg(PROV_OLLAMA, "llama3.2", NULL, NULL);
+ u = agent_models_url(&c);
+ CHECK_EQ_STR(u, "http://localhost:11434/api/show?name=llama3.2",
+ "ollama url");
+ free(u);
+ freecfg(&c);
+
+ c = mkcfg(PROV_OLLAMA, "llama 3.2", "http://host:1/x/", NULL);
+ u = agent_models_url(&c);
+ CHECK(strstr(u, "name=llama%203.2") != NULL, "ollama name is url-encoded");
+ free(u);
+ freecfg(&c);
+}
+
+static void test_auth(void) {
+ config c = mkcfg(PROV_OPENAI, "llm", NULL, "sk-1");
+ char *h = NULL, *v = NULL;
+ agent_auth(&c, &h, &v);
+ CHECK_EQ_STR(h, "Authorization", "openai auth header");
+ CHECK_EQ_STR(v, "Bearer sk-1", "openai bearer value");
+ free(h);
+ free(v);
+ freecfg(&c);
+
+ c = mkcfg(PROV_OPENAI, "llm", "http://x", NULL);
+ agent_auth(&c, &h, &v);
+ CHECK(h == NULL && v == NULL, "openai without key: no auth");
+ free(h);
+ free(v);
+ freecfg(&c);
+
+ c = mkcfg(PROV_ANTHROPIC, "m", NULL, "ak");
+ agent_auth(&c, &h, &v);
+ CHECK_EQ_STR(h, "x-api-key", "anthropic auth header");
+ CHECK_EQ_STR(v, "ak", "anthropic key passthrough");
+ free(h);
+ free(v);
+ freecfg(&c);
+
+ c = mkcfg(PROV_OLLAMA, "m", NULL, "ignored");
+ agent_auth(&c, &h, &v);
+ CHECK(h == NULL && v == NULL, "ollama sends no auth");
+ free(h);
+ free(v);
+ freecfg(&c);
+}
+
+static void test_http_get_error_path(void) {
+ long status = 0;
+ char *body = NULL, *err = NULL;
+ /* port 1 on localhost: connection refused, no server expected */
+ int rc = agent_http_get("http://127.0.0.1:1/models", NULL, NULL, &status,
+ &body, &err);
+ CHECK(rc == -1, "unreachable endpoint -> transport failure");
+ CHECK(err != NULL, "transport error message present");
+ CHECK(body == NULL, "no body on transport failure");
+ free(err);
+ free(body);
+}
+
+int main(void) {
+ curl_global_init(CURL_GLOBAL_DEFAULT);
+ test_extract_openai_context_length();
+ test_openai_model_present();
+ test_extract_ollama_context_length();
+ test_extract_anthropic_max_tokens();
+ test_anthropic_model_present();
+ test_probe_helpers();
+ test_default_base_urls();
+ test_models_url();
+ test_auth();
+ test_http_get_error_path();
+ curl_global_cleanup();
+ printf("test_agent: %d checks, %d failures\n", checks, failures);
+ return failures ? 1 : 0;
+}
diff --git a/tests/test_clio.c b/tests/test_clio.c
new file mode 100644
index 0000000..535df1d
--- /dev/null
+++ b/tests/test_clio.c
@@ -0,0 +1,884 @@
+/* test_clio.c — standalone test suite for clio.h (+ escape.h).
+ * Zero dependencies: single TU, no linked libraries (defines
+ * ESCAPE_IMPLEMENTATION to pull in the esc_* symbols). */
+#define ESCAPE_IMPLEMENTATION
+#include "clio.h"
+
+#include <fcntl.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+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)
+
+/* Capture stdout (fd 1) into a malloc'd buffer across the test body. */
+typedef struct cap {
+ int pfd[2];
+ int saved;
+} cap_t;
+
+/* dup2() returns the target fd (STDOUT_FILENO == 1) on success, -1 on
+ * failure — compare against -1, not 0. */
+static int cap_begin(cap_t *c) {
+ if (pipe(c->pfd) != 0)
+ return -1;
+ c->saved = dup(STDOUT_FILENO);
+ if (c->saved < 0)
+ return -1;
+ if (dup2(c->pfd[1], STDOUT_FILENO) == -1) {
+ close(c->pfd[0]);
+ close(c->pfd[1]);
+ close(c->saved);
+ return -1;
+ }
+ close(c->pfd[1]);
+ return 0;
+}
+
+static char *cap_end(cap_t *c, size_t *len) {
+ fflush(stdout);
+ if (dup2(c->saved, STDOUT_FILENO) == -1) {
+ close(c->saved);
+ return NULL;
+ }
+ close(c->saved);
+ size_t cap = 256;
+ size_t n = 0;
+ char *buf = (char *) malloc(cap);
+ char tmp[1024];
+ ssize_t r;
+ while ((r = read(c->pfd[0], tmp, sizeof tmp)) > 0) {
+ if (n + (size_t) r > cap) {
+ cap = (cap + (size_t) r) * 2;
+ buf = (char *) realloc(buf, cap);
+ }
+ memcpy(buf + n, tmp, (size_t) r);
+ n += (size_t) r;
+ }
+ close(c->pfd[0]);
+ buf[n] = '\0'; /* callers use both strlen() and len */
+ *len = n;
+ return buf;
+}
+
+static int contains(const char *hay, size_t len, const char *needle) {
+ size_t nl = strlen(needle);
+ if (nl > len)
+ return 0;
+ for (size_t i = 0; i + nl <= len; i++)
+ if (memcmp(hay + i, needle, nl) == 0)
+ return 1;
+ return 0;
+}
+
+/* ------------------------------------------------------------------ */
+/* Text: UTF-8 + width */
+/* ------------------------------------------------------------------ */
+
+static void test_utf8(void) {
+ u32 cp;
+ char out[8];
+ CHECK(clio_utf8_next("a", &cp) == 1 && cp == 'a', "ascii decode");
+ CHECK(clio_utf8_next("\xC3\xA9", &cp) == 2 && cp == 0xE9, "latin1 decode");
+ CHECK(clio_utf8_next("\xE4\xB8\xAD", &cp) == 3 && cp == 0x4E2D,
+ "cjk decode");
+ CHECK(clio_utf8_next("\xF0\x9F\x98\x80", &cp) == 4 && cp == 0x1F600,
+ "emoji decode");
+ /* truncated sequence: consumes what is left */
+ CHECK(clio_utf8_next("\xE4\xB8", &cp) == 2, "truncated decode");
+ CHECK(clio_utf8_enc(0x4E2D, out) == 3 && memcmp(out, "\xE4\xB8\xAD", 3) == 0,
+ "cjk encode");
+ CHECK(clio_utf8_enc(0x1F600, out) == 4, "emoji encode");
+ CHECK(clio_utf8_enc(0xD800, out) == -1, "surrogate rejected");
+ CHECK(clio_utf8_enc(0x110000, out) == -1, "out of range rejected");
+
+ CHECK(clio_text_width("abc") == 3, "ascii width");
+ CHECK(clio_text_width("h\xC3\xA9llo") == 5, "latin width");
+ CHECK(clio_text_width("\xE4\xB8\xAD\xE6\x96\x87") == 4, "cjk width");
+ CHECK(clio_text_width("\xF0\x9F\x98\x80") == 2, "emoji width");
+ CHECK(clio_cp_width('\n') == 0, "newline zero width");
+
+ const char *s = "ab\xE4\xB8\xAD" "c"; /* a b 中 c */
+ CHECK(clio_char_start(s, 4) == 2, "char_start cjk");
+ CHECK(clio_char_start(s, 2) == 2, "char_start at boundary");
+ CHECK(clio_char_start(s, 1) == 1, "char_start ascii");
+ CHECK(clio_char_end(s, 6, 2) == 5, "char_end cjk");
+ CHECK(clio_char_end(s, 6, 5) == 6, "char_end last");
+
+ /* semantics verified against bash/readline Ctrl+W via pty:
+ * "foo bar baz"@12 -> 7, "foo bar"@8 -> 3, "a_b c"@5 -> 3 */
+ CHECK(clio_word_back("foo bar baz", 12) == 7, "word_back word");
+ CHECK(clio_word_back("foo bar", 8) == 3, "word_back across spaces");
+ CHECK(clio_word_back("a_b c", 5) == 3, "word_back underscore");
+}
+
+/* ------------------------------------------------------------------ */
+/* Canvas */
+/* ------------------------------------------------------------------ */
+
+static void test_buf(void) {
+ clio_buf b;
+ clio_style s = clio_style_default();
+ s.fg = 248;
+ s.bg = 238;
+ s.attr = CLIO_BOLD | CLIO_REVERSE;
+
+ CHECK(clio_buf_init(&b, 3, 8) == 0, "buf init");
+ CHECK(b.rows == 3 && b.cols == 8, "buf dims");
+
+ /* default cell is blank + default style */
+ clio_cell *c0 = b.cells;
+ CHECK(c0->ch == 0 && c0->fg == 0xFFFF && c0->bg == 0xFFFF && c0->attr == 0,
+ "default cell");
+
+ CHECK(clio_buf_put(&b, 0, 0, "hi", s, 0) == 2, "put ascii");
+ CHECK(b.cells[0].ch == 'h' && b.cells[1].ch == 'i', "put stored");
+ CHECK(b.cells[0].fg == 248 && b.cells[0].bg == 238, "put style");
+ CHECK(b.cells[0].attr == (CLIO_BOLD | CLIO_REVERSE), "put attr");
+ CHECK(b.cells[2].ch == 0, "put bounds");
+
+ /* clipping: no wrap */
+ CHECK(clio_buf_put(&b, 0, 6, "abc", s, 0) == 2, "clip no wrap");
+ CHECK(b.cells[7].ch == 'b', "clip stored edge");
+ clio_buf_clear(&b);
+
+ /* wrapping onto next row */
+ CHECK(clio_buf_put(&b, 0, 6, "abcd", s, 1) == 4, "wrap put");
+ CHECK(b.cells[7].ch == 'b', "wrap first row edge");
+ CHECK(b.cells[8].ch == 'c' && b.cells[9].ch == 'd', "wrap second row");
+
+ clio_buf_clear(&b);
+
+ /* newline moves to next row at col 0 */
+ clio_buf_put(&b, 0, 0, "a\nb", s, 1);
+ CHECK(b.cells[0].ch == 'a' && b.cells[8].ch == 'b', "newline put");
+
+ clio_buf_clear(&b);
+
+ /* wide char occupies two cells */
+ clio_buf_put(&b, 0, 0, "\xE4\xB8\xAD\xE6\x96\x87", s, 1);
+ CHECK(b.cells[0].ch == 0x4E2D && b.cells[1].ch == 0, "wide first cell");
+ CHECK(b.cells[2].ch == 0x6587 && b.cells[3].ch == 0, "wide second cell");
+
+ clio_buf_clear(&b);
+
+ /* zero-width chars consume no cell */
+ clio_buf_put(&b, 0, 0, "\xE2\x80\x8Bx", s, 1); /* ZWSP + x */
+ CHECK(b.cells[0].ch == 'x', "zero width skips");
+
+ clio_buf_clear(&b);
+
+ /* fill + clear region */
+ clio_buf_fill_region(&b, 0, 0, 1, 3, '*', s);
+ CHECK(b.cells[0].ch == '*' && b.cells[3].ch == '*', "fill row 0");
+ CHECK(b.cells[8].ch == '*' && b.cells[11].ch == '*', "fill row 1");
+ CHECK(b.cells[4].ch == 0, "fill bounds");
+ clio_buf_clear_region(&b, 0, 1, 1, 2);
+ CHECK(b.cells[0].ch == '*' && b.cells[1].ch == 0 && b.cells[2].ch == 0,
+ "clear region");
+ CHECK(b.cells[10].ch == 0 && b.cells[11].ch == '*', "clear region row 1");
+ clio_buf_clear(&b);
+
+ /* box 2x4 in top-left */
+ clio_buf_box(&b, 0, 0, 4, 2, '#', s);
+ CHECK(b.cells[0].ch == '#' && b.cells[3].ch == '#', "box top");
+ CHECK(b.cells[8].ch == '#' && b.cells[11].ch == '#', "box bottom");
+ clio_buf_clear(&b);
+
+ /* scroll up: rows 0..2 shift, bottom becomes blank */
+ clio_buf_put(&b, 0, 0, "aa", s, 0);
+ clio_buf_put(&b, 1, 0, "bb", s, 0);
+ clio_buf_put(&b, 2, 0, "cc", s, 0);
+ clio_buf_scroll_up(&b, 0, 0, 2, 1);
+ CHECK(b.cells[0].ch == 'b' && b.cells[8].ch == 'c', "scroll moved");
+ CHECK(b.cells[16].ch == 0, "scroll bottom blank");
+
+ clio_buf_free(&b);
+ CHECK(b.cells == NULL, "buf free");
+
+ /* init with zero dims clamps to 1x1 */
+ CHECK(clio_buf_init(&b, 0, 0) == 0 && b.rows == 1 && b.cols == 1,
+ "buf init clamps");
+ clio_buf_free(&b);
+}
+
+/* ------------------------------------------------------------------ */
+/* Key decoder */
+/* ------------------------------------------------------------------ */
+
+static size_t decode(clio_keydec *d, const char *in, clio_key *k, size_t cap) {
+ clio_keydec_init(d);
+ return clio_keydec_feed(d, (const unsigned char *) in, strlen(in), k, cap);
+}
+
+static void test_keydec(void) {
+ clio_keydec d;
+ clio_key k[16];
+ size_t n;
+
+ n = decode(&d, "\x1B[C", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_RIGHT, "csi right");
+ n = decode(&d, "\x1B[D", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_LEFT, "csi left");
+ n = decode(&d, "\x1B[A", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_UP, "csi up");
+ n = decode(&d, "\x1B[B", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_DOWN, "csi down");
+ n = decode(&d, "\x1B[H", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_HOME, "csi home");
+ n = decode(&d, "\x1B[F", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_END, "csi end");
+ n = decode(&d, "\x1B[Z", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_BACKTAB, "csi backtab");
+ n = decode(&d, "\x1B[1~", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_HOME, "csi 1~ home");
+ n = decode(&d, "\x1B[7~", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_HOME, "csi 7~ home");
+ n = decode(&d, "\x1B[3~", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_DELETE, "csi 3~ delete");
+ n = decode(&d, "\x1B[4~", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_END, "csi 4~ end");
+ n = decode(&d, "\x1B[5~", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_PAGEUP, "csi 5~ pageup");
+ n = decode(&d, "\x1B[6~", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_PAGEDOWN, "csi 6~ pagedown");
+
+ n = decode(&d, "\x1BOA", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_UP, "ss3 up");
+ n = decode(&d, "\x1BOF", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_END, "ss3 end");
+
+ n = decode(&d, "\t\r\x7F\x08", k, 16);
+ CHECK(n == 4 && k[0].kind == CLIOK_TAB && k[1].kind == CLIOK_ENTER &&
+ k[2].kind == CLIOK_BACKSPACE && k[3].kind == CLIOK_BACKSPACE,
+ "tab enter backspace del8");
+
+ n = decode(&d, "\x01\x03\x04\x05\x0B\x0C\x15\x17\x19\x1A", k, 16);
+ CHECK(n == 10 && k[0].kind == CLIOK_CTRL_A && k[1].kind == CLIOK_CTRL_C &&
+ k[2].kind == CLIOK_CTRL_D && k[3].kind == CLIOK_CTRL_E &&
+ k[4].kind == CLIOK_CTRL_K && k[5].kind == CLIOK_CTRL_L &&
+ k[6].kind == CLIOK_CTRL_U && k[7].kind == CLIOK_CTRL_W &&
+ k[8].kind == CLIOK_CTRL_Y && k[9].kind == CLIOK_CTRL_Z,
+ "ctrl letters");
+
+ n = decode(&d, "\x07", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_CTRL_G, "ctrl g");
+ n = decode(&d, "\x1B", k, 16);
+ CHECK(n == 0, "bare esc not emitted yet");
+ CHECK(clio_keydec_flush(&d, &k[0]) == 1 && k[0].kind == CLIOK_ESC,
+ "esc flush");
+
+ n = decode(&d, "h\xC3\xA9\xE4\xB8\xAD", k, 16);
+ CHECK(n == 3 && k[0].cp == 'h' && k[1].cp == 0xE9 && k[2].cp == 0x4E2D,
+ "utf8 keys");
+
+ /* kitty protocol */
+ n = decode(&d, "\x1B[13u", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_ENTER, "kitty enter");
+ n = decode(&d, "\x1B[127u", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_DELETE, "kitty delete");
+ n = decode(&d, "\x1B[9u", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_TAB, "kitty tab");
+ n = decode(&d, "\x1B[27u", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_ESC, "kitty esc");
+
+ /* bracketed paste: LF inside paste is a char; start/end emit no key */
+ n = decode(&d, "\x1B[200~a\nb\x1B[201~", k, 16);
+ CHECK(n == 3 && k[0].cp == 'a' && k[1].cp == '\n' && k[2].cp == 'b',
+ "bracketed paste");
+
+ /* bare LF outside paste: dead key */
+ n = decode(&d, "\n", k, 16);
+ CHECK(n == 0, "bare lf dead");
+
+ /* split feeds: escape sequence across chunk boundaries */
+ clio_keydec_init(&d);
+ n = clio_keydec_feed(&d, (const unsigned char *) "\x1B", 1, k, 16);
+ CHECK(n == 0, "split esc");
+ n = clio_keydec_feed(&d, (const unsigned char *) "[", 1, k, 16);
+ CHECK(n == 0, "split [");
+ n = clio_keydec_feed(&d, (const unsigned char *) "C", 1, k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_RIGHT, "split C");
+
+ /* split UTF-8 across chunk boundaries */
+ clio_keydec_init(&d);
+ n = clio_keydec_feed(&d, (const unsigned char *) "\xC3", 1, k, 16);
+ CHECK(n == 0, "split utf8 lead");
+ n = clio_keydec_feed(&d, (const unsigned char *) "\xA9", 1, k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_CHAR && k[0].cp == 0xE9, "split utf8");
+
+ /* unknown CSI emits UNKNOWN so the app can react (e.g. beep) */
+ n = decode(&d, "\x1B[15~", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_UNKNOWN, "unknown csi -> UNKNOWN");
+ /* cursor position report swallowed */
+ n = decode(&d, "\x1B[12;40R", k, 16);
+ CHECK(n == 0, "cpr swallowed");
+}
+
+/* ------------------------------------------------------------------ */
+/* Color policy */
+/* ------------------------------------------------------------------ */
+
+static void setenv0(const char *k, const char *v) {
+ if (v)
+ setenv(k, v, 1);
+ else
+ unsetenv(k);
+}
+
+static void test_color_policy(void) {
+ setenv0("CLICOLOR_FORCE", NULL);
+ setenv0("NO_COLOR", NULL);
+ setenv0("CI", NULL);
+ setenv0("TERM", NULL);
+ CHECK(clio_color_policy(1) == 1, "tty on");
+ CHECK(clio_color_policy(0) == 0, "notty off");
+ setenv0("NO_COLOR", "1");
+ CHECK(clio_color_policy(1) == 0, "NO_COLOR off");
+ setenv0("NO_COLOR", NULL);
+ setenv0("TERM", "dumb");
+ CHECK(clio_color_policy(1) == 0, "TERM=dumb off");
+ setenv0("TERM", NULL);
+ setenv0("CI", "true");
+ CHECK(clio_color_policy(1) == 0, "CI off");
+ setenv0("CI", NULL);
+ setenv0("CLICOLOR_FORCE", "1");
+ CHECK(clio_color_policy(0) == 1, "force on, notty");
+ setenv0("NO_COLOR", "1");
+ CHECK(clio_color_policy(1) == 1, "force beats NO_COLOR");
+ setenv0("CLICOLOR_FORCE", "0");
+ CHECK(clio_color_policy(1) == 0, "force 0 off");
+ setenv0("CLICOLOR_FORCE", NULL);
+}
+
+/* ------------------------------------------------------------------ */
+/* Style emission */
+/* ------------------------------------------------------------------ */
+
+static void test_fstyle(void) {
+ cap_t c;
+ clio_style s = clio_style_default();
+ s.fg = 38;
+ s.bg = 48;
+ s.attr = CLIO_BOLD | CLIO_UNDERLINE;
+
+ CHECK(cap_begin(&c) == 0, "cap begin 1");
+ clio_fstyle(stdout, s);
+ size_t len;
+ char *out = cap_end(&c, &len);
+ CHECK(out != NULL && len > 0, "fstyle wrote");
+ if (out) {
+ /* exact sequence: reset, bold, underline, fg, bg (in that order) */
+ CHECK(contains(out, len, ESC_RESET) == 1, "fstyle reset");
+ CHECK(contains(out, len, ESC_BOLD) == 1, "fstyle bold");
+ CHECK(contains(out, len, ESC_UNDERLINE) == 1, "fstyle underline");
+ CHECK(contains(out, len, ESC_COLOR_FG_256(38)) == 1, "fstyle fg256");
+ CHECK(contains(out, len, ESC_COLOR_BG_256(48)) == 1, "fstyle bg256");
+ CHECK(contains(out, len, "48;5;48m") == 1, "bg uses 48 not 38");
+ CHECK(contains(out, len, ESC_ITALIC) == 0, "no italic");
+ /* reset first, fg before bg */
+ const char *rp = strstr(out, ESC_RESET);
+ const char *fp = strstr(out, ESC_COLOR_FG_256(38));
+ const char *bp = strstr(out, ESC_COLOR_BG_256(48));
+ CHECK(rp && fp && bp && rp < fp && fp < bp, "fstyle order");
+ free(out);
+ }
+
+ /* default style: only reset */
+ CHECK(cap_begin(&c) == 0, "cap begin 2");
+ clio_fstyle(stdout, clio_style_default());
+ out = cap_end(&c, &len);
+ CHECK(out != NULL && strcmp(out, ESC_RESET) == 0, "default = reset only");
+ free(out);
+}
+
+/* ------------------------------------------------------------------ */
+/* Flush (non-tty plain + tty diff) */
+/* ------------------------------------------------------------------ */
+
+static void test_flush_plain(void) {
+ clio_term t;
+ clio_term_init(&t);
+ clio_buf b;
+ clio_buf_init(&b, 2, 8);
+ clio_style s = clio_style_default();
+ s.fg = 248;
+ clio_buf_put(&b, 0, 0, "hi", s, 0);
+ clio_buf_put(&b, 1, 0, "yo", s, 0);
+ cap_t c;
+ CHECK(cap_begin(&c) == 0, "cap begin");
+ clio_flush(&t, &b);
+ size_t len;
+ char *out = cap_end(&c, &len);
+ CHECK(out != NULL, "cap end");
+ CHECK(len > 0, "plain wrote");
+ CHECK(contains(out, len, "hi"), "plain row 0");
+ CHECK(contains(out, len, "yo"), "plain row 1");
+ CHECK(contains(out, len, "\x1B") == 0, "plain has no escapes");
+ CHECK(contains(out, len, "hi\nyo\n") == 1, "plain exact lines");
+ free(out);
+ clio_buf_free(&b);
+ clio_term_free(&t);
+}
+
+static void test_flush_diff(void) {
+ clio_term t;
+ clio_term_init(&t);
+ /* force tty mode + fixed size for a deterministic test */
+ t.is_tty = 1;
+ t.rows = 3;
+ t.cols = 8;
+
+ clio_buf a;
+ clio_buf_init(&a, 3, 8);
+ clio_style s = clio_style_default();
+ clio_buf_put(&a, 0, 0, "row0", s, 0);
+ clio_buf_put(&a, 1, 0, "row1", s, 0);
+ clio_buf_put(&a, 2, 0, "row2", s, 0);
+
+ cap_t c;
+ CHECK(cap_begin(&c) == 0, "cap begin 1");
+ clio_flush(&t, &a);
+ size_t len;
+ char *out = cap_end(&c, &len);
+ CHECK(contains(out, len, ESC_CLEAR_SCREEN) == 1, "first flush clears");
+ CHECK(contains(out, len, "\x1B[1;1H") == 1, "first flush row 1 pos");
+ CHECK(contains(out, len, "\x1B[3;1H") == 1, "first flush row 3 pos");
+ CHECK(contains(out, len, "row0") == 1, "first flush content");
+ free(out);
+
+ /* change only row 2 */
+ clio_buf b;
+ clio_buf_init(&b, 3, 8);
+ clio_buf_put(&b, 0, 0, "row0", s, 0);
+ clio_buf_put(&b, 1, 0, "row1", s, 0);
+ clio_buf_put(&b, 2, 0, "CHG2", s, 0);
+
+ CHECK(cap_begin(&c) == 0, "cap begin 2");
+ clio_flush(&t, &b);
+ out = cap_end(&c, &len);
+ CHECK(contains(out, len, "\x1B[3;1H") == 1, "diff row 3 pos");
+ CHECK(contains(out, len, "CHG2") == 1, "diff row 2 content");
+ CHECK(contains(out, len, "\x1B[1;1H") == 0, "diff skips row 1");
+ CHECK(contains(out, len, ESC_CLEAR_SCREEN) == 0, "diff no clear");
+ CHECK(contains(out, len, "row0") == 0, "diff skips row 0 content");
+ free(out);
+
+ /* size change forces full repaint */
+ clio_buf d2;
+ clio_buf_init(&d2, 2, 4);
+ clio_buf_put(&d2, 0, 0, "xy", s, 0);
+ clio_buf_put(&d2, 1, 0, "zw", s, 0);
+ clio_buf_free(&t.last);
+ t.has_last = 0;
+ t.rows = 2;
+ t.cols = 4;
+ CHECK(cap_begin(&c) == 0, "cap begin 3");
+ clio_flush(&t, &d2);
+ out = cap_end(&c, &len);
+ CHECK(contains(out, len, ESC_CLEAR_SCREEN) == 1, "resize full repaint");
+ CHECK(contains(out, len, "\x1B[1;1H") == 1, "resize row 1");
+ free(out);
+
+ clio_buf_free(&a);
+ clio_buf_free(&b);
+ clio_buf_free(&d2);
+ clio_term_free(&t);
+}
+
+/* ------------------------------------------------------------------ */
+/* Term toggles emit the right codes */
+/* ------------------------------------------------------------------ */
+
+static void test_term_toggles(void) {
+ clio_term t;
+ clio_term_init(&t);
+ t.is_tty = 1;
+ cap_t c;
+ CHECK(cap_begin(&c) == 0, "cap begin");
+ clio_term_alt_on(&t);
+ clio_term_cursor_hide(&t);
+ clio_term_paste_on(&t);
+ clio_term_kitty_on(&t);
+ clio_term_bell(&t);
+ clio_term_alt_on(&t); /* idempotent: no second emission */
+ size_t len;
+ char *out = cap_end(&c, &len);
+ CHECK(contains(out, len, ESC_ALT_SCREEN_ON) == 1, "alt on");
+ CHECK(contains(out, len, ESC_ALT_SCREEN_ON) == 1, "alt on once");
+ CHECK(contains(out, len, ESC_CURSOR_HIDE) == 1, "cursor hide");
+ CHECK(contains(out, len, ESC_BRACKETED_PASTE_ON) == 1, "paste on");
+ CHECK(contains(out, len, ESC_KITTY_ON) == 1, "kitty on");
+ CHECK(contains(out, len, ESC_BELL) == 1, "bell");
+ free(out);
+
+ /* shutdown restores everything, in reverse, once each */
+ CHECK(cap_begin(&c) == 0, "cap begin 2");
+ clio_term_shutdown(&t);
+ out = cap_end(&c, &len);
+ CHECK(contains(out, len, ESC_KITTY_OFF) == 1, "kitty off");
+ CHECK(contains(out, len, ESC_BRACKETED_PASTE_OFF) == 1, "paste off");
+ CHECK(contains(out, len, ESC_CURSOR_SHOW) == 1, "cursor show");
+ CHECK(contains(out, len, ESC_ALT_SCREEN_OFF) == 1, "alt off");
+ /* order: kitty first, alt last */
+ if (out) {
+ const char *kp = strstr(out, ESC_KITTY_OFF);
+ const char *ap = strstr(out, ESC_ALT_SCREEN_OFF);
+ CHECK(kp && ap && kp < ap, "shutdown order");
+ }
+ free(out);
+ clio_term_free(&t);
+}
+
+/* ------------------------------------------------------------------ */
+/* Readline (scripted key source) */
+/* ------------------------------------------------------------------ */
+
+typedef struct keys {
+ clio_key *ks;
+ size_t n;
+ size_t i;
+} keys_t;
+
+static int keys_next(void *ud, clio_key *k, int timeout_ms) {
+ (void) timeout_ms;
+ keys_t *s = (keys_t *) ud;
+ if (s->i >= s->n)
+ return 0;
+ *k = s->ks[s->i++];
+ return 1;
+}
+
+static void push(keys_t *s, int kind, u32 cp) {
+ s->ks[s->n].kind = kind;
+ s->ks[s->n].cp = cp;
+ s->n++;
+}
+
+static char *run_readline(keys_t *s, int echo, int rows, int cols) {
+ clio_term t;
+ clio_term_init(&t);
+ t.is_tty = 1;
+ t.rows = (size_t) rows;
+ t.cols = (size_t) cols;
+ clio_buf b;
+ clio_buf_init(&b, (size_t) rows, (size_t) cols);
+ clio_style p = clio_style_default();
+ clio_style st = clio_style_default();
+ s->i = 0;
+ char *r = clio_readline(&t, &b, 0, "> ", p, st, echo, keys_next, s, 0);
+ clio_buf_free(&b);
+ clio_term_free(&t);
+ return r;
+}
+
+static void test_readline(void) {
+ keys_t s;
+ clio_key ks[64];
+ s.ks = ks;
+ s.n = 0;
+ char *r;
+
+ /* type "ab", backspace, "c" -> "ac" */
+ push(&s, CLIOK_CHAR, 'a');
+ push(&s, CLIOK_CHAR, 'b');
+ push(&s, CLIOK_BACKSPACE, 0);
+ push(&s, CLIOK_CHAR, 'c');
+ push(&s, CLIOK_ENTER, 0);
+ r = run_readline(&s, 1, 24, 80);
+ CHECK(r != NULL && strcmp(r, "ac") == 0, "insert backspace");
+ free(r);
+
+ /* left arrow + insert: caret between a and c, x goes there -> "axc" */
+ s.n = 0;
+ push(&s, CLIOK_CHAR, 'a');
+ push(&s, CLIOK_CHAR, 'c');
+ push(&s, CLIOK_LEFT, 0);
+ push(&s, CLIOK_CHAR, 'x');
+ push(&s, CLIOK_ENTER, 0);
+ r = run_readline(&s, 1, 24, 80);
+ CHECK(r && strcmp(r, "axc") == 0, "left insert");
+ free(r);
+
+ /* ctrl+a, 'z' -> "zaxc" */
+ s.n = 0;
+ push(&s, CLIOK_CHAR, 'a');
+ push(&s, CLIOK_CHAR, 'x');
+ push(&s, CLIOK_CHAR, 'c');
+ push(&s, CLIOK_CTRL_A, 0);
+ push(&s, CLIOK_CHAR, 'z');
+ push(&s, CLIOK_ENTER, 0);
+ r = run_readline(&s, 1, 24, 80);
+ CHECK(r && strcmp(r, "zaxc") == 0, "ctrl+a insert");
+ free(r);
+
+ /* ctrl+u clears line -> "" (submit empty) */
+ s.n = 0;
+ push(&s, CLIOK_CHAR, 'a');
+ push(&s, CLIOK_CHAR, 'b');
+ push(&s, CLIOK_CTRL_U, 0);
+ push(&s, CLIOK_ENTER, 0);
+ r = run_readline(&s, 1, 24, 80);
+ CHECK(r == NULL, "ctrl+u clears all");
+
+ /* ctrl+a then ctrl+k kills whole line -> empty submit */
+ s.n = 0;
+ push(&s, CLIOK_CHAR, 'a');
+ push(&s, CLIOK_CHAR, 'b');
+ push(&s, CLIOK_CHAR, 'c');
+ push(&s, CLIOK_CTRL_A, 0);
+ push(&s, CLIOK_CTRL_K, 0);
+ push(&s, CLIOK_ENTER, 0);
+ r = run_readline(&s, 1, 24, 80);
+ CHECK(r == NULL, "ctrl+k empty after");
+ s.n = 0;
+ push(&s, CLIOK_CHAR, 'a');
+ push(&s, CLIOK_CHAR, 'b');
+ push(&s, CLIOK_CHAR, 'c');
+ push(&s, CLIOK_LEFT, 0);
+ push(&s, CLIOK_CTRL_K, 0);
+ push(&s, CLIOK_ENTER, 0);
+ r = run_readline(&s, 1, 24, 80);
+ CHECK(r && strcmp(r, "ab") == 0, "ctrl+k kills rest");
+ free(r);
+
+ /* ctrl+w deletes a word */
+ s.n = 0;
+ push(&s, CLIOK_CHAR, 'h');
+ push(&s, CLIOK_CHAR, 'e');
+ push(&s, CLIOK_CHAR, 'l');
+ push(&s, CLIOK_CHAR, 'l');
+ push(&s, CLIOK_CHAR, 'o');
+ push(&s, CLIOK_CHAR, ' ');
+ push(&s, CLIOK_CHAR, 'w');
+ push(&s, CLIOK_CHAR, 'o');
+ push(&s, CLIOK_CHAR, 'r');
+ push(&s, CLIOK_CHAR, 'l');
+ push(&s, CLIOK_CHAR, 'd');
+ push(&s, CLIOK_CTRL_W, 0);
+ push(&s, CLIOK_ENTER, 0);
+ r = run_readline(&s, 1, 24, 80);
+ /* matches bash/readline: the preceding space is killed too */
+ CHECK(r && strcmp(r, "hello") == 0, "ctrl+w word");
+ free(r);
+
+ /* ctrl+c cancels */
+ s.n = 0;
+ push(&s, CLIOK_CHAR, 'a');
+ push(&s, CLIOK_CTRL_C, 0);
+ r = run_readline(&s, 1, 24, 80);
+ CHECK(r == NULL, "ctrl+c cancels");
+
+ /* escape cancels */
+ s.n = 0;
+ push(&s, CLIOK_CHAR, 'a');
+ push(&s, CLIOK_ESC, 0);
+ r = run_readline(&s, 1, 24, 80);
+ CHECK(r == NULL, "esc cancels");
+
+ /* ctrl+d on empty submits */
+ s.n = 0;
+ push(&s, CLIOK_CTRL_D, 0);
+ r = run_readline(&s, 1, 24, 80);
+ CHECK(r == NULL, "ctrl+d empty submits");
+
+ /* ctrl+d deletes the char after the caret (left first) */
+ s.n = 0;
+ push(&s, CLIOK_CHAR, 'a');
+ push(&s, CLIOK_CHAR, 'b');
+ push(&s, CLIOK_LEFT, 0);
+ push(&s, CLIOK_CTRL_D, 0);
+ push(&s, CLIOK_ENTER, 0);
+ r = run_readline(&s, 1, 24, 80);
+ CHECK(r && strcmp(r, "a") == 0, "ctrl+d delete");
+ free(r);
+
+ /* delete key removes forward char (caret in the middle) */
+ s.n = 0;
+ push(&s, CLIOK_CHAR, 'a');
+ push(&s, CLIOK_CHAR, 'b');
+ push(&s, CLIOK_LEFT, 0);
+ push(&s, CLIOK_DELETE, 0);
+ push(&s, CLIOK_ENTER, 0);
+ r = run_readline(&s, 1, 24, 80);
+ CHECK(r && strcmp(r, "a") == 0, "delete forward");
+ free(r);
+
+ /* utf8 input round trip */
+ s.n = 0;
+ push(&s, CLIOK_CHAR, 0x4E2D); /* 中 */
+ push(&s, CLIOK_CHAR, 0x6587); /* 文 */
+ push(&s, CLIOK_ENTER, 0);
+ r = run_readline(&s, 1, 24, 80);
+ CHECK(r && strcmp(r, "\xE4\xB8\xAD\xE6\x96\x87") == 0, "utf8 input");
+ free(r);
+
+ /* echo=0: stars rendered, but value is real text */
+ s.n = 0;
+ push(&s, CLIOK_CHAR, 'a');
+ push(&s, CLIOK_CHAR, 'b');
+ push(&s, CLIOK_CHAR, 'c');
+ push(&s, CLIOK_ENTER, 0);
+ r = run_readline(&s, 0, 24, 80);
+ CHECK(r && strcmp(r, "abc") == 0, "echo off value");
+ free(r);
+}
+
+/* ------------------------------------------------------------------ */
+/* Long lines: no wrap, window scrolls, backspace spans the "wrap" */
+/* ------------------------------------------------------------------ */
+
+static void test_readline_long(void) {
+ /* 10 cols, prompt "> " => 8-cell text area. */
+ clio_term t;
+ clio_term_init(&t);
+ t.is_tty = 1;
+ t.rows = 3;
+ t.cols = 10;
+ clio_buf b;
+ clio_buf_init(&b, 3, 10);
+ clio_style p = clio_style_default();
+ clio_style st = clio_style_default();
+ clio_str txt;
+ clio_str_init(&txt);
+ clio_str_ins(&txt, 0, "abcdefghijkl", 12);
+ size_t col;
+
+ /* caret at end: caret pinned to last col, tail visible, head scrolled */
+ col = clio_readline_paint(&b, 0, "> ", p, &txt, 12, st, 1);
+ CHECK(col == 9, "caret pinned last col");
+ CHECK(b.cells[2].ch == 'f' && b.cells[8].ch == 'l', "tail window");
+ CHECK(b.cells[9].ch == 0, "nothing past caret");
+
+ /* one backspace of scroll: window slides left by one */
+ col = clio_readline_paint(&b, 0, "> ", p, &txt, 11, st, 1);
+ CHECK(col == 9, "caret stays pinned");
+ CHECK(b.cells[2].ch == 'e' && b.cells[9].ch == 'l', "window slid");
+
+ /* caret in the middle: anchored at 0, right edge clipped */
+ col = clio_readline_paint(&b, 0, "> ", p, &txt, 3, st, 1);
+ CHECK(col == 5, "caret middle");
+ CHECK(b.cells[2].ch == 'a' && b.cells[9].ch == 'h', "head window clipped");
+
+ /* wide chars: 8 CJK = 16 cells; window must start on a char
+ * boundary and the caret clamp inside the row */
+ clio_str_free(&txt);
+ clio_str_init(&txt);
+ static const char cjk[] = "\xE4\xB8\xAD\xE4\xB8\xAD\xE4\xB8\xAD\xE4\xB8\xAD"
+ "\xE4\xB8\xAD\xE4\xB8\xAD\xE4\xB8\xAD\xE4\xB8\xAD";
+ clio_str_ins(&txt, 0, cjk, sizeof cjk - 1);
+ col = clio_readline_paint(&b, 0, "> ", p, &txt, sizeof cjk - 1, st, 1);
+ CHECK(col == 9, "wide caret clamped");
+ CHECK(b.cells[2].ch == 0x4E2D && b.cells[8].ch == 0x4E2D,
+ "wide window on boundary");
+
+ /* noecho: same windowing over star count */
+ clio_str_free(&txt);
+ clio_str_init(&txt);
+ clio_str_ins(&txt, 0, "abcdefghijkl", 12);
+ col = clio_readline_paint(&b, 0, "> ", p, &txt, 12, st, 0);
+ CHECK(col == 9, "star caret pinned");
+ CHECK(b.cells[2].ch == '*' && b.cells[8].ch == '*' && b.cells[9].ch == 0,
+ "star window");
+
+ clio_str_free(&txt);
+ clio_buf_free(&b);
+ clio_term_free(&t);
+
+ /* integration: type past the edge, backspace across the scroll
+ * boundary — the logical result must be exact */
+ keys_t s;
+ clio_key ks[64];
+ s.ks = ks;
+ s.n = 0;
+ for (char ch = 'a'; ch <= 'l'; ch++)
+ push(&s, CLIOK_CHAR, (u32) ch);
+ push(&s, CLIOK_BACKSPACE, 0);
+ push(&s, CLIOK_BACKSPACE, 0);
+ push(&s, CLIOK_BACKSPACE, 0);
+ push(&s, CLIOK_ENTER, 0);
+ char *r = run_readline(&s, 1, 3, 10);
+ CHECK(r && strcmp(r, "abcdefghi") == 0, "backspace across wrap");
+ free(r);
+
+ /* left-arrow into the scrolled region, then backspace */
+ s.n = 0;
+ for (char ch = 'a'; ch <= 'l'; ch++)
+ push(&s, CLIOK_CHAR, (u32) ch);
+ push(&s, CLIOK_LEFT, 0);
+ push(&s, CLIOK_BACKSPACE, 0);
+ push(&s, CLIOK_ENTER, 0);
+ r = run_readline(&s, 1, 3, 10);
+ CHECK(r && strcmp(r, "abcdefghijl") == 0, "left+backspace across wrap");
+ free(r);
+}
+
+/* ------------------------------------------------------------------ */
+/* Readline canvas paint */
+/* ------------------------------------------------------------------ */
+
+static void test_readline_paint(void) {
+ clio_term t;
+ clio_term_init(&t);
+ t.is_tty = 1;
+ t.rows = 4;
+ t.cols = 20;
+ clio_buf b;
+ clio_buf_init(&b, 4, 20);
+ clio_style p = clio_style_default();
+ clio_style st = clio_style_default();
+ clio_str txt;
+ clio_str_init(&txt);
+
+ /* type "ab", caret after 'b' (col 4) */
+ clio_str_ins(&txt, 0, "ab", 2);
+ size_t col = clio_readline_paint(&b, 0, "> ", p, &txt, 2, st, 1);
+ CHECK(col == 4, "caret col echo");
+ CHECK(b.cells[0].ch == '>' && b.cells[1].ch == ' ', "prompt painted");
+ CHECK(b.cells[2].ch == 'a' && b.cells[3].ch == 'b', "text painted");
+
+ /* echo off: stars */
+ clio_buf_clear(&b);
+ col = clio_readline_paint(&b, 0, "> ", p, &txt, 2, st, 0);
+ CHECK(col == 4, "caret col noecho");
+ CHECK(b.cells[2].ch == '*' && b.cells[3].ch == '*', "stars painted");
+
+ /* caret in the middle */
+ clio_str_ins(&txt, 0, "ab", 2);
+ clio_str_ins(&txt, 1, "X", 1);
+ clio_str_ins(&txt, 2, "c", 1);
+ col = clio_readline_paint(&b, 0, "> ", p, &txt, 2, st, 1);
+ CHECK(col == 4, "caret middle");
+
+ clio_str_free(&txt);
+ clio_buf_free(&b);
+ clio_term_free(&t);
+}
+
+int main(void) {
+ test_utf8();
+ test_buf();
+ test_keydec();
+ test_color_policy();
+ test_fstyle();
+ test_flush_plain();
+ test_flush_diff();
+ test_term_toggles();
+ test_readline();
+ test_readline_long();
+ test_readline_paint();
+
+ printf("%d checks, %d failures\n", checks, failures);
+ return failures == 0 ? 0 : 1;
+}
diff --git a/tests/test_compact.cpp b/tests/test_compact.cpp
new file mode 100644
index 0000000..a8b64f9
--- /dev/null
+++ b/tests/test_compact.cpp
@@ -0,0 +1,517 @@
+#include "compact.h"
+#include "message.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+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_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)
+
+#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)
+
+static char *repeat_char(char c, size_t n) {
+ char *s = (char* ) xmalloc(n + 1);
+ memset(s, c, n);
+ s[n] = '\0';
+ return s;
+}
+
+/* text of the first tool-result item of a user message (or ""). */
+static char *trc_text_of(const message *m) {
+ if (!m->is_user() || m->user().parts.empty())
+ return xstrdup("");
+ const user_part &p = m->user().parts[0];
+ const user_tool_result_part *r = std::get_if<user_tool_result_part>(&p);
+ if (!r || r->content.empty())
+ return xstrdup("");
+ const trc_text *t = std::get_if<trc_text>(&r->content[0]);
+ return xstrdup(t ? t->text.c_str() : "");
+}
+
+/* ------------------------------------------------------------------ */
+/* estimate_tokens */
+/* ------------------------------------------------------------------ */
+
+static void test_estimate_tokens(void) {
+ char *body = repeat_char('a', 40);
+ message m = msg_user_text(body);
+ CHECK_EQ_U64(compact_estimate_tokens(&m, 1), 10,
+ "40 bytes -> 10 tokens");
+ free(body);
+
+ CHECK_EQ_U64(compact_estimate_tokens(NULL, 0), 0, "empty -> 0 tokens");
+}
+
+static void test_estimate_tokens_tool_call_args(void) {
+ message m = msg_assistant_tool_call("read_file", "{\"path\":\"a\"}");
+ CHECK_EQ_U64(compact_estimate_tokens(&m, 1), 6,
+ "name(9)+args(12)=21 -> 6 tokens");
+}
+
+/* ------------------------------------------------------------------ */
+/* keep_boundary */
+/* ------------------------------------------------------------------ */
+
+static void test_keep_boundary(void) {
+ message msgs[6] = {
+ msg_user_text("read the file"),
+ msg_assistant_text("ok"),
+ msg_user_tool_result("c1", "read_file", "contents"),
+ msg_assistant_text("here it is"),
+ msg_user_text("now edit it"),
+ msg_assistant_text("done"),
+ };
+ CHECK_EQ_U64(compact_keep_boundary(msgs, 6), 4,
+ "boundary is last user");
+
+ message n[2] = {
+ msg_user_tool_result("c1", "read_file", "contents"),
+ msg_assistant_text("done"),
+ };
+ CHECK_EQ_U64(compact_keep_boundary(n, 2), 2, "none -> keep all");
+
+ CHECK_EQ_U64(compact_keep_boundary(NULL, 0), 0, "empty -> 0");
+}
+
+/* ------------------------------------------------------------------ */
+/* clear_old_tool_results */
+/* ------------------------------------------------------------------ */
+
+static void test_clear_old_tool_results(void) {
+ message msgs[6] = {
+ msg_user_text("read the file"),
+ msg_assistant_text("ok"),
+ msg_user_tool_result("c1", "read_file", "lots of content"),
+ msg_assistant_text("here it is"),
+ msg_user_text("now edit it"),
+ msg_user_tool_result("c2", "write_file", "tail content"),
+ };
+ size_t boundary = compact_keep_boundary(msgs, 6);
+ CHECK_EQ_U64(boundary, 4, "boundary == 4");
+
+ size_t cleared = compact_clear_old_tool_results(msgs, 6, boundary);
+ CHECK_EQ_U64(cleared, 1, "cleared exactly one");
+
+ char *t2 = trc_text_of(&msgs[2]);
+ CHECK(xstreq(t2, CLEARED_PLACEHOLDER), "stale result placeholdered");
+ free(t2);
+
+ char *t5 = trc_text_of(&msgs[5]);
+ CHECK(xstreq(t5, "tail content"), "tail result untouched");
+ free(t5);
+
+ /* name + call preserved on the cleared result */
+ CHECK(msgs[2].is_user(), "still a user message");
+ CHECK(std::holds_alternative<user_tool_result_part>(msgs[2].user().parts[0]),
+ "still a tool result");
+ CHECK(std::get<user_tool_result_part>(msgs[2].user().parts[0]).name ==
+ "read_file",
+ "name preserved");
+ CHECK(std::get<user_tool_result_part>(msgs[2].user().parts[0]).call ==
+ "c1",
+ "call id preserved");
+
+ /* idempotent */
+ CHECK_EQ_U64(compact_clear_old_tool_results(msgs, 6, boundary), 0,
+ "second pass clears nothing");
+}
+
+/* ------------------------------------------------------------------ */
+/* transcript_markdown */
+/* ------------------------------------------------------------------ */
+
+static void test_transcript_markdown(void) {
+ message msgs[4] = {
+ msg_user_text("read the file"),
+ msg_assistant_tool_call("read_file", "{\"path\":\"a.txt\"}"),
+ msg_user_tool_result("c1", "read_file", "line one\nline two"),
+ msg_assistant_text("The file has two lines."),
+ };
+ char *out = compact_transcript_markdown(msgs, 4);
+ CHECK(out && strncmp(out, "# llsh transcript", 15) == 0, "header");
+ CHECK_CONT(out, "## User\n\nread the file", "user block");
+ CHECK_CONT(out, "### Tool call: `read_file`", "tool call heading");
+ CHECK_CONT(out, "```json", "json fence");
+ CHECK_CONT(out, "\"path\"", "json path key");
+ CHECK_CONT(out, "### Tool result: `read_file`", "tool result heading");
+ CHECK_CONT(out, "line one\nline two", "result body");
+ CHECK_CONT(out, "## Assistant\n\nThe file has two lines.", "assistant");
+ free(out);
+}
+
+static void test_transcript_markdown_empty(void) {
+ char *out = compact_transcript_markdown(NULL, 0);
+ CHECK(out && strncmp(out, "# llsh transcript", 15) == 0, "header");
+ CHECK_CONT(out, "_No messages yet._", "placeholder");
+ free(out);
+}
+
+/* ------------------------------------------------------------------ */
+/* recent_file_paths */
+/* ------------------------------------------------------------------ */
+
+static void test_recent_file_paths(void) {
+ strlist out;
+ const char *s1 = "## Goal\ndo a thing\n\n## Files\n- src/foo.rs\n"
+ "* src/bar.rs\n\n## Next steps\n- more";
+ compact_recent_file_paths(s1, &out);
+ CHECK(out.len == 2, "two paths");
+ if (out.len == 2) {
+ CHECK(xstreq(out.items[0], "src/foo.rs"), "path 0");
+ CHECK(xstreq(out.items[1], "src/bar.rs"), "path 1");
+ }
+ sl_free(&out);
+
+ compact_recent_file_paths("## Goal\nnothing here", &out);
+ CHECK(out.len == 0, "no files section -> empty");
+ sl_free(&out);
+
+ const char *s3 = "## Next steps\n- src/foo.rs\n\n## Files\n- src/bar.rs";
+ compact_recent_file_paths(s3, &out);
+ CHECK(out.len == 1, "ignores bullets outside files");
+ if (out.len == 1)
+ CHECK(xstreq(out.items[0], "src/bar.rs"), "only bar.rs");
+ sl_free(&out);
+}
+
+/* ------------------------------------------------------------------ */
+/* compose_history */
+/* ------------------------------------------------------------------ */
+
+static void test_compose_history(void) {
+ message tail[2] = {
+ msg_user_text("now edit it"),
+ msg_assistant_text("done"),
+ };
+ const char *paths[1] = {"src/foo.rs"};
+ const char *contents[1] = {"fn main() {}"};
+ history *h = compact_compose_history("## Goal\nship it", paths, contents,
+ 1, tail, 2);
+ CHECK_EQ_U64(history_len(h), 3, "one body + tail");
+ const message *first = history_at(h, 0);
+ CHECK(first->is_user(), "first is user");
+ CHECK_EQ_U64(first->user().parts.size(), 1, "single text part");
+ const std::string &body =
+ std::get<user_text_part>(first->user().parts[0]).text;
+ CHECK_CONT(body.c_str(), "## Goal", "summary present");
+ CHECK_CONT(body.c_str(), "src/foo.rs", "file path present");
+ CHECK_CONT(body.c_str(), "fn main() {}", "file content present");
+ CHECK(history_at(h, 1)->is_user(), "tail[0] user");
+ CHECK(history_at(h, 2)->is_assistant(), "tail[1] assistant");
+ history_free(h);
+ free(h);
+}
+
+static void test_compose_history_caps(void) {
+ char *big = repeat_char('x', 200000);
+ const char *paths[1] = {"big.log"};
+ const char *contents[1] = {big};
+ history *h =
+ compact_compose_history("summary", paths, contents, 1, NULL, 0);
+ CHECK_EQ_U64(history_len(h), 1, "just the body");
+ const std::string &body =
+ std::get<user_text_part>(history_at(h, 0)->user().parts[0]).text;
+ CHECK(utf8_len(body.c_str()) < 200000, "content capped, not full");
+ char *want = repeat_char('x', PER_FILE_CAP);
+ CHECK_CONT(body.c_str(), want, "first PER_FILE_CAP chars present");
+ free(want);
+ history_free(h);
+ free(h);
+ free(big);
+}
+
+/* ------------------------------------------------------------------ */
+/* transcript (flat) */
+/* ------------------------------------------------------------------ */
+
+static void test_transcript_flat(void) {
+ message msgs[3] = {
+ msg_user_text("read the file"),
+ msg_assistant_text("reading"),
+ msg_user_tool_result("c1", "read_file", "the body"),
+ };
+ char *out = compact_transcript(msgs, 3);
+ CHECK_CONT(out, "User: read the file", "user line");
+ CHECK_CONT(out, "Assistant: reading", "assistant line");
+ CHECK_CONT(out, "ToolResult(read_file): the body", "tool result line");
+ free(out);
+}
+
+/* ------------------------------------------------------------------ */
+/* compact_at / clear_at */
+/* ------------------------------------------------------------------ */
+
+static void test_thresholds(void) {
+ compact_config c;
+ compact_config_from(1000, 0.75, 0.5, &c);
+ unsigned long v;
+ CHECK(compact_at(&c, &v), "compact enabled");
+ CHECK_EQ_U64(v, 750, "compact_at == 750");
+ CHECK(clear_at(&c, &v), "clear enabled");
+ CHECK_EQ_U64(v, 500, "clear_at == 500");
+
+ compact_config d;
+ compact_config_from(1000, 0.0, 0.0, &d);
+ CHECK(!compact_at(&d, &v), "compact disabled");
+ CHECK(!clear_at(&d, &v), "clear disabled");
+}
+
+/* ------------------------------------------------------------------ */
+/* message model */
+/* ------------------------------------------------------------------ */
+
+static void test_message_model(void) {
+ message m = msg_user_tool_result("c9", "grep", "match");
+ CHECK(m.is_user(), "role user");
+ CHECK_EQ_U64(m.user().parts.size(), 1, "one part");
+ CHECK(std::holds_alternative<user_tool_result_part>(m.user().parts[0]),
+ "tool result kind");
+ CHECK(std::get<user_tool_result_part>(m.user().parts[0]).name == "grep",
+ "name");
+ CHECK(std::get<user_tool_result_part>(m.user().parts[0]).call == "c9",
+ "call");
+ CHECK(std::get<trc_text>(
+ std::get<user_tool_result_part>(m.user().parts[0]).content[0])
+ .text == "match",
+ "content");
+
+ /* copy round-trip */
+ message orig = msg_assistant_tool_call("write_file", "{\"path\":\"a\"}");
+ message copy = orig;
+ CHECK(copy.is_assistant(), "copy role");
+ CHECK_EQ_U64(copy.assistant().parts.size(), 1, "copy len");
+ CHECK(std::get<asst_tool_call_part>(copy.assistant().parts[0]).name ==
+ "write_file",
+ "copy name");
+ CHECK(std::get<asst_tool_call_part>(copy.assistant().parts[0]).args ==
+ "{\"path\":\"a\"}",
+ "copy args");
+ /* independent storage: mutating orig does not affect copy */
+ std::get<asst_tool_call_part>(orig.assistant().parts[0]).name = "other";
+ CHECK(std::get<asst_tool_call_part>(copy.assistant().parts[0]).name ==
+ "write_file",
+ "copy independent");
+
+ /* multi-part assistant message */
+ message multi = msg_assistant_text("thinking...");
+ msg_push_assistant_tool_call(multi, "run_shell", "echo hi");
+ msg_push_assistant_reasoning(multi, "because");
+ CHECK_EQ_U64(multi.assistant().parts.size(), 3, "three parts");
+ CHECK(std::holds_alternative<asst_text_part>(multi.assistant().parts[0]),
+ "part0 text");
+ CHECK(std::holds_alternative<asst_tool_call_part>(
+ multi.assistant().parts[1]),
+ "part1 tool call");
+ CHECK(std::holds_alternative<asst_reasoning_part>(
+ multi.assistant().parts[2]),
+ "part2 reasoning");
+
+ /* history push / take */
+ history h;
+ history_push(&h, msg_user_text("one"));
+ history_push(&h, msg_user_text("two"));
+ history_push(&h, msg_user_text("three"));
+ CHECK_EQ_U64(history_len(&h), 3, "len 3");
+ message mid = history_take(&h, 1);
+ CHECK(std::get<user_text_part>(mid.user().parts[0]).text == "two",
+ "take returns elem 1");
+ CHECK_EQ_U64(history_len(&h), 2, "len 2 after take");
+ CHECK(std::get<user_text_part>(history_at(&h, 0)->user().parts[0]).text ==
+ "one",
+ "elem0 intact");
+ CHECK(std::get<user_text_part>(history_at(&h, 1)->user().parts[0]).text ==
+ "three",
+ "elem1 shifted");
+ /* stack history: RAII destructor cleans up at scope exit */
+
+ /* preamble sanity */
+ const char *p = compact_summarizer_preamble();
+ CHECK_CONT(p, "## Goal", "preamble goal section");
+ CHECK_CONT(p, "## Files", "preamble files section");
+ CHECK_CONT(p, "under 800 words", "preamble word limit");
+}
+
+/* ------------------------------------------------------------------ */
+/* rubric (SelfCompact) */
+/* ------------------------------------------------------------------ */
+
+static void test_rubric_parse_valid(void) {
+ const char *answer =
+ "C1: Y -- the last message ends with a finished tool result\n"
+ "C2: Y -- 1. fact one \"x\"\n"
+ "C3: Y -- a new URL was obtained\n"
+ "N1: N -- each recent call returned new content\n";
+ compact_rubric r;
+ char *err = NULL;
+ CHECK(compact_rubric_parse(answer, &r, &err) == 0, "parse ok");
+ CHECK(err == NULL, "no err");
+ CHECK_EQ_U64(r.c1, 1, "c1 Y");
+ CHECK_EQ_U64(r.c2, 1, "c2 Y");
+ CHECK_EQ_U64(r.c3, 1, "c3 Y");
+ CHECK_EQ_U64(r.n1, 0, "n1 N");
+ CHECK(compact_rubric_fire(&r), "fires when c1&c2&c3 & !n1");
+}
+
+static void test_rubric_parse_n(void) {
+ const char *answer =
+ "C1: N -- still mid-thought\n"
+ "C2: Y -- facts\n"
+ "C3: Y -- progress\n"
+ "N1: N -- fresh results\n";
+ compact_rubric r;
+ char *err = NULL;
+ CHECK(compact_rubric_parse(answer, &r, &err) == 0, "parse ok");
+ CHECK_EQ_U64(r.c1, 0, "c1 N");
+ CHECK(!compact_rubric_fire(&r), "does not fire with c1 N");
+}
+
+static void test_rubric_parse_stuck(void) {
+ const char *answer =
+ "C1: Y -- closed\n"
+ "C2: Y -- facts\n"
+ "C3: Y -- progress\n"
+ "N1: Y -- three of last four calls repeated\n";
+ compact_rubric r;
+ char *err = NULL;
+ CHECK(compact_rubric_parse(answer, &r, &err) == 0, "parse ok");
+ CHECK_EQ_U64(r.n1, 1, "n1 Y");
+ CHECK(!compact_rubric_fire(&r), "does not fire when stuck");
+}
+
+static void test_rubric_parse_missing(void) {
+ const char *answer =
+ "C1: Y -- closed\n"
+ "C2: Y -- facts\n"
+ "C3: Y -- progress\n";
+ compact_rubric r;
+ char *err = NULL;
+ CHECK(compact_rubric_parse(answer, &r, &err) != 0, "missing N1 -> error");
+ CHECK(err != NULL, "err set");
+ free(err);
+}
+
+static void test_rubric_parse_malformed(void) {
+ const char *answer =
+ "C1: MAYBE -- closed\n"
+ "C2: Y -- facts\n"
+ "C3: Y -- progress\n"
+ "N1: N -- fresh\n";
+ compact_rubric r;
+ char *err = NULL;
+ CHECK(compact_rubric_parse(answer, &r, &err) != 0, "bad token -> error");
+ CHECK(err != NULL, "err set");
+ free(err);
+}
+
+static void test_rubric_y_without_evidence_is_n(void) {
+ /* A Y with no evidence on the line is downgraded to N (the paper's rule). */
+ const char *answer =
+ "C1: Y\n"
+ "C2: Y -- facts\n"
+ "C3: Y -- progress\n"
+ "N1: N -- fresh\n";
+ compact_rubric r;
+ char *err = NULL;
+ CHECK(compact_rubric_parse(answer, &r, &err) == 0, "parse ok");
+ CHECK_EQ_U64(r.c1, 0, "Y without evidence downgraded to N");
+ CHECK(!compact_rubric_fire(&r), "does not fire");
+}
+
+static void test_rubric_prompt_and_preamble(void) {
+ const char *p = compact_rubric_prompt();
+ CHECK_CONT(p, "C1", "prompt has C1");
+ CHECK_CONT(p, "C2", "prompt has C2");
+ CHECK_CONT(p, "C3", "prompt has C3");
+ CHECK_CONT(p, "N1", "prompt has N1");
+ CHECK_CONT(p, "C1: Y/N -- <evidence>", "prompt output format");
+
+ const char *pre = compact_rubric_preamble();
+ CHECK_CONT(pre, "judge", "preamble is a judge role");
+}
+
+static void test_rubric_due(void) {
+ /* all gates satisfied */
+ CHECK(compact_rubric_due(RUBRIC_MIN_ITERATION, RUBRIC_MIN_TOKENS, 0,
+ RUBRIC_DEFAULT_MAX_SUMMARIES,
+ RUBRIC_MIN_ROUNDS_SINCE_PROBE),
+ "due at exact thresholds");
+ /* iteration too low */
+ CHECK(!compact_rubric_due(RUBRIC_MIN_ITERATION - 1, RUBRIC_MIN_TOKENS, 0,
+ RUBRIC_DEFAULT_MAX_SUMMARIES,
+ RUBRIC_MIN_ROUNDS_SINCE_PROBE),
+ "not due: iteration");
+ /* tokens too low */
+ CHECK(!compact_rubric_due(RUBRIC_MIN_ITERATION, RUBRIC_MIN_TOKENS - 1, 0,
+ RUBRIC_DEFAULT_MAX_SUMMARIES,
+ RUBRIC_MIN_ROUNDS_SINCE_PROBE),
+ "not due: tokens");
+ /* cap reached */
+ CHECK(!compact_rubric_due(RUBRIC_MIN_ITERATION, RUBRIC_MIN_TOKENS, 1,
+ RUBRIC_DEFAULT_MAX_SUMMARIES,
+ RUBRIC_MIN_ROUNDS_SINCE_PROBE),
+ "not due: cap");
+ /* rounds since probe too low */
+ CHECK(!compact_rubric_due(RUBRIC_MIN_ITERATION, RUBRIC_MIN_TOKENS, 0,
+ RUBRIC_DEFAULT_MAX_SUMMARIES,
+ RUBRIC_MIN_ROUNDS_SINCE_PROBE - 1),
+ "not due: rounds");
+ /* unlimited cap (0) */
+ CHECK(compact_rubric_due(RUBRIC_MIN_ITERATION, RUBRIC_MIN_TOKENS, 5, 0,
+ RUBRIC_MIN_ROUNDS_SINCE_PROBE),
+ "due: unlimited cap");
+}
+
+int main(void) {
+ test_estimate_tokens();
+ test_estimate_tokens_tool_call_args();
+ test_keep_boundary();
+ test_clear_old_tool_results();
+ test_transcript_markdown();
+ test_transcript_markdown_empty();
+ test_recent_file_paths();
+ test_compose_history();
+ test_compose_history_caps();
+ test_transcript_flat();
+ test_thresholds();
+ test_message_model();
+ test_rubric_parse_valid();
+ test_rubric_parse_n();
+ test_rubric_parse_stuck();
+ test_rubric_parse_missing();
+ test_rubric_parse_malformed();
+ test_rubric_y_without_evidence_is_n();
+ test_rubric_prompt_and_preamble();
+ test_rubric_due();
+ printf("%d checks, %d failures\n", checks, failures);
+ return failures ? 1 : 0;
+}
diff --git a/tests/test_config.cpp b/tests/test_config.cpp
new file mode 100644
index 0000000..c2b5e2b
--- /dev/null
+++ b/tests/test_config.cpp
@@ -0,0 +1,654 @@
+#include "config.h"
+
+#include <math.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+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_EQ_STR(a, b, msg) \
+ do { \
+ checks++; \
+ const char *_a = (a), *_b = (b); \
+ if (!_a || (_b && strcmp(_a, _b) != 0)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: %s (got `%s`, want `%s`)\n", \
+ __FILE__, __LINE__, (msg), _a ? _a : "(null)", \
+ _b ? _b : "(null)"); \
+ } \
+ } while (0)
+
+static cli mkcli(void) {
+ cli c;
+ memset(&c, 0, sizeof c);
+ return c;
+}
+
+static void freecfg(config *c) {
+ config_free(c);
+}
+
+/* ------------------------------------------------------------------ */
+/* Provider parsing */
+/* ------------------------------------------------------------------ */
+
+static void test_provider_parsing(void) {
+ char *err = NULL;
+ CHECK(provider_parse("openai", &err) == PROV_OPENAI, "openai");
+ CHECK(provider_parse("OpenAI-compatible", &err) == PROV_OPENAI,
+ "OpenAI-compatible");
+ CHECK(provider_parse("openai_compatible", &err) == PROV_OPENAI,
+ "openai_compatible");
+ CHECK(provider_parse("Anthropic", &err) == PROV_ANTHROPIC, "Anthropic");
+ CHECK(provider_parse("claude", &err) == PROV_ANTHROPIC, "claude");
+ CHECK(provider_parse("ollama", &err) == PROV_OLLAMA, "ollama");
+
+ err = NULL;
+ provider_kind k = provider_parse("nope", &err);
+ CHECK(k == PROV_OPENAI && err && strstr(err, "nope") &&
+ strstr(err, "unknown provider"),
+ "unknown provider error");
+ free(err);
+
+ CHECK(strcmp(provider_default_model(PROV_OPENAI), "gpt-5.2") == 0,
+ "default openai model");
+ CHECK(strcmp(provider_default_model(PROV_ANTHROPIC), "claude-sonnet-4-6") ==
+ 0,
+ "default anthropic model");
+ CHECK(strcmp(provider_default_model(PROV_OLLAMA), "llama3.2") == 0,
+ "default ollama model");
+}
+
+/* ------------------------------------------------------------------ */
+/* CLI parsing */
+/* ------------------------------------------------------------------ */
+
+static void test_cli_parse_basic(void) {
+ cli c;
+ char *err = NULL;
+ const char *a1 = "--provider";
+ const char *a2 = "ollama";
+ const char *a3 = "--model";
+ const char *a4 = "llama3.2";
+ const char *a5 = "hello world";
+ const char *argv[] = {
+ "llsh", a1, a2, a3, a4, a5
+ };
+ int r = cli_parse(6, (char **)argv, &c, &err);
+ CHECK(r == 0, "parse ok");
+ CHECK_EQ_STR(c.provider, "ollama", "provider");
+ CHECK_EQ_STR(c.model, "llama3.2", "model");
+ CHECK_EQ_STR(c.prompt, "hello world", "prompt positional");
+ CHECK(!c.debug, "debug off by default");
+ cli_free(&c);
+ free(err);
+
+ /* -C and --verbose and numeric flags */
+ const char *b1 = "-C";
+ const char *b2 = "/tmp";
+ const char *b3 = "--verbose";
+ const char *b4 = "--max-turns";
+ const char *b5 = "0";
+ const char *b6 = "--max-retries";
+ const char *b7 = "2";
+ const char *b8 = "--debug";
+ const char *b9 = "--allow-mcp";
+ const char *argv2[] = {
+ "llsh", b1, b2, b3, b4, b5, b6, b7, b8, b9
+ };
+ r = cli_parse(10, (char **)argv2, &c, &err);
+ CHECK(r == 0, "parse -C/--verbose ok");
+ CHECK_EQ_STR(c.cwd, "/tmp", "cwd");
+ CHECK(c.verbose, "verbose flag");
+ CHECK(c.debug, "debug flag");
+ CHECK(c.allow_mcp, "allow-mcp trust opt-in");
+ CHECK(c.has_max_turns && c.max_turns == 0, "max-turns 0 accepted");
+ CHECK(c.has_max_retries && c.max_retries == 2, "max-retries");
+ cli_free(&c);
+ free(err);
+
+ /* unknown option */
+ const char *u1 = "--frobnicate";
+ const char *argv3[] = {
+ "llsh", u1
+ };
+ r = cli_parse(2, (char **)argv3, &c, &err);
+ CHECK(r == -1 && err, "unknown option errors");
+ free(err);
+ cli_free(&c);
+
+ /* two positionals */
+ const char *p1 = "one";
+ const char *p2 = "two";
+ const char *argv4[] = {
+ "llsh", p1, p2
+ };
+ r = cli_parse(3, (char **)argv4, &c, &err);
+ CHECK(r == -1 && err && strstr(err, "unexpected argument"),
+ "two positionals error");
+ free(err);
+ cli_free(&c);
+}
+
+static void test_cli_shell_policy_options(void) {
+ cli c;
+ char *err = NULL;
+ const char *argv[] = {"llsh", "--shell-allow", "git", "--shell-allow",
+ "rg", "--shell-deny", "rm", "--confirm-destructive"};
+ CHECK(cli_parse(8, (char **)argv, &c, &err) == 0,
+ "parse shell policy options");
+ CHECK(c.shell_allowlist.len == 2, "repeatable shell allowlist");
+ CHECK(c.shell_denylist.len == 1, "shell denylist");
+ CHECK(c.confirm_destructive, "destructive confirmation enabled");
+ config resolved;
+ CHECK(config_merge(&c, &resolved, &err) == 0,
+ "merge shell policy into runtime config");
+ CHECK(resolved.shell_allowlist.len == 2 &&
+ strcmp(resolved.shell_allowlist.items[0], "git") == 0,
+ "merged allowlist preserved");
+ CHECK(resolved.shell_denylist.len == 1 &&
+ strcmp(resolved.shell_denylist.items[0], "rm") == 0,
+ "merged denylist preserved");
+ CHECK(resolved.confirm_destructive,
+ "merged destructive confirmation enabled");
+ config_free(&resolved);
+ cli_free(&c);
+ free(err);
+}
+
+static void test_cli_parse_rejects_negative_ulong(void) {
+ /* `--max-turns -1` used to parse as ULONG_MAX via strtoull; it must error. */
+ cli c;
+ char *err = NULL;
+ const char *a1 = "--max-turns";
+ const char *a2 = "-1";
+ const char *argv[] = {"llsh", a1, a2};
+ int r = cli_parse(3, (char **)argv, &c, &err);
+ CHECK(r == -1, "negative max-turns rejected");
+ CHECK(err && strstr(err, "invalid value") != NULL, "invalid-value message");
+ free(err);
+ cli_free(&c);
+
+ /* A genuinely huge value that overflows unsigned long must also error. */
+ const char *h1 = "--max-turns";
+ const char *h2 = "99999999999999999999";
+ const char *argv2[] = {"llsh", h1, h2};
+ r = cli_parse(3, (char **)argv2, &c, &err);
+ CHECK(r == -1, "overflowing max-turns rejected");
+ free(err);
+ cli_free(&c);
+}
+
+static void test_cli_parse_fraction_thresholds(void) {
+ cli c;
+ char *err = NULL;
+ const char *good[] = {"llsh", "--compact-threshold", "0.75",
+ "--clear-threshold", "0"};
+ CHECK(cli_parse(5, (char **)good, &c, &err) == 0,
+ "valid fractions accepted");
+ CHECK(c.has_compact_threshold && c.compact_threshold == 0.75,
+ "compact fraction parsed");
+ CHECK(c.has_clear_threshold && c.clear_threshold == 0,
+ "zero disables clear fraction");
+ cli_free(&c);
+ free(err);
+ const char *bad_values[] = {"xyz", "0.75junk", "nan", "inf", "1.01"};
+ for (size_t i = 0; i < sizeof bad_values / sizeof bad_values[0]; i++) {
+ const char *args[] = {"llsh", "--compact-threshold", bad_values[i]};
+ err = NULL;
+ CHECK(cli_parse(3, (char **)args, &c, &err) == -1,
+ "malformed/out-of-range fraction rejected");
+ CHECK(err != NULL, "fraction rejection explains error");
+ free(err);
+ cli_free(&c);
+ }
+}
+
+/* ------------------------------------------------------------------ */
+/* File config loading */
+/* ------------------------------------------------------------------ */
+
+/* ------------------------------------------------------------------ */
+/* Merge */
+/* ------------------------------------------------------------------ */
+
+static void test_merge_cli_values(void) {
+ cli c = mkcli();
+ c.provider = xstrdup("anthropic");
+ c.model = xstrdup("cli-model");
+ c.api_key = xstrdup("cli-key");
+
+ config cfg;
+ char *err = NULL;
+ int r = config_merge(&c, &cfg, &err);
+ CHECK(r == 0, "merge ok");
+ CHECK(cfg.provider == PROV_ANTHROPIC, "cli provider");
+ CHECK_EQ_STR(cfg.model, "cli-model", "cli model");
+ CHECK_EQ_STR(cfg.api_key, "cli-key", "cli key");
+ freecfg(&cfg);
+ cli_free(&c);
+}
+
+static void test_merge_defaults(void) {
+ cli c = mkcli();
+ c.provider = xstrdup("ollama");
+
+ config cfg;
+ char *err = NULL;
+ int r = config_merge(&c, &cfg, &err);
+ CHECK(r == 0, "merge ok");
+ CHECK(cfg.provider == PROV_OLLAMA, "provider from cli");
+ CHECK_EQ_STR(cfg.model, "llama3.2", "provider default model");
+ CHECK(cfg.api_key == NULL, "no key for ollama");
+ CHECK(cfg.max_turns == 0, "max_turns unbounded by default");
+ CHECK(cfg.preamble && cfg.preamble[0] != '\0', "built-in preamble");
+ CHECK(cfg.max_retries == 3, "default max_retries");
+ CHECK(cfg.context_limit == 0, "no built-in window");
+ CHECK(cfg.compact_threshold > 0.74 && cfg.compact_threshold < 0.76,
+ "default compact_threshold");
+ CHECK(cfg.clear_threshold > 0.49 && cfg.clear_threshold < 0.51,
+ "default clear_threshold");
+ CHECK(!cfg.verbose, "verbose off");
+ CHECK(cfg.skill_index_cap == 50, "default skill cap");
+ CHECK(cfg.shell_timeout_secs == 120, "default shell timeout");
+ CHECK(cfg.max_output_chars == 16000, "default max output");
+ CHECK(cfg.max_file_bytes == 2000000, "default max file bytes");
+ freecfg(&cfg);
+ cli_free(&c);
+}
+
+static void test_merge_api_key_rules(void) {
+ /* openai without base_url or key: the merge defers the check so a
+ * models.json preset can still supply the key */
+ cli c = mkcli();
+ c.provider = xstrdup("openai");
+ config cfg;
+ char *err = NULL;
+ int r = config_merge(&c, &cfg, &err);
+ CHECK(r == 0, "merge defers key check");
+ r = config_require_key_or_base(&cfg, &err);
+ CHECK(r == -1 && err && strstr(err, "no API key"),
+ "openai needs key or base_url");
+ free(err);
+ config_free(&cfg);
+ cli_free(&c);
+
+ /* base_url is enough */
+ c = mkcli();
+ c.provider = xstrdup("openai");
+ c.base_url = xstrdup("http://localhost:8080/v1");
+ err = NULL;
+ r = config_merge(&c, &cfg, &err);
+ CHECK(r == 0, "base_url ok");
+ CHECK_EQ_STR(cfg.base_url, "http://localhost:8080/v1", "base_url kept");
+ CHECK(cfg.api_key == NULL, "no key needed");
+ freecfg(&cfg);
+ cli_free(&c);
+
+ /* anthropic with key is fine */
+ c = mkcli();
+ c.provider = xstrdup("anthropic");
+ c.api_key = xstrdup("k");
+ err = NULL;
+ r = config_merge(&c, &cfg, &err);
+ CHECK(r == 0, "anthropic+key ok");
+ freecfg(&cfg);
+ cli_free(&c);
+}
+
+static void test_merge_max_turns_and_retries(void) {
+ /* max_turns 0 is unbounded and stays 0 */
+ cli c = mkcli();
+ c.provider = xstrdup("ollama");
+ c.max_turns = 0;
+ c.has_max_turns = true;
+ config cfg;
+ char *err = NULL;
+ CHECK(config_merge(&c, &cfg, &err) == 0, "merge ok");
+ CHECK(cfg.max_turns == 0, "max_turns 0 stays 0 (unbounded)");
+ freecfg(&cfg);
+ cli_free(&c);
+
+ /* explicit retries are honored */
+ c = mkcli();
+ c.provider = xstrdup("ollama");
+ c.max_retries = 5;
+ c.has_max_retries = true;
+ err = NULL;
+ CHECK(config_merge(&c, &cfg, &err) == 0, "merge ok");
+ CHECK(cfg.max_retries == 5, "retries honored");
+ freecfg(&cfg);
+ cli_free(&c);
+}
+
+static void test_merge_compaction(void) {
+ cli c = mkcli();
+ c.provider = xstrdup("ollama");
+ c.context_limit = 4000;
+ c.has_context_limit = true;
+ c.compact_threshold = 0.9;
+ c.has_compact_threshold = true;
+ c.clear_threshold = 0.25;
+ c.has_clear_threshold = true;
+ config cfg;
+ char *err = NULL;
+ CHECK(config_merge(&c, &cfg, &err) == 0, "merge ok");
+ CHECK(cfg.context_limit == 4000, "context_limit");
+ CHECK(fabs(cfg.compact_threshold - 0.9) < 1e-9, "compact");
+ CHECK(fabs(cfg.clear_threshold - 0.25) < 1e-9, "clear");
+ freecfg(&cfg);
+ cli_free(&c);
+
+ /* threshold out of range */
+ int r;
+ double bad[] = {2.0, -0.1, 1.1, NAN, INFINITY};
+ for (size_t i = 0; i < sizeof bad / sizeof *bad; i++) {
+ c = mkcli();
+ c.provider = xstrdup("ollama");
+ c.compact_threshold = bad[i];
+ c.has_compact_threshold = true;
+ err = NULL;
+ config cfg2;
+ r = config_merge(&c, &cfg2, &err);
+ CHECK(r == -1 && err && strstr(err, "compact_threshold"),
+ "bad compact_threshold rejected");
+ free(err);
+ config_free(&cfg2);
+ cli_free(&c);
+ }
+ double badc[] = {-0.1, 1.5, NAN};
+ for (size_t i = 0; i < sizeof badc / sizeof *badc; i++) {
+ c = mkcli();
+ c.provider = xstrdup("ollama");
+ c.clear_threshold = badc[i];
+ c.has_clear_threshold = true;
+ err = NULL;
+ config cfg2;
+ r = config_merge(&c, &cfg2, &err);
+ CHECK(r == -1 && err && strstr(err, "clear_threshold"),
+ "bad clear_threshold rejected");
+ free(err);
+ config_free(&cfg2);
+ cli_free(&c);
+ }
+
+ /* clear < compact required when a window is known */
+ c = mkcli();
+ c.provider = xstrdup("ollama");
+ c.context_limit = 1000;
+ c.has_context_limit = true;
+ c.clear_threshold = 0.8;
+ c.has_clear_threshold = true;
+ c.compact_threshold = 0.5;
+ c.has_compact_threshold = true;
+ err = NULL;
+ config cfg2;
+ r = config_merge(&c, &cfg2, &err);
+ CHECK(r == -1 && err && strstr(err, "clear_threshold"),
+ "clear >= compact rejected");
+ free(err);
+ config_free(&cfg2);
+ cli_free(&c);
+
+ /* disabled tier (0.0) imposes no ordering constraint */
+ c = mkcli();
+ c.provider = xstrdup("ollama");
+ c.context_limit = 1000;
+ c.has_context_limit = true;
+ c.clear_threshold = 0.0;
+ c.has_clear_threshold = true;
+ c.compact_threshold = 0.5;
+ c.has_compact_threshold = true;
+ err = NULL;
+ r = config_merge(&c, &cfg2, &err);
+ CHECK(r == 0, "clear=0 disables ordering");
+ freecfg(&cfg2);
+ cli_free(&c);
+
+ /* 0 thresholds are valid (tiers off) */
+ c = mkcli();
+ c.provider = xstrdup("ollama");
+ c.compact_threshold = 0.0;
+ c.has_compact_threshold = true;
+ c.clear_threshold = 0.0;
+ c.has_clear_threshold = true;
+ err = NULL;
+ r = config_merge(&c, &cfg2, &err);
+ CHECK(r == 0, "0/0 thresholds ok");
+ freecfg(&cfg2);
+ cli_free(&c);
+}
+
+static void test_merge_lower_bounds_and_verbose(void) {
+ cli c = mkcli();
+ c.provider = xstrdup("ollama");
+ c.skill_index_cap = 0;
+ c.has_skill_index_cap = true;
+ c.shell_timeout_secs = 0;
+ c.has_shell_timeout_secs = true;
+ config cfg;
+ char *err = NULL;
+ CHECK(config_merge(&c, &cfg, &err) == 0, "merge ok");
+ CHECK(cfg.skill_index_cap == 1, "skill cap floor 1");
+ CHECK(cfg.shell_timeout_secs == 1, "shell timeout floor 1");
+ freecfg(&cfg);
+ cli_free(&c);
+
+ /* verbose from CLI */
+ c = mkcli();
+ c.provider = xstrdup("ollama");
+ c.verbose = true;
+ err = NULL;
+ CHECK(config_merge(&c, &cfg, &err) == 0, "merge ok");
+ CHECK(cfg.verbose, "cli verbose applied");
+ freecfg(&cfg);
+ cli_free(&c);
+}
+
+/* ------------------------------------------------------------------ */
+/* Presets */
+/* ------------------------------------------------------------------ */
+
+static models_file mkmodels(const char *def, const char *prov,
+ const char *model, const char *base_url,
+ const char *key, unsigned long mt,
+ unsigned long ctx) {
+ models_file m;
+ memset(&m, 0, sizeof m);
+ m.default_name = xstrdup(def);
+ m.presets = (model_preset* ) xmalloc(sizeof *m.presets);
+ memset(m.presets, 0, sizeof *m.presets);
+ m.presets[0].name = xstrdup(def);
+ m.presets[0].provider = prov ? xstrdup(prov) : NULL;
+ m.presets[0].model = model ? xstrdup(model) : NULL;
+ m.presets[0].base_url = base_url ? xstrdup(base_url) : NULL;
+ m.presets[0].api_key = key ? xstrdup(key) : NULL;
+ if (mt) {
+ m.presets[0].max_turns = mt;
+ m.presets[0].has_max_turns = true;
+ }
+ if (ctx) {
+ m.presets[0].context_limit = ctx;
+ m.presets[0].has_context_limit = true;
+ }
+ m.preset_count = 1;
+ return m;
+}
+
+static void test_preset_overrides_defaults(void) {
+ /* CLI base_url (with an unparseable-ish provider) lets the preset supply
+ * provider/model/key; every preset field applies over the base. */
+ cli c = mkcli();
+ c.base_url = xstrdup("http://localhost:8080/v1");
+ config cfg;
+ char *err = NULL;
+ CHECK(config_merge(&c, &cfg, &err) == 0, "merge ok");
+
+ models_file m = mkmodels("local", "openai", "llm", NULL, "pk", 7, 0);
+ char *name = config_apply_models_preset(&cfg, &m, &c, &err);
+ CHECK(name && strcmp(name, "local") == 0, "preset applied");
+ CHECK(cfg.provider == PROV_OPENAI, "preset provider");
+ CHECK_EQ_STR(cfg.model, "llm", "preset model");
+ CHECK_EQ_STR(cfg.base_url, "http://localhost:8080/v1", "base_url kept");
+ CHECK_EQ_STR(cfg.api_key, "pk", "preset api_key");
+ CHECK(cfg.max_turns == 7, "preset max_turns");
+ free(name);
+ models_file_free(&m);
+ freecfg(&cfg);
+ cli_free(&c);
+}
+
+static void test_preset_cli_flag_wins(void) {
+ /* CLI --model / --provider are authoritative: the preset must not clobber
+ * them (it may still fill fields the CLI left unset). */
+ cli c = mkcli();
+ c.model = xstrdup("cli-model");
+ c.provider = xstrdup("anthropic");
+ c.base_url = xstrdup("http://localhost:8080/v1");
+ config cfg;
+ char *err = NULL;
+ CHECK(config_merge(&c, &cfg, &err) == 0, "merge ok");
+
+ models_file m = mkmodels("local", "openai", "preset-model",
+ "http://preset.example/v1", NULL, 0, 0);
+ char *name = config_apply_models_preset(&cfg, &m, &c, &err);
+ CHECK(name, "preset applied");
+ CHECK(cfg.provider == PROV_ANTHROPIC, "CLI provider wins");
+ CHECK_EQ_STR(cfg.model, "cli-model", "CLI model wins");
+ CHECK_EQ_STR(cfg.base_url, "http://localhost:8080/v1", "CLI base_url wins");
+ free(name);
+ models_file_free(&m);
+ freecfg(&cfg);
+ cli_free(&c);
+}
+
+static void test_preset_errors(void) {
+ cli c = mkcli();
+ c.provider = xstrdup("ollama");
+ config cfg;
+ char *err = NULL;
+ CHECK(config_merge(&c, &cfg, &err) == 0, "merge ok");
+
+ models_file empty;
+ memset(&empty, 0, sizeof empty);
+ char *name = config_apply_models_preset(&cfg, &empty, &c, &err);
+ CHECK(!name && err && strstr(err, "no `default` preset"),
+ "no default preset error");
+ free(err);
+ err = NULL;
+
+ models_file ghost = mkmodels("ghost", NULL, NULL, NULL, NULL, 0, 0);
+ free(ghost.presets[0].name);
+ ghost.presets[0].name = xstrdup("other");
+ name = config_apply_models_preset(&cfg, &ghost, &c, &err);
+ CHECK(!name && err && strstr(err, "not in presets"),
+ "default not in presets error");
+ free(err);
+ models_file_free(&ghost);
+ freecfg(&cfg);
+ cli_free(&c);
+}
+
+static void test_preset_supplies_api_key(void) {
+ /* Base is valid via base_url but has no key; the preset fills the key
+ * (and the model) for a key-requiring provider. */
+ cli c = mkcli();
+ c.base_url = xstrdup("http://localhost:8080/v1");
+ config cfg;
+ char *err = NULL;
+ CHECK(config_merge(&c, &cfg, &err) == 0, "merge ok");
+ CHECK(cfg.api_key == NULL, "no key before preset");
+
+ models_file m = mkmodels("cloud", "openai", "cm", NULL, "pk", 0, 0);
+ char *name = config_apply_models_preset(&cfg, &m, &c, &err);
+ CHECK(name, "preset applied");
+ CHECK(cfg.provider == PROV_OPENAI, "preset provider");
+ CHECK_EQ_STR(cfg.api_key, "pk", "preset supplies api_key");
+ CHECK_EQ_STR(cfg.model, "cm", "preset model");
+ free(name);
+ models_file_free(&m);
+ freecfg(&cfg);
+ cli_free(&c);
+}
+
+static void test_preset_context_limit(void) {
+ cli c = mkcli();
+ c.provider = xstrdup("ollama");
+ c.base_url = xstrdup("http://localhost:8080/v1");
+ config cfg;
+ char *err = NULL;
+ CHECK(config_merge(&c, &cfg, &err) == 0, "merge ok");
+ models_file m = mkmodels("small", "openai", "sm", NULL, "k", 0, 8000);
+ char *name = config_apply_models_preset(&cfg, &m, &c, &err);
+ CHECK(name && cfg.context_limit == 8000, "preset context_limit applied");
+ free(name);
+ models_file_free(&m);
+ freecfg(&cfg);
+ cli_free(&c);
+
+ /* explicit CLI --context-limit wins over the preset */
+ c = mkcli();
+ c.provider = xstrdup("ollama");
+ c.base_url = xstrdup("http://localhost:8080/v1");
+ c.context_limit = 16000;
+ c.has_context_limit = true;
+ err = NULL;
+ CHECK(config_merge(&c, &cfg, &err) == 0, "merge ok");
+ m = mkmodels("small", "openai", "sm", NULL, "k", 0, 8000);
+ name = config_apply_models_preset(&cfg, &m, &c, &err);
+ CHECK(name && cfg.context_limit == 16000, "CLI context_limit wins");
+ free(name);
+ models_file_free(&m);
+ freecfg(&cfg);
+ cli_free(&c);
+}
+
+
+static void test_merge_openai_requires_key(void) {
+ cli c = mkcli();
+ c.provider = xstrdup("openai");
+ config cfg;
+ char *err = NULL;
+ int r = config_merge(&c, &cfg, &err);
+ CHECK(r == 0, "merge ok without key (preset may supply it)");
+ r = config_require_key_or_base(&cfg, &err);
+ CHECK(r == -1 && err && strstr(err, "no API key"),
+ "openai without key/base_url rejected");
+ free(err);
+ config_free(&cfg);
+ cli_free(&c);
+}
+
+int main(void) {
+ test_provider_parsing();
+ test_cli_parse_basic();
+ test_cli_shell_policy_options();
+ test_cli_parse_rejects_negative_ulong();
+ test_cli_parse_fraction_thresholds();
+ test_merge_cli_values();
+ test_merge_defaults();
+ test_merge_api_key_rules();
+ test_merge_max_turns_and_retries();
+ test_merge_compaction();
+ test_merge_lower_bounds_and_verbose();
+ test_preset_overrides_defaults();
+ test_preset_cli_flag_wins();
+ test_preset_errors();
+ test_preset_supplies_api_key();
+ test_preset_context_limit();
+ test_merge_openai_requires_key();
+ printf("test_config: %d checks, %d failures\n", checks, failures);
+ return failures ? 1 : 0;
+}
diff --git a/tests/test_dotagents.c b/tests/test_dotagents.c
new file mode 100644
index 0000000..af51506
--- /dev/null
+++ b/tests/test_dotagents.c
@@ -0,0 +1,618 @@
+/* test_dotagents.c — standalone test suite for dotagents.h (the .agents
+ * protocol layer). Zero dependencies: compiles to a single TU with no
+ * linked libraries (frontmatter.h provides everything it needs). */
+#include "dotagents.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <sys/types.h>
+#include <unistd.h>
+
+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)
+
+static char *tmpdir(const char *tag) {
+ char path[256];
+ snprintf(path, sizeof path, "/tmp/llshc-da-%s-%d", tag, (int)getpid());
+ /* rm -rf */
+ char cmd[300];
+ snprintf(cmd, sizeof cmd, "rm -rf '%s'", path);
+ if (system(cmd) != 0) {}
+ /* mkdir -p */
+ snprintf(cmd, sizeof cmd, "mkdir -p '%s'", path);
+ if (system(cmd) != 0)
+ exit(1);
+ return fm_strdup(path);
+}
+
+static void wfile_bin(const char *dir, const char *rel, const char *content) {
+ char path[512];
+ snprintf(path, sizeof path, "%s/%s", dir, rel);
+ char *slash = strrchr(path, '/');
+ if (slash) {
+ char dirpart[512];
+ size_t n = (size_t)(slash - path);
+ memcpy(dirpart, path, n);
+ dirpart[n] = '\0';
+ char cmd[600];
+ snprintf(cmd, sizeof cmd, "mkdir -p '%s'", dirpart);
+ if (system(cmd) != 0)
+ exit(1);
+ }
+ FILE *f = fopen(path, "w");
+ if (!f)
+ exit(1);
+ fputs(content, f);
+ fclose(f);
+}
+
+static int count_warns(const dotagents *d, const char *needle) {
+ int n = 0;
+ for (size_t i = 0; i < d->warnings.len; i++)
+ if (strstr(d->warnings.items[i], needle))
+ n++;
+ return n;
+}
+
+/* ------------------------------------------------------------------ */
+/* Layer tests */
+/* ------------------------------------------------------------------ */
+
+static void test_loads_global_only(void) {
+ char *ws = tmpdir("ws1");
+ char *global = tmpdir("g1");
+ wfile_bin(global, "agents.md", "global guidelines");
+ wfile_bin(global, "skills/g1/skill.md",
+ "---\nid: g1\nname: G1\ndescription: global skill\n---\ndo g1");
+ dotagents d;
+ dotagents_load_from(&d, ws, global);
+ CHECK(d.found_count == 1, "one layer found");
+ CHECK(d.found_layers[0] == LAYER_GLOBAL, "layer is global");
+ CHECK(d.agents_md && strcmp(d.agents_md, "global guidelines") == 0,
+ "agents_md");
+ CHECK(d.skill_count == 1, "one skill");
+ CHECK(strcmp(d.skills[0].id, "g1") == 0, "skill id");
+ CHECK(strcmp(d.skills[0].body, "do g1") == 0, "skill body");
+ dotagents_free(&d);
+ free(ws);
+ free(global);
+}
+
+static void test_md_filenames_case_insensitive(void) {
+ char *ws = tmpdir("ws-cs");
+ wfile_bin(ws, "AGENTS.MD", "workspace guidelines");
+ wfile_bin(ws, "System-Prompt.md", "the system prompt");
+ wfile_bin(ws, "skills/UPPER/SKILL.md",
+ "---\nid: upper\nname: Upper\ndescription: caps skill\n---\ndo "
+ "upper");
+ wfile_bin(ws, "agents/REVIEWER/Agent.md",
+ "---\nid: reviewer\nname: Reviewer\n---\nYou review.");
+ wfile_bin(ws, "tasks/daily/Task.MD",
+ "---\nid: daily\nname: Daily\nenabled: true\n---\nRun daily.");
+ wfile_bin(ws, "memories/note.MD",
+ "---\nid: note\ntitle: Note\n---\nbody");
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ CHECK(d.found_count == 1, "workspace found");
+ CHECK(d.agents_md && strcmp(d.agents_md, "workspace guidelines") == 0,
+ "AGENTS.MD");
+ CHECK(d.system_prompt && strcmp(d.system_prompt, "the system prompt") == 0,
+ "System-Prompt.md");
+ CHECK(d.skill_count == 1 && strcmp(d.skills[0].id, "upper") == 0,
+ "SKILL.md");
+ CHECK(d.sub_agent_count == 1 &&
+ strcmp(d.sub_agents[0].id, "reviewer") == 0,
+ "Agent.md");
+ CHECK(d.task_count == 1 && strcmp(d.tasks[0].id, "daily") == 0,
+ "Task.MD");
+ CHECK(d.memory_count == 1 && strcmp(d.memories[0].id, "note") == 0,
+ "note.MD");
+ CHECK(d.warnings.len == 0, "no case warnings");
+ dotagents_free(&d);
+ free(ws);
+}
+
+static void test_workspace_wins(void) {
+ char *ws = tmpdir("ws2");
+ char *global = tmpdir("g2");
+ wfile_bin(global, "agents.md", "global guidelines");
+ wfile_bin(global, "skills/review/skill.md",
+ "---\nid: review\nname: Review G\ndescription: global\n---\n"
+ "global body");
+ wfile_bin(global, "memories/m.md",
+ "---\nid: m\ntitle: M Global\nimportance: low\ntags: a\n---\ng");
+ wfile_bin(ws, "agents.md", "workspace guidelines");
+ wfile_bin(ws, "skills/review/skill.md",
+ "---\nid: review\nname: Review W\ndescription: workspace\n---\n"
+ "workspace body");
+ wfile_bin(ws, "skills/extra/skill.md",
+ "---\nid: extra\nname: Extra\n---\nextra body");
+ dotagents d;
+ dotagents_load_from(&d, ws, global);
+ CHECK(d.agents_md && strcmp(d.agents_md, "workspace guidelines") == 0,
+ "workspace agents.md wins");
+ const skill *review = dotagents_find_skill(&d, "review");
+ CHECK(review && review->layer == LAYER_WORKSPACE, "review layer");
+ CHECK(review && strcmp(review->body, "workspace body") == 0,
+ "review body");
+ CHECK(d.skill_count == 2, "review + extra");
+ CHECK(d.memory_count == 1, "only global memory");
+ dotagents_free(&d);
+ free(ws);
+ free(global);
+}
+
+static void test_json_layers_merge_by_key(void) {
+ char *ws = tmpdir("ws3");
+ char *global = tmpdir("g3");
+ wfile_bin(global, "models.json",
+ "{\"default\":\"ga\",\"presets\":{\"ga\":{\"model\":\"ga-"
+ "model\"},\"shared\":{\"model\":\"s\"}}}");
+ wfile_bin(global, "mcp.json",
+ "{\"mcpServers\":{\"fs\":{\"command\":\"npx\"},"
+ "\"git\":{\"url\":\"g\"}}}");
+ wfile_bin(ws, "models.json",
+ "{\"default\":\"wb\",\"presets\":{\"wb\":{\"provider\":\""
+ "ollama\"},\"shared\":{\"model\":\"ws\"}}}");
+ wfile_bin(ws, "mcp.json", "{\"mcpServers\":{\"git\":{\"url\":\"w\"}}}");
+ dotagents d;
+ dotagents_load_from(&d, ws, global);
+ CHECK(d.has_models, "models present");
+ CHECK(d.models.default_name &&
+ strcmp(d.models.default_name, "wb") == 0,
+ "default=wb");
+ CHECK(d.models.preset_count == 3, "3 presets");
+ const model_preset *ga = models_file_get(&d.models, "ga");
+ CHECK(ga && ga->model && strcmp(ga->model, "ga-model") == 0, "ga.model");
+ const model_preset *sh = models_file_get(&d.models, "shared");
+ CHECK(sh && sh->model && strcmp(sh->model, "ws") == 0, "shared.model");
+ CHECK(d.mcp_count == 2, "2 mcp servers");
+ const char *git = dotagents_mcp_config(&d, "git");
+ CHECK(git && strstr(git, "\"url\":\"w\""), "git.url=w");
+ dotagents_free(&d);
+ free(ws);
+ free(global);
+}
+
+static void test_malformed_files_warn_and_skip(void) {
+ char *ws = tmpdir("ws4");
+ wfile_bin(ws, "models.json", "{not json");
+ wfile_bin(ws, "mcp.json", "{\"nope\":true}");
+ wfile_bin(ws, "skills/broken/skill.md", "no frontmatter");
+ char cmd[300];
+ snprintf(cmd, sizeof cmd, "mkdir -p '%s/skills/empty'", ws);
+ if (system(cmd) != 0)
+ exit(1);
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ CHECK(!d.has_models, "no models");
+ CHECK(d.mcp_count == 0, "no mcp");
+ CHECK(d.skill_count == 1 && strcmp(d.skills[0].id, "broken") == 0,
+ "broken skill kept");
+ CHECK(d.skills[0].body &&
+ strstr(d.skills[0].body, "no frontmatter"),
+ "broken skill body");
+ CHECK(count_warns(&d, "models.json") > 0, "models.json warning");
+ CHECK(count_warns(&d, "mcp.json") > 0, "mcp.json warning");
+ CHECK(count_warns(&d, "skills/empty") > 0, "skills/empty warning");
+ dotagents_free(&d);
+ free(ws);
+}
+
+static void test_missing_layers_empty(void) {
+ char *ws = tmpdir("ws5");
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ CHECK(d.found_count == 0, "no layers");
+ CHECK(d.skill_count == 0, "no skills");
+ CHECK(d.warnings.len == 0, "no warnings");
+ dotagents_free(&d);
+ free(ws);
+}
+
+static void test_tasks_and_sub_agents(void) {
+ char *ws = tmpdir("ws6");
+ wfile_bin(ws, "tasks/daily/task.md",
+ "---\nkind: task\nid: daily\nname: Daily Review\nintervalMinutes"
+ ": 60\nenabled: true\nrunOnStartup: true\n---\nReview open "
+ "PRs.");
+ wfile_bin(ws, "agents/reviewer/agent.md",
+ "---\nid: reviewer\nname: Reviewer\ndescription: Reviews code\n"
+ "role: delegation-target\nenabled: true\n---\nYou are a "
+ "reviewer.");
+ wfile_bin(ws, "agents/reviewer/config.json",
+ "{\"modelConfig\":{\"mcpToolsOpenaiModel\":\"gpt-4o\"}}");
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ CHECK(d.task_count == 1, "one task");
+ CHECK(d.tasks[0].has_interval && d.tasks[0].interval_minutes == 60,
+ "interval");
+ CHECK(d.tasks[0].run_on_startup, "run_on_startup");
+ CHECK(d.tasks[0].enabled, "enabled");
+ CHECK(strcmp(d.tasks[0].body, "Review open PRs.") == 0, "task body");
+ CHECK(d.sub_agent_count == 1, "one sub agent");
+ CHECK(strcmp(d.sub_agents[0].role, "delegation-target") == 0, "role");
+ CHECK(strcmp(d.sub_agents[0].body, "You are a reviewer.") == 0,
+ "sub agent body");
+ CHECK(d.sub_agents[0].config != NULL, "config present");
+ dotagents_free(&d);
+ free(ws);
+}
+
+static void test_malformed_sub_agent_config(void) {
+ char *ws = tmpdir("ws9");
+ wfile_bin(ws, "agents/broken/agent.md",
+ "---\nid: broken\nname: Broken\ndescription: has a bad config\n"
+ "---\nYou are broken.");
+ wfile_bin(ws, "agents/broken/config.json", "{not json");
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ CHECK(d.sub_agent_count == 1, "one sub agent");
+ CHECK(d.sub_agents[0].config == NULL, "config dropped");
+ CHECK(count_warns(&d, "agents/broken/config.json") > 0,
+ "config warning");
+ dotagents_free(&d);
+ free(ws);
+}
+
+static void test_memories_rank(void) {
+ memory low, high;
+ memset(&low, 0, sizeof low);
+ low.importance = fm_strdup("low");
+ memset(&high, 0, sizeof high);
+ high.importance = fm_strdup("high");
+ CHECK(memory_importance_rank(&high) < memory_importance_rank(&low),
+ "high ranks before low");
+ free(low.importance);
+ free(high.importance);
+}
+
+static void test_project_sections(void) {
+ char *ws = tmpdir("ws7");
+ wfile_bin(ws, "agents.md", "# Guidelines\n- be nice");
+ wfile_bin(ws, "skills/cr/skill.md",
+ "---\nid: cr\nname: CR\ndescription: code review\n---\nreview "
+ "steps");
+ wfile_bin(ws, "memories/p.md",
+ "---\nid: p\ntitle: P\nimportance: high\ntags: x\n---\nbody");
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ char *sections = dotagents_project_sections(&d, 50);
+ CHECK(strstr(sections, "- be nice"), "guidelines");
+ CHECK(strstr(sections, "- cr: code review"), "skill line");
+ CHECK(strstr(sections, "use_skill"), "use_skill mention");
+ CHECK(strstr(sections, "search_memories"), "search_memories mention");
+ char *pre = dotagents_compose_preamble(&d, "BASE", 50);
+ CHECK(strstr(pre, "BASE") && strstr(sections, "be nice") &&
+ memcmp(pre, "BASE", 4) == 0,
+ "preamble = base + sections");
+ free(sections);
+ free(pre);
+ dotagents_free(&d);
+ free(ws);
+}
+
+static void test_skill_index_cap(void) {
+ char *ws = tmpdir("ws10");
+ const char *ids[5] = {"a", "b", "c", "d", "e"};
+ for (int i = 0; i < 5; i++) {
+ char rel[64];
+ char content[128];
+ snprintf(rel, sizeof rel, "skills/%s/skill.md", ids[i]);
+ snprintf(content, sizeof content,
+ "---\nid: %s\nname: %s\ndescription: skill %d\n---\nbody",
+ ids[i], ids[i], i);
+ wfile_bin(ws, rel, content);
+ }
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ char *sections = dotagents_project_sections(&d, 2);
+ CHECK(strstr(sections, "- a: skill 0"), "a listed");
+ CHECK(strstr(sections, "- b: skill 1"), "b listed");
+ CHECK(!strstr(sections, "- c: skill 2"), "c not listed");
+ CHECK(strstr(sections, "and 3 more"), "…and 3 more");
+ free(sections);
+ dotagents_free(&d);
+ free(ws);
+}
+
+static void test_disabled_skills_excluded(void) {
+ char *ws = tmpdir("ws8");
+ wfile_bin(ws, "skills/off/skill.md",
+ "---\nid: off\ndescription: hidden\nenabled: false\n---\nx");
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ CHECK(d.skill_count == 1, "skill kept");
+ CHECK(!d.skills[0].enabled, "disabled");
+ char *sections = dotagents_project_sections(&d, 50);
+ CHECK(!strstr(sections, "off"), "not in sections");
+ free(sections);
+ dotagents_free(&d);
+ free(ws);
+}
+
+/* ------------------------------------------------------------------ */
+/* Spec-gap conformance */
+/* ------------------------------------------------------------------ */
+
+static void test_task_profile_id(void) {
+ char *ws = tmpdir("ws-pid");
+ wfile_bin(ws, "tasks/nightly/task.md",
+ "---\nkind: task\nid: nightly\nname: Nightly\nprofileId: "
+ "prof-9\nenabled: true\n---\nRun nightly.");
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ CHECK(d.task_count == 1, "one task");
+ CHECK(d.tasks[0].profile_id &&
+ strcmp(d.tasks[0].profile_id, "prof-9") == 0,
+ "profile_id parsed");
+ dotagents_free(&d);
+ free(ws);
+}
+
+static void test_task_profile_id_workspace_override(void) {
+ char *ws = tmpdir("ws-pid2");
+ char *global = tmpdir("g-pid");
+ wfile_bin(global, "tasks/nightly/task.md",
+ "---\nid: nightly\nprofileId: g-prof\n---\ng");
+ wfile_bin(ws, "tasks/nightly/task.md",
+ "---\nid: nightly\nprofileId: w-prof\n---\nw");
+ dotagents d;
+ dotagents_load_from(&d, ws, global);
+ CHECK(d.task_count == 1, "one task");
+ CHECK(d.tasks[0].profile_id &&
+ strcmp(d.tasks[0].profile_id, "w-prof") == 0,
+ "workspace profileId wins");
+ dotagents_free(&d);
+ free(ws);
+ free(global);
+}
+
+static void test_agent_connection_type(void) {
+ char *ws = tmpdir("ws-ct");
+ wfile_bin(ws, "agents/remote/agent.md",
+ "---\nid: remote\nname: Remote\nrole: delegation-target\n"
+ "connection-type: internal\nenabled: true\n---\nYou are "
+ "remote.");
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ CHECK(d.sub_agent_count == 1, "one agent");
+ CHECK(d.sub_agents[0].connection_type &&
+ strcmp(d.sub_agents[0].connection_type, "internal") == 0,
+ "connection-type parsed");
+ dotagents_free(&d);
+ free(ws);
+}
+
+static void test_settings_layers_merge(void) {
+ char *ws = tmpdir("ws-set");
+ char *global = tmpdir("g-set");
+ wfile_bin(global, "speakmcp-settings.json",
+ "{\"theme\":\"dark\",\"telemetry\":true,\"shared\":\"g\"}");
+ wfile_bin(ws, "speakmcp-settings.json",
+ "{\"shared\":\"w\",\"volume\":2}");
+ dotagents d;
+ dotagents_load_from(&d, ws, global);
+ CHECK(d.settings_json != NULL, "settings present");
+ CHECK(d.settings_json && strstr(d.settings_json, "\"shared\":\"w\""),
+ "shared=w");
+ CHECK(d.settings_json && strstr(d.settings_json, "\"theme\":\"dark\""),
+ "theme kept");
+ CHECK(d.settings_json && strstr(d.settings_json, "\"volume\":2"),
+ "volume kept");
+ CHECK(d.settings_json && strstr(d.settings_json, "\"telemetry\":true"),
+ "telemetry kept");
+ dotagents_free(&d);
+ free(ws);
+ free(global);
+}
+
+static void test_settings_invalid_warns(void) {
+ char *ws = tmpdir("ws-setbad");
+ wfile_bin(ws, "speakmcp-settings.json", "{nope");
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ CHECK(d.settings_json == NULL, "no settings");
+ CHECK(count_warns(&d, "speakmcp-settings.json") > 0, "settings warning");
+ dotagents_free(&d);
+ free(ws);
+}
+
+static void test_layouts_ui_merge(void) {
+ char *ws = tmpdir("ws-lay");
+ char *global = tmpdir("g-lay");
+ wfile_bin(global, "layouts/ui.json", "{\"panes\":2,\"theme\":\"dark\"}");
+ wfile_bin(ws, "layouts/ui.json", "{\"theme\":\"light\"}");
+ dotagents d;
+ dotagents_load_from(&d, ws, global);
+ CHECK(d.layouts_json && strstr(d.layouts_json, "\"theme\":\"light\""),
+ "theme=w");
+ CHECK(d.layouts_json && strstr(d.layouts_json, "\"panes\":2"),
+ "panes kept");
+ dotagents_free(&d);
+ free(ws);
+ free(global);
+}
+
+static void test_settings_only_layer_discovered(void) {
+ char *ws = tmpdir("ws-setonly");
+ wfile_bin(ws, "speakmcp-settings.json", "{\"a\":1}");
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ CHECK(d.found_count == 1, "layer found");
+ CHECK(d.settings_json != NULL, "settings");
+ dotagents_free(&d);
+ free(ws);
+}
+
+static void test_layouts_only_layer_discovered(void) {
+ char *ws = tmpdir("ws-layonly");
+ wfile_bin(ws, "layouts/ui.json", "{\"a\":1}");
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ CHECK(d.found_count == 1, "layer found");
+ CHECK(d.layouts_json != NULL, "layouts");
+ dotagents_free(&d);
+ free(ws);
+}
+
+static void test_backups_ignored(void) {
+ char *ws = tmpdir("ws-bk");
+ wfile_bin(ws, "agents.md", "guidelines");
+ wfile_bin(ws, ".backups/skills/ghost/skill.md",
+ "---\nid: ghost\nname: Ghost\n---\nbody");
+ wfile_bin(ws, ".backups/memories/ghost.md",
+ "---\nid: ghost\ntitle: G\n---\nb");
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ CHECK(d.agents_md && strcmp(d.agents_md, "guidelines") == 0,
+ "agents_md");
+ CHECK(d.skill_count == 0, "no ghost skill");
+ CHECK(d.memory_count == 0, "no ghost memory");
+ dotagents_free(&d);
+ free(ws);
+}
+
+static void test_unknown_frontmatter_keys_accepted(void) {
+ char *ws = tmpdir("ws-unk");
+ wfile_bin(ws, "agents/x/agent.md",
+ "---\nkind: agent\nid: x\nname: X\nmetadata: stuff\n---\nb");
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ CHECK(d.sub_agent_count == 1, "agent kept");
+ CHECK(d.warnings.len == 0, "no warnings");
+ dotagents_free(&d);
+ free(ws);
+}
+
+/* Both ~/.agents and ./.agents must remain reachable in the model context:
+ * a global-only skill and a workspace-only skill both survive the merge, both
+ * appear in the skill index the model sees, and an id conflict resolves to
+ * the workspace copy. */
+static void test_both_layers_reachable_in_context(void) {
+ char *ws = tmpdir("ws-ctx");
+ char *global = tmpdir("g-ctx");
+ wfile_bin(global, "skills/g-only/skill.md",
+ "---\nid: g-only\nname: G Only\ndescription: global only skill\n"
+ "---\ng global body");
+ wfile_bin(global, "skills/shared/skill.md",
+ "---\nid: shared\nname: Shared G\ndescription: shared global\n"
+ "---\ng shared body");
+ wfile_bin(ws, "skills/w-only/skill.md",
+ "---\nid: w-only\nname: W Only\ndescription: workspace only "
+ "skill\n---\nw workspace body");
+ wfile_bin(ws, "skills/shared/skill.md",
+ "---\nid: shared\nname: Shared W\ndescription: shared "
+ "workspace\n---\nw shared body");
+
+ dotagents d;
+ dotagents_load_from(&d, ws, global);
+
+ /* Neither layer's skills are lost. */
+ CHECK(d.skill_count == 3, "g-only + w-only + shared");
+
+ const skill *g = dotagents_find_skill(&d, "g-only");
+ CHECK(g && g->layer == LAYER_GLOBAL, "g-only reachable");
+ CHECK(g && g->body && strstr(g->body, "g global body"), "g-only body");
+
+ const skill *w = dotagents_find_skill(&d, "w-only");
+ CHECK(w && w->layer == LAYER_WORKSPACE, "w-only reachable");
+ CHECK(w && w->body && strstr(w->body, "w workspace body"), "w-only body");
+
+ /* An id conflict resolves to the workspace copy. */
+ const skill *sh = dotagents_find_skill(&d, "shared");
+ CHECK(sh && sh->layer == LAYER_WORKSPACE, "shared -> workspace");
+ CHECK(sh && sh->body && strstr(sh->body, "w shared body"),
+ "shared workspace body");
+
+ /* Both layers remain visible in the context skill index. */
+ char *sections = dotagents_project_sections(&d, 50);
+ CHECK(strstr(sections, "- g-only") != NULL, "g-only in context index");
+ CHECK(strstr(sections, "- w-only") != NULL, "w-only in context index");
+ CHECK(strstr(sections, "- shared") != NULL, "shared in context index");
+ free(sections);
+
+ /* ...and therefore in the composed preamble the model actually receives. */
+ char *pre = dotagents_compose_preamble(&d, "BASE", 50);
+ CHECK(strstr(pre, "g-only") != NULL && strstr(pre, "w-only") != NULL,
+ "both layers in composed preamble");
+ free(pre);
+
+ dotagents_free(&d);
+ free(ws);
+ free(global);
+}
+
+/* skill_index_cap must apply to the merged two-layer set, not silently drop
+ * global skills: with 3 global + 2 workspace skills and cap 2, the first two
+ * (by id) are listed and the rest are elided. */
+static void test_skill_index_cap_across_layers(void) {
+ char *ws = tmpdir("ws-cap");
+ char *global = tmpdir("g-cap");
+ wfile_bin(global, "skills/g1/skill.md",
+ "---\nid: g1\nname: G1\ndescription: g1\n---\nb");
+ wfile_bin(global, "skills/g2/skill.md",
+ "---\nid: g2\nname: G2\ndescription: g2\n---\nb");
+ wfile_bin(global, "skills/g3/skill.md",
+ "---\nid: g3\nname: G3\ndescription: g3\n---\nb");
+ wfile_bin(ws, "skills/w1/skill.md",
+ "---\nid: w1\nname: W1\ndescription: w1\n---\nb");
+ wfile_bin(ws, "skills/w2/skill.md",
+ "---\nid: w2\nname: W2\ndescription: w2\n---\nb");
+
+ dotagents d;
+ dotagents_load_from(&d, ws, global);
+ CHECK(d.skill_count == 5, "5 merged skills");
+
+ char *sections = dotagents_project_sections(&d, 2);
+ CHECK(strstr(sections, "- g1") != NULL, "g1 listed");
+ CHECK(strstr(sections, "- g2") != NULL, "g2 listed");
+ CHECK(!strstr(sections, "- g3"), "g3 capped");
+ CHECK(!strstr(sections, "- w1"), "w1 capped");
+ CHECK(strstr(sections, "and 3 more") != NULL, "...and 3 more");
+ free(sections);
+
+ dotagents_free(&d);
+ free(ws);
+ free(global);
+}
+
+int main(void) {
+ test_loads_global_only();
+ test_md_filenames_case_insensitive();
+ test_workspace_wins();
+ test_json_layers_merge_by_key();
+ test_malformed_files_warn_and_skip();
+ test_missing_layers_empty();
+ test_tasks_and_sub_agents();
+ test_malformed_sub_agent_config();
+ test_memories_rank();
+ test_project_sections();
+ test_skill_index_cap();
+ test_disabled_skills_excluded();
+ test_task_profile_id();
+ test_task_profile_id_workspace_override();
+ test_agent_connection_type();
+ test_settings_layers_merge();
+ test_settings_invalid_warns();
+ test_layouts_ui_merge();
+ test_settings_only_layer_discovered();
+ test_layouts_only_layer_discovered();
+ test_backups_ignored();
+ test_unknown_frontmatter_keys_accepted();
+ test_both_layers_reachable_in_context();
+ test_skill_index_cap_across_layers();
+ printf("%d checks, %d failures\n", checks, failures);
+ return failures ? 1 : 0;
+}
diff --git a/tests/test_frontmatter.c b/tests/test_frontmatter.c
new file mode 100644
index 0000000..af4c2fc
--- /dev/null
+++ b/tests/test_frontmatter.c
@@ -0,0 +1,236 @@
+/* test_frontmatter.c — standalone test suite for frontmatter.h.
+ * Zero dependencies: compiles to a single TU with no linked libraries. */
+#include "frontmatter.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+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)
+
+/* ------------------------------------------------------------------ */
+/* Frontmatter */
+/* ------------------------------------------------------------------ */
+
+static void test_frontmatter_scalars_lists_quotes(void) {
+ doc d;
+ parse_frontmatter(
+ "---\nid: a-1\nname: \"A: One\"\nenabled: true\nn: 42\ntags: db, "
+ "arch\ntags2: [\"x\", \"y\"]\n---\nbody line",
+ &d);
+ CHECK(strcmp(d.body, "body line") == 0, "body");
+ const fm_value *v = doc_get(&d, "id");
+ CHECK(v && v->kind == FM_STR && strcmp(v->s, "a-1") == 0, "id");
+ v = doc_get(&d, "name");
+ CHECK(v && v->kind == FM_STR && strcmp(v->s, "A: One") == 0, "name");
+ v = doc_get(&d, "enabled");
+ CHECK(v && v->kind == FM_BOOL && v->b, "enabled");
+ v = doc_get(&d, "n");
+ CHECK(v && v->kind == FM_INT && v->i == 42, "n");
+ v = doc_get(&d, "tags");
+ CHECK(v, "tags present");
+ fm_strlist items;
+ fm_list_items(v, &items);
+ CHECK(items.len == 2 && strcmp(items.items[0], "db") == 0 &&
+ strcmp(items.items[1], "arch") == 0,
+ "tags csv");
+ fm_sl_free(&items);
+ v = doc_get(&d, "tags2");
+ CHECK(v && v->kind == FM_LIST && v->item_count == 2 &&
+ strcmp(v->items[0], "x") == 0 && strcmp(v->items[1], "y") == 0,
+ "tags2 array");
+ doc_free(&d);
+}
+
+static void test_frontmatter_missing_or_unterminated(void) {
+ doc d;
+ parse_frontmatter("just a body\n", &d);
+ CHECK(d.count == 0, "no meta");
+ CHECK(strcmp(d.body, "just a body") == 0, "body trimmed");
+ doc_free(&d);
+
+ parse_frontmatter("---\nid: x\nno closing fence\n", &d);
+ CHECK(d.count == 0, "unterminated: no meta");
+ CHECK(strcmp(d.body, "---\nid: x\nno closing fence") == 0,
+ "unterminated: whole text is body");
+ doc_free(&d);
+}
+
+static void test_frontmatter_ignores_blank_comment_colonless(void) {
+ doc d;
+ parse_frontmatter("---\n# comment\n\nnotakey\nk: v\n---\n", &d);
+ CHECK(d.count == 1, "one meta entry");
+ const fm_value *v = doc_get(&d, "k");
+ CHECK(v && v->kind == FM_STR && strcmp(v->s, "v") == 0, "k=v");
+ doc_free(&d);
+}
+
+static void test_fm_list_items_variants(void) {
+ doc d;
+ parse_frontmatter(
+ "---\ncsv: a, b, c\njson: [\"x\", \"y\"]\nsingle: only\nempty:\n"
+ "nums: [1, 2]\n---\nbody",
+ &d);
+ fm_strlist out;
+ fm_list_items(doc_get(&d, "csv"), &out);
+ CHECK(out.len == 3 && strcmp(out.items[0], "a") == 0 &&
+ strcmp(out.items[2], "c") == 0,
+ "csv");
+ fm_sl_free(&out);
+ fm_list_items(doc_get(&d, "json"), &out);
+ CHECK(out.len == 2 && strcmp(out.items[0], "x") == 0, "json");
+ fm_sl_free(&out);
+ fm_list_items(doc_get(&d, "single"), &out);
+ CHECK(out.len == 1 && strcmp(out.items[0], "only") == 0, "single");
+ fm_sl_free(&out);
+ fm_list_items(doc_get(&d, "empty"), &out);
+ CHECK(out.len == 0, "empty list");
+ fm_sl_free(&out);
+ fm_list_items(doc_get(&d, "nums"), &out);
+ CHECK(out.len == 2 && strcmp(out.items[0], "1") == 0, "non-string items");
+ fm_sl_free(&out);
+ fm_list_items(NULL, &out);
+ CHECK(out.len == 0, "null value");
+ fm_sl_free(&out);
+ doc_free(&d);
+}
+
+/* ------------------------------------------------------------------ */
+/* Embedded JSON */
+/* ------------------------------------------------------------------ */
+
+static void test_json_roundtrip(void) {
+ const char *src =
+ "{\"a\":1,\"b\":\"x\",\"c\":[true,null,1.5],\"d\":{\"e\":[1,2,"
+ "\"z\"]}}";
+ fm_json *v = fm_json_parse(src);
+ CHECK(v != NULL, "parse");
+ CHECK(v && v->type == FMJ_OBJECT, "object");
+ CHECK(v && v->count == 4, "4 keys");
+ const fm_json *a = fm_json_get(v, "a");
+ CHECK(a && a->type == FMJ_NUMBER && a->is_int && a->i == 1, "a=1");
+ const fm_json *b = fm_json_get(v, "b");
+ CHECK(b && b->type == FMJ_STRING && strcmp(b->str, "x") == 0, "b=x");
+ const fm_json *c = fm_json_get(v, "c");
+ CHECK(c && c->type == FMJ_ARRAY && c->count == 3, "c array");
+ CHECK(c && c->items[0]->type == FMJ_BOOL && c->items[0]->b, "c[0]=true");
+ CHECK(c && c->items[1]->type == FMJ_NULL, "c[1]=null");
+ CHECK(c && c->items[2]->type == FMJ_NUMBER && c->items[2]->num == 1.5,
+ "c[2]=1.5");
+ const fm_json *d = fm_json_get(v, "d");
+ CHECK(d && d->type == FMJ_OBJECT, "d object");
+ const fm_json *e = d ? fm_json_get(d, "e") : NULL;
+ CHECK(e && e->type == FMJ_ARRAY && e->count == 3, "d.e array");
+ CHECK(e && e->items[1]->is_int && e->items[1]->i == 2, "d.e[1]=2");
+ char *out = fm_json_serialize(v);
+ CHECK(out && strcmp(out, src) == 0, "roundtrip");
+ free(out);
+ fm_json_free(v);
+}
+
+static void test_json_escapes(void) {
+ fm_json *v = fm_json_parse("\"a\\\"b\\\\c\\nd\\u0041\"");
+ CHECK(v && v->type == FMJ_STRING, "parse");
+ CHECK(v && strcmp(v->str, "a\"b\\c\ndA") == 0, "unescaped value");
+ char *s = fm_json_serialize(v);
+ CHECK(s && strcmp(s, "\"a\\\"b\\\\c\\ndA\"") == 0, "escaped output");
+ free(s);
+ fm_json_free(v);
+
+ fm_json *u = fm_json_parse("\"\\u4e2d\"");
+ CHECK(u && u->type == FMJ_STRING, "unicode parse");
+ CHECK(u && strcmp(u->str, "\xE4\xB8\xAD") == 0, "utf-8 out");
+ fm_json_free(u);
+}
+
+static void test_json_invalid(void) {
+ CHECK(fm_json_parse("{") == NULL, "open brace");
+ CHECK(fm_json_parse("[1,") == NULL, "dangling comma");
+ CHECK(fm_json_parse("{bad:1}") == NULL, "bare key");
+ CHECK(fm_json_parse("{\"a\":1\"b\":2}") == NULL, "missing comma");
+ CHECK(fm_json_parse("{}x") == NULL, "trailing garbage");
+ CHECK(fm_json_parse("") == NULL, "empty");
+ CHECK(fm_json_parse("01") == NULL, "leading zero");
+ CHECK(fm_json_parse("1.") == NULL, "dangling dot");
+ CHECK(fm_json_parse("-") == NULL, "bare minus");
+ CHECK(fm_json_parse("tru") == NULL, "bare tru");
+ CHECK(fm_json_parse("\"unterminated") == NULL, "unterminated string");
+ CHECK(fm_json_parse("[1 2]") == NULL, "missing separator");
+ fm_json *f1 = fm_json_parse("1.5");
+ CHECK(f1 != NULL, "float ok");
+ fm_json_free(f1);
+ fm_json *f2 = fm_json_parse("1e3");
+ CHECK(f2 != NULL, "exponent ok");
+ fm_json_free(f2);
+ fm_json *f3 = fm_json_parse("null");
+ CHECK(f3 != NULL, "null ok");
+ fm_json_free(f3);
+ fm_json *f4 = fm_json_parse("[]");
+ CHECK(f4 != NULL, "empty array ok");
+ fm_json_free(f4);
+ fm_json *f5 = fm_json_parse("{}");
+ CHECK(f5 != NULL, "empty object ok");
+ fm_json_free(f5);
+}
+
+static void test_json_merge(void) {
+ fm_json *base = fm_json_parse("{\"a\":1,\"b\":{\"x\":1}}");
+ fm_json *over = fm_json_parse("{\"b\":{\"y\":2},\"c\":3}");
+ fm_json *m = fm_json_merge_owned(base, over);
+ char *s = fm_json_serialize(m);
+ CHECK(s && strcmp(s, "{\"b\":{\"y\":2},\"c\":3,\"a\":1}") == 0,
+ "merged content");
+ free(s);
+ fm_json_free(m);
+
+ fm_json *o2 = fm_json_parse("{\"z\":9}");
+ fm_json *m2 = fm_json_merge_owned(NULL, o2);
+ char *s2 = fm_json_serialize(m2);
+ CHECK(s2 && strcmp(s2, "{\"z\":9}") == 0, "over only");
+ free(s2);
+ fm_json_free(m2);
+
+ fm_json *b2 = fm_json_parse("{\"q\":1}");
+ fm_json *m3 = fm_json_merge_owned(b2, NULL);
+ char *s3 = fm_json_serialize(m3);
+ CHECK(s3 && strcmp(s3, "{\"q\":1}") == 0, "base only");
+ free(s3);
+ fm_json_free(m3);
+
+ fm_json *n1 = fm_json_parse("[1]");
+ fm_json *n2 = fm_json_parse("\"x\"");
+ fm_json *m4 = fm_json_merge_owned(n1, n2);
+ char *s4 = fm_json_serialize(m4);
+ CHECK(s4 && strcmp(s4, "{}") == 0, "non-object inputs dropped");
+ free(s4);
+ fm_json_free(m4);
+
+ fm_json *m5 = fm_json_merge_owned(NULL, NULL);
+ char *s5 = fm_json_serialize(m5);
+ CHECK(s5 && strcmp(s5, "{}") == 0, "both null");
+ free(s5);
+ fm_json_free(m5);
+}
+
+int main(void) {
+ test_frontmatter_scalars_lists_quotes();
+ test_frontmatter_missing_or_unterminated();
+ test_frontmatter_ignores_blank_comment_colonless();
+ test_fm_list_items_variants();
+ test_json_roundtrip();
+ test_json_escapes();
+ test_json_invalid();
+ test_json_merge();
+ printf("%d checks, %d failures\n", checks, failures);
+ return failures ? 1 : 0;
+}
diff --git a/tests/test_goal.cpp b/tests/test_goal.cpp
new file mode 100644
index 0000000..8e06517
--- /dev/null
+++ b/tests/test_goal.cpp
@@ -0,0 +1,230 @@
+#include "goal.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+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)
+
+static void test_parse_defaults(void)
+{
+ goal_spec spec;
+ char *err = NULL;
+ CHECK(goal_parse("all tests pass", &spec, &err) == 1, "parse");
+ CHECK(strcmp(spec.criteria, "all tests pass") == 0, "criteria");
+ CHECK(spec.max_tries == GOAL_DEFAULT_MAX_TRIES, "default cap");
+ goal_spec_free(&spec);
+}
+
+static void test_parse_reads_cap(void)
+{
+ goal_spec spec;
+ char *err = NULL;
+ CHECK(goal_parse("all tests pass, stop after 3 tries", &spec, &err) == 1,
+ "parse");
+ CHECK(strcmp(spec.criteria, "all tests pass") == 0, "criteria");
+ CHECK(spec.max_tries == 3, "cap");
+ goal_spec_free(&spec);
+}
+
+static void test_parse_cap_without_tries_word(void)
+{
+ goal_spec spec;
+ char *err = NULL;
+ CHECK(goal_parse("make it green, stop after 10", &spec, &err) == 1, "parse");
+ CHECK(strcmp(spec.criteria, "make it green") == 0, "criteria");
+ CHECK(spec.max_tries == 10, "cap");
+ goal_spec_free(&spec);
+}
+
+static void test_parse_tries_word_case_insensitive(void)
+{
+ goal_spec spec;
+ char *err = NULL;
+ CHECK(goal_parse("ship it, stop after 4 TRIES", &spec, &err) == 1,
+ "uppercase TRIES accepted");
+ CHECK(spec.max_tries == 4, "cap");
+ goal_spec_free(&spec);
+}
+
+static void test_parse_rejections(void)
+{
+ goal_spec spec;
+ char *err = NULL;
+
+ err = NULL;
+ CHECK(goal_parse(" ", &spec, &err) == 0, "empty input rejected");
+ CHECK(err && strstr(err, "success criteria") != NULL, "empty err msg");
+ free(err);
+
+ err = NULL;
+ CHECK(goal_parse("stop after 5 tries", &spec, &err) == 0,
+ "missing criteria rejected");
+ CHECK(err && strstr(err, "before `stop after`") != NULL, "missing err msg");
+ free(err);
+
+ err = NULL;
+ CHECK(goal_parse("ship it, stop after", &spec, &err) == 0,
+ "missing cap rejected");
+ CHECK(err && strstr(err, "number of tries") != NULL, "missing cap err");
+ free(err);
+
+ err = NULL;
+ CHECK(goal_parse("ship it, stop after 0 tries", &spec, &err) == 0,
+ "zero cap rejected");
+ CHECK(err && strstr(err, "at least 1 try") != NULL, "zero cap err");
+ free(err);
+
+ err = NULL;
+ CHECK(goal_parse("ship it, stop after 5xyz", &spec, &err) == 0,
+ "junk after the number rejected");
+ CHECK(err && strstr(err, "number of tries") != NULL, "junk err msg");
+ free(err);
+}
+
+static void test_first_attempt_prompt(void)
+{
+ goal_spec spec;
+ spec.criteria = xstrdup("all tests pass");
+ spec.max_tries = 5;
+ char *prompt = goal_prompt(&spec, 1, NULL);
+ CHECK_CONT(prompt, "all tests pass", "states the goal");
+ CHECK_NOT_CONT(prompt, "NOT yet met", "no feedback on first attempt");
+ CHECK_CONT(prompt, "fully met", "asks to keep going");
+ free(prompt);
+ goal_spec_free(&spec);
+}
+
+static void test_later_attempt_prompt(void)
+{
+ goal_spec spec;
+ spec.criteria = xstrdup("all tests pass");
+ spec.max_tries = 3;
+ char *prompt = goal_prompt(&spec, 2, "the build still fails on x86");
+ CHECK_CONT(prompt, "attempt 2 of 3", "attempt framing");
+ CHECK_CONT(prompt, "NOT yet met", "verdict phrasing");
+ CHECK_CONT(prompt, "the build still fails on x86", "includes feedback");
+ free(prompt);
+
+ char *no_fb = goal_prompt(&spec, 4, NULL);
+ CHECK_CONT(no_fb, "attempt 4 of 3", "attempt number");
+ CHECK_CONT(no_fb, "no specific feedback was given", "fallback feedback");
+ free(no_fb);
+ goal_spec_free(&spec);
+}
+
+static void test_verdict_met(void)
+{
+ goal_verdict v;
+ goal_parse_verdict("MET\nAll tests pass and the suite is green.", &v);
+ CHECK(v.met, "met detected");
+ CHECK_CONT(v.reason, "green", "reason captured");
+ free(v.reason);
+}
+
+static void test_verdict_not_met(void)
+{
+ goal_verdict v;
+ goal_parse_verdict("NOT MET\nTwo tests still fail in the linux job.", &v);
+ CHECK(!v.met, "not met detected");
+ CHECK_CONT(v.reason, "still fail", "reason captured");
+ free(v.reason);
+}
+
+static void test_verdict_garbage(void)
+{
+ goal_verdict v;
+ goal_parse_verdict("I think we are close! Let me keep going.", &v);
+ CHECK(!v.met, "conservative on garbage");
+ CHECK_CONT(v.reason, "close", "first line is the reason");
+ free(v.reason);
+}
+
+static void test_verdict_unmet_is_not_met(void)
+{
+ /* "UNMET" contains the substring "MET"; it must not read as met. */
+ goal_verdict v;
+ goal_parse_verdict("UNMET\nStill failing.", &v);
+ CHECK(!v.met, "UNMET is not met");
+ free(v.reason);
+
+ goal_parse_verdict("NOT UNMET", &v);
+ CHECK(!v.met, "NOT UNMET is not met");
+ free(v.reason);
+
+ goal_parse_verdict("unmet", &v);
+ CHECK(!v.met, "lowercase unmet is not met");
+ free(v.reason);
+
+ goal_parse_verdict("MET", &v);
+ CHECK(v.met, "plain MET is met");
+ free(v.reason);
+}
+
+static void test_verdict_no_reason(void)
+{
+ goal_verdict v;
+ goal_parse_verdict("MET", &v);
+ CHECK(v.met, "bare MET is met");
+ CHECK_CONT(v.reason, "did not give a reason", "fallback reason");
+ free(v.reason);
+}
+
+static void test_preamble(void)
+{
+ const char *p = goal_evaluator_preamble();
+ CHECK_CONT(p, "MET", "mentions MET token");
+ CHECK_CONT(p, "NOT MET", "mentions NOT MET token");
+ CHECK_CONT(p, "strict", "strict reviewer");
+}
+
+int main(void)
+{
+ test_parse_defaults();
+ test_parse_reads_cap();
+ test_parse_cap_without_tries_word();
+ test_parse_tries_word_case_insensitive();
+ test_parse_rejections();
+ test_first_attempt_prompt();
+ test_later_attempt_prompt();
+ test_verdict_met();
+ test_verdict_not_met();
+ test_verdict_garbage();
+ test_verdict_unmet_is_not_met();
+ test_verdict_no_reason();
+ test_preamble();
+ printf("%d checks, %d failures\n", checks, failures);
+ return failures ? 1 : 0;
+}
diff --git a/tests/test_input.cpp b/tests/test_input.cpp
new file mode 100644
index 0000000..99a719f
--- /dev/null
+++ b/tests/test_input.cpp
@@ -0,0 +1,1263 @@
+#include "input.h"
+#include "util.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+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_STR(got, want, msg) \
+ do { \
+ checks++; \
+ if (!got || strcmp(got, want) != 0) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: %s (got `%s`, want `%s`)\n", \
+ __FILE__, __LINE__, (msg), got ? got : "(null)", (want));\
+ } \
+ } while (0)
+
+/* ------------------------------------------------------------------ */
+/* Helpers */
+/* ------------------------------------------------------------------ */
+
+static input_key kkind(int kind) {
+ input_key k;
+ k.kind = kind;
+ k.cp = 0;
+ return k;
+}
+
+static evlist feed(const unsigned char *bytes, size_t n) {
+ key_parser p;
+ evlist ev;
+ key_parser_init(&p);
+ evlist_init(&ev);
+ key_parser_feed(&p, bytes, n, &ev);
+ key_parser_flush(&p, &ev);
+ key_parser_free(&p);
+ return ev;
+}
+
+/* Apply a run of parsed keys; every action must be ACT_NONE. */
+static void type_str(editor *e, const char *s) {
+ evlist ev = feed((const unsigned char *)s, strlen(s));
+ for (size_t i = 0; i < ev.len; i++) {
+ input_action a;
+ editor_apply(e, ev.items[i], &a);
+ CHECK(a.kind == ACT_NONE, "type_str: unexpected action while typing");
+ input_action_free(&a);
+ }
+ evlist_free(&ev);
+}
+
+/* Apply a single named key and release its action. */
+static void apply_key(editor *e, int kind) {
+ input_action a;
+ editor_apply(e, kkind(kind), &a);
+ input_action_free(&a);
+}
+
+/* ================================================================== */
+/* Parser tests */
+/* ================================================================== */
+
+static void test_parser_plain_chars(void) {
+ evlist ev = feed((const unsigned char *)"abc", 3);
+ CHECK(ev.len == 3, "three chars");
+ if (ev.len == 3) {
+ CHECK(ev.items[0].kind == KEY_CHAR && ev.items[0].cp == 'a', "a");
+ CHECK(ev.items[1].kind == KEY_CHAR && ev.items[1].cp == 'b', "b");
+ CHECK(ev.items[2].kind == KEY_CHAR && ev.items[2].cp == 'c', "c");
+ CHECK(ev.raws[0] && ev.lens[0] == 1 && ev.raws[0][0] == 'a', "raw a");
+ CHECK(ev.raws[2] && ev.lens[2] == 1 && ev.raws[2][0] == 'c', "raw c");
+ }
+ evlist_free(&ev);
+}
+
+static void test_parser_enter_variants(void) {
+ evlist ev = feed((const unsigned char *)"\r", 1);
+ CHECK(ev.len == 1 && ev.items[0].kind == KEY_ENTER, "CR -> Enter");
+ evlist_free(&ev);
+
+ /* LF (Ctrl+J) is a dead key outside of paste: no event. */
+ ev = feed((const unsigned char *)"\n", 1);
+ CHECK(ev.len == 0, "LF -> no event (dead key)");
+ evlist_free(&ev);
+}
+
+/* Shift-modified Enter and Esc carry no special binding: they behave as the
+ * plain key. */
+static void test_parser_shift_enter_and_esc(void) {
+ evlist ev = feed((const unsigned char *)"\x1b[13;2u", 7);
+ CHECK(ev.len == 1 && ev.items[0].kind == KEY_ENTER, "CSI 13;2 -> Enter");
+ evlist_free(&ev);
+
+ ev = feed((const unsigned char *)"\x1b[27;2u", 7);
+ CHECK(ev.len == 1 && ev.items[0].kind == KEY_ESC, "CSI 27;2 -> Esc");
+ evlist_free(&ev);
+}
+
+static void test_parser_lone_escape_is_esc(void) {
+ /* a lone ESC is held until flush */
+ key_parser p;
+ evlist ev;
+ key_parser_init(&p);
+ evlist_init(&ev);
+ key_parser_feed(&p, (const unsigned char *)"\x1b", 1, &ev);
+ CHECK(ev.len == 0, "no event before flush");
+ CHECK(key_parser_flush(&p, &ev) == 1, "flush emits one");
+ CHECK(ev.len == 1 && ev.items[0].kind == KEY_ESC, "flush -> Esc");
+ evlist_free(&ev);
+ key_parser_free(&p);
+}
+
+static void test_parser_alt_letter_is_the_letter(void) {
+ evlist ev = feed((const unsigned char *)"\x1b" "a", 2);
+ CHECK(ev.len == 1 && ev.items[0].kind == KEY_CHAR && ev.items[0].cp == 'a',
+ "alt+a -> Char('a')");
+ if (ev.len == 1) {
+ CHECK(ev.raws[0] && ev.lens[0] == 2 && ev.raws[0][0] == '\x1b' &&
+ ev.raws[0][1] == 'a',
+ "raw = ESC a");
+ }
+ evlist_free(&ev);
+}
+
+static void test_parser_raw_bytes_preserved(void) {
+ evlist ev = feed((const unsigned char *)"\x1b[1;5A", 6);
+ CHECK(ev.len == 1 && ev.items[0].kind == KEY_UP, "UP");
+ if (ev.len == 1) {
+ CHECK(ev.raws[0] && ev.lens[0] == 6, "raw len 6");
+ if (ev.raws[0] && ev.lens[0] == 6)
+ CHECK(memcmp(ev.raws[0], "\x1b[1;5A", 6) == 0, "raw bytes");
+ }
+ evlist_free(&ev);
+}
+
+static void test_parser_control_keys(void) {
+ struct {
+ unsigned char b;
+ int want;
+ const char *m;
+ } cases[] = {
+ {0x01, KEY_CTRL_A, "Ctrl-A"},
+ {0x03, KEY_CTRL_C, "Ctrl-C"},
+ {0x04, KEY_CTRL_D, "Ctrl-D"},
+ {0x05, KEY_CTRL_E, "Ctrl-E"},
+ {0x0B, KEY_CTRL_K, "Ctrl-K"},
+ {0x0C, KEY_CTRL_L, "Ctrl-L"},
+ {0x15, KEY_CTRL_U, "Ctrl-U"},
+ {0x17, KEY_CTRL_W, "Ctrl-W"},
+ {0x19, KEY_CTRL_Y, "Ctrl-Y"},
+ {0x1F, KEY_UNDO, "Ctrl-_ (undo)"},
+ };
+ for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) {
+ evlist ev = feed(&cases[i].b, 1);
+ CHECK(ev.len == 1, cases[i].m);
+ if (ev.len == 1) {
+ checks++;
+ if (ev.items[0].kind != cases[i].want) {
+ failures++;
+ fprintf(stderr, "FAIL %s:%d: %s (got %d want %d)\n", __FILE__,
+ __LINE__, cases[i].m, ev.items[0].kind, cases[i].want);
+ }
+ }
+ evlist_free(&ev);
+ }
+
+ unsigned char bs[2] = {0x7F, 0x08};
+ for (int i = 0; i < 2; i++) {
+ evlist ev = feed(&bs[i], 1);
+ CHECK(ev.len == 1 && ev.items[0].kind == KEY_BACKSPACE, "backspace");
+ evlist_free(&ev);
+ }
+}
+
+static void test_parser_unmapped_control_bytes(void) {
+ int mapped[256] = {0};
+ mapped[0x09] = mapped[0x0A] = mapped[0x0D] = mapped[0x1B] = 1;
+ for (int b = 0; b < 0x20; b++) {
+ if (mapped[b])
+ continue;
+ unsigned char c = (unsigned char)b;
+ evlist ev = feed(&c, 1);
+ CHECK(ev.len == 1, "exactly one control event");
+ if (ev.len == 1)
+ CHECK(ev.items[0].kind != KEY_CHAR, "not a char");
+ evlist_free(&ev);
+ }
+}
+
+static void test_parser_unknown_sequence_consumed(void) {
+ // Unmapped CSI tilde: empty or a single Unknown (oracle allows either).
+ evlist ev = feed((const unsigned char *)"\x1b[999~", 6);
+ CHECK(ev.len == 0 || (ev.len == 1 && ev.items[0].kind == KEY_UNKNOWN),
+ "unknown CSI ~");
+ evlist_free(&ev);
+
+ // Empty bracketed paste: markers consumed, no events.
+ ev = feed((const unsigned char *)"\x1b[200~\x1b[201~", 12);
+ CHECK(ev.len == 0, "empty paste -> no events");
+ evlist_free(&ev);
+
+ // Mouse reports must not surface as editor keys.
+ ev = feed((const unsigned char *)"\x1b[M", 3);
+ CHECK(ev.len == 0, "mouse SGR -> no events");
+ evlist_free(&ev);
+
+ ev = feed((const unsigned char *)"\x1b[64;10;5M", 10);
+ CHECK(ev.len == 0, "mouse CSI -> no events");
+ evlist_free(&ev);
+
+ // Cursor position reports (DSR responses) are consumed.
+ ev = feed((const unsigned char *)"\x1b[12;40R", 8);
+ CHECK(ev.len == 0, "DSR report -> no events");
+ evlist_free(&ev);
+}
+
+static void test_parser_bracketed_paste(void) {
+ // Paste start marker: no event on its own.
+ evlist ev = feed((const unsigned char *)"\x1b[200~", 6);
+ CHECK(ev.len == 0, "paste start -> no event");
+ evlist_free(&ev);
+
+ // Pasted content is individual char events; a pasted CR is a Newline.
+ ev = feed((const unsigned char *)"\x1b[200~hello\rworld\x1b[201~", 23);
+ CHECK(ev.len == 11, "paste content = 11 events");
+ if (ev.len == 11) {
+ CHECK(ev.items[0].kind == KEY_CHAR && ev.items[0].cp == 'h', "h");
+ CHECK(ev.items[4].kind == KEY_CHAR && ev.items[4].cp == 'o', "o");
+ CHECK(ev.items[5].kind == KEY_NEWLINE, "pasted CR -> Newline");
+ CHECK(ev.items[10].kind == KEY_CHAR && ev.items[10].cp == 'd', "d");
+ }
+ evlist_free(&ev);
+}
+
+static void test_parser_utf8(void) {
+ evlist ev = feed((const unsigned char *)"h\xc3\xa9llo", 6);
+ CHECK(ev.len == 5, "five codepoints");
+ if (ev.len == 5) {
+ CHECK(ev.items[0].kind == KEY_CHAR && ev.items[0].cp == 'h', "h");
+ CHECK(ev.items[1].kind == KEY_CHAR && ev.items[1].cp == 0xE9, "e-acute");
+ CHECK(ev.items[4].kind == KEY_CHAR && ev.items[4].cp == 'o', "o");
+ }
+ evlist_free(&ev);
+
+ /* split across feed() calls */
+ key_parser p;
+ evlist ev2;
+ key_parser_init(&p);
+ evlist_init(&ev2);
+ key_parser_feed(&p, (const unsigned char *)"h\xc3", 2, &ev2);
+ CHECK(ev2.len == 1 && ev2.items[0].kind == KEY_CHAR &&
+ ev2.items[0].cp == 'h',
+ "h flushed, partial UTF-8 held");
+ key_parser_feed(&p, (const unsigned char *)"\xa9llo", 4, &ev2);
+ key_parser_flush(&p, &ev2);
+ CHECK(ev2.len == 5, "recombined to five codepoints");
+ evlist_free(&ev2);
+ key_parser_free(&p);
+
+ evlist ev3 = feed((const unsigned char *)"\xff", 1);
+ CHECK(ev3.len == 1 && ev3.items[0].kind == KEY_UNKNOWN, "invalid 0xFF");
+ evlist_free(&ev3);
+}
+
+static void test_parser_mixed_stream(void) {
+ unsigned char seq[] = "a\x1b[1;5A\xc3\xa9\x1b[13u";
+ evlist ev = feed(seq, sizeof(seq) - 1);
+ CHECK(ev.len == 4, "four events");
+ if (ev.len == 4) {
+ CHECK(ev.items[0].kind == KEY_CHAR && ev.items[0].cp == 'a', "a");
+ CHECK(ev.items[1].kind == KEY_UP, "UP");
+ CHECK(ev.items[2].kind == KEY_CHAR && ev.items[2].cp == 0xE9, "e-acute");
+ CHECK(ev.items[3].kind == KEY_ENTER, "Enter (CSI 13)");
+ }
+ evlist_free(&ev);
+}
+
+static void test_parser_kitty_and_ss3(void) {
+ struct {
+ const char *seq;
+ int want;
+ const char *m;
+ } cases[] = {
+ {"\x1b[8593;1u", KEY_UP, "kitty up"},
+ {"\x1b[8594;1u", KEY_RIGHT, "kitty right"},
+ {"\x1b[9u", KEY_TAB, "kitty tab"},
+ {"\x1b[9;2u", KEY_BACKTAB, "kitty backtab"},
+ {"\x1b[12u", KEY_BACKSPACE, "kitty backspace"},
+ {"\x1b[127u", KEY_DELETE, "kitty delete"},
+ {"\x1b[13u", KEY_ENTER, "kitty enter"},
+ {"\x1b[27u", KEY_ESC, "kitty esc"},
+ {"\x1bOA", KEY_UP, "ss3 up"},
+ {"\x1bOD", KEY_LEFT, "ss3 left"},
+ {"\x1b[H", KEY_HOME, "csi home"},
+ {"\x1b[3~", KEY_DELETE, "csi delete"},
+ {"\x1b[4~", KEY_END, "csi end"},
+ {"\x1b[5~", KEY_PAGEUP, "csi pageup"},
+ {"\x1b[6~", KEY_PAGEDOWN, "csi pagedown"},
+ };
+ for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) {
+ const char *seq = cases[i].seq;
+ evlist ev = feed((const unsigned char *)seq, strlen(seq));
+ CHECK(ev.len == 1, cases[i].m);
+ if (ev.len == 1) {
+ checks++;
+ if (ev.items[0].kind != cases[i].want) {
+ failures++;
+ fprintf(stderr, "FAIL %s:%d: %s (got %d want %d)\n", __FILE__,
+ __LINE__, cases[i].m, ev.items[0].kind, cases[i].want);
+ }
+ }
+ evlist_free(&ev);
+ }
+}
+
+/* ================================================================== */
+/* Editor tests */
+/* ================================================================== */
+
+static void test_editor_typing_and_submit(void) {
+ editor e;
+ editor_init(&e);
+ type_str(&e, "hello");
+ char *t = editor_text(&e);
+ CHECK_STR(t, "hello", "text after typing");
+ free(t);
+
+ input_action a;
+ editor_apply(&e, kkind(KEY_ENTER), &a);
+ CHECK(a.kind == ACT_SUBMIT, "Enter submits");
+ if (a.kind == ACT_SUBMIT)
+ CHECK_STR(a.text, "hello", "submitted text");
+ input_action_free(&a);
+ editor_free(&e);
+}
+
+static void test_editor_shift_enter_inserts_newline(void) {
+ editor e;
+ editor_init(&e);
+ type_str(&e, "he");
+ apply_key(&e, KEY_NEWLINE);
+ type_str(&e, "llo");
+ char *t = editor_text(&e);
+ CHECK_STR(t, "he\nllo", "NEWLINE key inserts a newline");
+ free(t);
+ CHECK(editor_line_count(&e) == 2, "two lines");
+
+ input_action a;
+ editor_apply(&e, kkind(KEY_ENTER), &a);
+ CHECK(a.kind == ACT_SUBMIT, "Enter submits");
+ if (a.kind == ACT_SUBMIT)
+ CHECK_STR(a.text, "he\nllo", "submitted multi-line text");
+ input_action_free(&a);
+ editor_free(&e);
+}
+
+/* Backslash + Enter at the end of the buffer is a line continuation (a
+ * universal newline trigger for terminals where Shift+Enter is not
+ * distinguishable from Enter); any other Enter still submits. */
+static void test_editor_backslash_enter_continues(void) {
+ editor e;
+ editor_init(&e);
+ type_str(&e, "ls \\");
+ input_action a;
+ editor_apply(&e, kkind(KEY_ENTER), &a);
+ CHECK(a.kind == ACT_NEWLINE, "trailing backslash + Enter -> continuation");
+ input_action_free(&a);
+ char *t = editor_text(&e);
+ CHECK_STR(t, "ls \n", "backslash became a newline");
+ free(t);
+ CHECK(e.caret == 4, "caret after the newline");
+
+ type_str(&e, "-la");
+ editor_apply(&e, kkind(KEY_ENTER), &a);
+ CHECK(a.kind == ACT_SUBMIT, "second line's Enter submits");
+ if (a.kind == ACT_SUBMIT)
+ CHECK_STR(a.text, "ls \n-la", "multi-line text submitted");
+ input_action_free(&a);
+ editor_free(&e);
+
+ /* No backslash before the caret -> Enter still submits. */
+ editor f;
+ editor_init(&f);
+ type_str(&f, "a\\b");
+ input_action b;
+ editor_apply(&f, kkind(KEY_ENTER), &b);
+ CHECK(b.kind == ACT_SUBMIT, "no backslash before caret submits");
+ if (b.kind == ACT_SUBMIT)
+ CHECK_STR(b.text, "a\\b", "text submitted unchanged");
+ input_action_free(&b);
+ editor_free(&f);
+
+ /* A backslash before the caret continues even with text after it. */
+ editor g;
+ editor_init(&g);
+ type_str(&g, "a\\b");
+ apply_key(&g, KEY_LEFT); /* caret sits right after the backslash */
+ input_action c;
+ editor_apply(&g, kkind(KEY_ENTER), &c);
+ CHECK(c.kind == ACT_NEWLINE, "backslash before caret continues");
+ input_action_free(&c);
+ char *gt = editor_text(&g);
+ CHECK_STR(gt, "a\nb", "newline inserted after the backslash");
+ free(gt);
+ editor_free(&g);
+}
+
+/* Regression: after building a multi-line buffer and moving the caret UP to
+ * a middle line, backslash+Enter on that line must split it (insert a
+ * newline), not submit. Previously the continuation only fired when the
+ * caret was at the very end of the whole buffer. */
+static void test_editor_middle_line_continuation(void) {
+ editor e;
+ editor_init(&e);
+ /* Build "alpha \nbeta": line 1 "alpha ", line 2 "beta". */
+ type_str(&e, "alpha ");
+ type_str(&e, "\\");
+ apply_key(&e, KEY_ENTER); /* "alpha \" -> "alpha \n" */
+ type_str(&e, "beta"); /* -> "alpha \nbeta", caret at end */
+ CHECK(editor_line_count(&e) == 2, "two lines before the split");
+ /* Move the caret UP to the middle (first) line. */
+ apply_key(&e, KEY_UP);
+ /* Walk to the end of that line (just before the newline). */
+ while (e.caret < strlen(e.text) && e.text[e.caret] != '\n')
+ apply_key(&e, KEY_RIGHT);
+ CHECK(e.caret == 6, "caret at end of the middle line");
+ /* Type a backslash on the middle line, then Enter -> split it. */
+ type_str(&e, "\\");
+ input_action a;
+ editor_apply(&e, kkind(KEY_ENTER), &a);
+ CHECK(a.kind == ACT_NEWLINE, "middle-line backslash+Enter continues");
+ input_action_free(&a);
+ char *t = editor_text(&e);
+ CHECK_STR(t, "alpha \n\nbeta", "middle line was split");
+ free(t);
+ CHECK(editor_line_count(&e) == 3, "three lines after the split");
+
+ /* And the buffer still submits as a whole from its last line. */
+ apply_key(&e, KEY_DOWN);
+ while (e.caret < strlen(e.text) && e.text[e.caret] != '\n')
+ apply_key(&e, KEY_RIGHT);
+ editor_apply(&e, kkind(KEY_ENTER), &a);
+ CHECK(a.kind == ACT_SUBMIT, "last-line Enter submits");
+ if (a.kind == ACT_SUBMIT)
+ CHECK_STR(a.text, "alpha \n\nbeta", "multi-line submit intact");
+ input_action_free(&a);
+ editor_free(&e);
+}
+
+/* End-to-end regression: Ctrl+J is LF (0x0A). Outside of bracketed paste it
+ * is a dead key — it neither submits nor inserts a newline. Multi-line input
+ * is via backslash+Enter or bracketed paste. */
+static void test_ctrl_j_is_dead_key(void) {
+ key_parser p;
+ evlist ev;
+ key_parser_init(&p);
+ evlist_init(&ev);
+ key_parser_feed(&p, (const unsigned char *)"hi\n", 3, &ev);
+ key_parser_free(&p);
+ CHECK(ev.len == 2, "only the two chars, LF dropped");
+
+ editor e;
+ editor_init(&e);
+ input_action a = {ACT_NONE, NULL};
+ int submitted = 0;
+ for (size_t i = 0; i < ev.len; i++) {
+ editor_apply(&e, ev.items[i], &a);
+ if (a.kind == ACT_SUBMIT)
+ submitted = 1;
+ input_action_free(&a);
+ }
+ CHECK(!submitted, "Ctrl+J (LF) does not submit");
+ char *t = editor_text(&e);
+ CHECK_STR(t, "hi", "LF left no newline in the buffer");
+ free(t);
+ evlist_free(&ev);
+ editor_free(&e);
+}
+
+static void test_editor_backspace_and_delete(void) {
+ editor e;
+ editor_init(&e);
+ type_str(&e, "he");
+ apply_key(&e, KEY_NEWLINE);
+ type_str(&e, "llo");
+ apply_key(&e, KEY_LEFT);
+ apply_key(&e, KEY_LEFT);
+ apply_key(&e, KEY_LEFT);
+ apply_key(&e, KEY_BACKSPACE);
+ char *t = editor_text(&e);
+ CHECK_STR(t, "hello", "backspace removed the newline");
+ CHECK(e.caret == 2, "caret at the join");
+ free(t);
+ editor_free(&e);
+
+ /* A forward-delete needs the caret at the end of the first line: Up gets
+ * there (Left is now clamped to a line), then Delete removes the newline. */
+ editor f;
+ editor_init(&f);
+ type_str(&f, "ab");
+ apply_key(&f, KEY_NEWLINE);
+ type_str(&f, "cd");
+ apply_key(&f, KEY_UP);
+ apply_key(&f, KEY_DELETE);
+ char *u = editor_text(&f);
+ CHECK_STR(u, "abcd", "delete removed the newline");
+ CHECK(f.caret == 2, "caret at the join");
+ free(u);
+ editor_free(&f);
+}
+
+static void test_editor_multibyte_backspace(void) {
+ editor e;
+ editor_init(&e);
+ type_str(&e, "h\xc3\xa9llo");
+ apply_key(&e, KEY_END);
+ apply_key(&e, KEY_BACKSPACE);
+ apply_key(&e, KEY_BACKSPACE);
+ apply_key(&e, KEY_BACKSPACE);
+ apply_key(&e, KEY_BACKSPACE);
+ char *t = editor_text(&e);
+ CHECK_STR(t, "h", "removed whole codepoints");
+ free(t);
+ editor_free(&e);
+}
+
+static void test_editor_horizontal_movement(void) {
+ editor e;
+ editor_init(&e);
+ type_str(&e, "one");
+ apply_key(&e, KEY_NEWLINE);
+ type_str(&e, "two");
+ CHECK(e.caret == 7, "caret at the end");
+
+ /* Left stays on the current line: three presses reach its start, and a
+ * fourth is a no-op (it no longer crosses into the previous line). */
+ apply_key(&e, KEY_LEFT);
+ apply_key(&e, KEY_LEFT);
+ apply_key(&e, KEY_LEFT);
+ CHECK(e.caret == 4, "left moves the caret to the line start");
+ apply_key(&e, KEY_LEFT);
+ CHECK(e.caret == 4, "left is a no-op at the line start");
+ apply_key(&e, KEY_RIGHT);
+ CHECK(e.caret == 5, "right moves the caret");
+
+ /* HOME/END (and their Ctrl aliases) act on the current line, not the
+ * whole buffer. Move to the first line and exercise them there. */
+ apply_key(&e, KEY_UP);
+ CHECK(editor_caret_line(&e) == 0, "on the first line");
+ apply_key(&e, KEY_HOME);
+ CHECK(e.caret == 0, "home is the line start");
+ apply_key(&e, KEY_END);
+ CHECK(e.caret == 3, "end is the line end");
+ apply_key(&e, KEY_CTRL_A);
+ CHECK(e.caret == 0, "ctrl-a is the line start");
+ apply_key(&e, KEY_CTRL_E);
+ CHECK(e.caret == 3, "ctrl-e is the line end");
+ editor_free(&e);
+}
+
+/* Vertical movement walks the lines, keeps the column, clamps it to short
+ * lines on a UTF-8 boundary, and stops at the top/bottom edges. */
+static void test_editor_vertical_movement(void) {
+ editor e;
+ editor_init(&e);
+ editor_set(&e, "one\ntwo\nthree");
+ CHECK(editor_line_count(&e) == 3, "three lines");
+ CHECK(editor_caret_line(&e) == 2, "caret on the last line");
+ CHECK(e.caret == 13, "caret at the end");
+
+ apply_key(&e, KEY_UP);
+ CHECK(e.caret == 7, "up to the end of line 2");
+ CHECK(editor_caret_line(&e) == 1, "caret on line 2");
+ apply_key(&e, KEY_UP);
+ CHECK(e.caret == 3, "up to the end of line 1");
+ CHECK(editor_caret_line(&e) == 0, "caret on line 1");
+ apply_key(&e, KEY_UP);
+ CHECK(e.caret == 3, "up is a no-op on the first line");
+
+ apply_key(&e, KEY_DOWN);
+ CHECK(e.caret == 7, "down back to line 2");
+ apply_key(&e, KEY_DOWN);
+ CHECK(e.caret == 11, "down into line 3, column preserved");
+ CHECK(editor_caret_line(&e) == 2, "caret on line 3");
+ apply_key(&e, KEY_DOWN);
+ CHECK(e.caret == 11, "down is a no-op on the last line");
+ editor_free(&e);
+
+ /* Column clamping: a column longer than the target line clamps to its
+ * end, and the clamped column is what comes back. */
+ editor f;
+ editor_init(&f);
+ editor_set(&f, "abcdef\ngh");
+ CHECK(f.caret == 9, "set caret at the end");
+ apply_key(&f, KEY_UP);
+ CHECK(f.caret == 2, "up keeps the column");
+ apply_key(&f, KEY_RIGHT);
+ apply_key(&f, KEY_RIGHT);
+ apply_key(&f, KEY_RIGHT);
+ apply_key(&f, KEY_RIGHT);
+ CHECK(f.caret == 6, "end of the long line");
+ apply_key(&f, KEY_DOWN);
+ CHECK(f.caret == 9, "down clamps the column to the short line");
+ apply_key(&f, KEY_UP);
+ CHECK(f.caret == 2, "up keeps the clamped column");
+ editor_free(&f);
+
+ /* A single-line buffer: UP/DOWN never move the caret. */
+ editor h;
+ editor_init(&h);
+ editor_set(&h, "hello");
+ apply_key(&h, KEY_HOME);
+ apply_key(&h, KEY_UP);
+ CHECK(h.caret == 0, "UP is a no-op on one line");
+ apply_key(&h, KEY_DOWN);
+ CHECK(h.caret == 0, "DOWN is a no-op on one line");
+ editor_free(&h);
+
+ /* UTF-8: a column landing mid-codepoint snaps to the boundary. */
+ editor u;
+ editor_init(&u);
+ editor_set(&u, "abc\n\xc3\xa9\xc3\xa9"); /* "abc", then two e-acute */
+ CHECK(u.caret == 8, "caret at the end");
+ apply_key(&u, KEY_UP);
+ CHECK(u.caret == 3, "up to the end of line 1");
+ apply_key(&u, KEY_DOWN);
+ CHECK(u.caret == 6, "down clamps between the two codepoints");
+ apply_key(&u, KEY_UP);
+ CHECK(u.caret == 2, "up keeps the clamped column");
+ editor_free(&u);
+}
+
+static void test_editor_insert_at_caret(void) {
+ editor e;
+ editor_init(&e);
+ type_str(&e, "helo");
+ apply_key(&e, KEY_HOME);
+ apply_key(&e, KEY_RIGHT);
+ type_str(&e, "i");
+ char *t = editor_text(&e);
+ CHECK_STR(t, "hielo", "inserted before the caret");
+ CHECK(e.caret == 2, "caret after the inserted char");
+ free(t);
+ editor_free(&e);
+
+ editor f;
+ editor_init(&f);
+ apply_key(&f, KEY_BACKSPACE);
+ CHECK(editor_is_empty(&f), "backspace at 0 is a no-op");
+ apply_key(&f, KEY_DELETE);
+ CHECK(editor_is_empty(&f), "delete at 0 is a no-op");
+ editor_free(&f);
+}
+
+static void test_editor_kill_and_word_kills(void) {
+ editor e;
+ editor_init(&e);
+ type_str(&e, "foo bar");
+ apply_key(&e, KEY_CTRL_K);
+ char *t = editor_text(&e);
+ CHECK_STR(t, "foo bar", "Ctrl-K at end is a no-op");
+ free(t);
+ editor_free(&e);
+
+ editor f;
+ editor_init(&f);
+ type_str(&f, "foo bar");
+ apply_key(&f, KEY_HOME);
+ apply_key(&f, KEY_RIGHT);
+ apply_key(&f, KEY_RIGHT);
+ apply_key(&f, KEY_RIGHT);
+ apply_key(&f, KEY_CTRL_K);
+ char *u = editor_text(&f);
+ CHECK_STR(u, "foo", "Ctrl-K cut the rest of the line");
+ free(u);
+ editor_free(&f);
+
+ editor g;
+ editor_init(&g);
+ type_str(&g, "foo bar");
+ apply_key(&g, KEY_END);
+ apply_key(&g, KEY_LEFT);
+ apply_key(&g, KEY_LEFT);
+ apply_key(&g, KEY_LEFT);
+ apply_key(&g, KEY_CTRL_U);
+ char *v = editor_text(&g);
+ CHECK_STR(v, "bar", "Ctrl-U cut the front of the line");
+ free(v);
+ editor_free(&g);
+
+ editor h;
+ editor_init(&h);
+ type_str(&h, "hello bar_baz");
+ apply_key(&h, KEY_END);
+ apply_key(&h, KEY_CTRL_W);
+ char *w = editor_text(&h);
+ CHECK_STR(w, "hello", "Ctrl-W removed one word");
+ free(w);
+ editor_free(&h);
+}
+
+static void test_editor_undo(void) {
+ editor e;
+ editor_init(&e);
+ type_str(&e, "hello");
+ char *t = editor_text(&e);
+ CHECK_STR(t, "hello", "typed");
+ free(t);
+
+ apply_key(&e, KEY_UNDO);
+ t = editor_text(&e);
+ CHECK_STR(t, "hell", "undo removed the last char");
+ free(t);
+
+ apply_key(&e, KEY_UNDO);
+ t = editor_text(&e);
+ CHECK_STR(t, "hel", "undo again");
+ free(t);
+
+ apply_key(&e, KEY_UNDO);
+ apply_key(&e, KEY_UNDO);
+ apply_key(&e, KEY_UNDO);
+ t = editor_text(&e);
+ CHECK_STR(t, "", "undo back to empty");
+ free(t);
+
+ apply_key(&e, KEY_UNDO);
+ t = editor_text(&e);
+ CHECK_STR(t, "", "undo past empty is a no-op");
+ free(t);
+ editor_free(&e);
+}
+
+static void test_editor_yank(void) {
+ /* Ctrl-W kill, then Ctrl-Y yank it back. */
+ editor e;
+ editor_init(&e);
+ type_str(&e, "foo bar");
+ apply_key(&e, KEY_END);
+ apply_key(&e, KEY_CTRL_W);
+ char *t = editor_text(&e);
+ CHECK_STR(t, "foo", "Ctrl-W killed the last word");
+ free(t);
+ apply_key(&e, KEY_CTRL_Y);
+ t = editor_text(&e);
+ CHECK_STR(t, "foo bar", "Ctrl-Y yanked the killed word");
+ free(t);
+ editor_free(&e);
+
+ /* A backspaced char is also yankable. */
+ editor f;
+ editor_init(&f);
+ type_str(&f, "ab");
+ apply_key(&f, KEY_BACKSPACE);
+ char *u = editor_text(&f);
+ CHECK_STR(u, "a", "backspace removed b");
+ free(u);
+ apply_key(&f, KEY_CTRL_Y);
+ u = editor_text(&f);
+ CHECK_STR(u, "ab", "Ctrl-Y yanked the backspaced char");
+ free(u);
+ editor_free(&f);
+}
+
+static void test_editor_ctrl_c_and_ctrl_d(void) {
+ editor e;
+ editor_init(&e);
+ type_str(&e, "hello");
+ input_action a;
+ editor_apply(&e, kkind(KEY_CTRL_C), &a);
+ CHECK(a.kind == ACT_NONE, "Ctrl-C on non-empty is an edit op");
+ CHECK(editor_is_empty(&e), "Ctrl-C clears the buffer");
+ input_action_free(&a);
+
+ input_action q;
+ editor_apply(&e, kkind(KEY_CTRL_C), &q);
+ CHECK(q.kind == ACT_QUIT, "Ctrl-C on empty -> quit");
+ input_action_free(&q);
+ editor_free(&e);
+
+ editor f;
+ editor_init(&f);
+ input_action d;
+ editor_apply(&f, kkind(KEY_CTRL_D), &d);
+ CHECK(d.kind == ACT_QUIT, "Ctrl-D on empty -> quit");
+ input_action_free(&d);
+
+ type_str(&f, "hello");
+ input_action d2;
+ editor_apply(&f, kkind(KEY_CTRL_D), &d2);
+ CHECK(d2.kind == ACT_NONE, "Ctrl-D on non-empty is an edit op");
+ char *t = editor_text(&f);
+ CHECK_STR(t, "hello", "no-op at end of a single line");
+ free(t);
+ input_action_free(&d2);
+ editor_free(&f);
+}
+
+static void test_editor_backtab_tab_and_esc(void) {
+ editor e;
+ editor_init(&e);
+ input_action a;
+ editor_apply(&e, kkind(KEY_BACKTAB), &a);
+ CHECK(a.kind == ACT_TOGGLE_MODE, "backtab toggles mode");
+ input_action_free(&a);
+ editor_free(&e);
+
+ editor f;
+ editor_init(&f);
+ input_action t;
+ editor_apply(&f, kkind(KEY_TAB), &t);
+ CHECK(t.kind == ACT_NONE, "tab is an insert op");
+ char *ts = editor_text(&f);
+ CHECK_STR(ts, "\t", "tab inserted");
+ free(ts);
+ input_action_free(&t);
+ editor_free(&f);
+
+ editor g;
+ editor_init(&g);
+ input_action s;
+ editor_apply(&g, kkind(KEY_ESC), &s);
+ CHECK(s.kind == ACT_STOP, "esc -> stop");
+ input_action_free(&s);
+ editor_free(&g);
+}
+
+/* ================================================================== */
+/* Display tests */
+/* ================================================================== */
+
+static void test_display_len(void) {
+ CHECK(input_display_len("\x1b[1;36m›\x1b[0m ") == 2, "SGR ignored");
+ CHECK(input_display_len("plain") == 5, "plain ascii");
+ CHECK(input_display_len("") == 0, "empty");
+}
+
+static void test_display_len_wide(void) {
+ CHECK(input_display_len("\xe4\xb8\xad\xe6\x96\x87") == 4, "two CJK = 4");
+ CHECK(input_display_len("a\xe4\xb8\xad" "b") == 4, "a CJK b = 4");
+ CHECK(input_display_len("h\xc3\xa9llo") == 5, "accented = 5");
+}
+
+static void test_display_len_escape_families(void) {
+ CHECK(input_display_len("\x1b]0;llsh\x1b\\hi") == 2, "OSC st");
+ CHECK(input_display_len("\x1b]0;llsh\x07hi") == 2, "OSC BEL");
+ CHECK(input_display_len("\x1bOA") == 0, "SS3");
+ CHECK(input_display_len("\x1b(Bx") == 1, "charset");
+ CHECK(input_display_len("\x1b[c") == 0, "CSI cursor");
+ CHECK(input_display_len("a\x1b") == 1, "trailing lone ESC");
+}
+
+/* History tests */
+/* ================================================================== */
+
+static char *hist_dir(void) {
+ static char buf[512];
+ snprintf(buf, sizeof(buf), "/tmp/llshc_hist_%d", (int)getpid());
+ return buf;
+}
+
+static char *hist_path(const char *name) {
+ static char path[640];
+ snprintf(path, sizeof(path), "%s/%s", hist_dir(), name);
+ return path;
+}
+
+static void hist_cleanup(void) {
+ const char *names[] = {"a.json", "b.json"};
+ for (size_t i = 0; i < 2; i++) {
+ char p[640];
+ snprintf(p, sizeof(p), "%s/%s", hist_dir(), names[i]);
+ remove(p);
+ }
+ rmdir(hist_dir());
+}
+
+static void test_history_capture_submit_cycle(void) {
+ hist_cleanup();
+ char *path = hist_path("a.json");
+
+ input_history *h = input_history_open(path);
+ CHECK(h != NULL, "open history");
+ input_history_push(h, "echo hi");
+ input_history_push(h, "ls -la");
+
+ /* While composing, Up engages only from the first line (caret_line 0);
+ * a mid-buffer Up moves the caret, not history. */
+ CHECK(input_history_walk(h, -1, 1, "") == NULL, "Up mid-buffer -> no recall");
+
+ char *line = input_history_walk(h, -1, 0, "");
+ CHECK_STR(line, "ls -la", "newest first");
+ free(line);
+
+ /* Mid-walk: Up advances toward the oldest regardless of the caret line. */
+ line = input_history_walk(h, -1, 2, "");
+ CHECK_STR(line, "echo hi", "oldest second");
+ free(line);
+
+ /* Oldest already shown: Up hands back to the caret. */
+ CHECK(input_history_walk(h, -1, 0, "") == NULL, "Up at oldest -> NULL");
+
+ /* Down walks back toward the newest entry. */
+ line = input_history_walk(h, +1, 0, "");
+ CHECK_STR(line, "ls -la", "Down back to newest");
+ free(line);
+
+ /* Down past the newest restores the pre-recall line and ends the walk. */
+ line = input_history_walk(h, +1, 0, "");
+ CHECK_STR(line, "", "Down past newest restores pre-recall line");
+ free(line);
+ CHECK(h->active == false, "deactivated after restoring");
+
+ input_history_free(h);
+
+ input_history *h2 = input_history_open(path);
+ CHECK(h2 && h2->entries.len == 2, "reloaded 2 entries");
+ input_history_free(h2);
+ hist_cleanup();
+}
+
+static void test_history_restores_pre_recall_line(void) {
+ hist_cleanup();
+ char *path = hist_path("a.json");
+ input_history *h = input_history_open(path);
+ input_history_push(h, "first");
+ input_history_push(h, "second");
+
+ /* Down while composing never recalls — it moves the caret. */
+ CHECK(input_history_walk(h, +1, 0, "typed but not sent") == NULL,
+ "Down while composing -> no recall");
+
+ char *line = input_history_walk(h, -1, 0, "typed but not sent");
+ CHECK_STR(line, "second", "newest first");
+ free(line);
+
+ line = input_history_walk(h, +1, 0, "typed but not sent");
+ CHECK_STR(line, "typed but not sent", "restored the pre-recall line");
+ free(line);
+ CHECK(h->active == false, "deactivated after restoring");
+ CHECK(h->snapshot == NULL, "snapshot consumed");
+ input_history_free(h);
+ hist_cleanup();
+}
+
+static void test_history_skips_duplicates_and_empty(void) {
+ hist_cleanup();
+ char *path = hist_path("a.json");
+ input_history *h = input_history_open(path);
+ input_history_push(h, "same");
+ input_history_push(h, "same");
+ input_history_push(h, " ");
+ input_history_push(h, " spaced ");
+ input_history_push(h, "spaced");
+
+ CHECK(h->entries.len == 2, "two unique entries");
+ if (h->entries.len == 2) {
+ CHECK_STR(h->entries.items[0], "same", "entry 0");
+ CHECK_STR(h->entries.items[1], "spaced", "entry 1 trimmed");
+ }
+ input_history_free(h);
+ hist_cleanup();
+}
+
+static void test_history_persistence_and_limit(void) {
+ hist_cleanup();
+ char *path = hist_path("b.json");
+
+ input_history *h = input_history_open(path);
+ for (int i = 1; i <= LLSH_HISTORY_LIMIT + 1; i++) {
+ char buf[32];
+ snprintf(buf, sizeof(buf), "cmd%03d", i);
+ input_history_push(h, buf);
+ }
+ size_t live = h->entries.len - h->head;
+ CHECK(live == LLSH_HISTORY_LIMIT, "limit enforced");
+ if (live == LLSH_HISTORY_LIMIT) {
+ CHECK_STR(h->entries.items[h->head], "cmd002", "oldest evicted");
+ CHECK_STR(h->entries.items[h->entries.len - 1], "cmd101",
+ "newest preserved");
+ }
+ input_history_free(h);
+
+ input_history *h2 = input_history_open(path);
+ CHECK(h2 && h2->entries.len - h2->head == LLSH_HISTORY_LIMIT,
+ "reloaded at limit");
+ input_history_free(h2);
+ hist_cleanup();
+}
+
+/* ================================================================== */
+/* Shell shadow tests */
+/* ================================================================== */
+
+static void test_shell_shadow(void) {
+ hist_cleanup();
+ editor shadow;
+ editor_init(&shadow);
+ type_str(&shadow, "echo hi");
+ char *out = shell_shadow_apply(&shadow, kkind(KEY_ENTER));
+ CHECK_STR(out, "echo hi", "submitted line returned");
+ free(out);
+ CHECK(editor_is_empty(&shadow), "shadow cleared after submit");
+ editor_free(&shadow);
+
+ editor s2;
+ editor_init(&s2);
+ type_str(&s2, "ls");
+ char *o2 = shell_shadow_apply(&s2, kkind(KEY_LEFT));
+ CHECK(o2 == NULL, "intermediate key -> NULL");
+ CHECK(!editor_is_empty(&s2), "shadow NOT cleared on intermediate key");
+ editor_free(&s2);
+
+ editor s3;
+ editor_init(&s3);
+ type_str(&s3, " ");
+ char *o3 = shell_shadow_apply(&s3, kkind(KEY_ENTER));
+ CHECK(o3 == NULL, "blank submit -> NULL");
+ CHECK(!editor_is_empty(&s3), "shadow NOT cleared on blank");
+ editor_free(&s3);
+ hist_cleanup();
+}
+
+/* ================================================================== */
+/* Reader queue */
+/* ================================================================== */
+
+/* The reader's event queue must be FIFO: keys typed quickly must reach the
+ * editor in order (regression: a swap-pop scrambled the sequence, dropping
+ * and reordering characters). */
+static void test_reader_queue_fifo(void) {
+ reader_ctx *ctx = (reader_ctx* ) xmalloc(sizeof *ctx);
+ memset(ctx, 0, sizeof *ctx);
+ input_key_reader *r = input_key_reader_new(ctx);
+
+ const unsigned int cps[] = {'a', 'b', 'c', 'd', 'e', 'f'};
+ for (size_t i = 0; i < sizeof cps / sizeof *cps; i++) {
+ input_key k = {KEY_CHAR, cps[i]};
+ input_key_reader_send(r, &k);
+ }
+ for (size_t i = 0; i < sizeof cps / sizeof *cps; i++) {
+ input_key out;
+ const unsigned char *raw;
+ size_t raw_len;
+ CHECK(input_key_reader_recv(r, &out, &raw, &raw_len),
+ "recv returns queued event");
+ CHECK(out.kind == KEY_CHAR, "queued key kind preserved");
+ CHECK(out.cp == cps[i], "queue is FIFO (order preserved)");
+ input_raw_free(raw);
+ }
+ input_key out;
+ CHECK(!input_key_reader_recv(r, &out, NULL, NULL), "queue drained");
+ input_key_reader_free(r);
+}
+
+/* The raw buffer handed out by recv is owned by the caller: it must stay
+ * valid no matter what the queue does afterwards (regression: recv used to
+ * pop-and-free the very buffer it returned, so callers read freed memory and
+ * shell-mode keystrokes arrived scrambled). */
+static void test_recv_raw_owned(void) {
+ reader_ctx *ctx = (reader_ctx* ) xmalloc(sizeof *ctx);
+ memset(ctx, 0, sizeof *ctx);
+ input_key_reader *r = input_key_reader_new(ctx);
+
+ input_key k = {KEY_CHAR, 'x'};
+ input_key_reader_send_raw(r, &k, "payload");
+
+ input_key out;
+ const unsigned char *raw1 = NULL;
+ size_t raw_len = 0;
+ CHECK(input_key_reader_recv(r, &out, &raw1, &raw_len), "recv raw event");
+ CHECK(raw_len == 7, "raw length preserved");
+
+ /* Fill the ring past one full lap so the queue reuses the first slot;
+ * with a borrowed (freed-on-pop) pointer, `raw1` would now be garbage. */
+ for (int i = 0; i < 16; i++)
+ input_key_reader_send_raw(r, &k, "scratch");
+ for (int i = 0; i < 16; i++) {
+ input_key o2;
+ const unsigned char *r2 = NULL;
+ if (!input_key_reader_recv(r, &o2, &r2, &raw_len))
+ break;
+ input_raw_free(r2);
+ }
+
+ CHECK(raw1 && raw1[0] == 'p' && memcmp(raw1, "payload", 7) == 0,
+ "recv'd raw stays valid after the queue laps it");
+ input_raw_free(raw1);
+ input_key_reader_free(r);
+}
+
+/* ================================================================== */
+
+/* The renderer and these tests share input_wrap_total / input_wrap_caret to
+ * agree on how a buffer breaks into physical rows and where the caret lands on
+ * a terminal of a given width, using the delayed-wrap model real terminals use.
+ * Line 0 carries the 2-column prompt. */
+static void test_wrap_geometry(void) {
+ size_t r, c;
+
+ /* A short first line: the prompt takes the first two columns, nothing
+ * wraps. */
+ CHECK(input_wrap_total("hi", 10) == 1, "short line is one row");
+ input_wrap_caret("hi", 0, 10, &r, &c);
+ CHECK(r == 0 && c == 2, "caret at text start sits after the prompt");
+ input_wrap_caret("hi", 2, 10, &r, &c);
+ CHECK(r == 0 && c == 4, "caret at the text end");
+
+ /* A first line long enough to wrap: prompt + text flow across rows. The
+ * cursor sits on the last typed column, not a spurious next row. */
+ CHECK(input_wrap_total("abcdef", 5) == 2, "wrapped line is two rows");
+ input_wrap_caret("abcdef", 0, 5, &r, &c);
+ CHECK(r == 0 && c == 2, "caret before the first char");
+ input_wrap_caret("abcdef", 3, 5, &r, &c);
+ CHECK(r == 0 && c == 4, "caret after 'abc' sits on the last column");
+ input_wrap_caret("abcdef", 6, 5, &r, &c);
+ CHECK(r == 1 && c == 3, "caret at the wrapped end");
+
+ /* A multi-line buffer: only line 0 carries the prompt. */
+ CHECK(input_wrap_total("ab\ncdef", 5) == 2, "two short lines are two rows");
+ input_wrap_caret("ab\ncdef", 7, 5, &r, &c);
+ CHECK(r == 1 && c == 4, "caret at the buffer end");
+ input_wrap_caret("ab\ncdef", 5, 5, &r, &c);
+ CHECK(r == 1 && c == 2, "caret mid second line");
+
+ /* Regression: a first line that ends exactly at the margin, followed by a
+ * newline, must NOT gain a spurious empty row. '» ab' is exactly 4
+ * columns, so the 'cdefgh' line starts immediately on row 1. */
+ CHECK(input_wrap_total("ab\ncdefgh", 4) == 3,
+ "full first line + newline is three rows, not four");
+ input_wrap_caret("ab\ncdefgh", 9, 4, &r, &c);
+ CHECK(r == 2 && c == 2, "caret at the end of the third row");
+ input_wrap_caret("ab\ncdefgh", 2, 4, &r, &c);
+ CHECK(r == 0 && c == 3, "caret at the end of the full first row");
+ input_wrap_caret("ab\ncdefgh", 3, 4, &r, &c);
+ CHECK(r == 1 && c == 0, "caret at the start of the second line");
+
+ /* A trailing full line keeps the cursor on its last column (no empty row). */
+ CHECK(input_wrap_total("abcdef", 4) == 2, "two full rows is two rows");
+ input_wrap_caret("abcdef", 6, 4, &r, &c);
+ CHECK(r == 1 && c == 3, "caret at the end of a full row sits on it");
+
+ /* The renderer needs the pending flag to tell a cursor sitting on the last
+ * column (delayed wrap) from one merely after the second-to-last column. */
+ CHECK(input_wrap_pending("hi", 2, 10) == false,
+ "short end is not pending");
+ CHECK(input_wrap_pending("abcdef", 3, 5) == true,
+ "caret after the last-column char is pending");
+ CHECK(input_wrap_pending("abcdef", 4, 5) == false,
+ "caret on the wrapped row is not pending");
+ CHECK(input_wrap_pending("abcdefg", 7, 10) == false,
+ "second-to-last column end is not pending");
+ CHECK(input_wrap_pending("abcdefgh", 8, 10) == true,
+ "last-column end is pending");
+ CHECK(input_wrap_pending("a\xE4\xB8\xAD", 4, 10) == false,
+ "three-byte char advances one column");
+ CHECK(input_wrap_pending("a\xF0\x9F\x98\x80", 5, 10) == false,
+ "four-byte char advances one column");
+ CHECK(input_wrap_pending("a", 1, 1) == true,
+ "one-column terminal stays pending");
+ CHECK(input_wrap_pending("a\n", 2, 1) == false,
+ "newline clears pending wrap");
+ CHECK(input_wrap_pending("ab\ncdef\n", 8, 5) == false,
+ "a trailing newline is not pending");
+
+ /* A blank buffer still renders the prompt as one row. */
+ CHECK(input_wrap_total("", 10) == 1, "blank buffer is one row");
+ input_wrap_caret("", 0, 10, &r, &c);
+ CHECK(r == 0 && c == 2, "caret sits after the prompt");
+}
+
+int main(void) {
+ /* parser */
+ test_parser_plain_chars();
+ test_parser_enter_variants();
+ test_parser_shift_enter_and_esc();
+ test_parser_lone_escape_is_esc();
+ test_parser_alt_letter_is_the_letter();
+ test_parser_raw_bytes_preserved();
+ test_parser_control_keys();
+ test_parser_unmapped_control_bytes();
+ test_parser_unknown_sequence_consumed();
+ test_parser_bracketed_paste();
+ test_parser_utf8();
+ test_parser_mixed_stream();
+ test_parser_kitty_and_ss3();
+
+ /* editor */
+ test_editor_typing_and_submit();
+ test_editor_shift_enter_inserts_newline();
+ test_editor_backslash_enter_continues();
+ test_editor_middle_line_continuation();
+ test_ctrl_j_is_dead_key();
+ test_editor_backspace_and_delete();
+ test_editor_multibyte_backspace();
+ test_editor_horizontal_movement();
+ test_editor_vertical_movement();
+ test_editor_insert_at_caret();
+ test_editor_kill_and_word_kills();
+ test_editor_undo();
+ test_editor_yank();
+ test_editor_ctrl_c_and_ctrl_d();
+ test_editor_backtab_tab_and_esc();
+
+ /* display */
+ test_display_len();
+ test_display_len_wide();
+ test_display_len_escape_families();
+
+ /* wrap geometry */
+ test_wrap_geometry();
+
+ /* history */
+ test_history_capture_submit_cycle();
+ test_history_restores_pre_recall_line();
+ test_history_skips_duplicates_and_empty();
+ test_history_persistence_and_limit();
+
+ /* shell shadow */
+ test_shell_shadow();
+
+ /* reader queue */
+ test_reader_queue_fifo();
+ test_recv_raw_owned();
+
+ printf("%d checks, %d failures\n", checks, failures);
+ return failures ? 1 : 0;
+}
diff --git a/tests/test_limits.cpp b/tests/test_limits.cpp
new file mode 100644
index 0000000..f425d64
--- /dev/null
+++ b/tests/test_limits.cpp
@@ -0,0 +1,379 @@
+#include "limits.h"
+#include "verbose.h"
+
+#include <cjson/cJSON.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+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_EQ_STR(a, b, msg) \
+ do { \
+ checks++; \
+ const char *_a = (a), *_b = (b); \
+ if (!_a || (_b && strcmp(_a, _b) != 0)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: %s (got `%s`, want `%s`)\n", \
+ __FILE__, __LINE__, (msg), _a ? _a : "(null)", \
+ _b ? _b : "(null)"); \
+ } \
+ } while (0)
+
+static tool_limits mklimits(const char **allow, size_t nallow,
+ const char **deny, size_t ndeny) {
+ tool_limits l;
+ tool_limits_defaults(&l);
+ for (size_t i = 0; i < nallow; i++)
+ sl_push(&l.shell_allowlist, xstrdup(allow[i]));
+ for (size_t i = 0; i < ndeny; i++)
+ sl_push(&l.shell_denylist, xstrdup(deny[i]));
+ return l;
+}
+
+static void test_defaults(void) {
+ tool_limits l;
+ tool_limits_defaults(&l);
+ CHECK(l.shell_timeout_secs == 120, "default shell_timeout_secs");
+ CHECK(l.max_output_chars == 16000, "default max_output_chars");
+ CHECK(l.max_file_bytes == 2000000, "default max_file_bytes");
+ CHECK(l.shell_allowlist.len == 0, "default allowlist empty");
+ CHECK(l.shell_denylist.len == 0, "default denylist empty");
+ CHECK(!l.confirm_destructive, "default confirm_destructive off");
+ CHECK(!l.verbose, "default verbose off");
+ tool_limits_free(&l);
+}
+
+static void test_set_get_roundtrip(void) {
+ const char *allow[] = {"git", "ls"};
+ const char *deny[] = {"git push"};
+ tool_limits l = mklimits(allow, 2, deny, 1);
+ l.confirm_destructive = true;
+ l.verbose = true;
+ tool_limits_set(&l);
+ tool_limits g;
+ tool_limits_get(&g);
+ CHECK(g.shell_timeout_secs == 120, "get timeout");
+ CHECK(g.confirm_destructive, "get confirm_destructive");
+ CHECK(g.verbose, "get verbose");
+ CHECK(g.shell_allowlist.len == 2, "get allowlist len");
+ CHECK(g.shell_allowlist.len == 2 &&
+ strcmp(g.shell_allowlist.items[0], "git") == 0,
+ "get allowlist[0]");
+ CHECK(g.shell_allowlist.len == 2 &&
+ strcmp(g.shell_allowlist.items[1], "ls") == 0,
+ "get allowlist[1]");
+ CHECK(g.shell_denylist.len == 1 &&
+ strcmp(g.shell_denylist.items[0], "git push") == 0,
+ "get denylist[0]");
+ tool_limits_free(&l);
+ tool_limits_free(&g);
+ /* restore defaults for later tests */
+ tool_limits d;
+ tool_limits_defaults(&d);
+ tool_limits_set(&d);
+ tool_limits_free(&d);
+}
+
+static void test_allowlist_allows_matching_and_refuses_others(void) {
+ const char *allow[] = {"git", "ls"};
+ tool_limits l = mklimits(allow, 2, NULL, 0);
+ char *reason = NULL;
+ CHECK(shell_policy("git status", &l, NULL) == SHELL_ALLOWED,
+ "git status allowed");
+ CHECK(shell_policy("ls -la", &l, NULL) == SHELL_ALLOWED, "ls -la allowed");
+ CHECK(shell_policy("rm -rf x", &l, &reason) == SHELL_DENIED,
+ "rm -rf x refused");
+ CHECK(reason && strstr(reason, "allowlist") != NULL,
+ "refusal mentions allowlist");
+ free(reason);
+ tool_limits_free(&l);
+}
+
+static void test_allowlist_entry_does_not_match_longer_word(void) {
+ const char *allow[] = {"git"};
+ tool_limits l = mklimits(allow, 1, NULL, 0);
+ char *reason = NULL;
+ CHECK(shell_policy("igit x", &l, &reason) == SHELL_DENIED,
+ "igit x refused (no word-boundary match)");
+ free(reason);
+ CHECK(shell_policy("git push", &l, NULL) == SHELL_ALLOWED,
+ "git push allowed by bare-word entry");
+ tool_limits_free(&l);
+}
+
+static void test_denylist_wins_over_allowlist(void) {
+ const char *allow[] = {"git"};
+ const char *deny[] = {"git push"};
+ tool_limits l = mklimits(allow, 1, deny, 1);
+ CHECK(shell_policy("git status", &l, NULL) == SHELL_ALLOWED,
+ "git status allowed despite denylist presence");
+ char *reason = NULL;
+ CHECK(shell_policy("git push origin", &l, &reason) == SHELL_DENIED,
+ "git push origin refused by denylist");
+ CHECK_EQ_STR(
+ reason,
+ "command matches the denylist entry `git push`",
+ "exact denylist reason");
+ free(reason);
+ tool_limits_free(&l);
+}
+
+static void test_allowlist_reason_string(void) {
+ const char *allow[] = {"git", "ls"};
+ tool_limits l = mklimits(allow, 2, NULL, 0);
+ char *reason = NULL;
+ CHECK(shell_policy("rm -rf x", &l, &reason) == SHELL_DENIED, "denied");
+ CHECK_EQ_STR(reason, "command not in the allowlist (git, ls)",
+ "exact allowlist reason");
+ free(reason);
+ tool_limits_free(&l);
+}
+
+static void test_no_allowlist_means_no_allowlist(void) {
+ tool_limits l;
+ tool_limits_defaults(&l);
+ CHECK(shell_policy("anything at all", &l, NULL) == SHELL_ALLOWED,
+ "no allowlist: everything allowed");
+ tool_limits_free(&l);
+}
+
+static void test_gate_reason_wrapping(void) {
+ const char *deny[] = {"git push"};
+ tool_limits l = mklimits(NULL, 0, deny, 1);
+ char *err = NULL;
+ CHECK(shell_gate("git push origin", &l, &err) == -1, "gate denies");
+ CHECK_EQ_STR(err, "run_shell blocked: command matches the denylist "
+ "entry `git push`",
+ "gate wraps policy reason");
+ free(err);
+ tool_limits_free(&l);
+}
+
+static void test_destructive_detection(void) {
+ CHECK(is_destructive("rm -rf /tmp/x"), "rm is destructive");
+ CHECK(is_destructive("git reset --hard"), "git reset is destructive");
+ CHECK(is_destructive("dd if=/dev/zero of=/dev/sda bs=1M"),
+ "dd with of= is destructive");
+ CHECK(!is_destructive("ls -la"), "ls is not destructive");
+ CHECK(!is_destructive("git status"), "git status is not destructive");
+ CHECK(!is_destructive("echo hi"), "echo is not destructive");
+ CHECK(is_destructive("chmod 777 /etc/passwd"), "chmod is destructive");
+ CHECK(!is_destructive("dd if=/dev/zero bs=1M"),
+ "dd without of= is not destructive");
+ CHECK(is_destructive("git checkout -- ."), "git checkout is destructive");
+ CHECK(!is_destructive("git commit -m x"), "git commit is not destructive");
+ CHECK(is_destructive(" rm x"), "leading whitespace still destructive");
+ CHECK(is_destructive("echo safe; rm x"), "compound command requires confirmation");
+ CHECK(is_destructive("echo safe | rm x"), "pipeline requires confirmation");
+ CHECK(is_destructive("echo $(rm x)"), "expansion requires confirmation");
+ CHECK(!is_destructive(""), "empty command not destructive");
+}
+
+static bool hook_yes(const char *cmd, void *ud) { (void)cmd; (void)ud; return true; }
+static int hook_calls = 0;
+static char hook_last[256];
+static bool hook_record(const char *cmd, void *ud) {
+ (void)ud;
+ hook_calls++;
+ snprintf(hook_last, sizeof hook_last, "%s", cmd);
+ return false;
+}
+
+static void test_gate_refuses_when_hook_declines(void) {
+ tool_limits l;
+ tool_limits_defaults(&l);
+ l.confirm_destructive = true;
+ set_confirm_hook(hook_record, NULL);
+ hook_calls = 0;
+ char *err = NULL;
+ CHECK(shell_gate("rm -rf /tmp/x", &l, &err) == -1, "declined => -1");
+ CHECK(err && strstr(err, "declined") != NULL, "reason says declined");
+ CHECK_EQ_STR(err, "user declined to run destructive command: "
+ "rm -rf /tmp/x",
+ "exact decline reason");
+ free(err);
+ CHECK(hook_calls == 1, "hook invoked exactly once");
+ CHECK_EQ_STR(hook_last, "rm -rf /tmp/x", "hook received the command");
+ set_confirm_hook(NULL, NULL);
+ tool_limits_free(&l);
+}
+
+static void test_gate_allows_when_hook_approves(void) {
+ tool_limits l;
+ tool_limits_defaults(&l);
+ l.confirm_destructive = true;
+ set_confirm_hook(hook_yes, NULL);
+ char *err = NULL;
+ CHECK(shell_gate("rm -rf /tmp/x", &l, &err) == 0, "approved => 0");
+ CHECK(err == NULL, "no error on approval");
+ free(err);
+ set_confirm_hook(NULL, NULL);
+ tool_limits_free(&l);
+}
+
+static void test_gate_no_hook_denies(void) {
+ tool_limits l;
+ tool_limits_defaults(&l);
+ l.confirm_destructive = true;
+ set_confirm_hook(NULL, NULL);
+ char *err = NULL;
+ CHECK(shell_gate("rm -rf /tmp/x", &l, &err) == -1,
+ "no hook: destructive command denied");
+ CHECK(err && strstr(err, "declined"), "no-hook denial has reason");
+ free(err);
+ tool_limits_free(&l);
+}
+
+static void test_gate_non_destructive_skips_hook(void) {
+ tool_limits l;
+ tool_limits_defaults(&l);
+ l.confirm_destructive = true;
+ set_confirm_hook(hook_record, NULL);
+ hook_calls = 0;
+ char *err = NULL;
+ CHECK(shell_gate("ls -la", &l, &err) == 0, "non-destructive passes");
+ CHECK(hook_calls == 0, "hook not consulted for non-destructive");
+ free(err);
+ set_confirm_hook(NULL, NULL);
+ tool_limits_free(&l);
+}
+
+static void test_policy_null_reason(void) {
+ const char *deny[] = {"git push"};
+ tool_limits l = mklimits(NULL, 0, deny, 1);
+ CHECK(shell_policy("git push", &l, NULL) == SHELL_DENIED, "denied");
+ CHECK(shell_policy("ls", &l, NULL) == SHELL_ALLOWED, "allowed");
+ tool_limits_free(&l);
+}
+
+static void test_policy_checks_wrapped_commands(void) {
+ const char *deny[] = {"rm"};
+ tool_limits l = mklimits(NULL, 0, deny, 1);
+ CHECK(shell_policy("sudo -u root rm -rf /tmp/x", &l, NULL) == SHELL_DENIED,
+ "denylist catches sudo-wrapped rm");
+ CHECK(is_destructive("env PATH=/bin git clean -fd"),
+ "destructive classifier catches env-wrapped git clean");
+ CHECK(is_destructive("sudo git push origin main"),
+ "destructive classifier catches sudo-wrapped git push");
+ tool_limits_free(&l);
+}
+
+/* ---- verbose ---- */
+
+static char **vlines = NULL;
+static size_t vline_n = 0, vline_cap = 0;
+
+static void vrecord(const char *line, void *ud) {
+ (void)ud;
+ if (vline_n + 1 > vline_cap) {
+ vline_cap = vline_cap ? vline_cap * 2 : 8;
+ vlines = (char* * ) realloc(vlines, vline_cap * sizeof(char *));
+ }
+ vlines[vline_n++] = xstrdup(line);
+}
+
+static void vreset(void) {
+ for (size_t i = 0; i < vline_n; i++)
+ free(vlines[i]);
+ free(vlines);
+ vlines = NULL;
+ vline_n = 0;
+ vline_cap = 0;
+ verbose_set(false);
+ verbose_set_sink(NULL, NULL);
+}
+
+static void test_verbose_defaults_off(void) {
+ verbose_set(false);
+ CHECK(!verbose_enabled(), "verbose defaults off");
+}
+
+static void test_log_emits_json_line_when_enabled(void) {
+ vreset();
+ verbose_set(true);
+ verbose_set_sink(vrecord, NULL);
+ vlog_kv kvs[] = {
+ VLOG_STR("name", "grep"),
+ VLOG_STR("pattern", "x"),
+ VLOG_INT("count", 42),
+ VLOG_BOOL("ok", true),
+ VLOG_DBL("ratio", 0.5),
+ };
+ verbose_log("tool_call", kvs, 5);
+ CHECK(vline_n == 1, "exactly one line emitted");
+ cJSON *v = vline_n ? cJSON_Parse(vlines[0]) : NULL;
+ CHECK(v != NULL, "emitted line is valid JSON");
+ if (v) {
+ CHECK(strcmp(cJSON_GetObjectItem(v, "event")->valuestring,
+ "tool_call") == 0,
+ "event field");
+ CHECK(strcmp(cJSON_GetObjectItem(v, "name")->valuestring, "grep") == 0,
+ "name field");
+ CHECK(strcmp(cJSON_GetObjectItem(v, "pattern")->valuestring, "x") == 0,
+ "pattern field");
+ CHECK(cJSON_GetObjectItem(v, "count")->valueint == 42, "count field");
+ CHECK(cJSON_IsTrue(cJSON_GetObjectItem(v, "ok")), "ok field");
+ CHECK(cJSON_GetObjectItem(v, "ratio")->valuedouble == 0.5,
+ "ratio field");
+ cJSON_Delete(v);
+ }
+ /* no kv at all: just the event */
+ vreset();
+ verbose_set(true);
+ verbose_set_sink(vrecord, NULL);
+ verbose_log("turn_start", NULL, 0);
+ CHECK(vline_n == 1, "event-only line emitted");
+ cJSON *v2 = vline_n ? cJSON_Parse(vlines[0]) : NULL;
+ CHECK(v2 != NULL &&
+ strcmp(cJSON_GetObjectItem(v2, "event")->valuestring,
+ "turn_start") == 0,
+ "event-only payload");
+ if (v2)
+ cJSON_Delete(v2);
+ vreset();
+}
+
+static void test_log_noop_when_disabled(void) {
+ vreset();
+ verbose_set(false);
+ verbose_set_sink(vrecord, NULL);
+ vlog_kv kvs[] = {VLOG_STR("k", "v")};
+ verbose_log("never", kvs, 1);
+ CHECK(vline_n == 0, "no output when disabled");
+ vreset();
+}
+
+int main(void) {
+ test_defaults();
+ test_set_get_roundtrip();
+ test_allowlist_allows_matching_and_refuses_others();
+ test_allowlist_entry_does_not_match_longer_word();
+ test_denylist_wins_over_allowlist();
+ test_allowlist_reason_string();
+ test_no_allowlist_means_no_allowlist();
+ test_gate_reason_wrapping();
+ test_destructive_detection();
+ test_gate_refuses_when_hook_declines();
+ test_gate_allows_when_hook_approves();
+ test_gate_no_hook_denies();
+ test_gate_non_destructive_skips_hook();
+ test_policy_null_reason();
+ test_policy_checks_wrapped_commands();
+ test_verbose_defaults_off();
+ test_log_emits_json_line_when_enabled();
+ test_log_noop_when_disabled();
+ vreset();
+ printf("test_limits: %d checks, %d failures\n", checks, failures);
+ return failures ? 1 : 0;
+}
diff --git a/tests/test_mcp.cpp b/tests/test_mcp.cpp
new file mode 100644
index 0000000..ae112ba
--- /dev/null
+++ b/tests/test_mcp.cpp
@@ -0,0 +1,579 @@
+#include "mcp.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include <fcntl.h>
+#include <signal.h>
+#include <sys/types.h>
+#include <sys/wait.h>
+#include <unistd.h>
+
+#include <cjson/cJSON.h>
+
+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_EQ(a, b, msg) \
+ do { \
+ checks++; \
+ if (strcmp((a) ? (a) : "(null)", (b)) != 0) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: %s (got `%s`, want `%s`)\n", \
+ __FILE__, __LINE__, (msg), (a) ? (a) : "(null)", (b)); \
+ } \
+ } 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)
+
+static void mem_free(mcp_memory *m)
+{
+ sl_free(&m->responses);
+ sl_free(&m->sent);
+}
+
+/* helpers: push a queued JSON-RPC response / read the nth sent message */
+
+static void push_resp(mcp_memory *m, unsigned long id, const char *result)
+{
+ char *line = xstrfmt("{\"jsonrpc\":\"2.0\",\"id\":%lu,\"result\":%s}", id,
+ result);
+ sl_push(&m->responses, line);
+}
+
+static cJSON *sent_msg(mcp_memory *m, size_t i)
+{
+ if (i >= m->sent.len)
+ return NULL;
+ return cJSON_Parse(m->sent.items[i]);
+}
+
+static void test_parses_stdio_config(void)
+{
+ mcp_server_config cfg;
+ CHECK(mcp_server_config_from_json(
+ "{\"command\":\"node\",\"args\":[\"server.js\",\"--fast\"],"
+ "\"env\":{\"API_KEY\":\"k\"}}",
+ &cfg) == 1,
+ "stdio config parses");
+ CHECK_EQ(cfg.command, "node", "command");
+ CHECK(cfg.arg_count == 2, "args count");
+ CHECK(cfg.arg_count == 2 && strcmp(cfg.args[0], "server.js") == 0,
+ "arg 0");
+ CHECK(cfg.arg_count == 2 && strcmp(cfg.args[1], "--fast") == 0, "arg 1");
+ CHECK(cfg.env_count == 1 && strcmp(cfg.env[0].key, "API_KEY") == 0,
+ "env key");
+ CHECK(cfg.env_count == 1 && strcmp(cfg.env[0].val, "k") == 0, "env value");
+ CHECK(cfg.url == NULL, "no url");
+ mcp_server_config_free(&cfg);
+}
+
+static void test_parses_http_config(void)
+{
+ mcp_server_config cfg;
+ CHECK(mcp_server_config_from_json(
+ "{\"url\":\"https://example.com/mcp\","
+ "\"headers\":{\"Authorization\":\"Bearer t\"}}",
+ &cfg) == 1,
+ "http config parses");
+ CHECK_EQ(cfg.url, "https://example.com/mcp", "url");
+ CHECK(cfg.header_count == 1 &&
+ strcmp(cfg.headers[0].key, "Authorization") == 0,
+ "header key");
+ CHECK(cfg.header_count == 1 &&
+ strcmp(cfg.headers[0].val, "Bearer t") == 0,
+ "header value");
+ CHECK(cfg.command == NULL, "no command");
+ mcp_server_config_free(&cfg);
+}
+
+static void test_rejects_no_transport(void)
+{
+ mcp_server_config cfg;
+ CHECK(mcp_server_config_from_json("{\"foo\":\"bar\"}", &cfg) == 0,
+ "object without transport");
+ mcp_server_config_free(&cfg);
+ CHECK(mcp_server_config_from_json("42", &cfg) == 0, "non-object");
+ mcp_server_config_free(&cfg);
+ CHECK(mcp_server_config_from_json(NULL, &cfg) == 0, "null");
+ mcp_server_config_free(&cfg);
+}
+
+static void test_handshake_discovery_call(void)
+{
+ mcp_memory mem;
+ mcp_memory_init(&mem);
+ push_resp(&mem, 1,
+ "{\"protocolVersion\":\"2024-11-05\",\"capabilities\":{},\"serverInfo\":{\"name\":\"fake\",\"version\":\"0\"}}");
+ push_resp(&mem, 2,
+ "{\"tools\":[{\"name\":\"add\",\"description\":\"Add two "
+ "integers\",\"inputSchema\":{\"type\":\"object\",\"properties\":"
+ "{\"a\":{\"type\":\"integer\"},\"b\":{\"type\":\"integer\"}},"
+ "\"required\":[\"a\",\"b\"]}}]}");
+ push_resp(&mem, 3, "{\"content\":[{\"type\":\"text\",\"text\":\"7\"}]}");
+
+ char *err = NULL;
+ mcp_client *c = mcp_connect_memory(&mem, &err);
+ CHECK(c != NULL, "connect memory");
+
+ CHECK(mcp_initialize(c, &err) == 1, "handshake");
+ CHECK(err == NULL, "no handshake error");
+
+ mcp_tool *tools = NULL;
+ size_t n = 0;
+ err = NULL;
+ CHECK(mcp_list_tools(c, &tools, &n, &err) == 1, "tools/list");
+ CHECK(n == 1, "one tool");
+ if (n == 1) {
+ CHECK_EQ(tools[0].name, "add", "tool name");
+ CHECK_EQ(tools[0].description, "Add two integers", "tool description");
+ CHECK_CONT(tools[0].input_schema, "\"type\":\"object\"", "schema type");
+ CHECK_CONT(tools[0].input_schema, "\"required\"", "schema required");
+ }
+
+ err = NULL;
+ char *out = mcp_call_tool(c, "add", "{\"a\":3,\"b\":4}", &err);
+ CHECK(out != NULL, "tools/call");
+ CHECK_EQ(out, "7", "call result");
+ free(out);
+
+ /* wire order and ids must be exactly the MCP handshake */
+ CHECK(mem.sent.len == 4, "four messages sent");
+ const char *want[] = {"initialize", "notifications/initialized",
+ "tools/list", "tools/call"};
+ for (size_t i = 0; i < 4 && i < mem.sent.len; i++) {
+ cJSON *m = sent_msg(&mem, i);
+ cJSON *method = m ? cJSON_GetObjectItemCaseSensitive(m, "method") : NULL;
+ CHECK(cJSON_IsString(method) && strcmp(method->valuestring, want[i]) == 0,
+ "method order");
+ cJSON_Delete(m);
+ }
+ {
+ cJSON *m0 = sent_msg(&mem, 0);
+ cJSON *m2 = sent_msg(&mem, 2);
+ cJSON *m3 = sent_msg(&mem, 3);
+ CHECK(m0 && (unsigned long long)cJSON_GetObjectItemCaseSensitive(m0, "id")->valuedouble == 1,
+ "initialize id 1");
+ CHECK(m2 && (unsigned long long)cJSON_GetObjectItemCaseSensitive(m2, "id")->valuedouble == 2,
+ "tools/list id 2");
+ CHECK(m3 && (unsigned long long)cJSON_GetObjectItemCaseSensitive(m3, "id")->valuedouble == 3,
+ "tools/call id 3");
+ if (m3) {
+ cJSON *params = cJSON_GetObjectItemCaseSensitive(m3, "params");
+ cJSON *name = params ? cJSON_GetObjectItemCaseSensitive(params, "name") : NULL;
+ CHECK(cJSON_IsString(name) && strcmp(name->valuestring, "add") == 0,
+ "call params name");
+ cJSON *args = params ? cJSON_GetObjectItemCaseSensitive(params, "arguments") : NULL;
+ CHECK(args && (long long)cJSON_GetObjectItemCaseSensitive(args, "a")->valuedouble == 3,
+ "call params a=3");
+ }
+ cJSON_Delete(m0);
+ cJSON_Delete(m2);
+ cJSON_Delete(m3);
+ }
+ mcp_tools_free(tools, n);
+ mcp_client_free(c);
+ mem_free(&mem);
+}
+
+static void test_server_error_is_surfaced(void)
+{
+ mcp_memory mem;
+ mcp_memory_init(&mem);
+ sl_push(&mem.responses,
+ xstrdup("{\"jsonrpc\":\"2.0\",\"id\":1,\"error\":{\"code\":-32601,"
+ "\"message\":\"Method not found\"}}"));
+ char *err = NULL;
+ mcp_client *c = mcp_connect_memory(&mem, &err);
+ err = NULL;
+ char *res = mcp_request(c, "initialize", "{}", &err);
+ CHECK(res == NULL, "request failed");
+ CHECK(err && strstr(err, "server error -32601") != NULL, "error code");
+ CHECK(err && strstr(err, "Method not found") != NULL, "error message");
+ free(err);
+ mcp_client_free(c);
+ mem_free(&mem);
+}
+
+static void test_notification_lines_are_skipped(void)
+{
+ mcp_memory mem;
+ mcp_memory_init(&mem);
+ sl_push(&mem.responses, xstrdup(
+ "{\"jsonrpc\":\"2.0\",\"method\":"
+ "\"notifications/message\",\"params\":{}}"));
+ push_resp(&mem, 1, "{\"ok\":true}");
+ char *err = NULL;
+ mcp_client *c = mcp_connect_memory(&mem, &err);
+ err = NULL;
+ char *res = mcp_request(c, "ping", "{}", &err);
+ CHECK(res != NULL, "response past the notification");
+ CHECK_CONT(res, "\"ok\":true", "result ok");
+ free(res);
+ mcp_client_free(c);
+ mem_free(&mem);
+}
+
+static void test_tool_invoke_roundtrip(void)
+{
+ mcp_memory mem;
+ mcp_memory_init(&mem);
+ push_resp(&mem, 1,
+ "{\"tools\":[{\"name\":\"echo\",\"description\":\"echo\","
+ "\"inputSchema\":{\"type\":\"object\"}}]}");
+ push_resp(&mem, 2, "{\"content\":[{\"type\":\"text\",\"text\":\"pong\"}]}");
+ char *err = NULL;
+ mcp_client *c = mcp_connect_memory(&mem, &err);
+
+ mcp_tool *tools = NULL;
+ size_t n = 0;
+ err = NULL;
+ CHECK(mcp_list_tools(c, &tools, &n, &err) == 1, "tools");
+ CHECK(n == 1, "one tool");
+ if (n == 1) {
+ CHECK_EQ(tools[0].name, "echo", "tool name");
+ err = NULL;
+ char *out = mcp_tool_invoke(&tools[0], "{\"msg\":\"hi\"}", &err);
+ CHECK(out != NULL, "execute");
+ CHECK_CONT(out, "pong", "server output");
+ free(out);
+ }
+ mcp_tools_free(tools, n);
+ mcp_client_free(c);
+ mem_free(&mem);
+}
+
+static void test_tool_invoke_wraps_errors(void)
+{
+ mcp_memory mem;
+ mcp_memory_init(&mem);
+ push_resp(&mem, 1,
+ "{\"tools\":[{\"name\":\"boom\",\"description\":\"\",\"inputSchema\":{\"type\":\"object\"}}]}");
+ push_resp(&mem, 2,
+ "{\"isError\":true,\"content\":[{\"type\":\"text\",\"text\":\"kaboom\"}]}");
+ char *err = NULL;
+ mcp_client *c = mcp_connect_memory(&mem, &err);
+ mcp_tool *tools = NULL;
+ size_t n = 0;
+ err = NULL;
+ CHECK(mcp_list_tools(c, &tools, &n, &err) == 1, "tools");
+ if (n == 1) {
+ err = NULL;
+ char *out = mcp_tool_invoke(&tools[0], "{}", &err);
+ CHECK(out == NULL, "isError surfaces as failure");
+ CHECK(err && strstr(err, "MCP tool `boom` failed") != NULL,
+ "wrapped error");
+ CHECK(err && strstr(err, "kaboom") != NULL, "server text included");
+ free(err);
+ }
+ mcp_tools_free(tools, n);
+ mcp_client_free(c);
+ mem_free(&mem);
+}
+
+static void test_extract_json_plain_and_sse(void)
+{
+ char *err = NULL;
+ char *v = mcp_extract_json(
+ "application/json",
+ "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"a\":1}}", &err);
+ CHECK(v != NULL, "plain parses");
+ CHECK_CONT(v, "\"a\":1", "plain result");
+ free(v);
+
+ const char *sse = "event: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":2,"
+ "\"result\":{\"b\":2}}\n\n";
+ err = NULL;
+ v = mcp_extract_json("text/event-stream", sse, &err);
+ CHECK(v != NULL, "sse parses");
+ CHECK_CONT(v, "\"b\":2", "sse result");
+ free(v);
+
+ err = NULL;
+ v = mcp_extract_json("text/event-stream", "event: message\n\n", &err);
+ CHECK(v == NULL, "sse without data line fails");
+ CHECK(err && strstr(err, "data:") != NULL, "sse error mentions data:");
+ free(err);
+}
+
+static void test_connect_all_collects_warnings(void)
+{
+ const char *names[] = {"good", "bad"};
+ const char *configs[] = {
+ "{\"command\":\"/definitely/not/a/real/binary-llsh\"}",
+ "{\"foo\":\"bar\"}",
+ };
+ strlist warnings;
+ sl_init(&warnings);
+ mcp_registry reg;
+ mcp_connect_all(names, configs, 2, &reg, &warnings);
+ CHECK(reg.tool_count == 0, "no tools from unreachable servers");
+ CHECK(warnings.len == 2, "one warning per failed server");
+ if (warnings.len == 2) {
+ CHECK_CONT(warnings.items[0], "`good`", "good warning");
+ CHECK_CONT(warnings.items[1], "`bad`", "bad warning");
+ CHECK_CONT(warnings.items[1], "no `command` or `url`", "bad reason");
+ }
+ for (size_t i = 0; i < warnings.len; i++)
+ free(warnings.items[i]);
+ free(warnings.items);
+ mcp_registry_free(&reg);
+}
+
+/* ------------------------------------------------------------------ */
+/* read_line_fd (via mcp_read_line) -- buffered stdio reads */
+/* ------------------------------------------------------------------ */
+
+/* Spawn `sh -c cmd` with stdout piped; returns the read end and the pid. */
+static int spawn_sh(const char *cmd, pid_t *out_pid)
+{
+ int pfd[2];
+ if (pipe(pfd) != 0)
+ return -1;
+ pid_t pid = fork();
+ if (pid < 0) {
+ close(pfd[0]);
+ close(pfd[1]);
+ return -1;
+ }
+ if (pid == 0) {
+ dup2(pfd[1], STDOUT_FILENO);
+ close(pfd[0]);
+ close(pfd[1]);
+ int dn = open("/dev/null", O_WRONLY);
+ if (dn >= 0) {
+ dup2(dn, STDERR_FILENO);
+ close(dn);
+ }
+ setpgid(0, 0);
+ execl("/bin/sh", "sh", "-c", cmd, (char *)NULL);
+ _exit(127);
+ }
+ close(pfd[1]);
+ if (out_pid)
+ *out_pid = pid;
+ return pfd[0];
+}
+
+static void kill_sh(pid_t pid)
+{
+ if (pid <= 0)
+ return;
+ kill(-pid, SIGKILL);
+ int st;
+ waitpid(pid, &st, 0);
+}
+
+/* A burst of lines arriving in one read(): the first line must come back
+ * alone, with the rest carried for the following calls. */
+static void test_read_line_burst(void)
+{
+ pid_t pid = 0;
+ int fd = spawn_sh("printf 'a\\nb\\nc\\n'", &pid);
+ CHECK(fd >= 0, "server spawned");
+ char *err = NULL;
+ mcp_client *c = mcp_connect_fds(-1, fd, &err);
+ CHECK(c != NULL, "fd client created");
+ char *l = mcp_read_line(c, "t", &err);
+ CHECK(l && strcmp(l, "a") == 0, "first line of the burst");
+ free(l);
+ err = NULL;
+ l = mcp_read_line(c, "t", &err);
+ CHECK(l && strcmp(l, "b") == 0, "second line of the burst");
+ free(l);
+ err = NULL;
+ l = mcp_read_line(c, "t", &err);
+ CHECK(l && strcmp(l, "c") == 0, "third line of the burst");
+ free(l);
+ err = NULL;
+ l = mcp_read_line(c, "t", &err);
+ CHECK(l == NULL, "EOF after the burst");
+ CHECK(err && strstr(err, "server closed") != NULL, "EOF error message");
+ free(err);
+ mcp_client_free(c);
+ kill_sh(pid);
+}
+
+/* A line split across two read() calls must be reassembled from the carry
+ * buffer. */
+static void test_read_line_partial_across_reads(void)
+{
+ pid_t pid = 0;
+ int fd = spawn_sh("printf 'hel'; sleep 0.5; printf 'lo\\nworld\\n'", &pid);
+ CHECK(fd >= 0, "server spawned");
+ char *err = NULL;
+ mcp_client *c = mcp_connect_fds(-1, fd, &err);
+ char *l = mcp_read_line(c, "t", &err);
+ CHECK(l && strcmp(l, "hello") == 0, "line assembled across two reads");
+ free(l);
+ err = NULL;
+ l = mcp_read_line(c, "t", &err);
+ CHECK(l && strcmp(l, "world") == 0, "following line intact");
+ free(l);
+ mcp_client_free(c);
+ kill_sh(pid);
+}
+
+/* Timeout with bytes in flight: the partial line is what we got. */
+static void test_read_line_timeout_partial(void)
+{
+ setenv("LLSH_MCP_TIMEOUT", "1", 1);
+ pid_t pid = 0;
+ int fd = spawn_sh("printf 'partial'; sleep 5", &pid);
+ CHECK(fd >= 0, "server spawned");
+ char *err = NULL;
+ mcp_client *c = mcp_connect_fds(-1, fd, &err);
+ char *l = mcp_read_line(c, "t", &err);
+ CHECK(l && strcmp(l, "partial") == 0, "partial line returned on timeout");
+ free(l);
+ mcp_client_free(c);
+ kill_sh(pid);
+ unsetenv("LLSH_MCP_TIMEOUT");
+}
+
+/* Timeout with a silent server: an error, not a hang or a fake line. */
+static void test_read_line_timeout_empty(void)
+{
+ setenv("LLSH_MCP_TIMEOUT", "1", 1);
+ pid_t pid = 0;
+ int fd = spawn_sh("sleep 5", &pid);
+ CHECK(fd >= 0, "server spawned");
+ char *err = NULL;
+ mcp_client *c = mcp_connect_fds(-1, fd, &err);
+ char *l = mcp_read_line(c, "t", &err);
+ CHECK(l == NULL, "no line when the server is silent");
+ CHECK(err && strstr(err, "timed out") != NULL, "timeout error message");
+ free(err);
+ mcp_client_free(c);
+ kill_sh(pid);
+ unsetenv("LLSH_MCP_TIMEOUT");
+}
+
+static void test_stdio_line_size_limit(void)
+{
+ setenv("LLSH_MCP_TIMEOUT", "3", 1);
+ pid_t pid = 0;
+ int fd = spawn_sh("head -c 4194305 /dev/zero", &pid);
+ CHECK(fd >= 0, "large-response server spawned");
+ char *err = NULL;
+ mcp_client *c = mcp_connect_fds(-1, fd, &err);
+ char *line = mcp_read_line(c, "large", &err);
+ CHECK(line == NULL, "oversized MCP response line rejected");
+ CHECK(err && strstr(err, "4 MiB limit"), "size-limit error returned");
+ free(line);
+ free(err);
+ mcp_client_free(c);
+ kill_sh(pid);
+ unsetenv("LLSH_MCP_TIMEOUT");
+}
+
+/* ------------------------------------------------------------------ */
+/* mcp_connect_stdio -- real fork/pipe wiring */
+/* ------------------------------------------------------------------ */
+
+/* The child must be able to write to stdout and the parent to read it
+ * back: the pipe ends were historically swapped, which made every stdio
+ * server fail. A child that answers on its own (and stays alive so the
+ * parent's write to its stdin does not hit EPIPE) exercises both ends. */
+static void test_connect_stdio_roundtrip(void)
+{
+ mcp_server_config cfg;
+ memset(&cfg, 0, sizeof cfg);
+ cfg.command = xstrdup("/bin/sh");
+ cfg.args = (char **)xmalloc(2 * sizeof *cfg.args);
+ cfg.args[0] = xstrdup("-c");
+ cfg.args[1] =
+ xstrdup("printf '{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\"pong\"}"
+ "\\n'; sleep 1");
+ cfg.arg_count = 2;
+
+ char *err = NULL;
+ mcp_client *c = mcp_connect_stdio(&cfg, &err);
+ CHECK(c != NULL, "stdio client spawned");
+ if (!c) {
+ if (err)
+ free(err);
+ mcp_server_config_free(&cfg);
+ return;
+ }
+ char *res = mcp_request(c, "ping", "{}", &err);
+ CHECK(res != NULL, "request round-trips to the child's stdout");
+ if (res)
+ CHECK(strstr(res, "pong") != NULL, "child response read back");
+ free(res);
+ if (err)
+ free(err);
+ mcp_client_free(c);
+ mcp_server_config_free(&cfg);
+}
+
+static void test_connect_stdio_sends_newline_terminated_request(void)
+{
+ setenv("LLSH_MCP_TIMEOUT", "2", 1);
+ mcp_server_config cfg;
+ memset(&cfg, 0, sizeof cfg);
+ cfg.command = xstrdup("/bin/sh");
+ cfg.args = (char **)xmalloc(2 * sizeof *cfg.args);
+ cfg.args[0] = xstrdup("-c");
+ cfg.args[1] = xstrdup(
+ "IFS= read -r req || exit 2; printf '{\"jsonrpc\":\"2.0\","
+ "\"id\":1,\"result\":\"newline-ok\"}\\n'; sleep 1");
+ cfg.arg_count = 2;
+ char *err = NULL;
+ mcp_client *c = mcp_connect_stdio(&cfg, &err);
+ CHECK(c != NULL, "stdio client spawned");
+ char *res = c ? mcp_request(c, "ping", "{}", &err) : NULL;
+ CHECK(res && strstr(res, "newline-ok"),
+ "server receives complete newline-framed request");
+ free(res);
+ free(err);
+ mcp_client_free(c);
+ mcp_server_config_free(&cfg);
+ unsetenv("LLSH_MCP_TIMEOUT");
+}
+
+int main(void)
+{
+ /* Writes to a pipe whose server already died (e.g. a failed exec) must
+ * surface as EPIPE, not kill the process with SIGPIPE. */
+ signal(SIGPIPE, SIG_IGN);
+ test_parses_stdio_config();
+ test_parses_http_config();
+ test_rejects_no_transport();
+ test_handshake_discovery_call();
+ test_server_error_is_surfaced();
+ test_notification_lines_are_skipped();
+ test_tool_invoke_roundtrip();
+ test_tool_invoke_wraps_errors();
+ test_extract_json_plain_and_sse();
+ test_connect_all_collects_warnings();
+ test_read_line_burst();
+ test_read_line_partial_across_reads();
+ test_read_line_timeout_partial();
+ test_read_line_timeout_empty();
+ test_stdio_line_size_limit();
+ test_connect_stdio_roundtrip();
+ test_connect_stdio_sends_newline_terminated_request();
+ printf("%d checks, %d failures\n", checks, failures);
+ return failures ? 1 : 0;
+}
diff --git a/tests/test_provider.cpp b/tests/test_provider.cpp
new file mode 100644
index 0000000..7bc983d
--- /dev/null
+++ b/tests/test_provider.cpp
@@ -0,0 +1,647 @@
+#include "provider.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "config.h"
+#include "dotagents.h"
+#include "message.h"
+#include "session.h"
+
+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 (want substring `%s`)\n", \
+ __FILE__, __LINE__, (msg), (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 unwanted `%s`)\n", \
+ __FILE__, __LINE__, (msg), (needle)); \
+ } \
+ } while (0)
+
+static const char *SENTINEL = "SENTINEL-PREAMBLE-XYZ";
+
+static void mock_destroy(llsh_model *m) { (void)m; }
+static llsh_model MOCK = {NULL, NULL, mock_destroy};
+
+static session *make_session(const char *provider) {
+ config c;
+ memset(&c, 0, sizeof c);
+ c.provider = provider_parse(provider, NULL);
+ c.model = xstrdup("test-model");
+ c.preamble = xstrdup(SENTINEL);
+ dotagents d;
+ memset(&d, 0, sizeof d);
+ return session_new_with_model(&MOCK, c, d);
+}
+
+/* The preamble must reach the wire for every provider. Before the fix it was
+ * only emitted for Anthropic and silently dropped for OpenAI/Ollama. */
+static void test_preamble_openai(void) {
+ session *s = make_session("openai");
+ char *body = provider_preview_request(s, "hello", true);
+ CHECK(body != NULL, "openai body built");
+ /* OpenAI carries the system prompt as a leading system message. */
+ CHECK_CONT(body, "\"role\":\"system\"", "has a system message");
+ CHECK_CONT(body, SENTINEL, "system message carries the preamble");
+ CHECK_CONT(body, "\"role\":\"user\"", "user message still present");
+ CHECK_CONT(body, "hello", "prompt present");
+ free(body);
+ session_free(s);
+}
+
+static void test_preamble_ollama(void) {
+ session *s = make_session("ollama");
+ char *body = provider_preview_request(s, "hello", true);
+ CHECK(body != NULL, "ollama body built");
+ /* Ollama carries the system prompt as a top-level `system` field. */
+ CHECK_CONT(body, "\"system\":", "has a top-level system field");
+ CHECK_CONT(body, SENTINEL, "system field carries the preamble");
+ CHECK_CONT(body, "hello", "prompt present");
+ free(body);
+ session_free(s);
+}
+
+static void test_preamble_anthropic(void) {
+ session *s = make_session("anthropic");
+ char *body = provider_preview_request(s, "hello", true);
+ CHECK(body != NULL, "anthropic body built");
+ CHECK_CONT(body, "\"system\":", "has a system field");
+ CHECK_CONT(body, SENTINEL, "system carries the preamble");
+ free(body);
+ session_free(s);
+}
+
+/* A model name with JSON-special characters must not corrupt the body. */
+static void test_model_name_escaped(void) {
+ config c;
+ memset(&c, 0, sizeof c);
+ c.provider = PROV_OPENAI;
+ c.model = xstrdup("we\"ird/model");
+ c.preamble = xstrdup(SENTINEL);
+ dotagents d;
+ memset(&d, 0, sizeof d);
+ session *s = session_new_with_model(&MOCK, c, d);
+ char *body = provider_preview_request(s, "hi", false);
+ CHECK(body != NULL, "body built");
+ /* The raw `"` in the model name must be escaped to \". */
+ CHECK_CONT(body, "we\\\"ird/model", "model name quote is escaped");
+ free(body);
+ session_free(s);
+}
+
+static void test_no_preamble_omits_system(void) {
+ config c;
+ memset(&c, 0, sizeof c);
+ c.provider = PROV_OPENAI;
+ c.model = xstrdup("m");
+ c.preamble = NULL; /* no preamble */
+ dotagents d;
+ memset(&d, 0, sizeof d);
+ session *s = session_new_with_model(&MOCK, c, d);
+ char *body = provider_preview_request(s, "hi", false);
+ CHECK(body != NULL, "body built");
+ CHECK_NOT_CONT(body, "\"role\":\"system\"", "no system message without preamble");
+ free(body);
+ session_free(s);
+}
+
+/* A turn stopped mid tool-loop leaves an assistant message with more tool
+ * calls than the tool-result messages that follow it. The trim must drop the
+ * surplus calls so the history stays a valid request. */
+static void test_trim_dangling_calls(void) {
+ message am = msg_assistant_text("working on it");
+ msg_push_assistant_tool_call(am, "read_file", "{\"path\":\"a\"}");
+ msg_push_assistant_tool_call(am, "write_file", "{\"path\":\"b\"}");
+ message r1 = msg_user_tool_result("call-1", "read_file", "contents");
+ message msgs[2] = {am, r1};
+
+ CHECK(am.assistant().parts.size() == 3,
+ "assistant has text + 2 calls before trim");
+ provider_trim_dangling_calls(msgs, 2);
+ int calls = 0;
+ for (size_t i = 0; i < msgs[0].assistant().parts.size(); i++)
+ if (std::holds_alternative<asst_tool_call_part>(
+ msgs[0].assistant().parts[i]))
+ calls++;
+ CHECK(calls == 1, "surplus call trimmed to match the single result");
+
+ /* Fully-answered calls must be untouched. */
+ message am2 = msg_assistant_text("");
+ msg_push_assistant_tool_call(am2, "read_file", "{}");
+ message ra = msg_user_tool_result("c1", "read_file", "x");
+ message rb = msg_user_tool_result("c2", "write_file", "ok");
+ msg_push_assistant_tool_call(am2, "write_file", "{}");
+ message msgs2[3] = {am2, ra, rb};
+ provider_trim_dangling_calls(msgs2, 3);
+ int calls2 = 0;
+ for (size_t i = 0; i < msgs2[0].assistant().parts.size(); i++)
+ if (std::holds_alternative<asst_tool_call_part>(
+ msgs2[0].assistant().parts[i]))
+ calls2++;
+ CHECK(calls2 == 2, "complete call/result pair is preserved");
+}
+
+/* Reasoning models return the chain of thought in provider-specific fields.
+ * None of the parsers extracted it before the fix, so it was never displayed. */
+static void test_reasoning_openai(void) {
+ const char *body =
+ "{\"choices\":[{\"message\":{\"content\":\"42\","
+ "\"reasoning_content\":\"think... think... 42\"}}]}";
+ llm_conn conn; memset(&conn, 0, sizeof conn);
+ conn.provider = LLM_OPENAI; conn.model = "m";
+ llm_completion comp; memset(&comp, 0, sizeof comp);
+ CHECK(llm_parse_body(&conn, body, &comp) == 0, "openai body parses");
+ CHECK_CONT(comp.text, "42", "answer text extracted");
+ CHECK(comp.reasoning != NULL, "reasoning extracted (was NULL before fix)");
+ CHECK_CONT(comp.reasoning, "think...", "reasoning content preserved");
+ llm_completion_free(&comp);
+}
+
+static void test_reasoning_ollama(void) {
+ const char *body =
+ "{\"message\":{\"content\":\"the answer\","
+ "\"thinking\":\"pondering the answer...\"}}";
+ llm_conn conn; memset(&conn, 0, sizeof conn);
+ conn.provider = LLM_OLLAMA; conn.model = "m";
+ llm_completion comp; memset(&comp, 0, sizeof comp);
+ CHECK(llm_parse_body(&conn, body, &comp) == 0, "ollama body parses");
+ CHECK_CONT(comp.text, "the answer", "answer text extracted");
+ CHECK(comp.reasoning != NULL, "reasoning extracted from message.thinking");
+ CHECK_CONT(comp.reasoning, "pondering", "reasoning content preserved");
+ llm_completion_free(&comp);
+}
+
+static void test_reasoning_anthropic(void) {
+ const char *body =
+ "{\"content\":[{\"type\":\"thinking\",\"thinking\":\"hmm, let me "
+ "reason...\"},{\"type\":\"text\",\"text\":\"done\"}]}";
+ llm_conn conn; memset(&conn, 0, sizeof conn);
+ conn.provider = LLM_ANTHROPIC; conn.model = "m";
+ llm_completion comp; memset(&comp, 0, sizeof comp);
+ CHECK(llm_parse_body(&conn, body, &comp) == 0, "anthropic body parses");
+ CHECK_CONT(comp.text, "done", "answer text extracted");
+ CHECK(comp.reasoning != NULL, "reasoning extracted from thinking block");
+ CHECK_CONT(comp.reasoning, "reason", "reasoning content preserved");
+ llm_completion_free(&comp);
+}
+
+/* A plain (non-reasoning) response must not fabricate a reasoning field. */
+static void test_no_reasoning_when_absent(void) {
+ const char *body =
+ "{\"choices\":[{\"message\":{\"content\":\"just an answer\"}}]}";
+ llm_conn conn; memset(&conn, 0, sizeof conn);
+ conn.provider = LLM_OPENAI; conn.model = "m";
+ llm_completion comp; memset(&comp, 0, sizeof comp);
+ CHECK(llm_parse_body(&conn, body, &comp) == 0, "plain body parses");
+ CHECK_CONT(comp.text, "just an answer", "answer text extracted");
+ CHECK(comp.reasoning == NULL, "no reasoning field fabricated");
+ llm_completion_free(&comp);
+}
+
+/* ------------------------------------------------------------------ */
+/* sse_parse_line -- the pure streaming unit (AUDIT phase 2 test seam) */
+/* ------------------------------------------------------------------ */
+
+/* OpenAI: text deltas, two interleaved multi-chunk tool calls,
+ * finish_reason, [DONE], usage. */
+static void test_sse_openai(void) {
+ llm_sse_acc a;
+ llm_sse_acc_init(&a, LLM_OPENAI);
+ llm_sse_parse_line(LLM_OPENAI, "data: {\"choices\":[{\"delta\":{\"content\":\"Hel\"}}]}", &a);
+ llm_sse_parse_line(LLM_OPENAI, "data: {\"choices\":[{\"delta\":{\"content\":\"lo\"}}]}", &a);
+ CHECK(a.text.len == 5, "text deltas accumulate");
+ CHECK_CONT(a.text.data, "Hello", "text content");
+ llm_sse_parse_line(LLM_OPENAI,
+ "data: {\"choices\":[{\"delta\":{\"tool_calls\":["
+ "{\"index\":0,\"id\":\"c1\",\"function\":{\"name\":\"read_file\","
+ "\"arguments\":\"{\\\"pa\"}}]}}]}", &a);
+ llm_sse_parse_line(LLM_OPENAI,
+ "data: {\"choices\":[{\"delta\":{\"tool_calls\":["
+ "{\"index\":1,\"id\":\"c2\",\"function\":{\"name\":\"grep\","
+ "\"arguments\":\"{\\\"pat\"}}]}}]}", &a);
+ llm_sse_parse_line(LLM_OPENAI,
+ "data: {\"choices\":[{\"delta\":{\"tool_calls\":["
+ "{\"index\":0,\"function\":{\"arguments\":\"th\\\":\\\"a.txt\\\"}\"}}]}}]}",
+ &a);
+ llm_sse_parse_line(LLM_OPENAI,
+ "data: {\"choices\":[{\"delta\":{\"tool_calls\":["
+ "{\"index\":1,\"function\":{\"arguments\":\"tern\\\":\\\"x\\\"}\"}}]}}]}",
+ &a);
+ CHECK(a.tc_len == 2, "two tool calls indexed");
+ CHECK(a.tc_id[0] && strcmp(a.tc_id[0], "c1") == 0, "call 0 id");
+ CHECK(a.tc_id[1] && strcmp(a.tc_id[1], "c2") == 0, "call 1 id");
+ CHECK(a.tc_name[0] && strcmp(a.tc_name[0], "read_file") == 0, "call 0 name");
+ CHECK_CONT(a.tc_args[0].data, "\"path\":\"a.txt\"", "call 0 args assembled");
+ CHECK_CONT(a.tc_args[1].data, "\"pattern\":\"x\"", "call 1 args assembled");
+ llm_sse_parse_line(LLM_OPENAI,
+ "data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}]}",
+ &a);
+ CHECK(a.done, "finish_reason marks the stream done");
+ llm_sse_parse_line(LLM_OPENAI, "data: [DONE]", &a);
+ llm_sse_parse_line(LLM_OPENAI, "data: {\"usage\":{\"prompt_tokens\":42}}", &a);
+ CHECK(a.input_tokens == 42, "usage.prompt_tokens captured");
+ llm_sse_acc_free(&a);
+}
+
+/* Anthropic: event:/data: pairing, thinking + text blocks, a tool_use
+ * block with chunked input_json_delta, message_start usage, message_stop. */
+static void test_sse_anthropic(void) {
+ llm_sse_acc a;
+ llm_sse_acc_init(&a, LLM_ANTHROPIC);
+ llm_sse_parse_line(LLM_ANTHROPIC, "event: message_start", &a);
+ llm_sse_parse_line(LLM_ANTHROPIC,
+ "data: {\"message\":{\"usage\":{\"input_tokens\":7}}}", &a);
+ CHECK(a.input_tokens == 7, "usage from message_start");
+ llm_sse_parse_line(LLM_ANTHROPIC, "event: content_block_start", &a);
+ llm_sse_parse_line(LLM_ANTHROPIC,
+ "data: {\"content_block\":{\"type\":\"thinking\"}}", &a);
+ llm_sse_parse_line(LLM_ANTHROPIC, "event: content_block_delta", &a);
+ llm_sse_parse_line(LLM_ANTHROPIC,
+ "data: {\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"hmm \"}}",
+ &a);
+ llm_sse_parse_line(LLM_ANTHROPIC, "event: content_block_start", &a);
+ llm_sse_parse_line(LLM_ANTHROPIC,
+ "data: {\"content_block\":{\"type\":\"text\"}}", &a);
+ llm_sse_parse_line(LLM_ANTHROPIC, "event: content_block_delta", &a);
+ llm_sse_parse_line(LLM_ANTHROPIC,
+ "data: {\"delta\":{\"type\":\"text_delta\",\"text\":\"ok\"}}", &a);
+ CHECK_CONT(a.reasoning.data, "hmm", "thinking_delta captured");
+ CHECK_CONT(a.text.data, "ok", "text_delta captured");
+ llm_sse_parse_line(LLM_ANTHROPIC, "event: content_block_start", &a);
+ llm_sse_parse_line(LLM_ANTHROPIC,
+ "data: {\"content_block\":{\"type\":\"tool_use\",\"id\":\"t1\","
+ "\"name\":\"write_file\"}}", &a);
+ llm_sse_parse_line(LLM_ANTHROPIC, "event: content_block_delta", &a);
+ llm_sse_parse_line(LLM_ANTHROPIC,
+ "data: {\"delta\":{\"type\":\"input_json_delta\","
+ "\"partial_json\":\"{\\\"pa\"}}", &a);
+ llm_sse_parse_line(LLM_ANTHROPIC,
+ "data: {\"delta\":{\"type\":\"input_json_delta\","
+ "\"partial_json\":\"th\\\":\\\"x\\\"}\"}}", &a);
+ CHECK(a.tc_len == 1, "one tool_use block");
+ CHECK(a.tc_id[0] && strcmp(a.tc_id[0], "t1") == 0, "tool_use id");
+ CHECK(a.tc_name[0] && strcmp(a.tc_name[0], "write_file") == 0, "tool_use name");
+ CHECK_CONT(a.tc_args[0].data, "\"path\":\"x\"", "chunked partial_json assembled");
+ llm_sse_parse_line(LLM_ANTHROPIC, "event: message_stop", &a);
+ llm_sse_parse_line(LLM_ANTHROPIC, "data: [DONE]", &a);
+ CHECK(a.done, "message_stop marks done");
+ llm_sse_acc_free(&a);
+}
+
+/* Ollama NDJSON: content + thinking chunks, prompt_eval_count, tool_calls
+ * on the done chunk (object arguments), done:true. */
+static void test_sse_ollama(void) {
+ llm_sse_acc a;
+ llm_sse_acc_init(&a, LLM_OLLAMA);
+ llm_sse_parse_line(LLM_OLLAMA, "{\"message\":{\"content\":\"He\"}}", &a);
+ llm_sse_parse_line(LLM_OLLAMA, "{\"message\":{\"thinking\":\"think\"}}", &a);
+ llm_sse_parse_line(LLM_OLLAMA, "{\"message\":{\"content\":\"llo\"}}", &a);
+ CHECK_CONT(a.text.data, "Hello", "content chunks accumulate");
+ CHECK_CONT(a.reasoning.data, "think", "thinking chunk captured");
+ llm_sse_parse_line(LLM_OLLAMA, "{\"prompt_eval_count\":9}", &a);
+ CHECK(a.input_tokens == 9, "prompt_eval_count captured");
+ llm_sse_parse_line(LLM_OLLAMA,
+ "{\"message\":{\"tool_calls\":[{\"id\":\"o1\",\"function\":"
+ "{\"name\":\"list_dir\",\"arguments\":{\"path\":\".\"}}}]},"
+ "\"done\":true}", &a);
+ CHECK(a.tc_len == 1, "tool call on the done chunk");
+ CHECK(a.tc_id[0] && strcmp(a.tc_id[0], "o1") == 0, "tool id");
+ CHECK(a.tc_name[0] && strcmp(a.tc_name[0], "list_dir") == 0, "tool name");
+ CHECK_CONT(a.tc_args[0].data, "\"path\":\".\"", "object args serialized");
+ CHECK(a.done, "done:true marks done");
+ llm_sse_acc_free(&a);
+}
+
+/* Lines outside the protocol (stray text, bad JSON) are ignored without
+ * corrupting the accumulator. */
+static void test_sse_noise_lines(void) {
+ llm_sse_acc a;
+ llm_sse_acc_init(&a, LLM_OPENAI);
+ llm_sse_parse_line(LLM_OPENAI, "event: ping", &a); /* not `data:` -> ignored */
+ llm_sse_parse_line(LLM_OPENAI, "data: not-json", &a); /* bad JSON -> ignored */
+ llm_sse_parse_line(LLM_OPENAI, "data: {\"choices\":[]}", &a); /* empty choices */
+ CHECK(a.text.len == 0 && a.tc_len == 0 && !a.done, "noise left acc empty");
+ llm_sse_acc_free(&a);
+
+ llm_sse_acc b;
+ llm_sse_acc_init(&b, LLM_ANTHROPIC);
+ llm_sse_parse_line(LLM_ANTHROPIC, "data: {\"no\":\"event\"}", &b); /* no event set */
+ llm_sse_parse_line(LLM_ANTHROPIC, "data: {", &b); /* truncated JSON */
+ CHECK(b.text.len == 0 && b.tc_len == 0 && !b.done, "anthropic noise ignored");
+ llm_sse_acc_free(&b);
+
+ llm_sse_acc c;
+ llm_sse_acc_init(&c, LLM_OLLAMA);
+ llm_sse_parse_line(LLM_OLLAMA, "hello", &c); /* not JSON */
+ llm_sse_parse_line(LLM_OLLAMA, "{}", &c); /* no message field */
+ CHECK(c.text.len == 0 && c.tc_len == 0 && !c.done, "ollama noise ignored");
+ llm_sse_acc_free(&c);
+}
+
+/* sse_acc_into: unnamed/unargued calls get default ids and `{}` args. */
+static void test_sse_acc_into_defaults(void) {
+ llm_sse_acc a;
+ llm_sse_acc_init(&a, LLM_OLLAMA);
+ llm_acc_push_tool(&a, NULL, NULL);
+ llm_completion c;
+ memset(&c, 0, sizeof c);
+ llm_sse_acc_into(&a, &c);
+ CHECK(c.tc_len == 1, "one materialized call");
+ CHECK(c.tc_id[0] && strstr(c.tc_id[0], "call-1") != NULL, "default id");
+ CHECK(c.tc_args[0] && strcmp(c.tc_args[0], "{}") == 0, "default empty args");
+ llm_completion_free(&c);
+ llm_sse_acc_free(&a);
+}
+
+/* sse_acc_into: non-empty text/reasoning transfer ownership to `c` and must
+ * survive completion_free + sse_acc_free without a double-free. Regression:
+ * the old code freed the stolen buffer right after handing it to `c`,
+ * dangling c->text/c->reasoning (crashed on real streamed responses). */
+static void test_sse_acc_into_text_reasoning_ownership(void) {
+ llm_sse_acc a;
+ llm_sse_acc_init(&a, LLM_OPENAI);
+ llm_buf_put(&a.text, "the answer is 42");
+ llm_buf_put(&a.reasoning, "pondering deeply");
+ llm_acc_push_tool(&a, xstrdup("call-9"), xstrdup("read_file"));
+ llm_buf_put(&a.tc_args[0], "\"path\":\"AUDIT.md\"");
+
+ llm_completion c;
+ memset(&c, 0, sizeof c);
+ llm_sse_acc_into(&a, &c);
+ CHECK(c.text && strcmp(c.text, "the answer is 42") == 0,
+ "text transferred intact");
+ CHECK(c.reasoning && strcmp(c.reasoning, "pondering deeply") == 0,
+ "reasoning transferred intact");
+ CHECK(c.tc_len == 1 && c.tc_args[0] &&
+ strcmp(c.tc_args[0], "\"path\":\"AUDIT.md\"") == 0,
+ "tool args transferred intact");
+ /* Both frees must be safe: `c` owns the strings now, `a` is reset. */
+ llm_completion_free(&c);
+ llm_sse_acc_free(&a);
+}
+
+/* ================= llm_params -> request body =================
+ * The `llm` tool threads llm_params (temperature, max_tokens, reasoning
+ * effort, thinking budget, response schema) into the wire body. These are
+ * pure request-builder tests (no network) per provider. */
+
+static llm_message one_user(const char *text) {
+ static llm_part part;
+ memset(&part, 0, sizeof part);
+ part.kind = LLM_TEXT;
+ part.text = text;
+ llm_message m;
+ memset(&m, 0, sizeof m);
+ m.role = LLM_ROLE_USER;
+ m.parts = &part;
+ m.part_count = 1;
+ return m;
+}
+
+static void test_params_openai(void) {
+ llm_conn c;
+ memset(&c, 0, sizeof c);
+ c.provider = LLM_OPENAI;
+ c.model = "test-model";
+ llm_params p;
+ llm_params_init(&p);
+ p.temperature = 0.5;
+ p.max_tokens = 256;
+ p.reasoning_effort = "high";
+ p.response_schema = "{\"type\":\"object\"}";
+
+ llm_message m = one_user("hi");
+ char *body = llm_build_request_ex(&c, NULL, &m, 1, NULL, false, &p);
+ CHECK(body != NULL, "openai body built");
+ if (body) {
+ CHECK_CONT(body, "\"temperature\":0.5", "openai temperature");
+ CHECK_CONT(body, "\"max_tokens\":256", "openai max_tokens");
+ CHECK_CONT(body, "\"reasoning_effort\":\"high\"",
+ "openai reasoning_effort");
+ CHECK_CONT(body, "\"response_format\":{\"type\":\"json_schema\"",
+ "openai response_format");
+ CHECK_CONT(body, "\"type\":\"object\"", "openai schema inlined");
+ free(body);
+ }
+}
+
+static void test_params_ollama(void) {
+ llm_conn c;
+ memset(&c, 0, sizeof c);
+ c.provider = LLM_OLLAMA;
+ c.model = "ollama-model";
+ llm_params p;
+ llm_params_init(&p);
+ p.temperature = 0.75; /* 3/4: exact in binary, so %.6g is stable */
+ p.max_tokens = 128;
+ p.reasoning_effort = "medium";
+ p.response_schema = "{\"type\":\"object\"}";
+
+ llm_message m = one_user("hi");
+ char *body = llm_build_request_ex(&c, "SYS", &m, 1, NULL, false, &p);
+ CHECK(body != NULL, "ollama body built");
+ if (body) {
+ CHECK_CONT(body, "\"system\":\"SYS\"", "ollama top-level system");
+ CHECK_CONT(body, "\"options\":{\"temperature\":0.75",
+ "ollama options.temperature");
+ CHECK_CONT(body, "\"num_predict\":128", "ollama num_predict");
+ CHECK_CONT(body, "\"think\":true", "ollama think enabled");
+ CHECK_CONT(body, "\"format\":", "ollama format field");
+ CHECK_CONT(body, "\"type\":\"object\"", "ollama schema");
+ free(body);
+ }
+}
+
+static void test_params_anthropic_thinking(void) {
+ llm_conn c;
+ memset(&c, 0, sizeof c);
+ c.provider = LLM_ANTHROPIC;
+ c.model = "claude";
+ llm_params p;
+ llm_params_init(&p);
+ p.thinking_budget = 16384;
+
+ llm_message m = one_user("hi");
+ char *body = llm_build_request_ex(&c, NULL, &m, 1, NULL, false, &p);
+ CHECK(body != NULL, "anthropic body built");
+ if (body) {
+ CHECK_CONT(body, "\"thinking\":{\"type\":\"enabled\","
+ "\"budget_tokens\":16384}",
+ "anthropic thinking budget");
+ /* max_tokens must be bumped above the thinking budget. */
+ CHECK_CONT(body, "\"max_tokens\":17408",
+ "anthropic max_tokens > budget");
+ free(body);
+ }
+}
+
+static void test_params_anthropic_effort(void) {
+ llm_conn c;
+ memset(&c, 0, sizeof c);
+ c.provider = LLM_ANTHROPIC;
+ c.model = "claude";
+ llm_params p;
+ llm_params_init(&p);
+ p.reasoning_effort = "low"; /* maps to a 1024-token budget */
+
+ llm_message m = one_user("hi");
+ char *body = llm_build_request_ex(&c, NULL, &m, 1, NULL, false, &p);
+ CHECK(body != NULL, "anthropic body built");
+ if (body)
+ CHECK_CONT(body, "\"budget_tokens\":1024",
+ "effort 'low' maps to a 1024 budget");
+ free(body);
+}
+
+static void test_params_anthropic_temperature(void) {
+ llm_conn c;
+ memset(&c, 0, sizeof c);
+ c.provider = LLM_ANTHROPIC;
+ c.model = "claude";
+ llm_params p;
+ llm_params_init(&p);
+ p.temperature = 0.25; /* 1/4: exact in binary, no thinking budget */
+
+ llm_message m = one_user("hi");
+ char *body = llm_build_request_ex(&c, NULL, &m, 1, NULL, false, &p);
+ CHECK(body != NULL, "anthropic body built");
+ if (body) {
+ CHECK_CONT(body, "\"temperature\":0.25", "anthropic temperature");
+ CHECK_NOT_CONT(body, "\"thinking\"",
+ "no thinking block without a budget");
+ free(body);
+ }
+}
+
+static void test_params_anthropic_schema(void) {
+ llm_conn c;
+ memset(&c, 0, sizeof c);
+ c.provider = LLM_ANTHROPIC;
+ c.model = "claude";
+ llm_params p;
+ llm_params_init(&p);
+ p.response_schema = "{\"type\":\"object\"}";
+
+ llm_message m = one_user("hi");
+ char *body = llm_build_request_ex(&c, "SYS", &m, 1, NULL, false, &p);
+ CHECK(body != NULL, "anthropic body built");
+ if (body) {
+ CHECK_CONT(body, "SYS", "preamble kept");
+ CHECK_CONT(body, "JSON Schema", "schema instruction appended");
+ /* The schema is JSON-encoded into the system string, so its quotes
+ * are escaped in the body. */
+ CHECK_CONT(body, "\\\"type\\\"", "schema text embedded (escaped)");
+ CHECK_NOT_CONT(body, "response_format",
+ "anthropic has no response_format");
+ free(body);
+ }
+}
+
+static void test_params_unset(void) {
+ llm_conn c;
+ memset(&c, 0, sizeof c);
+ c.provider = LLM_OPENAI;
+ c.model = "m";
+
+ llm_message m = one_user("hi");
+ char *body = llm_build_request_ex(&c, NULL, &m, 1, NULL, false, NULL);
+ CHECK(body != NULL, "body built with NULL params");
+ if (body) {
+ CHECK_NOT_CONT(body, "temperature", "no temperature when unset");
+ CHECK_NOT_CONT(body, "max_tokens", "no max_tokens when unset");
+ CHECK_NOT_CONT(body, "response_format",
+ "no response_format when unset");
+ free(body);
+ }
+}
+
+/* ================= `compact` built-in tool =================
+ * session_force_compact is the dispatch target for the `compact` tool call. */
+
+static void test_force_compact_short(void) {
+ session *s = make_session("openai");
+ session_push_history(s, msg_user_text("hello"));
+ session_push_history(s, msg_assistant_text("hi"));
+ compact_report rep;
+ memset(&rep, 0, sizeof rep);
+ char *err = NULL;
+ int rc = session_force_compact(s, &rep, &err);
+ /* user+assistant only: keep-boundary at index 0 -> no pass can run. */
+ CHECK(rc == 0, "no pass on a too-short history");
+ free(err);
+ session_free(s);
+}
+
+static void test_force_compact_clears(void) {
+ session *s = make_session("openai");
+ session_push_history(s, msg_user_text("read the file"));
+ session_push_history(s, msg_assistant_tool_call("read_file", "{}"));
+ session_push_history(s, msg_user_tool_result("c1", "read_file",
+ "a lot of cleared content"));
+ session_push_history(s, msg_assistant_text("done reading"));
+ session_push_history(s, msg_user_text("now edit it"));
+
+ compact_report rep;
+ memset(&rep, 0, sizeof rep);
+ char *err = NULL;
+ int rc = session_force_compact(s, &rep, &err);
+ CHECK(rc == 1, "compaction pass ran");
+ CHECK(rep.tier == TIER_FALLBACK_CLEAR,
+ "no summarizer -> fallback clear tier");
+ CHECK(rep.cleared_tool_results >= 1, "stale tool result cleared");
+ free(err);
+ session_free(s);
+}
+
+int main(void) {
+ test_preamble_openai();
+ test_preamble_ollama();
+ test_preamble_anthropic();
+ test_model_name_escaped();
+ test_no_preamble_omits_system();
+ test_trim_dangling_calls();
+ test_reasoning_openai();
+ test_reasoning_ollama();
+ test_reasoning_anthropic();
+ test_no_reasoning_when_absent();
+ test_sse_openai();
+ test_sse_anthropic();
+ test_sse_ollama();
+ test_sse_noise_lines();
+ test_sse_acc_into_defaults();
+ test_sse_acc_into_text_reasoning_ownership();
+ test_params_openai();
+ test_params_ollama();
+ test_params_anthropic_thinking();
+ test_params_anthropic_effort();
+ test_params_anthropic_temperature();
+ test_params_anthropic_schema();
+ test_params_unset();
+ test_force_compact_short();
+ test_force_compact_clears();
+ printf("provider: %d checks, %d failures\n", checks, failures);
+ return failures ? 1 : 0;
+}
diff --git a/tests/test_quine.c b/tests/test_quine.c
new file mode 100644
index 0000000..c010539
--- /dev/null
+++ b/tests/test_quine.c
@@ -0,0 +1,27 @@
+/* test_quine.c — a C quine: running it prints its own source.
+ *
+ * How it works: the file is a fixed point of a "quinate" function.
+ * The two strings below are that function's data; everything around
+ * them is code that re-emits the data verbatim, spliced back into the
+ * same program shape. No argv, no /proc/self, no tricks — just two
+ * printf calls.
+ *
+ * Build: musl-gcc -std=gnu11 -Oz -Wall -Wextra -o test_quine tests/test_quine.c
+ * Check: ./test_quine | diff - tests/test_quine.c (no output = quine) */
+
+#include <stdio.h>
+
+int main(void) {
+ const char *s =
+ "const char *s = \"%s\";\n"
+ "const char *f = \"%s\";\n"
+ "printf(f, s, s, f);\n"
+ "return 0;\n";
+ const char *f =
+ "int main(void) {\n"
+ " %s printf(f, s, s, f);\n"
+ " return 0;\n"
+ "}\n";
+ printf(f, s, s, f);
+ return 0;
+}
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;
+}
diff --git a/tests/test_shell.cpp b/tests/test_shell.cpp
new file mode 100644
index 0000000..bd10642
--- /dev/null
+++ b/tests/test_shell.cpp
@@ -0,0 +1,131 @@
+#include "shell.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+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)
+
+/* Spawn a shell, confirm it is a live process, then reap and free it. */
+static void test_spawn_and_reap(void)
+{
+ interactive_shell *s = shell_spawn();
+ CHECK(s != NULL, "shell_spawn returns a shell");
+ if (!s)
+ return;
+ CHECK(s->pid > 0, "shell has a live pid");
+ CHECK(s->master >= 0, "shell has a master fd");
+
+ /* Let it settle, then confirm it is still alive (not yet reaped). */
+ usleep(100 * 1000);
+ CHECK(s->pid > 0, "shell still alive before reap");
+
+ /* Free kills it and reaps; afterwards the pid is 0. */
+ shell_free(s);
+}
+
+/* Writing to a freed/closed shell must not crash. */
+static void test_write_after_free_is_safe(void)
+{
+ interactive_shell *s = shell_spawn();
+ CHECK(s != NULL, "shell_spawn");
+ if (!s)
+ return;
+ shell_free(s);
+ /* shell_write on a NULL shell is guarded by the caller; on a freed one we
+ * cannot call it (use-after-free), so just confirm free was clean. */
+}
+
+/* A shell that `cd`s and then exits must leave its final directory recorded
+ * (regression: the cwd was read after waitpid, when /proc/<pid>/cwd is gone,
+ * so `shell_final_cwd` returned NULL and llsh stayed in the old directory). */
+static void test_final_cwd_after_exit(void)
+{
+ setenv("SHELL", "/bin/bash", 1);
+ interactive_shell *s = shell_spawn();
+ CHECK(s != NULL, "shell_spawn");
+ if (!s)
+ return;
+ /* The sleep holds the shell alive after the cd, so the cwd poll (which
+ * only sees a live process) is guaranteed to observe /tmp. */
+ const char *cmd = "cd /tmp\nsleep 0.3\nexit\n";
+ for (size_t i = 0; i < strlen(cmd); i++)
+ CHECK(shell_write(s, cmd + i, 1) == 0, "write to shell");
+ bool reaped = false;
+ for (int i = 0; i < 200 && !reaped; i++) {
+ if (shell_reap_if_dead(s))
+ reaped = true;
+ else
+ usleep(25 * 1000);
+ }
+ CHECK(reaped, "shell reaped after exit");
+ char *cwd = shell_final_cwd(s);
+ CHECK(cwd != NULL, "final cwd is known");
+ if (cwd) {
+ CHECK(xstreq(cwd, "/tmp"), "final cwd is the shell's last directory");
+ free(cwd);
+ }
+ shell_free(s);
+}
+
+/* Output the shell prints is captured (sanitized, terminal escapes stripped)
+ * alongside streaming to stdout, so llsh can record a shell-mode visit
+ * (commands and output) in the conversation context. `shell_captured`
+ * returns the text and resets the buffer. */
+static void test_output_captured(void)
+{
+ setenv("SHELL", "/bin/bash", 1);
+ interactive_shell *s = shell_spawn();
+ CHECK(s != NULL, "shell_spawn");
+ if (!s)
+ return;
+ /* `echo OUTMARKER-$((1+1))` prints `OUTMARKER-2`, which only appears in
+ * the command's *output* (the input echo shows the unexpanded form), so a
+ * match proves the output was captured, not just the typed command. */
+ const char *cmd = "echo OUTMARKER-$((1+1))\n";
+ for (size_t i = 0; i < strlen(cmd); i++)
+ CHECK(shell_write(s, cmd + i, 1) == 0, "write to shell");
+ char *cap = NULL;
+ for (int i = 0; i < 200 && !cap; i++) {
+ usleep(50 * 1000);
+ char *c = shell_captured(s);
+ if (strstr(c, "OUTMARKER-2"))
+ cap = c; /* keep it */
+ else
+ free(c);
+ }
+ CHECK(cap != NULL, "captured output contains the command's output");
+ if (cap) {
+ /* The sanitized text must have no terminal escapes left. */
+ CHECK(strchr(cap, '\x1B') == NULL, "captured output has no ESC");
+ fprintf(stderr, "\n=== CAPTURED (%zu bytes) ===\n%s\n=== END ===\n",
+ strlen(cap), cap);
+ }
+ free(cap);
+ shell_free(s);
+}
+
+int main(int argc, char **argv)
+{
+ if (argc > 1 && xstreq(argv[1], "dump")) {
+ test_output_captured();
+ return 0;
+ }
+ test_spawn_and_reap();
+ test_write_after_free_is_safe();
+ test_final_cwd_after_exit();
+ test_output_captured();
+ printf("shell: %d checks, %d failures\n", checks, failures);
+ return failures == 0 ? 0 : 1;
+}
diff --git a/tests/test_tools.cpp b/tests/test_tools.cpp
new file mode 100644
index 0000000..3a92e05
--- /dev/null
+++ b/tests/test_tools.cpp
@@ -0,0 +1,1174 @@
+#include "tools.h"
+
+#include <cjson/cJSON.h>
+#include <ctype.h>
+#include <dirent.h>
+#include <math.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <sys/types.h>
+#include <time.h>
+#include <unistd.h>
+
+#include "limits.h"
+
+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 (got `%s`, must not contain `%s`)\n", \
+ __FILE__, __LINE__, (msg), hay, (needle)); \
+ } \
+ } while (0)
+
+static char *tmpdir(const char *tag) {
+ char path[256];
+ snprintf(path, sizeof path, "/tmp/llshc-tools-%s-%d", tag, (int)getpid());
+ char cmd[320];
+ snprintf(cmd, sizeof cmd, "rm -rf '%s'", path);
+ if (system(cmd) != 0) {}
+ snprintf(cmd, sizeof cmd, "mkdir -p '%s'", path);
+ if (system(cmd) != 0)
+ exit(1);
+ return xstrdup(path);
+}
+
+static void wfile(const char *dir, const char *rel, const char *content) {
+ char path[512];
+ snprintf(path, sizeof path, "%s/%s", dir, rel);
+ char *slash = strrchr(path, '/');
+ if (slash) {
+ char dirpart[512];
+ size_t n = (size_t)(slash - path);
+ memcpy(dirpart, path, n);
+ dirpart[n] = '\0';
+ char cmd[600];
+ snprintf(cmd, sizeof cmd, "mkdir -p '%s'", dirpart);
+ if (system(cmd) != 0)
+ exit(1);
+ }
+ FILE *f = fopen(path, "w");
+ if (!f)
+ exit(1);
+ fputs(content, f);
+ fclose(f);
+}
+
+static tool_limits mklimits2(const char **list, size_t n, bool to_deny) {
+ tool_limits l;
+ tool_limits_defaults(&l);
+ for (size_t i = 0; i < n; i++)
+ sl_push_copy(to_deny ? &l.shell_denylist : &l.shell_allowlist,
+ list[i]);
+ return l;
+}
+
+static void test_read_write_roundtrip(void) {
+ char *dir = tmpdir("rw");
+ char *p = xstrfmt("%s/nested/roundtrip.txt", dir);
+ char *err = NULL;
+ char *r = tool_write_file(p, "hi there", &err);
+ CHECK(r != NULL, "write_file");
+ if (!r && err)
+ fprintf(stderr, " write_file err: %s\n", err);
+ free(r);
+ char *content = tool_read_file(p, 0, 0, &err);
+ CHECK(content && strcmp(content, "hi there") == 0, "read_file content");
+ if (!content && err)
+ fprintf(stderr, " read_file err: %s\n", err);
+ free(content);
+ char *rm = xstrfmt("rm -rf '%s'", dir);
+ system(rm);
+ free(rm);
+ free(p);
+ free(dir);
+}
+
+static void test_read_file_missing_errors(void) {
+ char *dir = tmpdir("missing");
+ char *p = xstrfmt("%s/nope.txt", dir);
+ char *err = NULL;
+ char *r = tool_read_file(p, 0, 0, &err);
+ CHECK(r == NULL, "missing file should fail");
+ CHECK(err && strstr(err, "nope.txt") != NULL, "error names the path");
+ free(err);
+ free(p);
+ free(dir);
+}
+
+static void test_read_file_empty_path_errors(void) {
+ char *err = NULL;
+ CHECK(tool_read_file("", 0, 0, &err) == NULL, "empty path should fail");
+ CHECK(err && strstr(err, "path") != NULL, "error names the missing path");
+ free(err);
+ err = NULL;
+ CHECK(tool_read_file(NULL, 0, 0, &err) == NULL, "NULL path should fail");
+ CHECK(err && strstr(err, "path") != NULL, "NULL path error names it");
+ free(err);
+}
+
+static void test_read_file_too_large(void) {
+ char *dir = tmpdir("large");
+ char *p = xstrfmt("%s/big.bin", dir);
+ /* 3 MiB of zeros: above the 2 MiB default max_file_bytes */
+ FILE *f = fopen(p, "wb");
+ if (f) {
+ char chunk[4096];
+ memset(chunk, 0, sizeof chunk);
+ for (int i = 0; i < 768; i++)
+ fwrite(chunk, 1, sizeof chunk, f);
+ fclose(f);
+ }
+ char *err = NULL;
+ char *r = tool_read_file(p, 0, 0, &err);
+ CHECK(r == NULL, "oversized file should fail");
+ CHECK(err && strstr(err, "too large to read") != NULL,
+ "error says too large");
+ free(err);
+ free(p);
+ char *rm = xstrfmt("rm -rf '%s'", dir);
+ system(rm);
+ free(rm);
+ free(dir);
+}
+
+static void test_read_file_binary_rejected(void) {
+ char *dir = tmpdir("bin");
+ char *p = xstrfmt("%s/blob.bin", dir);
+ FILE *f = fopen(p, "wb");
+ if (f) {
+ unsigned char b[] = {0xFF, 0xFE, 0x01};
+ fwrite(b, 1, sizeof b, f);
+ fclose(f);
+ }
+ char *err = NULL;
+ char *r = tool_read_file(p, 0, 0, &err);
+ CHECK(r == NULL, "binary file should fail");
+ CHECK(err && strstr(err, "valid UTF-8") != NULL,
+ "error mentions UTF-8");
+ free(err);
+ free(p);
+ char *rm = xstrfmt("rm -rf '%s'", dir);
+ system(rm);
+ free(rm);
+ free(dir);
+}
+
+static void test_read_file_offset_limit(void) {
+ char *dir = tmpdir("offlimit");
+ char *p = xstrfmt("%s/lines.txt", dir);
+ wfile(dir, "lines.txt", "l1\nl2\nl3\nl4\nl5\n");
+ char *err = NULL;
+
+ /* offset=1, limit=2 -> first two lines + a truncation note */
+ char *r = tool_read_file(p, 1, 2, &err);
+ CHECK(r != NULL, "read with offset/limit succeeds");
+ CHECK_CONT(r, "l1\nl2", "first two lines present");
+ CHECK_CONT(r, "3 more lines", "notes how many lines remain");
+ CHECK_CONT(r, "offset=3", "notes the offset to continue from");
+ free(r);
+
+ /* offset=3, limit=2 -> lines 3-4, exactly one line remaining */
+ r = tool_read_file(p, 3, 2, &err);
+ CHECK(r != NULL &&
+ strcmp(r, "l3\nl4\n\n[... 1 more line; use offset=5 to continue "
+ "...]") == 0,
+ "middle slice + singular note, exact");
+ free(r);
+
+ /* limit beyond the end -> the rest of the file, no note */
+ r = tool_read_file(p, 2, 100, &err);
+ CHECK(r != NULL && strcmp(r, "l2\nl3\nl4\nl5\n") == 0,
+ "limit beyond end returns the rest with no note");
+ free(r);
+
+ /* the last single line, no note */
+ r = tool_read_file(p, 5, 1, &err);
+ CHECK(r != NULL && strcmp(r, "l5\n") == 0, "last line only, no note");
+ free(r);
+
+ /* offset past the end -> empty string */
+ r = tool_read_file(p, 6, 1, &err);
+ CHECK(r != NULL && strcmp(r, "") == 0, "offset past the end is empty");
+ free(r);
+
+ free(p);
+ char *rm = xstrfmt("rm -rf '%s'", dir);
+ system(rm);
+ free(rm);
+ free(dir);
+}
+
+static void test_read_file_offset_after_unterminated_last_line(void) {
+ char *dir = tmpdir("unterminated");
+ char *path = xstrfmt("%s/one.txt", dir);
+ wfile(dir, "one.txt", "only line");
+ char *err = NULL;
+ char *out = tool_read_file(path, 2, 1, &err);
+ CHECK(out && strcmp(out, "") == 0,
+ "offset beyond unterminated final line returns empty");
+ free(out);
+ free(err);
+ free(path);
+ char *rm = xstrfmt("rm -rf '%s'", dir);
+ system(rm);
+ free(rm);
+ free(dir);
+}
+
+static void test_read_file_rejects_embedded_nul(void) {
+ char *dir = tmpdir("nul");
+ char *path = xstrfmt("%s/nul.txt", dir);
+ FILE *f = fopen(path, "wb");
+ const char bytes[] = {'a', '\0', 'b'};
+ fwrite(bytes, 1, sizeof bytes, f);
+ fclose(f);
+ char *err = NULL;
+ char *out = tool_read_file(path, 0, 0, &err);
+ CHECK(out == NULL, "embedded NUL is rejected as non-text");
+ free(out);
+ free(err);
+ free(path);
+ char *rm = xstrfmt("rm -rf '%s'", dir);
+ system(rm);
+ free(rm);
+ free(dir);
+}
+
+static void test_write_file_rejects_missing_content(void) {
+ char *err = NULL;
+ CHECK(tool_write_file("/tmp/llsh-must-not-be-created", NULL, &err) == NULL,
+ "missing content rejected");
+ CHECK(err && strstr(err, "content"), "missing content is explained");
+ free(err);
+}
+
+static void test_run_shell_success_output(void) {
+ char *err = NULL;
+ char *out = tool_run_shell("echo hello-llsh", &err);
+ CHECK(out != NULL, "run_shell");
+ CHECK_CONT(out, "hello-llsh", "stdout captured");
+ CHECK_NOT_CONT(out, "exit code 0", "no status note on success");
+ free(out);
+}
+
+static void test_run_shell_success_no_output(void) {
+ char *err = NULL;
+ char *out = tool_run_shell("true", &err);
+ CHECK(out && strcmp(out, "(no output)") == 0, "no-output marker");
+ if (!out && err)
+ fprintf(stderr, " err: %s\n", err);
+ free(out);
+}
+
+static void test_run_shell_failure_status(void) {
+ char *err = NULL;
+ char *out = tool_run_shell("exit 3", &err);
+ CHECK(out != NULL, "failing command still returns output");
+ CHECK_CONT(out, "exit code 3", "exit code reported");
+ free(out);
+}
+
+static void test_run_shell_stderr_section(void) {
+ char *err = NULL;
+ char *out = tool_run_shell("echo out-line; echo err-line 1>&2", &err);
+ CHECK(out != NULL, "run_shell with stderr");
+ CHECK_CONT(out, "out-line", "stdout part");
+ CHECK_CONT(out, "[stderr]\nerr-line", "stderr section");
+ free(out);
+}
+
+static void test_run_shell_signal(void) {
+ char *err = NULL;
+ char *out = tool_run_shell("kill -9 $$", &err);
+ CHECK(out != NULL, "signaled command returns output");
+ CHECK_CONT(out, "terminated by signal", "signal note");
+ free(out);
+}
+
+static void test_run_shell_denylist_blocks(void) {
+ tool_limits saved;
+ tool_limits_get(&saved);
+ const char *deny[] = {"curl"};
+ tool_limits l = mklimits2(deny, 1, true);
+ tool_limits_set(&l);
+ char *err = NULL;
+ char *out = tool_run_shell("curl example.com", &err);
+ CHECK(out == NULL, "denylisted command blocked");
+ CHECK(err && strstr(err, "denylist") != NULL, "denylist mentioned");
+ free(err);
+ tool_limits_free(&l);
+ tool_limits_set(&saved);
+ tool_limits_free(&saved);
+}
+
+static void test_run_shell_allowlist_requires_match(void) {
+ tool_limits saved;
+ tool_limits_get(&saved);
+ const char *allow[] = {"git"};
+ tool_limits l = mklimits2(allow, 1, false);
+ tool_limits_set(&l);
+ char *err = NULL;
+ char *out = tool_run_shell("git status", &err);
+ CHECK(out != NULL, "allowlisted command runs");
+ free(out);
+ err = NULL;
+ out = tool_run_shell("ls -la", &err);
+ CHECK(out == NULL, "non-allowlisted command blocked");
+ CHECK(err && strstr(err, "allowlist") != NULL, "allowlist mentioned");
+ free(err);
+ free(out);
+ tool_limits_free(&l);
+ tool_limits_set(&saved);
+ tool_limits_free(&saved);
+}
+
+static bool deny_hook(const char *cmd, void *ud) {
+ (void)cmd;
+ (void)ud;
+ return false;
+}
+
+static void test_run_shell_confirm_hook_aborts(void) {
+ tool_limits saved;
+ tool_limits_get(&saved);
+ tool_limits l;
+ tool_limits_defaults(&l);
+ l.confirm_destructive = true;
+ tool_limits_set(&l);
+ set_confirm_hook(deny_hook, NULL);
+ char *err = NULL;
+ char *out = tool_run_shell("rm -rf /tmp/llsh-test", &err);
+ CHECK(out == NULL, "declined destructive command aborts");
+ CHECK(err && strstr(err, "declined") != NULL, "declined mentioned");
+ free(err);
+ set_confirm_hook(NULL, NULL);
+ tool_limits_free(&l);
+ tool_limits_set(&saved);
+ tool_limits_free(&saved);
+}
+
+/* Number of live /proc entries whose cmdline contains `needle`. */
+static int leftover_cmdlines(const char *needle) {
+ DIR *d = opendir("/proc");
+ if (!d)
+ return 0;
+ int found = 0;
+ struct dirent *e;
+ while ((e = readdir(d)) != NULL) {
+ bool digits = e->d_name[0] != '\0';
+ for (const char *c = e->d_name; *c; c++)
+ if (!isdigit((unsigned char)*c)) {
+ digits = false;
+ break;
+ }
+ if (!digits)
+ continue;
+ char path[300];
+ snprintf(path, sizeof path, "/proc/%s/cmdline", e->d_name);
+ FILE *f = fopen(path, "rb");
+ if (!f)
+ continue;
+ char buf[8192];
+ size_t n = fread(buf, 1, sizeof buf - 1, f);
+ fclose(f);
+ buf[n] = '\0';
+ for (size_t i = 0; i <= n && !found; i++) {
+ if (i < n && buf[i] != '\0')
+ continue;
+ if (strstr(buf + i, needle))
+ found = 1;
+ i++;
+ }
+ }
+ closedir(d);
+ return found;
+}
+
+static void test_run_shell_timeout_kills_process_group(void) {
+ char *err = NULL;
+ char *out = tool_run_shell_timed("sleep 60 & echo spawned; wait", 1, &err);
+ CHECK(out == NULL, "long command times out");
+ CHECK(err && strstr(err, "timed out") != NULL, "timeout message");
+ free(err);
+ /* Give the kernel a moment to reap, then check nothing survived. */
+ struct timespec ts = {0, 300 * 1000 * 1000};
+ nanosleep(&ts, NULL);
+ CHECK(leftover_cmdlines("sleep 60") == 0,
+ "backgrounded sleep must die with its process group");
+}
+
+static void test_run_shell_large_output_does_not_hang(void) {
+ /* A child writing more than MAX_SHELL_OUTPUT_BYTES (1MB) used to block on
+ * a full pipe because draining stopped at the cap, forcing a full timeout.
+ * It must now complete promptly with its output capped. */
+ char *err = NULL;
+ char *out = tool_run_shell_timed("head -c 2000000 /dev/zero | tr '\\0' 'x'",
+ 5, &err);
+ CHECK(out != NULL, "large output completes (no spurious timeout)");
+ if (out) {
+ CHECK(strlen(out) <= 16000 + 256, "output is capped/truncated");
+ free(out);
+ }
+ free(err);
+}
+
+static void test_truncate_output(void) {
+ xbuf long_b;
+ xbuf_init(&long_b);
+ for (int i = 0; i < 20000; i++)
+ xbuf_push(&long_b, 'x');
+ char *long_s = xbuf_steal(&long_b);
+ char *t = tool_truncate_output(long_s);
+ CHECK_CONT(t, "truncated", "truncation note");
+ CHECK(strlen(t) < 20000, "output shortened");
+ free(t);
+ char *short_s = xstrdup("small");
+ char *t2 = tool_truncate_output(short_s);
+ CHECK(t2 && strcmp(t2, "small") == 0, "short text unchanged");
+ free(t2);
+ free(long_s);
+ free(short_s);
+}
+
+static void test_tool_list(void) {
+ const tool_entry *entries;
+ size_t n = tool_list_entries(&entries);
+ CHECK(n == 8, "eight tools registered");
+ const char *want[8] = {"read_file", "write_file", "run_shell",
+ "use_skill", "search_memories", "embed",
+ "llm", "compact"};
+ bool ok = true;
+ for (size_t i = 0; i < n; i++)
+ if (strcmp(entries[i].name, want[i]) != 0)
+ ok = false;
+ CHECK(ok, "tool names in order");
+}
+
+static void test_tool_params_schemas(void) {
+ for (size_t i = 0; i < tool_builtin_count(); i++) {
+ const char *name = tool_builtin_name(i);
+ cJSON *schema = tool_params_json(name);
+ CHECK(schema != NULL, "schema non-NULL");
+ const cJSON *stype = cJSON_GetObjectItem(schema, "type");
+ CHECK(cJSON_IsString(stype) && strcmp(stype->valuestring, "object") == 0,
+ "schema type is object");
+ const cJSON *props = cJSON_GetObjectItem(schema, "properties");
+ CHECK(cJSON_IsObject(props), "properties is an object");
+ if (!cJSON_IsObject(props)) {
+ cJSON_Delete(schema);
+ continue;
+ }
+ for (const cJSON *p = props->child; p; p = p->next) {
+ int duplicates = 0;
+ for (const cJSON *q = props->child; q; q = q->next)
+ if (p->string && q->string && strcmp(p->string, q->string) == 0)
+ duplicates++;
+ CHECK(duplicates == 1, "property key appears exactly once");
+ CHECK(cJSON_HasObjectItem(p, "type"), "property carries a type");
+ }
+ cJSON_Delete(schema);
+ }
+
+ cJSON *rf = tool_params_json("read_file");
+ const cJSON *path = cJSON_GetObjectItem(
+ cJSON_GetObjectItem(rf, "properties"), "path");
+ CHECK(cJSON_IsObject(path), "read_file has a path property");
+ const cJSON *ptype = path ? cJSON_GetObjectItem(path, "type") : NULL;
+ CHECK(cJSON_IsString(ptype) && strcmp(ptype->valuestring, "string") == 0,
+ "read_file path type is string");
+ const cJSON *pd = path ? cJSON_GetObjectItem(path, "description") : NULL;
+ CHECK(cJSON_IsString(pd) && pd->valuestring[0] != 0,
+ "read_file path keeps its description");
+ char *rfstr = cJSON_PrintUnformatted(rf);
+ CHECK_CONT(rfstr, "\"path\":{\"type\":\"string\",\"description\":",
+ "read_file schema serializes path property");
+ free(rfstr);
+ cJSON_Delete(rf);
+}
+
+static void test_tokenize(void) {
+ strlist t;
+ sl_init(&t);
+ tool_tokenize("Billing Notes (DB)", &t);
+ CHECK(t.len == 3, "three tokens");
+ CHECK(t.len == 3 && strcmp(t.items[0], "billing") == 0, "token 1");
+ CHECK(t.len == 3 && strcmp(t.items[1], "notes") == 0, "token 2");
+ CHECK(t.len == 3 && strcmp(t.items[2], "db") == 0, "token 3");
+ sl_free(&t);
+ strlist empty;
+ sl_init(&empty);
+ tool_tokenize(" ", &empty);
+ CHECK(empty.len == 0, "whitespace-only query has no tokens");
+ sl_free(&empty);
+}
+
+static void test_is_subsequence(void) {
+ CHECK(tool_is_subsequence("abc", "a1b2c3"), "scattered chars match");
+ CHECK(!tool_is_subsequence("abc", "acb"), "out-of-order rejected");
+ CHECK(tool_is_subsequence("bill", "billed"), "prefix subsequence");
+ CHECK(!tool_is_subsequence("billing", "billed"), "longer needle rejected");
+}
+
+static void test_field_match_strength(void) {
+ const char *field = "billing is monthly";
+ double word = tool_field_match_strength(field, "billing");
+ double sub = tool_field_match_strength(field, "billin");
+ double fuzzy = tool_field_match_strength(field, "billng");
+ double none = tool_field_match_strength(field, "zzz");
+ CHECK(word > sub, "word beats substring");
+ CHECK(sub > fuzzy, "substring beats fuzzy");
+ CHECK(fuzzy > none, "fuzzy beats no match");
+ CHECK(none == 0.0, "no match is zero");
+ CHECK(tool_field_match_strength("abc", "ac") == 0.0,
+ "2-char terms get no fuzzy credit");
+ CHECK(tool_field_match_strength("billing", "billing") == 1.0,
+ "exact match is 1.0");
+ CHECK(tool_field_match_strength("", "x") == 0.0, "empty field is zero");
+}
+
+static memory mk_mem(const char *id, const char *importance, const char *title,
+ const char **tags, size_t ntags, const char *summary,
+ const char *body) {
+ memory m;
+ m.id = xstrdup(id);
+ m.title = xstrdup(title);
+ m.importance = xstrdup(importance);
+ m.tags = NULL;
+ m.tag_count = 0;
+ for (size_t i = 0; i < ntags; i++)
+ m.tags = (char* * ) realloc(m.tags, (m.tag_count + 1) * sizeof(char *)),
+ m.tags[m.tag_count++] = xstrdup(tags[i]);
+ m.summary = xstrdup(summary);
+ m.body = xstrdup(body);
+ m.layer = LAYER_WORKSPACE;
+ return m;
+}
+
+static void free_mem(memory *m) {
+ free(m->id);
+ free(m->title);
+ free(m->importance);
+ for (size_t i = 0; i < m->tag_count; i++)
+ free(m->tags[i]);
+ free(m->tags);
+ free(m->summary);
+ free(m->body);
+}
+
+static void test_memory_relevance(void) {
+ strlist q_billing;
+ sl_init(&q_billing);
+ tool_tokenize("billing", &q_billing);
+ memory in_title =
+ mk_mem("a", "low", "Billing", NULL, 0, "", "");
+ memory in_body = mk_mem("b", "low", "Other", NULL, 0, "",
+ "billing monthly");
+ CHECK(tool_memory_relevance(&in_title, &q_billing) >
+ tool_memory_relevance(&in_body, &q_billing),
+ "title hit outranks body-only hit");
+ strlist q_two;
+ sl_init(&q_two);
+ tool_tokenize("billing note", &q_two);
+ memory covers_both = mk_mem("c", "low", "Billing Note", NULL, 0, "", "");
+ memory covers_one = mk_mem("d", "low", "Billing", NULL, 0, "", "");
+ CHECK(tool_memory_relevance(&covers_both, &q_two) >
+ tool_memory_relevance(&covers_one, &q_two),
+ "covering both terms outranks covering one");
+ memory unrelated = mk_mem("e", "low", "Unrelated", NULL, 0, "", "");
+ CHECK(tool_memory_relevance(&unrelated, &q_billing) == 0.0,
+ "no match scores zero");
+ free_mem(&in_title);
+ free_mem(&in_body);
+ free_mem(&covers_both);
+ free_mem(&covers_one);
+ free_mem(&unrelated);
+ sl_free(&q_billing);
+ sl_free(&q_two);
+}
+
+static void test_search_memories_ranks_relevance(void) {
+ char *home = getcwd(NULL, 0);
+ char *dir = tmpdir("memrank");
+ wfile(dir, ".agents/memories/important.md",
+ "---\nid: important\ntitle: Random\nimportance: high\n---\n"
+ "We mention billing here.");
+ wfile(dir, ".agents/memories/relevant.md",
+ "---\nid: relevant\ntitle: Billing\nimportance: low\n---\n"
+ "Details.");
+ if (chdir(dir) != 0)
+ exit(1);
+ dotagents dot;
+ dotagents_load(&dot);
+ char *err = NULL;
+ char *out = tool_search_memories(&dot, "billing", &err);
+ CHECK(out != NULL, "search_memories");
+ if (!out && err)
+ fprintf(stderr, " err: %s\n", err);
+ char *p_relevant = out ? strstr(out, "## Billing") : NULL;
+ char *p_important = out ? strstr(out, "## Random") : NULL;
+ CHECK(p_relevant && p_important && p_relevant < p_important,
+ "relevance outranks importance");
+ free(out);
+ dotagents_free(&dot);
+ if (chdir(home) != 0)
+ exit(1);
+ free(home);
+ char *rm = xstrfmt("rm -rf '%s'", dir);
+ system(rm);
+ free(rm);
+ free(dir);
+}
+
+static void test_search_memories_empty_query(void) {
+ char *err = NULL;
+ char *out = tool_search_memories(NULL, " ", &err);
+ CHECK(out == NULL, "blank query fails");
+ CHECK(err && strstr(err, "query must not be empty") != NULL,
+ "error message");
+ free(err);
+}
+
+static void test_use_skill_and_search_memories_workspace(void) {
+ char *home = getcwd(NULL, 0);
+ char *dir = tmpdir("ws");
+ wfile(dir, ".agents/skills/demo/skill.md",
+ "---\nid: demo\nname: Demo Skill\ndescription: a demo\n---\n"
+ "Step one. Step two.");
+ wfile(dir, ".agents/memories/note.md",
+ "---\nid: note\ntitle: Billing Note\nimportance: high\n"
+ "tags: billing, db\n---\nWe bill monthly.");
+ if (chdir(dir) != 0)
+ exit(1);
+ dotagents dot;
+ dotagents_load(&dot);
+ char *err = NULL;
+ char *out = tool_use_skill(&dot, "demo", &err);
+ CHECK(out != NULL, "use_skill");
+ if (!out && err)
+ fprintf(stderr, " use_skill err: %s\n", err);
+ CHECK_CONT(out, "Demo Skill", "skill name");
+ CHECK_CONT(out, "Step one. Step two.", "skill body");
+ free(out);
+ err = NULL;
+ out = tool_use_skill(&dot, "nope", &err);
+ CHECK(out == NULL, "unknown skill fails");
+ CHECK(err && strstr(err, "no skill") != NULL, "error names available");
+ free(err);
+ err = NULL;
+ out = tool_search_memories(&dot, "billing", &err);
+ CHECK(out != NULL, "search_memories hit");
+ CHECK_CONT(out, "Billing Note", "memory title");
+ CHECK_CONT(out, "importance: high", "importance line");
+ CHECK_CONT(out, "tags: billing, db", "tags line");
+ free(out);
+ err = NULL;
+ out = tool_search_memories(&dot, "zzz-qqq-xxx", &err);
+ CHECK(out != NULL, "no-match search returns a message");
+ CHECK_CONT(out, "no memories matching", "no-match message");
+ free(out);
+ dotagents_free(&dot);
+ if (chdir(home) != 0)
+ exit(1);
+ free(home);
+ char *rm = xstrfmt("rm -rf '%s'", dir);
+ system(rm);
+ free(rm);
+ free(dir);
+}
+
+/* Tools must serve the session's loaded `.agents` snapshot, not re-read
+ * the disk (AUDIT: no live mid-session reload). */
+static void test_tools_read_snapshot_not_disk(void) {
+ char *home = getcwd(NULL, 0);
+ char *dir = tmpdir("snap");
+ wfile(dir, ".agents/skills/demo/skill.md",
+ "---\nid: demo\nname: Demo Skill\ndescription: a demo\n---\n"
+ "ORIGINAL BODY.");
+ wfile(dir, ".agents/memories/note.md",
+ "---\nid: note\ntitle: Billing Note\nimportance: high\n"
+ "tags: billing\n---\nORIGINAL MEMORY.");
+ if (chdir(dir) != 0)
+ exit(1);
+ dotagents dot;
+ dotagents_load(&dot);
+
+ /* Mutate the on-disk state after the snapshot was taken. */
+ wfile(dir, ".agents/skills/demo/skill.md",
+ "---\nid: demo\nname: Demo Skill\ndescription: a demo\n---\n"
+ "REPLACED BODY.");
+ wfile(dir, ".agents/memories/note.md", "entirely new memory");
+ wfile(dir, ".agents/skills/fresh/skill.md",
+ "---\nid: fresh\nname: Fresh\ndescription: added later\n---\n"
+ "brand new skill.");
+
+ char *err = NULL;
+ char *out = tool_use_skill(&dot, "demo", &err);
+ CHECK(out != NULL, "skill served from snapshot");
+ CHECK_CONT(out, "ORIGINAL BODY.", "pre-mutation body intact");
+ CHECK_NOT_CONT(out, "REPLACED BODY", "disk change not visible");
+ free(out);
+ err = NULL;
+ out = tool_search_memories(&dot, "billing", &err);
+ CHECK(out != NULL, "memory served from snapshot");
+ CHECK_CONT(out, "ORIGINAL MEMORY.", "pre-mutation memory body");
+ CHECK_NOT_CONT(out, "entirely new memory", "new disk file not visible");
+ free(out);
+ err = NULL;
+ out = tool_use_skill(&dot, "fresh", &err);
+ CHECK(out == NULL, "skill added after snapshot is unknown");
+ CHECK(err != NULL, "unknown-skill error set");
+ free(out);
+ free(err);
+
+ dotagents_free(&dot);
+ if (chdir(home) != 0)
+ exit(1);
+ free(home);
+ char *rm = xstrfmt("rm -rf '%s'", dir);
+ system(rm);
+ free(rm);
+ free(dir);
+}
+
+static void test_write_file_creates_parents(void) {
+ char *dir = tmpdir("wpar");
+ char *p = xstrfmt("%s/deep/deeper/file.txt", dir);
+ char *err = NULL;
+ char *out = tool_write_file(p, "nested", &err);
+ CHECK(out != NULL, "write_file with new parents");
+ CHECK_CONT(out, "wrote 6 bytes to", "byte count reported");
+ free(out);
+ char *back = tool_read_file(p, 0, 0, &err);
+ CHECK(back && strcmp(back, "nested") == 0, "content roundtrip");
+ free(back);
+ free(p);
+ char *rm = xstrfmt("rm -rf '%s'", dir);
+ system(rm);
+ free(rm);
+ free(dir);
+}
+
+static void test_display_vtable(void) {
+ const tool_display *op;
+
+ op = tool_display_for("run_shell");
+ CHECK(op != NULL, "run_shell has a vtable entry");
+ CHECK(op->render_call != NULL, "run_shell has a custom renderer");
+ CHECK(op->dim_result, "run_shell results render muted");
+
+ char *line = op->render_call("{\"command\":\"ls -la src\"}");
+ CHECK(line != NULL, "renderer returns a line");
+ CHECK_CONT(line, "$ ", "renders a shell prompt line");
+ CHECK_CONT(line, "ls -la src", "shell line carries the command");
+ CHECK_NOT_CONT(line, "command", "raw JSON key does not leak");
+ CHECK(line && line[0] == '\n' && line[strlen(line) - 1] == '\n',
+ "line framed with newlines");
+ free(line);
+
+ CHECK(tool_display_for("run_shell")->render_call(NULL) == NULL,
+ "NULL args -> generic fallback");
+ CHECK(tool_display_for("run_shell")->render_call("{}") == NULL,
+ "missing command -> generic fallback");
+ CHECK(tool_display_for("run_shell")->render_call("nope") == NULL,
+ "bad json -> generic fallback");
+
+ op = tool_display_for("read_file");
+ CHECK(op && op->render_call == NULL && op->dim_result,
+ "read_file: generic display, muted result");
+ op = tool_display_for("write_file");
+ CHECK(op && op->render_call == NULL && !op->dim_result,
+ "write_file: generic display, normal result");
+ op = tool_display_for("use_skill");
+ CHECK(op && op->render_call == NULL && !op->dim_result,
+ "use_skill: generic display, normal result");
+ op = tool_display_for("search_memories");
+ CHECK(op && op->render_call == NULL && !op->dim_result,
+ "search_memories: generic display, normal result");
+
+ CHECK(tool_display_for("frobnicate") == NULL, "unknown tool -> NULL");
+ CHECK(tool_display_for("mcp__srv__tool") == NULL, "mcp tool -> NULL");
+ CHECK(tool_display_for(NULL) == NULL, "NULL name -> NULL");
+}
+
+/* ------------------------------------------------------------------ */
+/* embed tool */
+/* ------------------------------------------------------------------ */
+
+static dotagents embed_dot(const char *model, const char *key,
+ const char *url) {
+ dotagents d;
+ memset(&d, 0, sizeof d);
+ fm_sl_init(&d.warnings);
+ d.models.default_name = xstrdup("emb");
+ d.models.presets = (model_preset *) xmalloc(sizeof *d.models.presets);
+ memset(d.models.presets, 0, sizeof *d.models.presets);
+ d.models.presets[0].name = xstrdup("emb");
+ d.models.presets[0].model = model ? xstrdup(model) : NULL;
+ d.models.presets[0].api_key = key ? xstrdup(key) : NULL;
+ d.models.presets[0].base_url = url ? xstrdup(url) : NULL;
+ d.models.preset_count = 1;
+ d.has_models = true;
+ return d;
+}
+
+static config embed_cfg(const char *model, const char *key, const char *url) {
+ config c;
+ memset(&c, 0, sizeof c);
+ sl_init(&c.shell_allowlist);
+ sl_init(&c.shell_denylist);
+ if (model)
+ c.model = xstrdup(model);
+ if (key)
+ c.api_key = xstrdup(key);
+ if (url)
+ c.base_url = xstrdup(url);
+ return c;
+}
+
+static void free_embed_cfg(config *c) {
+ free(c->model);
+ free(c->api_key);
+ free(c->base_url);
+ sl_free(&c->shell_allowlist);
+ sl_free(&c->shell_denylist);
+}
+
+static void test_embed_resolve_profile_values(void) {
+ config c = embed_cfg(NULL, NULL, NULL);
+ dotagents d = embed_dot("emb-model", "emb-key", "https://emb.example/v1");
+ embed_target t;
+ char *err = NULL;
+ CHECK(tool_embed_resolve(&c, &d, &t, &err) == 0, "resolve ok");
+ CHECK(t.model && strcmp(t.model, "emb-model") == 0, "preset model");
+ CHECK(t.api_key && strcmp(t.api_key, "emb-key") == 0, "preset api_key");
+ CHECK(t.base_url && strcmp(t.base_url, "https://emb.example/v1") == 0,
+ "preset base_url");
+ embed_target_free(&t);
+ dotagents_free(&d);
+ free_embed_cfg(&c);
+}
+
+static void test_embed_resolve_falls_back_to_top_level(void) {
+ config c = embed_cfg("top-model", "top-key", "https://top.example/v1");
+ dotagents d = embed_dot(NULL, NULL, NULL); /* preset with empty fields */
+ embed_target t;
+ char *err = NULL;
+ CHECK(tool_embed_resolve(&c, &d, &t, &err) == 0, "resolve ok");
+ CHECK(t.model && strcmp(t.model, "top-model") == 0, "inherits model");
+ CHECK(t.api_key && strcmp(t.api_key, "top-key") == 0, "inherits api_key");
+ CHECK(t.base_url && strcmp(t.base_url, "https://top.example/v1") == 0,
+ "inherits base_url");
+ embed_target_free(&t);
+ dotagents_free(&d);
+ free_embed_cfg(&c);
+}
+
+static void test_embed_resolve_errors(void) {
+ config c;
+ memset(&c, 0, sizeof c);
+ sl_init(&c.shell_allowlist);
+ sl_init(&c.shell_denylist);
+ embed_target t;
+ char *err = NULL;
+
+ CHECK(tool_embed_resolve(&c, NULL, &t, &err) == -1 && err,
+ "no models.json -> error");
+ CHECK(err && strstr(err, "not configured"), "not-configured message");
+ free(err);
+ err = NULL;
+
+ dotagents d = embed_dot("emb-model", "emb-key", "https://emb.example/v1");
+ free(d.models.default_name);
+ d.models.default_name = xstrdup("ghost");
+ CHECK(tool_embed_resolve(&c, &d, &t, &err) == -1 && err,
+ "unknown preset -> error");
+ CHECK(err && strstr(err, "ghost"), "unknown-preset message");
+ free(err);
+ err = NULL;
+ dotagents_free(&d);
+
+ config c2 = embed_cfg(NULL, NULL, NULL);
+ dotagents d2 = embed_dot("emb-model", NULL, "https://emb.example/v1");
+ err = NULL;
+ CHECK(tool_embed_resolve(&c2, &d2, &t, &err) == -1 && err,
+ "preset missing api_key -> error");
+ CHECK(err && strstr(err, "api_key"), "missing-key message");
+ free(err);
+ dotagents_free(&d2);
+ free_embed_cfg(&c2);
+
+ sl_free(&c.shell_allowlist);
+ sl_free(&c.shell_denylist);
+}
+
+static void test_embed_request_body(void) {
+ char *err = NULL;
+ char *req = tool_embed_request("emb-model", "hello \"world\"\n", &err);
+ CHECK(req && !err, "request built");
+ CHECK_CONT(req, "\"model\":\"emb-model\"", "model in body");
+ CHECK_CONT(req, "\"input\":\"hello \\\"world\\\"\\n\"", "input escaped");
+ free(req);
+ free(err);
+ err = NULL;
+ CHECK(tool_embed_request("", "x", &err) == NULL, "empty model rejected");
+ free(err);
+ err = NULL;
+ CHECK(tool_embed_request("m", "", &err) == NULL, "empty text rejected");
+ free(err);
+}
+
+static void test_embed_url(void) {
+ char *u = tool_embed_url("https://avois.org/v1/");
+ CHECK(u && strcmp(u, "https://avois.org/v1/embeddings") == 0,
+ "trailing slash trimmed");
+ free(u);
+ u = tool_embed_url("http://localhost:11434");
+ CHECK(u && strcmp(u, "http://localhost:11434/embeddings") == 0,
+ "plain base");
+ free(u);
+ u = tool_embed_url("");
+ CHECK(u && strcmp(u, "https://api.openai.com/v1/embeddings") == 0,
+ "default base");
+ free(u);
+}
+
+static void test_embed_parse(void) {
+ char *vec = NULL;
+ char *err = NULL;
+ const char *body =
+ "{\"object\":\"list\",\"data\":[{\"object\":\"embedding\","
+ "\"embedding\":[0.1,-0.2,0.3],\"index\":0}],\"model\":\"emb\"}";
+ CHECK(tool_embed_parse(body, &vec, &err) == 0, "parse ok");
+ CHECK(vec, "vector non-NULL");
+ /* Compare values, not serialization: float formatting is not stable
+ * across FP environments (valgrind shifts last ulps). */
+ cJSON *v = vec ? cJSON_Parse(vec) : NULL;
+ CHECK(v && cJSON_IsArray(v) && cJSON_GetArraySize(v) == 3,
+ "vector has 3 elements");
+ if (v && cJSON_IsArray(v) && cJSON_GetArraySize(v) == 3) {
+ CHECK(fabs(cJSON_GetArrayItem(v, 0)->valuedouble - 0.1) < 1e-9,
+ "v[0] ~= 0.1");
+ CHECK(fabs(cJSON_GetArrayItem(v, 1)->valuedouble - (-0.2)) < 1e-9,
+ "v[1] ~= -0.2");
+ CHECK(fabs(cJSON_GetArrayItem(v, 2)->valuedouble - 0.3) < 1e-9,
+ "v[2] ~= 0.3");
+ cJSON_Delete(v);
+ }
+ CHECK(!err, "no error");
+ free(vec);
+ vec = NULL;
+
+ CHECK(tool_embed_parse("{\"data\":[]}", &vec, &err) == -1,
+ "empty data -> error");
+ CHECK(err && strstr(err, "data[0].embedding"), "missing-array message");
+ free(err);
+ err = NULL;
+ CHECK(tool_embed_parse("{\"data\":[{\"embedding\":[1,\"x\"]}]}", &vec,
+ &err) == -1,
+ "non-numeric element -> error");
+ free(err);
+ err = NULL;
+ CHECK(tool_embed_parse("not json", &vec, &err) == -1, "bad JSON -> error");
+ free(err);
+ err = NULL;
+ CHECK(tool_embed_parse("", &vec, &err) == -1, "empty body -> error");
+ free(err);
+}
+
+static void test_embed_tool_unset(void) {
+ config c;
+ memset(&c, 0, sizeof c);
+ sl_init(&c.shell_allowlist);
+ sl_init(&c.shell_denylist);
+ char *err = NULL;
+ CHECK(tool_embed("text", &c, NULL, &err) == NULL, "unset -> NULL");
+ CHECK(err && strstr(err, "not configured"), "unset message");
+ free(err);
+ sl_free(&c.shell_allowlist);
+ sl_free(&c.shell_denylist);
+}
+
+static config mkllmcfg(void) {
+ config c;
+ memset(&c, 0, sizeof c);
+ sl_init(&c.shell_allowlist);
+ sl_init(&c.shell_denylist);
+ c.provider = PROV_OPENAI;
+ c.model = xstrdup("test-model");
+ c.base_url = xstrdup("http://127.0.0.1:9/v1");
+ c.api_key = xstrdup("k");
+ return c;
+}
+
+static void freellmcfg(config *c) {
+ sl_free(&c->shell_allowlist);
+ sl_free(&c->shell_denylist);
+ free((void *)c->model);
+ free((void *)c->base_url);
+ free((void *)c->api_key);
+}
+
+/* ------------------------------------------------------------------ */
+/* llm tool */
+/* ------------------------------------------------------------------ */
+
+static void test_llm_tool_schemas(void) {
+ const char *names[] = {"llm", "compact"};
+ for (size_t i = 0; i < sizeof names / sizeof names[0]; i++) {
+ const char *name = names[i];
+ cJSON *schema = tool_params_json(name);
+ CHECK(schema != NULL, "schema non-NULL");
+ if (!schema)
+ continue;
+ const cJSON *stype = cJSON_GetObjectItem(schema, "type");
+ CHECK(cJSON_IsString(stype) && strcmp(stype->valuestring, "object") == 0,
+ "schema type is object");
+ CHECK(cJSON_IsObject(cJSON_GetObjectItem(schema, "properties")),
+ "properties is an object");
+ CHECK(cJSON_IsArray(cJSON_GetObjectItem(schema, "required")),
+ "required is an array");
+ cJSON_Delete(schema);
+ }
+
+ /* llm: required `prompt`, and every documented parameter present. */
+ cJSON *llm = tool_params_json("llm");
+ const cJSON *req = cJSON_GetObjectItem(llm, "required");
+ int req_has_prompt = 0;
+ if (cJSON_IsArray(req)) {
+ for (const cJSON *r = req->child; r; r = r->next)
+ if (cJSON_IsString(r) && strcmp(r->valuestring, "prompt") == 0)
+ req_has_prompt = 1;
+ }
+ CHECK(req_has_prompt, "llm requires prompt");
+ const char *props[] = {"prompt", "model", "temperature",
+ "max_tokens", "reasoning_effort", "thinking_budget",
+ "response_schema", "embed"};
+ const cJSON *llmprops = cJSON_GetObjectItem(llm, "properties");
+ for (size_t i = 0; i < sizeof props / sizeof props[0]; i++)
+ CHECK(cJSON_HasObjectItem(llmprops, props[i]), props[i]);
+ /* reasoning_effort is an enum of the three allowed values. */
+ const cJSON *re = cJSON_GetObjectItem(llmprops, "reasoning_effort");
+ const cJSON *reenum = cJSON_GetObjectItem(re, "enum");
+ CHECK(cJSON_IsArray(reenum) && cJSON_GetArraySize(reenum) == 3,
+ "reasoning_effort enum has 3 values");
+ cJSON_Delete(llm);
+
+ /* compact: takes no parameters. */
+ cJSON *cmp = tool_params_json("compact");
+ const cJSON *cmpreq = cJSON_GetObjectItem(cmp, "required");
+ CHECK(cJSON_IsArray(cmpreq) && cJSON_GetArraySize(cmpreq) == 0,
+ "compact required is empty");
+ const cJSON *cmpprops = cJSON_GetObjectItem(cmp, "properties");
+ CHECK(cJSON_IsObject(cmpprops) && cmpprops->child == NULL,
+ "compact properties is empty");
+ cJSON_Delete(cmp);
+}
+
+static void test_llm_tool_requires_prompt(void) {
+ config c = mkllmcfg();
+ llm_tool_call call;
+ memset(&call, 0, sizeof call);
+ char *err = NULL;
+ CHECK(tool_llm(&call, &c, NULL, &err) == NULL, "no prompt -> NULL");
+ CHECK(err && strstr(err, "prompt"), "names the prompt");
+ free(err);
+ freellmcfg(&c);
+}
+
+static void test_llm_tool_bad_schema(void) {
+ config c = mkllmcfg();
+ llm_tool_call call;
+ memset(&call, 0, sizeof call);
+ call.prompt = "hi";
+ call.response_schema = "not-json";
+ char *err = NULL;
+ CHECK(tool_llm(&call, &c, NULL, &err) == NULL, "bad schema -> NULL");
+ CHECK(err && strstr(err, "response_schema"), "names the schema");
+ free(err);
+ freellmcfg(&c);
+}
+
+static void test_llm_tool_null_call(void) {
+ config c = mkllmcfg();
+ char *err = NULL;
+ CHECK(tool_llm(NULL, &c, NULL, &err) == NULL, "null call -> NULL");
+ CHECK(err && strstr(err, "prompt"), "names the prompt");
+ free(err);
+ freellmcfg(&c);
+}
+
+int main(void) {
+ test_read_write_roundtrip();
+ test_read_file_missing_errors();
+ test_read_file_empty_path_errors();
+ test_read_file_too_large();
+ test_read_file_binary_rejected();
+ test_read_file_offset_limit();
+ test_read_file_offset_after_unterminated_last_line();
+ test_read_file_rejects_embedded_nul();
+ test_write_file_rejects_missing_content();
+ test_run_shell_success_output();
+ test_run_shell_success_no_output();
+ test_run_shell_failure_status();
+ test_run_shell_stderr_section();
+ test_run_shell_signal();
+ test_run_shell_denylist_blocks();
+ test_run_shell_allowlist_requires_match();
+ test_run_shell_confirm_hook_aborts();
+ test_run_shell_timeout_kills_process_group();
+ test_run_shell_large_output_does_not_hang();
+ test_truncate_output();
+ test_tool_list();
+ test_display_vtable();
+ test_tool_params_schemas();
+ test_tokenize();
+ test_is_subsequence();
+ test_field_match_strength();
+ test_memory_relevance();
+ test_search_memories_ranks_relevance();
+ test_search_memories_empty_query();
+ test_use_skill_and_search_memories_workspace();
+ test_tools_read_snapshot_not_disk();
+ test_write_file_creates_parents();
+ test_embed_resolve_profile_values();
+ test_embed_resolve_falls_back_to_top_level();
+ test_embed_resolve_errors();
+ test_embed_request_body();
+ test_embed_url();
+ test_embed_parse();
+ test_embed_tool_unset();
+ test_llm_tool_schemas();
+ test_llm_tool_requires_prompt();
+ test_llm_tool_bad_schema();
+ test_llm_tool_null_call();
+
+ printf("%d checks, %d failures\n", checks, failures);
+ return failures ? 1 : 0;
+}