summaryrefslogtreecommitdiffstats
path: root/tests/test_provider.cpp
diff options
context:
space:
mode:
authorEthan Werner <mail@ethanwerner.com>2026-10-10 20:55:33 +0000
committerEthan Werner <mail@ethanwerner.com>2026-10-10 20:56:50 +0000
commitf8423ebea413605b42de23f5dd06c5479d3fa1dd (patch)
tree2612a5eb026c59e7aae01f10a1cf62f2ee43042c /tests/test_provider.cpp
initial commitHEADmaster
Diffstat (limited to 'tests/test_provider.cpp')
-rw-r--r--tests/test_provider.cpp647
1 files changed, 647 insertions, 0 deletions
diff --git a/tests/test_provider.cpp b/tests/test_provider.cpp
new file mode 100644
index 0000000..7bc983d
--- /dev/null
+++ b/tests/test_provider.cpp
@@ -0,0 +1,647 @@
+#include "provider.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "config.h"
+#include "dotagents.h"
+#include "message.h"
+#include "session.h"
+
+static int failures = 0;
+static int checks = 0;
+
+#define CHECK(cond, msg) \
+ do { \
+ checks++; \
+ if (!(cond)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, (msg)); \
+ } \
+ } while (0)
+
+#define CHECK_CONT(hay, needle, msg) \
+ do { \
+ checks++; \
+ if (!hay || !strstr(hay, needle)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: %s (want substring `%s`)\n", \
+ __FILE__, __LINE__, (msg), (needle)); \
+ } \
+ } while (0)
+
+#define CHECK_NOT_CONT(hay, needle, msg) \
+ do { \
+ checks++; \
+ if (hay && strstr(hay, needle)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: %s (found unwanted `%s`)\n", \
+ __FILE__, __LINE__, (msg), (needle)); \
+ } \
+ } while (0)
+
+static const char *SENTINEL = "SENTINEL-PREAMBLE-XYZ";
+
+static void mock_destroy(llsh_model *m) { (void)m; }
+static llsh_model MOCK = {NULL, NULL, mock_destroy};
+
+static session *make_session(const char *provider) {
+ config c;
+ memset(&c, 0, sizeof c);
+ c.provider = provider_parse(provider, NULL);
+ c.model = xstrdup("test-model");
+ c.preamble = xstrdup(SENTINEL);
+ dotagents d;
+ memset(&d, 0, sizeof d);
+ return session_new_with_model(&MOCK, c, d);
+}
+
+/* The preamble must reach the wire for every provider. Before the fix it was
+ * only emitted for Anthropic and silently dropped for OpenAI/Ollama. */
+static void test_preamble_openai(void) {
+ session *s = make_session("openai");
+ char *body = provider_preview_request(s, "hello", true);
+ CHECK(body != NULL, "openai body built");
+ /* OpenAI carries the system prompt as a leading system message. */
+ CHECK_CONT(body, "\"role\":\"system\"", "has a system message");
+ CHECK_CONT(body, SENTINEL, "system message carries the preamble");
+ CHECK_CONT(body, "\"role\":\"user\"", "user message still present");
+ CHECK_CONT(body, "hello", "prompt present");
+ free(body);
+ session_free(s);
+}
+
+static void test_preamble_ollama(void) {
+ session *s = make_session("ollama");
+ char *body = provider_preview_request(s, "hello", true);
+ CHECK(body != NULL, "ollama body built");
+ /* Ollama carries the system prompt as a top-level `system` field. */
+ CHECK_CONT(body, "\"system\":", "has a top-level system field");
+ CHECK_CONT(body, SENTINEL, "system field carries the preamble");
+ CHECK_CONT(body, "hello", "prompt present");
+ free(body);
+ session_free(s);
+}
+
+static void test_preamble_anthropic(void) {
+ session *s = make_session("anthropic");
+ char *body = provider_preview_request(s, "hello", true);
+ CHECK(body != NULL, "anthropic body built");
+ CHECK_CONT(body, "\"system\":", "has a system field");
+ CHECK_CONT(body, SENTINEL, "system carries the preamble");
+ free(body);
+ session_free(s);
+}
+
+/* A model name with JSON-special characters must not corrupt the body. */
+static void test_model_name_escaped(void) {
+ config c;
+ memset(&c, 0, sizeof c);
+ c.provider = PROV_OPENAI;
+ c.model = xstrdup("we\"ird/model");
+ c.preamble = xstrdup(SENTINEL);
+ dotagents d;
+ memset(&d, 0, sizeof d);
+ session *s = session_new_with_model(&MOCK, c, d);
+ char *body = provider_preview_request(s, "hi", false);
+ CHECK(body != NULL, "body built");
+ /* The raw `"` in the model name must be escaped to \". */
+ CHECK_CONT(body, "we\\\"ird/model", "model name quote is escaped");
+ free(body);
+ session_free(s);
+}
+
+static void test_no_preamble_omits_system(void) {
+ config c;
+ memset(&c, 0, sizeof c);
+ c.provider = PROV_OPENAI;
+ c.model = xstrdup("m");
+ c.preamble = NULL; /* no preamble */
+ dotagents d;
+ memset(&d, 0, sizeof d);
+ session *s = session_new_with_model(&MOCK, c, d);
+ char *body = provider_preview_request(s, "hi", false);
+ CHECK(body != NULL, "body built");
+ CHECK_NOT_CONT(body, "\"role\":\"system\"", "no system message without preamble");
+ free(body);
+ session_free(s);
+}
+
+/* A turn stopped mid tool-loop leaves an assistant message with more tool
+ * calls than the tool-result messages that follow it. The trim must drop the
+ * surplus calls so the history stays a valid request. */
+static void test_trim_dangling_calls(void) {
+ message am = msg_assistant_text("working on it");
+ msg_push_assistant_tool_call(am, "read_file", "{\"path\":\"a\"}");
+ msg_push_assistant_tool_call(am, "write_file", "{\"path\":\"b\"}");
+ message r1 = msg_user_tool_result("call-1", "read_file", "contents");
+ message msgs[2] = {am, r1};
+
+ CHECK(am.assistant().parts.size() == 3,
+ "assistant has text + 2 calls before trim");
+ provider_trim_dangling_calls(msgs, 2);
+ int calls = 0;
+ for (size_t i = 0; i < msgs[0].assistant().parts.size(); i++)
+ if (std::holds_alternative<asst_tool_call_part>(
+ msgs[0].assistant().parts[i]))
+ calls++;
+ CHECK(calls == 1, "surplus call trimmed to match the single result");
+
+ /* Fully-answered calls must be untouched. */
+ message am2 = msg_assistant_text("");
+ msg_push_assistant_tool_call(am2, "read_file", "{}");
+ message ra = msg_user_tool_result("c1", "read_file", "x");
+ message rb = msg_user_tool_result("c2", "write_file", "ok");
+ msg_push_assistant_tool_call(am2, "write_file", "{}");
+ message msgs2[3] = {am2, ra, rb};
+ provider_trim_dangling_calls(msgs2, 3);
+ int calls2 = 0;
+ for (size_t i = 0; i < msgs2[0].assistant().parts.size(); i++)
+ if (std::holds_alternative<asst_tool_call_part>(
+ msgs2[0].assistant().parts[i]))
+ calls2++;
+ CHECK(calls2 == 2, "complete call/result pair is preserved");
+}
+
+/* Reasoning models return the chain of thought in provider-specific fields.
+ * None of the parsers extracted it before the fix, so it was never displayed. */
+static void test_reasoning_openai(void) {
+ const char *body =
+ "{\"choices\":[{\"message\":{\"content\":\"42\","
+ "\"reasoning_content\":\"think... think... 42\"}}]}";
+ llm_conn conn; memset(&conn, 0, sizeof conn);
+ conn.provider = LLM_OPENAI; conn.model = "m";
+ llm_completion comp; memset(&comp, 0, sizeof comp);
+ CHECK(llm_parse_body(&conn, body, &comp) == 0, "openai body parses");
+ CHECK_CONT(comp.text, "42", "answer text extracted");
+ CHECK(comp.reasoning != NULL, "reasoning extracted (was NULL before fix)");
+ CHECK_CONT(comp.reasoning, "think...", "reasoning content preserved");
+ llm_completion_free(&comp);
+}
+
+static void test_reasoning_ollama(void) {
+ const char *body =
+ "{\"message\":{\"content\":\"the answer\","
+ "\"thinking\":\"pondering the answer...\"}}";
+ llm_conn conn; memset(&conn, 0, sizeof conn);
+ conn.provider = LLM_OLLAMA; conn.model = "m";
+ llm_completion comp; memset(&comp, 0, sizeof comp);
+ CHECK(llm_parse_body(&conn, body, &comp) == 0, "ollama body parses");
+ CHECK_CONT(comp.text, "the answer", "answer text extracted");
+ CHECK(comp.reasoning != NULL, "reasoning extracted from message.thinking");
+ CHECK_CONT(comp.reasoning, "pondering", "reasoning content preserved");
+ llm_completion_free(&comp);
+}
+
+static void test_reasoning_anthropic(void) {
+ const char *body =
+ "{\"content\":[{\"type\":\"thinking\",\"thinking\":\"hmm, let me "
+ "reason...\"},{\"type\":\"text\",\"text\":\"done\"}]}";
+ llm_conn conn; memset(&conn, 0, sizeof conn);
+ conn.provider = LLM_ANTHROPIC; conn.model = "m";
+ llm_completion comp; memset(&comp, 0, sizeof comp);
+ CHECK(llm_parse_body(&conn, body, &comp) == 0, "anthropic body parses");
+ CHECK_CONT(comp.text, "done", "answer text extracted");
+ CHECK(comp.reasoning != NULL, "reasoning extracted from thinking block");
+ CHECK_CONT(comp.reasoning, "reason", "reasoning content preserved");
+ llm_completion_free(&comp);
+}
+
+/* A plain (non-reasoning) response must not fabricate a reasoning field. */
+static void test_no_reasoning_when_absent(void) {
+ const char *body =
+ "{\"choices\":[{\"message\":{\"content\":\"just an answer\"}}]}";
+ llm_conn conn; memset(&conn, 0, sizeof conn);
+ conn.provider = LLM_OPENAI; conn.model = "m";
+ llm_completion comp; memset(&comp, 0, sizeof comp);
+ CHECK(llm_parse_body(&conn, body, &comp) == 0, "plain body parses");
+ CHECK_CONT(comp.text, "just an answer", "answer text extracted");
+ CHECK(comp.reasoning == NULL, "no reasoning field fabricated");
+ llm_completion_free(&comp);
+}
+
+/* ------------------------------------------------------------------ */
+/* sse_parse_line -- the pure streaming unit (AUDIT phase 2 test seam) */
+/* ------------------------------------------------------------------ */
+
+/* OpenAI: text deltas, two interleaved multi-chunk tool calls,
+ * finish_reason, [DONE], usage. */
+static void test_sse_openai(void) {
+ llm_sse_acc a;
+ llm_sse_acc_init(&a, LLM_OPENAI);
+ llm_sse_parse_line(LLM_OPENAI, "data: {\"choices\":[{\"delta\":{\"content\":\"Hel\"}}]}", &a);
+ llm_sse_parse_line(LLM_OPENAI, "data: {\"choices\":[{\"delta\":{\"content\":\"lo\"}}]}", &a);
+ CHECK(a.text.len == 5, "text deltas accumulate");
+ CHECK_CONT(a.text.data, "Hello", "text content");
+ llm_sse_parse_line(LLM_OPENAI,
+ "data: {\"choices\":[{\"delta\":{\"tool_calls\":["
+ "{\"index\":0,\"id\":\"c1\",\"function\":{\"name\":\"read_file\","
+ "\"arguments\":\"{\\\"pa\"}}]}}]}", &a);
+ llm_sse_parse_line(LLM_OPENAI,
+ "data: {\"choices\":[{\"delta\":{\"tool_calls\":["
+ "{\"index\":1,\"id\":\"c2\",\"function\":{\"name\":\"grep\","
+ "\"arguments\":\"{\\\"pat\"}}]}}]}", &a);
+ llm_sse_parse_line(LLM_OPENAI,
+ "data: {\"choices\":[{\"delta\":{\"tool_calls\":["
+ "{\"index\":0,\"function\":{\"arguments\":\"th\\\":\\\"a.txt\\\"}\"}}]}}]}",
+ &a);
+ llm_sse_parse_line(LLM_OPENAI,
+ "data: {\"choices\":[{\"delta\":{\"tool_calls\":["
+ "{\"index\":1,\"function\":{\"arguments\":\"tern\\\":\\\"x\\\"}\"}}]}}]}",
+ &a);
+ CHECK(a.tc_len == 2, "two tool calls indexed");
+ CHECK(a.tc_id[0] && strcmp(a.tc_id[0], "c1") == 0, "call 0 id");
+ CHECK(a.tc_id[1] && strcmp(a.tc_id[1], "c2") == 0, "call 1 id");
+ CHECK(a.tc_name[0] && strcmp(a.tc_name[0], "read_file") == 0, "call 0 name");
+ CHECK_CONT(a.tc_args[0].data, "\"path\":\"a.txt\"", "call 0 args assembled");
+ CHECK_CONT(a.tc_args[1].data, "\"pattern\":\"x\"", "call 1 args assembled");
+ llm_sse_parse_line(LLM_OPENAI,
+ "data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}]}",
+ &a);
+ CHECK(a.done, "finish_reason marks the stream done");
+ llm_sse_parse_line(LLM_OPENAI, "data: [DONE]", &a);
+ llm_sse_parse_line(LLM_OPENAI, "data: {\"usage\":{\"prompt_tokens\":42}}", &a);
+ CHECK(a.input_tokens == 42, "usage.prompt_tokens captured");
+ llm_sse_acc_free(&a);
+}
+
+/* Anthropic: event:/data: pairing, thinking + text blocks, a tool_use
+ * block with chunked input_json_delta, message_start usage, message_stop. */
+static void test_sse_anthropic(void) {
+ llm_sse_acc a;
+ llm_sse_acc_init(&a, LLM_ANTHROPIC);
+ llm_sse_parse_line(LLM_ANTHROPIC, "event: message_start", &a);
+ llm_sse_parse_line(LLM_ANTHROPIC,
+ "data: {\"message\":{\"usage\":{\"input_tokens\":7}}}", &a);
+ CHECK(a.input_tokens == 7, "usage from message_start");
+ llm_sse_parse_line(LLM_ANTHROPIC, "event: content_block_start", &a);
+ llm_sse_parse_line(LLM_ANTHROPIC,
+ "data: {\"content_block\":{\"type\":\"thinking\"}}", &a);
+ llm_sse_parse_line(LLM_ANTHROPIC, "event: content_block_delta", &a);
+ llm_sse_parse_line(LLM_ANTHROPIC,
+ "data: {\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"hmm \"}}",
+ &a);
+ llm_sse_parse_line(LLM_ANTHROPIC, "event: content_block_start", &a);
+ llm_sse_parse_line(LLM_ANTHROPIC,
+ "data: {\"content_block\":{\"type\":\"text\"}}", &a);
+ llm_sse_parse_line(LLM_ANTHROPIC, "event: content_block_delta", &a);
+ llm_sse_parse_line(LLM_ANTHROPIC,
+ "data: {\"delta\":{\"type\":\"text_delta\",\"text\":\"ok\"}}", &a);
+ CHECK_CONT(a.reasoning.data, "hmm", "thinking_delta captured");
+ CHECK_CONT(a.text.data, "ok", "text_delta captured");
+ llm_sse_parse_line(LLM_ANTHROPIC, "event: content_block_start", &a);
+ llm_sse_parse_line(LLM_ANTHROPIC,
+ "data: {\"content_block\":{\"type\":\"tool_use\",\"id\":\"t1\","
+ "\"name\":\"write_file\"}}", &a);
+ llm_sse_parse_line(LLM_ANTHROPIC, "event: content_block_delta", &a);
+ llm_sse_parse_line(LLM_ANTHROPIC,
+ "data: {\"delta\":{\"type\":\"input_json_delta\","
+ "\"partial_json\":\"{\\\"pa\"}}", &a);
+ llm_sse_parse_line(LLM_ANTHROPIC,
+ "data: {\"delta\":{\"type\":\"input_json_delta\","
+ "\"partial_json\":\"th\\\":\\\"x\\\"}\"}}", &a);
+ CHECK(a.tc_len == 1, "one tool_use block");
+ CHECK(a.tc_id[0] && strcmp(a.tc_id[0], "t1") == 0, "tool_use id");
+ CHECK(a.tc_name[0] && strcmp(a.tc_name[0], "write_file") == 0, "tool_use name");
+ CHECK_CONT(a.tc_args[0].data, "\"path\":\"x\"", "chunked partial_json assembled");
+ llm_sse_parse_line(LLM_ANTHROPIC, "event: message_stop", &a);
+ llm_sse_parse_line(LLM_ANTHROPIC, "data: [DONE]", &a);
+ CHECK(a.done, "message_stop marks done");
+ llm_sse_acc_free(&a);
+}
+
+/* Ollama NDJSON: content + thinking chunks, prompt_eval_count, tool_calls
+ * on the done chunk (object arguments), done:true. */
+static void test_sse_ollama(void) {
+ llm_sse_acc a;
+ llm_sse_acc_init(&a, LLM_OLLAMA);
+ llm_sse_parse_line(LLM_OLLAMA, "{\"message\":{\"content\":\"He\"}}", &a);
+ llm_sse_parse_line(LLM_OLLAMA, "{\"message\":{\"thinking\":\"think\"}}", &a);
+ llm_sse_parse_line(LLM_OLLAMA, "{\"message\":{\"content\":\"llo\"}}", &a);
+ CHECK_CONT(a.text.data, "Hello", "content chunks accumulate");
+ CHECK_CONT(a.reasoning.data, "think", "thinking chunk captured");
+ llm_sse_parse_line(LLM_OLLAMA, "{\"prompt_eval_count\":9}", &a);
+ CHECK(a.input_tokens == 9, "prompt_eval_count captured");
+ llm_sse_parse_line(LLM_OLLAMA,
+ "{\"message\":{\"tool_calls\":[{\"id\":\"o1\",\"function\":"
+ "{\"name\":\"list_dir\",\"arguments\":{\"path\":\".\"}}}]},"
+ "\"done\":true}", &a);
+ CHECK(a.tc_len == 1, "tool call on the done chunk");
+ CHECK(a.tc_id[0] && strcmp(a.tc_id[0], "o1") == 0, "tool id");
+ CHECK(a.tc_name[0] && strcmp(a.tc_name[0], "list_dir") == 0, "tool name");
+ CHECK_CONT(a.tc_args[0].data, "\"path\":\".\"", "object args serialized");
+ CHECK(a.done, "done:true marks done");
+ llm_sse_acc_free(&a);
+}
+
+/* Lines outside the protocol (stray text, bad JSON) are ignored without
+ * corrupting the accumulator. */
+static void test_sse_noise_lines(void) {
+ llm_sse_acc a;
+ llm_sse_acc_init(&a, LLM_OPENAI);
+ llm_sse_parse_line(LLM_OPENAI, "event: ping", &a); /* not `data:` -> ignored */
+ llm_sse_parse_line(LLM_OPENAI, "data: not-json", &a); /* bad JSON -> ignored */
+ llm_sse_parse_line(LLM_OPENAI, "data: {\"choices\":[]}", &a); /* empty choices */
+ CHECK(a.text.len == 0 && a.tc_len == 0 && !a.done, "noise left acc empty");
+ llm_sse_acc_free(&a);
+
+ llm_sse_acc b;
+ llm_sse_acc_init(&b, LLM_ANTHROPIC);
+ llm_sse_parse_line(LLM_ANTHROPIC, "data: {\"no\":\"event\"}", &b); /* no event set */
+ llm_sse_parse_line(LLM_ANTHROPIC, "data: {", &b); /* truncated JSON */
+ CHECK(b.text.len == 0 && b.tc_len == 0 && !b.done, "anthropic noise ignored");
+ llm_sse_acc_free(&b);
+
+ llm_sse_acc c;
+ llm_sse_acc_init(&c, LLM_OLLAMA);
+ llm_sse_parse_line(LLM_OLLAMA, "hello", &c); /* not JSON */
+ llm_sse_parse_line(LLM_OLLAMA, "{}", &c); /* no message field */
+ CHECK(c.text.len == 0 && c.tc_len == 0 && !c.done, "ollama noise ignored");
+ llm_sse_acc_free(&c);
+}
+
+/* sse_acc_into: unnamed/unargued calls get default ids and `{}` args. */
+static void test_sse_acc_into_defaults(void) {
+ llm_sse_acc a;
+ llm_sse_acc_init(&a, LLM_OLLAMA);
+ llm_acc_push_tool(&a, NULL, NULL);
+ llm_completion c;
+ memset(&c, 0, sizeof c);
+ llm_sse_acc_into(&a, &c);
+ CHECK(c.tc_len == 1, "one materialized call");
+ CHECK(c.tc_id[0] && strstr(c.tc_id[0], "call-1") != NULL, "default id");
+ CHECK(c.tc_args[0] && strcmp(c.tc_args[0], "{}") == 0, "default empty args");
+ llm_completion_free(&c);
+ llm_sse_acc_free(&a);
+}
+
+/* sse_acc_into: non-empty text/reasoning transfer ownership to `c` and must
+ * survive completion_free + sse_acc_free without a double-free. Regression:
+ * the old code freed the stolen buffer right after handing it to `c`,
+ * dangling c->text/c->reasoning (crashed on real streamed responses). */
+static void test_sse_acc_into_text_reasoning_ownership(void) {
+ llm_sse_acc a;
+ llm_sse_acc_init(&a, LLM_OPENAI);
+ llm_buf_put(&a.text, "the answer is 42");
+ llm_buf_put(&a.reasoning, "pondering deeply");
+ llm_acc_push_tool(&a, xstrdup("call-9"), xstrdup("read_file"));
+ llm_buf_put(&a.tc_args[0], "\"path\":\"AUDIT.md\"");
+
+ llm_completion c;
+ memset(&c, 0, sizeof c);
+ llm_sse_acc_into(&a, &c);
+ CHECK(c.text && strcmp(c.text, "the answer is 42") == 0,
+ "text transferred intact");
+ CHECK(c.reasoning && strcmp(c.reasoning, "pondering deeply") == 0,
+ "reasoning transferred intact");
+ CHECK(c.tc_len == 1 && c.tc_args[0] &&
+ strcmp(c.tc_args[0], "\"path\":\"AUDIT.md\"") == 0,
+ "tool args transferred intact");
+ /* Both frees must be safe: `c` owns the strings now, `a` is reset. */
+ llm_completion_free(&c);
+ llm_sse_acc_free(&a);
+}
+
+/* ================= llm_params -> request body =================
+ * The `llm` tool threads llm_params (temperature, max_tokens, reasoning
+ * effort, thinking budget, response schema) into the wire body. These are
+ * pure request-builder tests (no network) per provider. */
+
+static llm_message one_user(const char *text) {
+ static llm_part part;
+ memset(&part, 0, sizeof part);
+ part.kind = LLM_TEXT;
+ part.text = text;
+ llm_message m;
+ memset(&m, 0, sizeof m);
+ m.role = LLM_ROLE_USER;
+ m.parts = &part;
+ m.part_count = 1;
+ return m;
+}
+
+static void test_params_openai(void) {
+ llm_conn c;
+ memset(&c, 0, sizeof c);
+ c.provider = LLM_OPENAI;
+ c.model = "test-model";
+ llm_params p;
+ llm_params_init(&p);
+ p.temperature = 0.5;
+ p.max_tokens = 256;
+ p.reasoning_effort = "high";
+ p.response_schema = "{\"type\":\"object\"}";
+
+ llm_message m = one_user("hi");
+ char *body = llm_build_request_ex(&c, NULL, &m, 1, NULL, false, &p);
+ CHECK(body != NULL, "openai body built");
+ if (body) {
+ CHECK_CONT(body, "\"temperature\":0.5", "openai temperature");
+ CHECK_CONT(body, "\"max_tokens\":256", "openai max_tokens");
+ CHECK_CONT(body, "\"reasoning_effort\":\"high\"",
+ "openai reasoning_effort");
+ CHECK_CONT(body, "\"response_format\":{\"type\":\"json_schema\"",
+ "openai response_format");
+ CHECK_CONT(body, "\"type\":\"object\"", "openai schema inlined");
+ free(body);
+ }
+}
+
+static void test_params_ollama(void) {
+ llm_conn c;
+ memset(&c, 0, sizeof c);
+ c.provider = LLM_OLLAMA;
+ c.model = "ollama-model";
+ llm_params p;
+ llm_params_init(&p);
+ p.temperature = 0.75; /* 3/4: exact in binary, so %.6g is stable */
+ p.max_tokens = 128;
+ p.reasoning_effort = "medium";
+ p.response_schema = "{\"type\":\"object\"}";
+
+ llm_message m = one_user("hi");
+ char *body = llm_build_request_ex(&c, "SYS", &m, 1, NULL, false, &p);
+ CHECK(body != NULL, "ollama body built");
+ if (body) {
+ CHECK_CONT(body, "\"system\":\"SYS\"", "ollama top-level system");
+ CHECK_CONT(body, "\"options\":{\"temperature\":0.75",
+ "ollama options.temperature");
+ CHECK_CONT(body, "\"num_predict\":128", "ollama num_predict");
+ CHECK_CONT(body, "\"think\":true", "ollama think enabled");
+ CHECK_CONT(body, "\"format\":", "ollama format field");
+ CHECK_CONT(body, "\"type\":\"object\"", "ollama schema");
+ free(body);
+ }
+}
+
+static void test_params_anthropic_thinking(void) {
+ llm_conn c;
+ memset(&c, 0, sizeof c);
+ c.provider = LLM_ANTHROPIC;
+ c.model = "claude";
+ llm_params p;
+ llm_params_init(&p);
+ p.thinking_budget = 16384;
+
+ llm_message m = one_user("hi");
+ char *body = llm_build_request_ex(&c, NULL, &m, 1, NULL, false, &p);
+ CHECK(body != NULL, "anthropic body built");
+ if (body) {
+ CHECK_CONT(body, "\"thinking\":{\"type\":\"enabled\","
+ "\"budget_tokens\":16384}",
+ "anthropic thinking budget");
+ /* max_tokens must be bumped above the thinking budget. */
+ CHECK_CONT(body, "\"max_tokens\":17408",
+ "anthropic max_tokens > budget");
+ free(body);
+ }
+}
+
+static void test_params_anthropic_effort(void) {
+ llm_conn c;
+ memset(&c, 0, sizeof c);
+ c.provider = LLM_ANTHROPIC;
+ c.model = "claude";
+ llm_params p;
+ llm_params_init(&p);
+ p.reasoning_effort = "low"; /* maps to a 1024-token budget */
+
+ llm_message m = one_user("hi");
+ char *body = llm_build_request_ex(&c, NULL, &m, 1, NULL, false, &p);
+ CHECK(body != NULL, "anthropic body built");
+ if (body)
+ CHECK_CONT(body, "\"budget_tokens\":1024",
+ "effort 'low' maps to a 1024 budget");
+ free(body);
+}
+
+static void test_params_anthropic_temperature(void) {
+ llm_conn c;
+ memset(&c, 0, sizeof c);
+ c.provider = LLM_ANTHROPIC;
+ c.model = "claude";
+ llm_params p;
+ llm_params_init(&p);
+ p.temperature = 0.25; /* 1/4: exact in binary, no thinking budget */
+
+ llm_message m = one_user("hi");
+ char *body = llm_build_request_ex(&c, NULL, &m, 1, NULL, false, &p);
+ CHECK(body != NULL, "anthropic body built");
+ if (body) {
+ CHECK_CONT(body, "\"temperature\":0.25", "anthropic temperature");
+ CHECK_NOT_CONT(body, "\"thinking\"",
+ "no thinking block without a budget");
+ free(body);
+ }
+}
+
+static void test_params_anthropic_schema(void) {
+ llm_conn c;
+ memset(&c, 0, sizeof c);
+ c.provider = LLM_ANTHROPIC;
+ c.model = "claude";
+ llm_params p;
+ llm_params_init(&p);
+ p.response_schema = "{\"type\":\"object\"}";
+
+ llm_message m = one_user("hi");
+ char *body = llm_build_request_ex(&c, "SYS", &m, 1, NULL, false, &p);
+ CHECK(body != NULL, "anthropic body built");
+ if (body) {
+ CHECK_CONT(body, "SYS", "preamble kept");
+ CHECK_CONT(body, "JSON Schema", "schema instruction appended");
+ /* The schema is JSON-encoded into the system string, so its quotes
+ * are escaped in the body. */
+ CHECK_CONT(body, "\\\"type\\\"", "schema text embedded (escaped)");
+ CHECK_NOT_CONT(body, "response_format",
+ "anthropic has no response_format");
+ free(body);
+ }
+}
+
+static void test_params_unset(void) {
+ llm_conn c;
+ memset(&c, 0, sizeof c);
+ c.provider = LLM_OPENAI;
+ c.model = "m";
+
+ llm_message m = one_user("hi");
+ char *body = llm_build_request_ex(&c, NULL, &m, 1, NULL, false, NULL);
+ CHECK(body != NULL, "body built with NULL params");
+ if (body) {
+ CHECK_NOT_CONT(body, "temperature", "no temperature when unset");
+ CHECK_NOT_CONT(body, "max_tokens", "no max_tokens when unset");
+ CHECK_NOT_CONT(body, "response_format",
+ "no response_format when unset");
+ free(body);
+ }
+}
+
+/* ================= `compact` built-in tool =================
+ * session_force_compact is the dispatch target for the `compact` tool call. */
+
+static void test_force_compact_short(void) {
+ session *s = make_session("openai");
+ session_push_history(s, msg_user_text("hello"));
+ session_push_history(s, msg_assistant_text("hi"));
+ compact_report rep;
+ memset(&rep, 0, sizeof rep);
+ char *err = NULL;
+ int rc = session_force_compact(s, &rep, &err);
+ /* user+assistant only: keep-boundary at index 0 -> no pass can run. */
+ CHECK(rc == 0, "no pass on a too-short history");
+ free(err);
+ session_free(s);
+}
+
+static void test_force_compact_clears(void) {
+ session *s = make_session("openai");
+ session_push_history(s, msg_user_text("read the file"));
+ session_push_history(s, msg_assistant_tool_call("read_file", "{}"));
+ session_push_history(s, msg_user_tool_result("c1", "read_file",
+ "a lot of cleared content"));
+ session_push_history(s, msg_assistant_text("done reading"));
+ session_push_history(s, msg_user_text("now edit it"));
+
+ compact_report rep;
+ memset(&rep, 0, sizeof rep);
+ char *err = NULL;
+ int rc = session_force_compact(s, &rep, &err);
+ CHECK(rc == 1, "compaction pass ran");
+ CHECK(rep.tier == TIER_FALLBACK_CLEAR,
+ "no summarizer -> fallback clear tier");
+ CHECK(rep.cleared_tool_results >= 1, "stale tool result cleared");
+ free(err);
+ session_free(s);
+}
+
+int main(void) {
+ test_preamble_openai();
+ test_preamble_ollama();
+ test_preamble_anthropic();
+ test_model_name_escaped();
+ test_no_preamble_omits_system();
+ test_trim_dangling_calls();
+ test_reasoning_openai();
+ test_reasoning_ollama();
+ test_reasoning_anthropic();
+ test_no_reasoning_when_absent();
+ test_sse_openai();
+ test_sse_anthropic();
+ test_sse_ollama();
+ test_sse_noise_lines();
+ test_sse_acc_into_defaults();
+ test_sse_acc_into_text_reasoning_ownership();
+ test_params_openai();
+ test_params_ollama();
+ test_params_anthropic_thinking();
+ test_params_anthropic_effort();
+ test_params_anthropic_temperature();
+ test_params_anthropic_schema();
+ test_params_unset();
+ test_force_compact_short();
+ test_force_compact_clears();
+ printf("provider: %d checks, %d failures\n", checks, failures);
+ return failures ? 1 : 0;
+}