char-fork/llama.cpp

git clone https://git.t4t.associates/char-fork/llama.cpp

Xuan-Son Nguyentest: move tools/parser to tests (#27548)4a08fa297

char/aliceai-t5-moe
121.8 KiB2707 linesraw
1#include "chat-auto-parser-helpers.h"
2#include "chat-auto-parser.h"
3#include "chat-peg-parser.h"
4#include "chat.h"
5#include "gguf.h"
6#include "jinja/runtime.h"
7#include "log.h"
8#include "peg-parser.h"
9#include "testing.h"
10
11#include <cstdlib>
12#include <filesystem>
13#include <fstream>
14#include <iostream>
15#include <iterator>
16#include <optional>
17#include <sstream>
18#include <string>
19
20using namespace autoparser;
21
22static void test_calculate_diff_split_basic(testing & t);
23static void test_calculate_diff_split_identical(testing & t);
24static void test_calculate_diff_split_common_prefix(testing & t);
25static void test_calculate_diff_split_common_suffix(testing & t);
26static void test_calculate_diff_split_common_both(testing & t);
27static void test_calculate_diff_split_empty_cases(testing & t);
28static void test_calculate_diff_split_no_common(testing & t);
29static void test_calculate_diff_split_single_char(testing & t);
30static void test_calculate_diff_split_overlaps(testing & t);
31static void test_calculate_diff_split_tag_boundaries(testing & t);
32static void test_calculate_diff_split_generation_prompt(testing & t);
33static void test_calculate_diff_split(testing & t);
34
35static void test_until_common_prefix_basic(testing & t);
36static void test_until_common_prefix(testing & t);
37
38static void test_after_common_suffix_basic(testing & t);
39static void test_after_common_suffix(testing & t);
40
41static void test_analyze_tool_call_pure_json(testing & t);
42static void test_analyze_tool_call_function_name_markers(testing & t);
43static void test_analyze_tool_call_full_markers(testing & t);
44static void test_analyze_tool_call_edge_cases(testing & t);
45
46static void test_compare_variants_basic(testing & t);
47static void test_compare_variants_messages_modifier(testing & t);
48static void test_compare_variants_tools_modifier(testing & t);
49static void test_compare_variants_both_modifiers(testing & t);
50static void test_compare_variants_template_failure(testing & t);
51static void test_compare_variants_identity(testing & t);
52static void test_compare_variants(testing & t);
53
54// Seed-OSS template tool calling analysis tests
55static void test_seed_oss_tool_analysis(testing & t);
56static void test_seed_oss_tool_presence(testing & t);
57static void test_seed_oss_call_count(testing & t);
58static void test_seed_oss_function_names(testing & t);
59static void test_seed_oss_argument_count(testing & t);
60static void test_seed_oss_args_presence(testing & t);
61static void test_seed_oss_tool_with_reasoning(testing & t);
62
63// Nemotron template analysis tests
64static void test_nemotron_analysis(testing & t);
65static void test_nemotron_reasoning_detection(testing & t);
66static void test_nemotron_tool_format(testing & t);
67static void test_laguna_analysis(testing & t);
68static void test_laguna_reasoning_detection(testing & t);
69static void test_laguna_tool_format(testing & t);
70static void test_laguna_s_analysis(testing & t);
71static void test_laguna_s_reasoning_detection(testing & t);
72static void test_laguna_s_tool_format(testing & t);
73static void test_laguna_s_preserve_reasoning(testing & t);
74static void test_laguna_xs2_analysis(testing & t);
75static void test_laguna_xs2_reasoning_detection(testing & t);
76static void test_laguna_xs2_tool_format(testing & t);
77
78// CohereForAI template analysis tests
79static void test_cohere_reasoning_detection(testing & t);
80static void test_cohere_analysis(testing & t);
81
82// SmolLM3 template analysis tests
83static void test_smollm3_analysis(testing & t);
84
85// Marker separation
86static void test_marker_separation(testing & t);
87
88// standard_json_tools format tests
89static void test_standard_json_tools_formats(testing & t);
90static void test_standard_json_tools_openai(testing & t);
91static void test_standard_json_tools_cohere(testing & t);
92static void test_standard_json_tools_function_key(testing & t);
93
94// normalize_quotes_to_json tests
95static void test_normalize_quotes_to_json(testing & t);
96static void test_normalize_quotes_with_embedded_quotes(testing & t);
97
98// TAG_WITH_TAGGED argument parsing tests
99static void test_tagged_args_with_embedded_quotes(testing & t);
100static void test_bailing_v3_tool_format(testing & t);
101
102static void test_role_markers_all_templates(testing & t);
103
104static json build_tools_definition();
105
106//
107// debug mode: analyze a single template and dump the generated parser and grammar
108//
109
110enum class output_mode {
111    ANALYSIS,  // Only output analysis results (default)
112    TEMPLATE,  // Only output rendered template
113    BOTH       // Output both
114};
115
116enum class input_message_type {
117    NONE,                    // Don't render any message scenarios (only analysis)
118    CONTENT_ONLY,            // Simple assistant message with content
119    REASONING_CONTENT,       // Message with reasoning_content + content
120    TOOL_CALL_ONLY,          // Message with tool_calls only
121    CONTENT_TOOL_CALL,       // Message with content + tool_calls
122    REASONING_TOOL_CALL,     // Message with reasoning_content + tool_calls
123    CONTENT_FAKE_TOOL_CALL,  // Message with content but no actual tool_calls (for testing)
124    ALL                      // Render all scenarios
125};
126
127struct debug_options {
128    std::string        template_path;
129    bool               with_tools        = true;
130    bool               generation_prompt = true;
131    bool               enable_reasoning  = true;
132    bool               debug_jinja       = false;
133    bool               force_tool_call   = false;
134    bool               parallel_tool_calls = true;
135    output_mode        mode              = output_mode::BOTH;
136    input_message_type input_message     = input_message_type::NONE;
137};
138
139static std::string read_file(const std::string & path) {
140    std::ifstream fin(path, std::ios::binary);
141    if (!fin.is_open()) {
142        throw std::runtime_error("Could not open file: " + path);
143    }
144    std::ostringstream buf;
145    buf << fin.rdbuf();
146    return buf.str();
147}
148
149static std::string read_gguf_chat_template(const std::string & path) {
150    struct gguf_init_params params = { /*no_alloc =*/true,  // We only need metadata, not tensor data
151                                       /*ctx=*/nullptr };
152
153    struct gguf_context * ctx = gguf_init_from_file(path.c_str(), params);
154    if (ctx == nullptr) {
155        throw std::runtime_error("Could not open GGUF file: " + path);
156    }
157
158    const char * key    = "tokenizer.chat_template";
159    int64_t      key_id = gguf_find_key(ctx, key);
160
161    if (key_id == -1) {
162        gguf_free(ctx);
163        throw std::runtime_error("GGUF file does not contain chat template key: " + std::string(key));
164    }
165
166    const char * template_str = gguf_get_val_str(ctx, key_id);
167    if (template_str == nullptr) {
168        gguf_free(ctx);
169        throw std::runtime_error("GGUF file contains chat template key but value is null");
170    }
171
172    std::string result = template_str;
173    gguf_free(ctx);
174    return result;
175}
176
177static void print_usage(const char * program_name) {
178    LOG_ERR("Test the chat template auto-parser; also usable as a debug tool that shows the generated PEG parser, GBNF grammar and triggers for a given template.\n");
179    LOG_ERR("\nUsage: %s [filter_regex]                       run the automated tests (default)\n", program_name);
180    LOG_ERR("       %s <template_or_gguf_path> [options]     debug a single template\n", program_name);
181    LOG_ERR("\nDebug mode options:\n");
182    LOG_ERR("  --no-tools              Disable tool definitions\n");
183    LOG_ERR("  --force-tool-call       Set tool calls to forced\n");
184    LOG_ERR("  --parallel-tool-calls=0|1 Set parallel_tool_calls (default: 1)\n");
185    LOG_ERR("  --generation-prompt=0|1 Set add_generation_prompt (default: 1)\n");
186    LOG_ERR("  --enable-reasoning=0|1  Enable reasoning parsing (default: 1)\n");
187    LOG_ERR("  --output=MODE           Output mode: analysis, template, both (default: both)\n");
188    LOG_ERR("  --debug-jinja           Enable Jinja fine-grained debug\n");
189    LOG_ERR("  --input-message=TYPE    Message type to render:\n");
190    LOG_ERR("                          content_only, reasoning_content, tool_call_only,\n");
191    LOG_ERR("                          content_tool_call, reasoning_tool_call,\n");
192    LOG_ERR("                          content_fake_tool_call, all\n");
193    LOG_ERR("\nExamples:\n");
194    LOG_ERR("  %s template.jinja --input-message=all --generation-prompt=1\n", program_name);
195    LOG_ERR("  %s template.jinja --output=template --input-message=tool_call_only\n", program_name);
196}
197
198static bool parse_bool_option(const std::string & value) {
199    return value == "1" || value == "true" || value == "yes";
200}
201
202static bool parse_debug_options(int argc, char ** argv, debug_options & opts) {
203    opts.template_path = argv[1];
204
205    for (int i = 2; i < argc; ++i) {
206        std::string arg = argv[i];
207
208        if (arg == "--force-tool-call") {
209            opts.force_tool_call = true;
210        } else if (arg == "--debug-jinja") {
211            opts.debug_jinja = true;
212        } else if (arg == "--no-tools") {
213            opts.with_tools = false;
214        } else if (arg.rfind("--parallel-tool-calls=", 0) == 0) {
215            opts.parallel_tool_calls = parse_bool_option(arg.substr(22));
216        } else if (arg.rfind("--generation-prompt=", 0) == 0) {
217            opts.generation_prompt = parse_bool_option(arg.substr(20));
218        } else if (arg.rfind("--enable-reasoning=", 0) == 0) {
219            opts.enable_reasoning = parse_bool_option(arg.substr(19));
220        } else if (arg.rfind("--output=", 0) == 0) {
221            std::string mode = arg.substr(9);
222            if (mode == "analysis") {
223                opts.mode = output_mode::ANALYSIS;
224            } else if (mode == "template") {
225                opts.mode = output_mode::TEMPLATE;
226            } else if (mode == "both") {
227                opts.mode = output_mode::BOTH;
228            } else {
229                LOG_ERR("Unknown output mode: %s\n", mode.c_str());
230                return false;
231            }
232        } else if (arg.rfind("--input-message=", 0) == 0) {
233            std::string type = arg.substr(16);
234            if (type == "content_only") {
235                opts.input_message = input_message_type::CONTENT_ONLY;
236            } else if (type == "reasoning_content") {
237                opts.input_message = input_message_type::REASONING_CONTENT;
238            } else if (type == "tool_call_only") {
239                opts.input_message = input_message_type::TOOL_CALL_ONLY;
240            } else if (type == "content_tool_call") {
241                opts.input_message = input_message_type::CONTENT_TOOL_CALL;
242            } else if (type == "reasoning_tool_call") {
243                opts.input_message = input_message_type::REASONING_TOOL_CALL;
244            } else if (type == "content_fake_tool_call") {
245                opts.input_message = input_message_type::CONTENT_FAKE_TOOL_CALL;
246            } else if (type == "all") {
247                opts.input_message = input_message_type::ALL;
248            } else {
249                LOG_ERR("Unknown input message type: %s\n", type.c_str());
250                return false;
251            }
252        } else {
253            LOG_ERR("Unknown option: %s\n", arg.c_str());
254            print_usage(argv[0]);
255            return false;
256        }
257    }
258
259    return true;
260}
261
262static json build_debug_user_message() {
263    return json{
264        { "role",    "user"                               },
265        { "content", "Hello, please help me with a task." }
266    };
267}
268
269static json build_content_only_message() {
270    return json{
271        { "role",    "assistant"                                   },
272        { "content", "Hello! I'm here to help you with your task." }
273    };
274}
275
276static json build_reasoning_content_message() {
277    return json{
278        { "role",              "assistant"                                                               },
279        { "content",           "Hello! I'm here to help you with your task."                             },
280        { "reasoning_content", "The user is greeting me and asking for help. I should respond politely." }
281    };
282}
283
284static json build_tool_call_only_message() {
285    return json{
286        { "role",       "assistant"      },
287        { "content",    nullptr          },
288        { "tool_calls",
289         json::array({ json{
290              { "type", "function" },
291              { "function", json{ { "name", "test_function_name" },
292                                  { "arguments", json::object({ { "param1", "value1" }, { "param2", "value2" } }) } } },
293              { "id", "123456789" } } }) }
294    };
295}
296
297static json build_content_tool_call_message() {
298    return json{
299        { "role",       "assistant"                                                                              },
300        { "content",    "I'll help you by calling a function."                                                   },
301        { "tool_calls",
302         json::array({ json{
303              { "type", "function" },
304              { "function",
305                json{ { "name", "test_function_name" },
306                      { "arguments", json::object({ { "param1", "value1" }, { "param2", "value2" } }) } } } } }) }
307    };
308}
309
310static json build_reasoning_tool_call_message() {
311    return json{
312        { "role",              "assistant"                                                                       },
313        { "content",           nullptr                                                                           },
314        { "reasoning_content", "I need to call a function to help with this task."                               },
315        { "tool_calls",
316         json::array({ json{
317              { "type", "function" },
318              { "function",
319                json{ { "name", "test_function_name" },
320                      { "arguments", json::object({ { "param1", "value1" }, { "param2", "value2" } }) } } } } }) }
321    };
322}
323
324static json build_content_fake_tool_call_message() {
325    // This message has content but NO tool_calls field
326    // It's used to test if a template renders tool definitions but not tool calls
327    return json{
328        { "role",    "assistant"                            },
329        { "content", "I'll help you by calling a function." }
330    };
331}
332
333static void render_scenario(const common_chat_template & tmpl,
334                            const std::string &          scenario_name,
335                            const json &                 messages,
336                            const json &                 tools,
337                            bool                         add_generation_prompt,
338                            bool                         enable_thinking) {
339    LOG_ERR("\n=== Scenario: %s ===\n", scenario_name.c_str());
340    LOG_ERR("add_generation_prompt: %s, enable_thinking: %s\n", add_generation_prompt ? "true" : "false",
341            enable_thinking ? "true" : "false");
342
343    // When add_generation_prompt is true, add a trailing user message to trigger the prompt
344    json final_messages = messages;
345    if (add_generation_prompt && !messages.empty() && messages.back().value("role", "") == "assistant") {
346        final_messages.push_back(json{
347            { "role",    "user"                                       },
348            { "content", "Now please continue with another response." }
349        });
350    }
351
352    LOG_ERR("Messages:\n%s\n", final_messages.dump(2).c_str());
353
354    try {
355        generation_params inputs;
356        inputs.messages                         = final_messages;
357        inputs.add_generation_prompt            = add_generation_prompt;
358        inputs.extra_context["enable_thinking"] = enable_thinking;
359
360        if (!tools.is_null() && tools.is_array() && !tools.empty()) {
361            inputs.tools = tools;
362        }
363
364        std::string output = common_chat_template_direct_apply(tmpl, inputs);
365
366        LOG_ERR("\n--- Rendered Output ---\n");
367        LOG_ERR("%s\n", output.c_str());
368        LOG_ERR("--- End Output (length: %zu) ---\n", output.length());
369    } catch (const std::exception & e) {
370        LOG_ERR("Rendering failed: %s\n", e.what());
371    }
372}
373
374static void render_all_scenarios(const common_chat_template & tmpl,
375                                 const json &                 tools,
376                                 bool                         add_generation_prompt,
377                                 bool                         enable_thinking,
378                                 input_message_type           message_type) {
379    json user_msg = build_debug_user_message();
380
381    auto render_if = [&](input_message_type type, const std::string & name, const json & assistant_msg) {
382        if (message_type == input_message_type::ALL || message_type == type) {
383            json messages = json::array({ user_msg, assistant_msg });
384            render_scenario(tmpl, name, messages, tools, add_generation_prompt, enable_thinking);
385        }
386    };
387
388    render_if(input_message_type::CONTENT_ONLY, "content_only", build_content_only_message());
389    render_if(input_message_type::REASONING_CONTENT, "reasoning_content", build_reasoning_content_message());
390    render_if(input_message_type::TOOL_CALL_ONLY, "tool_call_only", build_tool_call_only_message());
391    render_if(input_message_type::CONTENT_TOOL_CALL, "content_tool_call", build_content_tool_call_message());
392    render_if(input_message_type::REASONING_TOOL_CALL, "reasoning_tool_call", build_reasoning_tool_call_message());
393    render_if(input_message_type::CONTENT_FAKE_TOOL_CALL, "content_fake_tool_call",
394              build_content_fake_tool_call_message());
395
396    // Also render with add_generation_prompt=true to show the prompt ending
397    if (message_type == input_message_type::ALL) {
398        LOG_ERR("\n\n=== Generation Prompt Scenarios (add_generation_prompt=true) ===\n");
399
400        json prompt_messages = json::array({ user_msg });
401        render_scenario(tmpl, "generation_prompt_only", prompt_messages, tools, true, enable_thinking);
402
403        // With enable_thinking toggled
404        render_scenario(tmpl, "generation_prompt_thinking_disabled", prompt_messages, tools, true, false);
405    }
406}
407
408static generation_params prepare_debug_params(const debug_options & opts, const json & tools) {
409    generation_params params;
410    params.messages         = json::array({ build_debug_user_message() });
411    params.reasoning_format = opts.enable_reasoning ? COMMON_REASONING_FORMAT_DEEPSEEK : COMMON_REASONING_FORMAT_NONE;
412    params.enable_thinking  = opts.enable_reasoning;
413    params.add_generation_prompt = opts.generation_prompt;
414
415    if (opts.with_tools) {
416        params.tools       = tools;
417        params.tool_choice = opts.force_tool_call ? COMMON_CHAT_TOOL_CHOICE_REQUIRED : COMMON_CHAT_TOOL_CHOICE_AUTO;
418    } else {
419        params.tools       = json();
420        params.tool_choice = COMMON_CHAT_TOOL_CHOICE_NONE;
421    }
422    params.parallel_tool_calls = opts.parallel_tool_calls;
423    return params;
424}
425
426static int debug_single_template(const debug_options & opts) {
427    std::string template_source;
428    try {
429        // Check if the file is a GGUF file
430        if (opts.template_path.size() >= 5 &&
431            opts.template_path.compare(opts.template_path.size() - 5, 5, ".gguf") == 0) {
432            template_source = read_gguf_chat_template(opts.template_path);
433        } else {
434            template_source = read_file(opts.template_path);
435        }
436    } catch (const std::exception & e) {
437        LOG_ERR("Error reading template: %s\n", e.what());
438        return 1;
439    }
440
441    LOG_ERR("Analyzing template: %s\n", opts.template_path.c_str());
442    LOG_ERR("Options: with_tools=%s, generation_prompt=%s, enable_reasoning=%s\n", opts.with_tools ? "true" : "false",
443            opts.generation_prompt ? "true" : "false", opts.enable_reasoning ? "true" : "false");
444
445    try {
446        common_chat_template chat_template(template_source, "", "");
447
448        json tools = opts.with_tools ? build_tools_definition() : json();
449
450        generation_params params = prepare_debug_params(opts, tools);
451        common_chat_params            parser_data;
452        if (std::optional<common_chat_params> spec_tmpl =
453                common_chat_try_specialized_template(chat_template, template_source, params)) {
454            LOG_ERR("\n");
455            LOG_ERR("This template uses a specialized parser, analysis results will not be available.\n");
456            parser_data = *spec_tmpl;
457        } else {
458            // Render template scenarios if requested
459            if (opts.input_message != input_message_type::NONE &&
460                (opts.mode == output_mode::TEMPLATE || opts.mode == output_mode::BOTH)) {
461                LOG_ERR("\n");
462                LOG_ERR("================================================================================\n");
463                LOG_ERR("                         TEMPLATE RENDERING OUTPUT\n");
464                LOG_ERR("================================================================================\n");
465
466                render_all_scenarios(chat_template, tools, opts.generation_prompt, opts.enable_reasoning,
467                                     opts.input_message);
468            }
469
470            // Output analysis if requested
471            if (opts.mode == output_mode::ANALYSIS || opts.mode == output_mode::BOTH) {
472                LOG_ERR("\n");
473                LOG_ERR("================================================================================\n");
474                LOG_ERR("                           TEMPLATE ANALYSIS\n");
475                LOG_ERR("================================================================================\n");
476
477                struct autoparser analysis;
478                analysis.analyze_template(chat_template);
479
480                // Generate Parser
481                parser_data = peg_generator::generate_parser(chat_template, params, analysis);
482            }
483        }
484
485        if (!std::empty(parser_data.parser)) {
486            LOG_ERR("\n=== Generated Parser ===\n");
487            common_peg_arena arena;
488            arena.load(parser_data.parser);
489            LOG_ERR("%s\n", arena.dump(arena.root()).c_str());
490
491            LOG_ERR("\n=== Generated Grammar ===\n");
492            LOG_ERR("%s\n", parser_data.grammar.c_str());
493
494            LOG_ERR("\n=== Generated Lazy Grammar ===\n");
495            LOG_ERR("%d\n", parser_data.grammar_lazy);
496
497            LOG_ERR("\n=== Generated Grammar Triggers ===\n");
498            for (const common_grammar_trigger & cgt : parser_data.grammar_triggers) {
499                LOG_ERR("Token: %d | Type: %d | Value: %s\n", cgt.token, cgt.type, cgt.value.c_str());
500            }
501
502            LOG_ERR("\n=== Preserved Tokens ===\n");
503            for (const std::string & token : parser_data.preserved_tokens) {
504                LOG_ERR("  '%s'\n", token.c_str());
505            }
506        }
507    } catch (const std::exception & e) {
508        LOG_ERR("Analysis failed: %s\n", e.what());
509        return 1;
510    }
511
512    return 0;
513}
514
515int main(int argc, char * argv[]) {
516    if (argc > 1) {
517        std::string arg = argv[1];
518        if (arg == "-h" || arg == "--help") {
519            common_log_set_verbosity_thold(99);
520            print_usage(argv[0]);
521            return 0;
522        }
523
524        // debug mode: if the first argument is an existing file, analyze that template instead of running the automated tests
525        if (std::filesystem::is_regular_file(arg)) {
526            common_log_set_verbosity_thold(99);
527
528            debug_options opts;
529            if (!parse_debug_options(argc, argv, opts)) {
530                return 1;
531            }
532
533            if (opts.debug_jinja || std::getenv("LLAMA_DEBUG_JINJA") != nullptr) {
534                jinja::enable_debug(true);
535            }
536
537            return debug_single_template(opts);
538        }
539    }
540
541    testing t(std::cout);
542    t.verbose = true;
543
544    // usage: test-chat-auto-parser [filter_regex]
545
546    if (argc > 1) {
547        t.set_filter(argv[1]);
548    }
549
550    t.test("diff_split", test_calculate_diff_split);
551    t.test("common_prefix", test_until_common_prefix);
552    t.test("common_suffix", test_after_common_suffix);
553    t.test("compare_variants", test_compare_variants);
554    t.test("segments", test_marker_separation);
555    t.test("seed_oss_diffs", test_seed_oss_tool_analysis);
556    t.test("cohere", test_cohere_analysis);
557    t.test("nemotron", test_nemotron_analysis);
558    t.test("laguna", test_laguna_analysis);
559    t.test("laguna-s", test_laguna_s_analysis);
560    t.test("laguna-xs2", test_laguna_xs2_analysis);
561    t.test("smollm3", test_smollm3_analysis);
562    t.test("standard_json_tools", test_standard_json_tools_formats);
563    t.test("normalize_quotes_to_json", test_normalize_quotes_to_json);
564    t.test("tagged_args_embedded_quotes", test_tagged_args_with_embedded_quotes);
565    t.test("bailing_v3", test_bailing_v3_tool_format);
566    t.test("role_markers_all_templates", test_role_markers_all_templates);
567
568    return t.summary();
569}
570
571static void test_marker_separation(testing & t) {
572    auto single_square_marker = segmentize_markers("pre_marker[marker]post_marker");
573    auto single_diag_marker = segmentize_markers("pre_marker<marker>post_marker");
574    auto paired_markers = segmentize_markers("<hello>world</hello>");
575    auto double_different_markers = segmentize_markers("<hello>[hello]<world>[world]");
576    auto in_between = segmentize_markers("im<blue>daba<dee>da[hey]");
577
578    t.test("single_square_marker", [&] (testing & t) {
579        t.assert_equal("first is text", segment_type::TEXT, single_square_marker[0].type);
580        t.assert_equal("second is marker", segment_type::MARKER, single_square_marker[1].type);
581        t.assert_equal("last is text", segment_type::TEXT, single_square_marker[2].type);
582
583        t.assert_equal("first is 'pre_marker'", "pre_marker", single_square_marker[0].value);
584        t.assert_equal("second is '[marker]'", "[marker]", single_square_marker[1].value);
585        t.assert_equal("last is 'post_marker'", "post_marker", single_square_marker[2].value);
586    });
587
588    t.test("single_diagonal_marker", [&] (testing & t) {
589        t.assert_equal("first is text", segment_type::TEXT, single_diag_marker[0].type);
590        t.assert_equal("second is marker", segment_type::MARKER, single_diag_marker[1].type);
591        t.assert_equal("last is text", segment_type::TEXT, single_diag_marker[2].type);
592
593        t.assert_equal("first is 'pre_marker'", "pre_marker", single_diag_marker[0].value);
594        t.assert_equal("second is '<marker>'", "<marker>", single_diag_marker[1].value);
595        t.assert_equal("last is 'post_marker'", "post_marker", single_diag_marker[2].value);
596    });
597
598    t.test("paired_markers", [&] (testing & t) {
599        t.assert_equal("first is marker", segment_type::MARKER, paired_markers[0].type);
600        t.assert_equal("second is text", segment_type::TEXT, paired_markers[1].type);
601        t.assert_equal("third is marker", segment_type::MARKER, paired_markers[2].type);
602
603        t.assert_equal("first is '<hello>'", "<hello>", paired_markers[0].value);
604        t.assert_equal("second is 'world'", "world", paired_markers[1].value);
605        t.assert_equal("third is '</hello>'", "</hello>", paired_markers[2].value);
606    });
607
608    t.test("double_different_markers", [&] (testing & t) {
609        t.assert_equal("first is marker", segment_type::MARKER, double_different_markers[0].type);
610        t.assert_equal("second is marker", segment_type::MARKER, double_different_markers[1].type);
611        t.assert_equal("third is marker", segment_type::MARKER, double_different_markers[2].type);
612        t.assert_equal("fourth is marker", segment_type::MARKER, double_different_markers[3].type);
613
614        t.assert_equal("first is '<hello>'", "<hello>", double_different_markers[0].value);
615        t.assert_equal("second is '[hello]'", "[hello]", double_different_markers[1].value);
616        t.assert_equal("third is '<world>'", "<world>", double_different_markers[2].value);
617        t.assert_equal("fourth is '[world]'", "[world]", double_different_markers[3].value);
618    });
619
620    t.test("in_between", [&] (testing & t) {
621        t.assert_equal("first is text", segment_type::TEXT, in_between[0].type);
622        t.assert_equal("second is marker", segment_type::MARKER, in_between[1].type);
623        t.assert_equal("third is text", segment_type::TEXT, in_between[2].type);
624        t.assert_equal("fourth is marker", segment_type::MARKER, in_between[3].type);
625        t.assert_equal("fifth is text", segment_type::TEXT, in_between[4].type);
626        t.assert_equal("sixth is marker", segment_type::MARKER, in_between[5].type);
627
628        t.assert_equal("first is 'im'", "im", in_between[0].value);
629        t.assert_equal("second is '<blue>'", "<blue>", in_between[1].value);
630        t.assert_equal("third is 'daba'", "daba", in_between[2].value);
631        t.assert_equal("fourth is '<dee>'", "<dee>", in_between[3].value);
632        t.assert_equal("fifth is 'da'", "da", in_between[4].value);
633        t.assert_equal("sixth is '[hey]'", "[hey]", in_between[5].value);
634    });
635}
636
637static void test_calculate_diff_split(testing & t) {
638    t.test("calculate_diff_split basic", test_calculate_diff_split_basic);
639    t.test("calculate_diff_split identical", test_calculate_diff_split_identical);
640    t.test("calculate_diff_split common prefix", test_calculate_diff_split_common_prefix);
641    t.test("calculate_diff_split common suffix", test_calculate_diff_split_common_suffix);
642    t.test("calculate_diff_split common both", test_calculate_diff_split_common_both);
643    t.test("calculate_diff_split empty cases", test_calculate_diff_split_empty_cases);
644    t.test("calculate_diff_split no common", test_calculate_diff_split_no_common);
645    t.test("calculate_diff_split single char", test_calculate_diff_split_single_char);
646    t.test("calculate_diff_split overlaps", test_calculate_diff_split_overlaps);
647    t.test("calculate_diff_split tag boundaries", test_calculate_diff_split_tag_boundaries);
648    t.test("calculate_diff_split generation prompt", test_calculate_diff_split_generation_prompt);
649}
650
651static void test_calculate_diff_split_basic(testing & t) {
652    diff_split result = calculate_diff_split("hello world", "hello test");
653    t.assert_equal("prefix should be 'hello '", "hello ", result.prefix);
654    t.assert_equal("left should be 'world'", "world", result.left);
655    t.assert_equal("right should be 'test'", "test", result.right);
656    t.assert_equal("suffix should be empty", "", result.suffix);
657
658    result = calculate_diff_split("abc", "xyz");
659    t.assert_equal("prefix should be empty", "", result.prefix);
660    t.assert_equal("left should be 'abc'", "abc", result.left);
661    t.assert_equal("right should be 'xyz'", "xyz", result.right);
662    t.assert_equal("suffix should be empty", "", result.suffix);
663
664    result = calculate_diff_split("prefixA suffix", "prefixB suffix");
665    t.assert_equal("prefix should be 'prefix'", "prefix", result.prefix);
666    t.assert_equal("left should be 'A'", "A", result.left);
667    t.assert_equal("right should be 'B'", "B", result.right);
668    t.assert_equal("suffix should be ' suffix'", " suffix", result.suffix);
669}
670
671static void test_calculate_diff_split_identical(testing & t) {
672    diff_split result = calculate_diff_split("hello", "hello");
673    t.assert_equal("prefix should be 'hello'", "hello", result.prefix);
674    t.assert_equal("left should be empty", "", result.left);
675    t.assert_equal("right should be empty", "", result.right);
676    t.assert_equal("suffix should be empty", "", result.suffix);
677
678    result = calculate_diff_split("", "");
679    t.assert_equal("prefix should be empty", "", result.prefix);
680    t.assert_equal("left should be empty", "", result.left);
681    t.assert_equal("right should be empty", "", result.right);
682    t.assert_equal("suffix should be empty", "", result.suffix);
683
684    result = calculate_diff_split("a", "a");
685    t.assert_equal("prefix should be 'a'", "a", result.prefix);
686    t.assert_equal("left should be empty", "", result.left);
687    t.assert_equal("right should be empty", "", result.right);
688    t.assert_equal("suffix should be empty", "", result.suffix);
689
690    result = calculate_diff_split("<row><row><row><your><boat><gently>", "<row><row><row><your><boat><gently>");
691    t.assert_equal("prefix should be '<row><row><row><your><boat><gently>'", "<row><row><row><your><boat><gently>", result.prefix);
692    t.assert_equal("left should be empty", "", result.left);
693    t.assert_equal("right should be empty", "", result.right);
694    t.assert_equal("suffix should be empty", "", result.suffix);
695}
696
697static void test_calculate_diff_split_common_prefix(testing & t) {
698    diff_split result = calculate_diff_split("abcdef", "abcxyz");
699    t.assert_equal("prefix should be 'abc'", "abc", result.prefix);
700    t.assert_equal("left should be 'def'", "def", result.left);
701    t.assert_equal("right should be 'xyz'", "xyz", result.right);
702    t.assert_equal("suffix should be empty", "", result.suffix);
703
704    result = calculate_diff_split("same", "sameagain");
705    t.assert_equal("prefix should be 'same'", "same", result.prefix);
706    t.assert_equal("left should be empty", "", result.left);
707    t.assert_equal("right should be 'again'", "again", result.right);
708    t.assert_equal("suffix should be empty", "", result.suffix);
709
710    result = calculate_diff_split("test", "testing");
711    t.assert_equal("prefix should be 'test'", "test", result.prefix);
712    t.assert_equal("left should be empty", "", result.left);
713    t.assert_equal("right should be 'ing'", "ing", result.right);
714    t.assert_equal("suffix should be empty", "", result.suffix);
715}
716
717static void test_calculate_diff_split_common_suffix(testing & t) {
718    diff_split result = calculate_diff_split("123end", "456end");
719    t.assert_equal("prefix should be empty", "", result.prefix);
720    t.assert_equal("left should be '123'", "123", result.left);
721    t.assert_equal("right should be '456'", "456", result.right);
722    t.assert_equal("suffix should be 'end'", "end", result.suffix);
723
724    result = calculate_diff_split("start", "end");
725    t.assert_equal("prefix should be empty", "", result.prefix);
726    t.assert_equal("left should be 'start'", "start", result.left);
727    t.assert_equal("right should be 'end'", "end", result.right);
728    t.assert_equal("suffix should be empty", "", result.suffix);
729
730    result = calculate_diff_split("abcsuffix", "xyzsuffix");
731    t.assert_equal("prefix should be empty", "", result.prefix);
732    t.assert_equal("left should be 'abc'", "abc", result.left);
733    t.assert_equal("right should be 'xyz'", "xyz", result.right);
734    t.assert_equal("suffix should be 'suffix'", "suffix", result.suffix);
735}
736
737static void test_calculate_diff_split_common_both(testing & t) {
738    diff_split result = calculate_diff_split("helloXworld", "helloYworld");
739    t.assert_equal("prefix should be 'hello'", "hello", result.prefix);
740    t.assert_equal("left should be 'X'", "X", result.left);
741    t.assert_equal("right should be 'Y'", "Y", result.right);
742    t.assert_equal("suffix should be 'world'", "world", result.suffix);
743
744    result = calculate_diff_split("ABCmiddleXYZ", "ABCdifferentXYZ");
745    t.assert_equal("prefix should be 'ABC'", "ABC", result.prefix);
746    t.assert_equal("left should be 'middle'", "middle", result.left);
747    t.assert_equal("right should be 'different'", "different", result.right);
748    t.assert_equal("suffix should be 'XYZ'", "XYZ", result.suffix);
749
750    result = calculate_diff_split("startAend", "startBend");
751    t.assert_equal("prefix should be 'start'", "start", result.prefix);
752    t.assert_equal("left should be 'A'", "A", result.left);
753    t.assert_equal("right should be 'B'", "B", result.right);
754    t.assert_equal("suffix should be 'end'", "end", result.suffix);
755
756    // Edge case: common prefix and suffix overlap
757    result = calculate_diff_split("aa", "ab");
758    t.assert_equal("prefix should be 'a'", "a", result.prefix);
759    t.assert_equal("left should be 'a'", "a", result.left);
760    t.assert_equal("right should be 'b'", "b", result.right);
761    t.assert_equal("suffix should be empty", "", result.suffix);
762}
763
764static void test_calculate_diff_split_empty_cases(testing & t) {
765    // Empty left, non-empty right
766    diff_split result = calculate_diff_split("", "hello");
767    t.assert_equal("prefix should be empty", "", result.prefix);
768    t.assert_equal("left should be empty", "", result.left);
769    t.assert_equal("right should be 'hello'", "hello", result.right);
770    t.assert_equal("suffix should be empty", "", result.suffix);
771
772    // Non-empty left, empty right
773    result = calculate_diff_split("hello", "");
774    t.assert_equal("prefix should be empty", "", result.prefix);
775    t.assert_equal("left should be 'hello'", "hello", result.left);
776    t.assert_equal("right should be empty", "", result.right);
777    t.assert_equal("suffix should be empty", "", result.suffix);
778
779    // Both empty
780    result = calculate_diff_split("", "");
781    t.assert_equal("prefix should be empty", "", result.prefix);
782    t.assert_equal("left should be empty", "", result.left);
783    t.assert_equal("right should be empty", "", result.right);
784    t.assert_equal("suffix should be empty", "", result.suffix);
785
786    // Left single char, empty right
787    result = calculate_diff_split("a", "");
788    t.assert_equal("prefix should be empty", "", result.prefix);
789    t.assert_equal("left should be 'a'", "a", result.left);
790    t.assert_equal("right should be empty", "", result.right);
791    t.assert_equal("suffix should be empty", "", result.suffix);
792
793    // Empty left, right single char
794    result = calculate_diff_split("", "a");
795    t.assert_equal("prefix should be empty", "", result.prefix);
796    t.assert_equal("left should be empty", "", result.left);
797    t.assert_equal("right should be 'a'", "a", result.right);
798    t.assert_equal("suffix should be empty", "", result.suffix);
799}
800
801static void test_calculate_diff_split_no_common(testing & t) {
802    diff_split result = calculate_diff_split("abc", "xyz");
803    t.assert_equal("prefix should be empty", "", result.prefix);
804    t.assert_equal("left should be 'abc'", "abc", result.left);
805    t.assert_equal("right should be 'xyz'", "xyz", result.right);
806    t.assert_equal("suffix should be empty", "", result.suffix);
807
808    result = calculate_diff_split("left", "right");
809    // The algorithm finds "t" as a common suffix since both strings end with 't'
810    // This is the algorithm's actual behavior - it finds maximal common suffix
811    t.assert_equal("prefix should be empty", "", result.prefix);
812    t.assert_equal("left should be 'lef'", "lef", result.left);
813    t.assert_equal("right should be 'righ'", "righ", result.right);
814    t.assert_equal("suffix should be 't'", "t", result.suffix);
815
816    result = calculate_diff_split("123", "456");
817    t.assert_equal("prefix should be empty", "", result.prefix);
818    t.assert_equal("left should be '123'", "123", result.left);
819    t.assert_equal("right should be '456'", "456", result.right);
820    t.assert_equal("suffix should be empty", "", result.suffix);
821}
822
823static void test_calculate_diff_split_single_char(testing & t) {
824    diff_split result = calculate_diff_split("a", "b");
825    t.assert_equal("prefix should be empty", "", result.prefix);
826    t.assert_equal("left should be 'a'", "a", result.left);
827    t.assert_equal("right should be 'b'", "b", result.right);
828    t.assert_equal("suffix should be empty", "", result.suffix);
829
830    result = calculate_diff_split("a", "a");
831    t.assert_equal("prefix should be 'a'", "a", result.prefix);
832    t.assert_equal("left should be empty", "", result.left);
833    t.assert_equal("right should be empty", "", result.right);
834    t.assert_equal("suffix should be empty", "", result.suffix);
835
836    result = calculate_diff_split("a", "ab");
837    t.assert_equal("prefix should be 'a'", "a", result.prefix);
838    t.assert_equal("left should be empty", "", result.left);
839    t.assert_equal("right should be 'b'", "b", result.right);
840    t.assert_equal("suffix should be empty", "", result.suffix);
841
842    result = calculate_diff_split("ab", "a");
843    t.assert_equal("prefix should be 'a'", "a", result.prefix);
844    t.assert_equal("left should be 'b'", "b", result.left);
845    t.assert_equal("right should be empty", "", result.right);
846    t.assert_equal("suffix should be empty", "", result.suffix);
847}
848
849static void test_calculate_diff_split_overlaps(testing & t) {
850    // One string is substring of another
851    diff_split result = calculate_diff_split("test", "testing");
852    t.assert_equal("prefix should be 'test'", "test", result.prefix);
853    t.assert_equal("left should be empty", "", result.left);
854    t.assert_equal("right should be 'ing'", "ing", result.right);
855    t.assert_equal("suffix should be empty", "", result.suffix);
856
857    result = calculate_diff_split("testing", "test");
858    t.assert_equal("prefix should be 'test'", "test", result.prefix);
859    t.assert_equal("left should be 'ing'", "ing", result.left);
860    t.assert_equal("right should be empty", "", result.right);
861    t.assert_equal("suffix should be empty", "", result.suffix);
862
863    // Similar strings with one extra char at start
864    result = calculate_diff_split("Xtest", "Ytest");
865    // The algorithm finds "test" as a common suffix since both strings end with "test"
866    // This is the algorithm's actual behavior - it finds maximal common suffix
867    t.assert_equal("prefix should be empty", "", result.prefix);
868    t.assert_equal("left should be 'X'", "X", result.left);
869    t.assert_equal("right should be 'Y'", "Y", result.right);
870    t.assert_equal("suffix should be 'test'", "test", result.suffix);
871
872    // Similar strings with one extra char at end
873    result = calculate_diff_split("testX", "testY");
874    t.assert_equal("prefix should be 'test'", "test", result.prefix);
875    t.assert_equal("left should be 'X'", "X", result.left);
876    t.assert_equal("right should be 'Y'", "Y", result.right);
877    t.assert_equal("suffix should be empty", "", result.suffix);
878
879    // Strings that are reverses
880    result = calculate_diff_split("abc", "cba");
881    t.assert_equal("prefix should be empty", "", result.prefix);
882    t.assert_equal("left should be 'abc'", "abc", result.left);
883    t.assert_equal("right should be 'cba'", "cba", result.right);
884    t.assert_equal("suffix should be empty", "", result.suffix);
885}
886
887static void test_calculate_diff_split_tag_boundaries(testing & t) {
888    // Test with unclosed XML tags
889    diff_split result = calculate_diff_split("test<tag", "test>content");
890    // The fix_tag_boundaries should move incomplete tags appropriately
891    t.assert_true("prefix should start with 'test'", result.prefix.find("test") == 0);
892    t.assert_true("should handle tag boundaries", result.left != "" || result.right != "" || result.suffix != "");
893
894    // Test with unclosed brackets
895    result = calculate_diff_split("test[", "test]value");
896    t.assert_true("should handle bracket boundaries", result.left != "" || result.right != "" || result.suffix != "");
897
898    // Test with partial tags on both sides
899    result = calculate_diff_split("prefix<tag>", "prefix</tag>suffix");
900    // fix_tag_boundaries moves the incomplete '<' from prefix to left/right
901    t.assert_equal("prefix should be 'prefix'", "prefix", result.prefix);
902    t.assert_equal("left should be '<tag>'", "<tag>", result.left);
903    t.assert_equal("right should be '</tag>suffix'", "</tag>suffix", result.right);
904    t.assert_equal("suffix should be empty", "", result.suffix);
905
906    // Test with complex nested tags
907    result = calculate_diff_split("prefix<div>content</div>", "prefix<div>different</div>");
908    // Algorithm finds "ent</div>" as a common suffix because both strings end with it
909    // This is the actual algorithm behavior, though not semantically ideal
910    t.assert_equal("prefix should be 'prefix<div>'", "prefix<div>", result.prefix);
911    t.assert_equal("left should be 'cont'", "cont", result.left);
912    t.assert_equal("right should be 'differ'", "differ", result.right);
913    t.assert_equal("suffix should be 'ent</div>'", "ent</div>", result.suffix);
914
915    // Test with unclosed angle bracket
916    result = calculate_diff_split("Hello <world>", "Hello test");
917    t.assert_equal("prefix should be 'Hello '", "Hello ", result.prefix);
918    t.assert_true("left should contain '<world>'", result.left.find("<world>") != std::string::npos);
919    t.assert_equal("right should be 'test'", "test", result.right);
920    t.assert_equal("suffix should be empty", "", result.suffix);
921
922    // Test with unclosed square bracket
923    result = calculate_diff_split("test [array]", "test other");
924    t.assert_equal("prefix should be 'test '", "test ", result.prefix);
925    t.assert_true("left should contain '[array]'", result.left.find("[array]") != std::string::npos);
926    t.assert_equal("right should be 'other'", "other", result.right);
927    t.assert_equal("suffix should be empty", "", result.suffix);
928
929    // Test empty prefix and suffix with tags
930    result = calculate_diff_split("<tag>left</tag>", "<tag>righ</tag>");
931    t.assert_equal("prefix should be '<tag>'", "<tag>", result.prefix);
932    t.assert_equal("left should be 'left'", "left", result.left);
933    t.assert_equal("right should be 'righ'", "righ", result.right);
934    t.assert_equal("suffix should be '</tag>'", "</tag>", result.suffix);
935
936    {
937        // real case from template tests, simplified
938        std::string left  = "PREFIX</think>Sure";
939        std::string right = "PREFIX<think>Lemme think</think>Sure";
940        result            = calculate_diff_split(left, right);
941        t.assert_equal("prefix should be PREFIX", "PREFIX", result.prefix);
942        t.assert_equal("suffix should be </think>Sure", "</think>Sure", result.suffix);
943        t.assert_equal("left should be empty", "", result.left);
944        t.assert_equal("right should be <think>Lemme think", "<think>Lemme think", result.right);
945    }
946
947    {
948        // Real case: special tokens with |> boundary issue
949        // The suffix starts with |> which should be moved to complete <|END_RESPONSE and <|END_ACTION
950        std::string prefix    = "SOME_PREFIX";
951        std::string suffix    = "|><|END_OF_TURN_TOKEN|><|START_OF_TURN_TOKEN|><|CHATBOT_TOKEN|>";
952        std::string left_diff = "<|START_RESPONSE|>Let me help you.<|END_RESPONSE";
953        std::string right_diff =
954            "<|START_THINKING|><|END_THINKING|><|START_ACTION|>[\n"
955            "    {\"tool_call_id\": \"0\", \"tool_name\": \"test_function_name\", "
956            "\"parameters\": {\"param1\": \"value1\", \"param2\": \"value2\"}}\n"
957            "]<|END_ACTION";
958
959        std::string left  = prefix + left_diff + suffix;
960        std::string right = prefix + right_diff + suffix;
961        result            = calculate_diff_split(left, right);
962
963        t.assert_equal("special token prefix", prefix, result.prefix);
964        // The |> should be moved from suffix to complete the tokens
965        t.assert_equal("special token left", "<|START_RESPONSE|>Let me help you.<|END_RESPONSE|>", result.left);
966        t.assert_true("special token right ends with |>", result.right.find("<|END_ACTION|>") != std::string::npos);
967        t.assert_equal("special token suffix", "<|END_OF_TURN_TOKEN|><|START_OF_TURN_TOKEN|><|CHATBOT_TOKEN|>",
968                       result.suffix);
969    }
970}
971
972static void test_calculate_diff_split_generation_prompt(testing & t) {
973    // ChatML thinking template: left is a prefix of right, generation_prompt is the appended part.
974    // The trailing \n in left matches the trailing \n in the generation_prompt, causing
975    // the suffix matcher to steal it and rotate the diff result.
976    {
977        // Simplified reproduction: left ends with \n, right = left + "<|im_start|>assistant\n<think>\n"
978        std::string left  = "<|im_start|>user\nHello<|im_end|>\n";
979        std::string right = left + "<|im_start|>assistant\n<think>\n";
980        diff_split result = calculate_diff_split(left, right);
981        t.assert_equal("chatml prefix", left, result.prefix);
982        t.assert_equal("chatml left", "", result.left);
983        t.assert_equal("chatml right should be generation prompt",
984                       "<|im_start|>assistant\n<think>\n", result.right);
985        t.assert_equal("chatml suffix", "", result.suffix);
986    }
987
988    {
989        // More realistic: longer conversation ending with tool_response
990        std::string common =
991            "<|im_start|>system\nYou are a helpful assistant.<|im_end|>\n"
992            "<|im_start|>user\nSearch for files<|im_end|>\n"
993            "<|im_start|>assistant\n<think>\nLet me search.\n</think>\n\n"
994            "<tool_call>\n<function=search>\n</function>\n</tool_call><|im_end|>\n"
995            "<|im_start|>user\n<tool_response>\nNo files found\n</tool_response><|im_end|>\n";
996        std::string left  = common;
997        std::string right = common + "<|im_start|>assistant\n<think>\n";
998        diff_split result = calculate_diff_split(left, right);
999        t.assert_equal("tool_response left", "", result.left);
1000        t.assert_equal("tool_response right should be generation prompt",
1001                       "<|im_start|>assistant\n<think>\n", result.right);
1002    }
1003}
1004
1005static void test_until_common_prefix(testing & t) {
1006    t.test("until_common_prefix basic", test_until_common_prefix_basic);
1007}
1008
1009static void test_until_common_prefix_basic(testing & t) {
1010    // Test case from the user request
1011    std::string result = until_common_prefix("<function name=foo><arg name=bar>", "<arg name=bar>", "<arg name=baz>");
1012    t.assert_equal("untilCommonPrefix should return '<function name=foo>'", "<function name=foo>", result);
1013
1014    // Additional test cases to ensure robustness
1015    // Test with different common prefix lengths
1016    result = until_common_prefix("prefix<test>suffix", "<test>different", "<test>other");
1017    t.assert_equal("should return 'prefix'", "prefix", result);
1018
1019    // Test when common prefix is at the start
1020    result = until_common_prefix("<common>rest", "<common>left", "<common>right");
1021    t.assert_equal("should return empty string when common prefix at start", "", result);
1022
1023    // Test when there's no common prefix
1024    result = until_common_prefix("something", "left", "right");
1025    t.assert_equal("should return empty string when no common prefix", "", result);
1026
1027    // Test with empty strings
1028    result = until_common_prefix("test", "", "right");
1029    t.assert_equal("should return empty string when left is empty", "", result);
1030
1031    // Test with longer common prefix
1032    result = until_common_prefix("abcXYZ<shared_prefix>rest", "<shared_prefix>left", "<shared_prefix>right");
1033    t.assert_equal("should return 'abcXYZ'", "abcXYZ", result);
1034}
1035
1036static void test_after_common_suffix(testing & t) {
1037    t.test("after_common_suffix basic", test_after_common_suffix_basic);
1038}
1039
1040static void test_after_common_suffix_basic(testing & t) {
1041    // Test case from the user request
1042    std::string result = after_common_suffix("<function name=foo><arg name=bar>100</arg></function>",
1043                                            "<arg name=bar>100</arg>",
1044                                            "<arg name=baz>535</arg>");
1045    t.assert_equal("afterCommonSuffix should return '</function>'", "</function>", result);
1046
1047    // Test when common suffix is at the end
1048    result = after_common_suffix("rest<common>", "left<common>", "right<common>");
1049    t.assert_equal("should return empty string when common suffix at end", "", result);
1050
1051    // Test with empty strings
1052    result = after_common_suffix("test", "left", "");
1053    t.assert_equal("should return empty string when right is empty", "", result);
1054
1055    // Test case with XML-like structure similar to the main example
1056    result = after_common_suffix("<outer><inner>value</inner></outer>",
1057                                "<inner>value</inner>",
1058                                "<inner>different</inner>");
1059    t.assert_equal("should return '</outer>'", "</outer>", result);
1060
1061    // Test with longer common suffix appearing at the end of full
1062    result = after_common_suffix("prefix<shared>rest</shared>", "prefix<shared>left</shared>", "prefix<shared>right</shared>");
1063    t.assert_equal("should return '' when common suffix is at end of full", "", result);
1064
1065    // Test with common suffix appearing in middle but not at end
1066    result = after_common_suffix("<tag>content</tag><extra>", "<tag>value</tag>", "<tag>other</tag>");
1067    t.assert_equal("should return '<extra>' when common suffix appears before end", "<extra>", result);
1068
1069    // Test with multi-character common suffix at the very end of full
1070    result = after_common_suffix("start<middle>end</middle>", "prefix<middle>left</middle>", "prefix<middle>right</middle>");
1071    t.assert_equal("should return '' when common suffix </middle> is at end of full", "", result);
1072}
1073
1074static void test_compare_variants(testing & t) {
1075    t.test("compare_variants basic", test_compare_variants_basic);
1076    t.test("compare_variants messages modifier", test_compare_variants_messages_modifier);
1077    t.test("compare_variants tools modifier", test_compare_variants_tools_modifier);
1078    t.test("compare_variants both modifiers", test_compare_variants_both_modifiers);
1079    t.test("compare_variants template failure", test_compare_variants_template_failure);
1080    t.test("compare_variants identity", test_compare_variants_identity);
1081}
1082
1083static void test_compare_variants_basic(testing & t) {
1084    // Create a simple template that just echoes messages
1085    common_chat_template tmpl("{{ messages[0]['content'] }}", "", "");
1086
1087    template_params params;
1088    params.messages = json::array({
1089        json {{"role", "user"}, {"content", "Hello"}}
1090    });
1091
1092    auto modifier = [](template_params & p) {
1093        p.messages[0]["content"] = "World";
1094    };
1095
1096    auto result = ::compare_variants(tmpl, params, modifier);
1097
1098    if (!t.assert_true("result should have value", result.has_value())) {
1099        return;
1100    }
1101    // The template might not output anything if messages is empty or format is different
1102    // Check that we get a valid result
1103    t.assert_true("prefix or left should have content", !result->diff.prefix.empty() || !result->diff.left.empty());
1104}
1105
1106static void test_compare_variants_messages_modifier(testing & t) {
1107    // Test with messages modifier only
1108    common_chat_template tmpl("{% for message in messages %}{{ message['role'] }}:{{ message['content'] }}{% endfor %}", "", "");
1109
1110    template_params params;
1111    params.messages = json::array({
1112        json {{"role", "user"}, {"content", "A"}}
1113    });
1114
1115    auto modifier = [](template_params & p) {
1116        p.messages[0]["content"] = "B";
1117    };
1118
1119    std::optional<compare_variants_result> result = ::compare_variants(tmpl, params, modifier);
1120
1121    if (!t.assert_true("result should have value", result.has_value())) {
1122        return;
1123    }
1124    t.assert_equal("left should be 'A'", "A", result->diff.left);
1125    t.assert_equal("right should be 'B'", "B", result->diff.right);
1126}
1127
1128static void test_compare_variants_tools_modifier(testing & t) {
1129    // Test with tools modifier only
1130    common_chat_template tmpl(
1131        "{% for tool in tools %}{{ tool['name'] }}{% endfor %}", "", "");
1132
1133    template_params params;
1134    params.tools = json::array({
1135        json {{"name", "foo"}}
1136    });
1137
1138    auto modifier = [](template_params & p) {
1139        p.tools[0]["name"] = "bar";
1140    };
1141
1142    auto result = ::compare_variants(tmpl, params, modifier);
1143
1144    if (!t.assert_true("result should have value", result.has_value())) {
1145        return;
1146    }
1147    t.assert_equal("left should be 'foo'", "foo", result->diff.left);
1148    t.assert_equal("right should be 'bar'", "bar", result->diff.right);
1149}
1150
1151static void test_compare_variants_both_modifiers(testing & t) {
1152    // Test with both messages and tools modifiers using the for loop approach
1153    common_chat_template tmpl(
1154        "{% for message in messages %}{{ message['role'] }}:{{ message['content'] }}{% endfor %}", "", "");
1155
1156    template_params params;
1157    params.messages = json::array({
1158        json {{"role", "user"}, {"content", "A"}}
1159    });
1160
1161    auto modifier = [](template_params & p) {
1162        p.messages[0]["content"] = "B";
1163        p.messages[0]["role"] = "newuser";
1164    };
1165
1166    auto result = ::compare_variants(tmpl, params, modifier);
1167
1168    if (!t.assert_true("result should have value", result.has_value())) {
1169        return;
1170    }
1171    t.assert_equal("left should be 'user:A'", "user:A", result->diff.left);
1172    t.assert_equal("right should be 'newuser:B'", "newuser:B", result->diff.right);
1173}
1174
1175static void test_compare_variants_template_failure(testing & t) {
1176    // Test with template that causes failure during application (not construction)
1177    // We use a valid template syntax but one that will fail during application
1178    common_chat_template tmpl("{{ messages.cahoot()[0]['nonexistent_field'] }}", "", "");
1179
1180    template_params params;
1181    params.messages = json::array({
1182        json {{"role", "user"}, {"content", "Hello"}}
1183    });
1184
1185    auto modifier = [](template_params & p) {
1186        p.messages[0]["content"] = "World";
1187    };
1188
1189    auto result = ::compare_variants(tmpl, params, modifier);
1190
1191    t.assert_true("result should be nullopt on template failure", !result.has_value());
1192}
1193
1194static void test_compare_variants_identity(testing & t) {
1195    // Test with identity modifier (no change)
1196    common_chat_template tmpl("{{ messages[0]['content'] }}", "", "");
1197
1198    template_params params;
1199    params.messages = json::array({
1200        json {{"role", "user"}, {"content", "Hello"}}
1201    });
1202
1203    // No modifier - should use identity
1204    auto result = ::compare_variants(tmpl, params, nullptr);
1205
1206    if (!t.assert_true("result should have value", result.has_value())) {
1207        return;
1208    }
1209    t.assert_equal("prefix should be 'Hello'", "Hello", result->diff.prefix);
1210    t.assert_equal("left should be empty", "", result->diff.left);
1211    t.assert_equal("right should be empty", "", result->diff.right);
1212    t.assert_equal("suffix should be empty", "", result->diff.suffix);
1213}
1214
1215// ============================================================================
1216// Seed-OSS Template Tool Calling Analysis Tests
1217// ============================================================================
1218
1219static void test_seed_oss_tool_analysis(testing & t) {
1220    t.test("Seed-OSS tool presence", test_seed_oss_tool_presence);
1221    t.test("Seed-OSS call count", test_seed_oss_call_count);
1222    t.test("Seed-OSS function names", test_seed_oss_function_names);
1223    t.test("Seed-OSS argument count", test_seed_oss_argument_count);
1224    t.test("Seed-OSS args presence", test_seed_oss_args_presence);
1225    t.test("Seed-OSS tool with reasoning", test_seed_oss_tool_with_reasoning);
1226}
1227
1228// Helper to load Seed-OSS template
1229static common_chat_template load_seed_oss_template(testing & t) {
1230    std::string template_path = "models/templates/ByteDance-Seed-OSS.jinja";
1231    std::ifstream fin(template_path, std::ios::binary);
1232    std::ostringstream buf;
1233    if (fin.is_open()) {
1234        buf << fin.rdbuf();
1235    }
1236    std::string template_source = buf.str();
1237    common_chat_template tmpl(template_source, "", "");
1238    t.assert_true("Seed-OSS template loaded successfully", template_source.length() > 0);
1239    return tmpl;
1240}
1241
1242// Helper to build tool call JSON
1243static json build_tool_call(const std::string & name, const json & args, const std::string & id = "call_001") {
1244    return json{
1245        {"id", id},
1246        {"type", "function"},
1247        {"function", json{
1248            {"name", name},
1249            {"arguments", args}
1250        }}
1251    };
1252}
1253
1254// Helper to build tools definition
1255static json build_tools_definition() {
1256    json parameters_schema = json::object();
1257    parameters_schema["type"] = "object";
1258    parameters_schema["properties"] = json::object();
1259    parameters_schema["properties"]["param1"] = json::object({
1260        {"type", "string"},
1261        {"description", "First parameter"}
1262    });
1263    parameters_schema["properties"]["param2"] = json::object({
1264        {"type", "string"},
1265        {"description", "Second parameter"}
1266    });
1267    parameters_schema["required"] = json::array({"param1", "param2"});
1268
1269    return json::array({
1270        json{
1271            {"type", "function"},
1272            {"function", json{
1273                {"name", "test_function_name"},
1274                {"description", "A test function for debugging"},
1275                {"parameters", parameters_schema}
1276            }}
1277        }
1278    });
1279}
1280
1281// T1: Compare with/without tool call (user, assistant)
1282static void test_seed_oss_tool_presence(testing & t) {
1283    common_chat_template tmpl = load_seed_oss_template(t);
1284
1285    json assistant_no_tools = json{
1286        {"role", "assistant"},
1287        {"content", "Let me help you."}
1288    };
1289
1290    json assistant_with_tools = json{
1291        {"role", "assistant"},
1292        {"content", nullptr},
1293        {"tool_calls", json::array({
1294            build_tool_call("test_function_name", json::object({{"param1", "value1"}, {"param2", "value2"}}))
1295        })}
1296    };
1297
1298    json user_msg = json{
1299        {"role", "user"},
1300        {"content", "Hello, please help me."}
1301    };
1302
1303    template_params params_no_tools;
1304    params_no_tools.messages = json::array({user_msg, assistant_no_tools});
1305    params_no_tools.tools = build_tools_definition();
1306    params_no_tools.add_generation_prompt = false;
1307    params_no_tools.enable_thinking = true;
1308
1309    template_params params_with_tools;
1310    params_with_tools.messages = json::array({user_msg, assistant_with_tools});
1311    params_with_tools.tools = build_tools_definition();
1312    params_with_tools.add_generation_prompt = false;
1313    params_with_tools.enable_thinking = true;
1314
1315    auto result = ::compare_variants(tmpl, params_no_tools,
1316        [&](template_params & p) {
1317            p.messages = params_with_tools.messages;
1318        });
1319
1320    if (!t.assert_true("T1 result should have value", result.has_value())) {
1321        return;
1322    }
1323
1324    const auto & diff = result->diff;
1325    t.assert_true("T1 prefix should contain system", diff.prefix.find("system") != std::string::npos);
1326    t.assert_true("T1 prefix should contain user", diff.prefix.find("user") != std::string::npos);
1327    t.assert_true("T1 prefix should contain assistant", diff.prefix.find("assistant") != std::string::npos);
1328
1329    // Left should be the assistant content without tool
1330    t.assert_equal("T1 left should contain 'Let me help you.'", "Let me help you.", diff.left);
1331
1332    // Right should contain the tool call markers
1333    t.assert_true("T1 right should contain tool_call begin", diff.right.find("<seed:tool_call>") != std::string::npos);
1334    t.assert_true("T1 right should contain function tag", diff.right.find("<function=test_function_name>") != std::string::npos);
1335    t.assert_true("T1 right should contain parameter=param1", diff.right.find("<parameter=param1>") != std::string::npos);
1336    t.assert_true("T1 right should contain parameter=param2", diff.right.find("<parameter=param2>") != std::string::npos);
1337    t.assert_true("T1 right should contain value1", diff.right.find("value1") != std::string::npos);
1338    t.assert_true("T1 right should contain value2", diff.right.find("value2") != std::string::npos);
1339    t.assert_true("T1 right should contain tool_call end", diff.right.find("</seed:tool_call>") != std::string::npos);
1340
1341    // Suffix should be the eos token
1342    t.assert_equal("T1 suffix should be '<seed:eos>'", "<seed:eos>", diff.suffix);
1343}
1344
1345// T2: Compare one vs two tool calls
1346static void test_seed_oss_call_count(testing & t) {
1347    common_chat_template tmpl = load_seed_oss_template(t);
1348
1349    json assistant_one_call = json{
1350        {"role", "assistant"},
1351        {"content", nullptr},
1352        {"tool_calls", json::array({
1353            build_tool_call("test_function_name", json::object({{"param1", "value1"}, {"param2", "value2"}}))
1354        })}
1355    };
1356
1357    json assistant_two_calls = json{
1358        {"role", "assistant"},
1359        {"content", nullptr},
1360        {"tool_calls", json::array({
1361            build_tool_call("test_function_name", json::object({{"param1", "value1"}, {"param2", "value2"}})),
1362            build_tool_call("test_function_name", json::object({{"param1", "value3"}, {"param2", "value4"}}), "call_002")
1363        })}
1364    };
1365
1366    json user_msg = json{
1367        {"role", "user"},
1368        {"content", "Hello, please help me."}
1369    };
1370
1371    template_params params_one;
1372    params_one.messages = json::array({user_msg, assistant_one_call});
1373    params_one.tools = build_tools_definition();
1374    params_one.add_generation_prompt = false;
1375    params_one.enable_thinking = true;
1376
1377    auto result = ::compare_variants(tmpl, params_one,
1378        [&](template_params & p) {
1379            p.messages = json::array({user_msg, assistant_two_calls});
1380        });
1381
1382    if (!t.assert_true("T2 result should have value", result.has_value())) {
1383        return;
1384    }
1385
1386    const auto & diff = result->diff;
1387
1388    // Prefix should include the first tool call
1389    t.assert_true("T2 prefix should contain first tool_call begin", diff.prefix.find("<seed:tool_call>") != std::string::npos);
1390    t.assert_true("T2 prefix should contain first function", diff.prefix.find("<function=test_function_name>") != std::string::npos);
1391    t.assert_true("T2 prefix should contain value1", diff.prefix.find("value1") != std::string::npos);
1392    t.assert_true("T2 prefix should contain value2", diff.prefix.find("value2") != std::string::npos);
1393    t.assert_true("T2 prefix should contain first tool_call end", diff.prefix.find("</seed:tool_call>") != std::string::npos);
1394
1395    // Left should be empty (no second tool call in variant A)
1396    t.assert_equal("T2 left should be empty", "", diff.left);
1397
1398    // Right should contain the second tool call
1399    t.assert_true("T2 right should contain second tool_call begin", diff.right.find("<seed:tool_call>") != std::string::npos);
1400    t.assert_true("T2 right should contain second function", diff.right.find("<function=test_function_name>") != std::string::npos);
1401    t.assert_true("T2 right should contain value3", diff.right.find("value3") != std::string::npos);
1402    t.assert_true("T2 right should contain value4", diff.right.find("value4") != std::string::npos);
1403    t.assert_true("T2 right should contain second tool_call end", diff.right.find("</seed:tool_call>") != std::string::npos);
1404
1405    // Suffix should end with the eos token
1406    t.assert_equal("T2 suffix should end with '<seed:eos>'", "<seed:eos>", diff.suffix.substr(diff.suffix.length() - 10, 10));
1407}
1408
1409// T3: Compare different function names
1410static void test_seed_oss_function_names(testing & t) {
1411    common_chat_template tmpl = load_seed_oss_template(t);
1412
1413    // Build tools with two different function names
1414    json parameters_schema = json::object();
1415    parameters_schema["type"] = "object";
1416    parameters_schema["properties"] = json::object();
1417    parameters_schema["properties"]["arg1"] = json::object({
1418        {"type", "string"},
1419        {"description", "Argument 1"}
1420    });
1421    parameters_schema["required"] = json::array({"arg1"});
1422
1423    json tools = json::array({
1424        json{
1425            {"type", "function"},
1426            {"function", json{
1427                {"name", "func_alpha"},
1428                {"description", "First function"},
1429                {"parameters", parameters_schema}
1430            }}
1431        },
1432        json{
1433            {"type", "function"},
1434            {"function", json{
1435                {"name", "func_beta"},
1436                {"description", "Second function"},
1437                {"parameters", parameters_schema}
1438            }}
1439        }
1440    });
1441
1442    json assistant_func_alpha = json{
1443        {"role", "assistant"},
1444        {"content", nullptr},
1445        {"tool_calls", json::array({
1446            build_tool_call("func_alpha", json::object({{"arg1", "test_value"}}))
1447        })}
1448    };
1449
1450    json assistant_func_beta = json{
1451        {"role", "assistant"},
1452        {"content", nullptr},
1453        {"tool_calls", json::array({
1454            build_tool_call("func_beta", json::object({{"arg1", "test_value"}}))
1455        })}
1456    };
1457
1458    json user_msg = json{
1459        {"role", "user"},
1460        {"content", "Hello"}
1461    };
1462
1463    template_params params_alpha;
1464    params_alpha.messages = json::array({user_msg, assistant_func_alpha});
1465    params_alpha.tools = tools;
1466    params_alpha.add_generation_prompt = false;
1467    params_alpha.enable_thinking = true;
1468
1469    auto result = ::compare_variants(tmpl, params_alpha,
1470        [&](template_params & p) {
1471            p.messages = json::array({user_msg, assistant_func_beta});
1472        });
1473
1474    if (!t.assert_true("T3 result should have value", result.has_value())) {
1475        return;
1476    }
1477
1478    const auto & diff = result->diff;
1479
1480    bool func_alpha_in_left = diff.left.find("func_alpha") != std::string::npos;
1481    bool func_alpha_in_prefix = diff.prefix.find("func_alpha") != std::string::npos;
1482    bool func_beta_in_right = diff.right.find("func_beta") != std::string::npos;
1483    bool func_beta_in_prefix = diff.prefix.find("func_beta") != std::string::npos;
1484    bool func_beta_in_suffix = diff.suffix.find("func_beta") != std::string::npos;
1485
1486    // Left should contain func_alpha (or be in prefix)
1487    t.assert_true("T3 left should contain func_alpha (or prefix)", func_alpha_in_left || func_alpha_in_prefix);
1488
1489    // Right should contain func_beta
1490    t.assert_true("T3 right should contain func_beta", func_beta_in_right || func_beta_in_prefix || func_beta_in_suffix);
1491
1492    // Both should have the same parameter value (in common parts, not in diffs)
1493    // Since both have same args, test_value will be in prefix/suffix
1494    t.assert_true("T3 diff should contain test_value (in prefix or suffix)",
1495        diff.prefix.find("test_value") != std::string::npos || diff.suffix.find("test_value") != std::string::npos);
1496}
1497
1498// T4: Compare different argument counts (zero, one, two parameters)
1499static void test_seed_oss_argument_count(testing & t) {
1500    common_chat_template tmpl = load_seed_oss_template(t);
1501
1502    // Build tools with 0, 1, or 2 required parameters
1503    json params_2_required = json::object();
1504    params_2_required["type"] = "object";
1505    params_2_required["properties"] = json::object();
1506    params_2_required["properties"]["arg1"] = json::object({
1507        {"type", "string"},
1508        {"description", "Argument 1"}
1509    });
1510    params_2_required["properties"]["arg2"] = json::object({
1511        {"type", "string"},
1512        {"description", "Argument 2"}
1513    });
1514    params_2_required["required"] = json::array({"arg1", "arg2"});
1515
1516    json params_1_required = json::object();
1517    params_1_required["type"] = "object";
1518    params_1_required["properties"] = json::object();
1519    params_1_required["properties"]["arg1"] = json::object({
1520        {"type", "string"},
1521        {"description", "Argument 1"}
1522    });
1523    params_1_required["required"] = json::array({"arg1"});
1524
1525    json tools = json::array({
1526        json{
1527            {"type", "function"},
1528            {"function", json{
1529                {"name", "test_func"},
1530                {"description", "Test function"},
1531                {"parameters", params_2_required}
1532            }}
1533        }
1534    });
1535
1536    // Test: zero args vs one arg
1537    json assistant_zero_args = json{
1538        {"role", "assistant"},
1539        {"content", nullptr},
1540        {"tool_calls", json::array({
1541            build_tool_call("test_func", json::object())
1542        })}
1543    };
1544
1545    json assistant_one_arg = json{
1546        {"role", "assistant"},
1547        {"content", nullptr},
1548        {"tool_calls", json::array({
1549            build_tool_call("test_func", json::object({{"arg1", "value1"}}))
1550        })}
1551    };
1552
1553    json assistant_two_args = json{
1554        {"role", "assistant"},
1555        {"content", nullptr},
1556        {"tool_calls", json::array({
1557            build_tool_call("test_func", json::object({{"arg1", "value1"}, {"arg2", "value2"}}))
1558        })}
1559    };
1560
1561    json user_msg = json{
1562        {"role", "user"},
1563        {"content", "Hello"}
1564    };
1565
1566    // Test zero vs one
1567    template_params params_zero;
1568    params_zero.messages = json::array({user_msg, assistant_zero_args});
1569    params_zero.tools = tools;
1570    params_zero.add_generation_prompt = false;
1571    params_zero.enable_thinking = true;
1572
1573    auto result_zero_one = ::compare_variants(tmpl, params_zero,
1574        [&](template_params & p) {
1575            p.messages = json::array({user_msg, assistant_one_arg});
1576        });
1577
1578    if (!t.assert_true("T4 zero vs one result should have value", result_zero_one.has_value())) {
1579        return;
1580    }
1581    t.assert_true("T4 zero vs one left should be empty or minimal", result_zero_one->diff.left.empty() || result_zero_one->diff.left == "");
1582    t.assert_true("T4 zero vs one right should contain arg1", result_zero_one->diff.right.find("arg1") != std::string::npos);
1583
1584    // Test one vs two
1585    template_params params_one;
1586    params_one.messages = json::array({user_msg, assistant_one_arg});
1587    params_one.tools = tools;
1588    params_one.add_generation_prompt = false;
1589    params_one.enable_thinking = true;
1590
1591    auto result_one_two = ::compare_variants(tmpl, params_one,
1592        [&](template_params & p) {
1593            p.messages = json::array({user_msg, assistant_two_args});
1594        });
1595
1596    if (!t.assert_true("T4 one vs two result should have value", result_one_two.has_value())) {
1597        return;
1598    }
1599
1600    const auto & diff4 = result_one_two->diff;
1601    t.assert_true("T4 one vs two left should contain arg1 (or prefix)",
1602        diff4.left.find("arg1") != std::string::npos || diff4.prefix.find("arg1") != std::string::npos);
1603    t.assert_true("T4 one vs two right should contain arg1 (or prefix)",
1604        diff4.right.find("arg1") != std::string::npos || diff4.prefix.find("arg1") != std::string::npos);
1605    t.assert_true("T4 one vs two right should contain arg2 (or prefix/suffix)",
1606        diff4.right.find("arg2") != std::string::npos || diff4.prefix.find("arg2") != std::string::npos || diff4.suffix.find("arg2") != std::string::npos);
1607}
1608
1609// T5: Compare different argument values
1610static void test_seed_oss_args_presence(testing & t) {
1611    common_chat_template tmpl = load_seed_oss_template(t);
1612
1613    json assistant_same_arg = json{
1614        {"role", "assistant"},
1615        {"content", nullptr},
1616        {"tool_calls", json::array({
1617            build_tool_call("test_function_name", json::object({{"param1", "value1"}}))
1618        })}
1619    };
1620
1621    json assistant_other_arg = json{
1622        {"role", "assistant"},
1623        {"content", nullptr},
1624        {"tool_calls", json::array({
1625            build_tool_call("test_function_name", json::object({{"param2", "value2"}}))
1626        })}
1627    };
1628
1629    json assistant_both_args = json{
1630        {"role", "assistant"},
1631        {"content", nullptr},
1632        {"tool_calls", json::array({
1633            build_tool_call("test_function_name", json::object({{"param1", "value1"}, {"param2", "value2"}}))
1634        })}
1635    };
1636
1637    json user_msg = json{
1638        {"role", "user"},
1639        {"content", "Hello"}
1640    };
1641
1642    template_params params_same;
1643    params_same.messages = json::array({user_msg, assistant_same_arg});
1644    params_same.tools = build_tools_definition();
1645    params_same.add_generation_prompt = false;
1646    params_same.enable_thinking = true;
1647
1648    // Test same arg vs other arg
1649    auto result_same_other = ::compare_variants(tmpl, params_same,
1650        [&](template_params & p) {
1651            p.messages = json::array({user_msg, assistant_other_arg});
1652        });
1653
1654    if (!t.assert_true("T5 same vs other result should have value", result_same_other.has_value())) {
1655        return;
1656    }
1657    const auto & diff5a = result_same_other->diff;
1658    t.assert_true("T5 same vs other left should contain param1 (or prefix/suffix)",
1659        diff5a.left.find("param1") != std::string::npos || diff5a.prefix.find("param1") != std::string::npos || diff5a.suffix.find("param1") != std::string::npos);
1660    t.assert_true("T5 same vs other left should contain value1 (or prefix/suffix)",
1661        diff5a.left.find("value1") != std::string::npos || diff5a.prefix.find("value1") != std::string::npos);
1662    t.assert_true("T5 same vs other right should contain param2 (or prefix/suffix)",
1663        diff5a.right.find("param2") != std::string::npos || diff5a.prefix.find("param2") != std::string::npos || diff5a.suffix.find("param2") != std::string::npos);
1664    t.assert_true("T5 same vs other right should contain value2 (or prefix/suffix)",
1665        diff5a.right.find("value2") != std::string::npos || diff5a.prefix.find("value2") != std::string::npos || diff5a.suffix.find("value2") != std::string::npos);
1666
1667    // Test same arg vs both args
1668    auto result_same_both = ::compare_variants(tmpl, params_same,
1669        [&](template_params & p) {
1670            p.messages = json::array({user_msg, assistant_both_args});
1671        });
1672
1673    if (!t.assert_true("T5 same vs both result should have value", result_same_both.has_value())) {
1674        return;
1675    }
1676    const auto & diff5b = result_same_both->diff;
1677    t.assert_true("T5 same vs both left should contain param1 (or prefix/suffix)",
1678        diff5b.left.find("param1") != std::string::npos || diff5b.prefix.find("param1") != std::string::npos || diff5b.suffix.find("param1") != std::string::npos);
1679    t.assert_true("T5 same vs both right should contain param1 (or prefix/suffix)",
1680        diff5b.right.find("param1") != std::string::npos || diff5b.prefix.find("param1") != std::string::npos || diff5b.suffix.find("param1") != std::string::npos);
1681    t.assert_true("T5 same vs both right should contain param2 (or prefix/suffix)",
1682        diff5b.right.find("param2") != std::string::npos || diff5b.prefix.find("param2") != std::string::npos || diff5b.suffix.find("param2") != std::string::npos);
1683}
1684
1685// T6: Tool call with vs without reasoning_content
1686static void test_seed_oss_tool_with_reasoning(testing & t) {
1687    common_chat_template tmpl = load_seed_oss_template(t);
1688
1689    json assistant_tool_only = json{
1690        {"role", "assistant"},
1691        {"content", nullptr},
1692        {"tool_calls", json::array({
1693            build_tool_call("test_function_name", json::object({{"param1", "value1"}, {"param2", "value2"}}))
1694        })}
1695    };
1696
1697    json assistant_tool_with_reasoning = json{
1698        {"role", "assistant"},
1699        {"content", nullptr},
1700        {"tool_calls", json::array({
1701            build_tool_call("test_function_name", json::object({{"param1", "value1"}, {"param2", "value2"}}))
1702        })},
1703        {"reasoning_content", "I need to call the tool first."}
1704    };
1705
1706    json user_msg = json{
1707        {"role", "user"},
1708        {"content", "Hello, please help me."}
1709    };
1710
1711    template_params params_tool_only;
1712    params_tool_only.messages = json::array({user_msg, assistant_tool_only});
1713    params_tool_only.tools = build_tools_definition();
1714    params_tool_only.add_generation_prompt = false;
1715    params_tool_only.enable_thinking = true;
1716
1717    auto result = ::compare_variants(tmpl, params_tool_only,
1718        [&](template_params & p) {
1719            p.messages = json::array({user_msg, assistant_tool_with_reasoning});
1720        });
1721
1722    if (!t.assert_true("T6 result should have value", result.has_value())) {
1723        return;
1724    }
1725
1726    const auto & diff = result->diff;
1727
1728    // Left should be empty (no reasoning in variant A)
1729    t.assert_equal("T6 left should be empty", "", diff.left);
1730
1731    // Right should contain the thinking token with reasoning content
1732    t.assert_true("T6 right should contain think begin", diff.right.find("<seed:think>") != std::string::npos);
1733    t.assert_true("T6 right should contain reasoning content", diff.right.find("I need to call the tool first.") != std::string::npos);
1734    t.assert_true("T6 right should contain think end", diff.right.find("</seed:think>") != std::string::npos);
1735
1736    // Prefix should contain the assistant role
1737    t.assert_true("T6 prefix should contain assistant", diff.prefix.find("assistant") != std::string::npos);
1738
1739    // Suffix should contain the tool call
1740    t.assert_true("T6 suffix should contain tool_call begin", diff.suffix.find("<seed:tool_call>") != std::string::npos);
1741    t.assert_true("T6 suffix should contain function name", diff.suffix.find("test_function_name") != std::string::npos);
1742    t.assert_true("T6 suffix should contain eos", diff.suffix.find("<seed:eos>") != std::string::npos);
1743}
1744
1745static common_chat_template load_template(testing & t, const std::string & template_path) {
1746    std::ifstream fin(template_path, std::ios::binary);
1747    std::ostringstream buf;
1748    if (fin.is_open()) {
1749        buf << fin.rdbuf();
1750    }
1751    std::string template_source = buf.str();
1752    common_chat_template tmpl(template_source, "", "");
1753    t.assert_true("Nemotron template loaded successfully", template_source.length() > 0);
1754    return tmpl;
1755}
1756
1757// ============================================================================
1758// Nemotron Template Analysis Tests
1759// ============================================================================
1760static common_chat_template load_nemotron_template(testing & t) {
1761    return load_template(t, "models/templates/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16.jinja");
1762}
1763
1764static void test_nemotron_analysis(testing & t) {
1765    t.test("Nemotron reasoning detection", test_nemotron_reasoning_detection);
1766    t.test("Nemotron tool format", test_nemotron_tool_format);
1767}
1768
1769static void test_nemotron_reasoning_detection(testing & t) {
1770    common_chat_template tmpl = load_nemotron_template(t);
1771
1772    // Test the comparison manually to see what's happening
1773    json user_msg = json{ { "role", "user" }, { "content", "Hello" } };
1774    json assistant_no_reasoning = json{
1775        { "role", "assistant" },
1776        { "content", "I can help." }
1777    };
1778    json assistant_with_reasoning = json{
1779        { "role", "assistant" },
1780        { "content", "I can help." },
1781        { "reasoning_content", "Let me think about this." }
1782    };
1783
1784    template_params params;
1785    params.messages = json::array({ user_msg, assistant_no_reasoning });
1786    params.add_generation_prompt = false;
1787    params.enable_thinking = true;
1788
1789    // Run differential analysis
1790    struct autoparser analysis;
1791    analysis.analyze_template(tmpl);
1792
1793    // Check reasoning markers
1794    t.assert_equal("reasoning_start should be '<think>\\n'", "<think>\n", analysis.reasoning.start);
1795    t.assert_equal("reasoning_end should be '\\n</think>\\n'", "\n</think>\n", analysis.reasoning.end);
1796
1797    // Check reasoning mode detection
1798    // Nemotron uses tag-based reasoning; prefill handles the template's forced markers
1799    t.assert_equal("reasoning should be TAG_BASED", reasoning_mode::TAG_BASED, analysis.reasoning.mode);
1800
1801    // Make sure reasoning markers don't spill over to content markers
1802    t.assert_equal("content start should be empty", "", analysis.content.start);
1803    t.assert_equal("content end should be empty", "", analysis.content.end);
1804
1805    t.assert_equal("content should be PLAIN", content_mode::PLAIN, analysis.content.mode);
1806}
1807
1808static void test_nemotron_tool_format(testing & t) {
1809    common_chat_template tmpl = load_nemotron_template(t);
1810
1811    // Run differential analysis
1812    struct autoparser analysis;
1813    analysis.analyze_template(tmpl);
1814
1815    // Check tool markers - Nemotron uses per-call wrapping (each call individually wrapped)
1816    t.assert_equal("tool_section_start should be empty (per-call format)", "", analysis.tools.format.section_start);
1817    t.assert_equal("tool_section_end should be empty (per-call format)", "", analysis.tools.format.section_end);
1818    t.assert_equal("per_call_start should be '<tool_call>\\n'", "<tool_call>\n", analysis.tools.format.per_call_start);
1819    t.assert_equal("per_call_end should be '</tool_call>'", "</tool_call>", analysis.tools.format.per_call_end);
1820    t.assert_true("should support parallel calls", analysis.jinja_caps.supports_parallel_tool_calls);
1821
1822    // Check function markers
1823    t.assert_equal("func_name_prefix should be '<function='", "<function=", analysis.tools.function.name_prefix);
1824    t.assert_equal("func_name_suffix should be '>\\n'", ">\n", analysis.tools.function.name_suffix);
1825    t.assert_equal("func_close should be '</function>\\n'", "</function>\n", analysis.tools.function.close);
1826
1827    // Check argument markers (note: markers retain trailing newlines for proper parsing)
1828    t.assert_equal("arg_name_prefix should be '<parameter='", "<parameter=", analysis.tools.arguments.name_prefix);
1829    t.assert_equal("arg_name_suffix should be '>\\n'", ">\n", analysis.tools.arguments.name_suffix);
1830    t.assert_equal("arg_value_suffix should be '\\n</parameter>\\n'", "\n</parameter>\n", analysis.tools.arguments.value_suffix);
1831
1832    // Check format classification
1833    t.assert_true("tool format should be TAG_WITH_TAGGED", analysis.tools.format.mode == tool_format::TAG_WITH_TAGGED);
1834
1835    // Verify tool support
1836    t.assert_true("should support tools", analysis.jinja_caps.supports_tools);
1837}
1838
1839// ============================================================================
1840// Laguna Template Analysis Tests
1841// ============================================================================
1842static common_chat_template load_laguna_template(testing & t) {
1843    return load_template(t, "models/templates/poolside-Laguna-XS-2.1.jinja");
1844}
1845
1846static void test_laguna_reasoning_detection(testing & t) {
1847    common_chat_template tmpl = load_laguna_template(t);
1848    struct autoparser analysis;
1849    analysis.analyze_template(tmpl);
1850    // Laguna's template renders reasoning delimiters with formatting whitespace
1851    // ("<think>\n") that the model does not emit; the Laguna patch trims them.
1852    t.assert_equal("reasoning_start should be '<think>'", "<think>", analysis.reasoning.start);
1853    t.assert_equal("reasoning_end should be '</think>'", "</think>", analysis.reasoning.end);
1854    t.assert_equal("reasoning should be TAG_BASED", reasoning_mode::TAG_BASED, analysis.reasoning.mode);
1855}
1856
1857static void test_laguna_tool_format(testing & t) {
1858    common_chat_template tmpl = load_laguna_template(t);
1859    struct autoparser analysis;
1860    analysis.analyze_template(tmpl);
1861    t.assert_equal("arg_value_suffix should be '</arg_value>'", "</arg_value>", analysis.tools.arguments.value_suffix);
1862}
1863
1864static void test_laguna_stop_string(testing & t) {
1865    // The </assistant> turn terminator can be emitted as ordinary text tokens
1866    // (not the single eot token), so it must also be a literal stop string.
1867    common_chat_template tmpl = load_laguna_template(t);
1868    struct autoparser analysis;
1869    analysis.analyze_template(tmpl);
1870    bool has_stop = false;
1871    for (const auto & stop : analysis.additional_stops) {
1872        if (stop == "</assistant>") { has_stop = true; break; }
1873    }
1874    t.assert_true("Laguna additional_stops contains </assistant>", has_stop);
1875}
1876
1877static void test_laguna_analysis(testing & t) {
1878    t.test("Laguna reasoning detection", test_laguna_reasoning_detection);
1879    t.test("Laguna tool format", test_laguna_tool_format);
1880    t.test("Laguna stop string", test_laguna_stop_string);
1881}
1882
1883static common_chat_template load_laguna_s_template(testing & t) {
1884    return load_template(t, "models/templates/poolside-Laguna-S-2.1.jinja");
1885}
1886static void test_laguna_s_reasoning_detection(testing & t) {
1887    common_chat_template tmpl = load_laguna_s_template(t);
1888    struct autoparser analysis;
1889    analysis.analyze_template(tmpl);
1890    t.assert_equal("Laguna-S(v8) reasoning_start should be '<think>'", "<think>", analysis.reasoning.start);
1891    t.assert_equal("Laguna-S(v8) reasoning_end should be '</think>'", "</think>", analysis.reasoning.end);
1892    t.assert_equal("Laguna-S(v8) reasoning should be TAG_BASED", reasoning_mode::TAG_BASED, analysis.reasoning.mode);
1893}
1894static void test_laguna_s_tool_format(testing & t) {
1895    common_chat_template tmpl = load_laguna_s_template(t);
1896    struct autoparser analysis;
1897    analysis.analyze_template(tmpl);
1898    t.assert_equal("Laguna-S(v8) arg_value_suffix should be '</arg_value>'", "</arg_value>", analysis.tools.arguments.value_suffix);
1899}
1900static void test_laguna_s_preserve_reasoning(testing & t) {
1901    common_chat_template tmpl = load_laguna_s_template(t);
1902    t.assert_true("Laguna-S(v8) supports preserving reasoning", tmpl.original_caps().supports_preserve_reasoning);
1903}
1904static void test_laguna_s_analysis(testing & t) {
1905    t.test("Laguna-S(v8) reasoning detection", test_laguna_s_reasoning_detection);
1906    t.test("Laguna-S(v8) tool format", test_laguna_s_tool_format);
1907    t.test("Laguna-S(v8) preserve reasoning", test_laguna_s_preserve_reasoning);
1908}
1909
1910static common_chat_template load_laguna_xs2_template(testing & t) {
1911    return load_template(t, "models/templates/poolside-Laguna-XS.2.jinja");
1912}
1913static void test_laguna_xs2_reasoning_detection(testing & t) {
1914    common_chat_template tmpl = load_laguna_xs2_template(t);
1915    struct autoparser analysis;
1916    analysis.analyze_template(tmpl);
1917    t.assert_equal("Laguna-XS.2(v5) reasoning_start should be '<think>'", "<think>", analysis.reasoning.start);
1918    t.assert_equal("Laguna-XS.2(v5) reasoning_end should be '</think>'", "</think>", analysis.reasoning.end);
1919    t.assert_equal("Laguna-XS.2(v5) reasoning should be TAG_BASED", reasoning_mode::TAG_BASED, analysis.reasoning.mode);
1920}
1921static void test_laguna_xs2_tool_format(testing & t) {
1922    common_chat_template tmpl = load_laguna_xs2_template(t);
1923    struct autoparser analysis;
1924    analysis.analyze_template(tmpl);
1925    t.assert_equal("Laguna-XS.2(v5) arg_value_suffix should be '</arg_value>'", "</arg_value>", analysis.tools.arguments.value_suffix);
1926}
1927static void test_laguna_xs2_analysis(testing & t) {
1928    t.test("Laguna-XS.2(v5) reasoning detection", test_laguna_xs2_reasoning_detection);
1929    t.test("Laguna-XS.2(v5) tool format", test_laguna_xs2_tool_format);
1930}
1931
1932static common_chat_template load_cohere_template(testing & t) {
1933    return load_template(t, "models/templates/CohereForAI-c4ai-command-r7b-12-2024-tool_use.jinja");
1934}
1935
1936static void test_cohere_analysis(testing & t) {
1937    t.test("Cohere reasoning detection", test_cohere_reasoning_detection);
1938}
1939
1940static void test_cohere_reasoning_detection(testing & t) {
1941    common_chat_template tmpl = load_cohere_template(t);
1942
1943    // Run differential analysis
1944    struct autoparser analysis;
1945    analysis.analyze_template(tmpl);
1946
1947    // Check reasoning markers - Cohere uses special token format
1948    t.assert_equal("reasoning_start should be '<|START_THINKING|>'", "<|START_THINKING|>", analysis.reasoning.start);
1949    t.assert_equal("reasoning_end should be '<|END_THINKING|>'", "<|END_THINKING|>", analysis.reasoning.end);
1950
1951    // Check reasoning mode - Cohere only shows reasoning with tool calls (TOOLS_ONLY)
1952    t.assert_equal("reasoning should be TOOLS_ONLY", reasoning_mode::TOOLS_ONLY, analysis.reasoning.mode);
1953
1954    // Check content markers - Cohere wraps all content with START/END_RESPONSE
1955    t.assert_equal("content_start should be '<|START_RESPONSE|>'", "<|START_RESPONSE|>", analysis.content.start);
1956    t.assert_equal("content_end should be '<|END_RESPONSE|>'", "<|END_RESPONSE|>", analysis.content.end);
1957
1958    // Content is always wrapped (both with and without tools)
1959    t.assert_equal("content should be ALWAYS_WRAPPED", content_mode::ALWAYS_WRAPPED, analysis.content.mode);
1960}
1961
1962static void test_tool_format_cohere(testing & t) {
1963    common_chat_template tmpl = load_cohere_template(t);
1964
1965    // Run differential analysis
1966    struct autoparser analysis;
1967    analysis.analyze_template(tmpl);
1968
1969    // Check tool section markers - Cohere uses ACTION markers
1970    t.assert_equal("tool_section_start should be '<|START_ACTION|>'", "<|START_ACTION|>", analysis.tools.format.section_start);
1971    t.assert_equal("tool_section_end should be '<|END_ACTION|>'", "<|END_ACTION|>", analysis.tools.format.section_end);
1972
1973    // JSON_NATIVE format has no per-call markers
1974    t.assert_equal("per_call_start should be empty", "", analysis.tools.format.per_call_start);
1975    t.assert_equal("per_call_end should be empty", "", analysis.tools.format.per_call_end);
1976
1977    // JSON_NATIVE format has empty function markers (no XML-style markers)
1978    t.assert_equal("func_name_prefix should be empty", "", analysis.tools.function.name_prefix);
1979    t.assert_equal("func_name_suffix should be empty", "", analysis.tools.function.name_suffix);
1980    t.assert_equal("func_close should be empty", "", analysis.tools.function.close);
1981
1982    // JSON_NATIVE format has empty args markers
1983    t.assert_equal("args_start should be empty", "", analysis.tools.arguments.start);
1984    t.assert_equal("args_end should be empty", "", analysis.tools.arguments.end);
1985
1986    // JSON_NATIVE format has empty argument markers
1987    t.assert_equal("arg_name_prefix should be empty", "", analysis.tools.arguments.name_prefix);
1988    t.assert_equal("arg_name_suffix should be empty", "", analysis.tools.arguments.name_suffix);
1989    t.assert_equal("arg_value_prefix should be empty", "", analysis.tools.arguments.value_prefix);
1990    t.assert_equal("arg_value_suffix should be empty", "", analysis.tools.arguments.value_suffix);
1991    t.assert_equal("arg_separator should be empty", "", analysis.tools.arguments.separator);
1992
1993    // Check JSON field names - Cohere uses non-standard names
1994    t.assert_equal("name_field should be 'tool_name'", "tool_name", analysis.tools.format.name_field);
1995    t.assert_equal("args_field should be 'parameters'", "parameters", analysis.tools.format.args_field);
1996    // This isn't a real tool call id field, i.e. with the OpenAI tool call ID format
1997    t.assert_equal("id_field should be 'tool_call_id'", "", analysis.tools.format.id_field);
1998
1999    // Check format classification
2000    t.assert_equal("tool format should be JSON_NATIVE", tool_format::JSON_NATIVE, analysis.tools.format.mode);
2001
2002    // Check flags
2003    t.assert_true("should support tools", analysis.jinja_caps.supports_tools);
2004    t.assert_true("should support parallel calls", analysis.jinja_caps.supports_parallel_tool_calls);
2005    t.assert_true("should not require nonnull content", !analysis.content.requires_nonnull_content);
2006    t.assert_true("tools_array_wrapped should be true", analysis.tools.format.tools_array_wrapped);
2007}
2008
2009// ============================================================================
2010// SmolLM3 Template Analysis Tests
2011// Tests for templates that change system message when enable_thinking flips
2012// and prefill an empty <think></think> block in no-think mode.
2013// ============================================================================
2014static common_chat_template load_smollm3_template(testing & t) {
2015    return load_template(t, "models/templates/HuggingFaceTB-SmolLM3-3B.jinja");
2016}
2017
2018static void test_smollm3_reasoning_detection(testing & t);
2019
2020static void test_smollm3_analysis(testing & t) {
2021    t.test("SmolLM3 reasoning detection", test_smollm3_reasoning_detection);
2022}
2023
2024static void test_smollm3_reasoning_detection(testing & t) {
2025    common_chat_template tmpl = load_smollm3_template(t);
2026
2027    // Run differential analysis
2028    struct autoparser analysis;
2029    analysis.analyze_template(tmpl);
2030
2031    // SmolLM3 uses <think>/<think> reasoning tags.
2032    // The template changes the entire system message when enable_thinking flips,
2033    // so the analyzer must compare isolated generation prompts (not full outputs).
2034    t.assert_equal("reasoning_start should be '<think>'", "<think>", analysis.reasoning.start);
2035    t.assert_equal("reasoning_end should be '</think>'", "</think>", analysis.reasoning.end);
2036    t.assert_equal("reasoning should be TAG_BASED", reasoning_mode::TAG_BASED, analysis.reasoning.mode);
2037
2038    // Content should remain plain (no wrappers)
2039    t.assert_equal("content start should be empty", "", analysis.content.start);
2040    t.assert_equal("content end should be empty", "", analysis.content.end);
2041    t.assert_equal("content should be PLAIN", content_mode::PLAIN, analysis.content.mode);
2042
2043    // Preserved tokens should include the reasoning markers
2044    bool has_think_start = std::find(analysis.preserved_tokens.begin(), analysis.preserved_tokens.end(), "<think>") != analysis.preserved_tokens.end();
2045    bool has_think_end = std::find(analysis.preserved_tokens.begin(), analysis.preserved_tokens.end(), "</think>") != analysis.preserved_tokens.end();
2046    t.assert_true("preserved_tokens should contain '<think>'", has_think_start);
2047    t.assert_true("preserved_tokens should contain '</think>'", has_think_end);
2048}
2049
2050// ============================================================================
2051// standard_json_tools Format Tests
2052// ============================================================================
2053
2054// Helper to build tools definition for tests
2055static json build_test_tools() {
2056    json parameters_schema = json::object();
2057    parameters_schema["type"] = "object";
2058    parameters_schema["properties"] = json::object();
2059    parameters_schema["properties"]["location"] = json::object({
2060        {"type", "string"},
2061        {"description", "The city and state"}
2062    });
2063    parameters_schema["properties"]["unit"] = json::object({
2064        {"type", "string"},
2065        {"description", "Temperature unit"},
2066        {"enum", json::array({"celsius", "fahrenheit"})}
2067    });
2068    parameters_schema["required"] = json::array({"location"});
2069
2070    return json::array({
2071        json{
2072            {"type", "function"},
2073            {"function", json{
2074                {"name", "get_current_weather"},
2075                {"description", "Get the current weather in a given location"},
2076                {"parameters", parameters_schema}
2077            }}
2078        }
2079    });
2080}
2081
2082static void test_standard_json_tools_formats(testing & t) {
2083    t.test("OpenAI format", test_standard_json_tools_openai);
2084    t.test("Cohere format", test_standard_json_tools_cohere);
2085    t.test("function-as-key format", test_standard_json_tools_function_key);
2086}
2087
2088// Test 1: OpenAI Standard Format
2089// {"id": "call_abc", "function": {"name": "get_weather", "arguments": {"location": "NYC"}}}
2090static void test_standard_json_tools_openai(testing & t) {
2091    json tools = build_test_tools();
2092
2093    auto parser = build_chat_peg_parser([&](common_chat_peg_builder & p) {
2094        auto tool_call = p.standard_json_tools(
2095            "<tool_call>", "</tool_call>", tools,
2096            /* parallel */ true,
2097            /* force */ false,
2098            /* name_key */ "function.name",
2099            /* args_key */ "function.arguments",
2100            /* array_wrapped */ false,
2101            /* function_is_key */ false,
2102            /* call_id_key */ "id",
2103            /* gen_call_id_key */ "",
2104            /* parameters_order */ {}
2105        );
2106        return p.content(p.until("<tool_call>")) + p.optional(tool_call) + p.end();
2107    });
2108
2109    std::string input =
2110        "Let me check the weather."
2111        "<tool_call>"
2112        R"({"id": "call_abc123", "function": {"name": "get_current_weather", "arguments": {"location": "NYC"}}})"
2113        "</tool_call>";
2114
2115    common_peg_parse_context ctx(input);
2116    auto result = parser.parse(ctx);
2117
2118    if (!t.assert_true("parse success", result.success())) {
2119        return;
2120    }
2121
2122    common_chat_msg msg;
2123    auto mapper = common_chat_peg_mapper(msg);
2124    mapper.from_ast(ctx.ast, result);
2125
2126    t.assert_equal("tool calls count", 1u, msg.tool_calls.size());
2127    if (!msg.tool_calls.empty()) {
2128        t.assert_equal("tool name", "get_current_weather", msg.tool_calls[0].name);
2129        t.assert_equal("tool id", "call_abc123", msg.tool_calls[0].id);
2130    }
2131    t.assert_true("content present", msg.content.find("Let me check the weather") != std::string::npos);
2132}
2133
2134// Test 2: Cohere Format
2135// {"tool_call_id": 0, "tool_name": "get_weather", "parameters": {"location": "NYC"}}
2136static void test_standard_json_tools_cohere(testing & t) {
2137    json tools = build_test_tools();
2138
2139    auto parser = build_chat_peg_parser([&](common_chat_peg_builder & p) {
2140        auto tool_call = p.standard_json_tools(
2141            "<|START_ACTION|>[", "]<|END_ACTION|>", tools,
2142            /* parallel */ true,
2143            /* force */ false,
2144            /* name_key */ "tool_name",
2145            /* args_key */ "parameters",
2146            /* array_wrapped */ false,  // Brackets are part of section markers
2147            /* function_is_key */ false,
2148            /* call_id_key */ "",
2149            /* gen_call_id_key */ "tool_call_id",
2150            /* parameters_order */ {"tool_call_id", "tool_name", "parameters"}
2151        );
2152        return p.content(p.until("<|START_ACTION|>")) + p.optional(tool_call) + p.end();
2153    });
2154
2155    std::string input =
2156        "Let me search for that."
2157        "<|START_ACTION|>["
2158        R"({"tool_call_id": 0, "tool_name": "get_current_weather", "parameters": {"location": "NYC", "unit": "celsius"}})"
2159        "]<|END_ACTION|>";
2160
2161    common_peg_parse_context ctx(input);
2162    auto result = parser.parse(ctx);
2163
2164    if (!t.assert_true("parse success", result.success())) {
2165        return;
2166    }
2167
2168    common_chat_msg msg;
2169    auto mapper = common_chat_peg_mapper(msg);
2170    mapper.from_ast(ctx.ast, result);
2171
2172    t.assert_equal("tool calls count", 1u, msg.tool_calls.size());
2173    if (!msg.tool_calls.empty()) {
2174        t.assert_equal("tool name", "get_current_weather", msg.tool_calls[0].name);
2175        t.assert_equal("tool id", "0", msg.tool_calls[0].id);
2176    }
2177    t.assert_true("content present", msg.content.find("Let me search") != std::string::npos);
2178}
2179
2180// Test 3: Function-as-Key Format
2181// {"get_current_weather": {"id": "call-0001", "args": {"location": "NYC"}}}
2182static void test_standard_json_tools_function_key(testing & t) {
2183    json tools = build_test_tools();
2184
2185    auto parser = build_chat_peg_parser([&](common_chat_peg_builder & p) {
2186        auto tool_call = p.standard_json_tools(
2187            "<tool_calls>[", "]</tool_calls>", tools,
2188            /* parallel */ true,
2189            /* force */ false,
2190            /* name_key */ "",  // Name is the key itself
2191            /* args_key */ "args",
2192            /* array_wrapped */ false,
2193            /* function_is_key */ true,
2194            /* call_id_key */ "id",
2195            /* gen_call_id_key */ "",
2196            /* parameters_order */ {}
2197        );
2198        return p.content(p.until("<tool_calls>")) + p.optional(tool_call) + p.end();
2199    });
2200
2201    std::string input =
2202        "I'll call the weather function."
2203        "<tool_calls>["
2204        R"({"get_current_weather": {"id": "call-0001", "args": {"location": "NYC", "unit": "celsius"}}})"
2205        "]</tool_calls>";
2206
2207    common_peg_parse_context ctx(input);
2208    auto result = parser.parse(ctx);
2209
2210    if (!t.assert_true("parse success", result.success())) {
2211        return;
2212    }
2213
2214    common_chat_msg msg;
2215    auto mapper = common_chat_peg_mapper(msg);
2216    mapper.from_ast(ctx.ast, result);
2217
2218    t.assert_equal("tool calls count", 1u, msg.tool_calls.size());
2219    if (!msg.tool_calls.empty()) {
2220        t.assert_equal("tool name", "get_current_weather", msg.tool_calls[0].name);
2221        t.assert_equal("tool id", "call-0001", msg.tool_calls[0].id);
2222    }
2223    t.assert_true("content present", msg.content.find("I'll call the weather") != std::string::npos);
2224}
2225
2226// ============================================================================
2227// normalize_quotes_to_json Tests
2228// ============================================================================
2229
2230// Copy of the function for isolated testing (original is static in chat-peg-parser.cpp)
2231static std::string normalize_quotes_to_json(const std::string & input) {
2232    std::string result;
2233    result.reserve(input.size() + 16);
2234
2235    bool in_single_quoted = false;
2236    bool in_double_quoted = false;
2237
2238    for (size_t i = 0; i < input.size(); ++i) {
2239        char c = input[i];
2240
2241        if (c == '\\' && i + 1 < input.size()) {
2242            char next = input[i + 1];
2243
2244            if (in_single_quoted) {
2245                if (next == '\'') {
2246                    result += '\'';
2247                    ++i;
2248                    continue;
2249                }
2250                if (next == '"') {
2251                    result += "\\\"";
2252                    ++i;
2253                    continue;
2254                }
2255                result += c;
2256                result += next;
2257                ++i;
2258                continue;
2259            }
2260
2261            if (in_double_quoted) {
2262                result += c;
2263                result += next;
2264                ++i;
2265                continue;
2266            }
2267
2268            result += c;
2269            continue;
2270        }
2271
2272        if (c == '"') {
2273            if (in_single_quoted) {
2274                result += "\\\"";
2275            } else {
2276                in_double_quoted = !in_double_quoted;
2277                result += c;
2278            }
2279        } else if (c == '\'') {
2280            if (in_double_quoted) {
2281                result += c;
2282            } else if (in_single_quoted) {
2283                in_single_quoted = false;
2284                result += '"';
2285            } else {
2286                in_single_quoted = true;
2287                result += '"';
2288            }
2289        } else {
2290            result += c;
2291        }
2292    }
2293
2294    return result;
2295}
2296
2297static void test_normalize_quotes_to_json(testing & t) {
2298    t.test("basic single to double quotes", [](testing & t) {
2299        std::string input = "{'key': 'value'}";
2300        std::string expected = "{\"key\": \"value\"}";
2301        std::string result = normalize_quotes_to_json(input);
2302        t.assert_equal("basic conversion", expected, result);
2303    });
2304
2305    t.test("escaped single quote inside single-quoted string", [](testing & t) {
2306        std::string input = "{'code': 'print(\\'hello\\')'}";
2307        std::string expected = "{\"code\": \"print('hello')\"}";
2308        std::string result = normalize_quotes_to_json(input);
2309        t.assert_equal("escaped single quote", expected, result);
2310    });
2311
2312    t.test("double quote inside single-quoted string", [](testing & t) {
2313        std::string input = "{'msg': 'He said \"hi\"'}";
2314        std::string expected = "{\"msg\": \"He said \\\"hi\\\"\"}";
2315        std::string result = normalize_quotes_to_json(input);
2316        t.assert_equal("double quote escaping", expected, result);
2317    });
2318
2319    t.test("nested backslash escapes", [](testing & t) {
2320        std::string input = "{'path': 'C:\\\\Users\\\\test'}";
2321        std::string expected = "{\"path\": \"C:\\\\Users\\\\test\"}";
2322        std::string result = normalize_quotes_to_json(input);
2323        t.assert_equal("backslash escaping", expected, result);
2324    });
2325
2326    t.test("newline escapes", [](testing & t) {
2327        std::string input = "{'text': 'line1\\nline2'}";
2328        std::string expected = "{\"text\": \"line1\\nline2\"}";
2329        std::string result = normalize_quotes_to_json(input);
2330        t.assert_equal("newline escaping", expected, result);
2331    });
2332
2333    t.test("mixed quotes", [](testing & t) {
2334        std::string input = "{\"already_double\": 'single_value'}";
2335        std::string expected = "{\"already_double\": \"single_value\"}";
2336        std::string result = normalize_quotes_to_json(input);
2337        t.assert_equal("mixed quotes", expected, result);
2338    });
2339
2340    t.test("embedded quotes - the test case", test_normalize_quotes_with_embedded_quotes);
2341}
2342
2343// Test case that mirrors the Seed-OSS failing test scenario
2344static void test_normalize_quotes_with_embedded_quotes(testing & t) {
2345    // This is similar to the Seed-OSS template test case
2346    // The input has embedded double quotes like "14" and "bar" inside string values
2347    std::string input = "{'filename': 'foo.cpp', 'oldString': 'def foo(arg = \"14\"):\\n    return arg + \"bar\"\\n', 'newString': 'def foo(arg = \"15\"):\\n    pass\\n'}";
2348
2349    // Expected: Python single quotes -> JSON double quotes, internal double quotes escaped
2350    std::string expected = "{\"filename\": \"foo.cpp\", \"oldString\": \"def foo(arg = \\\"14\\\"):\\n    return arg + \\\"bar\\\"\\n\", \"newString\": \"def foo(arg = \\\"15\\\"):\\n    pass\\n\"}";
2351
2352    std::string result = normalize_quotes_to_json(input);
2353
2354    t.assert_equal("normalize quotes with embedded double quotes", expected, result);
2355
2356    // Also verify the result is valid JSON
2357    try {
2358        json parsed = json::parse(result);
2359        t.assert_true("result is valid JSON", true);
2360        t.assert_equal("filename field", "foo.cpp", parsed["filename"].get<std::string>());
2361        t.assert_true("oldString contains embedded quotes",
2362            parsed["oldString"].get<std::string>().find("\"14\"") != std::string::npos);
2363        t.assert_true("newString contains embedded quotes",
2364            parsed["newString"].get<std::string>().find("\"15\"") != std::string::npos);
2365    } catch (const std::exception & e) {
2366        t.assert_true(std::string("JSON parse failed: ") + e.what(), false);
2367    }
2368}
2369
2370// ============================================================================
2371// TAG_WITH_TAGGED Argument Parsing Tests
2372// ============================================================================
2373
2374// Build tools definition for edit function
2375static json build_edit_tool() {
2376    json parameters_schema = json::object();
2377    parameters_schema["type"] = "object";
2378    parameters_schema["properties"] = json::object();
2379    parameters_schema["properties"]["filename"] = json::object({
2380        {"type", "string"},
2381        {"description", "Path of file to edit"}
2382    });
2383    parameters_schema["properties"]["oldString"] = json::object({
2384        {"type", "string"},
2385        {"description", "String to replace"}
2386    });
2387    parameters_schema["properties"]["newString"] = json::object({
2388        {"type", "string"},
2389        {"description", "New (replacement) value"}
2390    });
2391    parameters_schema["required"] = json::array({"filename", "oldString", "newString"});
2392
2393    return json::array({
2394        json{
2395            {"type", "function"},
2396            {"function", json{
2397                {"name", "edit"},
2398                {"description", "Edit a file"},
2399                {"parameters", parameters_schema}
2400            }}
2401        }
2402    });
2403}
2404
2405// ============================================================================
2406// Role marker detection tests for all autoparser-handled templates
2407//
2408// Verifies that detect_user_start_marker / detect_assistant_start_marker
2409// return the correct boundary text between turns for every template that
2410// falls through to the differential autoparser (i.e. is not handled by a
2411// dedicated specialized template in common_chat_try_specialized_template).
2412//
2413// Markers were deduced manually from the jinja sources in models/templates/.
2414// ============================================================================
2415struct role_marker_case {
2416    std::string template_file;
2417    std::string expected_user_start;
2418    std::string expected_assistant_start;
2419};
2420
2421static void test_role_markers_all_templates(testing & t) {
2422    // Each entry is { template filename, user_start, assistant_start } as
2423    // produced when rendering the standard chatml-like sequences. The values
2424    // come from reading each jinja template and tracing what text precedes
2425    // a user/assistant message body once the autoparser strips any reasoning
2426    // markers it detected first.
2427    const std::vector<role_marker_case> cases = {
2428        // ChatML family: <|im_start|>{role} ... <|im_end|>
2429        { "Bielik-11B-v3.0-Instruct.jinja",                  "<|im_start|>user",       "<|im_start|>assistant"      },
2430        { "HuggingFaceTB-SmolLM3-3B.jinja",                  "<|im_start|>user",       "<|im_start|>assistant"      },
2431        { "MiMo-VL.jinja",                                   "<|im_start|>user",       "<|im_start|>assistant"      },
2432        { "NousResearch-Hermes-2-Pro-Llama-3-8B-tool_use.jinja", "<|im_start|>user",   "<|im_start|>assistant"      },
2433        { "NousResearch-Hermes-3-Llama-3.1-8B-tool_use.jinja",   "<|im_start|>user",   "<|im_start|>assistant"      },
2434        { "NVIDIA-Nemotron-3-Nano-30B-A3B-BF16.jinja",       "<|im_start|>user",       "<|im_start|>assistant"      },
2435        { "Qwen3.5-4B.jinja",                                "<|im_start|>user",       "<|im_start|>assistant"      },
2436        { "Qwen3-Coder.jinja",                               "<|im_start|>user",       "<|im_start|>assistant"      },
2437        { "Qwen-Qwen2.5-7B-Instruct.jinja",                  "<|im_start|>user",       "<|im_start|>assistant"      },
2438        { "Qwen-Qwen3-0.6B.jinja",                           "<|im_start|>user",       "<|im_start|>assistant"      },
2439        { "Qwen-QwQ-32B.jinja",                              "<|im_start|>user",       "<|im_start|>assistant"      },
2440        { "StepFun3.5-Flash.jinja",                          "<|im_start|>user",       "<|im_start|>assistant"      },
2441
2442        // DeepSeek family
2443        { "deepseek-ai-DeepSeek-R1-Distill-Llama-8B.jinja",  "<|User|>",                "<|Assistant|>"             },
2444        { "deepseek-ai-DeepSeek-R1-Distill-Qwen-32B.jinja",  "<|User|>",                "<|Assistant|>"             },
2445        { "deepseek-ai-DeepSeek-V3.1.jinja",                 "<|User|>",                "<|Assistant|>"             },
2446        { "llama-cpp-deepseek-r1.jinja",                     "<|User|>",                "<|Assistant|>"             },
2447
2448        // Llama 3 header family
2449        { "meetkai-functionary-medium-v3.1.jinja",           "<|start_header_id|>user<|end_header_id|>", "<|start_header_id|>assistant<|end_header_id|>" },
2450        { "meta-llama-Llama-3.1-8B-Instruct.jinja",          "<|start_header_id|>user<|end_header_id|>", "<|start_header_id|>assistant<|end_header_id|>" },
2451        { "meta-llama-Llama-3.2-3B-Instruct.jinja",          "<|start_header_id|>user<|end_header_id|>", "<|start_header_id|>assistant<|end_header_id|>" },
2452        { "meta-llama-Llama-3.3-70B-Instruct.jinja",         "<|start_header_id|>user<|end_header_id|>", "<|start_header_id|>assistant<|end_header_id|>" },
2453        // fireworks-ai forces a trailing assistant header even without add_generation_prompt,
2454        // so the marker is absorbed into the common suffix and assistant_start is detected as empty.
2455        { "fireworks-ai-llama-3-firefunction-v2.jinja",      "<|start_header_id|>user<|end_header_id|>", "<|start_header_id|>assistant<|end_header_id|>" },
2456
2457        // Phi/GLM/Apriel-style: <|user|> / <|assistant|>
2458        { "microsoft-Phi-3.5-mini-instruct.jinja",           "<|user|>",               "<|assistant|>"              },
2459        { "GLM-4.6.jinja",                                   "<|user|>",               "<|assistant|>"              },
2460        { "unsloth-Apriel-1.5.jinja",                        "<|user|>",               "<|assistant|>"              },
2461        { "GLM-4.7-Flash.jinja",                             "<|user|>",                 "<|assistant|>"                },
2462
2463        // Gemma 2: <start_of_turn>{user|model}
2464        { "google-gemma-2-2b-it.jinja",                      "<start_of_turn>user",    "<start_of_turn>model"       },
2465
2466        // IBM Granite
2467        { "ibm-granite-granite-3.3-2B-Instruct.jinja",       "<|start_of_role|>user<|end_of_role|>", "<|start_of_role|>assistant<|end_of_role|>" },
2468        { "ibm-granite-granite-4.0.jinja",                   "<|start_of_role|>user<|end_of_role|>", "<|start_of_role|>assistant<|end_of_role|>" },
2469
2470        // Cohere R-series
2471        { "CohereForAI-c4ai-command-r7b-12-2024-tool_use.jinja",
2472            "<|START_OF_TURN_TOKEN|><|USER_TOKEN|>", "<|START_RESPONSE|>" },
2473        { "CohereForAI-c4ai-command-r-plus-tool_use.jinja",
2474            "<|START_OF_TURN_TOKEN|><|USER_TOKEN|>", "<|START_OF_TURN_TOKEN|><|CHATBOT_TOKEN|>" },
2475
2476        // Mistral: assistant content follows [/INST] immediately, no header
2477        { "mistralai-Mistral-Nemo-Instruct-2407.jinja",      "[INST]",                   "" },
2478        { "Mistral-Small-3.2-24B-Instruct-2506.jinja",       "[INST]",                   "" },
2479
2480        // Apertus uses <|user_start|> / <|assistant_start|> but the user diff
2481        // carries the preceding <|assistant_end|> from the previous turn.
2482        { "Apertus-8B-Instruct.jinja",                       "<|user_start|>", "<|assistant_start|>" },
2483
2484        // Apriel 1.6 wraps the assistant body with <|begin_assistant|>, but
2485        // <|begin_assistant|> is also the detected reasoning start, so the
2486        // assistant_start is trimmed back to the preceding newline.
2487        { "Apriel-1.6-15b-Thinker-fixed.jinja",              "<|begin_user|>", "<|begin_assistant|>" },
2488
2489        // ByteDance Seed-OSS: <seed:bos>{role}
2490        { "ByteDance-Seed-OSS.jinja",                        "<seed:bos>user",         "<seed:bos>assistant"        },
2491
2492        // GigaChat 3.1: {role}<|role_sep|>
2493        { "GigaChat3.1-10B-A1.8B.jinja",                     "user<|role_sep|>",       "assistant<|role_sep|>"      },
2494
2495        // MiniMax M2: ]~b]{user|ai}
2496        { "MiniMax-M2.jinja",                                "]~b]user",               "]~b]ai"                     },
2497
2498        // HunYuan V3: <|hy_User:opensource|> / <|hy_Assistant:opensource|>
2499        { "tencent-Hy3.jinja",                               "<|hy_User:opensource|>", "<|hy_Assistant:opensource|>" },
2500
2501        // Nemotron Nano v2: <SPECIAL_11>{User|Assistant}; assistant marker
2502        // is followed by a prefilled <think> block that gets included.
2503        { "NVIDIA-Nemotron-Nano-v2.jinja",                   "<SPECIAL_11>User",       "<SPECIAL_11>Assistant" },
2504
2505        // Reka Edge: "human: " / "assistant: " — but the rendered preamble
2506        // depends on enable_thinking, which currently confuses the user-start
2507        // diff and trims the marker down. Lock in the observed value.
2508        { "Reka-Edge.jinja",                                 "human:",                     "assistant:"       },
2509
2510        // RWKV-world chat preset: "User: " / "Assistant: "
2511        { "llama-cpp-rwkv-world.jinja",                      "User:",               "Assistant:"              },
2512
2513        // Upstage Solar 100B: <|begin|>{role}... but reasoning marker absorbs
2514        // the "<|begin|>assistant" prefix from assistant_start.
2515        { "upstage-Solar-Open-100B.jinja",                   "<|begin|>user<|content|>", "<|begin|>assistant"           },
2516    };
2517
2518    for (const auto & c : cases) {
2519        t.test(c.template_file, [&](testing & t) {
2520            common_chat_template tmpl = load_template(t, "models/templates/" + c.template_file);
2521            struct autoparser ap;
2522            ap.analyze_template(tmpl);
2523            t.assert_equal("user_start",      c.expected_user_start,      ap.user_start);
2524            t.assert_equal("assistant_start", c.expected_assistant_start, ap.assistant_start);
2525        });
2526    }
2527}
2528
2529static void test_bailing_v3_tool_format(testing & t) {
2530    const std::string template_source = R"JINJA(
2531{# Bailing V3 chat template #}
2532{%- if tools %}{{ tools | tojson }}{%- endif %}
2533{%- for message in messages %}
2534    {%- if message.role == "user" %}
2535        {{- '<role>HUMAN</role>' + message.content + '<|role_end|>' }}
2536    {%- elif message.role == "assistant" %}
2537        {{- '<role>ASSISTANT</role>' }}
2538        {%- if message.tool_calls %}
2539            {%- for tool_call in message.tool_calls %}
2540                {%- set tc = tool_call.function %}
2541                {{- '<tool_call>' + tc.name }}
2542                {%- for k, v in tc.arguments.items() %}
2543                    {{- '<arg_key>' + k + '</arg_key>' }}
2544                    {{- '\n<arg_value>' + v + '</arg_value>' }}
2545                {%- endfor %}
2546                {{- '\n</tool_call>' }}
2547            {%- endfor %}
2548        {%- endif %}
2549        {{- '<|role_end|>' }}
2550    {%- endif %}
2551{%- endfor %}
2552{%- if add_generation_prompt %}{{- '<role>ASSISTANT</role>' }}{%- endif %}
2553)JINJA";
2554
2555    common_chat_template tmpl(template_source, "", "");
2556    struct autoparser analysis;
2557    analysis.analyze_template(tmpl);
2558
2559    t.assert_equal("arg_value_suffix", "</arg_value>", analysis.tools.arguments.value_suffix);
2560    t.assert_true("intertag whitespace", analysis.tools.arguments.tolerate_intertag_whitespace);
2561
2562    generation_params inputs;
2563    inputs.tools = json::array({
2564        {
2565            { "type", "function" },
2566            { "function", {
2567                { "name", "test_function_name" },
2568                { "parameters", {
2569                    { "type", "object" },
2570                    { "properties", {
2571                        { "param1", { { "type", "string" } } },
2572                        { "param2", { { "type", "string" } } },
2573                    } },
2574                } },
2575            } },
2576        },
2577    });
2578    inputs.reasoning_format = COMMON_REASONING_FORMAT_NONE;
2579    auto parser = analysis.build_parser(inputs, "");
2580    const std::string output =
2581        "<tool_call>test_function_name\n"
2582        "<arg_key>param1</arg_key>\n"
2583        "<arg_value>value1</arg_value>"
2584        "<arg_key>param2</arg_key>\n"
2585        "<arg_value>value2</arg_value>\n"
2586        "</tool_call>";
2587    common_peg_parse_context ctx(output, COMMON_PEG_PARSE_FLAG_LENIENT);
2588    t.assert_true("multi-argument tool call", parser.parse(ctx).success());
2589}
2590
2591// Test that reproduces the Seed-OSS template issue with embedded quotes
2592static void test_tagged_args_with_embedded_quotes(testing & t) {
2593    json tools = build_edit_tool();
2594
2595    // Build a parser for TAG_WITH_TAGGED format like Seed-OSS/Nemotron
2596    auto parser = build_chat_peg_parser([&](common_chat_peg_builder & p) {
2597        // Build tool choice for the edit function
2598        auto tool_choice = p.choice();
2599
2600        for (const auto & tool_def : tools) {
2601            if (!tool_def.contains("function")) { continue; }
2602            const auto & function = tool_def.at("function");
2603            std::string name = function.at("name");
2604            const auto & params = function.at("parameters");
2605
2606            if (!params.contains("properties") || !params.at("properties").is_object()) { continue; }
2607
2608            const auto & properties = params.at("properties");
2609
2610            // Build argument parsers
2611            std::vector<common_peg_parser> arg_parsers;
2612            for (const auto & [param_name, param_schema] : properties.items()) {
2613                auto arg = p.tool_arg(
2614                    p.tool_arg_open(p.literal("<parameter=") + p.tool_arg_name(p.literal(param_name)) + p.literal(">")) +
2615                    p.space() +
2616                    p.tool_arg_string_value(p.until("</parameter>")) +
2617                    p.space() +
2618                    p.tool_arg_close(p.literal("</parameter>"))
2619                );
2620                arg_parsers.push_back(p.optional(p.rule("arg-" + param_name, arg)));
2621            }
2622
2623            // Build arg sequence with space() between
2624            common_peg_parser args_seq = p.eps();
2625            for (size_t i = 0; i < arg_parsers.size(); i++) {
2626                if (i > 0) {
2627                    args_seq = args_seq + p.space();
2628                }
2629                args_seq = args_seq + arg_parsers[i];
2630            }
2631
2632            auto func_parser =
2633                p.tool_open(p.literal("<function=") + p.tool_name(p.literal(name)) + p.literal(">")) +
2634                p.space() + args_seq + p.space() +
2635                p.tool_close(p.literal("</function>"));
2636
2637            tool_choice |= p.rule("tool-" + name, p.tool(func_parser));
2638        }
2639
2640        auto tool_section =
2641            p.literal("<seed:tool_call>") + p.space() +
2642            tool_choice +
2643            p.space() + p.literal("</seed:tool_call>");
2644
2645        return p.content(p.until("<seed:tool_call>")) + p.optional(tool_section) + p.end();
2646    });
2647
2648    std::string input =
2649        "<seed:tool_call>\n"
2650        "<function=edit>\n"
2651        "<parameter=filename>"
2652        "foo.cpp"
2653        "</parameter>\n"
2654        "<parameter=oldString>"
2655        "def foo(arg = \"14\"):\n"
2656        "    return arg + \"bar\"\n"
2657        "\n"
2658        "</parameter>\n"
2659        "<parameter=newString>"
2660        "def foo(arg = \"15\"):\n"
2661        "    pass\n"
2662        "\n"
2663        "</parameter>\n"
2664        "</function>\n"
2665        "</seed:tool_call>";
2666
2667    common_peg_parse_context ctx(input);
2668    auto result = parser.parse(ctx);
2669
2670    if (!t.assert_true("parse success", result.success())) {
2671        return;
2672    }
2673
2674    common_chat_msg msg;
2675    auto mapper = common_chat_peg_mapper(msg);
2676    mapper.from_ast(ctx.ast, result);
2677
2678    t.assert_equal("tool calls count", 1u, msg.tool_calls.size());
2679
2680    if (!msg.tool_calls.empty()) {
2681        t.assert_equal("tool name", "edit", msg.tool_calls[0].name);
2682
2683        // Parse the arguments as JSON to verify they're valid
2684        std::string args = msg.tool_calls[0].arguments;
2685
2686        try {
2687            json parsed = json::parse(args);
2688            t.assert_true("arguments is valid JSON", true);
2689
2690            // Verify each field has proper value
2691            t.assert_equal("filename", "foo.cpp", parsed.value("filename", ""));
2692
2693            std::string oldString = parsed.value("oldString", "");
2694            t.assert_true("oldString contains embedded quotes",
2695                oldString.find("\"14\"") != std::string::npos);
2696            t.assert_true("oldString contains bar with quotes",
2697                oldString.find("\"bar\"") != std::string::npos);
2698
2699            std::string newString = parsed.value("newString", "");
2700            t.assert_true("newString contains embedded quotes",
2701                newString.find("\"15\"") != std::string::npos);
2702
2703        } catch (const std::exception & e) {
2704            t.assert_true(std::string("arguments should be valid JSON: ") + e.what(), false);
2705        }
2706    }
2707}