Working mode: --mode coding|general (CST_MODE, settings.json, /mode, WebUI run settings). general swaps the default framing for a general assistant one -- workflow, safety notes, tool wording (run_shell, the subagents) -- and /status shows it; minimal (the paper profile) stays byte-frozen. The prompt state carries the mode so a switch leaves a system note in history. Clipboard: clipboard_read / clipboard_write tools (16 MiB cap read, default 64 KiB window; writing is a WRITE permission with a preview) on pbcopy / wl-copy / xclip / pbpaste / wl-paste / xclip and the Win32 clipboard. In the TUI, /attach paste falls back to clipboard text when there is no image on it. PDF: a dependency-free text extractor (src/pdf_text.c): inflate, Flate/ASCIIHex/ASCII85, PNG/TIFF predictors, layout-independent object scan, object streams, page tree walk with inherited resources, content lexer (Tj/TJ/'/" and the Td/TD/T*/Tm spacing heuristics), per-font ToUnicode CMaps and inline images skipped. Encrypted files and pages with no text layer are refused with the reason. Attachments: /attach (a path or files dropped on the terminal) now take images, PDFs and text. A PDF is stored as its text layer with the mime still saying application/pdf and its page count; anything else has to be UTF-8 text. Documents travel inline as text parts, so CST_NO_IMAGES and text-only models no longer drop them, while images keep their old path. Tests: test_pdf_text (plain, bare and zlib-framed Flate, inline image, CID + ToUnicode, object stream, error paths), documents in test_attach, inlined documents in test_llm_body, mode in test_config.
536 lines
20 KiB
C
536 lines
20 KiB
C
#include "cJSON.h"
|
|
#include "cst_agent.h"
|
|
#include "cst_attach.h"
|
|
#include "cst_test_util.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);
|
|
}
|
|
|
|
/* 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_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;
|
|
}
|