/* SPDX-License-Identifier: AGPL-3.0-or-later */ /* Copyright (C) 2026 David (Corey) Herrera */ /* * Unit tests for the streaming reasoning/content/tool-call splitter * (src/serve/serve_emitter.c). * * Focus is Phase 2 wrapper-tolerant STREAM detection: a bare "" wrapper), which nex-n2-mini at Q3_K_M emits most of the time, * must be diverted into the raw `tool` channel (→ sg_result.tool_raw) instead of * being streamed out as content — including when the opener straddles two token * deltas (em_feed calls). With tools NOT armed, a "" wrapper is detected regardless. * * The emitter depends only on libc (no engine/CUDA), so it links standalone. */ #include "lib/test.h" #include "../src/serve/serve_emitter.h" #include #include #include /* Delta collector: concatenates every content delta (is_reasoning==0) the * emitter streams, so a test can assert what was (and was not) emitted as * visible answer content. */ typedef struct { char buf[4096]; size_t len; } sink; static void sink_cb(const char *text, int is_reasoning, const sg_token_logprob *const *lps, int n_lps, void *ud) { (void)lps; (void)n_lps; if (is_reasoning) return; /* only track answer content for these tests */ sink *s = (sink *)ud; size_t n = strlen(text); if (s->len + n < sizeof(s->buf)) { memcpy(s->buf + s->len, text, n); s->len += n; s->buf[s->len] = '\0'; } } /* Drive the emitter out of the block so we are on the content channel * (generation begins inside ; "" flips to content). */ static void enter_content(emitter *em) { em_feed(em, ""); } /* ============================================================ * Bare "\nREADME.md\n"); em_flush(&em); /* The pre-opener prose streamed as content; the call did NOT. */ ASSERT_STR_EQ(s.buf, "Sure, let me help. "); /* The tool channel captured from the opener onward, verbatim. */ ASSERT_NOT_NULL(em.tool.data); ASSERT_STR_EQ(em.tool.data, "\nREADME.md\n"); em_free(&em); } /* ============================================================ * Bare "\nls\n"); em_free(&em); } static void test_split_opener_captured(void) { const char *full = "\nls\n"; /* Split right in the middle of "\nls\n"); /* Split after "\nls\n"); /* Split after just "<". */ feed_split_and_check("<", "function=bash>\nls\n"); (void)full; } /* Split with leading content before the (split) opener: prose streams, opener * held across the boundary, call captured. */ static void test_split_opener_with_prefix(void) { sink s = {0}; emitter em; em_init(&em, sink_cb, &s, NULL, 0, /*has_tools=*/true, TC_DIALECT_QWEN_XML); enter_content(&em); em_feed(&em, "ok *.c"); em_flush(&em); ASSERT_STR_EQ(s.buf, "ok "); ASSERT_NOT_NULL(em.tool.data); ASSERT_STR_EQ(em.tool.data, "*.c"); em_free(&em); } /* ============================================================ * Tools NOT armed → a " now"); em_flush(&em); ASSERT_STR_EQ(s.buf, "call now"); ASSERT_NULL(em.tool.data); em_free(&em); } /* ============================================================ * Canonical "" wrapper still diverts (regression), even tools-off: * "" detection is unconditional. * ============================================================ */ static void test_wrapped_opener_captured(void) { sink s = {0}; emitter em; em_init(&em, sink_cb, &s, NULL, 0, /*has_tools=*/false, TC_DIALECT_QWEN_XML); enter_content(&em); em_feed(&em, "here: "); em_flush(&em); ASSERT_STR_EQ(s.buf, "here: "); ASSERT_NOT_NULL(em.tool.data); ASSERT_STR_EQ(em.tool.data, ""); em_free(&em); } /* ============================================================ * Earliest-opener-wins: a "" that precedes a bare "\n\n"); em_flush(&em); ASSERT_STR_EQ(s.buf, ""); ASSERT_NOT_NULL(em.tool.data); ASSERT_STR_EQ(em.tool.data, "\n\n"); em_free(&em); } /* ============================================================ * Plausible-shape guard: a stray "). */ ASSERT_TRUE(em.in_think == 1); /* Reasoning runs away — every token routes to the reasoning channel. */ em_feed(&em, "let me think about "); em_feed(&em, "this problem carefully"); ASSERT_TRUE(em.in_think == 1); ASSERT_STR_EQ(s.c, ""); /* nothing on the content channel yet */ /* Budget tripped: sg_run_completion force-decodes "" and feeds its * decoded text here. The emitter's own THINK_CLOSE match flips to content. */ em_feed(&em, ""); ASSERT_TRUE(em.in_think == 0); /* The answer now streams as CONTENT, not reasoning_content. */ em_feed(&em, "The answer is 42."); em_flush(&em); ASSERT_STR_EQ(s.c, "The answer is 42."); ASSERT_STR_EQ(s.r, "let me think about this problem carefully"); em_free(&em); } /* ============================================================ * Ornith JSON dialect (TC_DIALECT_ORNITH_JSON): a ```json fence / plausible * bare "{" diverts to the tool channel; XML openers are NOT watched. * ============================================================ */ /* A ```json fence after reasoning diverts the whole block to the tool channel. */ static void test_ornith_fence_captured(void) { sink s = {0}; emitter em; em_init(&em, sink_cb, &s, NULL, 0, /*has_tools=*/true, TC_DIALECT_ORNITH_JSON); enter_content(&em); em_feed(&em, "Let me read that. "); em_feed(&em, "```json\n{\"function\":\"read\",\"arguments\":{\"path\":\"/x\"}}\n```"); em_flush(&em); ASSERT_STR_EQ(s.buf, "Let me read that. "); ASSERT_NOT_NULL(em.tool.data); ASSERT_TRUE(strstr(em.tool.data, "```json") != NULL); ASSERT_TRUE(strstr(em.tool.data, "\"path\":\"/x\"") != NULL); em_free(&em); } /* A bare plausible "{" (no fence) diverts too. */ static void test_ornith_bare_brace_captured(void) { sink s = {0}; emitter em; em_init(&em, sink_cb, &s, NULL, 0, /*has_tools=*/true, TC_DIALECT_ORNITH_JSON); enter_content(&em); em_feed(&em, "{\"function\":\"read\",\"arguments\":{\"path\":\"/y\"}}"); em_flush(&em); ASSERT_STR_EQ(s.buf, ""); /* the whole object was diverted */ ASSERT_NOT_NULL(em.tool.data); ASSERT_TRUE(strstr(em.tool.data, "\"path\":\"/y\"") != NULL); em_free(&em); } /* The fence split across two feeds still diverts (hold-back spans the boundary). */ static void test_ornith_fence_split(void) { sink s = {0}; emitter em; em_init(&em, sink_cb, &s, NULL, 0, /*has_tools=*/true, TC_DIALECT_ORNITH_JSON); enter_content(&em); em_feed(&em, "ok ```js"); em_feed(&em, "on\n{\"function\":\"read\",\"arguments\":{\"path\":\"/z\"}}\n```"); em_flush(&em); ASSERT_STR_EQ(s.buf, "ok "); ASSERT_NOT_NULL(em.tool.data); ASSERT_TRUE(strstr(em.tool.data, "\"path\":\"/z\"") != NULL); em_free(&em); } /* A plausible "{" split right after the brace (key on the next feed) still * diverts — the undecided-brace hold-back covers it. */ static void test_ornith_brace_split(void) { sink s = {0}; emitter em; em_init(&em, sink_cb, &s, NULL, 0, /*has_tools=*/true, TC_DIALECT_ORNITH_JSON); enter_content(&em); em_feed(&em, "answer: {"); em_feed(&em, "\"function\":\"read\",\"arguments\":{\"path\":\"/w\"}}"); em_flush(&em); ASSERT_STR_EQ(s.buf, "answer: "); ASSERT_NOT_NULL(em.tool.data); ASSERT_TRUE(strstr(em.tool.data, "\"path\":\"/w\"") != NULL); em_free(&em); } /* A stray prose "{" that does NOT open a function/name key stays content. */ static void test_ornith_prose_brace_stays_content(void) { sink s = {0}; emitter em; em_init(&em, sink_cb, &s, NULL, 0, /*has_tools=*/true, TC_DIALECT_ORNITH_JSON); enter_content(&em); em_feed(&em, "The set is {1, 2, 3} of numbers."); em_flush(&em); ASSERT_STR_EQ(s.buf, "The set is {1, 2, 3} of numbers."); ASSERT_TRUE(em.tool.data == NULL || em.tool.len == 0); em_free(&em); } /* Tools OFF: even the ornith dialect never diverts (has_tools gate). */ static void test_ornith_tools_off_stays_content(void) { sink s = {0}; emitter em; em_init(&em, sink_cb, &s, NULL, 0, /*has_tools=*/false, TC_DIALECT_ORNITH_JSON); enter_content(&em); em_feed(&em, "{\"function\":\"read\",\"arguments\":{\"path\":\"/x\"}}"); em_flush(&em); ASSERT_STR_EQ(s.buf, "{\"function\":\"read\",\"arguments\":{\"path\":\"/x\"}}"); ASSERT_TRUE(em.tool.data == NULL || em.tool.len == 0); em_free(&em); } /* Regression: under the ornith dialect an XML "\n/x\n"); em_flush(&em); ASSERT_TRUE(strstr(s.buf, "") != NULL); ASSERT_TRUE(em.tool.data == NULL || em.tool.len == 0); em_free(&em); } /* ============================================================ * AUTO reasoning-budget TRIP timing (regression for the saber runaway). * * sg_run_completion caps AUTO-path reasoning at reasoning_budget tokens: while * the emitter is inside , every generated token counts, and once the * count reaches the budget the loop force-decodes "" so the model * concludes. The bug: the loop counted a token off the PRE-feed think state * (was_think) but decided whether to fire off the POST-feed em.in_think. When * the model self-emits "" on the very token that reaches the budget, * em_feed clears em.in_think in the same step, so the (em.in_think && rtok >= * budget) fire guard is already false — the trip is STARVED, the cap never * fires, and the model runs on to max_tokens (the ~8 tok/s never-concludes * runaway). The fix latches the trip off was_think (as the count is) so the * boundary token can't starve it. * * This models the loop's counting + latch decision over the real emitter with a * budget of 3 and a token stream whose 3rd think token is exactly "". * old_fire = the buggy post-feed guard; new_fire = the fix's pre-feed latch. * ============================================================ */ static void test_reason_budget_trips_on_closing_token(void) { split_sink s = {0}; emitter em; em_init(&em, split_cb, &s, NULL, 0, /*has_tools=*/false, TC_DIALECT_QWEN_XML); ASSERT_TRUE(em.in_think == 1); /* Budget 3: the 3rd think token IS the close. Feed one "token" per step. */ const char *toks[] = { "reason A ", "reason B ", "" }; const int budget = 3; int rtok = 0; int old_fire = 0; /* buggy: gate on POST-feed em.in_think */ int new_fire = 0; /* fixed: latch on PRE-feed was_think */ for (int i = 0; i < 3; i++) { int was_think = em.in_think; em_feed(&em, toks[i]); if (was_think) { rtok++; /* Fix: latch the trip here, off was_think — survives a same-token * close that just cleared em.in_think. */ if (rtok >= budget) new_fire = 1; } /* Old buggy guard, evaluated after em_feed. */ if (em.in_think && rtok >= budget) old_fire = 1; } /* The regression: the OLD post-feed guard never armed (in_think went 0 on the * same token that reached the budget), so the reasoning cap silently failed. */ ASSERT_INT_EQ(old_fire, 0); /* The fix: the pre-feed latch DID arm — the cap fires and the model concludes. */ ASSERT_INT_EQ(new_fire, 1); /* And the budget really was reached exactly on the closing token. */ ASSERT_INT_EQ(rtok, budget); em_free(&em); } /* Sanity: a genuine still-in-think overflow (no self-close) fires under BOTH the * old and new logic — the fix must not regress the plain runaway case. */ static void test_reason_budget_trips_still_in_think(void) { split_sink s = {0}; emitter em; em_init(&em, split_cb, &s, NULL, 0, /*has_tools=*/false, TC_DIALECT_QWEN_XML); const char *toks[] = { "ramble 1 ", "ramble 2 ", "ramble 3 ", "ramble 4 " }; const int budget = 3; int rtok = 0, old_fire = 0, new_fire = 0; for (int i = 0; i < 4; i++) { int was_think = em.in_think; em_feed(&em, toks[i]); if (was_think) { rtok++; if (rtok >= budget) new_fire = 1; } if (em.in_think && rtok >= budget) old_fire = 1; } /* Never closed , so both strategies arm at the budget — parity. */ ASSERT_INT_EQ(old_fire, 1); ASSERT_INT_EQ(new_fire, 1); ASSERT_TRUE(em.in_think == 1); em_free(&em); } /* ============================================================ * A tool-call opener INSIDE is never a tool call. * ============================================================ */ /* nous_fncall_prompt.py:136-147 splits on the LAST "" and parses only * what follows — verbatim comment: "# Do not parse in thought!!!". * We get the same guarantee structurally rather than by a post-hoc split: the * emitter's in_think branch scans for "" and NOTHING else, so opener * detection is not even armed while reasoning is open. The whole call text below * is reasoning, the tool channel stays empty, and the parser is never handed it. * * This is a STANDING guard, not a regression from a known bug: it is the only * thing keeping a model that talks about calling a tool inside its reasoning * from having that talk executed. */ static void test_tool_opener_inside_think_is_not_diverted(void) { sink s = {0}; emitter em; em_init(&em, sink_cb, &s, NULL, 0, /*has_tools=*/true, TC_DIALECT_QWEN_XML); /* NOTE: no enter_content() — generation begins inside . */ em_feed(&em, "I could emit "); em_feed(&em, "\n\n\n/etc/hostname\n" "\n\n"); em_feed(&em, " but I will check first."); ASSERT_TRUE(em.in_think == 1); ASSERT_NULL(em.tool.data); /* nothing was ever diverted */ /* Only once reasoning actually closes does an opener arm. */ em_feed(&em, ""); ASSERT_TRUE(em.in_think == 0); em_feed(&em, "\n\n/etc/hostname\n\n"); em_flush(&em); ASSERT_NOT_NULL(em.tool.data); ASSERT_STR_EQ(s.buf, ""); /* the real call never reached content */ em_free(&em); } int main(void) { printf("\n=== serve_emitter (stream tool-call detection) ===\n\n"); RUN_TEST(tool_opener_inside_think_is_not_diverted); RUN_TEST(budget_force_close_flips_to_content); RUN_TEST(reason_budget_trips_on_closing_token); RUN_TEST(reason_budget_trips_still_in_think); RUN_TEST(bare_opener_captured); RUN_TEST(split_opener_captured); RUN_TEST(split_opener_with_prefix); RUN_TEST(tools_off_stays_content); RUN_TEST(tools_off_split_stays_content); RUN_TEST(wrapped_opener_captured); RUN_TEST(wrapped_before_bare); RUN_TEST(stray_fn_fragment_stays_content); RUN_TEST(real_opener_after_stray); RUN_TEST(no_opener_all_content); RUN_TEST(minicpm_opener_captured); RUN_TEST(minicpm_opener_split); RUN_TEST(minicpm_opener_tools_off_stays_content); RUN_TEST(forced_value_wrapper_captured); RUN_TEST(split_opener_after_equals); RUN_TEST(trailing_fn_open_never_followed_is_content); RUN_TEST(ornith_fence_captured); RUN_TEST(ornith_bare_brace_captured); RUN_TEST(ornith_fence_split); RUN_TEST(ornith_brace_split); RUN_TEST(ornith_prose_brace_stays_content); RUN_TEST(ornith_tools_off_stays_content); RUN_TEST(ornith_ignores_xml_opener); TEST_REPORT(); return __ft_failed == 0 ? 0 : 1; }