/* SPDX-License-Identifier: AGPL-3.0-or-later */ /* Copyright (C) 2026 David (Corey) Herrera */ /* * Unit tests for the JSON tool-call decoding grammar * (src/serve/tool_grammar_json.c) — ornith's dialect. * * The grammar must ACCEPT ornith's real emission (a fenced OR bare JSON object * {"function":NAME,"arguments":{KEY:value,…}}, "name"/"parameters" spellings * too) and REJECT the off-contract drift: an unknown NAME/KEY, an empty value, * a missing required key, an empty {} when a key is required, and a value * outside a parameter's schema `enum`. Free prose stays UNCONSTRAINED in AUTO; * FORCED compels a call. This is the Phase-2 gate: the `required` guarantee for * ornith, structurally. * * Pure C, libc-only (json_reader for the tools DOM) — fully local, no CUDA. */ #include "lib/test.h" #include "../src/serve/tool_grammar_json.h" #include "../src/serve/json_reader.h" #include #include #include /* Two tools: * read(path: string, minLength 1) — path required, may not be empty * add(a: integer, b: integer) — both required * * `path` carries "minLength":1 because the client's real schema does * (the client's schema code) and because that keyword is now load- * bearing: it is the ONLY thing that separates `read`'s path (empty is the * empty-path wedge) from `edit`'s new_string (empty is the deletion). A * fixture that omitted it would be asserting the grammar's behaviour for a * schema the client never sends. See EMPTY_OK_TOOLS_JSON for the other side. */ static const char *TOOLS_JSON = "[{\"type\":\"function\",\"function\":{\"name\":\"read\"," "\"parameters\":{\"type\":\"object\",\"properties\":{" "\"path\":{\"type\":\"string\",\"minLength\":1}},\"required\":[\"path\"]}}}," "{\"type\":\"function\",\"function\":{\"name\":\"add\"," "\"parameters\":{\"type\":\"object\",\"properties\":{" "\"a\":{\"type\":\"integer\"},\"b\":{\"type\":\"integer\"}}," "\"required\":[\"a\",\"b\"]}}}]"; /* The client's REAL `edit` shape (the client's schema code): all three * parameters are `required`, `path` and `old_string` carry "minLength":1, and * `new_string` deliberately does NOT — because an empty new_string is what a * deletion IS. Requiredness is therefore not the empty-value predicate, and * this fixture is what proves the grammar does not treat it as one. */ static const char *EMPTY_OK_TOOLS_JSON = "[{\"type\":\"function\",\"function\":{\"name\":\"edit\"," "\"parameters\":{\"type\":\"object\",\"properties\":{" "\"path\":{\"type\":\"string\",\"minLength\":1}," "\"old_string\":{\"type\":\"string\",\"minLength\":1}," "\"new_string\":{\"type\":\"string\"}}," "\"required\":[\"path\",\"old_string\",\"new_string\"]}}}]"; /* The client's REAL switch_mode schema (the client's schema code) — the * live victim of the dropped-`enum` gap: before the enum was compiled, this * grammar accepted "mode":"banana" as a complete, well-formed call. */ static const char *SWITCH_MODE_JSON = "[{\"type\":\"function\",\"function\":{\"name\":\"switch_mode\"," "\"parameters\":{\"type\":\"object\",\"properties\":" "{\"mode\":{\"type\":\"string\"," "\"enum\":[\"chat\",\"plan\",\"act\",\"orchestrate\"]," "\"description\":\"the mode to switch to\"}," "\"reason\":{\"type\":\"string\",\"description\":\"one short line\"}}," "\"required\":[\"mode\",\"reason\"]}}}]"; /* log(msg: string REQUIRED, level: string enum OPTIONAL) — an enum on a key the * model may legally leave out entirely. */ static const char *ENUM_OPTIONAL_JSON = "[{\"type\":\"function\",\"function\":{\"name\":\"log\"," "\"parameters\":{\"type\":\"object\",\"properties\":{" "\"msg\":{\"type\":\"string\"}," "\"level\":{\"type\":\"string\",\"enum\":[\"info\",\"warn\",\"error\"]}}," "\"required\":[\"msg\"]}}}]"; /* Every enum shape that must DEGRADE to the plain type rather than reject: * nums — non-string members * mixed — one non-string member among strings * blank — an empty member (unsatisfiable if compiled) * nolist — an empty enum array * clash — string members on an integer-typed key (schema contradiction) * notype — string members with NO declared type: this one DOES compile. */ static const char *ENUM_DEGRADE_JSON = "[{\"type\":\"function\",\"function\":{\"name\":\"cfg\"," "\"parameters\":{\"type\":\"object\",\"properties\":{" "\"nums\":{\"type\":\"integer\",\"enum\":[1,2,3]}," "\"mixed\":{\"type\":\"string\",\"enum\":[\"a\",7]}," "\"blank\":{\"type\":\"string\",\"enum\":[\"a\",\"\"]}," "\"nolist\":{\"type\":\"string\",\"enum\":[]}," "\"clash\":{\"type\":\"integer\",\"enum\":[\"a\",\"b\"]}," "\"notype\":{\"enum\":[\"yes\",\"no\"]}}," "\"required\":[\"nums\"]}}}]"; /* get_weather(city: string, unit: string) — only city required (unit optional). */ static const char *WEATHER_JSON = "[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\"," "\"parameters\":{\"type\":\"object\",\"properties\":{" "\"city\":{\"type\":\"string\"},\"unit\":{\"type\":\"string\"}}," "\"required\":[\"city\"]}}}]"; /* Feed a whole string char-by-char through an AUTO state; each char must stay * allowed. Returns the final state (caller frees). */ static tgj_state *feed_ok(tgj_grammar *g, const char *s, bool *all_allowed) { tgj_state *st = tgj_state_new(g); char one[2] = {0, 0}; *all_allowed = true; for (const char *p = s; *p; p++) { one[0] = *p; if (!tgj_token_allowed(st, one)) { *all_allowed = false; break; } tgj_advance(st, one); } return st; } static bool feed_rejects(tgj_grammar *g, const char *s) { bool ok; tgj_state *st = feed_ok(g, s, &ok); tgj_state_free(st); return !ok; } /* Feed into an already-created state (forced/other). */ static void feed_into(tgj_state *st, const char *s, bool *all_allowed) { char one[2] = {0, 0}; *all_allowed = true; for (const char *p = s; *p; p++) { one[0] = *p; if (!tgj_token_allowed(st, one)) { *all_allowed = false; break; } tgj_advance(st, one); } } static bool forced_rejects(tgj_grammar *g, const char *s) { tgj_state *st = tgj_state_new_forced(g, NULL); tgj_reasoning_end(st); bool ok; feed_into(st, s, &ok); tgj_state_free(st); return !ok; } /* ============================================================ * ACCEPT: fenced single call. * ============================================================ */ static void test_fenced_accepts(void) { jr_value *tools = jr_parse(TOOLS_JSON); tgj_grammar *g = tgj_build(tools); ASSERT_NOT_NULL(g); const char *call = "```json\n{\"function\": \"read\", \"arguments\": " "{\"path\": \"/etc/hostname\"}}\n```"; bool ok; tgj_state *st = feed_ok(g, call, &ok); ASSERT_TRUE(ok); ASSERT_TRUE(tgj_complete(st)); tgj_state_free(st); tgj_free(g); jr_free(tools); } /* ============================================================ * ACCEPT: bare (unfenced) call. * ============================================================ */ static void test_bare_accepts(void) { jr_value *tools = jr_parse(TOOLS_JSON); tgj_grammar *g = tgj_build(tools); const char *call = "{\"function\":\"read\",\"arguments\":{\"path\":\"README.md\"}}"; bool ok; tgj_state *st = feed_ok(g, call, &ok); ASSERT_TRUE(ok); ASSERT_TRUE(tgj_complete(st)); tgj_state_free(st); tgj_free(g); jr_free(tools); } /* ============================================================ * ACCEPT: the "name"/"parameters" key spellings. * ============================================================ */ static void test_name_and_parameters_keys(void) { jr_value *tools = jr_parse(TOOLS_JSON); tgj_grammar *g = tgj_build(tools); const char *call = "{\"name\":\"read\",\"parameters\":{\"path\":\"x\"}}"; bool ok; tgj_state *st = feed_ok(g, call, &ok); ASSERT_TRUE(ok); ASSERT_TRUE(tgj_complete(st)); tgj_state_free(st); tgj_free(g); jr_free(tools); } /* ============================================================ * ACCEPT: integer-typed args (numeric literals), both required present. * ============================================================ */ static void test_integer_args(void) { jr_value *tools = jr_parse(TOOLS_JSON); tgj_grammar *g = tgj_build(tools); const char *call = "{\"function\":\"add\",\"arguments\":{\"a\": 5, \"b\": 37}}"; bool ok; tgj_state *st = feed_ok(g, call, &ok); ASSERT_TRUE(ok); ASSERT_TRUE(tgj_complete(st)); tgj_state_free(st); tgj_free(g); jr_free(tools); } /* An integer param cannot take a quoted string value. */ static void test_integer_rejects_string(void) { jr_value *tools = jr_parse(TOOLS_JSON); tgj_grammar *g = tgj_build(tools); ASSERT_TRUE(feed_rejects(g, "{\"function\":\"add\",\"arguments\":{\"a\": \"5\"")); tgj_free(g); jr_free(tools); } /* ============================================================ * REJECT: unknown function name / unknown parameter key. * ============================================================ */ static void test_unknown_name_rejected(void) { jr_value *tools = jr_parse(TOOLS_JSON); tgj_grammar *g = tgj_build(tools); /* "reads" diverges from "read" at the trailing 's' before the closing quote * (read is a valid prefix but the exact-match on '"' fails). */ ASSERT_TRUE(feed_rejects(g, "{\"function\":\"reads\"")); ASSERT_TRUE(feed_rejects(g, "{\"function\":\"glob\"")); tgj_free(g); jr_free(tools); } static void test_unknown_key_rejected(void) { jr_value *tools = jr_parse(TOOLS_JSON); tgj_grammar *g = tgj_build(tools); /* read has only "path"; "mode" is not a key. */ ASSERT_TRUE(feed_rejects(g, "{\"function\":\"read\",\"arguments\":{\"mode\"")); tgj_free(g); jr_free(tools); } /* ============================================================ * REJECT (structurally): empty value for a required string. * ============================================================ */ static void test_empty_value_impossible(void) { jr_value *tools = jr_parse(TOOLS_JSON); tgj_grammar *g = tgj_build(tools); /* `"path": ""` — the closing quote after zero content chars is DEAD, because * read.path declares "minLength":1. */ ASSERT_TRUE(feed_rejects(g, "{\"function\":\"read\",\"arguments\":{\"path\": \"\"")); tgj_free(g); jr_free(tools); } /* ============================================================ * ACCEPT: an empty value for a string with NO length floor. * * The mirror of the test above, and the reason the floor is per-key rather than * blanket. `edit`'s new_string is `required` AND legitimately empty — a * deletion — so a guard keyed on requiredness, or an unconditional one (which is * what this dialect shipped for a while), makes a LEGAL call unemittable. Under * tool_choice=required that is its own wedge: the model must emit a call, and * the only correct one is masked. * ============================================================ */ static void test_empty_value_allowed_without_a_floor(void) { jr_value *tools = jr_parse(EMPTY_OK_TOOLS_JSON); tgj_grammar *g = tgj_build(tools); bool ok; tgj_state *st = feed_ok(g, "{\"function\":\"edit\",\"arguments\":{\"path\":\"/tmp/x.c\"," "\"old_string\":\"drop me\\n\",\"new_string\":\"\"}}", &ok); ASSERT_TRUE(ok); ASSERT_TRUE(tgj_complete(st)); tgj_state_free(st); /* CONTROL: the floored siblings in the SAME tool are still refused, so this * is a per-key verdict and not the guard having been switched off. */ ASSERT_TRUE(feed_rejects(g, "{\"function\":\"edit\",\"arguments\":{\"path\":\"\"")); ASSERT_TRUE(feed_rejects(g, "{\"function\":\"edit\",\"arguments\":{\"path\":\"/tmp/x.c\"," "\"old_string\":\"\"")); tgj_free(g); jr_free(tools); } /* ============================================================ * REJECT: a WHITESPACE-ONLY value for a required string. * * The guard above used to key on "any in-quote char", so `" "` and `"\n"` passed * it while carrying nothing. Unlike the XML dialect the ornith parser does not * trim, so this forwarded `{"path":" "}` verbatim — a different shape from the * XML path's fabricated `{"path":""}`, but the same wedge once it reached the * tool (the client's require_string() skips whitespace before testing for empty). * * The escape cases are the second half of the hole and survive fixing only the * plain-char site: the escape pair used to set val_content without decoding it. * ============================================================ */ static void test_whitespace_only_value_impossible(void) { jr_value *tools = jr_parse(TOOLS_JSON); tgj_grammar *g = tgj_build(tools); const char *head = "{\"function\":\"read\",\"arguments\":{\"path\": "; char buf[256]; const char *ws_values[] = { "\" \"", /* one space */ "\" \\t \"", /* mixed run */ "\"\\n\"", /* escaped newline — the escape-pair hole */ "\"\\t\"", /* escaped tab */ "\"\\r\"", /* escaped CR */ "\"\\f\"", /* escaped form feed */ }; for (size_t i = 0; i < sizeof(ws_values) / sizeof(ws_values[0]); i++) { snprintf(buf, sizeof(buf), "%s%s", head, ws_values[i]); ASSERT_TRUE(feed_rejects(g, buf)); } /* CONTROL: real content still accepted, including with whitespace padding * and an escape that is NOT whitespace (\\b, \\", \\\\). */ snprintf(buf, sizeof(buf), "%s\" /tmp/x.c \"", head); ASSERT_FALSE(feed_rejects(g, buf)); snprintf(buf, sizeof(buf), "%s\"\\b\"", head); ASSERT_FALSE(feed_rejects(g, buf)); snprintf(buf, sizeof(buf), "%s\"a\\nb\"", head); ASSERT_FALSE(feed_rejects(g, buf)); tgj_free(g); jr_free(tools); } /* ============================================================ * REJECT (structurally): empty {} arguments when a key is required — the outer * object cannot close until the required key is present. * ============================================================ */ static void test_empty_args_impossible_when_required(void) { jr_value *tools = jr_parse(TOOLS_JSON); tgj_grammar *g = tgj_build(tools); bool ok; /* "arguments":{} then attempt to close — the '}' that would close the arg * object is rejected because required_left > 0 (path outstanding). */ tgj_state *st = feed_ok(g, "{\"function\":\"read\",\"arguments\":{}", &ok); ASSERT_FALSE(ok); /* the inner '}' closing an empty args object is DEAD */ tgj_state_free(st); /* The add tool needs BOTH a and b: closing after only a is impossible. */ ASSERT_TRUE(feed_rejects(g, "{\"function\":\"add\",\"arguments\":{\"a\": 5}")); tgj_free(g); jr_free(tools); } /* An OPTIONAL-only extra can be omitted: get_weather with just city closes. */ static void test_optional_key_omittable(void) { jr_value *tools = jr_parse(WEATHER_JSON); tgj_grammar *g = tgj_build(tools); const char *call = "{\"function\":\"get_weather\",\"arguments\":{\"city\":\"Paris\"}}"; bool ok; tgj_state *st = feed_ok(g, call, &ok); ASSERT_TRUE(ok); ASSERT_TRUE(tgj_complete(st)); tgj_state_free(st); tgj_free(g); jr_free(tools); } /* ============================================================ * ACCEPT: a key split across token boundaries still matches. * ============================================================ */ static void test_cross_token_key(void) { jr_value *tools = jr_parse(WEATHER_JSON); tgj_grammar *g = tgj_build(tools); tgj_state *st = tgj_state_new(g); ASSERT_TRUE(tgj_token_allowed(st, "{\"function\":\"get_")); tgj_advance(st, "{\"function\":\"get_"); ASSERT_TRUE(tgj_token_allowed(st, "weather\",")); tgj_advance(st, "weather\","); ASSERT_TRUE(tgj_token_allowed(st, "\"arguments\":{\"ci")); tgj_advance(st, "\"arguments\":{\"ci"); ASSERT_TRUE(tgj_token_allowed(st, "ty\":\"NY\"}}")); tgj_advance(st, "ty\":\"NY\"}}"); ASSERT_TRUE(tgj_complete(st)); tgj_state_free(st); /* A key split that diverges is still rejected. */ tgj_state *bad = tgj_state_new(g); tgj_advance(bad, "{\"function\":\"get_weather\",\"arguments\":{\"ci"); ASSERT_FALSE(tgj_token_allowed(bad, "x\"")); /* "cix" not a key */ tgj_state_free(bad); tgj_free(g); jr_free(tools); } /* ============================================================ * AUTO: free prose before the call is unconstrained; pure prose never forced. * ============================================================ */ static void test_prose_then_call_auto(void) { jr_value *tools = jr_parse(TOOLS_JSON); tgj_grammar *g = tgj_build(tools); const char *txt = "Let me read that file for you.\n" "```json\n{\"function\":\"read\",\"arguments\":{\"path\":\"a.txt\"}}\n```"; bool ok; tgj_state *st = feed_ok(g, txt, &ok); ASSERT_TRUE(ok); ASSERT_TRUE(tgj_complete(st)); tgj_state_free(st); /* Pure prose, no call → never forced, never in a call. */ tgj_state *p = feed_ok(g, "The answer is 42, no tools needed.", &ok); ASSERT_TRUE(ok); ASSERT_FALSE(tgj_in_call(p)); ASSERT_FALSE(tgj_complete(p)); ASSERT_FALSE(tgj_mask_active(p)); /* AUTO: FREE prose unmasked */ tgj_state_free(p); tgj_free(g); jr_free(tools); } /* ============================================================ * FORCED: after reasoning, prose is rejected; a valid call is compelled. * ============================================================ */ static void test_forced_rejects_prose(void) { jr_value *tools = jr_parse(TOOLS_JSON); tgj_grammar *g = tgj_build(tools); ASSERT_TRUE(forced_rejects(g, "I cannot do that.")); ASSERT_TRUE(forced_rejects(g, "Sure! {\"function\":\"read\"}")); /* A valid call from the start (reasoning ended) is accepted. */ tgj_state *st = tgj_state_new_forced(g, NULL); tgj_reasoning_end(st); bool ok; feed_into(st, "```json\n{\"function\":\"read\",\"arguments\":{\"path\":\"x\"}}\n```", &ok); ASSERT_TRUE(ok); ASSERT_TRUE(tgj_complete(st)); tgj_state_free(st); tgj_free(g); jr_free(tools); } /* FORCED: reasoning is allowed first, then "" self-fires the constraint. */ static void test_forced_self_detects_think_close(void) { jr_value *tools = jr_parse(TOOLS_JSON); tgj_grammar *g = tgj_build(tools); tgj_state *st = tgj_state_new_forced(g, NULL); bool ok; feed_into(st, "I should read the file.", &ok); ASSERT_TRUE(ok); ASSERT_FALSE(tgj_mask_active(st)); /* grace */ feed_into(st, "", &ok); ASSERT_TRUE(ok); ASSERT_TRUE(tgj_mask_active(st)); /* self-detected → opener compelled */ ASSERT_FALSE(tgj_token_allowed(st, "Sorry, I can't.")); ASSERT_TRUE(tgj_token_allowed(st, "```json\n{")); feed_into(st, "```json\n{\"function\":\"read\",\"arguments\":{\"path\":\"a\"}}\n```", &ok); ASSERT_TRUE(ok); ASSERT_TRUE(tgj_complete(st)); tgj_state_free(st); tgj_free(g); jr_free(tools); } /* FORCED-FUNCTION pins NAME: the target is accepted; another tool is rejected. */ static void test_forced_function_pins_name(void) { jr_value *tools = jr_parse(TOOLS_JSON); tgj_grammar *g = tgj_build(tools); tgj_state *good = tgj_state_new_forced(g, "read"); ASSERT_NOT_NULL(good); tgj_reasoning_end(good); bool ok; feed_into(good, "{\"function\":\"read\",\"arguments\":{\"path\":\"x\"}}", &ok); ASSERT_TRUE(ok); ASSERT_TRUE(tgj_complete(good)); tgj_state_free(good); tgj_state *bad = tgj_state_new_forced(g, "read"); tgj_reasoning_end(bad); feed_into(bad, "{\"function\":\"add\"", &ok); ASSERT_FALSE(ok); /* pinned to read → "add" rejected */ tgj_state_free(bad); ASSERT_NULL(tgj_state_new_forced(g, "not_a_tool")); tgj_free(g); jr_free(tools); } /* ============================================================ * 0-BUDGET FORCED ARMING (the ornith reasoning_budget:0 edge). A forced state * with reasoning ended IMMEDIATELY (no grace at all, mirroring a * 0-budget model that opens straight into the answer channel) must arm the * opener from the very first token — prose is impossible, the fence/`{` opener * survives. This exercises the path the N2 (budget 2000) grammar never did. * ============================================================ */ static void test_zero_budget_forced_arming(void) { jr_value *tools = jr_parse(TOOLS_JSON); tgj_grammar *g = tgj_build(tools); /* Reasoning ends before any token — the 0-budget shape: the very first * content token is already under the forced constraint. */ tgj_state *st = tgj_state_new_forced(g, NULL); tgj_reasoning_end(st); ASSERT_TRUE(tgj_mask_active(st)); /* armed with zero grace */ ASSERT_FALSE(tgj_token_allowed(st, "Here is the fix:")); ASSERT_TRUE(tgj_token_allowed(st, "```json")); ASSERT_TRUE(tgj_token_allowed(st, "{\"function\":\"read\"")); /* And it drives a full call to completion. */ bool ok; feed_into(st, "{\"function\":\"read\",\"arguments\":{\"path\":\"/etc/hostname\"}}", &ok); ASSERT_TRUE(ok); ASSERT_TRUE(tgj_complete(st)); tgj_state_free(st); /* Also verify the budget backstop trips for a runaway 0-grace-expecting model * that never emits : a long prose ramble must arm the mask in bounded * time (not stream unconstrained forever). */ tgj_state *st2 = tgj_state_new_forced(g, NULL); char one[2] = {'a', 0}; bool armed = false; for (int i = 0; i < 12000; i++) { one[0] = "abcdefgh"[i & 7]; tgj_advance(st2, one); if (tgj_mask_active(st2)) { armed = true; break; } } ASSERT_TRUE(armed); ASSERT_TRUE(tgj_mask_active(st2)); ASSERT_TRUE(tgj_token_allowed(st2, "```json\n{")); ASSERT_FALSE(tgj_token_allowed(st2, "still rambling")); tgj_state_free(st2); tgj_free(g); jr_free(tools); } /* ============================================================ * AUTO mode unchanged by forced work: mask only inside a call. * ============================================================ */ static void test_auto_mode_masking(void) { jr_value *tools = jr_parse(TOOLS_JSON); tgj_grammar *g = tgj_build(tools); tgj_state *st = tgj_state_new(g); tgj_reasoning_end(st); /* no-op in AUTO */ bool ok; feed_into(st, "no tools needed", &ok); ASSERT_TRUE(ok); ASSERT_FALSE(tgj_mask_active(st)); ASSERT_FALSE(tgj_in_call(st)); /* Once the object opens, the mask arms. */ tgj_advance(st, "{\"function\":\"read"); ASSERT_TRUE(tgj_in_call(st)); ASSERT_TRUE(tgj_mask_active(st)); tgj_state_free(st); tgj_free(g); jr_free(tools); } /* ============================================================ * Malformed / truncated → never a complete call. * ============================================================ */ static void test_truncated_incomplete(void) { jr_value *tools = jr_parse(TOOLS_JSON); tgj_grammar *g = tgj_build(tools); bool ok; /* Missing the closing braces → not complete. */ tgj_state *st = feed_ok(g, "{\"function\":\"read\",\"arguments\":{\"path\":\"x\"", &ok); ASSERT_TRUE(ok); /* valid prefix so far */ ASSERT_FALSE(tgj_complete(st)); /* but not a complete call */ tgj_state_free(st); tgj_free(g); jr_free(tools); } /* ============================================================ * ENUM — the live switch_mode victim. Every declared mode is still emittable * and completes; anything off the list is structurally impossible. BOTH halves * matter: a grammar that rejected every value would pass the reject half alone. * ============================================================ */ static void test_enum_closes_switch_mode(void) { jr_value *tools = jr_parse(SWITCH_MODE_JSON); tgj_grammar *g = tgj_build(tools); ASSERT_NOT_NULL(g); static const char *const MODES[] = { "chat", "plan", "act", "orchestrate" }; for (int i = 0; i < 4; i++) { char call[256]; snprintf(call, sizeof(call), "{\"function\":\"switch_mode\",\"arguments\":" "{\"mode\":\"%s\",\"reason\":\"the job needs it\"}}", MODES[i]); bool ok; tgj_state *st = feed_ok(g, call, &ok); ASSERT_TRUE(ok); /* every legal mode still accepted */ ASSERT_TRUE(tgj_complete(st)); tgj_state_free(st); } /* The bug this closes: "banana" was a complete, well-formed switch_mode. */ ASSERT_TRUE(feed_rejects(g, "{\"function\":\"switch_mode\",\"arguments\":{\"mode\":\"banana\"")); /* An off-set value that shares a prefix dies where it diverges. */ ASSERT_TRUE(feed_rejects(g, "{\"function\":\"switch_mode\",\"arguments\":{\"mode\":\"plans\"")); /* A strict PREFIX of a member is not a member: the closing quote is DEAD. */ ASSERT_TRUE(feed_rejects(g, "{\"function\":\"switch_mode\",\"arguments\":{\"mode\":\"pla\"")); /* And empty stays impossible. */ ASSERT_TRUE(feed_rejects(g, "{\"function\":\"switch_mode\",\"arguments\":{\"mode\":\"\"")); tgj_free(g); jr_free(tools); } /* An enum member split across token boundaries still matches (the state carries * the accumulator), and a split that diverges is still rejected. */ static void test_enum_cross_token(void) { jr_value *tools = jr_parse(SWITCH_MODE_JSON); tgj_grammar *g = tgj_build(tools); tgj_state *st = tgj_state_new(g); tgj_advance(st, "{\"function\":\"switch_mode\",\"arguments\":{\"mode\":\"orch"); ASSERT_TRUE(tgj_token_allowed(st, "estrate\"")); tgj_advance(st, "estrate\",\"reason\":\"x\"}}"); ASSERT_TRUE(tgj_complete(st)); tgj_state_free(st); tgj_state *bad = tgj_state_new(g); tgj_advance(bad, "{\"function\":\"switch_mode\",\"arguments\":{\"mode\":\"orch"); ASSERT_FALSE(tgj_token_allowed(bad, "id\"")); /* "orchid" is not a mode */ tgj_state_free(bad); tgj_free(g); jr_free(tools); } /* An enum on an OPTIONAL key constrains the value WITHOUT making the key * mandatory: omitting it entirely still completes. */ static void test_enum_on_optional_key(void) { jr_value *tools = jr_parse(ENUM_OPTIONAL_JSON); tgj_grammar *g = tgj_build(tools); bool ok; tgj_state *a = feed_ok(g, "{\"function\":\"log\",\"arguments\":{\"msg\":\"hi\"}}", &ok); ASSERT_TRUE(ok); /* level omitted → still a complete call */ ASSERT_TRUE(tgj_complete(a)); tgj_state_free(a); tgj_state *b = feed_ok(g, "{\"function\":\"log\",\"arguments\":{\"msg\":\"hi\",\"level\":\"warn\"}}", &ok); ASSERT_TRUE(ok); ASSERT_TRUE(tgj_complete(b)); tgj_state_free(b); /* Present but off-set is still impossible. */ ASSERT_TRUE(feed_rejects(g, "{\"function\":\"log\",\"arguments\":{\"msg\":\"hi\",\"level\":\"loud\"")); tgj_free(g); jr_free(tools); } /* Enums we cannot represent DEGRADE to the declared type — they never reject a * legal call (the TG_REQ_MAX_KEYS precedent). */ static void test_enum_degrades_when_unrepresentable(void) { jr_value *tools = jr_parse(ENUM_DEGRADE_JSON); tgj_grammar *g = tgj_build(tools); ASSERT_NOT_NULL(g); bool ok; /* nums: integer type + numeric members → dropped, still a plain integer, so * a value outside the list is accepted (unconstrained, not rejected). */ tgj_state *a = feed_ok(g, "{\"function\":\"cfg\",\"arguments\":{\"nums\":99}}", &ok); ASSERT_TRUE(ok); ASSERT_TRUE(tgj_complete(a)); tgj_state_free(a); /* mixed / blank / nolist: dropped → plain strings, any non-empty text ok. */ tgj_state *b = feed_ok(g, "{\"function\":\"cfg\",\"arguments\":{\"nums\":1,\"mixed\":\"zzz\"," "\"blank\":\"zzz\",\"nolist\":\"zzz\"}}", &ok); ASSERT_TRUE(ok); ASSERT_TRUE(tgj_complete(b)); tgj_state_free(b); /* clash: string members on an integer key → the declared type wins, so the * value is a number and the enum's own members are NOT accepted. */ ASSERT_TRUE(feed_rejects(g, "{\"function\":\"cfg\",\"arguments\":{\"nums\":1,\"clash\":\"a\"")); tgj_state *c = feed_ok(g, "{\"function\":\"cfg\",\"arguments\":{\"nums\":1,\"clash\":42}}", &ok); ASSERT_TRUE(ok); ASSERT_TRUE(tgj_complete(c)); tgj_state_free(c); /* notype: string members and NO declared type → the enum DOES apply. */ tgj_state *d = feed_ok(g, "{\"function\":\"cfg\",\"arguments\":{\"nums\":1,\"notype\":\"yes\"}}", &ok); ASSERT_TRUE(ok); ASSERT_TRUE(tgj_complete(d)); tgj_state_free(d); ASSERT_TRUE(feed_rejects(g, "{\"function\":\"cfg\",\"arguments\":{\"nums\":1,\"notype\":\"maybe\"")); tgj_free(g); jr_free(tools); } /* An enum larger than the cap degrades to unconstrained rather than rejecting. */ static void test_enum_over_cap_degrades(void) { char schema[8192]; int n = snprintf(schema, sizeof(schema), "[{\"type\":\"function\",\"function\":{\"name\":\"pick\"," "\"parameters\":{\"type\":\"object\",\"properties\":{" "\"v\":{\"type\":\"string\",\"enum\":["); for (int i = 0; i < 65; i++) /* 65 > TGJ_ENUM_MAX_MEMBERS */ n += snprintf(schema + n, sizeof(schema) - (size_t)n, "%s\"m%03d\"", i ? "," : "", i); snprintf(schema + n, sizeof(schema) - (size_t)n, "]}},\"required\":[\"v\"]}}}]"); jr_value *tools = jr_parse(schema); tgj_grammar *g = tgj_build(tools); ASSERT_NOT_NULL(g); bool ok; /* Off-list value accepted → the enum was dropped, not enforced. */ tgj_state *st = feed_ok(g, "{\"function\":\"pick\",\"arguments\":{\"v\":\"anything\"}}", &ok); ASSERT_TRUE(ok); ASSERT_TRUE(tgj_complete(st)); tgj_state_free(st); tgj_free(g); jr_free(tools); } /* ============================================================ * Build guards: empty / non-array tools → NULL grammar. * ============================================================ */ static void test_build_guards(void) { jr_value *empty = jr_parse("[]"); ASSERT_NULL(tgj_build(empty)); jr_free(empty); jr_value *obj = jr_parse("{\"x\":1}"); ASSERT_NULL(tgj_build(obj)); jr_free(obj); } /* ============================================================ * The compelled region must start at a TOKEN boundary. * * The XML dialect's twin (test_tool_grammar.c * reason_budget_trips_mid_token_without_dying) and the same live failure: * tgj_advance walks a WHOLE token, so a reasoning budget that trips mid-token * would judge that token's remaining chars under the compelled rule with no * mask in between — and one prose char there is TGJ_DEAD, which reports the * mask INACTIVE for every remaining token of the turn. Every other test in this * file feeds one char at a time, the one granularity where it cannot happen. * ============================================================ */ static void test_reason_budget_trips_mid_token_without_dying(void) { jr_value *tools = jr_parse(TOOLS_JSON); tgj_grammar *g = tgj_build(tools); tgj_state *st = tgj_state_new_forced(g, NULL); const char *piece = " thinking"; /* multi-char, prose, no opener, no close */ bool armed = false; for (int i = 0; i < 4096 && !armed; i++) { tgj_advance(st, piece); armed = tgj_mask_active(st); if (tgj_is_dead(st)) break; } ASSERT_FALSE(tgj_is_dead(st)); ASSERT_TRUE(armed); ASSERT_TRUE(tgj_mask_active(st)); ASSERT_TRUE(tgj_token_allowed(st, "{")); ASSERT_FALSE(tgj_token_allowed(st, " still rambling")); tgj_state_free(st); tgj_free(g); jr_free(tools); } /* A DEAD walk must not tell the serve that reasoning is over — that bit flips * the emitter's channel, and a dead walk is compelling nothing, so the flip * routes the model's ongoing thought into the content channel. */ static void test_dead_walk_does_not_report_reasoning_ended(void) { jr_value *tools = jr_parse(TOOLS_JSON); tgj_grammar *g = tgj_build(tools); tgj_state *st = tgj_state_new_forced(g, NULL); tgj_reasoning_end(st); ASSERT_TRUE(tgj_forced_reasoning_ended(st)); tgj_advance(st, "prose"); ASSERT_TRUE(tgj_is_dead(st)); ASSERT_FALSE(tgj_mask_active(st)); ASSERT_FALSE(tgj_forced_reasoning_ended(st)); tgj_state_free(st); tgj_free(g); jr_free(tools); } /* ============================================================ * tool_choice "required" with thinking ON (tgj_state_new_required, think_open): * the think block is NEVER constrained — a '{' or a ```json fence written while * thinking is thought, not a call, and a think that never closes never arms the * mask (max_tokens bounds it) — and from the token after "" the call is * compelled. * ============================================================ */ static void test_required_think_gated_reasoning_is_free(void) { jr_value *tools = jr_parse(TOOLS_JSON); tgj_grammar *g = tgj_build(tools); tgj_state *st = tgj_state_new_required(g, true); ASSERT_NOT_NULL(st); ASSERT_FALSE(tgj_mask_active(st)); const char *thought = "The user wants the file. I could call {\"function\":\"read\"} or emit " "```json\n{ ... } — let me think about { and } more."; bool ok; char one[2] = {0, 0}; for (const char *p = thought; *p; p++) { one[0] = *p; ASSERT_TRUE(tgj_token_allowed(st, one)); tgj_advance(st, one); ASSERT_FALSE(tgj_mask_active(st)); } ASSERT_FALSE(tgj_in_call(st)); /* A think that never closes: far past the legacy 8192-char budget, the * mask stays off — reasoning is bounded by max_tokens, not by the grammar. */ for (int i = 0; i < 30000; i++) { one[0] = "abc {}`\n"[i % 8]; tgj_advance(st, one); if (tgj_mask_active(st)) break; } ASSERT_FALSE(tgj_mask_active(st)); ASSERT_FALSE(tgj_forced_reasoning_ended(st)); /* The model closes its think block: from the next token the call is due. */ feed_into(st, "", &ok); ASSERT_TRUE(ok); ASSERT_TRUE(tgj_mask_active(st)); ASSERT_TRUE(tgj_forced_reasoning_ended(st)); ASSERT_FALSE(tgj_token_allowed(st, "Let me see the rest of the file.")); ASSERT_FALSE(tgj_token_allowed(st, "{\"function\":\"write\"")); feed_into(st, "\n\n```json\n{\"function\": \"read\", \"arguments\": " "{\"path\": \"src/main.c\"}}\n```", &ok); ASSERT_TRUE(ok); ASSERT_TRUE(tgj_complete(st)); ASSERT_FALSE(tgj_mask_active(st)); tgj_state_free(st); tgj_free(g); jr_free(tools); } /* "required" with NO open think block (thinking off): compelled from token 0. */ static void test_required_without_think_compels_from_start(void) { jr_value *tools = jr_parse(TOOLS_JSON); tgj_grammar *g = tgj_build(tools); tgj_state *st = tgj_state_new_required(g, false); ASSERT_TRUE(tgj_mask_active(st)); ASSERT_FALSE(tgj_token_allowed(st, "Sure, let me look.")); ASSERT_TRUE(tgj_token_allowed(st, "```json")); ASSERT_TRUE(tgj_token_allowed(st, "{\"name\": \"add\"")); tgj_state_free(st); tgj_free(g); jr_free(tools); } /* The serve's reasoning_budget force-close ends a think-gated block through * tgj_reasoning_end (it injects "" too, but the explicit end alone must * be enough): the call is compelled right after. */ static void test_required_think_gated_external_end(void) { jr_value *tools = jr_parse(TOOLS_JSON); tgj_grammar *g = tgj_build(tools); tgj_state *st = tgj_state_new_required(g, true); bool ok; feed_into(st, "still thinking about {", &ok); ASSERT_FALSE(tgj_mask_active(st)); tgj_reasoning_end(st); ASSERT_TRUE(tgj_mask_active(st)); ASSERT_FALSE(tgj_token_allowed(st, "prose")); tgj_state_free(st); tgj_free(g); jr_free(tools); } /* The sampler's full mask rejects a token whose first 1 or 2 bytes kill the walk * without walking the rest (engine.c grammar_first_char_verdicts). Sound only * because DEAD is absorbing: check it at every prefix of a real required call, * for a spread of token texts. */ static void test_first_char_prefilter_is_sound(void) { jr_value *tools = jr_parse(TOOLS_JSON); tgj_grammar *g = tgj_build(tools); static const char *TOKENS[] = { "```json", "```", "{\"", "{\"function", "\":", " \"", "read", "\"}}", "\n```", "Sure", " the", "", "\n\n", "{", "}", ",", " ", "\"path\": \"", "src/main.c", "\\n", "\"", "1", "true", "]", "\u00e9", "", NULL }; const char *script = "think {about} it\n\n```json\n{\"function\": \"read\", " "\"arguments\": {\"path\": \"src/main.c\"}}\n```"; tgj_state *st = tgj_state_new_required(g, true); char one[2] = { 0, 0 }; int checked = 0, violations = 0; for (const char *p = script; ; p++) { for (int t = 0; TOKENS[t]; t++) { char two[3] = { TOKENS[t][0], TOKENS[t][0] ? TOKENS[t][1] : 0, 0 }; one[0] = TOKENS[t][0]; if (tgj_token_allowed(st, TOKENS[t]) && (!tgj_token_allowed(st, one) || !tgj_token_allowed(st, two))) violations++; checked++; } if (!*p) break; one[0] = *p; tgj_advance(st, one); } ASSERT_INT_EQ(violations, 0); ASSERT_TRUE(checked > 1000); tgj_state_free(st); tgj_free(g); jr_free(tools); } /* A "required" turn may end only where that is legal: never before the call * opens (the turn must make a call), and inside the call only where the serve * can close it — and the closing text it gets must complete the call. */ static void test_required_end_and_closing_text(void) { jr_value *tools = jr_parse(TOOLS_JSON); tgj_grammar *g = tgj_build(tools); char closing[16]; bool ok; tgj_state *st = tgj_state_new_required(g, true); ASSERT_TRUE(tgj_end_allowed(st)); /* thinking: free */ feed_into(st, "plan\n\n", &ok); ASSERT_FALSE(tgj_end_allowed(st)); /* compelled, no call yet */ ASSERT_INT_EQ(tgj_closing_text(st, closing, 16), -1); feed_into(st, "```json\n{\"function\": \"read\", \"arguments\": {", &ok); ASSERT_FALSE(tgj_end_allowed(st)); /* required `path` missing */ feed_into(st, "\"path\": \"a.c\n```", &ok); /* ornith: fence inside the string */ ASSERT_TRUE(ok); ASSERT_TRUE(tgj_end_allowed(st)); ASSERT_INT_EQ(tgj_closing_text(st, closing, 16), 7); ASSERT_STR_EQ(closing, "\"}}\n```"); tgj_advance(st, closing); ASSERT_TRUE(tgj_complete(st)); ASSERT_TRUE(tgj_end_allowed(st)); /* done: free again */ tgj_state_free(st); /* Bare object, integer arguments: closed by the braces alone. */ st = tgj_state_new_required(g, false); feed_into(st, "{\"name\":\"add\",\"parameters\":{\"a\": 1, \"b\": 22", &ok); ASSERT_TRUE(ok); ASSERT_INT_EQ(tgj_closing_text(st, closing, 16), 2); ASSERT_STR_EQ(closing, "}}"); /* A key with no value yet cannot be closed. */ tgj_state_free(st); st = tgj_state_new_required(g, false); feed_into(st, "{\"name\":\"add\",\"parameters\":{\"a\": 1, \"b\":", &ok); ASSERT_FALSE(tgj_end_allowed(st)); tgj_state_free(st); tgj_free(g); jr_free(tools); } int main(void) { printf("\n=== tool_grammar_json (ornith JSON grammar) ===\n\n"); RUN_TEST(fenced_accepts); RUN_TEST(bare_accepts); RUN_TEST(name_and_parameters_keys); RUN_TEST(integer_args); RUN_TEST(integer_rejects_string); RUN_TEST(unknown_name_rejected); RUN_TEST(unknown_key_rejected); RUN_TEST(empty_value_impossible); RUN_TEST(empty_value_allowed_without_a_floor); RUN_TEST(whitespace_only_value_impossible); RUN_TEST(empty_args_impossible_when_required); RUN_TEST(optional_key_omittable); RUN_TEST(cross_token_key); RUN_TEST(prose_then_call_auto); RUN_TEST(forced_rejects_prose); RUN_TEST(forced_self_detects_think_close); RUN_TEST(forced_function_pins_name); RUN_TEST(zero_budget_forced_arming); RUN_TEST(reason_budget_trips_mid_token_without_dying); RUN_TEST(dead_walk_does_not_report_reasoning_ended); RUN_TEST(auto_mode_masking); RUN_TEST(truncated_incomplete); RUN_TEST(enum_closes_switch_mode); RUN_TEST(enum_cross_token); RUN_TEST(enum_on_optional_key); RUN_TEST(enum_degrades_when_unrepresentable); RUN_TEST(enum_over_cap_degrades); RUN_TEST(build_guards); RUN_TEST(required_think_gated_reasoning_is_free); RUN_TEST(required_without_think_compels_from_start); RUN_TEST(required_think_gated_external_end); RUN_TEST(first_char_prefilter_is_sound); RUN_TEST(required_end_and_closing_text); TEST_REPORT(); return __ft_failed == 0 ? 0 : 1; }