/* 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 /.agent/sessions//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 * .jsonl + .title + attachments//), 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 #include #include 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 /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 /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 .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 /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; }