@@ -353,6 +353,39 @@ TEST_F(Qwen25DetectorTest, PerformanceWithManyToolCalls) {
353353 }
354354}
355355
356+ // Regression test: malformed qwen3 payload in message.content should still be
357+ // recovered into structured tool_calls for non-stream output.
358+ TEST_F (Qwen25StreamingTest, NonStreamMalformedToolPayloadRecoversToolCall) {
359+ nlohmann::json search_recall_params = {
360+ {" type" , " object" },
361+ {" properties" ,
362+ {{" attribute_selection" , {{" type" , " object" }}},
363+ {" query_list" , {{" type" , " array" }}},
364+ {" search_mode" , {{" type" , " string" }}}}},
365+ {" required" , {" attribute_selection" , " query_list" , " search_mode" }}};
366+ JsonTool search_recall_tool (
367+ " function" ,
368+ JsonFunction (" SearchRecall" , " SearchRecall tool" , search_recall_params));
369+
370+ FunctionCallParser parser ({search_recall_tool}, " qwen25" );
371+
372+ // Real user sample: malformed JSON inside <tool_call> content.
373+ std::string malformed_content =
374+ " <tool_call>\n "
375+ " {\" name\" : \" SearchRecall\" , \" arguments\" : {\" attribute_selection\" : "
376+ " {\" color: [\" }, \" query_list\" : [{\" query\" : \" kids wiggle car\" }], "
377+ " \" search_mode\" : \" single\" }}\n "
378+ " </tool_call>" ;
379+
380+ auto [normal_text, calls] = parser.parse_non_stream (malformed_content);
381+
382+ EXPECT_TRUE (normal_text.empty ());
383+ ASSERT_EQ (calls.size (), 1 );
384+ ASSERT_TRUE (calls[0 ].name .has_value ());
385+ EXPECT_EQ (calls[0 ].name .value (), " SearchRecall" );
386+ EXPECT_FALSE (calls[0 ].parameters .empty ());
387+ }
388+
356389// Test basic streaming functionality
357390TEST_F (Qwen25StreamingTest, BasicStreamingParsing) {
358391 FunctionCallParser parser (tools_, " qwen25" );
@@ -541,5 +574,52 @@ TEST_F(Qwen25StreamingTest, PartialTokenHandling) {
541574 EXPECT_GT (accumulated_calls.size (), 0 );
542575}
543576
577+ // Regression test: even with malformed payload from message.content, streaming
578+ // parser may still emit intermediate tool-call events (e.g. tool name token).
579+ TEST_F (Qwen25StreamingTest,
580+ StreamingMalformedToolPayloadStillEmitsToolNameEvent) {
581+ nlohmann::json search_recall_params = {
582+ {" type" , " object" },
583+ {" properties" ,
584+ {{" attribute_selection" , {{" type" , " object" }}},
585+ {" query_list" , {{" type" , " array" }}},
586+ {" search_mode" , {{" type" , " string" }}}}},
587+ {" required" , {" attribute_selection" , " query_list" , " search_mode" }}};
588+ JsonTool search_recall_tool (
589+ " function" ,
590+ JsonFunction (" SearchRecall" , " SearchRecall tool" , search_recall_params));
591+
592+ FunctionCallParser parser ({search_recall_tool}, " qwen25" );
593+
594+ std::vector<std::string> chunks = {
595+ " <tool_call>\n " ,
596+ " {\" name\" : \" SearchRecall\" , \" arguments\" : {\" attribute_selection\" : "
597+ " {\" color: [\" }, " ,
598+ " \" query_list\" : [{\" query\" : \" kids wiggle car\" }], \" search_mode\" : "
599+ " \" single\" }}\n " ,
600+ " </tool_call>" };
601+
602+ std::string streamed_normal_text;
603+ std::vector<ToolCallItem> streamed_calls;
604+ for (const auto & chunk : chunks) {
605+ auto result = parser.parse_streaming_increment (chunk);
606+ streamed_normal_text += result.normal_text ;
607+ streamed_calls.insert (
608+ streamed_calls.end (), result.calls .begin (), result.calls .end ());
609+ }
610+
611+ bool found_search_recall_name = false ;
612+ for (const auto & call : streamed_calls) {
613+ if (call.name .has_value () && call.name .value () == " SearchRecall" ) {
614+ found_search_recall_name = true ;
615+ break ;
616+ }
617+ }
618+
619+ EXPECT_FALSE (streamed_calls.empty ());
620+ EXPECT_TRUE (found_search_recall_name);
621+ EXPECT_TRUE (streamed_normal_text.empty ());
622+ }
623+
544624} // namespace function_call
545- } // namespace xllm
625+ } // namespace xllm
0 commit comments