diff options
| author | Ethan Werner <mail@ethanwerner.com> | 2026-10-10 20:55:33 +0000 |
|---|---|---|
| committer | Ethan Werner <mail@ethanwerner.com> | 2026-10-10 20:56:50 +0000 |
| commit | f8423ebea413605b42de23f5dd06c5479d3fa1dd (patch) | |
| tree | 2612a5eb026c59e7aae01f10a1cf62f2ee43042c /tests/test_tools.cpp | |
Diffstat (limited to 'tests/test_tools.cpp')
| -rw-r--r-- | tests/test_tools.cpp | 1174 |
1 files changed, 1174 insertions, 0 deletions
diff --git a/tests/test_tools.cpp b/tests/test_tools.cpp new file mode 100644 index 0000000..3a92e05 --- /dev/null +++ b/tests/test_tools.cpp @@ -0,0 +1,1174 @@ +#include "tools.h" + +#include <cjson/cJSON.h> +#include <ctype.h> +#include <dirent.h> +#include <math.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/stat.h> +#include <sys/types.h> +#include <time.h> +#include <unistd.h> + +#include "limits.h" + +static int failures = 0; +static int checks = 0; + +#define CHECK(cond, msg) \ + do { \ + checks++; \ + if (!(cond)) { \ + failures++; \ + fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, (msg)); \ + } \ + } while (0) + +#define CHECK_CONT(hay, needle, msg) \ + do { \ + checks++; \ + if (!hay || !strstr(hay, needle)) { \ + failures++; \ + fprintf(stderr, "FAIL %s:%d: %s (got `%s`, want substring `%s`)\n", \ + __FILE__, __LINE__, (msg), hay ? hay : "(null)", \ + (needle)); \ + } \ + } while (0) + +#define CHECK_NOT_CONT(hay, needle, msg) \ + do { \ + checks++; \ + if (hay && strstr(hay, needle)) { \ + failures++; \ + fprintf(stderr, "FAIL %s:%d: %s (got `%s`, must not contain `%s`)\n", \ + __FILE__, __LINE__, (msg), hay, (needle)); \ + } \ + } while (0) + +static char *tmpdir(const char *tag) { + char path[256]; + snprintf(path, sizeof path, "/tmp/llshc-tools-%s-%d", tag, (int)getpid()); + char cmd[320]; + snprintf(cmd, sizeof cmd, "rm -rf '%s'", path); + if (system(cmd) != 0) {} + snprintf(cmd, sizeof cmd, "mkdir -p '%s'", path); + if (system(cmd) != 0) + exit(1); + return xstrdup(path); +} + +static void wfile(const char *dir, const char *rel, const char *content) { + char path[512]; + snprintf(path, sizeof path, "%s/%s", dir, rel); + char *slash = strrchr(path, '/'); + if (slash) { + char dirpart[512]; + size_t n = (size_t)(slash - path); + memcpy(dirpart, path, n); + dirpart[n] = '\0'; + char cmd[600]; + snprintf(cmd, sizeof cmd, "mkdir -p '%s'", dirpart); + if (system(cmd) != 0) + exit(1); + } + FILE *f = fopen(path, "w"); + if (!f) + exit(1); + fputs(content, f); + fclose(f); +} + +static tool_limits mklimits2(const char **list, size_t n, bool to_deny) { + tool_limits l; + tool_limits_defaults(&l); + for (size_t i = 0; i < n; i++) + sl_push_copy(to_deny ? &l.shell_denylist : &l.shell_allowlist, + list[i]); + return l; +} + +static void test_read_write_roundtrip(void) { + char *dir = tmpdir("rw"); + char *p = xstrfmt("%s/nested/roundtrip.txt", dir); + char *err = NULL; + char *r = tool_write_file(p, "hi there", &err); + CHECK(r != NULL, "write_file"); + if (!r && err) + fprintf(stderr, " write_file err: %s\n", err); + free(r); + char *content = tool_read_file(p, 0, 0, &err); + CHECK(content && strcmp(content, "hi there") == 0, "read_file content"); + if (!content && err) + fprintf(stderr, " read_file err: %s\n", err); + free(content); + char *rm = xstrfmt("rm -rf '%s'", dir); + system(rm); + free(rm); + free(p); + free(dir); +} + +static void test_read_file_missing_errors(void) { + char *dir = tmpdir("missing"); + char *p = xstrfmt("%s/nope.txt", dir); + char *err = NULL; + char *r = tool_read_file(p, 0, 0, &err); + CHECK(r == NULL, "missing file should fail"); + CHECK(err && strstr(err, "nope.txt") != NULL, "error names the path"); + free(err); + free(p); + free(dir); +} + +static void test_read_file_empty_path_errors(void) { + char *err = NULL; + CHECK(tool_read_file("", 0, 0, &err) == NULL, "empty path should fail"); + CHECK(err && strstr(err, "path") != NULL, "error names the missing path"); + free(err); + err = NULL; + CHECK(tool_read_file(NULL, 0, 0, &err) == NULL, "NULL path should fail"); + CHECK(err && strstr(err, "path") != NULL, "NULL path error names it"); + free(err); +} + +static void test_read_file_too_large(void) { + char *dir = tmpdir("large"); + char *p = xstrfmt("%s/big.bin", dir); + /* 3 MiB of zeros: above the 2 MiB default max_file_bytes */ + FILE *f = fopen(p, "wb"); + if (f) { + char chunk[4096]; + memset(chunk, 0, sizeof chunk); + for (int i = 0; i < 768; i++) + fwrite(chunk, 1, sizeof chunk, f); + fclose(f); + } + char *err = NULL; + char *r = tool_read_file(p, 0, 0, &err); + CHECK(r == NULL, "oversized file should fail"); + CHECK(err && strstr(err, "too large to read") != NULL, + "error says too large"); + free(err); + free(p); + char *rm = xstrfmt("rm -rf '%s'", dir); + system(rm); + free(rm); + free(dir); +} + +static void test_read_file_binary_rejected(void) { + char *dir = tmpdir("bin"); + char *p = xstrfmt("%s/blob.bin", dir); + FILE *f = fopen(p, "wb"); + if (f) { + unsigned char b[] = {0xFF, 0xFE, 0x01}; + fwrite(b, 1, sizeof b, f); + fclose(f); + } + char *err = NULL; + char *r = tool_read_file(p, 0, 0, &err); + CHECK(r == NULL, "binary file should fail"); + CHECK(err && strstr(err, "valid UTF-8") != NULL, + "error mentions UTF-8"); + free(err); + free(p); + char *rm = xstrfmt("rm -rf '%s'", dir); + system(rm); + free(rm); + free(dir); +} + +static void test_read_file_offset_limit(void) { + char *dir = tmpdir("offlimit"); + char *p = xstrfmt("%s/lines.txt", dir); + wfile(dir, "lines.txt", "l1\nl2\nl3\nl4\nl5\n"); + char *err = NULL; + + /* offset=1, limit=2 -> first two lines + a truncation note */ + char *r = tool_read_file(p, 1, 2, &err); + CHECK(r != NULL, "read with offset/limit succeeds"); + CHECK_CONT(r, "l1\nl2", "first two lines present"); + CHECK_CONT(r, "3 more lines", "notes how many lines remain"); + CHECK_CONT(r, "offset=3", "notes the offset to continue from"); + free(r); + + /* offset=3, limit=2 -> lines 3-4, exactly one line remaining */ + r = tool_read_file(p, 3, 2, &err); + CHECK(r != NULL && + strcmp(r, "l3\nl4\n\n[... 1 more line; use offset=5 to continue " + "...]") == 0, + "middle slice + singular note, exact"); + free(r); + + /* limit beyond the end -> the rest of the file, no note */ + r = tool_read_file(p, 2, 100, &err); + CHECK(r != NULL && strcmp(r, "l2\nl3\nl4\nl5\n") == 0, + "limit beyond end returns the rest with no note"); + free(r); + + /* the last single line, no note */ + r = tool_read_file(p, 5, 1, &err); + CHECK(r != NULL && strcmp(r, "l5\n") == 0, "last line only, no note"); + free(r); + + /* offset past the end -> empty string */ + r = tool_read_file(p, 6, 1, &err); + CHECK(r != NULL && strcmp(r, "") == 0, "offset past the end is empty"); + free(r); + + free(p); + char *rm = xstrfmt("rm -rf '%s'", dir); + system(rm); + free(rm); + free(dir); +} + +static void test_read_file_offset_after_unterminated_last_line(void) { + char *dir = tmpdir("unterminated"); + char *path = xstrfmt("%s/one.txt", dir); + wfile(dir, "one.txt", "only line"); + char *err = NULL; + char *out = tool_read_file(path, 2, 1, &err); + CHECK(out && strcmp(out, "") == 0, + "offset beyond unterminated final line returns empty"); + free(out); + free(err); + free(path); + char *rm = xstrfmt("rm -rf '%s'", dir); + system(rm); + free(rm); + free(dir); +} + +static void test_read_file_rejects_embedded_nul(void) { + char *dir = tmpdir("nul"); + char *path = xstrfmt("%s/nul.txt", dir); + FILE *f = fopen(path, "wb"); + const char bytes[] = {'a', '\0', 'b'}; + fwrite(bytes, 1, sizeof bytes, f); + fclose(f); + char *err = NULL; + char *out = tool_read_file(path, 0, 0, &err); + CHECK(out == NULL, "embedded NUL is rejected as non-text"); + free(out); + free(err); + free(path); + char *rm = xstrfmt("rm -rf '%s'", dir); + system(rm); + free(rm); + free(dir); +} + +static void test_write_file_rejects_missing_content(void) { + char *err = NULL; + CHECK(tool_write_file("/tmp/llsh-must-not-be-created", NULL, &err) == NULL, + "missing content rejected"); + CHECK(err && strstr(err, "content"), "missing content is explained"); + free(err); +} + +static void test_run_shell_success_output(void) { + char *err = NULL; + char *out = tool_run_shell("echo hello-llsh", &err); + CHECK(out != NULL, "run_shell"); + CHECK_CONT(out, "hello-llsh", "stdout captured"); + CHECK_NOT_CONT(out, "exit code 0", "no status note on success"); + free(out); +} + +static void test_run_shell_success_no_output(void) { + char *err = NULL; + char *out = tool_run_shell("true", &err); + CHECK(out && strcmp(out, "(no output)") == 0, "no-output marker"); + if (!out && err) + fprintf(stderr, " err: %s\n", err); + free(out); +} + +static void test_run_shell_failure_status(void) { + char *err = NULL; + char *out = tool_run_shell("exit 3", &err); + CHECK(out != NULL, "failing command still returns output"); + CHECK_CONT(out, "exit code 3", "exit code reported"); + free(out); +} + +static void test_run_shell_stderr_section(void) { + char *err = NULL; + char *out = tool_run_shell("echo out-line; echo err-line 1>&2", &err); + CHECK(out != NULL, "run_shell with stderr"); + CHECK_CONT(out, "out-line", "stdout part"); + CHECK_CONT(out, "[stderr]\nerr-line", "stderr section"); + free(out); +} + +static void test_run_shell_signal(void) { + char *err = NULL; + char *out = tool_run_shell("kill -9 $$", &err); + CHECK(out != NULL, "signaled command returns output"); + CHECK_CONT(out, "terminated by signal", "signal note"); + free(out); +} + +static void test_run_shell_denylist_blocks(void) { + tool_limits saved; + tool_limits_get(&saved); + const char *deny[] = {"curl"}; + tool_limits l = mklimits2(deny, 1, true); + tool_limits_set(&l); + char *err = NULL; + char *out = tool_run_shell("curl example.com", &err); + CHECK(out == NULL, "denylisted command blocked"); + CHECK(err && strstr(err, "denylist") != NULL, "denylist mentioned"); + free(err); + tool_limits_free(&l); + tool_limits_set(&saved); + tool_limits_free(&saved); +} + +static void test_run_shell_allowlist_requires_match(void) { + tool_limits saved; + tool_limits_get(&saved); + const char *allow[] = {"git"}; + tool_limits l = mklimits2(allow, 1, false); + tool_limits_set(&l); + char *err = NULL; + char *out = tool_run_shell("git status", &err); + CHECK(out != NULL, "allowlisted command runs"); + free(out); + err = NULL; + out = tool_run_shell("ls -la", &err); + CHECK(out == NULL, "non-allowlisted command blocked"); + CHECK(err && strstr(err, "allowlist") != NULL, "allowlist mentioned"); + free(err); + free(out); + tool_limits_free(&l); + tool_limits_set(&saved); + tool_limits_free(&saved); +} + +static bool deny_hook(const char *cmd, void *ud) { + (void)cmd; + (void)ud; + return false; +} + +static void test_run_shell_confirm_hook_aborts(void) { + tool_limits saved; + tool_limits_get(&saved); + tool_limits l; + tool_limits_defaults(&l); + l.confirm_destructive = true; + tool_limits_set(&l); + set_confirm_hook(deny_hook, NULL); + char *err = NULL; + char *out = tool_run_shell("rm -rf /tmp/llsh-test", &err); + CHECK(out == NULL, "declined destructive command aborts"); + CHECK(err && strstr(err, "declined") != NULL, "declined mentioned"); + free(err); + set_confirm_hook(NULL, NULL); + tool_limits_free(&l); + tool_limits_set(&saved); + tool_limits_free(&saved); +} + +/* Number of live /proc entries whose cmdline contains `needle`. */ +static int leftover_cmdlines(const char *needle) { + DIR *d = opendir("/proc"); + if (!d) + return 0; + int found = 0; + struct dirent *e; + while ((e = readdir(d)) != NULL) { + bool digits = e->d_name[0] != '\0'; + for (const char *c = e->d_name; *c; c++) + if (!isdigit((unsigned char)*c)) { + digits = false; + break; + } + if (!digits) + continue; + char path[300]; + snprintf(path, sizeof path, "/proc/%s/cmdline", e->d_name); + FILE *f = fopen(path, "rb"); + if (!f) + continue; + char buf[8192]; + size_t n = fread(buf, 1, sizeof buf - 1, f); + fclose(f); + buf[n] = '\0'; + for (size_t i = 0; i <= n && !found; i++) { + if (i < n && buf[i] != '\0') + continue; + if (strstr(buf + i, needle)) + found = 1; + i++; + } + } + closedir(d); + return found; +} + +static void test_run_shell_timeout_kills_process_group(void) { + char *err = NULL; + char *out = tool_run_shell_timed("sleep 60 & echo spawned; wait", 1, &err); + CHECK(out == NULL, "long command times out"); + CHECK(err && strstr(err, "timed out") != NULL, "timeout message"); + free(err); + /* Give the kernel a moment to reap, then check nothing survived. */ + struct timespec ts = {0, 300 * 1000 * 1000}; + nanosleep(&ts, NULL); + CHECK(leftover_cmdlines("sleep 60") == 0, + "backgrounded sleep must die with its process group"); +} + +static void test_run_shell_large_output_does_not_hang(void) { + /* A child writing more than MAX_SHELL_OUTPUT_BYTES (1MB) used to block on + * a full pipe because draining stopped at the cap, forcing a full timeout. + * It must now complete promptly with its output capped. */ + char *err = NULL; + char *out = tool_run_shell_timed("head -c 2000000 /dev/zero | tr '\\0' 'x'", + 5, &err); + CHECK(out != NULL, "large output completes (no spurious timeout)"); + if (out) { + CHECK(strlen(out) <= 16000 + 256, "output is capped/truncated"); + free(out); + } + free(err); +} + +static void test_truncate_output(void) { + xbuf long_b; + xbuf_init(&long_b); + for (int i = 0; i < 20000; i++) + xbuf_push(&long_b, 'x'); + char *long_s = xbuf_steal(&long_b); + char *t = tool_truncate_output(long_s); + CHECK_CONT(t, "truncated", "truncation note"); + CHECK(strlen(t) < 20000, "output shortened"); + free(t); + char *short_s = xstrdup("small"); + char *t2 = tool_truncate_output(short_s); + CHECK(t2 && strcmp(t2, "small") == 0, "short text unchanged"); + free(t2); + free(long_s); + free(short_s); +} + +static void test_tool_list(void) { + const tool_entry *entries; + size_t n = tool_list_entries(&entries); + CHECK(n == 8, "eight tools registered"); + const char *want[8] = {"read_file", "write_file", "run_shell", + "use_skill", "search_memories", "embed", + "llm", "compact"}; + bool ok = true; + for (size_t i = 0; i < n; i++) + if (strcmp(entries[i].name, want[i]) != 0) + ok = false; + CHECK(ok, "tool names in order"); +} + +static void test_tool_params_schemas(void) { + for (size_t i = 0; i < tool_builtin_count(); i++) { + const char *name = tool_builtin_name(i); + cJSON *schema = tool_params_json(name); + CHECK(schema != NULL, "schema non-NULL"); + const cJSON *stype = cJSON_GetObjectItem(schema, "type"); + CHECK(cJSON_IsString(stype) && strcmp(stype->valuestring, "object") == 0, + "schema type is object"); + const cJSON *props = cJSON_GetObjectItem(schema, "properties"); + CHECK(cJSON_IsObject(props), "properties is an object"); + if (!cJSON_IsObject(props)) { + cJSON_Delete(schema); + continue; + } + for (const cJSON *p = props->child; p; p = p->next) { + int duplicates = 0; + for (const cJSON *q = props->child; q; q = q->next) + if (p->string && q->string && strcmp(p->string, q->string) == 0) + duplicates++; + CHECK(duplicates == 1, "property key appears exactly once"); + CHECK(cJSON_HasObjectItem(p, "type"), "property carries a type"); + } + cJSON_Delete(schema); + } + + cJSON *rf = tool_params_json("read_file"); + const cJSON *path = cJSON_GetObjectItem( + cJSON_GetObjectItem(rf, "properties"), "path"); + CHECK(cJSON_IsObject(path), "read_file has a path property"); + const cJSON *ptype = path ? cJSON_GetObjectItem(path, "type") : NULL; + CHECK(cJSON_IsString(ptype) && strcmp(ptype->valuestring, "string") == 0, + "read_file path type is string"); + const cJSON *pd = path ? cJSON_GetObjectItem(path, "description") : NULL; + CHECK(cJSON_IsString(pd) && pd->valuestring[0] != 0, + "read_file path keeps its description"); + char *rfstr = cJSON_PrintUnformatted(rf); + CHECK_CONT(rfstr, "\"path\":{\"type\":\"string\",\"description\":", + "read_file schema serializes path property"); + free(rfstr); + cJSON_Delete(rf); +} + +static void test_tokenize(void) { + strlist t; + sl_init(&t); + tool_tokenize("Billing Notes (DB)", &t); + CHECK(t.len == 3, "three tokens"); + CHECK(t.len == 3 && strcmp(t.items[0], "billing") == 0, "token 1"); + CHECK(t.len == 3 && strcmp(t.items[1], "notes") == 0, "token 2"); + CHECK(t.len == 3 && strcmp(t.items[2], "db") == 0, "token 3"); + sl_free(&t); + strlist empty; + sl_init(&empty); + tool_tokenize(" ", &empty); + CHECK(empty.len == 0, "whitespace-only query has no tokens"); + sl_free(&empty); +} + +static void test_is_subsequence(void) { + CHECK(tool_is_subsequence("abc", "a1b2c3"), "scattered chars match"); + CHECK(!tool_is_subsequence("abc", "acb"), "out-of-order rejected"); + CHECK(tool_is_subsequence("bill", "billed"), "prefix subsequence"); + CHECK(!tool_is_subsequence("billing", "billed"), "longer needle rejected"); +} + +static void test_field_match_strength(void) { + const char *field = "billing is monthly"; + double word = tool_field_match_strength(field, "billing"); + double sub = tool_field_match_strength(field, "billin"); + double fuzzy = tool_field_match_strength(field, "billng"); + double none = tool_field_match_strength(field, "zzz"); + CHECK(word > sub, "word beats substring"); + CHECK(sub > fuzzy, "substring beats fuzzy"); + CHECK(fuzzy > none, "fuzzy beats no match"); + CHECK(none == 0.0, "no match is zero"); + CHECK(tool_field_match_strength("abc", "ac") == 0.0, + "2-char terms get no fuzzy credit"); + CHECK(tool_field_match_strength("billing", "billing") == 1.0, + "exact match is 1.0"); + CHECK(tool_field_match_strength("", "x") == 0.0, "empty field is zero"); +} + +static memory mk_mem(const char *id, const char *importance, const char *title, + const char **tags, size_t ntags, const char *summary, + const char *body) { + memory m; + m.id = xstrdup(id); + m.title = xstrdup(title); + m.importance = xstrdup(importance); + m.tags = NULL; + m.tag_count = 0; + for (size_t i = 0; i < ntags; i++) + m.tags = (char* * ) realloc(m.tags, (m.tag_count + 1) * sizeof(char *)), + m.tags[m.tag_count++] = xstrdup(tags[i]); + m.summary = xstrdup(summary); + m.body = xstrdup(body); + m.layer = LAYER_WORKSPACE; + return m; +} + +static void free_mem(memory *m) { + free(m->id); + free(m->title); + free(m->importance); + for (size_t i = 0; i < m->tag_count; i++) + free(m->tags[i]); + free(m->tags); + free(m->summary); + free(m->body); +} + +static void test_memory_relevance(void) { + strlist q_billing; + sl_init(&q_billing); + tool_tokenize("billing", &q_billing); + memory in_title = + mk_mem("a", "low", "Billing", NULL, 0, "", ""); + memory in_body = mk_mem("b", "low", "Other", NULL, 0, "", + "billing monthly"); + CHECK(tool_memory_relevance(&in_title, &q_billing) > + tool_memory_relevance(&in_body, &q_billing), + "title hit outranks body-only hit"); + strlist q_two; + sl_init(&q_two); + tool_tokenize("billing note", &q_two); + memory covers_both = mk_mem("c", "low", "Billing Note", NULL, 0, "", ""); + memory covers_one = mk_mem("d", "low", "Billing", NULL, 0, "", ""); + CHECK(tool_memory_relevance(&covers_both, &q_two) > + tool_memory_relevance(&covers_one, &q_two), + "covering both terms outranks covering one"); + memory unrelated = mk_mem("e", "low", "Unrelated", NULL, 0, "", ""); + CHECK(tool_memory_relevance(&unrelated, &q_billing) == 0.0, + "no match scores zero"); + free_mem(&in_title); + free_mem(&in_body); + free_mem(&covers_both); + free_mem(&covers_one); + free_mem(&unrelated); + sl_free(&q_billing); + sl_free(&q_two); +} + +static void test_search_memories_ranks_relevance(void) { + char *home = getcwd(NULL, 0); + char *dir = tmpdir("memrank"); + wfile(dir, ".agents/memories/important.md", + "---\nid: important\ntitle: Random\nimportance: high\n---\n" + "We mention billing here."); + wfile(dir, ".agents/memories/relevant.md", + "---\nid: relevant\ntitle: Billing\nimportance: low\n---\n" + "Details."); + if (chdir(dir) != 0) + exit(1); + dotagents dot; + dotagents_load(&dot); + char *err = NULL; + char *out = tool_search_memories(&dot, "billing", &err); + CHECK(out != NULL, "search_memories"); + if (!out && err) + fprintf(stderr, " err: %s\n", err); + char *p_relevant = out ? strstr(out, "## Billing") : NULL; + char *p_important = out ? strstr(out, "## Random") : NULL; + CHECK(p_relevant && p_important && p_relevant < p_important, + "relevance outranks importance"); + free(out); + dotagents_free(&dot); + if (chdir(home) != 0) + exit(1); + free(home); + char *rm = xstrfmt("rm -rf '%s'", dir); + system(rm); + free(rm); + free(dir); +} + +static void test_search_memories_empty_query(void) { + char *err = NULL; + char *out = tool_search_memories(NULL, " ", &err); + CHECK(out == NULL, "blank query fails"); + CHECK(err && strstr(err, "query must not be empty") != NULL, + "error message"); + free(err); +} + +static void test_use_skill_and_search_memories_workspace(void) { + char *home = getcwd(NULL, 0); + char *dir = tmpdir("ws"); + wfile(dir, ".agents/skills/demo/skill.md", + "---\nid: demo\nname: Demo Skill\ndescription: a demo\n---\n" + "Step one. Step two."); + wfile(dir, ".agents/memories/note.md", + "---\nid: note\ntitle: Billing Note\nimportance: high\n" + "tags: billing, db\n---\nWe bill monthly."); + if (chdir(dir) != 0) + exit(1); + dotagents dot; + dotagents_load(&dot); + char *err = NULL; + char *out = tool_use_skill(&dot, "demo", &err); + CHECK(out != NULL, "use_skill"); + if (!out && err) + fprintf(stderr, " use_skill err: %s\n", err); + CHECK_CONT(out, "Demo Skill", "skill name"); + CHECK_CONT(out, "Step one. Step two.", "skill body"); + free(out); + err = NULL; + out = tool_use_skill(&dot, "nope", &err); + CHECK(out == NULL, "unknown skill fails"); + CHECK(err && strstr(err, "no skill") != NULL, "error names available"); + free(err); + err = NULL; + out = tool_search_memories(&dot, "billing", &err); + CHECK(out != NULL, "search_memories hit"); + CHECK_CONT(out, "Billing Note", "memory title"); + CHECK_CONT(out, "importance: high", "importance line"); + CHECK_CONT(out, "tags: billing, db", "tags line"); + free(out); + err = NULL; + out = tool_search_memories(&dot, "zzz-qqq-xxx", &err); + CHECK(out != NULL, "no-match search returns a message"); + CHECK_CONT(out, "no memories matching", "no-match message"); + free(out); + dotagents_free(&dot); + if (chdir(home) != 0) + exit(1); + free(home); + char *rm = xstrfmt("rm -rf '%s'", dir); + system(rm); + free(rm); + free(dir); +} + +/* Tools must serve the session's loaded `.agents` snapshot, not re-read + * the disk (AUDIT: no live mid-session reload). */ +static void test_tools_read_snapshot_not_disk(void) { + char *home = getcwd(NULL, 0); + char *dir = tmpdir("snap"); + wfile(dir, ".agents/skills/demo/skill.md", + "---\nid: demo\nname: Demo Skill\ndescription: a demo\n---\n" + "ORIGINAL BODY."); + wfile(dir, ".agents/memories/note.md", + "---\nid: note\ntitle: Billing Note\nimportance: high\n" + "tags: billing\n---\nORIGINAL MEMORY."); + if (chdir(dir) != 0) + exit(1); + dotagents dot; + dotagents_load(&dot); + + /* Mutate the on-disk state after the snapshot was taken. */ + wfile(dir, ".agents/skills/demo/skill.md", + "---\nid: demo\nname: Demo Skill\ndescription: a demo\n---\n" + "REPLACED BODY."); + wfile(dir, ".agents/memories/note.md", "entirely new memory"); + wfile(dir, ".agents/skills/fresh/skill.md", + "---\nid: fresh\nname: Fresh\ndescription: added later\n---\n" + "brand new skill."); + + char *err = NULL; + char *out = tool_use_skill(&dot, "demo", &err); + CHECK(out != NULL, "skill served from snapshot"); + CHECK_CONT(out, "ORIGINAL BODY.", "pre-mutation body intact"); + CHECK_NOT_CONT(out, "REPLACED BODY", "disk change not visible"); + free(out); + err = NULL; + out = tool_search_memories(&dot, "billing", &err); + CHECK(out != NULL, "memory served from snapshot"); + CHECK_CONT(out, "ORIGINAL MEMORY.", "pre-mutation memory body"); + CHECK_NOT_CONT(out, "entirely new memory", "new disk file not visible"); + free(out); + err = NULL; + out = tool_use_skill(&dot, "fresh", &err); + CHECK(out == NULL, "skill added after snapshot is unknown"); + CHECK(err != NULL, "unknown-skill error set"); + free(out); + free(err); + + dotagents_free(&dot); + if (chdir(home) != 0) + exit(1); + free(home); + char *rm = xstrfmt("rm -rf '%s'", dir); + system(rm); + free(rm); + free(dir); +} + +static void test_write_file_creates_parents(void) { + char *dir = tmpdir("wpar"); + char *p = xstrfmt("%s/deep/deeper/file.txt", dir); + char *err = NULL; + char *out = tool_write_file(p, "nested", &err); + CHECK(out != NULL, "write_file with new parents"); + CHECK_CONT(out, "wrote 6 bytes to", "byte count reported"); + free(out); + char *back = tool_read_file(p, 0, 0, &err); + CHECK(back && strcmp(back, "nested") == 0, "content roundtrip"); + free(back); + free(p); + char *rm = xstrfmt("rm -rf '%s'", dir); + system(rm); + free(rm); + free(dir); +} + +static void test_display_vtable(void) { + const tool_display *op; + + op = tool_display_for("run_shell"); + CHECK(op != NULL, "run_shell has a vtable entry"); + CHECK(op->render_call != NULL, "run_shell has a custom renderer"); + CHECK(op->dim_result, "run_shell results render muted"); + + char *line = op->render_call("{\"command\":\"ls -la src\"}"); + CHECK(line != NULL, "renderer returns a line"); + CHECK_CONT(line, "$ ", "renders a shell prompt line"); + CHECK_CONT(line, "ls -la src", "shell line carries the command"); + CHECK_NOT_CONT(line, "command", "raw JSON key does not leak"); + CHECK(line && line[0] == '\n' && line[strlen(line) - 1] == '\n', + "line framed with newlines"); + free(line); + + CHECK(tool_display_for("run_shell")->render_call(NULL) == NULL, + "NULL args -> generic fallback"); + CHECK(tool_display_for("run_shell")->render_call("{}") == NULL, + "missing command -> generic fallback"); + CHECK(tool_display_for("run_shell")->render_call("nope") == NULL, + "bad json -> generic fallback"); + + op = tool_display_for("read_file"); + CHECK(op && op->render_call == NULL && op->dim_result, + "read_file: generic display, muted result"); + op = tool_display_for("write_file"); + CHECK(op && op->render_call == NULL && !op->dim_result, + "write_file: generic display, normal result"); + op = tool_display_for("use_skill"); + CHECK(op && op->render_call == NULL && !op->dim_result, + "use_skill: generic display, normal result"); + op = tool_display_for("search_memories"); + CHECK(op && op->render_call == NULL && !op->dim_result, + "search_memories: generic display, normal result"); + + CHECK(tool_display_for("frobnicate") == NULL, "unknown tool -> NULL"); + CHECK(tool_display_for("mcp__srv__tool") == NULL, "mcp tool -> NULL"); + CHECK(tool_display_for(NULL) == NULL, "NULL name -> NULL"); +} + +/* ------------------------------------------------------------------ */ +/* embed tool */ +/* ------------------------------------------------------------------ */ + +static dotagents embed_dot(const char *model, const char *key, + const char *url) { + dotagents d; + memset(&d, 0, sizeof d); + fm_sl_init(&d.warnings); + d.models.default_name = xstrdup("emb"); + d.models.presets = (model_preset *) xmalloc(sizeof *d.models.presets); + memset(d.models.presets, 0, sizeof *d.models.presets); + d.models.presets[0].name = xstrdup("emb"); + d.models.presets[0].model = model ? xstrdup(model) : NULL; + d.models.presets[0].api_key = key ? xstrdup(key) : NULL; + d.models.presets[0].base_url = url ? xstrdup(url) : NULL; + d.models.preset_count = 1; + d.has_models = true; + return d; +} + +static config embed_cfg(const char *model, const char *key, const char *url) { + config c; + memset(&c, 0, sizeof c); + sl_init(&c.shell_allowlist); + sl_init(&c.shell_denylist); + if (model) + c.model = xstrdup(model); + if (key) + c.api_key = xstrdup(key); + if (url) + c.base_url = xstrdup(url); + return c; +} + +static void free_embed_cfg(config *c) { + free(c->model); + free(c->api_key); + free(c->base_url); + sl_free(&c->shell_allowlist); + sl_free(&c->shell_denylist); +} + +static void test_embed_resolve_profile_values(void) { + config c = embed_cfg(NULL, NULL, NULL); + dotagents d = embed_dot("emb-model", "emb-key", "https://emb.example/v1"); + embed_target t; + char *err = NULL; + CHECK(tool_embed_resolve(&c, &d, &t, &err) == 0, "resolve ok"); + CHECK(t.model && strcmp(t.model, "emb-model") == 0, "preset model"); + CHECK(t.api_key && strcmp(t.api_key, "emb-key") == 0, "preset api_key"); + CHECK(t.base_url && strcmp(t.base_url, "https://emb.example/v1") == 0, + "preset base_url"); + embed_target_free(&t); + dotagents_free(&d); + free_embed_cfg(&c); +} + +static void test_embed_resolve_falls_back_to_top_level(void) { + config c = embed_cfg("top-model", "top-key", "https://top.example/v1"); + dotagents d = embed_dot(NULL, NULL, NULL); /* preset with empty fields */ + embed_target t; + char *err = NULL; + CHECK(tool_embed_resolve(&c, &d, &t, &err) == 0, "resolve ok"); + CHECK(t.model && strcmp(t.model, "top-model") == 0, "inherits model"); + CHECK(t.api_key && strcmp(t.api_key, "top-key") == 0, "inherits api_key"); + CHECK(t.base_url && strcmp(t.base_url, "https://top.example/v1") == 0, + "inherits base_url"); + embed_target_free(&t); + dotagents_free(&d); + free_embed_cfg(&c); +} + +static void test_embed_resolve_errors(void) { + config c; + memset(&c, 0, sizeof c); + sl_init(&c.shell_allowlist); + sl_init(&c.shell_denylist); + embed_target t; + char *err = NULL; + + CHECK(tool_embed_resolve(&c, NULL, &t, &err) == -1 && err, + "no models.json -> error"); + CHECK(err && strstr(err, "not configured"), "not-configured message"); + free(err); + err = NULL; + + dotagents d = embed_dot("emb-model", "emb-key", "https://emb.example/v1"); + free(d.models.default_name); + d.models.default_name = xstrdup("ghost"); + CHECK(tool_embed_resolve(&c, &d, &t, &err) == -1 && err, + "unknown preset -> error"); + CHECK(err && strstr(err, "ghost"), "unknown-preset message"); + free(err); + err = NULL; + dotagents_free(&d); + + config c2 = embed_cfg(NULL, NULL, NULL); + dotagents d2 = embed_dot("emb-model", NULL, "https://emb.example/v1"); + err = NULL; + CHECK(tool_embed_resolve(&c2, &d2, &t, &err) == -1 && err, + "preset missing api_key -> error"); + CHECK(err && strstr(err, "api_key"), "missing-key message"); + free(err); + dotagents_free(&d2); + free_embed_cfg(&c2); + + sl_free(&c.shell_allowlist); + sl_free(&c.shell_denylist); +} + +static void test_embed_request_body(void) { + char *err = NULL; + char *req = tool_embed_request("emb-model", "hello \"world\"\n", &err); + CHECK(req && !err, "request built"); + CHECK_CONT(req, "\"model\":\"emb-model\"", "model in body"); + CHECK_CONT(req, "\"input\":\"hello \\\"world\\\"\\n\"", "input escaped"); + free(req); + free(err); + err = NULL; + CHECK(tool_embed_request("", "x", &err) == NULL, "empty model rejected"); + free(err); + err = NULL; + CHECK(tool_embed_request("m", "", &err) == NULL, "empty text rejected"); + free(err); +} + +static void test_embed_url(void) { + char *u = tool_embed_url("https://avois.org/v1/"); + CHECK(u && strcmp(u, "https://avois.org/v1/embeddings") == 0, + "trailing slash trimmed"); + free(u); + u = tool_embed_url("http://localhost:11434"); + CHECK(u && strcmp(u, "http://localhost:11434/embeddings") == 0, + "plain base"); + free(u); + u = tool_embed_url(""); + CHECK(u && strcmp(u, "https://api.openai.com/v1/embeddings") == 0, + "default base"); + free(u); +} + +static void test_embed_parse(void) { + char *vec = NULL; + char *err = NULL; + const char *body = + "{\"object\":\"list\",\"data\":[{\"object\":\"embedding\"," + "\"embedding\":[0.1,-0.2,0.3],\"index\":0}],\"model\":\"emb\"}"; + CHECK(tool_embed_parse(body, &vec, &err) == 0, "parse ok"); + CHECK(vec, "vector non-NULL"); + /* Compare values, not serialization: float formatting is not stable + * across FP environments (valgrind shifts last ulps). */ + cJSON *v = vec ? cJSON_Parse(vec) : NULL; + CHECK(v && cJSON_IsArray(v) && cJSON_GetArraySize(v) == 3, + "vector has 3 elements"); + if (v && cJSON_IsArray(v) && cJSON_GetArraySize(v) == 3) { + CHECK(fabs(cJSON_GetArrayItem(v, 0)->valuedouble - 0.1) < 1e-9, + "v[0] ~= 0.1"); + CHECK(fabs(cJSON_GetArrayItem(v, 1)->valuedouble - (-0.2)) < 1e-9, + "v[1] ~= -0.2"); + CHECK(fabs(cJSON_GetArrayItem(v, 2)->valuedouble - 0.3) < 1e-9, + "v[2] ~= 0.3"); + cJSON_Delete(v); + } + CHECK(!err, "no error"); + free(vec); + vec = NULL; + + CHECK(tool_embed_parse("{\"data\":[]}", &vec, &err) == -1, + "empty data -> error"); + CHECK(err && strstr(err, "data[0].embedding"), "missing-array message"); + free(err); + err = NULL; + CHECK(tool_embed_parse("{\"data\":[{\"embedding\":[1,\"x\"]}]}", &vec, + &err) == -1, + "non-numeric element -> error"); + free(err); + err = NULL; + CHECK(tool_embed_parse("not json", &vec, &err) == -1, "bad JSON -> error"); + free(err); + err = NULL; + CHECK(tool_embed_parse("", &vec, &err) == -1, "empty body -> error"); + free(err); +} + +static void test_embed_tool_unset(void) { + config c; + memset(&c, 0, sizeof c); + sl_init(&c.shell_allowlist); + sl_init(&c.shell_denylist); + char *err = NULL; + CHECK(tool_embed("text", &c, NULL, &err) == NULL, "unset -> NULL"); + CHECK(err && strstr(err, "not configured"), "unset message"); + free(err); + sl_free(&c.shell_allowlist); + sl_free(&c.shell_denylist); +} + +static config mkllmcfg(void) { + config c; + memset(&c, 0, sizeof c); + sl_init(&c.shell_allowlist); + sl_init(&c.shell_denylist); + c.provider = PROV_OPENAI; + c.model = xstrdup("test-model"); + c.base_url = xstrdup("http://127.0.0.1:9/v1"); + c.api_key = xstrdup("k"); + return c; +} + +static void freellmcfg(config *c) { + sl_free(&c->shell_allowlist); + sl_free(&c->shell_denylist); + free((void *)c->model); + free((void *)c->base_url); + free((void *)c->api_key); +} + +/* ------------------------------------------------------------------ */ +/* llm tool */ +/* ------------------------------------------------------------------ */ + +static void test_llm_tool_schemas(void) { + const char *names[] = {"llm", "compact"}; + for (size_t i = 0; i < sizeof names / sizeof names[0]; i++) { + const char *name = names[i]; + cJSON *schema = tool_params_json(name); + CHECK(schema != NULL, "schema non-NULL"); + if (!schema) + continue; + const cJSON *stype = cJSON_GetObjectItem(schema, "type"); + CHECK(cJSON_IsString(stype) && strcmp(stype->valuestring, "object") == 0, + "schema type is object"); + CHECK(cJSON_IsObject(cJSON_GetObjectItem(schema, "properties")), + "properties is an object"); + CHECK(cJSON_IsArray(cJSON_GetObjectItem(schema, "required")), + "required is an array"); + cJSON_Delete(schema); + } + + /* llm: required `prompt`, and every documented parameter present. */ + cJSON *llm = tool_params_json("llm"); + const cJSON *req = cJSON_GetObjectItem(llm, "required"); + int req_has_prompt = 0; + if (cJSON_IsArray(req)) { + for (const cJSON *r = req->child; r; r = r->next) + if (cJSON_IsString(r) && strcmp(r->valuestring, "prompt") == 0) + req_has_prompt = 1; + } + CHECK(req_has_prompt, "llm requires prompt"); + const char *props[] = {"prompt", "model", "temperature", + "max_tokens", "reasoning_effort", "thinking_budget", + "response_schema", "embed"}; + const cJSON *llmprops = cJSON_GetObjectItem(llm, "properties"); + for (size_t i = 0; i < sizeof props / sizeof props[0]; i++) + CHECK(cJSON_HasObjectItem(llmprops, props[i]), props[i]); + /* reasoning_effort is an enum of the three allowed values. */ + const cJSON *re = cJSON_GetObjectItem(llmprops, "reasoning_effort"); + const cJSON *reenum = cJSON_GetObjectItem(re, "enum"); + CHECK(cJSON_IsArray(reenum) && cJSON_GetArraySize(reenum) == 3, + "reasoning_effort enum has 3 values"); + cJSON_Delete(llm); + + /* compact: takes no parameters. */ + cJSON *cmp = tool_params_json("compact"); + const cJSON *cmpreq = cJSON_GetObjectItem(cmp, "required"); + CHECK(cJSON_IsArray(cmpreq) && cJSON_GetArraySize(cmpreq) == 0, + "compact required is empty"); + const cJSON *cmpprops = cJSON_GetObjectItem(cmp, "properties"); + CHECK(cJSON_IsObject(cmpprops) && cmpprops->child == NULL, + "compact properties is empty"); + cJSON_Delete(cmp); +} + +static void test_llm_tool_requires_prompt(void) { + config c = mkllmcfg(); + llm_tool_call call; + memset(&call, 0, sizeof call); + char *err = NULL; + CHECK(tool_llm(&call, &c, NULL, &err) == NULL, "no prompt -> NULL"); + CHECK(err && strstr(err, "prompt"), "names the prompt"); + free(err); + freellmcfg(&c); +} + +static void test_llm_tool_bad_schema(void) { + config c = mkllmcfg(); + llm_tool_call call; + memset(&call, 0, sizeof call); + call.prompt = "hi"; + call.response_schema = "not-json"; + char *err = NULL; + CHECK(tool_llm(&call, &c, NULL, &err) == NULL, "bad schema -> NULL"); + CHECK(err && strstr(err, "response_schema"), "names the schema"); + free(err); + freellmcfg(&c); +} + +static void test_llm_tool_null_call(void) { + config c = mkllmcfg(); + char *err = NULL; + CHECK(tool_llm(NULL, &c, NULL, &err) == NULL, "null call -> NULL"); + CHECK(err && strstr(err, "prompt"), "names the prompt"); + free(err); + freellmcfg(&c); +} + +int main(void) { + test_read_write_roundtrip(); + test_read_file_missing_errors(); + test_read_file_empty_path_errors(); + test_read_file_too_large(); + test_read_file_binary_rejected(); + test_read_file_offset_limit(); + test_read_file_offset_after_unterminated_last_line(); + test_read_file_rejects_embedded_nul(); + test_write_file_rejects_missing_content(); + test_run_shell_success_output(); + test_run_shell_success_no_output(); + test_run_shell_failure_status(); + test_run_shell_stderr_section(); + test_run_shell_signal(); + test_run_shell_denylist_blocks(); + test_run_shell_allowlist_requires_match(); + test_run_shell_confirm_hook_aborts(); + test_run_shell_timeout_kills_process_group(); + test_run_shell_large_output_does_not_hang(); + test_truncate_output(); + test_tool_list(); + test_display_vtable(); + test_tool_params_schemas(); + test_tokenize(); + test_is_subsequence(); + test_field_match_strength(); + test_memory_relevance(); + test_search_memories_ranks_relevance(); + test_search_memories_empty_query(); + test_use_skill_and_search_memories_workspace(); + test_tools_read_snapshot_not_disk(); + test_write_file_creates_parents(); + test_embed_resolve_profile_values(); + test_embed_resolve_falls_back_to_top_level(); + test_embed_resolve_errors(); + test_embed_request_body(); + test_embed_url(); + test_embed_parse(); + test_embed_tool_unset(); + test_llm_tool_schemas(); + test_llm_tool_requires_prompt(); + test_llm_tool_bad_schema(); + test_llm_tool_null_call(); + + printf("%d checks, %d failures\n", checks, failures); + return failures ? 1 : 0; +} |