summaryrefslogtreecommitdiffstats
path: root/tests/test_compact.cpp
blob: a8b64f9109822806ca57d9bc59e6426ae30d429c (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
#include "compact.h"
#include "message.h"

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

static int failures = 0;
static int checks = 0;

#define CHECK(cond, msg)                                                     \
    do {                                                                     \
        checks++;                                                            \
        if (!(cond)) {                                                       \
            failures++;                                                      \
            fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, (msg)); \
        }                                                                    \
    } while (0)

#define CHECK_EQ_U64(got, want, msg)                                         \
    do {                                                                     \
        checks++;                                                            \
        if ((unsigned long long)(got) != (unsigned long long)(want)) {       \
            failures++;                                                      \
            fprintf(stderr, "FAIL %s:%d: %s (got %llu, want %llu)\n",       \
                    __FILE__, __LINE__, (msg), (unsigned long long)(got),   \
                    (unsigned long long)(want));                             \
        }                                                                    \
    } while (0)

#define CHECK_CONT(hay, needle, msg)                                         \
    do {                                                                     \
        checks++;                                                            \
        if (!hay || !strstr(hay, needle)) {                                  \
            failures++;                                                      \
            fprintf(stderr, "FAIL %s:%d: %s (got `%s`, want substring `%s`)\n", \
                    __FILE__, __LINE__, (msg), hay ? hay : "(null)",         \
                    (needle));                                               \
        }                                                                    \
    } while (0)

static char *repeat_char(char c, size_t n) {
    char *s = (char* ) xmalloc(n + 1);
    memset(s, c, n);
    s[n] = '\0';
    return s;
}

/* text of the first tool-result item of a user message (or ""). */
static char *trc_text_of(const message *m) {
    if (!m->is_user() || m->user().parts.empty())
        return xstrdup("");
    const user_part &p = m->user().parts[0];
    const user_tool_result_part *r = std::get_if<user_tool_result_part>(&p);
    if (!r || r->content.empty())
        return xstrdup("");
    const trc_text *t = std::get_if<trc_text>(&r->content[0]);
    return xstrdup(t ? t->text.c_str() : "");
}

/* ------------------------------------------------------------------ */
/* estimate_tokens                                                     */
/* ------------------------------------------------------------------ */

static void test_estimate_tokens(void) {
    char *body = repeat_char('a', 40);
    message m = msg_user_text(body);
    CHECK_EQ_U64(compact_estimate_tokens(&m, 1), 10,
                 "40 bytes -> 10 tokens");
    free(body);

    CHECK_EQ_U64(compact_estimate_tokens(NULL, 0), 0, "empty -> 0 tokens");
}

static void test_estimate_tokens_tool_call_args(void) {
    message m = msg_assistant_tool_call("read_file", "{\"path\":\"a\"}");
    CHECK_EQ_U64(compact_estimate_tokens(&m, 1), 6,
                 "name(9)+args(12)=21 -> 6 tokens");
}

/* ------------------------------------------------------------------ */
/* keep_boundary                                                       */
/* ------------------------------------------------------------------ */

static void test_keep_boundary(void) {
    message msgs[6] = {
        msg_user_text("read the file"),
        msg_assistant_text("ok"),
        msg_user_tool_result("c1", "read_file", "contents"),
        msg_assistant_text("here it is"),
        msg_user_text("now edit it"),
        msg_assistant_text("done"),
    };
    CHECK_EQ_U64(compact_keep_boundary(msgs, 6), 4,
                 "boundary is last user");

    message n[2] = {
        msg_user_tool_result("c1", "read_file", "contents"),
        msg_assistant_text("done"),
    };
    CHECK_EQ_U64(compact_keep_boundary(n, 2), 2, "none -> keep all");

    CHECK_EQ_U64(compact_keep_boundary(NULL, 0), 0, "empty -> 0");
}

/* ------------------------------------------------------------------ */
/* clear_old_tool_results                                              */
/* ------------------------------------------------------------------ */

static void test_clear_old_tool_results(void) {
    message msgs[6] = {
        msg_user_text("read the file"),
        msg_assistant_text("ok"),
        msg_user_tool_result("c1", "read_file", "lots of content"),
        msg_assistant_text("here it is"),
        msg_user_text("now edit it"),
        msg_user_tool_result("c2", "write_file", "tail content"),
    };
    size_t boundary = compact_keep_boundary(msgs, 6);
    CHECK_EQ_U64(boundary, 4, "boundary == 4");

    size_t cleared = compact_clear_old_tool_results(msgs, 6, boundary);
    CHECK_EQ_U64(cleared, 1, "cleared exactly one");

    char *t2 = trc_text_of(&msgs[2]);
    CHECK(xstreq(t2, CLEARED_PLACEHOLDER), "stale result placeholdered");
    free(t2);

    char *t5 = trc_text_of(&msgs[5]);
    CHECK(xstreq(t5, "tail content"), "tail result untouched");
    free(t5);

    /* name + call preserved on the cleared result */
    CHECK(msgs[2].is_user(), "still a user message");
    CHECK(std::holds_alternative<user_tool_result_part>(msgs[2].user().parts[0]),
          "still a tool result");
    CHECK(std::get<user_tool_result_part>(msgs[2].user().parts[0]).name ==
              "read_file",
          "name preserved");
    CHECK(std::get<user_tool_result_part>(msgs[2].user().parts[0]).call ==
              "c1",
          "call id preserved");

    /* idempotent */
    CHECK_EQ_U64(compact_clear_old_tool_results(msgs, 6, boundary), 0,
                 "second pass clears nothing");
}

/* ------------------------------------------------------------------ */
/* transcript_markdown                                                 */
/* ------------------------------------------------------------------ */

static void test_transcript_markdown(void) {
    message msgs[4] = {
        msg_user_text("read the file"),
        msg_assistant_tool_call("read_file", "{\"path\":\"a.txt\"}"),
        msg_user_tool_result("c1", "read_file", "line one\nline two"),
        msg_assistant_text("The file has two lines."),
    };
    char *out = compact_transcript_markdown(msgs, 4);
    CHECK(out && strncmp(out, "# llsh transcript", 15) == 0, "header");
    CHECK_CONT(out, "## User\n\nread the file", "user block");
    CHECK_CONT(out, "### Tool call: `read_file`", "tool call heading");
    CHECK_CONT(out, "```json", "json fence");
    CHECK_CONT(out, "\"path\"", "json path key");
    CHECK_CONT(out, "### Tool result: `read_file`", "tool result heading");
    CHECK_CONT(out, "line one\nline two", "result body");
    CHECK_CONT(out, "## Assistant\n\nThe file has two lines.", "assistant");
    free(out);
}

static void test_transcript_markdown_empty(void) {
    char *out = compact_transcript_markdown(NULL, 0);
    CHECK(out && strncmp(out, "# llsh transcript", 15) == 0, "header");
    CHECK_CONT(out, "_No messages yet._", "placeholder");
    free(out);
}

/* ------------------------------------------------------------------ */
/* recent_file_paths                                                   */
/* ------------------------------------------------------------------ */

static void test_recent_file_paths(void) {
    strlist out;
    const char *s1 = "## Goal\ndo a thing\n\n## Files\n- src/foo.rs\n"
                     "* src/bar.rs\n\n## Next steps\n- more";
    compact_recent_file_paths(s1, &out);
    CHECK(out.len == 2, "two paths");
    if (out.len == 2) {
        CHECK(xstreq(out.items[0], "src/foo.rs"), "path 0");
        CHECK(xstreq(out.items[1], "src/bar.rs"), "path 1");
    }
    sl_free(&out);

    compact_recent_file_paths("## Goal\nnothing here", &out);
    CHECK(out.len == 0, "no files section -> empty");
    sl_free(&out);

    const char *s3 = "## Next steps\n- src/foo.rs\n\n## Files\n- src/bar.rs";
    compact_recent_file_paths(s3, &out);
    CHECK(out.len == 1, "ignores bullets outside files");
    if (out.len == 1)
        CHECK(xstreq(out.items[0], "src/bar.rs"), "only bar.rs");
    sl_free(&out);
}

/* ------------------------------------------------------------------ */
/* compose_history                                                     */
/* ------------------------------------------------------------------ */

static void test_compose_history(void) {
    message tail[2] = {
        msg_user_text("now edit it"),
        msg_assistant_text("done"),
    };
    const char *paths[1] = {"src/foo.rs"};
    const char *contents[1] = {"fn main() {}"};
    history *h = compact_compose_history("## Goal\nship it", paths, contents,
                                         1, tail, 2);
    CHECK_EQ_U64(history_len(h), 3, "one body + tail");
    const message *first = history_at(h, 0);
    CHECK(first->is_user(), "first is user");
    CHECK_EQ_U64(first->user().parts.size(), 1, "single text part");
    const std::string &body =
        std::get<user_text_part>(first->user().parts[0]).text;
    CHECK_CONT(body.c_str(), "## Goal", "summary present");
    CHECK_CONT(body.c_str(), "src/foo.rs", "file path present");
    CHECK_CONT(body.c_str(), "fn main() {}", "file content present");
    CHECK(history_at(h, 1)->is_user(), "tail[0] user");
    CHECK(history_at(h, 2)->is_assistant(), "tail[1] assistant");
    history_free(h);
    free(h);
}

static void test_compose_history_caps(void) {
    char *big = repeat_char('x', 200000);
    const char *paths[1] = {"big.log"};
    const char *contents[1] = {big};
    history *h =
        compact_compose_history("summary", paths, contents, 1, NULL, 0);
    CHECK_EQ_U64(history_len(h), 1, "just the body");
    const std::string &body =
        std::get<user_text_part>(history_at(h, 0)->user().parts[0]).text;
    CHECK(utf8_len(body.c_str()) < 200000, "content capped, not full");
    char *want = repeat_char('x', PER_FILE_CAP);
    CHECK_CONT(body.c_str(), want, "first PER_FILE_CAP chars present");
    free(want);
    history_free(h);
    free(h);
    free(big);
}

/* ------------------------------------------------------------------ */
/* transcript (flat)                                                   */
/* ------------------------------------------------------------------ */

static void test_transcript_flat(void) {
    message msgs[3] = {
        msg_user_text("read the file"),
        msg_assistant_text("reading"),
        msg_user_tool_result("c1", "read_file", "the body"),
    };
    char *out = compact_transcript(msgs, 3);
    CHECK_CONT(out, "User: read the file", "user line");
    CHECK_CONT(out, "Assistant: reading", "assistant line");
    CHECK_CONT(out, "ToolResult(read_file): the body", "tool result line");
    free(out);
}

/* ------------------------------------------------------------------ */
/* compact_at / clear_at                                               */
/* ------------------------------------------------------------------ */

static void test_thresholds(void) {
    compact_config c;
    compact_config_from(1000, 0.75, 0.5, &c);
    unsigned long v;
    CHECK(compact_at(&c, &v), "compact enabled");
    CHECK_EQ_U64(v, 750, "compact_at == 750");
    CHECK(clear_at(&c, &v), "clear enabled");
    CHECK_EQ_U64(v, 500, "clear_at == 500");

    compact_config d;
    compact_config_from(1000, 0.0, 0.0, &d);
    CHECK(!compact_at(&d, &v), "compact disabled");
    CHECK(!clear_at(&d, &v), "clear disabled");
}

/* ------------------------------------------------------------------ */
/* message model                                                       */
/* ------------------------------------------------------------------ */

static void test_message_model(void) {
    message m = msg_user_tool_result("c9", "grep", "match");
    CHECK(m.is_user(), "role user");
    CHECK_EQ_U64(m.user().parts.size(), 1, "one part");
    CHECK(std::holds_alternative<user_tool_result_part>(m.user().parts[0]),
          "tool result kind");
    CHECK(std::get<user_tool_result_part>(m.user().parts[0]).name == "grep",
          "name");
    CHECK(std::get<user_tool_result_part>(m.user().parts[0]).call == "c9",
          "call");
    CHECK(std::get<trc_text>(
              std::get<user_tool_result_part>(m.user().parts[0]).content[0])
              .text == "match",
          "content");

    /* copy round-trip */
    message orig = msg_assistant_tool_call("write_file", "{\"path\":\"a\"}");
    message copy = orig;
    CHECK(copy.is_assistant(), "copy role");
    CHECK_EQ_U64(copy.assistant().parts.size(), 1, "copy len");
    CHECK(std::get<asst_tool_call_part>(copy.assistant().parts[0]).name ==
              "write_file",
          "copy name");
    CHECK(std::get<asst_tool_call_part>(copy.assistant().parts[0]).args ==
              "{\"path\":\"a\"}",
          "copy args");
    /* independent storage: mutating orig does not affect copy */
    std::get<asst_tool_call_part>(orig.assistant().parts[0]).name = "other";
    CHECK(std::get<asst_tool_call_part>(copy.assistant().parts[0]).name ==
              "write_file",
          "copy independent");

    /* multi-part assistant message */
    message multi = msg_assistant_text("thinking...");
    msg_push_assistant_tool_call(multi, "run_shell", "echo hi");
    msg_push_assistant_reasoning(multi, "because");
    CHECK_EQ_U64(multi.assistant().parts.size(), 3, "three parts");
    CHECK(std::holds_alternative<asst_text_part>(multi.assistant().parts[0]),
          "part0 text");
    CHECK(std::holds_alternative<asst_tool_call_part>(
              multi.assistant().parts[1]),
          "part1 tool call");
    CHECK(std::holds_alternative<asst_reasoning_part>(
              multi.assistant().parts[2]),
          "part2 reasoning");

    /* history push / take */
    history h;
    history_push(&h, msg_user_text("one"));
    history_push(&h, msg_user_text("two"));
    history_push(&h, msg_user_text("three"));
    CHECK_EQ_U64(history_len(&h), 3, "len 3");
    message mid = history_take(&h, 1);
    CHECK(std::get<user_text_part>(mid.user().parts[0]).text == "two",
          "take returns elem 1");
    CHECK_EQ_U64(history_len(&h), 2, "len 2 after take");
    CHECK(std::get<user_text_part>(history_at(&h, 0)->user().parts[0]).text ==
              "one",
          "elem0 intact");
    CHECK(std::get<user_text_part>(history_at(&h, 1)->user().parts[0]).text ==
              "three",
          "elem1 shifted");
    /* stack history: RAII destructor cleans up at scope exit */

    /* preamble sanity */
    const char *p = compact_summarizer_preamble();
    CHECK_CONT(p, "## Goal", "preamble goal section");
    CHECK_CONT(p, "## Files", "preamble files section");
    CHECK_CONT(p, "under 800 words", "preamble word limit");
}

/* ------------------------------------------------------------------ */
/* rubric (SelfCompact)                                                */
/* ------------------------------------------------------------------ */

static void test_rubric_parse_valid(void) {
    const char *answer =
        "C1: Y -- the last message ends with a finished tool result\n"
        "C2: Y -- 1. fact one \"x\"\n"
        "C3: Y -- a new URL was obtained\n"
        "N1: N -- each recent call returned new content\n";
    compact_rubric r;
    char *err = NULL;
    CHECK(compact_rubric_parse(answer, &r, &err) == 0, "parse ok");
    CHECK(err == NULL, "no err");
    CHECK_EQ_U64(r.c1, 1, "c1 Y");
    CHECK_EQ_U64(r.c2, 1, "c2 Y");
    CHECK_EQ_U64(r.c3, 1, "c3 Y");
    CHECK_EQ_U64(r.n1, 0, "n1 N");
    CHECK(compact_rubric_fire(&r), "fires when c1&c2&c3 & !n1");
}

static void test_rubric_parse_n(void) {
    const char *answer =
        "C1: N -- still mid-thought\n"
        "C2: Y -- facts\n"
        "C3: Y -- progress\n"
        "N1: N -- fresh results\n";
    compact_rubric r;
    char *err = NULL;
    CHECK(compact_rubric_parse(answer, &r, &err) == 0, "parse ok");
    CHECK_EQ_U64(r.c1, 0, "c1 N");
    CHECK(!compact_rubric_fire(&r), "does not fire with c1 N");
}

static void test_rubric_parse_stuck(void) {
    const char *answer =
        "C1: Y -- closed\n"
        "C2: Y -- facts\n"
        "C3: Y -- progress\n"
        "N1: Y -- three of last four calls repeated\n";
    compact_rubric r;
    char *err = NULL;
    CHECK(compact_rubric_parse(answer, &r, &err) == 0, "parse ok");
    CHECK_EQ_U64(r.n1, 1, "n1 Y");
    CHECK(!compact_rubric_fire(&r), "does not fire when stuck");
}

static void test_rubric_parse_missing(void) {
    const char *answer =
        "C1: Y -- closed\n"
        "C2: Y -- facts\n"
        "C3: Y -- progress\n";
    compact_rubric r;
    char *err = NULL;
    CHECK(compact_rubric_parse(answer, &r, &err) != 0, "missing N1 -> error");
    CHECK(err != NULL, "err set");
    free(err);
}

static void test_rubric_parse_malformed(void) {
    const char *answer =
        "C1: MAYBE -- closed\n"
        "C2: Y -- facts\n"
        "C3: Y -- progress\n"
        "N1: N -- fresh\n";
    compact_rubric r;
    char *err = NULL;
    CHECK(compact_rubric_parse(answer, &r, &err) != 0, "bad token -> error");
    CHECK(err != NULL, "err set");
    free(err);
}

static void test_rubric_y_without_evidence_is_n(void) {
    /* A Y with no evidence on the line is downgraded to N (the paper's rule). */
    const char *answer =
        "C1: Y\n"
        "C2: Y -- facts\n"
        "C3: Y -- progress\n"
        "N1: N -- fresh\n";
    compact_rubric r;
    char *err = NULL;
    CHECK(compact_rubric_parse(answer, &r, &err) == 0, "parse ok");
    CHECK_EQ_U64(r.c1, 0, "Y without evidence downgraded to N");
    CHECK(!compact_rubric_fire(&r), "does not fire");
}

static void test_rubric_prompt_and_preamble(void) {
    const char *p = compact_rubric_prompt();
    CHECK_CONT(p, "C1", "prompt has C1");
    CHECK_CONT(p, "C2", "prompt has C2");
    CHECK_CONT(p, "C3", "prompt has C3");
    CHECK_CONT(p, "N1", "prompt has N1");
    CHECK_CONT(p, "C1: Y/N -- <evidence>", "prompt output format");

    const char *pre = compact_rubric_preamble();
    CHECK_CONT(pre, "judge", "preamble is a judge role");
}

static void test_rubric_due(void) {
    /* all gates satisfied */
    CHECK(compact_rubric_due(RUBRIC_MIN_ITERATION, RUBRIC_MIN_TOKENS, 0,
                             RUBRIC_DEFAULT_MAX_SUMMARIES,
                             RUBRIC_MIN_ROUNDS_SINCE_PROBE),
          "due at exact thresholds");
    /* iteration too low */
    CHECK(!compact_rubric_due(RUBRIC_MIN_ITERATION - 1, RUBRIC_MIN_TOKENS, 0,
                              RUBRIC_DEFAULT_MAX_SUMMARIES,
                              RUBRIC_MIN_ROUNDS_SINCE_PROBE),
          "not due: iteration");
    /* tokens too low */
    CHECK(!compact_rubric_due(RUBRIC_MIN_ITERATION, RUBRIC_MIN_TOKENS - 1, 0,
                              RUBRIC_DEFAULT_MAX_SUMMARIES,
                              RUBRIC_MIN_ROUNDS_SINCE_PROBE),
          "not due: tokens");
    /* cap reached */
    CHECK(!compact_rubric_due(RUBRIC_MIN_ITERATION, RUBRIC_MIN_TOKENS, 1,
                              RUBRIC_DEFAULT_MAX_SUMMARIES,
                              RUBRIC_MIN_ROUNDS_SINCE_PROBE),
          "not due: cap");
    /* rounds since probe too low */
    CHECK(!compact_rubric_due(RUBRIC_MIN_ITERATION, RUBRIC_MIN_TOKENS, 0,
                              RUBRIC_DEFAULT_MAX_SUMMARIES,
                              RUBRIC_MIN_ROUNDS_SINCE_PROBE - 1),
          "not due: rounds");
    /* unlimited cap (0) */
    CHECK(compact_rubric_due(RUBRIC_MIN_ITERATION, RUBRIC_MIN_TOKENS, 5, 0,
                             RUBRIC_MIN_ROUNDS_SINCE_PROBE),
          "due: unlimited cap");
}

int main(void) {
    test_estimate_tokens();
    test_estimate_tokens_tool_call_args();
    test_keep_boundary();
    test_clear_old_tool_results();
    test_transcript_markdown();
    test_transcript_markdown_empty();
    test_recent_file_paths();
    test_compose_history();
    test_compose_history_caps();
    test_transcript_flat();
    test_thresholds();
    test_message_model();
    test_rubric_parse_valid();
    test_rubric_parse_n();
    test_rubric_parse_stuck();
    test_rubric_parse_missing();
    test_rubric_parse_malformed();
    test_rubric_y_without_evidence_is_n();
    test_rubric_prompt_and_preamble();
    test_rubric_due();
    printf("%d checks, %d failures\n", checks, failures);
    return failures ? 1 : 0;
}