@@ -352,4 +352,88 @@ def contents(replies):
352352 self .assertEqual (
353353 joined .count ("The capital of France is Paris." ), 1 ,
354354 f"buffered content was duplicated: { joined !r} " ,
355+ )
356+ @unittest .expectedFailure
357+ def test_streaming_tool_parser_progressive_plain_text (self ):
358+ """
359+ Case 3 (TDD — currently fails, defines acceptance criterion for follow-up, see #582):
360+
361+ When a tool parser is active but the model returns plain text (no tool call),
362+ and the parser implements extract_tool_calls_streaming, tokens should be emitted
363+ progressively — not held until the final chunk.
364+
365+ Proposed interface:
366+ extract_tool_calls_streaming(delta: str, request=None)
367+ -> SimpleNamespace(is_tool_call_token=bool, content=str)
368+
369+ Fails on current code because has_tool_parser=True suppresses all intermediate
370+ deltas. Will pass after Option A or B from the follow-up (Issue #582).
371+ """
372+ import sys , os , asyncio
373+ from types import SimpleNamespace
374+ sys .path .insert (0 , os .path .dirname (os .path .abspath (__file__ )))
375+ from backend import BackendServicer
376+
377+ def make_generate (chunks ):
378+ async def gen (* a , ** k ):
379+ for t in chunks :
380+ yield SimpleNamespace (
381+ outputs = [SimpleNamespace (text = t , token_ids = [1 ], logprobs = None )],
382+ prompt_token_ids = [0 ],
383+ )
384+ return lambda * a , ** k : gen ()
385+
386+ class StreamingAwareParser :
387+ def __init__ (self , tokenizer , tools = None ):
388+ pass
389+
390+ def extract_tool_calls (self , c , request = None ):
391+ return SimpleNamespace (tools_called = False , content = c , tool_calls = [])
392+
393+ def extract_tool_calls_streaming (self , delta , request = None ):
394+ # Plain-text delta — not tool-call markup, pass through as content.
395+ return SimpleNamespace (is_tool_call_token = False , content = delta )
396+
397+ def collect (servicer , req ):
398+ async def run ():
399+ return [r async for r in servicer ._predict (req , None , streaming = True )]
400+ return asyncio .run (run ())
401+
402+ # vLLM yields cumulative text per iteration
403+ s = BackendServicer ()
404+ s .reasoning_parser_cls = None
405+ s .tokenizer = None
406+ s .llm = SimpleNamespace (generate = make_generate ([
407+ "Paris " ,
408+ "Paris is " ,
409+ "Paris is the capital of France." ,
410+ ]))
411+ s .tool_parser_cls = StreamingAwareParser
412+
413+ tools_json = '[{"type":"function","function":{"name":"calc"}}]'
414+ req = backend_pb2 .PredictOptions (Prompt = "x" , Tools = tools_json )
415+ replies = collect (s , req )
416+
417+ # Key assertion: intermediate replies (before the final chunk) must carry content.
418+ # Currently fails because has_tool_parser=True suppresses all intermediate deltas.
419+ intermediate_content = [
420+ cd .content
421+ for r in replies [:- 1 ]
422+ for cd in r .chat_deltas
423+ if cd .content
424+ ]
425+ self .assertTrue (
426+ len (intermediate_content ) > 0 ,
427+ "Plain-text response not streamed progressively — "
428+ "all content arrived in the final chunk. "
429+ "Fix: use extract_tool_calls_streaming when available (Option A)." ,
430+ )
431+
432+ # No duplication: assembled content equals the full text exactly once.
433+ assembled = "" .join (
434+ cd .content for r in replies for cd in r .chat_deltas if cd .content
435+ )
436+ self .assertEqual (
437+ assembled , "Paris is the capital of France." ,
438+ f"Content wrong or duplicated: { assembled !r} " ,
355439 )
0 commit comments