329 lines · 9.3 KB · C
RawDownload
// SPDX-License-Identifier: ISC/* * Unit tests for libclmsession + the lossless message round-trip in * libclm/history.c. Pure: no network, no event loop -- a mkdtemp'd * session dir stands in for $XDG_STATE_HOME. */#include <errno.h>#include <fcntl.h>#include <stdio.h>#include <stdlib.h>#include <string.h>#include <unistd.h> #include <cjson/cJSON.h> #include "clm/history.h"#include "clm/session.h" static int failures; #define CHECK(cond, msg)                                                       \	do {                                                                   \		if (!(cond)) {                                                 \			fprintf(stderr, "FAIL: %s (%s:%d)\n", (msg), __FILE__, \			        __LINE__);                                     \			failures++;                                            \		}                                                              \	} while (0) /* Build the canonical 5-message conversation used by several tests. */static voidfill_history(struct clm_history *h){	struct clm_message *m; 	clm_history_add_system(h, "you are test", NULL);	clm_history_add_user(h, "hello\nworld \"quoted\" \xf0\x9f\xa6\xb4",	                     NULL);	m = clm_history_add_assistant_tool_calls(h);	clm_message_add_tool_call(m, "call_1", "shell_exec",	                          "{\"cmd\":\"ls\"}");	clm_message_add_tool_call(m, "call_2", "read_file",	                          "{\"path\":\"/etc/motd\"}");	clm_history_add_tool_result(h, "call_1", "shell_exec", "file1\nfile2",	                            strlen("file1\nfile2"), NULL);	clm_history_add_assistant_text(h, "there are two files", NULL);} static size_thistory_len(const struct clm_history *h){	const struct clm_message *m;	size_t n = 0; 	TAILQ_FOREACH(m, h, entries)	n++;	return n;} static voidtest_message_roundtrip(void){	struct clm_history in, out;	const struct clm_message *m, *r; 	clm_history_init(&in);	clm_history_init(&out);	fill_history(&in); 	TAILQ_FOREACH(m, &in, entries)	{		cJSON *obj = clm_message_to_json_full(m, NULL);		CHECK(obj != NULL, "serialize message");		CHECK(clm_message_from_json(&out, obj, NULL) == 0,		      "deserialize message");		cJSON_Delete(obj);	} 	CHECK(history_len(&out) == history_len(&in), "same message count"); 	r = TAILQ_FIRST(&out);	CHECK(r->role == CLM_ROLE_SYSTEM, "system role survives");	CHECK(strcmp(r->content, "you are test") == 0, "system content"); 	r = TAILQ_NEXT(r, entries);	CHECK(r->role == CLM_ROLE_USER, "user role survives");	CHECK(strcmp(r->content, "hello\nworld \"quoted\" \xf0\x9f\xa6\xb4") ==	          0,	      "newlines/quotes/utf-8 survive"); 	r = TAILQ_NEXT(r, entries);	CHECK(r->role == CLM_ROLE_ASSISTANT, "assistant tool-call role");	CHECK(r->content == NULL, "tool-call message has no content");	{		const struct clm_tool_call *tc = TAILQ_FIRST(&r->tool_calls);		CHECK(tc != NULL && strcmp(tc->id, "call_1") == 0 &&		          strcmp(tc->name, "shell_exec") == 0 &&		          strcmp(tc->args, "{\"cmd\":\"ls\"}") == 0,		      "first tool call survives");		tc = tc != NULL ? TAILQ_NEXT(tc, entries) : NULL;		CHECK(tc != NULL && strcmp(tc->id, "call_2") == 0,		      "second tool call survives");	} 	r = TAILQ_NEXT(r, entries);	CHECK(r->role == CLM_ROLE_TOOL, "tool result role");	CHECK(r->tool_call_id != NULL && strcmp(r->tool_call_id, "call_1") == 0,	      "tool_call_id survives");	CHECK(r->tool_name != NULL && strcmp(r->tool_name, "shell_exec") == 0,	      "tool_name survives (the field the wire format drops)"); 	r = TAILQ_NEXT(r, entries);	CHECK(r->role == CLM_ROLE_ASSISTANT, "assistant text role");	CHECK(strcmp(r->content, "there are two files") == 0,	      "assistant text survives"); 	clm_history_free(&in);	clm_history_free(&out);} static voidtest_from_json_rejects_garbage(void){	struct clm_history h;	cJSON *obj; 	clm_history_init(&h); 	CHECK(clm_message_from_json(&h, NULL, NULL) == -EINVAL, "NULL obj"); 	obj = cJSON_Parse("{\"role\":\"emperor\",\"content\":\"hi\"}");	CHECK(clm_message_from_json(&h, obj, NULL) == -EINVAL, "bad role");	cJSON_Delete(obj); 	obj = cJSON_Parse("{\"content\":\"hi\"}");	CHECK(clm_message_from_json(&h, obj, NULL) == -EINVAL, "no role");	cJSON_Delete(obj); 	obj = cJSON_Parse("{\"role\":\"assistant\",\"tool_calls\":\"nope\"}");	CHECK(clm_message_from_json(&h, obj, NULL) == -EINVAL,	      "non-array tool_calls");	cJSON_Delete(obj); 	CHECK(history_len(&h) == 0, "nothing appended on rejects");	clm_history_free(&h);} static voidtest_session_file_roundtrip(const char *dir){	struct clm_session *s = NULL;	struct clm_history in, out;	struct clm_message *m;	cJSON *meta = NULL;	char id[128]; 	clm_history_init(&in);	clm_history_init(&out);	fill_history(&in); 	CHECK(clm_session_create(dir, "test-model", "test-provider",	                         "test-agent", &s) == 0,	      "create session");	CHECK(clm_session_is_empty(s), "fresh session is empty"); 	TAILQ_FOREACH(m, &in, entries)	{		if (m->role == CLM_ROLE_SYSTEM)			continue;		CHECK(clm_session_append(s, m, NULL) == 0, "append message");	}	CHECK(!clm_session_is_empty(s), "appended session not empty"); 	(void)snprintf(id, sizeof(id), "%s", clm_session_id(s));	clm_session_free(s); 	CHECK(clm_session_load(dir, id, &out, &meta) == 0, "load session");	CHECK(history_len(&out) == history_len(&in) - 1,	      "all non-system messages loaded");	CHECK(meta != NULL, "meta line parsed");	if (meta != NULL) {		CHECK(		    strcmp(cJSON_GetStringValue(		               cJSON_GetObjectItemCaseSensitive(meta, "model")),		           "test-model") == 0,		    "meta model");		cJSON_Delete(meta);	} 	/* Reopen for appending: keeps counting as non-empty. */	s = NULL;	CHECK(clm_session_open(dir, id, &s) == 0, "reopen session");	CHECK(!clm_session_is_empty(s), "reopened session not empty");	CHECK(clm_session_discard(s) == 0, "discard deletes");	CHECK(clm_session_load(dir, id, &out, NULL) == -ENOENT,	      "discarded session gone"); 	clm_history_free(&in);	clm_history_free(&out);} static voidappend_raw(const char *dir, const char *id, const char *bytes){	char path[512];	FILE *f; 	(void)snprintf(path, sizeof(path), "%s/%s.jsonl", dir, id);	f = fopen(path, "a");	if (f != NULL) {		fputs(bytes, f);		fclose(f);	}} static voidtest_crash_tolerance(const char *dir){	struct clm_session *s = NULL;	struct clm_history h;	char id[128]; 	clm_history_init(&h); 	CHECK(clm_session_create(dir, NULL, NULL, NULL, &s) == 0,	      "create session");	(void)snprintf(id, sizeof(id), "%s", clm_session_id(s));	{		struct clm_history tmp;		struct clm_message *m;		clm_history_init(&tmp);		m = clm_history_add_user(&tmp, "hi", NULL);		CHECK(clm_session_append(s, m, NULL) == 0, "append");		clm_history_free(&tmp);	}	clm_session_free(s); 	/* Garbage middle line + truncated (no newline) final line: both	 * must be skipped, the good message kept. */	append_raw(dir, id, "this is not json\n");	append_raw(dir, id, "{\"type\":\"msg\",\"role\":\"assistant\",\"con"); 	CHECK(clm_session_load(dir, id, &h, NULL) == 0,	      "load survives garbage");	CHECK(history_len(&h) == 1, "only the good message loaded");	clm_history_free(&h); 	/* A too-new meta version must refuse to load. */	clm_history_init(&h);	append_raw(dir, id, "\n{\"type\":\"meta\",\"v\":99}\n");	CHECK(clm_session_load(dir, id, &h, NULL) == -EPROTONOSUPPORT,	      "newer format version rejected");	clm_history_free(&h);} static voidtest_id_validation(const char *dir){	struct clm_session *s = NULL;	struct clm_history h; 	clm_history_init(&h);	CHECK(clm_session_open(dir, "../evil", &s) == -EINVAL,	      "path traversal id rejected on open");	CHECK(clm_session_load(dir, "a/b", &h, NULL) == -EINVAL,	      "slash id rejected on load");	CHECK(clm_session_open(dir, "", &s) == -EINVAL, "empty id rejected");	CHECK(clm_session_open(dir, "no-such-session", &s) == -ENOENT,	      "missing session is ENOENT");	clm_history_free(&h);} static voidtest_listing(const char *dir){	struct clm_session_info *infos = NULL;	size_t n = 0; 	/* Two fresh sessions with one user message each. */	for (int i = 0; i < 2; i++) {		struct clm_session *s = NULL;		struct clm_history tmp;		struct clm_message *m; 		CHECK(clm_session_create(dir, "m", NULL, NULL, &s) == 0,		      "create listed session");		clm_history_init(&tmp);		m = clm_history_add_user(&tmp, "list me", NULL);		CHECK(clm_session_append(s, m, NULL) == 0, "append");		clm_history_free(&tmp);		clm_session_free(s);	} 	CHECK(clm_session_list(dir, &infos, &n) == 0, "list sessions");	CHECK(n >= 2, "listing sees the sessions created above");	for (size_t i = 1; i < n; i++)		CHECK(infos[i - 1].created >= infos[i].created, "newest first");	for (size_t i = 0; i < n; i++)		CHECK(infos[i].id != NULL, "every row has an id");	clm_session_list_free(infos, n); 	CHECK(clm_session_list("/nonexistent-dir-xyzzy", &infos, &n) == 0 &&	          n == 0,	      "missing dir is an empty listing");} intmain(void){	char dir[] = "/tmp/clm-session-test-XXXXXX"; 	if (mkdtemp(dir) == NULL) {		perror("mkdtemp");		return 1;	} 	test_message_roundtrip();	test_from_json_rejects_garbage();	test_session_file_roundtrip(dir);	test_crash_tolerance(dir);	test_id_validation(dir);	test_listing(dir); 	if (failures == 0)		printf("test_session: all tests passed\n");	else		printf("test_session: %d failure(s)\n", failures);	return failures == 0 ? 0 : 1;}