/* 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 " in your code like .";
em_feed(&em, txt);
em_flush(&em);
/* Everything streamed as content; nothing captured to the tool channel. */
ASSERT_STR_EQ(s.buf, txt);
ASSERT_NULL(em.tool.data);
em_free(&em);
}
/* Even split across tokens, tools-off keeps " 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 "" (empty name) and " or then ");
em_flush(&em);
ASSERT_STR_EQ(s.buf, "junk then ");
ASSERT_NOT_NULL(em.tool.data);
ASSERT_STR_EQ(em.tool.data, "");
em_free(&em);
}
/* ============================================================
* No opener at all → pure content, nothing captured.
* ============================================================ */
static void test_no_opener_all_content(void) {
sink s = {0};
emitter em;
em_init(&em, sink_cb, &s, NULL, 0, /*has_tools=*/true, TC_DIALECT_QWEN_XML);
enter_content(&em);
const char *txt = "Just a normal answer with no tool calls.";
em_feed(&em, txt);
em_flush(&em);
ASSERT_STR_EQ(s.buf, txt);
ASSERT_NULL(em.tool.data);
em_free(&em);
}
/* ============================================================
* MiniCPM5 attribute-style opener "" (tools armed) →
* captured to the tool channel from ""
"write a haiku");
em_flush(&em);
ASSERT_STR_EQ(s.buf, "Dispatching now. ");
ASSERT_NOT_NULL(em.tool.data);
ASSERT_STR_EQ(em.tool.data,
""
"write a haiku");
em_free(&em);
}
/* The MiniCPM opener SPLIT across two deltas is still captured (the hold-back
* must span "y");
em_flush(&em);
ASSERT_STR_EQ(s.buf, "ok ");
ASSERT_TRUE(strstr(s.buf, "y");
em_free(&em);
}
/* With tools OFF, " is literal here";
em_feed(&em, txt);
em_flush(&em);
ASSERT_STR_EQ(s.buf, txt);
ASSERT_NULL(em.tool.data);
em_free(&em);
}
/* The grammar-forced dialect (bare "…) diverts EXACTLY like a plain bare call — the emitter captures
* from the opener to end raw (the wrapper is inside the span), nothing
* leaks to content. The parser then unwraps . Streamed across two deltas. */
static void test_forced_value_wrapper_captured(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, "Delegating. ");
em_feed(&em, "\n\n");
em_feed(&em, "\nbuild the parser module\n\n\n");
em_flush(&em);
ASSERT_STR_EQ(s.buf, "Delegating. ");
ASSERT_TRUE(strstr(s.buf, "") == NULL);
ASSERT_NOT_NULL(em.tool.data);
ASSERT_STR_EQ(em.tool.data,
"\n\n"
"\nbuild the parser module\n\n\n");
em_free(&em);
}
/* Live MiniCPM/grammar-forced token split: the model emits " \n\n \n\n");
em_feed(&em, "build it\n\n");
em_flush(&em);
/* Nothing of the call leaked to content. */
ASSERT_TRUE(strstr(s.buf, "\n\n"
"build it\n\n");
em_free(&em);
}
/* A "" token(s) and feeds each through
* em_feed exactly like a sampled token. This models the emitter side of that:
* reasoning tokens route to the reasoning channel, then the force-injected
* "" flips the emitter to content, and the following answer streams as
* content — proving the mechanism the force-decode relies on.
* ============================================================ */
/* Delta collector that keeps BOTH channels separately so this test can assert
* the reasoning/content split the budget force-close produces. */
typedef struct { char r[4096]; size_t rl; char c[4096]; size_t cl; } split_sink;
static void split_cb(const char *text, int is_reasoning,
const sg_token_logprob *const *lps, int n_lps,
void *ud)
{
(void)lps; (void)n_lps;
split_sink *s = (split_sink *)ud;
char *buf = is_reasoning ? s->r : s->c;
size_t *len = is_reasoning ? &s->rl : &s->cl;
size_t n = strlen(text);
if (*len + n < 4096) { memcpy(buf + *len, text, n); *len += n; buf[*len] = '\0'; }
}
static void test_budget_force_close_flips_to_content(void) {
split_sink s = {0};
emitter em;
em_init(&em, split_cb, &s, NULL, 0, /*has_tools=*/false, TC_DIALECT_QWEN_XML);
/* em_init starts in_think=1 (generation begins inside ). */
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;
}