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_compact.cpp | 517 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 517 insertions(+) create mode 100644 tests/test_compact.cpp (limited to 'tests/test_compact.cpp') 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; +} -- cgit v1.3.1