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