Files
CST-agent/tests/test_store.c
T
WHC dae51e06c7 fix the session-list scan's line count, and pin both new paths with tests
The rewritten cst_agent_store_info() counted a transcript line that ran past a
whole 64 KiB read block a second time, when the newline ending its tail
arrived: a session with one very long line (a tool that read a file back)
reported one message too many -- exactly what the sidebar shows the person.

- src/agent_store.c: the tail's newline ends a line that was already counted.
- tests/test_store.c: test_info_scan() holds the scan down -- a 70 KiB line is
  one line, the first user message comes from the head of the file, and a user
  line past the window is not called the first one.
- tests/test_tools_ext.c: a byte budget stops the session_search walk, says so
  in the result, and never reports a match it did not reach; the same query
  without a budget finds it.

make check and make asan are green.
2026-10-11 20:03:39 +08:00

385 lines
14 KiB
C

/* The session store: one directory per session, and the way in for stores
* written before that.
*
* What has to hold:
* - an id is a uuid v4 (two machines on one synced workspace must not hand
* out the same one);
* - a new session gets <ws>/.agent/sessions/<id>/session.jsonl, and its
* attachments land in that directory, not in a shared one;
* - a store from an older version keeps working exactly as it was (flat
* <id>.jsonl + <id>.title + attachments/<id>/), and --migrate-store moves
* it into the new shape without losing the transcript, the title or a
* single attachment byte;
* - deleting a session takes its whole directory with it.
*/
#define _POSIX_C_SOURCE 200809L
#include "cst_agent.h"
#include "cst_attach.h"
#include "cst_test_util.h"
#include "os_compat.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static int nfail;
static char g_ws[1024];
#define EXPECT(cond, msg) \
do { \
if (!(cond)) { \
fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, msg); \
nfail++; \
} \
} while (0)
static void wfile(const char *path, const char *data) {
FILE *f = fopen(path, "wb");
if (!f) {
fprintf(stderr, "cannot write %s\n", path);
exit(1);
}
fputs(data, f);
fclose(f);
}
static int exists(const char *path) {
os_stat_t st;
return os_lstat(path, &st) == 0;
}
static void test_ids(void) {
static char a[64], b[64];
EXPECT(cst_agent_store_new_id(a, sizeof(a)) == 0, "an id is made");
EXPECT(cst_agent_store_new_id(b, sizeof(b)) == 0, "and another one");
EXPECT(strcmp(a, b) != 0, "two ids differ");
EXPECT(strlen(a) == 36, "the id is 36 characters (uuid)");
EXPECT(a[8] == '-' && a[13] == '-' && a[18] == '-' && a[23] == '-',
"with the uuid dashes where they belong");
EXPECT(a[14] == '4', "version 4");
EXPECT(strchr("89ab", a[19]) != NULL, "the variant bits say rfc 4122");
char small[16];
EXPECT(cst_agent_store_new_id(small, sizeof(small)) != 0,
"a buffer too small for a uuid is refused");
}
static void test_new_session(void) {
char id[64];
EXPECT(cst_agent_store_new_id(id, sizeof(id)) == 0, "id for a new session");
char path[1200];
EXPECT(cst_agent_store_path_for_id(g_ws, id, path, sizeof(path)) == 0,
"the store names the transcript");
char want[1200];
snprintf(want, sizeof(want), "%s/.agent/sessions/%s/session.jsonl", g_ws, id);
EXPECT(strcmp(path, want) == 0, "it is <id>/session.jsonl");
char want_dir[1200];
snprintf(want_dir, sizeof(want_dir), "%s/.agent/sessions/%s", g_ws, id);
EXPECT(exists(want_dir), "and the session's directory was made for it");
/* the id is the directory, not the file name: a path in this layout must
* not make every session call itself "session" */
char back[128];
EXPECT(cst_agent_store_id_of_path(path, back, sizeof(back)) == 0 &&
strcmp(back, id) == 0,
"the transcript's path names its session");
/* the engine opens it like any other file */
cst_session_t *s = cst_session_open(path);
EXPECT(s != NULL, "the transcript opens");
cst_message_t m;
memset(&m, 0, sizeof(m));
m.role = CST_ROLE_USER;
m.content = (char *)"what is this?";
EXPECT(cst_session_append(s, &m) == 0, "a message goes in");
cst_session_close(s);
static char ids[16][128];
int n = cst_agent_store_list(g_ws, ids, 16);
int found = 0;
for (int i = 0; i < n; i++)
if (strcmp(ids[i], id) == 0)
found = 1;
EXPECT(found, "the store lists it");
cst_session_info_t in;
EXPECT(cst_agent_store_info(g_ws, id, &in) == 0, "its info reads back");
EXPECT(in.messages == 1, "one message in it");
EXPECT(strcmp(in.first_user, "what is this?") == 0, "the first user line");
EXPECT(cst_agent_store_title_set(g_ws, id, "about the store") == 0,
"a title can be set");
char tp[1200];
snprintf(tp, sizeof(tp), "%s/.agent/sessions/%s/title", g_ws, id);
EXPECT(exists(tp), "the title lives inside the session directory");
memset(&in, 0, sizeof(in));
EXPECT(cst_agent_store_info(g_ws, id, &in) == 0 &&
strcmp(in.title, "about the store") == 0,
"and comes back");
/* attachments: inside the session's own directory (import_bytes takes an
* image, so the payload says PNG) */
char err[256] = "";
static const char png[] = "\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR";
cst_attachment_t *a = cst_attach_import_bytes(path, "shot.png", png,
sizeof(png) - 1, err,
sizeof(err));
EXPECT(a != NULL, "a payload is stored");
if (a) {
char ap[1200];
EXPECT(cst_attach_full_path(path, a, ap, sizeof(ap)) == 0,
"its path resolves");
snprintf(want, sizeof(want), "%s/.agent/sessions/%s/attachments/", g_ws, id);
EXPECT(strncmp(ap, want, strlen(want)) == 0,
"which is inside <id>/attachments/");
const char *tail = strstr(ap, "/attachments/");
EXPECT(tail && strchr(tail + strlen("/attachments/"), '/') == NULL,
"with no second stem in the path");
cst_attach_free(a);
}
/* deleting takes the directory (and the payload) with it */
EXPECT(cst_agent_store_delete(g_ws, id) == 0, "the session is deleted");
char dir[1200];
snprintf(dir, sizeof(dir), "%s/.agent/sessions/%s", g_ws, id);
EXPECT(!exists(dir), "and its directory is gone, payload and all");
}
/* A store the way an older version wrote one. */
static void write_flat_session(const char *id, const char *title,
const char *first, const char *payload,
char *path_out, size_t path_sz) {
char dir[1200], p[1200];
snprintf(dir, sizeof(dir), "%s/.agent/sessions", g_ws);
os_mkdir_p(dir, 0755);
snprintf(p, sizeof(p), "%s/%s.jsonl", dir, id);
wfile(p, first);
if (title) {
snprintf(p, sizeof(p), "%s/%s.title", dir, id);
wfile(p, title);
}
if (payload) {
snprintf(p, sizeof(p), "%s/attachments/%s", dir, id);
os_mkdir_p(p, 0700);
snprintf(p, sizeof(p), "%s/attachments/%s/art-1.md", dir, id);
wfile(p, payload);
}
snprintf(path_out, path_sz, "%s/%s.jsonl", dir, id);
}
static void test_old_store(void) {
const char *id = "20260101-101010-abcd";
char flat[1200];
write_flat_session(id, "the old one\n",
"{\"role\":\"user\",\"content\":\"from the old layout\"}\n",
"an artifact body\n", flat, sizeof(flat));
/* before migrating: read exactly as before */
char path[1200];
EXPECT(cst_agent_store_path_for_id(g_ws, id, path, sizeof(path)) == 0,
"the old session resolves");
EXPECT(strcmp(path, flat) == 0, "to its flat file, untouched");
cst_session_info_t in;
EXPECT(cst_agent_store_info(g_ws, id, &in) == 0 &&
strcmp(in.title, "the old one") == 0,
"its title is read from <id>.title");
EXPECT(strcmp(in.first_user, "from the old layout") == 0,
"and its transcript");
static char ids[16][128];
int n = cst_agent_store_list(g_ws, ids, 16), found = 0;
for (int i = 0; i < n; i++)
if (strcmp(ids[i], id) == 0)
found = 1;
EXPECT(found, "the store lists it");
/* a payload of that session sits in the shared directory */
char old_payload[1200];
snprintf(old_payload, sizeof(old_payload),
"%s/.agent/sessions/attachments/%s/art-1.md", g_ws, id);
EXPECT(exists(old_payload), "its artifact is in the shared directory");
int moved = 0, failed = 0;
char err[256] = "";
EXPECT(cst_agent_store_migrate(g_ws, &moved, &failed, err, sizeof(err)) == 0,
"the store migrates");
EXPECT(moved == 1 && failed == 0, "one session moved, none failed");
char nested[1200];
snprintf(nested, sizeof(nested), "%s/.agent/sessions/%s/session.jsonl", g_ws,
id);
EXPECT(exists(nested), "the transcript is <id>/session.jsonl now");
char back[128];
EXPECT(cst_agent_store_id_of_path(nested, back, sizeof(back)) == 0 &&
strcmp(back, id) == 0,
"and names the same session after the move");
EXPECT(!exists(flat), "the flat file is gone");
char tp[1200];
snprintf(tp, sizeof(tp), "%s/.agent/sessions/%s/title", g_ws, id);
EXPECT(exists(tp), "the title moved inside");
char new_payload[1200];
snprintf(new_payload, sizeof(new_payload),
"%s/.agent/sessions/%s/attachments/art-1.md", g_ws, id);
EXPECT(exists(new_payload), "the artifact moved into the session directory");
EXPECT(!exists(old_payload), "and is not in the shared one any more");
{
FILE *f = fopen(new_payload, "rb");
char body[64] = "";
if (f) {
fgets(body, sizeof(body), f);
fclose(f);
}
EXPECT(strcmp(body, "an artifact body\n") == 0, "byte for byte");
}
/* the store still sees exactly one session of that id, now nested */
EXPECT(cst_agent_store_path_for_id(g_ws, id, path, sizeof(path)) == 0 &&
strcmp(path, nested) == 0,
"the id now resolves to the directory form");
memset(&in, 0, sizeof(in));
EXPECT(cst_agent_store_info(g_ws, id, &in) == 0 &&
strcmp(in.title, "the old one") == 0 &&
strcmp(in.first_user, "from the old layout") == 0,
"and reads the same session back");
n = cst_agent_store_list(g_ws, ids, 16);
int count = 0;
for (int i = 0; i < n; i++)
if (strcmp(ids[i], id) == 0)
count++;
EXPECT(count == 1, "listed once, not twice");
/* the attachment of a migrated session is readable through the new path */
cst_attachment_t a;
memset(&a, 0, sizeof(a));
snprintf(a.rel, sizeof(a.rel), "art-1.md");
size_t len = 0;
char *body = cst_attach_read(nested, &a, &len, err, sizeof(err));
EXPECT(body && len == 17 && strncmp(body, "an artifact body", 16) == 0,
"the migrated payload reads back through the session");
free(body);
/* running it again changes nothing */
moved = -1;
failed = -1;
EXPECT(cst_agent_store_migrate(g_ws, &moved, &failed, err, sizeof(err)) == 0,
"a second migration runs");
EXPECT(moved == 0 && failed == 0, "and finds nothing left to move");
EXPECT(exists(nested), "the session is still there");
EXPECT(cst_agent_store_delete(g_ws, id) == 0, "delete a migrated session");
EXPECT(!exists(nested) && !exists(new_payload), "takes the payload with it");
}
/* The scan behind the session list (cst_agent_store_info): the count is a
* count of transcript lines, a line longer than one read block is still one
* line, and the first user message is only looked for in the head of the file
* -- a sidebar refresh is not allowed to read a whole transcript of tool
* output to find a greeting. */
static void test_info_scan(void) {
char id[64] = "";
EXPECT(cst_agent_store_new_id(id, sizeof(id)) == 0, "an id for the scan");
char path[1200];
EXPECT(cst_agent_store_path_for_id(g_ws, id, path, sizeof(path)) == 0,
"a session path to write into");
cst_session_t *s = cst_session_open(path);
EXPECT(s != NULL, "its transcript opens");
if (!s)
return;
cst_message_t m;
memset(&m, 0, sizeof(m));
m.role = CST_ROLE_USER;
m.content = (char *)"the first user line";
EXPECT(cst_session_append(s, &m) == 0, "the first user message goes in");
memset(&m, 0, sizeof(m));
m.role = CST_ROLE_ASSISTANT;
m.content = (char *)"an answer";
EXPECT(cst_session_append(s, &m) == 0, "and an answer");
/* One line that does not fit a read block: counted once, not once per block
* of it (which is what a wrong block count would report). */
const size_t big = 70 * 1024;
char *huge = malloc(big + 1);
EXPECT(huge != NULL, "a buffer for one very long line");
if (!huge)
return;
memset(huge, 'x', big);
huge[big] = 0;
memset(&m, 0, sizeof(m));
m.role = CST_ROLE_ASSISTANT;
m.content = huge;
EXPECT(cst_session_append(s, &m) == 0, "a line longer than a block goes in");
memset(&m, 0, sizeof(m));
m.role = CST_ROLE_ASSISTANT;
m.content = (char *)"a short one after it";
EXPECT(cst_session_append(s, &m) == 0, "and a short line after it");
cst_session_close(s);
cst_session_info_t in;
memset(&in, 0, sizeof(in));
EXPECT(cst_agent_store_info(g_ws, id, &in) == 0, "its info reads back");
EXPECT(in.messages == 4, "four lines counted, the long one once");
EXPECT(strcmp(in.first_user, "the first user line") == 0,
"and the first user line is the one it found");
/* Now a head of tool output wider than the window the search gets, with no
* user line in it: the greeting below stays unseen (and nothing read
* megabytes of transcript to look for it). */
char id2[64] = "";
EXPECT(cst_agent_store_new_id(id2, sizeof(id2)) == 0, "an id for the tail");
char path2[1200];
EXPECT(cst_agent_store_path_for_id(g_ws, id2, path2, sizeof(path2)) == 0,
"a second session path");
s = cst_session_open(path2);
EXPECT(s != NULL, "the second transcript opens");
if (!s) {
free(huge);
return;
}
const size_t mid = 60 * 1024;
char *chunk = malloc(mid + 1);
if (chunk) {
memset(chunk, 'y', mid);
chunk[mid] = 0;
for (int i = 0; i < 6; i++) { /* 360 KiB of tool output, no user line */
memset(&m, 0, sizeof(m));
m.role = CST_ROLE_TOOL;
m.content = chunk;
if (cst_session_append(s, &m) != 0)
break;
}
free(chunk);
}
memset(&m, 0, sizeof(m));
m.role = CST_ROLE_USER;
m.content = (char *)"a greeting far down the file";
EXPECT(cst_session_append(s, &m) == 0, "a user line after all that");
cst_session_close(s);
memset(&in, 0, sizeof(in));
EXPECT(cst_agent_store_info(g_ws, id2, &in) == 0, "its info reads too");
EXPECT(in.messages == 7, "seven lines, one per line");
EXPECT(in.first_user[0] == 0,
"a user line past the window is not called the first one");
free(huge);
}
int main(void) {
if (test_tmpdir(g_ws, sizeof(g_ws), "cst-store") != 0) {
perror("tmpdir");
return 1;
}
if (cst_agent_store_ensure(g_ws) != 0) {
fprintf(stderr, "cannot make the store\n");
return 1;
}
test_ids();
test_new_session();
test_old_store();
test_info_scan();
test_rmrf(g_ws);
if (nfail) {
fprintf(stderr, "%d check(s) failed\n", nfail);
return 1;
}
printf("test_store: OK\n");
return 0;
}