[Refactor][GPT-OSS] Harmony Responses API Refactor to use HarmonyParser (#47185)

Signed-off-by: Yifan Zong <yzong@redhat.com>

yzong-rh committed Jun 30, 2026 at 19:23 UTC b1190d03cc13531ede563c4466215f9840baa933
9 files changed +424 -854
tests/entrypoints/openai/responses/conftest.py
+7 -1
@@ -251,7 +251,13 @@ def _validate_field_consistency(events: list) -> None:
251 "response.reasoning_part.added",
252 ):
253 _assert_item_fields(event, etype, active_item_id, active_output_index)
254 - active_content_index = getattr(event, "content_index", None)
254 + content_index = getattr(event, "content_index", None)
255 + if active_content_index is None:
256 + assert content_index == 0, (
257 + f"{etype} for a new item must start at content_index 0, "
258 + f"got {content_index}"
259 + )
260 + active_content_index = content_index
261 continue
262
263 # --- all other item-level events --------------------------
tests/entrypoints/openai/responses/test_harmony.py
+2 -1
@@ -454,6 +454,7 @@ async def test_streaming(client: OpenAI, model_name: str, background: bool):
454 if event.type == "response.output_item.added":
455 assert event.item.id != current_item_id
456 current_item_id = event.item.id
457 + current_content_index = -1
458 elif event.type in [
459 "response.output_text.delta",
460 "response.reasoning_text.delta",
@@ -465,7 +466,7 @@ async def test_streaming(client: OpenAI, model_name: str, background: bool):
466 "response.content_part.added",
467 "response.reasoning_part.added",
468 ]:
468 - assert event.content_index != current_content_index
469 + assert event.content_index == current_content_index + 1
470 current_content_index = event.content_index
471 elif event.type in [
472 "response.output_text.delta",
tests/entrypoints/openai/responses/test_harmony_utils.py
+126 -417
@@ -2,8 +2,10 @@
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Unit tests for vllm.entrypoints.openai.responses.harmony."""
4
5 +import pytest
6 from openai.types.responses import (
7 ResponseFunctionToolCall,
8 + ResponseFunctionWebSearch,
9 ResponseOutputMessage,
10 ResponseReasoningItem,
11 )
@@ -12,7 +14,6 @@ from openai_harmony import Author, Message, Role, TextContent
14
15 from vllm.entrypoints.openai.responses.harmony import (
16 harmony_to_response_output,
15 - parser_state_to_response_output,
17 response_previous_input_to_harmony,
18 )
19
@@ -95,7 +96,8 @@ class TestResponsePreviousInputToHarmony:
96 class TestHarmonyToResponseOutput:
97 """Tests for harmony_to_response_output function."""
98
98 - def test_commentary_with_no_recipient_creates_message(self):
99 + @pytest.mark.parametrize("incomplete", [False, True])
100 + def test_commentary_with_no_recipient_creates_message(self, incomplete):
101 """Test that commentary with recipient=None (preambles) creates message items.
102
103 Per Harmony format, preambles are intended to be shown to end-users,
@@ -108,13 +110,15 @@ class TestHarmonyToResponseOutput:
110 message = message.with_channel("commentary")
111 # recipient is None by default, representing a preamble
112
111 - output_items = harmony_to_response_output(message)
113 + output_items = harmony_to_response_output(
114 + message, frozenset(), incomplete=incomplete
115 + )
116
117 assert len(output_items) == 1
118 assert isinstance(output_items[0], ResponseOutputMessage)
119 assert output_items[0].type == "message"
120 assert output_items[0].role == "assistant"
117 - assert output_items[0].status == "completed"
121 + assert output_items[0].status == ("incomplete" if incomplete else "completed")
122 assert len(output_items[0].content) == 1
123 assert output_items[0].content[0].type == "output_text"
124 assert (
@@ -122,82 +126,148 @@ class TestHarmonyToResponseOutput:
126 == "I will now search for the weather information."
127 )
128
125 - def test_commentary_with_function_recipient_creates_function_call(self):
126 - """Test commentary with recipient='functions.X' creates function calls."""
127 - message = Message.from_role_and_content(
128 - Role.ASSISTANT, '{"location": "San Francisco", "units": "celsius"}'
129 + @pytest.mark.parametrize("channel", ["commentary", "comment", "analysis", "final"])
130 + @pytest.mark.parametrize(
131 + ("recipient", "fn_names", "expected_name"),
132 + [
133 + ("functions.get_weather", frozenset(), "get_weather"),
134 + ("get_weather", frozenset({"get_weather"}), "get_weather"),
135 + ("math.sum", frozenset({"math.sum"}), "math.sum"),
136 + ],
137 + )
138 + @pytest.mark.parametrize("incomplete", [False, True])
139 + def test_function_recipient_creates_function_call(
140 + self, channel, recipient, fn_names, expected_name, incomplete
141 + ):
142 + """Function recipients create function calls across channels."""
143 + content = '{"location": "San Francisco"}'
144 + if recipient == "math.sum":
145 + content = '{"a": 1, "b": 2}'
146 +
147 + message = Message.from_role_and_content(Role.ASSISTANT, content)
148 + message = message.with_channel(channel)
149 + message = message.with_recipient(recipient)
150 +
151 + output_items = harmony_to_response_output(
152 + message, fn_names, incomplete=incomplete
153 )
130 - message = message.with_channel("commentary")
131 - message = message.with_recipient("functions.get_weather")
132 -
133 - output_items = harmony_to_response_output(message)
154
155 assert len(output_items) == 1
156 assert isinstance(output_items[0], ResponseFunctionToolCall)
157 assert output_items[0].type == "function_call"
138 - assert output_items[0].name == "get_weather"
139 - assert (
140 - output_items[0].arguments
141 - == '{"location": "San Francisco", "units": "celsius"}'
142 - )
158 + assert output_items[0].name == expected_name
159 + assert output_items[0].arguments == content
160 assert output_items[0].call_id.startswith("call_")
161 assert output_items[0].id.startswith("fc_")
145 - assert output_items[0].status == "completed"
146 -
147 - def test_commentary_with_python_recipient_creates_reasoning(self):
148 - """Test that commentary with recipient='python' creates reasoning items."""
149 - message = Message.from_role_and_content(
150 - Role.ASSISTANT, "import numpy as np\nprint(np.array([1, 2, 3]))"
162 + assert output_items[0].status == ("incomplete" if incomplete else "completed")
163 +
164 + @pytest.mark.parametrize("channel", ["commentary", "comment", "analysis", "final"])
165 + @pytest.mark.parametrize(
166 + ("recipient", "content"),
167 + [
168 + ("python", "import numpy as np\nprint(np.array([1, 2, 3]))"),
169 + ("browser", "Navigating to the specified URL"),
170 + ("container", "Running command in container"),
171 + ],
172 + )
173 + @pytest.mark.parametrize("incomplete", [False, True])
174 + def test_builtin_recipient_creates_reasoning(
175 + self, channel, recipient, content, incomplete
176 + ):
177 + """Built-in recipients create reasoning items."""
178 + message = Message.from_role_and_content(Role.ASSISTANT, content)
179 + message = message.with_channel(channel)
180 + message = message.with_recipient(recipient)
181 +
182 + output_items = harmony_to_response_output(
183 + message, frozenset(), incomplete=incomplete
184 )
152 - message = message.with_channel("commentary")
153 - message = message.with_recipient("python")
154 -
155 - output_items = harmony_to_response_output(message)
185
186 assert len(output_items) == 1
187 assert isinstance(output_items[0], ResponseReasoningItem)
188 assert output_items[0].type == "reasoning"
160 - assert (
161 - output_items[0].content[0].text
162 - == "import numpy as np\nprint(np.array([1, 2, 3]))"
189 + assert output_items[0].content[0].text == content
190 + assert output_items[0].status is None
191 +
192 + @pytest.mark.parametrize("channel", ["commentary", "comment", "analysis", "final"])
193 + @pytest.mark.parametrize(
194 + ("recipient", "fn_names", "content", "expected_name", "expected_server_label"),
195 + [
196 + (
197 + "get_weather",
198 + frozenset(),
199 + '{"arg": "value"}',
200 + "get_weather",
201 + "get_weather",
202 + ),
203 + (
204 + "not_get_weather",
205 + frozenset({"get_weather"}),
206 + '{"arg": "value"}',
207 + "not_get_weather",
208 + "not_get_weather",
209 + ),
210 + ("repo_browser.list", frozenset(), '{"cmd": "ls"}', "list", "repo_browser"),
211 + ],
212 + )
213 + @pytest.mark.parametrize("incomplete", [False, True])
214 + def test_non_function_non_builtin_recipient_creates_mcp_call(
215 + self,
216 + channel,
217 + recipient,
218 + fn_names,
219 + content,
220 + expected_name,
221 + expected_server_label,
222 + incomplete,
223 + ):
224 + """Non-function, non-built-in recipients create MCP calls."""
225 + message = Message.from_role_and_content(Role.ASSISTANT, content)
226 + message = message.with_channel(channel)
227 + message = message.with_recipient(recipient)
228 +
229 + output_items = harmony_to_response_output(
230 + message, fn_names, incomplete=incomplete
231 )
232
165 - def test_commentary_with_browser_recipient_creates_reasoning(self):
166 - """Test that commentary with recipient='browser' creates reasoning items."""
233 + assert len(output_items) == 1
234 + assert isinstance(output_items[0], McpCall)
235 + assert output_items[0].type == "mcp_call"
236 + assert output_items[0].name == expected_name
237 + assert output_items[0].server_label == expected_server_label
238 + assert output_items[0].arguments == content
239 + assert output_items[0].status == ("incomplete" if incomplete else "completed")
240 +
241 + @pytest.mark.parametrize("incomplete", [False, True])
242 + def test_browser_search_recipient_respects_incomplete(self, incomplete):
243 + """browser.search emits a web search call unless the item is incomplete."""
244 message = Message.from_role_and_content(
168 - Role.ASSISTANT, "Navigating to the specified URL"
245 + Role.ASSISTANT, '{"query": "weather in San Francisco"}'
246 )
247 message = message.with_channel("commentary")
171 - message = message.with_recipient("browser")
172 -
173 - output_items = harmony_to_response_output(message)
174 -
175 - assert len(output_items) == 1
176 - assert isinstance(output_items[0], ResponseReasoningItem)
177 - assert output_items[0].type == "reasoning"
178 - assert output_items[0].content[0].text == "Navigating to the specified URL"
248 + message = message.with_recipient("browser.search")
249
180 - def test_commentary_with_container_recipient_creates_reasoning(self):
181 - """Test that commentary with recipient='container' creates reasoning items."""
182 - message = Message.from_role_and_content(
183 - Role.ASSISTANT, "Running command in container"
250 + output_items = harmony_to_response_output(
251 + message, frozenset(), incomplete=incomplete
252 )
185 - message = message.with_channel("commentary")
186 - message = message.with_recipient("container")
253
188 - output_items = harmony_to_response_output(message)
254 + if incomplete:
255 + assert output_items == []
256 + return
257
258 assert len(output_items) == 1
191 - assert isinstance(output_items[0], ResponseReasoningItem)
192 - assert output_items[0].type == "reasoning"
193 - assert output_items[0].content[0].text == "Running command in container"
259 + assert isinstance(output_items[0], ResponseFunctionWebSearch)
260 + assert output_items[0].type == "web_search_call"
261 + assert output_items[0].status == "completed"
262 + assert output_items[0].action.type == "search"
263 + assert output_items[0].action.query == "cursor:weather in San Francisco"
264
265 def test_commentary_with_empty_content_and_no_recipient(self):
266 """Test edge case: empty commentary with recipient=None."""
267 message = Message.from_role_and_content(Role.ASSISTANT, "")
268 message = message.with_channel("commentary")
269
200 - output_items = harmony_to_response_output(message)
270 + output_items = harmony_to_response_output(message, frozenset())
271
272 assert len(output_items) == 1
273 assert isinstance(output_items[0], ResponseOutputMessage)
@@ -212,7 +282,7 @@ class TestHarmonyToResponseOutput:
282 message = Message.from_role_and_contents(Role.ASSISTANT, contents)
283 message = message.with_channel("commentary")
284
215 - output_items = harmony_to_response_output(message)
285 + output_items = harmony_to_response_output(message, frozenset())
286
287 # _parse_final_message returns single ResponseOutputMessage with
288 # multiple contents
@@ -232,7 +302,7 @@ class TestHarmonyToResponseOutput:
302 message = message.with_channel("commentary")
303 message = message.with_recipient("functions.get_weather")
304
235 - output_items = harmony_to_response_output(message)
305 + output_items = harmony_to_response_output(message, frozenset())
306
307 assert len(output_items) == 2
308 assert all(isinstance(item, ResponseFunctionToolCall) for item in output_items)
@@ -241,21 +311,6 @@ class TestHarmonyToResponseOutput:
311 assert output_items[0].arguments == '{"location": "San Francisco"}'
312 assert output_items[1].arguments == '{"location": "New York"}'
313
244 - def test_commentary_with_unknown_recipient_creates_mcp_call(self):
245 - """Test that commentary with unknown recipient creates MCP call."""
246 - message = Message.from_role_and_content(Role.ASSISTANT, '{"arg": "value"}')
247 - message = message.with_channel("commentary")
248 - message = message.with_recipient("custom_tool")
249 -
250 - fn_names = frozenset({"other_tool"})
251 - output_items = harmony_to_response_output(message, fn_names)
252 -
253 - assert len(output_items) == 1
254 - assert isinstance(output_items[0], McpCall)
255 - assert output_items[0].type == "mcp_call"
256 - assert output_items[0].name == "custom_tool"
257 - assert output_items[0].server_label == "custom_tool"
258 -
314 def test_analysis_channel_creates_reasoning(self):
315 """Test that analysis channel creates reasoning items."""
316 message = Message.from_role_and_content(
@@ -263,7 +318,7 @@ class TestHarmonyToResponseOutput:
318 )
319 message = message.with_channel("analysis")
320
266 - output_items = harmony_to_response_output(message)
321 + output_items = harmony_to_response_output(message, frozenset())
322
323 assert len(output_items) == 1
324 assert isinstance(output_items[0], ResponseReasoningItem)
@@ -283,352 +338,6 @@ class TestHarmonyToResponseOutput:
338 "The weather is sunny, 72°F",
339 )
340
286 - output_items = harmony_to_response_output(message)
287 -
288 - assert len(output_items) == 0
289 -
290 -
291 -class TestHarmonyToResponseOutputWithFunctionToolNames:
292 - """Tests for bare function name handling with function_tool_names."""
293 -
294 - def test_bare_name_creates_function_call_when_in_tool_names(self):
295 - """Bare function name matching a known tool creates function call."""
296 - message = Message.from_role_and_content(
297 - Role.ASSISTANT, '{"location": "San Francisco"}'
298 - )
299 - message = message.with_channel("commentary")
300 - message = message.with_recipient("get_weather")
301 -
302 - fn_names = frozenset({"get_weather"})
303 - output_items = harmony_to_response_output(message, fn_names)
304 -
305 - assert len(output_items) == 1
306 - assert isinstance(output_items[0], ResponseFunctionToolCall)
307 - assert output_items[0].type == "function_call"
308 - assert output_items[0].name == "get_weather"
309 - assert output_items[0].arguments == '{"location": "San Francisco"}'
310 -
311 - def test_bare_name_creates_mcp_call_when_not_in_tool_names(self):
312 - """Bare name not matching any known tool creates MCP call."""
313 - message = Message.from_role_and_content(Role.ASSISTANT, '{"arg": "value"}')
314 - message = message.with_channel("commentary")
315 - message = message.with_recipient("custom_tool")
316 -
317 - fn_names = frozenset({"get_weather"})
318 - output_items = harmony_to_response_output(message, fn_names)
319 -
320 - assert len(output_items) == 1
321 - assert isinstance(output_items[0], McpCall)
322 - assert output_items[0].type == "mcp_call"
323 -
324 - def test_dotted_function_name_creates_function_call(self):
325 - """Dotted function name in tool names creates function call."""
326 - message = Message.from_role_and_content(Role.ASSISTANT, '{"a": 1, "b": 2}')
327 - message = message.with_channel("commentary")
328 - message = message.with_recipient("math.sum")
329 -
330 - fn_names = frozenset({"math.sum"})
331 - output_items = harmony_to_response_output(message, fn_names)
332 -
333 - assert len(output_items) == 1
334 - assert isinstance(output_items[0], ResponseFunctionToolCall)
335 - assert output_items[0].name == "math.sum"
336 -
337 - def test_empty_tool_names_defaults_to_mcp(self):
338 - """With empty function_tool_names, bare names become MCP calls."""
339 - message = Message.from_role_and_content(Role.ASSISTANT, '{"arg": "value"}')
340 - message = message.with_channel("commentary")
341 - message = message.with_recipient("get_weather")
342 -
341 output_items = harmony_to_response_output(message, frozenset())
342
345 - assert len(output_items) == 1
346 - assert isinstance(output_items[0], McpCall)
347 -
348 - def test_prefixed_name_always_function_call(self):
349 - """functions. prefix always creates function call even with empty tool names."""
350 - message = Message.from_role_and_content(Role.ASSISTANT, '{"arg": "value"}')
351 - message = message.with_channel("commentary")
352 - message = message.with_recipient("functions.get_weather")
353 -
354 - output_items = harmony_to_response_output(message, frozenset())
355 -
356 - assert len(output_items) == 1
357 - assert isinstance(output_items[0], ResponseFunctionToolCall)
358 - assert output_items[0].name == "get_weather"
359 -
360 -
361 -class TestParserStateWithFunctionToolNames:
362 - """Tests for parser_state_to_response_output with function_tool_names."""
363 -
364 - def test_bare_name_creates_function_call(self):
365 - from unittest.mock import Mock
366 -
367 - parser = Mock()
368 - parser.current_content = '{"arg": "value"}'
369 - parser.current_role = Role.ASSISTANT
370 - parser.current_channel = "commentary"
371 - parser.current_recipient = "get_weather"
372 -
373 - fn_names = frozenset({"get_weather"})
374 - items = parser_state_to_response_output(parser, fn_names)
375 -
376 - assert len(items) == 1
377 - assert isinstance(items[0], ResponseFunctionToolCall)
378 - assert items[0].name == "get_weather"
379 - assert items[0].status == "in_progress"
380 -
381 - def test_bare_name_creates_mcp_when_not_in_tool_names(self):
382 - from unittest.mock import Mock
383 -
384 - parser = Mock()
385 - parser.current_content = '{"arg": "value"}'
386 - parser.current_role = Role.ASSISTANT
387 - parser.current_channel = "commentary"
388 - parser.current_recipient = "unknown_tool"
389 -
390 - fn_names = frozenset({"get_weather"})
391 - items = parser_state_to_response_output(parser, fn_names)
392 -
393 - assert len(items) == 1
394 - assert isinstance(items[0], McpCall)
395 - assert items[0].name == "unknown_tool"
396 -
397 -
398 -class TestToolCallsOnNonStandardChannels:
399 - """Tests verifying tool calls are detected regardless of channel."""
400 -
401 - def test_function_call_on_comment_channel(self):
402 - message = Message.from_role_and_content(Role.ASSISTANT, '{"query": "weather"}')
403 - message = message.with_channel("comment")
404 - message = message.with_recipient("functions.get_weather")
405 -
406 - output_items = harmony_to_response_output(message)
407 -
408 - assert len(output_items) == 1
409 - assert isinstance(output_items[0], ResponseFunctionToolCall)
410 - assert output_items[0].type == "function_call"
411 - assert output_items[0].name == "get_weather"
412 -
413 - def test_bare_function_on_comment_channel(self):
414 - message = Message.from_role_and_content(Role.ASSISTANT, '{"query": "weather"}')
415 - message = message.with_channel("comment")
416 - message = message.with_recipient("get_weather")
417 -
418 - fn_names = frozenset({"get_weather"})
419 - output_items = harmony_to_response_output(message, fn_names)
420 -
421 - assert len(output_items) == 1
422 - assert isinstance(output_items[0], ResponseFunctionToolCall)
423 - assert output_items[0].name == "get_weather"
424 -
425 - def test_parser_state_comment_channel_function(self):
426 - from unittest.mock import Mock
427 -
428 - parser = Mock()
429 - parser.current_content = '{"arg": "value"}'
430 - parser.current_role = Role.ASSISTANT
431 - parser.current_channel = "comment"
432 - parser.current_recipient = "functions.get_weather"
433 -
434 - items = parser_state_to_response_output(parser)
435 -
436 - assert len(items) == 1
437 - assert isinstance(items[0], ResponseFunctionToolCall)
438 - assert items[0].name == "get_weather"
439 -
440 - def test_parser_state_comment_channel_mcp(self):
441 - from unittest.mock import Mock
442 -
443 - parser = Mock()
444 - parser.current_content = '{"arg": "value"}'
445 - parser.current_role = Role.ASSISTANT
446 - parser.current_channel = "comment"
447 - parser.current_recipient = "mcp.server.tool"
448 -
449 - fn_names: frozenset[str] = frozenset()
450 - items = parser_state_to_response_output(parser, fn_names)
451 -
452 - assert len(items) == 1
453 - assert isinstance(items[0], McpCall)
454 -
455 -
456 -def test_parse_mcp_call_basic() -> None:
457 - """Test that MCP calls are parsed with correct type and server_label."""
458 - message = Message.from_role_and_content(Role.ASSISTANT, '{"path": "/tmp"}')
459 - message = message.with_recipient("filesystem")
460 - message = message.with_channel("commentary")
461 -
462 - fn_names: frozenset[str] = frozenset()
463 - output_items = harmony_to_response_output(message, fn_names)
464 -
465 - assert len(output_items) == 1
466 - assert isinstance(output_items[0], McpCall)
467 - assert output_items[0].type == "mcp_call"
468 - assert output_items[0].name == "filesystem"
469 - assert output_items[0].server_label == "filesystem"
470 - assert output_items[0].arguments == '{"path": "/tmp"}'
471 - assert output_items[0].status == "completed"
472 -
473 -
474 -def test_parse_mcp_call_dotted_recipient() -> None:
475 - """Test that dotted recipients extract the tool name correctly."""
476 - message = Message.from_role_and_content(Role.ASSISTANT, '{"cmd": "ls"}')
477 - message = message.with_recipient("repo_browser.list")
478 - message = message.with_channel("commentary")
479 -
480 - fn_names: frozenset[str] = frozenset()
481 - output_items = harmony_to_response_output(message, fn_names)
482 -
483 - assert len(output_items) == 1
484 - assert isinstance(output_items[0], McpCall)
485 - assert output_items[0].name == "list"
486 - assert output_items[0].server_label == "repo_browser"
487 -
488 -
489 -def test_mcp_vs_function_call() -> None:
490 - """Test that function calls are not parsed as MCP calls."""
491 - func_message = Message.from_role_and_content(Role.ASSISTANT, '{"arg": "value"}')
492 - func_message = func_message.with_recipient("functions.my_tool")
493 - func_message = func_message.with_channel("commentary")
494 -
495 - func_items = harmony_to_response_output(func_message)
496 -
497 - assert len(func_items) == 1
498 - assert not isinstance(func_items[0], McpCall)
499 - assert func_items[0].type == "function_call"
500 -
501 -
502 -def test_mcp_vs_builtin_tools() -> None:
503 - """Test that built-in tools (python, container) are not parsed as MCP calls."""
504 - # Test python (built-in tool) - should be reasoning, not MCP
505 - python_message = Message.from_role_and_content(Role.ASSISTANT, "print('hello')")
506 - python_message = python_message.with_recipient("python")
507 - python_message = python_message.with_channel("commentary")
508 -
509 - python_items = harmony_to_response_output(python_message)
510 -
511 - assert len(python_items) == 1
512 - assert not isinstance(python_items[0], McpCall)
513 - assert python_items[0].type == "reasoning"
514 -
515 -
516 -def test_parser_state_to_response_output_commentary_channel() -> None:
517 - """Test parser_state_to_response_output with commentary
518 - channel and various recipients."""
519 - from unittest.mock import Mock
520 -
521 - # Test 1: functions.* recipient -> should return function tool call
522 - parser_func = Mock()
523 - parser_func.current_content = '{"arg": "value"}'
524 - parser_func.current_role = Role.ASSISTANT
525 - parser_func.current_channel = "commentary"
526 - parser_func.current_recipient = "functions.my_tool"
527 -
528 - func_items = parser_state_to_response_output(parser_func)
529 -
530 - assert len(func_items) == 1
531 - assert not isinstance(func_items[0], McpCall)
532 - assert func_items[0].type == "function_call"
533 - assert func_items[0].name == "my_tool"
534 - assert func_items[0].status == "in_progress"
535 -
536 - # Test 2: MCP tool (not builtin) -> should return MCP call
537 - parser_mcp = Mock()
538 - parser_mcp.current_content = '{"path": "/tmp"}'
539 - parser_mcp.current_role = Role.ASSISTANT
540 - parser_mcp.current_channel = "commentary"
541 - parser_mcp.current_recipient = "filesystem"
542 -
543 - fn_names: frozenset[str] = frozenset()
544 - mcp_items = parser_state_to_response_output(parser_mcp, fn_names)
545 -
546 - assert len(mcp_items) == 1
547 - assert isinstance(mcp_items[0], McpCall)
548 - assert mcp_items[0].type == "mcp_call"
549 - assert mcp_items[0].name == "filesystem"
550 - assert mcp_items[0].server_label == "filesystem"
551 - assert mcp_items[0].status == "in_progress"
552 -
553 - # Test 3: Built-in tool (python)
554 - # should NOT return MCP call, returns reasoning (internal tool interaction)
555 - parser_builtin = Mock()
556 - parser_builtin.current_content = "print('hello')"
557 - parser_builtin.current_role = Role.ASSISTANT
558 - parser_builtin.current_channel = "commentary"
559 - parser_builtin.current_recipient = "python"
560 -
561 - builtin_items = parser_state_to_response_output(parser_builtin)
562 -
563 - # Built-in tools explicitly return reasoning
564 - assert len(builtin_items) == 1
565 - assert not isinstance(builtin_items[0], McpCall)
566 - assert builtin_items[0].type == "reasoning"
567 -
568 - # Test 4: No recipient (preamble) → should return message, not reasoning
569 - parser_preamble = Mock()
570 - parser_preamble.current_content = "I'll search for that information now."
571 - parser_preamble.current_role = Role.ASSISTANT
572 - parser_preamble.current_channel = "commentary"
573 - parser_preamble.current_recipient = None
574 -
575 - preamble_items = parser_state_to_response_output(parser_preamble)
576 -
577 - assert len(preamble_items) == 1
578 - assert isinstance(preamble_items[0], ResponseOutputMessage)
579 - assert preamble_items[0].type == "message"
580 - assert preamble_items[0].content[0].text == "I'll search for that information now."
581 - assert preamble_items[0].status == "incomplete" # streaming
582 -
583 -
584 -def test_parser_state_to_response_output_analysis_channel() -> None:
585 - """Test parser_state_to_response_output with analysis
586 - channel and various recipients."""
587 - from unittest.mock import Mock
588 -
589 - # Test 1: functions.* recipient -> should return function tool call
590 - parser_func = Mock()
591 - parser_func.current_content = '{"arg": "value"}'
592 - parser_func.current_role = Role.ASSISTANT
593 - parser_func.current_channel = "analysis"
594 - parser_func.current_recipient = "functions.my_tool"
595 -
596 - func_items = parser_state_to_response_output(parser_func)
597 -
598 - assert len(func_items) == 1
599 - assert not isinstance(func_items[0], McpCall)
600 - assert func_items[0].type == "function_call"
601 - assert func_items[0].name == "my_tool"
602 - assert func_items[0].status == "in_progress"
603 -
604 - # Test 2: MCP tool (not builtin) -> should return MCP call
605 - parser_mcp = Mock()
606 - parser_mcp.current_content = '{"query": "test"}'
607 - parser_mcp.current_role = Role.ASSISTANT
608 - parser_mcp.current_channel = "analysis"
609 - parser_mcp.current_recipient = "database"
610 -
611 - fn_names: frozenset[str] = frozenset()
612 - mcp_items = parser_state_to_response_output(parser_mcp, fn_names)
613 -
614 - assert len(mcp_items) == 1
615 - assert isinstance(mcp_items[0], McpCall)
616 - assert mcp_items[0].type == "mcp_call"
617 - assert mcp_items[0].name == "database"
618 - assert mcp_items[0].server_label == "database"
619 - assert mcp_items[0].status == "in_progress"
620 -
621 - # Test 3: Built-in tool (container)
622 - # should NOT return MCP call, falls through to reasoning
623 - parser_builtin = Mock()
624 - parser_builtin.current_content = "docker run"
625 - parser_builtin.current_role = Role.ASSISTANT
626 - parser_builtin.current_channel = "analysis"
627 - parser_builtin.current_recipient = "container"
628 -
629 - builtin_items = parser_state_to_response_output(parser_builtin)
630 -
631 - # Should fall through to reasoning logic
632 - assert len(builtin_items) == 1
633 - assert not isinstance(builtin_items[0], McpCall)
634 - assert builtin_items[0].type == "reasoning"
343 + assert len(output_items) == 0
tests/entrypoints/openai/responses/test_serving_responses.py
+51 -13
@@ -51,6 +51,7 @@ from vllm.entrypoints.openai.responses.streaming_events import (
51 )
52 from vllm.inputs import tokens_input
53 from vllm.outputs import CompletionOutput, RequestOutput
54 +from vllm.parser.harmony import Segment
55 from vllm.sampling_params import SamplingParams
56
57
@@ -534,13 +535,9 @@ class TestHarmonyPreambleStreaming:
535 """Tests for preamble (commentary with no recipient) streaming events."""
536
537 @staticmethod
537 - def _make_ctx(*, channel, recipient, delta="hello"):
538 - """Build a lightweight mock StreamingHarmonyContext."""
539 - ctx = MagicMock()
540 - ctx.last_content_delta = delta
541 - ctx.parser.current_channel = channel
542 - ctx.parser.current_recipient = recipient
543 - return ctx
538 + def _make_segment(*, channel, recipient, delta="hello"):
539 + """Build a lightweight segment for Harmony streaming tests."""
540 + return Segment(channel=channel, recipient=recipient, delta=delta)
541
542 @staticmethod
543 def _make_previous_item(*, channel, recipient, text="preamble text"):
@@ -559,10 +556,10 @@ class TestHarmonyPreambleStreaming:
556 emit_content_delta_events,
557 )
558
562 - ctx = self._make_ctx(channel="commentary", recipient=None)
559 + segment = self._make_segment(channel="commentary", recipient=None)
560 state = StreamingState()
561
565 - events = emit_content_delta_events(ctx, state)
562 + events = emit_content_delta_events(segment, state)
563
564 type_names = [e.type for e in events]
565 assert "response.output_text.delta" in type_names
@@ -574,13 +571,13 @@ class TestHarmonyPreambleStreaming:
571 emit_content_delta_events,
572 )
573
577 - ctx = self._make_ctx(channel="commentary", recipient=None, delta="w")
574 + segment = self._make_segment(channel="commentary", recipient=None, delta="w")
575 state = StreamingState()
576 state.sent_output_item_added = True
577 state.current_item_id = "msg_test"
578 state.current_content_index = 0
579
583 - events = emit_content_delta_events(ctx, state)
580 + events = emit_content_delta_events(segment, state)
581
582 type_names = [e.type for e in events]
583 assert "response.output_text.delta" in type_names
@@ -592,13 +589,13 @@ class TestHarmonyPreambleStreaming:
589 emit_content_delta_events,
590 )
591
595 - ctx = self._make_ctx(
592 + segment = self._make_segment(
593 channel="commentary",
594 recipient="functions.get_weather",
595 )
596 state = StreamingState()
597
601 - events = emit_content_delta_events(ctx, state)
598 + events = emit_content_delta_events(segment, state)
599
600 type_names = [e.type for e in events]
601 assert "response.output_text.delta" not in type_names
@@ -612,6 +609,7 @@ class TestHarmonyPreambleStreaming:
609
610 previous = self._make_previous_item(channel="commentary", recipient=None)
611 state = StreamingState()
612 + state.sent_output_item_added = True
613 state.current_item_id = "msg_test"
614 state.current_output_index = 0
615 state.current_content_index = 0
@@ -634,13 +632,53 @@ class TestHarmonyPreambleStreaming:
632 channel="commentary", recipient="functions.get_weather"
633 )
634 state = StreamingState()
635 + state.is_first_function_call_delta = True
636 state.current_item_id = "fc_test"
637 + state.current_call_id = "call_test"
638
639 events = emit_previous_item_done_events(previous, state)
640
641 type_names = [e.type for e in events]
642 assert "response.output_text.done" not in type_names
643
644 + @pytest.mark.xfail(
645 + reason=(
646 + "TODO: Ensure added/in-progress events are emitted for zero-delta items."
647 + "So we can safely emit done events for zero-delta items."
648 + ),
649 + strict=True,
650 + )
651 + def test_zero_delta_items_should_preserve_streaming_lifecycle(
652 + self,
653 + ) -> None:
654 + """Zero-delta Harmony items should still produce a coherent lifecycle."""
655 + from vllm.entrypoints.openai.responses.streaming_events import (
656 + emit_previous_item_done_events,
657 + )
658 +
659 + cases: list[tuple[str, str | None, str]] = [
660 + ("commentary", None, "msg_stale"),
661 + ("analysis", None, "msg_stale"),
662 + ("commentary", "functions.get_weather", "fc_stale"),
663 + ("commentary", "python", "tool_stale"),
664 + ("commentary", "repo_browser.list", "mcp_stale"),
665 + ]
666 +
667 + for channel, recipient, current_item_id in cases:
668 + previous = self._make_previous_item(channel=channel, recipient=recipient)
669 + state = StreamingState()
670 + state.current_item_id = current_item_id
671 + state.current_call_id = "call_stale"
672 + state.current_content_index = 0
673 +
674 + events = emit_previous_item_done_events(
675 + previous, state, function_tool_names=None
676 + )
677 +
678 + type_names = [e.type for e in events]
679 + assert "response.output_item.added" in type_names
680 + assert "response.output_item.done" in type_names
681 +
682
683 def _make_simple_context_with_output(text, token_ids, response_parser=None):
684 """Create a SimpleContext with a RequestOutput containing the given text."""
tests/entrypoints/unit_tests/test_context.py
+116 -89
@@ -1,18 +1,18 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 -from unittest.mock import MagicMock, patch
4 +from unittest.mock import patch
5
6 import pytest
7 -from openai_harmony import Author, Message, Role, StreamState, TextContent
7 +from openai_harmony import Author, Message, Role, TextContent
8
9 from vllm.entrypoints.openai.responses.context import (
10 HarmonyContext,
11 SimpleContext,
12 - StreamingHarmonyContext,
12 TurnMetrics,
13 )
14 from vllm.outputs import CompletionOutput, RequestOutput
15 +from vllm.parser.harmony import ChunkResult, HarmonyParser, Segment
16
17
18 def create_mock_request_output(
@@ -68,25 +68,59 @@ async def generate_mock_outputs(
68 )
69
70
71 -@pytest.fixture
72 -def mock_parser():
73 - """Set up a mock parser for tests."""
74 - with patch(
75 - "vllm.entrypoints.openai.responses.context.get_streamable_parser_for_assistant"
76 - ) as mock_parser_factory:
77 - # Create a mock parser object
78 - parser = MagicMock()
79 - parser.messages = []
80 - parser.current_channel = None
81 - parser.state = StreamState.EXPECT_START
82 - mock_parser_factory.return_value = parser
83 - yield parser
71 +class FakeHarmonyParser(HarmonyParser):
72 + def __init__(self):
73 + # Skip HarmonyParser initialization and script outputs directly.
74 + self.reasoning_parser = None
75 + self.tool_parser = None
76 + self._chunk_results: list[ChunkResult] = []
77 + self._flush_results: list[Segment | None] = []
78 + self.processed_chunks: list[list[int]] = []
79 +
80 + def enqueue_chunk_result(
81 + self,
82 + segments: list[Segment] | None = None,
83 + reasoning_token_count: int = 0,
84 + ) -> None:
85 + self._chunk_results.append(
86 + ChunkResult(
87 + segments=[] if segments is None else segments,
88 + reasoning_token_count=reasoning_token_count,
89 + )
90 + )
91 +
92 + def enqueue_flush_result(self, segment: Segment | None) -> None:
93 + self._flush_results.append(segment)
94 +
95 + def process_chunk(self, token_ids) -> ChunkResult:
96 + self.processed_chunks.append(list(token_ids))
97 + if self._chunk_results:
98 + return self._chunk_results.pop(0)
99 + return ChunkResult(segments=[], reasoning_token_count=0)
100 +
101 + def flush(self) -> Segment | None:
102 + if self._flush_results:
103 + return self._flush_results.pop(0)
104 + return None
105 +
106 +
107 +def make_harmony_context(
108 + messages=None, available_tools=None, function_tool_names=None
109 +) -> tuple[HarmonyContext, FakeHarmonyParser]:
110 + fake_parser = FakeHarmonyParser()
111 + context = HarmonyContext(
112 + messages=[] if messages is None else messages,
113 + available_tools=[] if available_tools is None else available_tools,
114 + function_tool_names=function_tool_names,
115 + response_parser=fake_parser,
116 + )
117 + return context, fake_parser
118
119
120 def test_single_turn_token_counting():
121 """Test token counting behavior for a single turn."""
122 # Create a context
89 - context = HarmonyContext(messages=[], available_tools=[])
123 + context, _ = make_harmony_context()
124
125 # Create a mock RequestOutput with specific token counts
126 mock_output = create_mock_request_output(
@@ -118,7 +152,7 @@ def test_single_turn_token_counting():
152 async def test_multi_turn_token_counting():
153 """Test token counting behavior across multiple turns with tool output."""
154 # Create a context
121 - context = HarmonyContext(messages=[], available_tools=["browser"])
155 + context, _ = make_harmony_context(available_tools=["browser"])
156
157 # Simulate a conversation with 3 turns
158 # Turn 1: prefill 5, decode 3, tool 7
@@ -177,7 +211,7 @@ async def test_multi_turn_token_counting():
211
212 def test_empty_output_tokens():
213 """Test behavior when RequestOutput has empty output tokens."""
180 - context = HarmonyContext(messages=[], available_tools=[])
214 + context, _ = make_harmony_context()
215
216 # Create a RequestOutput with empty output tokens
217 mock_output = create_mock_request_output(
@@ -197,7 +231,7 @@ def test_empty_output_tokens():
231
232 def test_missing_prompt_token_ids():
233 """Test behavior when RequestOutput has None prompt_token_ids."""
200 - context = HarmonyContext(messages=[], available_tools=[])
234 + context, _ = make_harmony_context()
235
236 mock_output = create_mock_request_output(
237 prompt_token_ids=None, # No prompt token IDs
@@ -216,12 +250,10 @@ def test_missing_prompt_token_ids():
250 assert context.num_tool_output_tokens == 0
251
252
219 -def test_reasoning_tokens_counting(mock_parser):
253 +def test_reasoning_tokens_counting():
254 """Test that reasoning tokens are counted correctly."""
221 - context = HarmonyContext(messages=[], available_tools=[])
222 -
223 - # Mock parser to simulate reasoning channel
224 - mock_parser.current_channel = "analysis" # Reasoning channel
255 + context, parser = make_harmony_context()
256 + parser.enqueue_chunk_result(reasoning_token_count=4)
257
258 mock_output = create_mock_request_output(
259 prompt_token_ids=[1, 2, 3],
@@ -236,13 +268,11 @@ def test_reasoning_tokens_counting(mock_parser):
268 assert context.num_output_tokens == 4
269
270
239 -def test_preamble_tokens_not_counted_as_reasoning(mock_parser):
271 +def test_preamble_tokens_not_counted_as_reasoning():
272 """Preambles (commentary with no recipient) are visible user text,
273 not hidden reasoning. They must NOT inflate num_reasoning_tokens."""
242 - context = HarmonyContext(messages=[], available_tools=[])
243 -
244 - mock_parser.current_channel = "commentary"
245 - mock_parser.current_recipient = None # preamble
274 + context, parser = make_harmony_context()
275 + parser.enqueue_chunk_result(reasoning_token_count=0)
276
277 mock_output = create_mock_request_output(
278 prompt_token_ids=[1, 2, 3],
@@ -255,13 +285,11 @@ def test_preamble_tokens_not_counted_as_reasoning(mock_parser):
285 assert context.num_output_tokens == 3
286
287
258 -def test_commentary_with_recipient_counted_as_reasoning(mock_parser):
288 +def test_commentary_with_recipient_counted_as_reasoning():
289 """Commentary directed at a tool (recipient != None) is hidden from
290 the user, so it should still count as reasoning tokens."""
261 - context = HarmonyContext(messages=[], available_tools=[])
262 -
263 - mock_parser.current_channel = "commentary"
264 - mock_parser.current_recipient = "python"
291 + context, parser = make_harmony_context()
292 + parser.enqueue_chunk_result(reasoning_token_count=3)
293
294 mock_output = create_mock_request_output(
295 prompt_token_ids=[1, 2, 3],
@@ -276,7 +304,7 @@ def test_commentary_with_recipient_counted_as_reasoning(mock_parser):
304
305 def test_zero_tokens_edge_case():
306 """Test behavior with all zero token counts."""
279 - context = HarmonyContext(messages=[], available_tools=[])
307 + context, _ = make_harmony_context()
308
309 # Create a request with empty lists (not None) for both prompt and
310 # output tokens
@@ -299,10 +327,7 @@ def test_zero_tokens_edge_case():
327 @pytest.mark.asyncio
328 async def test_single_turn_no_tool_output():
329 """Test that first turn never generates tool output tokens."""
302 - context = HarmonyContext(
303 - messages=[],
304 - available_tools=["browser"], # Tools available
305 - )
330 + context, _ = make_harmony_context(available_tools=["browser"])
331
332 # Even with large prompt in first turn, no tool tokens should be counted
333 mock_output = create_mock_request_output(
@@ -324,7 +349,7 @@ async def test_negative_tool_tokens_edge_case():
349 tokens. We should log an error and clamp the value to 0."""
350 # Use patch to check if logger.error was called
351 with patch("vllm.entrypoints.openai.responses.context.logger.error") as mock_log:
327 - context = HarmonyContext(messages=[], available_tools=["browser"])
352 + context, _ = make_harmony_context(available_tools=["browser"])
353
354 # First turn
355 mock_output1 = create_mock_request_output(
@@ -360,15 +385,15 @@ async def test_negative_tool_tokens_edge_case():
385
386
387 @pytest.mark.asyncio
363 -async def test_streaming_multi_turn_token_counting(mock_parser):
388 +async def test_streaming_multi_turn_token_counting():
389 """Test token counting for streaming multi-turn conversations.
390
366 - This test focuses on how StreamingHarmonyContext counts tokens in a
391 + This test focuses on how HarmonyContext counts tokens in a
392 multi-turn conversation with streaming (token-by-token) outputs and
393 message boundaries.
394 """
395 # Create a streaming context
371 - context = StreamingHarmonyContext(messages=[], available_tools=["browser"])
396 + context, parser = make_harmony_context(available_tools=["browser"])
397
398 num_prompt_tokens = [3, 8, 13]
399 num_output_tokens = [3, 3, 2]
@@ -413,10 +438,8 @@ async def test_streaming_multi_turn_token_counting(mock_parser):
438 assert context.num_tool_output_tokens == 0 # No tool output in first turn
439 assert context.first_tok_of_message is True # Ready for next message
440
416 - # Second turn: reasoning tokens in analysis channel
417 - mock_parser.current_channel = "analysis" # Set to reasoning channel
418 -
441 # First token of second turn
442 + parser.enqueue_chunk_result(reasoning_token_count=1)
443 context.append_output(
444 create_mock_request_output(
445 prompt_token_ids=[
@@ -436,6 +459,7 @@ async def test_streaming_multi_turn_token_counting(mock_parser):
459 )
460
461 # More tokens in reasoning channel
462 + parser.enqueue_chunk_result(reasoning_token_count=1)
463 context.append_output(
464 create_mock_request_output(
465 output_token_ids=[202],
@@ -443,6 +467,7 @@ async def test_streaming_multi_turn_token_counting(mock_parser):
467 )
468 )
469
470 + parser.enqueue_chunk_result(reasoning_token_count=1)
471 context.append_output(
472 create_mock_request_output(
473 output_token_ids=[203],
@@ -460,9 +485,6 @@ async def test_streaming_multi_turn_token_counting(mock_parser):
485 expected_tool_tokens = 8 - 3 - 3 # = 2
486 assert context.num_tool_output_tokens == expected_tool_tokens
487
463 - # Third turn: regular output channel
464 - mock_parser.current_channel = "final" # Switch back to regular channel
465 -
488 # Third turn (with more cached tokens)
489 context.append_output(
490 create_mock_request_output(
@@ -520,13 +542,8 @@ async def test_streaming_multi_turn_token_counting(mock_parser):
542
543
544 @pytest.mark.asyncio
523 -async def test_streaming_message_synchronization(mock_parser):
524 - """Test message synchronization logic from lines 413-417 in context.py.
525 -
526 - This test verifies that when parser.messages contains more messages than
527 - the context's _messages (minus initial messages), the context properly
528 - extends its message list with the new parser messages.
529 - """
545 +async def test_streaming_message_synchronization():
546 + """Completed messages from append-local and flush segments sync into context."""
547
548 # Create a streaming context with some initial messages
549 initial_messages = [
@@ -536,23 +553,30 @@ async def test_streaming_message_synchronization(mock_parser):
553 recipient=Role.ASSISTANT,
554 )
555 ]
539 - context = StreamingHarmonyContext(messages=initial_messages, available_tools=[])
556 + context, parser = make_harmony_context(messages=initial_messages)
557
558 # Verify initial state
559 assert len(context._messages) == 1
560 assert context.num_init_messages == 1
561
545 - # Mock parser to have more messages than context
546 - # Simulate parser having processed 3 new messages
547 - mock_parser.messages = [
548 - Message(
549 - author=Author(role=Role.ASSISTANT, name="assistant"),
550 - content=[TextContent(text="Response 1")],
551 - recipient=Role.USER,
552 - ),
553 - ]
562 + response_text = "First response"
563 + message = Message(
564 + author=Author(role=Role.ASSISTANT, name="assistant"),
565 + content=[TextContent(text=response_text)],
566 + recipient=Role.USER,
567 + )
568 + parser.enqueue_chunk_result(
569 + segments=[
570 + Segment(
571 + channel="commentary",
572 + recipient=None,
573 + delta="",
574 + completed_message=message,
575 + )
576 + ]
577 + )
578
555 - # This should trigger the message synchronization logic
579 + # This should sync the completed message from the latest append
580 context.append_output(
581 create_mock_request_output(
582 prompt_token_ids=[1, 2, 3], output_token_ids=[101], finished=False
@@ -563,36 +587,39 @@ async def test_streaming_message_synchronization(mock_parser):
587 assert len(context._messages) == 2
588
589 # Verify the new messages were added correctly
566 - assert context._messages[1].content[0].text == "Response 1"
590 + assert context._messages[1].content[0].text == response_text
591
568 - # Test the specific condition from line 413-414:
569 - # len(self._messages) - self.num_init_messages < len(self.parser.messages)
592 messages_minus_init = len(context._messages) - context.num_init_messages
571 - parser_messages_count = len(mock_parser.messages)
593 + assert messages_minus_init == 1
594
573 - # After synchronization, they should be equal (no longer less than)
574 - assert messages_minus_init == parser_messages_count
575 -
576 - # Test edge case: add one more parser message
577 - mock_parser.messages.append(
578 - Message(
579 - author=Author(role=Role.ASSISTANT, name="assistant"),
580 - content=[TextContent(text="Response 4")],
581 - recipient=Role.USER,
582 - )
595 + response_text = "Second response"
596 + message = Message(
597 + author=Author(role=Role.ASSISTANT, name="assistant"),
598 + content=[TextContent(text=response_text)],
599 + recipient=Role.USER,
600 )
584 -
585 - # Create another output to trigger synchronization again
586 - mock_output2 = create_mock_request_output(
587 - prompt_token_ids=[1, 2, 3], output_token_ids=[102], finished=True
601 + flush_segment = Segment(
602 + channel="commentary",
603 + recipient=None,
604 + delta="",
605 + completed_message=message,
606 )
607 + parser.enqueue_flush_result(flush_segment)
608
590 - context.append_output(mock_output2)
609 + # Create another output to trigger synchronization via flush()
610 + context.append_output(
611 + create_mock_request_output(
612 + prompt_token_ids=[1, 2, 3], output_token_ids=[102], finished=True
613 + )
614 + )
615
592 - # Verify the fourth message was added, num_init_messages is still 1
616 + # Verify the flushed response was added, num_init_messages is still 1
617 assert len(context._messages) == 3
618 assert context.num_init_messages == 1
595 - assert context._messages[2].content[0].text == "Response 4"
619 + assert context._messages[2].content[0].text == response_text
620 + assert context.last_append_flush_status is True
621 + assert len(context.last_append_segments) == 1
622 + assert context.last_append_segments[0].completed_message is message
623
624
625 def test_turn_metrics_copy_and_reset():
vllm/entrypoints/openai/responses/context.py
+37 -120
@@ -18,7 +18,7 @@ from openai.types.responses.response_output_item import McpCall
18 from openai.types.responses.response_output_message import ResponseOutputMessage
19 from openai.types.responses.response_output_text import ResponseOutputText
20 from openai.types.responses.tool import Mcp
21 -from openai_harmony import Author, Message, Role, StreamState, TextContent
21 +from openai_harmony import Author, HarmonyError, Message, Role, TextContent
22
23 from vllm import envs
24 from vllm.entrypoints.chat_utils import (
@@ -29,11 +29,7 @@ from vllm.entrypoints.mcp.tool_server import ToolServer
29 from vllm.entrypoints.openai.engine.protocol import (
30 FunctionCall,
31 )
32 -from vllm.entrypoints.openai.parser.harmony_utils import (
33 - get_encoding,
34 - get_streamable_parser_for_assistant,
35 - render_for_completion,
36 -)
32 +from vllm.entrypoints.openai.parser.harmony_utils import render_for_completion
33 from vllm.entrypoints.openai.responses.protocol import (
34 ResponseInputOutputItem,
35 ResponseRawMessageAndToken,
@@ -597,18 +593,21 @@ class HarmonyContext(ConversationContext):
593 self,
594 messages: list,
595 available_tools: list[str],
600 - function_tool_names: frozenset[str] | None = None,
596 + function_tool_names: frozenset[str],
597 response_parser: Parser | None = None,
598 ):
599 + from vllm.parser.harmony import HarmonyParser, Segment
600 +
601 + assert isinstance(response_parser, HarmonyParser)
602 +
603 self._messages = messages
604 - self.response_parser = response_parser
604 + self.response_parser: HarmonyParser = response_parser
605 self.finish_reason: str | None = None
606 self.available_tools = available_tools
607 self.function_tool_names = function_tool_names
608 self._tool_sessions: dict[str, ClientSession | Tool] = {}
609 self.called_tools: set[str] = set()
610
611 - self.parser = get_streamable_parser_for_assistant()
611 self.num_init_messages = len(messages)
612 self.num_prompt_tokens = 0
613 self.num_output_tokens = 0
@@ -616,44 +615,47 @@ class HarmonyContext(ConversationContext):
615 self.num_reasoning_tokens = 0
616 self.num_tool_output_tokens = 0
617
618 + self.last_append_segments: list[Segment] = []
619 + self.last_append_flush_status: bool | HarmonyError = False
620 +
621 # Turn tracking - replaces multiple individual tracking variables
622 self.current_turn_metrics = TurnMetrics()
623 # Track metrics for all turns
624 self.all_turn_metrics: list[TurnMetrics] = []
625 self.is_first_turn = True
624 - self.first_tok_of_message = True # For streaming support
626 + self.first_tok_of_message = True
627 self.kv_transfer_params: dict[str, Any] | None = None
628
627 - def _update_num_reasoning_tokens(self):
628 - channel = self.parser.current_channel
629 - if channel == "analysis":
630 - self.num_reasoning_tokens += 1
631 - elif channel == "commentary" and self.parser.current_recipient is not None:
632 - # Tool interactions (python/browser/container) are hidden.
633 - # Preambles (recipient=None) are visible user text.
634 - self.num_reasoning_tokens += 1
635 -
629 def append_output(self, output: RequestOutput) -> None:
630 + if self.first_tok_of_message:
631 + self.finish_reason = None
632 + self._update_prefill_token_usage(output)
633 +
634 output_token_ids = output.outputs[0].token_ids
638 - self.parser = get_streamable_parser_for_assistant()
639 - for token_id in output_token_ids:
640 - self.parser.process(token_id)
641 - # Check if the current token is part of reasoning content
642 - self._update_num_reasoning_tokens()
643 - self._update_prefill_token_usage(output)
635 + result = self.response_parser.process_chunk(output_token_ids)
636 + segments = result.segments
637 + self.num_reasoning_tokens += result.reasoning_token_count
638 +
639 + self.first_tok_of_message = output.finished
640 self._update_decode_token_usage(output)
641 if output.kv_transfer_params is not None:
642 self.kv_transfer_params = output.kv_transfer_params
647 - # Append current turn to all turn list for next turn's calculations
648 - self.all_turn_metrics.append(self.current_turn_metrics.copy())
649 - self.current_turn_metrics.reset()
650 - # append_output is called only once before tool calling
651 - # in non-streaming case
652 - # so we can append all the parser messages to _messages
653 - output_msgs = self.parser.messages
654 - # The responses finish reason is set in the last message
655 - self.finish_reason = output.outputs[0].finish_reason
656 - self._messages.extend(output_msgs)
643 +
644 + if output.finished:
645 + self.finish_reason = output.outputs[0].finish_reason
646 + flushed = self.response_parser.flush()
647 + if flushed is not None:
648 + segments.append(flushed)
649 + self.last_append_flush_status = flushed is not None
650 + self.all_turn_metrics.append(self.current_turn_metrics.copy())
651 + self.current_turn_metrics.reset()
652 +
653 + self.last_append_segments = segments
654 + self._messages.extend(
655 + segment.completed_message
656 + for segment in segments
657 + if segment.completed_message is not None
658 + )
659
660 def append_tool_output(self, output: list[Message]) -> None:
661 output_msgs = output
@@ -920,88 +922,3 @@ class HarmonyContext(ConversationContext):
922 for tool in self.called_tools
923 )
924 )
923 -
924 -
925 -class StreamingHarmonyContext(HarmonyContext):
926 - def __init__(self, *args, **kwargs):
927 - super().__init__(*args, **kwargs)
928 - self.last_output = None
929 -
930 - self.parser = get_streamable_parser_for_assistant()
931 - self.encoding = get_encoding()
932 - self.last_tok = None
933 - self.first_tok_of_message = True
934 - self.last_content_delta = None
935 -
936 - @property
937 - def messages(self) -> list:
938 - return self._messages
939 -
940 - def append_output(self, output: RequestOutput) -> None:
941 - # append_output is called for each output token in streaming case,
942 - # so we only want to add the prompt tokens once for each message.
943 - self.last_content_delta = None
944 - if self.first_tok_of_message:
945 - self._update_prefill_token_usage(output)
946 - # Reset self.first_tok_of_message if needed:
947 - # if the current token is the last one of the current message
948 - # (finished=True), then the next token processed will mark the
949 - # beginning of a new message
950 - self.first_tok_of_message = output.finished
951 - last_delta_text = ""
952 - for tok in output.outputs[0].token_ids:
953 - self.parser.process(tok)
954 - last_delta_text += self.parser.last_content_delta or ""
955 - if last_delta_text:
956 - self.last_content_delta = last_delta_text
957 - self._update_decode_token_usage(output)
958 - if output.kv_transfer_params is not None:
959 - self.kv_transfer_params = output.kv_transfer_params
960 -
961 - # For streaming, update previous turn when message is complete
962 - if output.finished:
963 - self.all_turn_metrics.append(self.current_turn_metrics.copy())
964 - self.current_turn_metrics.reset()
965 - # Check if the current token is part of reasoning content
966 - self._update_num_reasoning_tokens()
967 - self.last_tok = tok
968 - if len(self._messages) - self.num_init_messages < len(self.parser.messages):
969 - self._messages.extend(
970 - self.parser.messages[len(self._messages) - self.num_init_messages :]
971 - )
972 -
973 - def append_tool_output(self, output: list[Message]) -> None:
974 - # Handle the case of tool output in direct message format
975 - assert len(output) == 1, "Tool output should be a single message"
976 - msg = output[0]
977 - # Sometimes the recipient is not set for tool messages,
978 - # so we set it to "assistant"
979 - if msg.author.role == Role.TOOL and msg.recipient is None:
980 - msg.recipient = "assistant"
981 - toks = self.encoding.render(msg)
982 - for tok in toks:
983 - self.parser.process(tok)
984 - self.last_tok = toks[-1]
985 - # TODO: add tool_output messages to self._messages
986 -
987 - def is_expecting_start(self) -> bool:
988 - return self.parser.state == StreamState.EXPECT_START
989 -
990 - def is_assistant_action_turn(self) -> bool:
991 - return self.last_tok in self.encoding.stop_tokens_for_assistant_actions()
992 -
993 - def render_for_completion(self) -> list[int]:
994 - # now this list of tokens as next turn's starting tokens
995 - # `<|start|>assistant`,
996 - # we need to process them in parser.
997 - rendered_tokens = super().render_for_completion()
998 -
999 - last_n = -1
1000 - to_process = []
1001 - while rendered_tokens[last_n] != self.last_tok:
1002 - to_process.append(rendered_tokens[last_n])
1003 - last_n -= 1
1004 - for tok in reversed(to_process):
1005 - self.parser.process(tok)
1006 -
1007 - return rendered_tokens
vllm/entrypoints/openai/responses/harmony.py
+26 -128
@@ -27,7 +27,7 @@ from openai.types.responses.response_output_item import McpCall
27 from openai.types.responses.response_reasoning_item import (
28 Content as ResponseReasoningTextContent,
29 )
30 -from openai_harmony import Author, Message, Role, StreamableParser, TextContent
30 +from openai_harmony import Author, Message, Role, TextContent
31
32 from vllm.entrypoints.openai.parser.harmony_utils import (
33 BUILTIN_TOOL_TO_MCP_SERVER_LABEL,
@@ -306,7 +306,9 @@ def _parse_browser_tool_call(message: Message, recipient: str) -> ResponseOutput
306 )
307
308
309 -def _parse_function_call(message: Message, recipient: str) -> list[ResponseOutputItem]:
309 +def _parse_function_call(
310 + message: Message, recipient: str, incomplete: bool = False
311 +) -> list[ResponseOutputItem]:
312 """Parse function calls into function tool call items."""
313 function_name = extract_function_from_recipient(recipient)
314 output_items = []
@@ -318,7 +320,7 @@ def _parse_function_call(message: Message, recipient: str) -> list[ResponseOutpu
320 type="function_call",
321 name=function_name,
322 id=f"fc_{random_id}",
321 - status="completed",
323 + status="incomplete" if incomplete else "completed",
324 )
325 output_items.append(response_item)
326 return output_items
@@ -341,7 +343,9 @@ def _parse_reasoning(message: Message) -> list[ResponseOutputItem]:
343 return output_items
344
345
344 -def _parse_final_message(message: Message) -> ResponseOutputItem:
346 +def _parse_final_message(
347 + message: Message, incomplete: bool = False
348 +) -> ResponseOutputItem:
349 """Parse final channel messages into output message items."""
350 contents = []
351 for content in message.content:
@@ -356,7 +360,7 @@ def _parse_final_message(message: Message) -> ResponseOutputItem:
360 id=f"msg_{random_uuid()}",
361 content=contents,
362 role=message.author.role,
359 - status="completed",
363 + status="incomplete" if incomplete else "completed",
364 type="message",
365 )
366
@@ -381,7 +385,9 @@ def _parse_mcp_recipient(recipient: str) -> tuple[str, str]:
385 return server_label, tool_name
386
387
384 -def _parse_mcp_call(message: Message, recipient: str) -> list[ResponseOutputItem]:
388 +def _parse_mcp_call(
389 + message: Message, recipient: str, incomplete: bool = False
390 +) -> list[ResponseOutputItem]:
391 """Parse MCP calls into MCP call items."""
392 # Handle built-in tools that need server_label mapping
393 if recipient in BUILTIN_TOOL_TO_MCP_SERVER_LABEL:
@@ -398,7 +404,7 @@ def _parse_mcp_call(message: Message, recipient: str) -> list[ResponseOutputItem
404 name=tool_name,
405 server_label=server_label,
406 id=f"mcp_{random_uuid()}",
401 - status="completed",
407 + status="incomplete" if incomplete else "completed",
408 )
409 output_items.append(response_item)
410 return output_items
@@ -406,6 +412,7 @@ def _parse_mcp_call(message: Message, recipient: str) -> list[ResponseOutputItem
412
413 def _parse_message_no_recipient(
414 message: Message,
415 + incomplete: bool = False,
416 ) -> list[ResponseOutputItem]:
417 """Parse a Harmony message with no recipient based on its channel."""
418 if message.channel == "analysis":
@@ -415,7 +422,7 @@ def _parse_message_no_recipient(
422 # Per Harmony format, preambles (commentary with no recipient) and
423 # final channel content are both intended to be shown to end-users.
424 # See: https://cookbook.openai.com/articles/openai-harmony
418 - return [_parse_final_message(message)]
425 + return [_parse_final_message(message, incomplete=incomplete)]
426
427 raise ValueError(f"Unknown channel: {message.channel}")
428
@@ -427,7 +434,8 @@ def _parse_message_no_recipient(
434
435 def harmony_to_response_output(
436 message: Message,
430 - function_tool_names: frozenset[str] | None = None,
437 + function_tool_names: frozenset[str],
438 + incomplete: bool = False,
439 ) -> list[ResponseOutputItem]:
440 """Parse a Harmony message into a list of output response items.
441
@@ -445,11 +453,14 @@ def harmony_to_response_output(
453 if recipient is not None:
454 # Browser tool calls (browser.search, browser.open, browser.find)
455 if recipient.startswith("browser."):
448 - output_items.append(_parse_browser_tool_call(message, recipient))
456 + if not incomplete:
457 + output_items.append(_parse_browser_tool_call(message, recipient))
458
459 # Function calls (with or without "functions." prefix)
460 elif is_function_recipient(recipient, function_tool_names):
452 - output_items.extend(_parse_function_call(message, recipient))
461 + output_items.extend(
462 + _parse_function_call(message, recipient, incomplete=incomplete)
463 + )
464
465 # Built-in MCP tools (python, browser, container)
466 elif recipient in BUILTIN_TOOL_TO_MCP_SERVER_LABEL:
@@ -457,125 +468,12 @@ def harmony_to_response_output(
468
469 # All other recipients are MCP calls
470 else:
460 - output_items.extend(_parse_mcp_call(message, recipient))
471 + output_items.extend(
472 + _parse_mcp_call(message, recipient, incomplete=incomplete)
473 + )
474
475 # No recipient - handle based on channel for non-tool messages
476 else:
464 - output_items.extend(_parse_message_no_recipient(message))
477 + output_items.extend(_parse_message_no_recipient(message, incomplete=incomplete))
478
479 return output_items
467 -
468 -
469 -def parser_state_to_response_output(
470 - parser: StreamableParser,
471 - function_tool_names: frozenset[str] | None = None,
472 -) -> list[ResponseOutputItem]:
473 - """Extract in-progress response items from incomplete parser state.
474 -
475 - Called when the parser has buffered content that hasn't formed a
476 - complete message yet (e.g., generation was cut short).
477 - """
478 - if not parser.current_content:
479 - return []
480 - if parser.current_role != Role.ASSISTANT:
481 - return []
482 - current_recipient = parser.current_recipient
483 - if current_recipient is not None and current_recipient.startswith("browser."):
484 - return []
485 -
486 - if current_recipient:
487 - if is_function_recipient(current_recipient, function_tool_names):
488 - rid = random_uuid()
489 - return [
490 - ResponseFunctionToolCall(
491 - arguments=parser.current_content,
492 - call_id=f"call_{rid}",
493 - type="function_call",
494 - name=extract_function_from_recipient(current_recipient),
495 - id=f"fc_{rid}",
496 - status="in_progress",
497 - )
498 - ]
499 - # Built-in MCP tools (python, browser, container)
500 - elif current_recipient in BUILTIN_TOOL_TO_MCP_SERVER_LABEL:
501 - return [
502 - ResponseReasoningItem(
503 - id=f"rs_{random_uuid()}",
504 - summary=[],
505 - type="reasoning",
506 - content=[
507 - ResponseReasoningTextContent(
508 - text=parser.current_content, type="reasoning_text"
509 - )
510 - ],
511 - status=None,
512 - )
513 - ]
514 - # All other recipients are MCP calls
515 - else:
516 - rid = random_uuid()
517 - server_label, tool_name = _parse_mcp_recipient(current_recipient)
518 - return [
519 - McpCall(
520 - arguments=parser.current_content,
521 - type="mcp_call",
522 - name=tool_name,
523 - server_label=server_label,
524 - id=f"mcp_{rid}",
525 - status="in_progress",
526 - )
527 - ]
528 -
529 - if parser.current_channel == "commentary":
530 - # Per Harmony format, preambles (commentary with no recipient) are
531 - # intended to be shown to end-users, unlike analysis channel content.
532 - output_text = ResponseOutputText(
533 - text=parser.current_content,
534 - annotations=[],
535 - type="output_text",
536 - logprobs=None,
537 - )
538 - return [
539 - ResponseOutputMessage(
540 - id=f"msg_{random_uuid()}",
541 - content=[output_text],
542 - role="assistant",
543 - status="incomplete",
544 - type="message",
545 - )
546 - ]
547 -
548 - if parser.current_channel == "analysis":
549 - return [
550 - ResponseReasoningItem(
551 - id=f"rs_{random_uuid()}",
552 - summary=[],
553 - type="reasoning",
554 - content=[
555 - ResponseReasoningTextContent(
556 - text=parser.current_content, type="reasoning_text"
557 - )
558 - ],
559 - status=None,
560 - )
561 - ]
562 -
563 - if parser.current_channel == "final":
564 - output_text = ResponseOutputText(
565 - text=parser.current_content,
566 - annotations=[], # TODO
567 - type="output_text",
568 - logprobs=None, # TODO
569 - )
570 - text_item = ResponseOutputMessage(
571 - id=f"msg_{random_uuid()}",
572 - content=[output_text],
573 - role="assistant",
574 - # if the parser still has messages (ie if the generator got cut
575 - # abruptly), this should be incomplete
576 - status="incomplete",
577 - type="message",
578 - )
579 - return [text_item]
580 -
581 - return []
vllm/entrypoints/openai/responses/serving.py
+37 -46
@@ -54,12 +54,10 @@ from vllm.entrypoints.openai.responses.context import (
54 HarmonyContext,
55 ParsableContext,
56 SimpleContext,
57 - StreamingHarmonyContext,
57 )
58 from vllm.entrypoints.openai.responses.harmony import (
59 construct_harmony_previous_input_messages,
60 harmony_to_response_output,
62 - parser_state_to_response_output,
61 response_input_to_harmony,
62 )
63 from vllm.entrypoints.openai.responses.protocol import (
@@ -462,20 +460,12 @@ class OpenAIServingResponses(OpenAIServing):
460 context: ConversationContext
461 function_tool_names = extract_function_tool_names(request.tools)
462 if self.use_harmony:
465 - if request.stream:
466 - context = StreamingHarmonyContext(
467 - messages,
468 - available_tools,
469 - function_tool_names,
470 - response_parser=response_parser,
471 - )
472 - else:
473 - context = HarmonyContext(
474 - messages,
475 - available_tools,
476 - function_tool_names,
477 - response_parser=response_parser,
478 - )
463 + context = HarmonyContext(
464 + messages,
465 + available_tools,
466 + function_tool_names,
467 + response_parser=response_parser,
468 + )
469 else:
470 if envs.VLLM_USE_EXPERIMENTAL_PARSER_CONTEXT:
471 # This is a feature in development for parsing
@@ -718,7 +708,7 @@ class OpenAIServingResponses(OpenAIServing):
708
709 # Create inputs for the next turn.
710 # Render the next prompt token ids and update sampling_params.
721 - if isinstance(context, (HarmonyContext, StreamingHarmonyContext)):
711 + if isinstance(context, HarmonyContext):
712 token_ids = context.render_for_completion()
713 engine_input = tokens_input(token_ids)
714
@@ -814,7 +804,20 @@ class OpenAIServingResponses(OpenAIServing):
804 output_messages: ResponseInputOutputMessage | None = None
805 if self.use_harmony:
806 assert isinstance(context, HarmonyContext)
817 - output = self._make_response_output_items_with_harmony(context)
807 + output = []
808 + harmony_msgs = context.messages[context.num_init_messages :]
809 + if harmony_msgs:
810 + fn_names = context.function_tool_names
811 + for msg in harmony_msgs[:-1]:
812 + output.extend(harmony_to_response_output(msg, fn_names))
813 + output.extend(
814 + harmony_to_response_output(
815 + harmony_msgs[-1],
816 + fn_names,
817 + incomplete=context.last_append_flush_status,
818 + )
819 + )
820 +
821 if request.enable_response_messages:
822 input_messages = context.messages[: context.num_init_messages]
823 output_messages = context.messages[context.num_init_messages :]
@@ -1092,21 +1095,6 @@ class OpenAIServingResponses(OpenAIServing):
1095 )
1096 ]
1097
1095 - def _make_response_output_items_with_harmony(
1096 - self,
1097 - context: HarmonyContext,
1098 - ) -> list[ResponseOutputItem]:
1099 - output_items: list[ResponseOutputItem] = []
1100 - num_init_messages = context.num_init_messages
1101 - fn_names = context.function_tool_names
1102 - for msg in context.messages[num_init_messages:]:
1103 - output_items.extend(harmony_to_response_output(msg, fn_names))
1104 - # Handle the generation stopped in the middle (if any).
1105 - last_items = parser_state_to_response_output(context.parser, fn_names)
1106 - if last_items:
1107 - output_items.extend(last_items)
1108 - return output_items
1109 -
1098 def _get_harmony_builtin_tool_descriptions(
1099 self, request: ResponsesRequest, tool_types: set[str]
1100 ) -> dict[str, str | None]:
@@ -1423,27 +1411,30 @@ class OpenAIServingResponses(OpenAIServing):
1411 state = StreamingState()
1412
1413 async for ctx in result_generator:
1426 - assert isinstance(ctx, StreamingHarmonyContext)
1414 + assert isinstance(ctx, HarmonyContext)
1415
1416 # finish_reason='error' indicates a retryable error
1417 self._raise_if_error(ctx.finish_reason, request.request_id)
1418
1431 - if ctx.is_expecting_start():
1432 - if len(ctx.parser.messages) > 0:
1433 - previous_item = ctx.parser.messages[-1]
1434 - for event in emit_previous_item_done_events(
1435 - previous_item, state, ctx.function_tool_names
1419 + for segment in ctx.last_append_segments:
1420 + if segment.delta:
1421 + for event in emit_content_delta_events(
1422 + segment, state, ctx.function_tool_names
1423 ):
1424 yield _increment_sequence_number_and_return(event)
1438 - state.reset_for_new_item()
1425
1440 - # Stream the output of a harmony message
1441 - for event in emit_content_delta_events(ctx, state):
1442 - yield _increment_sequence_number_and_return(event)
1426 + elif completed_message := segment.completed_message:
1427 + # TODO: Fix browser emitted as MCP calls
1428 + for event in emit_previous_item_done_events(
1429 + completed_message, state, ctx.function_tool_names
1430 + ):
1431 + yield _increment_sequence_number_and_return(event)
1432
1444 - # Stream tool call outputs
1445 - for event in emit_tool_action_events(ctx, state, self.tool_server):
1446 - yield _increment_sequence_number_and_return(event)
1433 + for event in emit_tool_action_events(
1434 + completed_message, state, self.tool_server
1435 + ):
1436 + yield _increment_sequence_number_and_return(event)
1437 + state.reset_for_new_item()
1438
1439 async def responses_stream_generator(
1440 self,
vllm/entrypoints/openai/responses/streaming_events.py
+22 -39
@@ -66,13 +66,13 @@ from vllm.entrypoints.openai.parser.harmony_utils import (
66 extract_function_from_recipient,
67 is_function_recipient,
68 )
69 -from vllm.entrypoints.openai.responses.context import StreamingHarmonyContext
69 from vllm.entrypoints.openai.responses.protocol import (
70 ResponseReasoningPartAddedEvent,
71 ResponseReasoningPartDoneEvent,
72 StreamingResponsesResponse,
73 )
74 from vllm.outputs import CompletionOutput
75 +from vllm.parser.harmony import Segment
76 from vllm.utils import random_uuid
77
78 TOOL_NAME_TO_MCP_SERVER_LABEL: Final[dict[str, str]] = {
@@ -110,6 +110,7 @@ class StreamingState:
110 def reset_for_new_item(self) -> None:
111 """Reset state when expecting a new output item."""
112 self.current_output_index += 1
113 + self.current_content_index = -1
114 self.sent_output_item_added = False
115 self.is_first_function_call_delta = False
116 self.current_call_id = ""
@@ -558,20 +559,21 @@ def emit_mcp_completion_events(
559
560
561 def emit_content_delta_events(
561 - ctx: StreamingHarmonyContext,
562 + segment: Segment,
563 state: StreamingState,
564 + function_tool_names: frozenset[str] | None = None,
565 ) -> list[StreamingResponsesResponse]:
566 """Emit events for content delta streaming based on channel type.
567
568 This is a Harmony-specific dispatcher that extracts values from the
567 - Harmony context and delegates to shared leaf helpers.
569 + latest append segment and delegates to shared leaf helpers.
570 """
569 - delta = ctx.last_content_delta
571 + delta = segment.delta
572 if not delta:
573 return []
574
573 - channel = ctx.parser.current_channel
574 - recipient = ctx.parser.current_recipient
575 + channel = segment.channel
576 + recipient = segment.recipient
577
578 if channel in ("final", "commentary") and recipient is None:
579 # Preambles (commentary with no recipient) and final messages
@@ -580,7 +582,7 @@ def emit_content_delta_events(
582 elif channel == "analysis" and recipient is None:
583 return emit_reasoning_delta_events(delta, state)
584 elif recipient is not None:
583 - fn_names = ctx.function_tool_names
585 + fn_names = function_tool_names
586 if is_function_recipient(recipient, fn_names):
587 function_name = extract_function_from_recipient(recipient)
588 return emit_function_call_delta_events(delta, function_name, state)
@@ -604,6 +606,12 @@ def emit_previous_item_done_events(
606 This is a Harmony-specific dispatcher that extracts values from the
607 Harmony parser's message object and delegates to shared leaf helpers.
608 """
609 + if not state.sent_output_item_added and not state.is_first_function_call_delta:
610 + # Suppress done events for items had no delta and thus had no
611 + # added/in-progress lifecycle events. This is a bug.
612 + # TODO: Ensure added/in-progress events are emitted for zero-delta items.
613 + return []
614 +
615 text = previous_item.content[0].text
616 if previous_item.recipient is not None:
617 # Deal with tool call
@@ -769,47 +777,22 @@ def emit_code_interpreter_completion_events(
777
778
779 def emit_tool_action_events(
772 - ctx: StreamingHarmonyContext,
780 + previous_item: HarmonyMessage,
781 state: StreamingState,
782 tool_server: ToolServer | None,
783 ) -> list[StreamingResponsesResponse]:
776 - """Emit events for tool action turn."""
777 - if not ctx.is_assistant_action_turn() or len(ctx.parser.messages) == 0:
778 - return []
779 -
780 - events: list[StreamingResponsesResponse] = []
781 - previous_item = ctx.parser.messages[-1]
782 -
784 + """Emit events for a completed assistant action turn."""
785 # Handle browser tool
786 if (
785 - tool_server is not None
786 - and tool_server.has_tool("browser")
787 + previous_item.author.role == "assistant"
788 and previous_item.recipient is not None
789 and previous_item.recipient.startswith("browser.")
790 + and tool_server is not None
791 + and tool_server.has_tool("browser")
792 ):
790 - events.extend(emit_browser_tool_events(previous_item, state))
791 -
792 - # Handle tool completion
793 - if (
794 - tool_server is not None
795 - and previous_item.recipient is not None
796 - and state.current_item_id is not None
797 - and state.sent_output_item_added
798 - ):
799 - recipient = previous_item.recipient
800 - fn_names = ctx.function_tool_names
801 - if recipient == "python":
802 - events.extend(emit_code_interpreter_completion_events(previous_item, state))
803 - elif recipient.startswith("mcp.") or is_mcp_tool_by_namespace(
804 - recipient, fn_names
805 - ):
806 - events.extend(
807 - emit_mcp_completion_events(
808 - recipient, previous_item.content[0].text, state
809 - )
810 - )
793 + return emit_browser_tool_events(previous_item, state)
794
812 - return events
795 + return []
796
797
798 # =====================================================================