Files
CST-agent/tests/test_llm_body.c
T
WHC 895b47274c v0.7.0: image input, model registry, prefix-cache savings
Images
- vision gate: text-only models get a note instead of image parts and no
  view_image tool; unknown models retry once without images on HTTP 400
- paste: Ctrl+V and /attach paste read the clipboard (wl-paste, xclip,
  pngpaste/osascript, Win32 PNG or CF_DIB converted to PNG); pasting image
  file paths attaches them
- MCP image content is stored in the session and sent with the tool result
- attach: rel path validation, base64 decode, /retry keeps images

Model registry
- built-in src/models_builtin.json embedded at build time, merged with
  <config>/models.json and cached GET /models metadata (context, vision,
  pricing); most specific rule wins per field
- /model labels, /status source, --list-models [provider]

Cache and cost
- stable request prefix (frozen tools, notes as messages), DeepSeek prefix
  completion, OpenAI/Anthropic cache hints, /cost and hit rate in the status
2026-10-08 20:34:09 +08:00

345 lines
13 KiB
C

#include "cJSON.h"
#include "cst_agent.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static int nfail;
#define EXPECT(cond, msg) \
do { \
if (!(cond)) { \
fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, msg); \
nfail++; \
} \
} while (0)
static cst_message_t msg(cst_role_t role, const char *content) {
cst_message_t m;
memset(&m, 0, sizeof(m));
m.role = role;
m.content = (char *)content;
return m;
}
static cJSON *build(const cst_message_t *msgs, int n, int tools,
size_t tool_cap) {
cst_llm_request_t r;
memset(&r, 0, sizeof(r));
r.model = "test-model";
r.msgs = msgs;
r.nmsgs = n;
r.enable_tools = tools;
r.tool_out_cap = tool_cap;
char *body = cst_llm_build_body(&r);
EXPECT(body != NULL, "body built");
cJSON *j = body ? cJSON_Parse(body) : NULL;
EXPECT(j != NULL, "body is valid JSON");
free(body);
return j;
}
static const char *content_at(const cJSON *root, int i) {
const cJSON *msgs = cJSON_GetObjectItemCaseSensitive(root, "messages");
const cJSON *m = cJSON_GetArrayItem(msgs, i);
const cJSON *c = cJSON_GetObjectItemCaseSensitive(m, "content");
return cJSON_IsString(c) ? c->valuestring : NULL;
}
static const char *role_at(const cJSON *root, int i) {
const cJSON *msgs = cJSON_GetObjectItemCaseSensitive(root, "messages");
const cJSON *m = cJSON_GetArrayItem(msgs, i);
const cJSON *r = cJSON_GetObjectItemCaseSensitive(m, "role");
return cJSON_IsString(r) ? r->valuestring : "";
}
static int nmsgs(const cJSON *root) {
return cJSON_GetArraySize(cJSON_GetObjectItemCaseSensitive(root, "messages"));
}
/* TAB / quotes / backslash / control bytes / CJK must survive verbatim
* (the old hand-rolled escaper turned a Makefile TAB into HTTP 400). */
static void test_escaping(void) {
const char *text = "all:\n\tgcc -o \"a b\" x.c \\\n\x01\x1f end 中文";
cst_message_t m[2] = {msg(CST_ROLE_SYSTEM, "sys"), msg(CST_ROLE_USER, text)};
cJSON *j = build(m, 2, 0, 0);
const char *c = content_at(j, 1);
EXPECT(c && strcmp(c, text) == 0, "user content roundtrips exactly");
EXPECT(!cJSON_GetObjectItemCaseSensitive(j, "tools"), "no tools when off");
const cJSON *so = cJSON_GetObjectItemCaseSensitive(j, "stream_options");
EXPECT(cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(so, "include_usage")),
"stream_options.include_usage");
cJSON_Delete(j);
}
static void test_invalid_utf8(void) {
const char text[] = "ok \xff\xfe bad \xe4\xb8 trunc";
cst_message_t m[1] = {msg(CST_ROLE_USER, text)};
cJSON *j = build(m, 1, 0, 0);
const char *c = content_at(j, 0);
EXPECT(c != NULL, "content present");
EXPECT(c && strlen(c) == strlen(text), "sanitize keeps byte length");
EXPECT(c && strstr(c, "ok ") == c, "valid prefix kept");
cJSON_Delete(j);
}
static void test_tool_calls_and_null_content(void) {
cst_message_t m[3];
m[0] = msg(CST_ROLE_USER, "go");
m[1] = msg(CST_ROLE_ASSISTANT, "");
m[1].tool_calls_json =
"[{\"id\":\"c1\",\"type\":\"function\",\"function\":{\"name\":"
"\"read_file\",\"arguments\":\"{\\\"path\\\":\\\"a\\tb.c\\\"}\"}}]";
m[2] = msg(CST_ROLE_TOOL, "[tool read_file id=c1 exit=0]\nhello");
snprintf(m[2].tool_call_id, sizeof(m[2].tool_call_id), "c1");
cJSON *j = build(m, 3, 1, 0);
const cJSON *msgs = cJSON_GetObjectItemCaseSensitive(j, "messages");
const cJSON *a = cJSON_GetArrayItem(msgs, 1);
EXPECT(cJSON_IsNull(cJSON_GetObjectItemCaseSensitive(a, "content")),
"assistant content null with tool_calls");
const cJSON *tc = cJSON_GetObjectItemCaseSensitive(a, "tool_calls");
EXPECT(cJSON_IsArray(tc) && cJSON_GetArraySize(tc) == 1, "tool_calls kept");
const cJSON *t = cJSON_GetArrayItem(msgs, 2);
EXPECT(strcmp(role_at(j, 2), "tool") == 0, "tool role");
const cJSON *tid = cJSON_GetObjectItemCaseSensitive(t, "tool_call_id");
EXPECT(cJSON_IsString(tid) && strcmp(tid->valuestring, "c1") == 0,
"tool_call_id");
const cJSON *tools = cJSON_GetObjectItemCaseSensitive(j, "tools");
EXPECT(cJSON_IsArray(tools) && cJSON_GetArraySize(tools) >= 4,
"tools[] schema present");
const cJSON *choice = cJSON_GetObjectItemCaseSensitive(j, "tool_choice");
EXPECT(cJSON_IsString(choice) && strcmp(choice->valuestring, "auto") == 0,
"tool_choice auto");
cJSON_Delete(j);
}
static void test_merge_and_orphan_tool(void) {
cst_message_t m[3];
m[0] = msg(CST_ROLE_USER, "first");
m[1] = msg(CST_ROLE_TOOL, "orphan output"); /* no id -> user text */
m[2] = msg(CST_ROLE_USER, "third");
cJSON *j = build(m, 3, 0, 0);
EXPECT(nmsgs(j) == 1, "consecutive user messages merged");
const char *c = content_at(j, 0);
EXPECT(c && strstr(c, "first") && strstr(c, "orphan output") &&
strstr(c, "third"),
"merged content keeps all parts in order");
cJSON_Delete(j);
}
static void test_tool_cap_head_tail(void) {
size_t n = 100000;
char *big = malloc(n + 1);
memset(big, 'x', n);
memcpy(big, "HEAD", 4);
memcpy(big + n - 4, "TAIL", 4);
big[n] = 0;
cst_message_t m[3];
m[0] = msg(CST_ROLE_USER, "go");
m[1] = msg(CST_ROLE_ASSISTANT, "");
m[1].tool_calls_json = "[{\"id\":\"c1\",\"type\":\"function\",\"function\":"
"{\"name\":\"run_shell\",\"arguments\":\"{}\"}}]";
m[2] = msg(CST_ROLE_TOOL, big);
snprintf(m[2].tool_call_id, sizeof(m[2].tool_call_id), "c1");
cJSON *j = build(m, 3, 1, 1000);
const char *c = content_at(j, 2);
EXPECT(c && strlen(c) < 1200, "tool output capped");
EXPECT(c && strncmp(c, "HEAD", 4) == 0, "head kept");
EXPECT(c && strcmp(c + strlen(c) - 4, "TAIL") == 0, "tail kept");
EXPECT(c && strstr(c, "omitted"), "omission note");
cJSON_Delete(j);
/* user text (e.g. text-protocol result frames) is never capped */
cst_message_t u[1] = {msg(CST_ROLE_USER, big)};
j = build(u, 1, 0, 1000);
c = content_at(j, 0);
EXPECT(c && strlen(c) == n, "user text not capped");
cJSON_Delete(j);
free(big);
}
static cJSON *build_req(const cst_llm_request_t *r) {
char *body = cst_llm_build_body(r);
EXPECT(body != NULL, "body built");
cJSON *j = body ? cJSON_Parse(body) : NULL;
EXPECT(j != NULL, "body is valid JSON");
free(body);
return j;
}
static const cJSON *msg_at(const cJSON *root, int i) {
return cJSON_GetArrayItem(cJSON_GetObjectItemCaseSensitive(root, "messages"),
i);
}
static const cJSON *cache_mark(const cJSON *m) {
const cJSON *c = cJSON_GetObjectItemCaseSensitive(m, "content");
if (!cJSON_IsArray(c))
return NULL;
const cJSON *last = cJSON_GetArrayItem(c, cJSON_GetArraySize(c) - 1);
return cJSON_GetObjectItemCaseSensitive(last, "cache_control");
}
static void test_reasoning_and_prefix(void) {
cst_message_t m[4];
m[0] = msg(CST_ROLE_SYSTEM, "sys");
m[1] = msg(CST_ROLE_USER, "go");
m[2] = msg(CST_ROLE_ASSISTANT, "partial answer");
m[2].reasoning = "thinking it over";
m[3] = msg(CST_ROLE_ASSISTANT, "cut off here");
m[3].prefix = 1;
cst_llm_request_t r;
memset(&r, 0, sizeof(r));
r.model = "deepseek-flash";
r.msgs = m;
r.nmsgs = 4;
cJSON *j = build_req(&r);
EXPECT(!cJSON_GetObjectItemCaseSensitive(msg_at(j, 2), "reasoning_content"),
"reasoning only when asked for");
const cJSON *p = cJSON_GetObjectItemCaseSensitive(msg_at(j, 3), "prefix");
EXPECT(cJSON_IsTrue(p), "prefix:true on the continued message");
EXPECT(!cJSON_GetObjectItemCaseSensitive(msg_at(j, 2), "prefix"),
"no prefix on history");
cJSON_Delete(j);
r.send_reasoning = 1;
j = build_req(&r);
const cJSON *rc =
cJSON_GetObjectItemCaseSensitive(msg_at(j, 2), "reasoning_content");
EXPECT(cJSON_IsString(rc) && strcmp(rc->valuestring, "thinking it over") == 0,
"reasoning_content passed back");
EXPECT(!cJSON_GetObjectItemCaseSensitive(j, "prompt_cache_key"),
"no cache key without a cache style");
cJSON_Delete(j);
}
static void test_cache_hints(void) {
cst_message_t m[4];
m[0] = msg(CST_ROLE_SYSTEM, "sys");
m[1] = msg(CST_ROLE_USER, "first");
m[2] = msg(CST_ROLE_ASSISTANT, "reply");
m[3] = msg(CST_ROLE_USER, "second");
cst_llm_request_t r;
memset(&r, 0, sizeof(r));
r.model = "gpt-4.1";
r.msgs = m;
r.nmsgs = 4;
r.cache_style = CST_CACHE_OPENAI;
r.cache_key = "sess-1";
cJSON *j = build_req(&r);
const cJSON *k = cJSON_GetObjectItemCaseSensitive(j, "prompt_cache_key");
EXPECT(cJSON_IsString(k) && strcmp(k->valuestring, "sess-1") == 0,
"prompt_cache_key");
EXPECT(content_at(j, 3) && !cache_mark(msg_at(j, 3)),
"OpenAI style leaves content as strings");
cJSON_Delete(j);
r.model = "anthropic/claude-sonnet-4";
r.cache_style = CST_CACHE_ANTHROPIC;
r.cache_ttl = "1h";
j = build_req(&r);
EXPECT(!cJSON_GetObjectItemCaseSensitive(j, "prompt_cache_key"),
"no prompt_cache_key for Anthropic");
const cJSON *cs = cache_mark(msg_at(j, 0));
const cJSON *cl = cache_mark(msg_at(j, 3));
const cJSON *cu = cache_mark(msg_at(j, 1));
EXPECT(cs && cl && cu, "breakpoints on system, newest, previous user");
EXPECT(!cache_mark(msg_at(j, 2)), "no breakpoint on the assistant reply");
const cJSON *ttl = cJSON_GetObjectItemCaseSensitive(cl, "ttl");
EXPECT(cJSON_IsString(ttl) && strcmp(ttl->valuestring, "1h") == 0, "ttl");
const cJSON *parts =
cJSON_GetObjectItemCaseSensitive(msg_at(j, 3), "content");
const cJSON *txt =
cJSON_GetObjectItemCaseSensitive(cJSON_GetArrayItem(parts, 0), "text");
EXPECT(cJSON_IsString(txt) && strcmp(txt->valuestring, "second") == 0,
"marked content keeps its text");
cJSON_Delete(j);
}
static void test_tools_snapshot(void) {
cst_message_t m[1] = {msg(CST_ROLE_USER, "go")};
cst_llm_request_t r;
memset(&r, 0, sizeof(r));
r.model = "test-model";
r.msgs = m;
r.nmsgs = 1;
r.enable_tools = 1;
r.tools_json = "[{\"type\":\"function\",\"function\":{\"name\":\"only_me\","
"\"parameters\":{\"type\":\"object\"}}}]";
cJSON *j = build_req(&r);
const cJSON *tools = cJSON_GetObjectItemCaseSensitive(j, "tools");
EXPECT(cJSON_IsArray(tools) && cJSON_GetArraySize(tools) == 1,
"tools[] taken from the snapshot");
const cJSON *nm = cJSON_GetObjectItemCaseSensitive(
cJSON_GetObjectItemCaseSensitive(cJSON_GetArrayItem(tools, 0),
"function"),
"name");
EXPECT(cJSON_IsString(nm) && strcmp(nm->valuestring, "only_me") == 0,
"snapshot tool name");
cJSON_Delete(j);
}
static void test_images_off(void) {
cst_attachment_t img;
memset(&img, 0, sizeof(img));
snprintf(img.id, sizeof(img.id), "img-1");
snprintf(img.rel, sizeof(img.rel), "img-1.png");
snprintf(img.name, sizeof(img.name), "shot.png");
snprintf(img.mime, sizeof(img.mime), "image/png");
img.width = 640;
img.height = 480;
img.bytes = 2048;
cst_message_t m[4];
m[0] = msg(CST_ROLE_USER, "what is this");
m[0].attach = &img;
m[0].nattach = 1;
m[1] = msg(CST_ROLE_ASSISTANT, "");
m[1].tool_calls_json = "[{\"id\":\"c1\",\"type\":\"function\",\"function\":"
"{\"name\":\"view_image\",\"arguments\":\"{}\"}}]";
m[2] = msg(CST_ROLE_TOOL, "view_image shot.png");
snprintf(m[2].tool_call_id, sizeof(m[2].tool_call_id), "c1");
m[2].attach = &img;
m[2].nattach = 1;
m[3] = msg(CST_ROLE_USER, "and now?");
cst_llm_request_t r;
memset(&r, 0, sizeof(r));
r.model = "deepseek-flash";
r.msgs = m;
r.nmsgs = 4;
r.images_off = 1;
r.session_path = "/nonexistent/s.jsonl";
cJSON *j = build_req(&r);
char *s = cJSON_PrintUnformatted(j);
EXPECT(s && !strstr(s, "image_url"), "no image parts when images are off");
free(s);
const char *c0 = content_at(j, 0);
EXPECT(c0 && strstr(c0, "what is this") &&
strstr(c0, "[image \"shot.png\" (640x480") &&
strstr(c0, "not sent: this model does not take images"),
"user image becomes a plain-text note");
const char *c2 = content_at(j, 2);
EXPECT(c2 && strstr(c2, "view_image shot.png") && strstr(c2, "not sent"),
"tool image noted inside the tool result");
EXPECT(nmsgs(j) == 4, "no extra user message for deferred tool images");
cJSON_Delete(j);
}
int main(void) {
test_images_off();
test_reasoning_and_prefix();
test_cache_hints();
test_tools_snapshot();
test_escaping();
test_invalid_utf8();
test_tool_calls_and_null_content();
test_merge_and_orphan_tool();
test_tool_cap_head_tail();
if (nfail) {
fprintf(stderr, "test_llm_body: %d failed\n", nfail);
return 1;
}
printf("test_llm_body: OK\n");
return 0;
}