diff --git a/python/packages/openai/agent_framework_openai/_chat_client.py b/python/packages/openai/agent_framework_openai/_chat_client.py index fdb8c3d8ea..ad3c514a37 100644 --- a/python/packages/openai/agent_framework_openai/_chat_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_client.py @@ -4520,6 +4520,55 @@ def parse_annotations( seen_function_call_output_ids, done_item ): contents.append(self._parse_function_call_output_content(output_item, done_call_id)) + elif getattr(done_item, "type", None) == "code_interpreter_call": + # The completed item is the only place `outputs` (logs/images) are ever + # delivered during streaming - there is no incremental outputs event for them - + # and its `code` is the authoritative final value, so both are tagged `replace` + # even though `code_interpreter_call_code.done` (above) usually already set the code. + call_id = getattr(done_item, "call_id", None) or getattr(done_item, "id", None) + outputs: list[Content] = [] + if done_item_outputs := getattr(done_item, "outputs", None): + for code_output in done_item_outputs: + if getattr(code_output, "type", None) == "logs": + outputs.append( + Content.from_text( + text=code_output.logs, + raw_representation=code_output, + ) + ) + elif getattr(code_output, "type", None) == "image": + outputs.append( + Content.from_uri( + uri=code_output.url, + raw_representation=code_output, + media_type="image", + ) + ) + if done_item_code := getattr(done_item, "code", None): + contents.append( + Content.from_code_interpreter_tool_call( + call_id=call_id, + inputs=[ + Content.from_text( + text=done_item_code, + merge_mode="replace", + raw_representation=done_item, + ) + ], + status=_provider_status(getattr(done_item, "status", None)), + merge_mode="replace", + raw_representation=done_item, + ) + ) + contents.append( + Content.from_code_interpreter_tool_result( + call_id=call_id, + outputs=outputs, + status=_provider_status(getattr(done_item, "status", None)), + merge_mode="replace", + raw_representation=done_item, + ) + ) elif getattr(done_item, "type", None) == _AZURE_AI_SEARCH_CALL_OUTPUT_TYPE: pass case _: diff --git a/python/packages/openai/tests/openai/test_openai_chat_client.py b/python/packages/openai/tests/openai/test_openai_chat_client.py index ca9870e1ae..4429b33e52 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_client.py @@ -8304,6 +8304,128 @@ def test_parse_chunk_from_openai_code_interpreter_done() -> None: assert result.contents[0].inputs[0].merge_mode == "replace" +def test_parse_chunk_from_openai_code_interpreter_output_item_done() -> None: + """Test _parse_chunk_from_openai with a completed code_interpreter_call output item. + + `response.output_item.done` is the only place `outputs` (logs/images) are ever delivered + for a streamed code interpreter call - there is no incremental outputs event - so this + completed item must be parsed, and its `code` must carry `merge_mode="replace"` like + `code_interpreter_call_code.done`'s does, since both can carry the same final code. + """ + client = OpenAIChatClient(model="test-model", api_key="test-key") + chat_options: dict[str, Any] = {} + function_call_ids: dict[int, tuple[str, str]] = {} + + mock_log_output = MagicMock() + mock_log_output.type = "logs" + mock_log_output.logs = "hello" + mock_image_output = MagicMock() + mock_image_output.type = "image" + mock_image_output.url = "https://example.com/plot.png" + + mock_done_event = MagicMock() + mock_done_event.type = "response.output_item.done" + mock_done_item = MagicMock() + mock_done_item.type = "code_interpreter_call" + mock_done_item.call_id = "ci_789" + mock_done_item.id = "ci_789" + mock_done_item.code = "print('hello')" + mock_done_item.outputs = [mock_log_output, mock_image_output] + mock_done_item.status = "completed" + mock_done_event.item = mock_done_item + + result = client._parse_chunk_from_openai(mock_done_event, chat_options, function_call_ids) + call_contents = [c for c in result.contents if c.type == "code_interpreter_tool_call"] + result_contents = [c for c in result.contents if c.type == "code_interpreter_tool_result"] + assert len(call_contents) == 1 + assert call_contents[0].call_id == "ci_789" + assert call_contents[0].inputs + assert call_contents[0].inputs[0].text == "print('hello')" + assert call_contents[0].merge_mode == "replace" + assert call_contents[0].inputs[0].merge_mode == "replace" + assert len(result_contents) == 1 + assert result_contents[0].call_id == "ci_789" + assert result_contents[0].merge_mode == "replace" + assert result_contents[0].outputs + assert any(out.type == "text" and out.text == "hello" for out in result_contents[0].outputs) + assert any( + out.type == "uri" and out.uri == "https://example.com/plot.png" and out.media_type == "image" + for out in result_contents[0].outputs + ) + + +def test_code_interpreter_streaming_sequence_does_not_duplicate_code_and_captures_outputs() -> None: + """A realistic added/delta/done/output_item.done sequence must not duplicate code. + + `code_interpreter_call_code.done` and `response.output_item.done` can both carry the + same final code for one call; without both carrying `merge_mode="replace"`, the second + one to arrive would concatenate onto the first instead of being recognized as a repeat + of the same authoritative value. + """ + client = OpenAIChatClient(model="test-model", api_key="test-key") + chat_options: dict[str, Any] = {} + function_call_ids: dict[int, tuple[str, str]] = {} + updates = [] + + added_event = MagicMock() + added_event.type = "response.output_item.added" + added_item = MagicMock() + added_item.type = "code_interpreter_call" + added_item.call_id = "ci_1" + added_item.id = "ci_1" + added_item.code = None + added_item.outputs = None + added_item.status = "in_progress" + added_event.item = added_item + updates.append(client._parse_chunk_from_openai(added_event, chat_options, function_call_ids)) + + for index, delta in enumerate(["import pandas", " as pd"]): + delta_event = MagicMock() + delta_event.type = "response.code_interpreter_call_code.delta" + delta_event.item_id = "ci_1" + delta_event.delta = delta + delta_event.output_index = 0 + delta_event.sequence_number = index + 1 + delta_event.call_id = None + delta_event.id = None + updates.append(client._parse_chunk_from_openai(delta_event, chat_options, function_call_ids)) + + done_code_event = MagicMock() + done_code_event.type = "response.code_interpreter_call_code.done" + done_code_event.item_id = "ci_1" + done_code_event.code = "import pandas as pd" + done_code_event.output_index = 0 + done_code_event.sequence_number = 3 + done_code_event.call_id = None + done_code_event.id = None + updates.append(client._parse_chunk_from_openai(done_code_event, chat_options, function_call_ids)) + + mock_log_output = MagicMock() + mock_log_output.type = "logs" + mock_log_output.logs = "pandas 2.1.0" + done_item_event = MagicMock() + done_item_event.type = "response.output_item.done" + done_item = MagicMock() + done_item.type = "code_interpreter_call" + done_item.call_id = "ci_1" + done_item.id = "ci_1" + done_item.code = "import pandas as pd" + done_item.outputs = [mock_log_output] + done_item.status = "completed" + done_item_event.item = done_item + updates.append(client._parse_chunk_from_openai(done_item_event, chat_options, function_call_ids)) + + response = ChatResponse.from_updates(updates) + calls = [c for c in response.messages[0].contents if c.type == "code_interpreter_tool_call"] + results = [c for c in response.messages[0].contents if c.type == "code_interpreter_tool_result"] + assert len(calls) == 1 + assert calls[0].inputs + assert "".join(item.text or "" for item in calls[0].inputs) == "import pandas as pd" + assert len(results) == 1 + assert results[0].outputs + assert any(out.type == "text" and out.text == "pandas 2.1.0" for out in results[0].outputs) + + def test_parse_chunk_from_openai_reasoning() -> None: """Test _parse_chunk_from_openai with reasoning content.""" client = OpenAIChatClient(model="test-model", api_key="test-key")