/* SPDX-License-Identifier: AGPL-3.0-or-later */ /* Copyright (C) 2026 David (Corey) Herrera */ /* * Unit tests for the tool-call parser (src/serve/tool_calling.c). * * Focus of these tests is tc_parse_tool_calls' wrapper-tolerant parsing: the * canonical wrapped form AND a * bare block with no wrapper (which the Q3 nex-n2-mini * emits most of the time) must both parse into identical structured calls, and * text with no #include #include /* A one-function tools schema (get_weather: location string, unit string) so * parameter values get schema-typed exactly like the serve path does. */ static const char *TOOLS_JSON = "[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\"," "\"description\":\"Get the weather.\",\"parameters\":{\"type\":\"object\"," "\"properties\":{\"location\":{\"type\":\"string\"}," "\"unit\":{\"type\":\"string\"},\"days\":{\"type\":\"integer\"}}," "\"required\":[\"location\"]}}}]"; /* ============================================================ * Wrapped form (canonical) still parses — regression guard. * ============================================================ */ static void test_wrapped_single(void) { jr_value *tools = jr_parse(TOOLS_JSON); const char *text = "\n\n" "\nParis\n\n" "\n"; tc_tool_call *calls = NULL; int n = -1; int rc = tc_parse_tool_calls(text, tools, TC_DIALECT_QWEN_XML, &calls, &n); ASSERT_INT_EQ(rc, 0); ASSERT_INT_EQ(n, 1); ASSERT_NOT_NULL(calls); ASSERT_STR_EQ(calls[0].name, "get_weather"); ASSERT_STR_EQ(calls[0].arguments, "{\"location\":\"Paris\"}"); ASSERT_TRUE(strncmp(calls[0].id, "call_", 5) == 0); tc_tool_calls_free(calls, n); jr_free(tools); } /* ============================================================ * Bare form (no wrapper) — the Phase 1 fix. * ============================================================ */ static void test_bare_single(void) { jr_value *tools = jr_parse(TOOLS_JSON); const char *text = "\n" "\nParis\n\n" ""; tc_tool_call *calls = NULL; int n = -1; int rc = tc_parse_tool_calls(text, tools, TC_DIALECT_QWEN_XML, &calls, &n); ASSERT_INT_EQ(rc, 0); ASSERT_INT_EQ(n, 1); ASSERT_NOT_NULL(calls); ASSERT_STR_EQ(calls[0].name, "get_weather"); ASSERT_STR_EQ(calls[0].arguments, "{\"location\":\"Paris\"}"); ASSERT_TRUE(strncmp(calls[0].id, "call_", 5) == 0); tc_tool_calls_free(calls, n); jr_free(tools); } /* Bare and wrapped forms of the same call yield identical name + arguments * (only the random id differs) — proves output parity. */ static void test_bare_matches_wrapped(void) { jr_value *tools = jr_parse(TOOLS_JSON); const char *bare = "\n\nParis\n\n" "\ncelsius\n\n"; const char *wrapped = "\n\n\nParis\n\n" "\ncelsius\n\n\n"; tc_tool_call *cb = NULL, *cw = NULL; int nb = -1, nw = -1; ASSERT_INT_EQ(tc_parse_tool_calls(bare, tools, TC_DIALECT_QWEN_XML, &cb, &nb), 0); ASSERT_INT_EQ(tc_parse_tool_calls(wrapped, tools, TC_DIALECT_QWEN_XML, &cw, &nw), 0); ASSERT_INT_EQ(nb, 1); ASSERT_INT_EQ(nw, 1); ASSERT_STR_EQ(cb[0].name, cw[0].name); ASSERT_STR_EQ(cb[0].arguments, cw[0].arguments); ASSERT_STR_EQ(cb[0].arguments, "{\"location\":\"Paris\",\"unit\":\"celsius\"}"); tc_tool_calls_free(cb, nb); tc_tool_calls_free(cw, nw); jr_free(tools); } /* Integer schema typing survives the bare path (days -> bare literal). */ static void test_bare_typed_integer(void) { jr_value *tools = jr_parse(TOOLS_JSON); const char *text = "\n\nParis\n\n" "\n3\n\n"; tc_tool_call *calls = NULL; int n = -1; ASSERT_INT_EQ(tc_parse_tool_calls(text, tools, TC_DIALECT_QWEN_XML, &calls, &n), 0); ASSERT_INT_EQ(n, 1); ASSERT_STR_EQ(calls[0].arguments, "{\"location\":\"Paris\",\"days\":3}"); tc_tool_calls_free(calls, n); jr_free(tools); } /* ============================================================ * Multiple calls in one message (bare + wrapped). * ============================================================ */ static void test_bare_multi(void) { jr_value *tools = jr_parse(TOOLS_JSON); const char *text = "\n\nParis\n\n\n" "\n\nTokyo\n\n"; tc_tool_call *calls = NULL; int n = -1; ASSERT_INT_EQ(tc_parse_tool_calls(text, tools, TC_DIALECT_QWEN_XML, &calls, &n), 0); ASSERT_INT_EQ(n, 2); ASSERT_STR_EQ(calls[0].arguments, "{\"location\":\"Paris\"}"); ASSERT_STR_EQ(calls[1].arguments, "{\"location\":\"Tokyo\"}"); tc_tool_calls_free(calls, n); jr_free(tools); } static void test_wrapped_multi(void) { jr_value *tools = jr_parse(TOOLS_JSON); const char *text = "\n\n\nParis\n\n\n\n" "\n\n\nTokyo\n\n\n"; tc_tool_call *calls = NULL; int n = -1; ASSERT_INT_EQ(tc_parse_tool_calls(text, tools, TC_DIALECT_QWEN_XML, &calls, &n), 0); ASSERT_INT_EQ(n, 2); ASSERT_STR_EQ(calls[0].arguments, "{\"location\":\"Paris\"}"); ASSERT_STR_EQ(calls[1].arguments, "{\"location\":\"Tokyo\"}"); tc_tool_calls_free(calls, n); jr_free(tools); } /* ============================================================ * Real-world noise tolerance. * ============================================================ */ /* Natural-language reasoning before the first bare \n\nParis\n\n"; tc_tool_call *calls = NULL; int n = -1; ASSERT_INT_EQ(tc_parse_tool_calls(text, tools, TC_DIALECT_QWEN_XML, &calls, &n), 0); ASSERT_INT_EQ(n, 1); ASSERT_STR_EQ(calls[0].name, "get_weather"); ASSERT_STR_EQ(calls[0].arguments, "{\"location\":\"Paris\"}"); tc_tool_calls_free(calls, n); jr_free(tools); } /* A doubled/again after a valid bare call must not crash or emit a * second garbage call. */ static void test_bare_doubled_close(void) { jr_value *tools = jr_parse(TOOLS_JSON); const char *text = "\n\nParis\n\n" "\n"; tc_tool_call *calls = NULL; int n = -1; ASSERT_INT_EQ(tc_parse_tool_calls(text, tools, TC_DIALECT_QWEN_XML, &calls, &n), 0); ASSERT_INT_EQ(n, 1); ASSERT_STR_EQ(calls[0].arguments, "{\"location\":\"Paris\"}"); tc_tool_calls_free(calls, n); jr_free(tools); } /* A trailing with no opener alongside a bare call: since a real * TC_OPEN is present in this shape only if wrapped, the bare scan handles it and * the stray closer is inert. */ static void test_bare_call_stray_closer(void) { jr_value *tools = jr_parse(TOOLS_JSON); const char *text = "\n\nParis\n\n" "\n"; tc_tool_call *calls = NULL; int n = -1; ASSERT_INT_EQ(tc_parse_tool_calls(text, tools, TC_DIALECT_QWEN_XML, &calls, &n), 0); ASSERT_INT_EQ(n, 1); ASSERT_STR_EQ(calls[0].arguments, "{\"location\":\"Paris\"}"); tc_tool_calls_free(calls, n); jr_free(tools); } /* ============================================================ * No false positives. * ============================================================ */ /* Plain answer text with no zero calls. */ static void test_no_tools_text(void) { jr_value *tools = jr_parse(TOOLS_JSON); const char *text = "The weather in Paris is sunny and 18 degrees celsius."; tc_tool_call *calls = NULL; int n = -1; ASSERT_INT_EQ(tc_parse_tool_calls(text, tools, TC_DIALECT_QWEN_XML, &calls, &n), 0); ASSERT_INT_EQ(n, 0); ASSERT_NULL(calls); tc_tool_calls_free(calls, n); jr_free(tools); } /* Empty string -> zero calls. */ static void test_empty(void) { tc_tool_call *calls = (tc_tool_call *)0x1; int n = -1; ASSERT_INT_EQ(tc_parse_tool_calls("", NULL, TC_DIALECT_QWEN_XML, &calls, &n), 0); ASSERT_INT_EQ(n, 0); ASSERT_NULL(calls); } /* NULL text -> zero calls, no crash. */ static void test_null_text(void) { tc_tool_call *calls = (tc_tool_call *)0x1; int n = -1; ASSERT_INT_EQ(tc_parse_tool_calls(NULL, NULL, TC_DIALECT_QWEN_XML, &calls, &n), 0); ASSERT_INT_EQ(n, 0); ASSERT_NULL(calls); } /* A stray "'-terminated name must not crash or emit a * garbage call. */ static void test_garbage_unterminated_name(void) { jr_value *tools = jr_parse(TOOLS_JSON); const char *text = "here is a stray " (has a '>' but no name) emits no call. */ static void test_garbage_empty_name(void) { jr_value *tools = jr_parse(TOOLS_JSON); const char *text = ""; tc_tool_call *calls = NULL; int n = -1; ASSERT_INT_EQ(tc_parse_tool_calls(text, tools, TC_DIALECT_QWEN_XML, &calls, &n), 0); ASSERT_INT_EQ(n, 0); tc_tool_calls_free(calls, n); jr_free(tools); } /* ============================================================ * Name-set gate (defense-in-depth) — a hallucinated tool name that is NOT in * the request's tools schema must NEVER surface as a tool_call, even when it is * otherwise well-formed XML. This is the parser-side backstop for the observed * saber-orch degeneration: PANTHEON_TOOL_GRAMMAR=1 constrains the NAME only once * the armed skeleton is entered, but a model that degenerates in the reasoning * region (or a fail-open step) can leave a garbage "" in the raw * text — the parser must reject any NAME outside the tools set. * ============================================================ */ /* The live-observed failure: a garbage hallucinated name with empty args, in an * otherwise well-formed bare block, is dropped (0 calls). */ static void test_hallucinated_name_rejected(void) { jr_value *tools = jr_parse(TOOLS_JSON); const char *text = "\n" ""; tc_tool_call *calls = NULL; int n = -1; ASSERT_INT_EQ(tc_parse_tool_calls(text, tools, TC_DIALECT_QWEN_XML, &calls, &n), 0); ASSERT_INT_EQ(n, 0); ASSERT_NULL(calls); tc_tool_calls_free(calls, n); jr_free(tools); } /* Wrapped form with a hallucinated name is likewise dropped. */ static void test_hallucinated_name_rejected_wrapped(void) { jr_value *tools = jr_parse(TOOLS_JSON); const char *text = "\n\n" "\nParis\n\n" "\n"; tc_tool_call *calls = NULL; int n = -1; ASSERT_INT_EQ(tc_parse_tool_calls(text, tools, TC_DIALECT_QWEN_XML, &calls, &n), 0); ASSERT_INT_EQ(n, 0); tc_tool_calls_free(calls, n); jr_free(tools); } /* A KNOWN name is unaffected — a garbage block adjacent to a valid one keeps the * valid call and drops only the bogus one. */ static void test_known_name_kept_bogus_dropped(void) { jr_value *tools = jr_parse(TOOLS_JSON); const char *text = "\n\n" "\n" "\nParis\n\n" ""; tc_tool_call *calls = NULL; int n = -1; ASSERT_INT_EQ(tc_parse_tool_calls(text, tools, TC_DIALECT_QWEN_XML, &calls, &n), 0); ASSERT_INT_EQ(n, 1); ASSERT_NOT_NULL(calls); ASSERT_STR_EQ(calls[0].name, "get_weather"); ASSERT_STR_EQ(calls[0].arguments, "{\"location\":\"Paris\"}"); tc_tool_calls_free(calls, n); jr_free(tools); } /* With NO tools schema the gate is permissive (nothing to validate against) — * preserves the prior behavior for schema-less parses. */ static void test_no_schema_permissive(void) { const char *text = "\n" "\ny\n\n" ""; tc_tool_call *calls = NULL; int n = -1; ASSERT_INT_EQ(tc_parse_tool_calls(text, NULL, TC_DIALECT_QWEN_XML, &calls, &n), 0); ASSERT_INT_EQ(n, 1); ASSERT_NOT_NULL(calls); ASSERT_STR_EQ(calls[0].name, "anything_goes"); tc_tool_calls_free(calls, n); jr_free(NULL); } /* ============================================================ * MiniCPM5 attribute-style dialect: bare, unwrapped * VALUE… * (quoted-attribute name/keys, optional CDATA values). Disjoint from the * Qwen "" "Paris"; tc_tool_call *calls = NULL; int n = -1; ASSERT_INT_EQ(tc_parse_tool_calls(text, tools, TC_DIALECT_QWEN_XML, &calls, &n), 0); ASSERT_INT_EQ(n, 1); ASSERT_NOT_NULL(calls); ASSERT_STR_EQ(calls[0].name, "get_weather"); ASSERT_STR_EQ(calls[0].arguments, "{\"location\":\"Paris\"}"); ASSERT_TRUE(strncmp(calls[0].id, "call_", 5) == 0); tc_tool_calls_free(calls, n); jr_free(tools); } /* Typed multi-param: integer stays a JSON literal, string stays quoted. */ static void test_minicpm_typed_multi(void) { jr_value *tools = jr_parse(TOOLS_JSON); const char *text = "" "Paris" "3"; tc_tool_call *calls = NULL; int n = -1; ASSERT_INT_EQ(tc_parse_tool_calls(text, tools, TC_DIALECT_QWEN_XML, &calls, &n), 0); ASSERT_INT_EQ(n, 1); ASSERT_STR_EQ(calls[0].name, "get_weather"); ASSERT_STR_EQ(calls[0].arguments, "{\"location\":\"Paris\",\"days\":3}"); tc_tool_calls_free(calls, n); jr_free(tools); } /* CDATA-wrapped multi-line value is taken verbatim (wrapper stripped). */ static void test_minicpm_cdata(void) { jr_value *tools = jr_parse(TOOLS_JSON); const char *text = "" "" ""; tc_tool_call *calls = NULL; int n = -1; ASSERT_INT_EQ(tc_parse_tool_calls(text, tools, TC_DIALECT_QWEN_XML, &calls, &n), 0); ASSERT_INT_EQ(n, 1); ASSERT_STR_EQ(calls[0].arguments, "{\"location\":\"Paris\\n& Berlin\"}"); tc_tool_calls_free(calls, n); jr_free(tools); } /* Single-quoted attributes are accepted. */ static void test_minicpm_single_quotes(void) { jr_value *tools = jr_parse(TOOLS_JSON); const char *text = "" "Paris"; tc_tool_call *calls = NULL; int n = -1; ASSERT_INT_EQ(tc_parse_tool_calls(text, tools, TC_DIALECT_QWEN_XML, &calls, &n), 0); ASSERT_INT_EQ(n, 1); ASSERT_STR_EQ(calls[0].name, "get_weather"); ASSERT_STR_EQ(calls[0].arguments, "{\"location\":\"Paris\"}"); tc_tool_calls_free(calls, n); jr_free(tools); } /* Leading reasoning prose before the call is skipped; hallucinated names are * still gated by the schema. */ static void test_minicpm_reasoning_prefix(void) { jr_value *tools = jr_parse(TOOLS_JSON); const char *text = "Let me check the weather for you.\n" "" "Paris"; tc_tool_call *calls = NULL; int n = -1; ASSERT_INT_EQ(tc_parse_tool_calls(text, tools, TC_DIALECT_QWEN_XML, &calls, &n), 0); ASSERT_INT_EQ(n, 1); ASSERT_STR_EQ(calls[0].name, "get_weather"); tc_tool_calls_free(calls, n); jr_free(tools); } /* The native preamble renderer emits the block + * instructions (not the Qwen form). */ static void test_minicpm_preamble(void) { jr_value *tools = jr_parse(TOOLS_JSON); char *pre = tc_render_tools_system_minicpm(tools, NULL); ASSERT_NOT_NULL(pre); ASSERT_TRUE(strstr(pre, "") != NULL); ASSERT_TRUE(strstr(pre, "") != NULL); ASSERT_TRUE(strstr(pre, "") != NULL); ASSERT_TRUE(strstr(pre, "get_weather") != NULL); /* the tool JSON */ /* Must NOT carry the Qwen dialect's example form. */ ASSERT_TRUE(strstr(pre, " / * (like the bare Qwen form) but with an extra … wrapper around * each value: * \n\n\nVALUE\n\n\n * The generation grammar accepts the wrapper as free VALUE text, so the parser * must peel it back to the bare VALUE. Regression: without the fix the value * captured as the literal "\nVALUE\n" text. * ============================================================ */ static void test_forced_value_wrapper_bare(void) { jr_value *tools = jr_parse(TOOLS_JSON); const char *text = "\n\n" "\nParis\n\n\n"; tc_tool_call *calls = NULL; int n = -1; ASSERT_INT_EQ(tc_parse_tool_calls(text, tools, TC_DIALECT_QWEN_XML, &calls, &n), 0); ASSERT_INT_EQ(n, 1); ASSERT_NOT_NULL(calls); ASSERT_STR_EQ(calls[0].name, "get_weather"); ASSERT_STR_EQ(calls[0].arguments, "{\"location\":\"Paris\"}"); tc_tool_calls_free(calls, n); jr_free(tools); } /* Same forced dialect INSIDE a wrapper (the grammar's wrapped form * with the wrapper). */ static void test_forced_value_wrapper_wrapped(void) { jr_value *tools = jr_parse(TOOLS_JSON); const char *text = "\n\n\n" "\nParis\n\n\n\n"; tc_tool_call *calls = NULL; int n = -1; ASSERT_INT_EQ(tc_parse_tool_calls(text, tools, TC_DIALECT_QWEN_XML, &calls, &n), 0); ASSERT_INT_EQ(n, 1); ASSERT_STR_EQ(calls[0].name, "get_weather"); ASSERT_STR_EQ(calls[0].arguments, "{\"location\":\"Paris\"}"); tc_tool_calls_free(calls, n); jr_free(tools); } /* A multi-line value inside the wrapper keeps its interior newlines. */ static void test_forced_value_wrapper_multiline(void) { jr_value *tools = jr_parse(TOOLS_JSON); const char *text = "\n\n" "\nParis\nand Berlin\n\n\n"; tc_tool_call *calls = NULL; int n = -1; ASSERT_INT_EQ(tc_parse_tool_calls(text, tools, TC_DIALECT_QWEN_XML, &calls, &n), 0); ASSERT_INT_EQ(n, 1); ASSERT_STR_EQ(calls[0].arguments, "{\"location\":\"Paris\\nand Berlin\"}"); tc_tool_calls_free(calls, n); jr_free(tools); } /* Typed integer survives the wrapper (unwrapped, then schema-typed). */ static void test_forced_value_wrapper_typed_integer(void) { jr_value *tools = jr_parse(TOOLS_JSON); const char *text = "\n\n" "\nParis\n\n\n" "\n\n3\n\n\n"; tc_tool_call *calls = NULL; int n = -1; ASSERT_INT_EQ(tc_parse_tool_calls(text, tools, TC_DIALECT_QWEN_XML, &calls, &n), 0); ASSERT_INT_EQ(n, 1); ASSERT_STR_EQ(calls[0].arguments, "{\"location\":\"Paris\",\"days\":3}"); tc_tool_calls_free(calls, n); jr_free(tools); } /* An empty inner yields an EMPTY arg, not a missing one: the * model wrote both halves of the wrapper, so it closed the value deliberately, * and the parameter-level close is the whole empty-value test. */ static void test_forced_value_wrapper_empty_inner(void) { jr_value *tools = jr_parse(TOOLS_JSON); const char *text = "\n\n" "\n\n\n"; tc_tool_call *calls = NULL; int n = -1; ASSERT_INT_EQ(tc_parse_tool_calls(text, tools, TC_DIALECT_QWEN_XML, &calls, &n), 0); ASSERT_INT_EQ(n, 1); /* name kept, and the closed-around-nothing value is kept as "". */ ASSERT_STR_EQ(calls[0].arguments, "{\"location\":\"\"}"); tc_tool_calls_free(calls, n); jr_free(tools); } /* The forced-dialect value and the plain bare value of the same call yield * identical structured args (only the id differs) — proves the wrapper is fully * transparent to the client. */ static void test_forced_value_matches_plain(void) { jr_value *tools = jr_parse(TOOLS_JSON); const char *wrapped_val = "\n\n" "\nParis\n\n\n"; const char *plain_val = "\n\nParis\n\n"; tc_tool_call *cw = NULL, *cp = NULL; int nw = -1, np = -1; ASSERT_INT_EQ(tc_parse_tool_calls(wrapped_val, tools, TC_DIALECT_QWEN_XML, &cw, &nw), 0); ASSERT_INT_EQ(tc_parse_tool_calls(plain_val, tools, TC_DIALECT_QWEN_XML, &cp, &np), 0); ASSERT_INT_EQ(nw, 1); ASSERT_INT_EQ(np, 1); ASSERT_STR_EQ(cw[0].arguments, cp[0].arguments); tc_tool_calls_free(cw, nw); tc_tool_calls_free(cp, np); jr_free(tools); } /* Live MiniCPM-Q8 quirk: the value is -wrapped AND the close * is emitted malformed (""). PARAM_CLOSE is then never found, so a * naive scan drags the wrapper + junk into the value. Anchoring on (the * model closes it reliably) recovers the clean value regardless. */ static void test_forced_value_wrapper_malformed_param_close(void) { jr_value *tools = jr_parse(TOOLS_JSON); const char *text = "\n\n" "Paris\n\n"; tc_tool_call *calls = NULL; int n = -1; ASSERT_INT_EQ(tc_parse_tool_calls(text, tools, TC_DIALECT_QWEN_XML, &calls, &n), 0); ASSERT_INT_EQ(n, 1); ASSERT_STR_EQ(calls[0].name, "get_weather"); ASSERT_STR_EQ(calls[0].arguments, "{\"location\":\"Paris\"}"); tc_tool_calls_free(calls, n); jr_free(tools); } /* Multiple -wrapped params, each with the malformed close, still parse. */ static void test_forced_value_wrapper_multi_malformed(void) { jr_value *tools = jr_parse(TOOLS_JSON); const char *text = "\n" "\nParis\n\n" "\n3\n\n"; tc_tool_call *calls = NULL; int n = -1; ASSERT_INT_EQ(tc_parse_tool_calls(text, tools, TC_DIALECT_QWEN_XML, &calls, &n), 0); ASSERT_INT_EQ(n, 1); ASSERT_STR_EQ(calls[0].arguments, "{\"location\":\"Paris\",\"days\":3}"); tc_tool_calls_free(calls, n); jr_free(tools); } /* A bare value that merely CONTAINS "" mid-text (not a full enclosing * wrapper) is NOT unwrapped — only a value that both starts with and * ends with is a wrapper. */ static void test_forced_value_partial_not_unwrapped(void) { jr_value *tools = jr_parse(TOOLS_JSON); const char *text = "\n\n" "Paris not a wrapper\n\n"; tc_tool_call *calls = NULL; int n = -1; ASSERT_INT_EQ(tc_parse_tool_calls(text, tools, TC_DIALECT_QWEN_XML, &calls, &n), 0); ASSERT_INT_EQ(n, 1); ASSERT_STR_EQ(calls[0].arguments, "{\"location\":\"Paris not a wrapper\"}"); tc_tool_calls_free(calls, n); jr_free(tools); } /* ============================================================ * Ornith JSON dialect (TC_DIALECT_ORNITH_JSON) — Phase 1. * The model emits a markdown-fenced or bare {"function"|"name":NAME, * "arguments":{…}} object instead of XML. * ============================================================ */ /* A read-tool schema (path string) matching a live agent client's envelope. */ static const char *READ_TOOLS = "[{\"type\":\"function\",\"function\":{\"name\":\"read\"," "\"parameters\":{\"type\":\"object\"," "\"properties\":{\"path\":{\"type\":\"string\"}}," "\"required\":[\"path\"]}}}]"; static void test_ornith_fenced_single(void) { jr_value *tools = jr_parse(READ_TOOLS); const char *text = "```json\n{\n \"function\": \"read\",\n" " \"arguments\": {\n \"path\": \"/etc/hostname\"\n }\n}\n```"; tc_tool_call *calls = NULL; int n = -1; ASSERT_INT_EQ(tc_parse_tool_calls(text, tools, TC_DIALECT_ORNITH_JSON, &calls, &n), 0); ASSERT_INT_EQ(n, 1); ASSERT_STR_EQ(calls[0].name, "read"); ASSERT_STR_EQ(calls[0].arguments, "{\"path\":\"/etc/hostname\"}"); ASSERT_TRUE(strncmp(calls[0].id, "call_", 5) == 0); tc_tool_calls_free(calls, n); jr_free(tools); } /* A bare (unfenced) object with reasoning prose before it. */ static void test_ornith_bare_with_prose(void) { jr_value *tools = jr_parse(READ_TOOLS); const char *text = "Let me read that file for you.\n" "{\"function\": \"read\", \"arguments\": {\"path\": \"/tmp/a.txt\"}}"; tc_tool_call *calls = NULL; int n = -1; ASSERT_INT_EQ(tc_parse_tool_calls(text, tools, TC_DIALECT_ORNITH_JSON, &calls, &n), 0); ASSERT_INT_EQ(n, 1); ASSERT_STR_EQ(calls[0].name, "read"); ASSERT_STR_EQ(calls[0].arguments, "{\"path\":\"/tmp/a.txt\"}"); tc_tool_calls_free(calls, n); jr_free(tools); } /* The "name" key spelling (some emissions use it instead of "function"). */ static void test_ornith_name_key(void) { jr_value *tools = jr_parse(READ_TOOLS); const char *text = "```json\n{\"name\": \"read\", \"arguments\": {\"path\": \"/x\"}}\n```"; tc_tool_call *calls = NULL; int n = -1; ASSERT_INT_EQ(tc_parse_tool_calls(text, tools, TC_DIALECT_ORNITH_JSON, &calls, &n), 0); ASSERT_INT_EQ(n, 1); ASSERT_STR_EQ(calls[0].name, "read"); ASSERT_STR_EQ(calls[0].arguments, "{\"path\":\"/x\"}"); tc_tool_calls_free(calls, n); jr_free(tools); } /* Schema typing: integer args stay JSON literals through the round-trip * (arguments objects are re-serialized from the parsed DOM). */ static void test_ornith_typed_args(void) { jr_value *tools = jr_parse(TOOLS_JSON); /* get_weather: location, unit, days(int) */ const char *text = "{\"function\": \"get_weather\", \"arguments\": " "{\"location\": \"Paris\", \"days\": 3}}"; tc_tool_call *calls = NULL; int n = -1; ASSERT_INT_EQ(tc_parse_tool_calls(text, tools, TC_DIALECT_ORNITH_JSON, &calls, &n), 0); ASSERT_INT_EQ(n, 1); ASSERT_STR_EQ(calls[0].name, "get_weather"); ASSERT_STR_EQ(calls[0].arguments, "{\"location\":\"Paris\",\"days\":3}"); tc_tool_calls_free(calls, n); jr_free(tools); } /* Parallel calls: two fenced JSON blocks in one emission. */ static void test_ornith_parallel_blocks(void) { jr_value *tools = jr_parse(READ_TOOLS); const char *text = "```json\n{\"function\":\"read\",\"arguments\":{\"path\":\"/a\"}}\n```\n" "```json\n{\"function\":\"read\",\"arguments\":{\"path\":\"/b\"}}\n```"; tc_tool_call *calls = NULL; int n = -1; ASSERT_INT_EQ(tc_parse_tool_calls(text, tools, TC_DIALECT_ORNITH_JSON, &calls, &n), 0); ASSERT_INT_EQ(n, 2); ASSERT_STR_EQ(calls[0].arguments, "{\"path\":\"/a\"}"); ASSERT_STR_EQ(calls[1].arguments, "{\"path\":\"/b\"}"); tc_tool_calls_free(calls, n); jr_free(tools); } /* An unknown function name is dropped (never surfaced as a bogus call). */ static void test_ornith_unknown_name_dropped(void) { jr_value *tools = jr_parse(READ_TOOLS); const char *text = "{\"function\":\"delete_everything\",\"arguments\":{\"path\":\"/\"}}"; tc_tool_call *calls = NULL; int n = -1; ASSERT_INT_EQ(tc_parse_tool_calls(text, tools, TC_DIALECT_ORNITH_JSON, &calls, &n), 0); ASSERT_INT_EQ(n, 0); tc_tool_calls_free(calls, n); jr_free(tools); } /* Malformed / truncated JSON → zero calls (never a bogus call). */ static void test_ornith_truncated_zero(void) { jr_value *tools = jr_parse(READ_TOOLS); const char *text = "```json\n{\"function\":\"read\",\"arguments\":{\"path\":"; tc_tool_call *calls = NULL; int n = -1; ASSERT_INT_EQ(tc_parse_tool_calls(text, tools, TC_DIALECT_ORNITH_JSON, &calls, &n), 0); ASSERT_INT_EQ(n, 0); tc_tool_calls_free(calls, n); jr_free(tools); } /* Plain prose with no JSON object → zero calls. */ static void test_ornith_prose_only(void) { jr_value *tools = jr_parse(READ_TOOLS); const char *text = "I don't think I need a tool for this one."; tc_tool_call *calls = NULL; int n = -1; ASSERT_INT_EQ(tc_parse_tool_calls(text, tools, TC_DIALECT_ORNITH_JSON, &calls, &n), 0); ASSERT_INT_EQ(n, 0); tc_tool_calls_free(calls, n); jr_free(tools); } /* Render→parse round-trip for ornith: history render produces a fenced JSON * block the ornith parser reads back into the identical call. */ static void test_ornith_render_roundtrip(void) { jr_value *tools = jr_parse(READ_TOOLS); const char *calls_json = "[{\"id\":\"call_1\",\"type\":\"function\",\"function\":" "{\"name\":\"read\",\"arguments\":\"{\\\"path\\\":\\\"/etc/hostname\\\"}\"}}]"; jr_value *tc = jr_parse(calls_json); char *rendered = tc_render_assistant_tool_calls(tc, true, TC_DIALECT_ORNITH_JSON); ASSERT_NOT_NULL(rendered); ASSERT_TRUE(strstr(rendered, "```json") != NULL); tc_tool_call *calls = NULL; int n = -1; ASSERT_INT_EQ(tc_parse_tool_calls(rendered, tools, TC_DIALECT_ORNITH_JSON, &calls, &n), 0); ASSERT_INT_EQ(n, 1); ASSERT_STR_EQ(calls[0].name, "read"); ASSERT_STR_EQ(calls[0].arguments, "{\"path\":\"/etc/hostname\"}"); tc_tool_calls_free(calls, n); free(rendered); jr_free(tc); jr_free(tools); } /* A bare "{" in a QWEN_XML request must NOT be parsed as a call (dialect gate: * the JSON parser only runs for ORNITH_JSON). */ static void test_json_ignored_under_xml_dialect(void) { jr_value *tools = jr_parse(READ_TOOLS); const char *text = "{\"function\":\"read\",\"arguments\":{\"path\":\"/etc/hostname\"}}"; tc_tool_call *calls = NULL; int n = -1; ASSERT_INT_EQ(tc_parse_tool_calls(text, tools, TC_DIALECT_QWEN_XML, &calls, &n), 0); ASSERT_INT_EQ(n, 0); tc_tool_calls_free(calls, n); jr_free(tools); } /* Dialect resolver (tc_dialect_for_model) — name/arch table. */ static void test_dialect_resolver(void) { ASSERT_INT_EQ(tc_dialect_for_model("ornith", false), TC_DIALECT_ORNITH_JSON); ASSERT_INT_EQ(tc_dialect_for_model("Ornith-1.0-9B", false), TC_DIALECT_ORNITH_JSON); ASSERT_INT_EQ(tc_dialect_for_model("saber-orch", false), TC_DIALECT_QWEN_XML); ASSERT_INT_EQ(tc_dialect_for_model("nex-n2-mini", false), TC_DIALECT_QWEN_XML); ASSERT_INT_EQ(tc_dialect_for_model("minicpm5", true), TC_DIALECT_MINICPM_XML); /* Name table wins over the minicpm flag for an ornith model. */ ASSERT_INT_EQ(tc_dialect_for_model("ornith", true), TC_DIALECT_ORNITH_JSON); ASSERT_INT_EQ(tc_dialect_for_model(NULL, false), TC_DIALECT_QWEN_XML); } /* ============================================================ * Abandoned call (TC_PARSE_CALL_ABANDONED). * * The defect: an opener and a were emitted, the parameter * was never closed, and no value was ever written — the model stopped in the * middle of the call. That used to parse to `{}`, which is byte-identical to * what a deliberately EMPTY argument produces, so a client could not tell the * two apart. The neighbour tests below are the point of the whole fix: three * conditions, three different answers. * ============================================================ */ static void test_abandoned_call_fixture(void) { jr_value *tools = jr_parse(READ_TOOLS); tc_tool_call *calls = NULL; int n = -1; tc_parse_error err = {0}; /* The 57-byte abandoned emission, as the diagnostic reports it. */ ASSERT_INT_EQ(tc_parse_tool_calls_diagnosed(FX_ABANDONED_READ_PATH, tools, TC_DIALECT_QWEN_XML, &calls, &n, &err, NULL), 0); ASSERT_INT_EQ(n, 0); /* not a call — a truncation */ ASSERT_INT_EQ(err.kind, TC_PARSE_CALL_ABANDONED); ASSERT_STR_EQ(tc_parse_failure_code(err.kind), "call_abandoned"); ASSERT_STR_EQ(err.function_name, "read"); ASSERT_STR_EQ(err.parameter_name, "path"); /* names the key left open */ ASSERT_TRUE(err.message != NULL); ASSERT_TRUE(strstr(err.message, "read") != NULL); ASSERT_TRUE(strstr(err.message, "abandoned") != NULL); ASSERT_TRUE(strstr(err.message, "'path'") != NULL); tc_parse_error_free(&err); ASSERT_INT_EQ(err.kind, TC_PARSE_OK); /* reset, nothing to leak */ tc_tool_calls_free(calls, n); jr_free(tools); } /* Both entry points agree on the RESULT: the abandoned call is absent either * way. tc_parse_tool_calls just does not say why. */ static void test_abandoned_legacy_entry_point_agrees(void) { jr_value *tools = jr_parse(READ_TOOLS); tc_tool_call *calls = NULL; int n = -1; ASSERT_INT_EQ(tc_parse_tool_calls(FX_ABANDONED_READ_PATH, tools, TC_DIALECT_QWEN_XML, &calls, &n), 0); ASSERT_INT_EQ(n, 0); tc_tool_calls_free(calls, n); jr_free(tools); } /* The call ends at END OF TEXT rather than at — same shape, same * verdict. The close tag is not what makes it abandoned; the missing * plus the missing value is. */ static void test_abandoned_at_end_of_text(void) { jr_value *tools = jr_parse(READ_TOOLS); const char *text = "\n\n"; tc_tool_call *calls = NULL; int n = -1; tc_parse_error err = {0}; ASSERT_INT_EQ(tc_parse_tool_calls_diagnosed(text, tools, TC_DIALECT_QWEN_XML, &calls, &n, &err, NULL), 0); ASSERT_INT_EQ(n, 0); ASSERT_INT_EQ(err.kind, TC_PARSE_CALL_ABANDONED); ASSERT_STR_EQ(err.parameter_name, "path"); tc_parse_error_free(&err); tc_tool_calls_free(calls, n); jr_free(tools); } /* NEIGHBOUR 1 — a genuinely EMPTY argument. The parameter IS closed, so the * model finished writing the argument; the call is emitted, and the argument * with it, empty. This is the condition that used to be indistinguishable from * abandoned — and dropping the value put it right back, because a member the * serve deleted and one the model never sent read identically at the client. */ static void test_abandoned_neighbour_empty_value_still_a_call(void) { jr_value *tools = jr_parse(READ_TOOLS); const char *text = "\n\n\n\n\n" ""; tc_tool_call *calls = NULL; int n = -1; tc_parse_error err = {0}; ASSERT_INT_EQ(tc_parse_tool_calls_diagnosed(text, tools, TC_DIALECT_QWEN_XML, &calls, &n, &err, NULL), 0); ASSERT_INT_EQ(n, 1); ASSERT_STR_EQ(calls[0].name, "read"); ASSERT_STR_EQ(calls[0].arguments, "{\"path\":\"\"}"); ASSERT_INT_EQ(err.kind, TC_PARSE_OK); /* NOT abandoned */ tc_parse_error_free(&err); tc_tool_calls_free(calls, n); jr_free(tools); } /* NEIGHBOUR 2 — a well-formed call is untouched: still one call, still its * arguments, and no error. */ static void test_abandoned_neighbour_wellformed_untouched(void) { jr_value *tools = jr_parse(READ_TOOLS); const char *text = "\n\n\n/etc/hostname\n" "\n"; tc_tool_call *calls = NULL; int n = -1; tc_parse_error err = {0}; ASSERT_INT_EQ(tc_parse_tool_calls_diagnosed(text, tools, TC_DIALECT_QWEN_XML, &calls, &n, &err, NULL), 0); ASSERT_INT_EQ(n, 1); ASSERT_STR_EQ(calls[0].arguments, "{\"path\":\"/etc/hostname\"}"); ASSERT_INT_EQ(err.kind, TC_PARSE_OK); tc_parse_error_free(&err); tc_tool_calls_free(calls, n); jr_free(tools); } /* NEIGHBOUR 3 — no opener at all is untouched: zero calls AND no error, which * is a different answer from the abandoned zero-calls-plus-error. */ static void test_abandoned_neighbour_no_opener_untouched(void) { jr_value *tools = jr_parse(READ_TOOLS); const char *text = "I could read that file, but let me think first."; tc_tool_call *calls = NULL; int n = -1; tc_parse_error err = {0}; ASSERT_INT_EQ(tc_parse_tool_calls_diagnosed(text, tools, TC_DIALECT_QWEN_XML, &calls, &n, &err, NULL), 0); ASSERT_INT_EQ(n, 0); ASSERT_INT_EQ(err.kind, TC_PARSE_OK); tc_parse_error_free(&err); tc_tool_calls_free(calls, n); jr_free(tools); } /* NEIGHBOUR 4 — the missing close tag ALONE is not abandonment. The live * MiniCPM-Q8 "" quirk drops the close over a perfectly good value; * that call must still come through, unflagged. */ static void test_abandoned_unclosed_but_valued_is_a_call(void) { jr_value *tools = jr_parse(READ_TOOLS); const char *text = "\n\n/etc/hostname\n"; tc_tool_call *calls = NULL; int n = -1; tc_parse_error err = {0}; ASSERT_INT_EQ(tc_parse_tool_calls_diagnosed(text, tools, TC_DIALECT_QWEN_XML, &calls, &n, &err, NULL), 0); ASSERT_INT_EQ(n, 1); ASSERT_STR_EQ(calls[0].arguments, "{\"path\":\"/etc/hostname\"}"); ASSERT_INT_EQ(err.kind, TC_PARSE_OK); tc_parse_error_free(&err); tc_tool_calls_free(calls, n); jr_free(tools); } /* A healthy call BEFORE an abandoned one: the good call survives and the * abandoned one is reported, so one truncation does not cost the whole turn. */ static void test_abandoned_after_a_good_call(void) { jr_value *tools = jr_parse(READ_TOOLS); const char *text = "\n\n/etc/hostname\n\n\n" "\n\n"; tc_tool_call *calls = NULL; int n = -1; tc_parse_error err = {0}; ASSERT_INT_EQ(tc_parse_tool_calls_diagnosed(text, tools, TC_DIALECT_QWEN_XML, &calls, &n, &err, NULL), 0); ASSERT_INT_EQ(n, 1); ASSERT_STR_EQ(calls[0].arguments, "{\"path\":\"/etc/hostname\"}"); ASSERT_INT_EQ(err.kind, TC_PARSE_CALL_ABANDONED); ASSERT_STR_EQ(err.parameter_name, "path"); tc_parse_error_free(&err); tc_tool_calls_free(calls, n); jr_free(tools); } /* ============================================================ * Main * ============================================================ */ int main(void) { printf("\n tool-call parser tests\n\n"); RUN_TEST(wrapped_single); RUN_TEST(bare_single); RUN_TEST(bare_matches_wrapped); RUN_TEST(bare_typed_integer); RUN_TEST(bare_multi); RUN_TEST(wrapped_multi); RUN_TEST(reasoning_then_bare_call); RUN_TEST(bare_doubled_close); RUN_TEST(bare_call_stray_closer); RUN_TEST(no_tools_text); RUN_TEST(empty); RUN_TEST(null_text); RUN_TEST(garbage_unterminated_name); RUN_TEST(garbage_empty_name); RUN_TEST(hallucinated_name_rejected); RUN_TEST(hallucinated_name_rejected_wrapped); RUN_TEST(known_name_kept_bogus_dropped); RUN_TEST(no_schema_permissive); RUN_TEST(minicpm_single); RUN_TEST(minicpm_typed_multi); RUN_TEST(minicpm_cdata); RUN_TEST(minicpm_single_quotes); RUN_TEST(minicpm_reasoning_prefix); RUN_TEST(minicpm_preamble); RUN_TEST(forced_value_wrapper_bare); RUN_TEST(forced_value_wrapper_wrapped); RUN_TEST(forced_value_wrapper_multiline); RUN_TEST(forced_value_wrapper_typed_integer); RUN_TEST(forced_value_wrapper_empty_inner); RUN_TEST(forced_value_matches_plain); RUN_TEST(forced_value_wrapper_malformed_param_close); RUN_TEST(forced_value_wrapper_multi_malformed); RUN_TEST(forced_value_partial_not_unwrapped); RUN_TEST(ornith_fenced_single); RUN_TEST(ornith_bare_with_prose); RUN_TEST(ornith_name_key); RUN_TEST(ornith_typed_args); RUN_TEST(ornith_parallel_blocks); RUN_TEST(ornith_unknown_name_dropped); RUN_TEST(ornith_truncated_zero); RUN_TEST(ornith_prose_only); RUN_TEST(ornith_render_roundtrip); RUN_TEST(json_ignored_under_xml_dialect); RUN_TEST(dialect_resolver); RUN_TEST(abandoned_call_fixture); RUN_TEST(abandoned_legacy_entry_point_agrees); RUN_TEST(abandoned_at_end_of_text); RUN_TEST(abandoned_neighbour_empty_value_still_a_call); RUN_TEST(abandoned_neighbour_wellformed_untouched); RUN_TEST(abandoned_neighbour_no_opener_untouched); RUN_TEST(abandoned_unclosed_but_valued_is_a_call); RUN_TEST(abandoned_after_a_good_call); TEST_REPORT(); return __ft_failed > 0 ? 1 : 0; }