summaryrefslogtreecommitdiffstats
path: root/tests/test_tools.cpp
diff options
context:
space:
mode:
authorEthan Werner <mail@ethanwerner.com>2026-10-10 20:55:33 +0000
committerEthan Werner <mail@ethanwerner.com>2026-10-10 20:56:50 +0000
commitf8423ebea413605b42de23f5dd06c5479d3fa1dd (patch)
tree2612a5eb026c59e7aae01f10a1cf62f2ee43042c /tests/test_tools.cpp
initial commitHEADmaster
Diffstat (limited to 'tests/test_tools.cpp')
-rw-r--r--tests/test_tools.cpp1174
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;
+}