#include "cJSON.h" #include "cst_agent.h" #include "cst_attach.h" #include "cst_test_util.h" #include "cst_hermit_glyph.h" #include #include #include 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); } /* Settings -> Advanced: the generation parameters go on the wire only when * they were set. An absent field is the provider's own default, which * "temperature": 0 would not be. */ static void test_gen_params(void) { cst_message_t m[1] = {msg(CST_ROLE_USER, "hi")}; cst_llm_request_t r; memset(&r, 0, sizeof(r)); r.model = "test-model"; r.msgs = m; r.nmsgs = 1; r.temperature = 0.2; r.top_p = 0.9; r.max_tokens = 4096; char *body = cst_llm_build_body(&r); EXPECT(body != NULL, "body with parameters"); cJSON *j = body ? cJSON_Parse(body) : NULL; free(body); const cJSON *t = cJSON_GetObjectItemCaseSensitive(j, "temperature"); const cJSON *p = cJSON_GetObjectItemCaseSensitive(j, "top_p"); const cJSON *mt = cJSON_GetObjectItemCaseSensitive(j, "max_tokens"); EXPECT(cJSON_IsNumber(t) && t->valuedouble > 0.19 && t->valuedouble < 0.21, "temperature is sent as a number"); EXPECT(cJSON_IsNumber(p) && cJSON_IsNumber(mt) && mt->valueint == 4096, "top_p and max_tokens are sent"); cJSON_Delete(j); /* unset: none of the three is in the body at all */ cJSON *plain = build(m, 1, 0, 0); EXPECT(!cJSON_GetObjectItemCaseSensitive(plain, "temperature") && !cJSON_GetObjectItemCaseSensitive(plain, "top_p") && !cJSON_GetObjectItemCaseSensitive(plain, "max_tokens"), "an unset parameter is not sent at all"); cJSON_Delete(plain); } 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"); /* The exact set depends on the host: a tool whose platform probe fails is * offered nowhere, so look for a core tool instead of counting. */ char *tj = tools ? cJSON_PrintUnformatted(tools) : NULL; EXPECT(cJSON_IsArray(tools) && cJSON_GetArraySize(tools) >= 4 && tj && strstr(tj, "read_file") != NULL, "tools[] schema present"); free(tj); 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_kimi_wire_format(void) { const char *old_no_cache = hermit_getenv("NO_CACHE_HINTS"); const char *old_ttl = hermit_getenv("KIMI_CACHE_TTL"); char *saved_no_cache = old_no_cache ? strdup(old_no_cache) : NULL; char *saved_ttl = old_ttl ? strdup(old_ttl) : NULL; setenv("CST_NO_CACHE_HINTS", "0", 1); unsetenv("CST_KIMI_CACHE_TTL"); cst_message_t m[3]; m[0] = msg(CST_ROLE_USER, "think"); m[1] = msg(CST_ROLE_ASSISTANT, "answer"); m[1].reasoning = "private reasoning"; m[2] = msg(CST_ROLE_ASSISTANT, "continue"); m[2].prefix = 1; cst_llm_request_t r; memset(&r, 0, sizeof(r)); r.api_base = "https://api.moonshot.ai/v1"; r.model = "kimi-k2.6"; r.msgs = m; r.nmsgs = 3; r.send_reasoning = 1; cJSON *j = build_req(&r); const cJSON *thinking = cJSON_GetObjectItemCaseSensitive(j, "thinking"); EXPECT(cJSON_IsString(cJSON_GetObjectItemCaseSensitive(thinking, "type")) && strcmp(cJSON_GetObjectItemCaseSensitive(thinking, "type")->valuestring, "enabled") == 0, "Kimi K2 thinking enabled"); EXPECT(cJSON_IsString(cJSON_GetObjectItemCaseSensitive(thinking, "keep")) && strcmp(cJSON_GetObjectItemCaseSensitive(thinking, "keep")->valuestring, "all") == 0, "Kimi K2 keeps reasoning"); EXPECT(!cJSON_GetObjectItemCaseSensitive(j, "reasoning_effort"), "Kimi K2 omits reasoning_effort"); const cJSON *cache = cJSON_GetObjectItemCaseSensitive(j, "prompt_cache_options"); EXPECT(cJSON_IsString(cJSON_GetObjectItemCaseSensitive(cache, "ttl")) && strcmp(cJSON_GetObjectItemCaseSensitive(cache, "ttl")->valuestring, "1h") == 0, "Kimi implicit cache uses the long TTL by default"); const cJSON *partial = cJSON_GetObjectItemCaseSensitive(msg_at(j, 2), "partial"); EXPECT(cJSON_IsTrue(partial) && !cJSON_GetObjectItemCaseSensitive(msg_at(j, 2), "prefix"), "Kimi assistant prefill uses partial"); cJSON_Delete(j); r.model = "kimi-k3"; r.reasoning_effort = "high"; j = build_req(&r); EXPECT(cJSON_IsString(cJSON_GetObjectItemCaseSensitive(j, "reasoning_effort")) && strcmp(cJSON_GetObjectItemCaseSensitive(j, "reasoning_effort")->valuestring, "high") == 0, "Kimi K3 uses reasoning_effort"); EXPECT(!cJSON_GetObjectItemCaseSensitive(j, "thinking"), "Kimi K3 omits K2 thinking object"); cJSON_Delete(j); if (saved_no_cache) { setenv("CST_NO_CACHE_HINTS", saved_no_cache, 1); free(saved_no_cache); } else { unsetenv("CST_NO_CACHE_HINTS"); } if (saved_ttl) { setenv("CST_KIMI_CACHE_TTL", saved_ttl, 1); free(saved_ttl); } else { unsetenv("CST_KIMI_CACHE_TTL"); } } 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); } /* Every element of every content array must be a typed part object. */ static int parts_typed(const cJSON *root) { const cJSON *m; cJSON_ArrayForEach(m, cJSON_GetObjectItemCaseSensitive(root, "messages")) { const cJSON *c = cJSON_GetObjectItemCaseSensitive(m, "content"); if (!cJSON_IsArray(c)) continue; const cJSON *p; cJSON_ArrayForEach(p, c) { const cJSON *t = cJSON_GetObjectItemCaseSensitive(p, "type"); if (!cJSON_IsObject(p) || !cJSON_IsString(t)) return 0; } } return 1; } static void test_images_on(void) { char dir[1024], sess[1100], err[256]; if (test_tmpdir(dir, sizeof(dir), "cst-body") != 0) { EXPECT(0, "scratch dir"); return; } snprintf(sess, sizeof(sess), "%s/s.jsonl", dir); static const unsigned char png[32] = {0x89, 'P', 'N', 'G', '\r', '\n', 0x1A, '\n', 0, 0, 0, 13, 'I', 'H', 'D', 'R', 0, 0, 0, 2, 0, 0, 0, 2}; cst_attachment_t *img = cst_attach_import_bytes(sess, "shot.png", (const char *)png, sizeof(png), err, sizeof(err)); EXPECT(img != NULL, "image stored"); if (!img) return; cst_attachment_t gone = *img; snprintf(gone.rel, sizeof(gone.rel), "missing.png"); cst_message_t m[5]; 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?"); m[4] = msg(CST_ROLE_USER, ""); m[4].attach = &gone; m[4].nattach = 1; cst_llm_request_t r; memset(&r, 0, sizeof(r)); r.model = "deepseek-flash"; r.msgs = m; r.nmsgs = 5; r.session_path = sess; cJSON *j = build_req(&r); EXPECT(parts_typed(j), "content arrays hold only typed parts"); const cJSON *p0 = cJSON_GetObjectItemCaseSensitive( cJSON_GetArrayItem(cJSON_GetObjectItemCaseSensitive(j, "messages"), 0), "content"); const cJSON *t0 = cJSON_GetObjectItemCaseSensitive( cJSON_GetArrayItem(p0, 0), "text"); const cJSON *u1 = cJSON_GetObjectItemCaseSensitive( cJSON_GetObjectItemCaseSensitive(cJSON_GetArrayItem(p0, 1), "image_url"), "url"); EXPECT(cJSON_IsString(t0) && strcmp(t0->valuestring, "what is this") == 0, "user text is a text part"); EXPECT(cJSON_IsString(u1) && strncmp(u1->valuestring, "data:image/png;base64,", 22) == 0, "image is a data URL part"); char *s = cJSON_PrintUnformatted(j); EXPECT(s && strstr(s, "and now?") && strstr(s, "not sent"), "follow-up text and the missing-image note are parts too"); free(s); cJSON_Delete(j); cst_attach_free(img); } /* Every text part of message i, joined (malloc'd, "" when there is none). */ static char *text_of(const cJSON *root, int i) { const cJSON *m = cJSON_GetArrayItem(cJSON_GetObjectItemCaseSensitive(root, "messages"), i); const cJSON *c = cJSON_GetObjectItemCaseSensitive(m, "content"); if (cJSON_IsString(c)) { size_t n = strlen(c->valuestring); char *out = malloc(n + 1); if (out) memcpy(out, c->valuestring, n + 1); return out; } size_t len = 1; const cJSON *p, *t, *s; cJSON_ArrayForEach(p, c) { t = cJSON_GetObjectItemCaseSensitive(p, "type"); s = cJSON_GetObjectItemCaseSensitive(p, "text"); if (cJSON_IsString(t) && strcmp(t->valuestring, "text") == 0 && cJSON_IsString(s)) len += strlen(s->valuestring) + 1; } char *out = calloc(1, len); if (!out) return NULL; cJSON_ArrayForEach(p, c) { t = cJSON_GetObjectItemCaseSensitive(p, "type"); s = cJSON_GetObjectItemCaseSensitive(p, "text"); if (cJSON_IsString(t) && strcmp(t->valuestring, "text") == 0 && cJSON_IsString(s)) { strcat(out, s->valuestring); strcat(out, "\n"); } } return out; } /* A document is inlined as text, so a text-only model and a message that * also carries an image both keep it; the image is what becomes a note. */ static void test_document_parts(void) { char dir[1024], sess[1100], err[256], src[1200]; if (test_tmpdir(dir, sizeof(dir), "cst-body-doc") != 0) { EXPECT(0, "scratch dir"); return; } snprintf(sess, sizeof(sess), "%s/s.jsonl", dir); snprintf(src, sizeof(src), "%s/paper.pdf", dir); static const char PDF[] = "%PDF-1.4\n" "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n" "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n" "3 0 obj\n<< /Type /Page /Parent 2 0 R /Resources << /Font << /F1 4 0 R " ">> >> /Contents 5 0 R >>\nendobj\n" "4 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\n" "endobj\n" "5 0 obj\n<< >>\nstream\n" "BT /F1 12 Tf 72 720 Td (Inlined paper text) Tj ET\n" "endstream\nendobj\n" "trailer\n<< /Root 1 0 R >>\n%%EOF\n"; FILE *f = fopen(src, "wb"); EXPECT(f != NULL, "write the PDF"); if (!f) return; fwrite(PDF, 1, strlen(PDF), f); fclose(f); cst_attachment_t *doc = cst_attach_import_file(sess, src, err, sizeof(err)); EXPECT(doc != NULL, "PDF stored"); if (!doc) return; static const unsigned char png[32] = {0x89, 'P', 'N', 'G', '\r', '\n', 0x1A, '\n', 0, 0, 0, 13, 'I', 'H', 'D', 'R', 0, 0, 0, 2, 0, 0, 0, 2}; cst_attachment_t *img = cst_attach_import_bytes( sess, "shot.png", (const char *)png, sizeof(png), err, sizeof(err)); EXPECT(img != NULL, "image stored"); if (!img) { cst_attach_free(doc); return; } cst_attachment_t both[2] = {*doc, *img}; cst_message_t m[2]; m[0] = msg(CST_ROLE_USER, "what does this say?"); m[0].attach = both; m[0].nattach = 2; m[1] = msg(CST_ROLE_USER, "thanks"); cst_llm_request_t r; memset(&r, 0, sizeof(r)); r.model = "deepseek-flash"; r.msgs = m; r.nmsgs = 2; r.images_off = 1; /* a text-only model */ r.session_path = sess; cJSON *j = build_req(&r); char *txt = text_of(j, 0); EXPECT(txt && strstr(txt, "[document \"paper.pdf\"") && strstr(txt, "Inlined paper text") && strstr(txt, "[-- end of document --]"), "the PDF text is inlined even when images are off"); EXPECT(txt && strstr(txt, "[image \"shot.png\"") && strstr(txt, "not sent"), "the image beside it is still only a note"); free(txt); char *s = cJSON_PrintUnformatted(j); EXPECT(s && !strstr(s, "image_url"), "no image part with images off"); free(s); cJSON_Delete(j); cst_attach_free(doc); cst_attach_free(img); } int main(void) { test_images_off(); test_images_on(); test_document_parts(); test_reasoning_and_prefix(); test_cache_hints(); test_kimi_wire_format(); test_tools_snapshot(); test_gen_params(); 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; }