Files
mlflow--mlflow/tests/pyfunc/test_responses_agent.py
2026-07-13 13:22:34 +08:00

1491 lines
55 KiB
Python

import functools
import pathlib
import pickle
from typing import Generator
from uuid import uuid4
import pytest
import mlflow
from mlflow.entities.span import SpanType
from mlflow.exceptions import MlflowException
from mlflow.models.signature import ModelSignature
from mlflow.pyfunc.loaders.responses_agent import _ResponsesAgentPyfuncWrapper
from mlflow.pyfunc.model import _DEFAULT_RESPONSES_AGENT_METADATA_TASK, ResponsesAgent
from mlflow.types.responses import (
_HAS_LANGCHAIN_BASE_MESSAGE,
RESPONSES_AGENT_INPUT_EXAMPLE,
RESPONSES_AGENT_INPUT_SCHEMA,
RESPONSES_AGENT_OUTPUT_SCHEMA,
ResponsesAgentRequest,
ResponsesAgentResponse,
ResponsesAgentStreamEvent,
output_to_responses_items_stream,
)
from tests.tracing.helper import get_traces, purge_traces
if _HAS_LANGCHAIN_BASE_MESSAGE:
pass
from mlflow.types.schema import ColSpec, DataType, Schema
def get_mock_response(request: ResponsesAgentRequest):
return {
"output": [
{
"type": "message",
"id": str(uuid4()),
"status": "completed",
"role": "assistant",
"content": [
{
"type": "output_text",
"text": request.input[0].content,
}
],
}
],
}
def get_stream_mock_response():
yield from [
{
"type": "response.output_item.added",
"output_index": 0,
"item": {
"type": "message",
"id": "1",
"status": "in_progress",
"role": "assistant",
"content": [],
},
},
{
"type": "response.content_part.added",
"item_id": "1",
"output_index": 0,
"content_index": 0,
"part": {"type": "output_text", "text": "", "annotations": []},
},
{
"type": "response.output_text.delta",
"item_id": "1",
"output_index": 0,
"content_index": 0,
"delta": "Deb",
},
{
"type": "response.output_text.delta",
"item_id": "1",
"output_index": 0,
"content_index": 0,
"delta": "rid",
},
{
"type": "response.output_text.done",
"item_id": "1",
"output_index": 0,
"content_index": 0,
"text": "Debrid",
},
{
"type": "response.content_part.done",
"item_id": "1",
"output_index": 0,
"content_index": 0,
"part": {
"type": "output_text",
"text": "Debrid",
"annotations": [],
},
},
]
class SimpleResponsesAgent(ResponsesAgent):
def predict(self, request: ResponsesAgentRequest) -> ResponsesAgentResponse:
mock_response = get_mock_response(request)
return ResponsesAgentResponse(**mock_response)
def predict_stream(
self, request: ResponsesAgentRequest
) -> Generator[ResponsesAgentStreamEvent, None, None]:
yield from [ResponsesAgentStreamEvent(**r) for r in get_stream_mock_response()]
class ResponsesAgentWithContext(ResponsesAgent):
def load_context(self, context):
predict_path = pathlib.Path(context.artifacts["predict_fn"])
self.predict_fn = pickle.loads(predict_path.read_bytes())
def predict(self, request: ResponsesAgentRequest) -> ResponsesAgentResponse:
return ResponsesAgentResponse(
output=[
{
"type": "message",
"id": "test-id",
"status": "completed",
"role": "assistant",
"content": [
{
"type": "output_text",
"text": self.predict_fn(),
}
],
}
]
)
def predict_stream(
self, request: ResponsesAgentRequest
) -> Generator[ResponsesAgentStreamEvent, None, None]:
yield ResponsesAgentStreamEvent(
type="response.output_item.added",
output_index=0,
item=self.create_text_output_item(self.predict_fn(), "test-id"),
)
def mock_responses_predict():
return "hello from context"
def test_responses_agent_with_context(tmp_path):
predict_path = tmp_path / "predict.pkl"
predict_path.write_bytes(pickle.dumps(mock_responses_predict))
model = ResponsesAgentWithContext()
with mlflow.start_run():
model_info = mlflow.pyfunc.log_model(
name="model",
python_model=model,
artifacts={"predict_fn": str(predict_path)},
)
loaded_model = mlflow.pyfunc.load_model(model_info.model_uri)
# Test predict
response = loaded_model.predict(RESPONSES_AGENT_INPUT_EXAMPLE)
assert response["output"][0]["content"][0]["text"] == "hello from context"
# Test predict_stream
responses = list(loaded_model.predict_stream(RESPONSES_AGENT_INPUT_EXAMPLE))
assert len(responses) == 1
assert responses[0]["item"]["content"][0]["text"] == "hello from context"
def test_responses_agent_save_load_signatures(tmp_path):
model = SimpleResponsesAgent()
mlflow.pyfunc.save_model(python_model=model, path=tmp_path)
loaded_model = mlflow.pyfunc.load_model(tmp_path)
assert isinstance(loaded_model._model_impl, _ResponsesAgentPyfuncWrapper)
input_schema = loaded_model.metadata.get_input_schema()
output_schema = loaded_model.metadata.get_output_schema()
assert input_schema == RESPONSES_AGENT_INPUT_SCHEMA
assert output_schema == RESPONSES_AGENT_OUTPUT_SCHEMA
def test_responses_agent_log_default_task():
model = SimpleResponsesAgent()
with mlflow.start_run():
model_info = mlflow.pyfunc.log_model(name="model", python_model=model)
assert model_info.metadata["task"] == _DEFAULT_RESPONSES_AGENT_METADATA_TASK
with mlflow.start_run():
model_info_with_override = mlflow.pyfunc.log_model(
name="model", python_model=model, metadata={"task": None}
)
assert model_info_with_override.metadata["task"] is None
def test_responses_agent_predict(tmp_path):
model_path = tmp_path / "model"
model = SimpleResponsesAgent()
response = model.predict(RESPONSES_AGENT_INPUT_EXAMPLE)
assert response.output[0].content[0]["type"] == "output_text"
response = model.predict_stream(RESPONSES_AGENT_INPUT_EXAMPLE)
assert next(response).type == "response.output_item.added"
mlflow.pyfunc.save_model(python_model=model, path=model_path)
loaded_model = mlflow.pyfunc.load_model(model_path)
response = loaded_model.predict(RESPONSES_AGENT_INPUT_EXAMPLE)
assert response["output"][0]["type"] == "message"
assert response["output"][0]["content"][0]["type"] == "output_text"
assert response["output"][0]["content"][0]["text"] == "Hello!"
def test_responses_agent_predict_stream(tmp_path):
model_path = tmp_path / "model"
model = SimpleResponsesAgent()
mlflow.pyfunc.save_model(python_model=model, path=model_path)
loaded_model = mlflow.pyfunc.load_model(model_path)
responses = list(loaded_model.predict_stream(RESPONSES_AGENT_INPUT_EXAMPLE))
# most of this test is that the predict_stream parsing works in _ResponsesAgentPyfuncWrapper
for r in responses:
assert "type" in r
def test_responses_agent_with_pydantic_input():
model = SimpleResponsesAgent()
response = model.predict(ResponsesAgentRequest(**RESPONSES_AGENT_INPUT_EXAMPLE))
assert response.output[0].content[0]["text"] == "Hello!"
class CustomInputsResponsesAgent(ResponsesAgent):
def predict(self, request: ResponsesAgentRequest) -> ResponsesAgentResponse:
mock_response = get_mock_response(request)
return ResponsesAgentResponse(**mock_response, custom_outputs=request.custom_inputs)
def predict_stream(self, request: ResponsesAgentRequest):
for r in get_stream_mock_response():
r["custom_outputs"] = request.custom_inputs
yield r
def test_responses_agent_custom_inputs(tmp_path):
model = CustomInputsResponsesAgent()
mlflow.pyfunc.save_model(python_model=model, path=tmp_path)
loaded_model = mlflow.pyfunc.load_model(tmp_path)
payload = {**RESPONSES_AGENT_INPUT_EXAMPLE, "custom_inputs": {"asdf": "asdf"}}
response = loaded_model.predict(payload)
assert response["custom_outputs"] == {"asdf": "asdf"}
responses = list(
loaded_model.predict_stream({
**RESPONSES_AGENT_INPUT_EXAMPLE,
"custom_inputs": {"asdf": "asdf"},
})
)
for r in responses:
assert r["custom_outputs"] == {"asdf": "asdf"}
def test_responses_agent_predict_with_params(tmp_path):
# needed because `load_model_and_predict` in `utils/_capture_modules.py` expects a params field
model = SimpleResponsesAgent()
mlflow.pyfunc.save_model(python_model=model, path=tmp_path)
loaded_model = mlflow.pyfunc.load_model(tmp_path)
response = loaded_model.predict(RESPONSES_AGENT_INPUT_EXAMPLE, params=None)
assert response["output"][0]["type"] == "message"
def test_responses_agent_save_throws_with_signature(tmp_path):
model = SimpleResponsesAgent()
with pytest.raises(MlflowException, match="Please remove the `signature` parameter"):
mlflow.pyfunc.save_model(
python_model=model,
path=tmp_path,
signature=ModelSignature(
inputs=Schema([ColSpec(name="test", type=DataType.string)]),
),
)
def test_responses_agent_throws_with_invalid_output(tmp_path):
class BadResponsesAgent(ResponsesAgent):
def predict(self, request: ResponsesAgentRequest) -> ResponsesAgentResponse:
return {"output": [{"type": "message", "content": [{"type": "output_text"}]}]}
model = BadResponsesAgent()
with pytest.raises(
MlflowException, match="Failed to save ResponsesAgent. Ensure your model's predict"
):
mlflow.pyfunc.save_model(python_model=model, path=tmp_path)
@pytest.mark.parametrize(
("input", "outputs"),
[
# 1. Normal text input output
(
RESPONSES_AGENT_INPUT_EXAMPLE,
{
"output": [
{
"type": "message",
"id": "test",
"status": "completed",
"role": "assistant",
"content": [{"type": "output_text", "text": "Dummy output"}],
}
],
},
),
# 2. Image input
(
{
"input": [
{
"role": "user",
"content": [
{"type": "input_text", "text": "what is in this image?"},
{"type": "input_image", "image_url": "test.jpg"},
],
}
],
},
{
"output": [
{
"type": "message",
"id": "test",
"status": "completed",
"role": "assistant",
"content": [{"type": "output_text", "text": "Dummy output"}],
}
],
},
),
# 3. Tool calling
(
{
"input": [
{
"role": "user",
"content": "What is the weather like in Boston today?",
}
],
"tools": [
{
"type": "function",
"name": "get_current_weather",
"parameters": {
"type": "object",
"properties": {"location": {"type": "string"}},
"required": ["location", "unit"],
},
}
],
},
{
"output": [
{
"arguments": '{"location":"Boston, MA","unit":"celsius"}',
"call_id": "function_call_1",
"name": "get_current_weather",
"type": "function_call",
"id": "fc_6805c835567481918c27724bbe931dc40b1b7951a48825bb",
"status": "completed",
}
]
},
),
],
)
def test_responses_agent_trace(input, outputs):
class TracedResponsesAgent(ResponsesAgent):
def predict(self, request: ResponsesAgentRequest) -> ResponsesAgentResponse:
return ResponsesAgentResponse(**outputs)
def predict_stream(
self, request: ResponsesAgentRequest
) -> Generator[ResponsesAgentStreamEvent, None, None]:
for item in outputs["output"]:
yield ResponsesAgentStreamEvent(
type="response.output_item.done",
item=item,
)
model = TracedResponsesAgent()
model.predict(ResponsesAgentRequest(**input))
traces = get_traces()
assert len(traces) == 1
spans = traces[0].data.spans
assert len(spans) == 1
assert spans[0].name == "predict"
assert spans[0].span_type == SpanType.AGENT
list(model.predict_stream(ResponsesAgentRequest(**input)))
traces = get_traces()
assert len(traces) == 2
spans = traces[0].data.spans
assert len(spans) == 1
assert spans[0].name == "predict_stream"
assert spans[0].span_type == SpanType.AGENT
assert "output" in spans[0].outputs
assert spans[0].outputs["output"] == outputs["output"]
def test_responses_agent_custom_trace_configurations():
# Agent with custom span names and attributes
class CustomTracedAgent(ResponsesAgent):
@mlflow.trace(
name="custom_predict", span_type=SpanType.AGENT, attributes={"custom": "value"}
)
def predict(self, request: ResponsesAgentRequest) -> ResponsesAgentResponse:
return ResponsesAgentResponse(**get_mock_response(request))
@mlflow.trace(
name="custom_predict_stream",
span_type=SpanType.AGENT,
attributes={"stream": "true"},
output_reducer=ResponsesAgent.responses_agent_output_reducer,
)
def predict_stream(
self, request: ResponsesAgentRequest
) -> Generator[ResponsesAgentStreamEvent, None, None]:
yield from [ResponsesAgentStreamEvent(**r) for r in get_stream_mock_response()]
purge_traces()
agent = CustomTracedAgent()
agent.predict(ResponsesAgentRequest(**RESPONSES_AGENT_INPUT_EXAMPLE))
traces_predict = get_traces()
assert len(traces_predict) == 1
spans_predict = traces_predict[0].data.spans
assert len(spans_predict) == 1
assert spans_predict[0].name == "custom_predict"
assert spans_predict[0].span_type == SpanType.AGENT
assert spans_predict[0].attributes.get("custom") == "value"
purge_traces()
list(agent.predict_stream(ResponsesAgentRequest(**RESPONSES_AGENT_INPUT_EXAMPLE)))
traces_stream = get_traces()
assert len(traces_stream) == 1
spans_stream = traces_stream[0].data.spans
assert len(spans_stream) == 1
assert spans_stream[0].name == "custom_predict_stream"
assert spans_stream[0].span_type == SpanType.AGENT
assert spans_stream[0].attributes.get("stream") == "true"
def test_responses_agent_non_mlflow_decorators():
# Create a custom decorator to test with
def custom_decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
class MixedDecoratedAgent(ResponsesAgent):
@custom_decorator
def predict(self, request: ResponsesAgentRequest) -> ResponsesAgentResponse:
return ResponsesAgentResponse(**get_mock_response(request))
# Just a regular method (no decorator) to test that it gets auto-traced
def predict_stream(
self, request: ResponsesAgentRequest
) -> Generator[ResponsesAgentStreamEvent, None, None]:
yield from [ResponsesAgentStreamEvent(**r) for r in get_stream_mock_response()]
# Both methods should get auto-traced since they don't have __mlflow_traced__
agent = MixedDecoratedAgent()
agent.predict(ResponsesAgentRequest(**RESPONSES_AGENT_INPUT_EXAMPLE))
traces_mixed_predict = get_traces()
assert len(traces_mixed_predict) == 1
spans_mixed_predict = traces_mixed_predict[0].data.spans
assert len(spans_mixed_predict) == 1
assert spans_mixed_predict[0].name == "predict"
assert spans_mixed_predict[0].span_type == SpanType.AGENT
purge_traces()
list(agent.predict_stream(ResponsesAgentRequest(**RESPONSES_AGENT_INPUT_EXAMPLE)))
traces_mixed_stream = get_traces()
assert len(traces_mixed_stream) == 1
spans_mixed_stream = traces_mixed_stream[0].data.spans
assert len(spans_mixed_stream) == 1
assert spans_mixed_stream[0].name == "predict_stream"
assert spans_mixed_stream[0].span_type == SpanType.AGENT
@pytest.mark.parametrize(
("chunks", "expected_output"),
[
(
[
{
"id": "chatcmpl_fd04a20f-f348-45e1-af37-68cf3bb08bdb",
"choices": [{"delta": {"content": "", "role": "assistant"}, "index": 0}],
"object": "chat.completion.chunk",
},
{
"id": "chatcmpl_fd04a20f-f348-45e1-af37-68cf3bb08bdb",
"choices": [
{
"delta": {
"content": [
{
"type": "reasoning",
"summary": [{"type": "summary_text", "text": "We"}],
}
]
},
"index": 0,
}
],
"object": "chat.completion.chunk",
},
{
"id": "chatcmpl_fd04a20f-f348-45e1-af37-68cf3bb08bdb",
"choices": [
{
"delta": {
"content": [
{
"type": "reasoning",
"summary": [{"type": "summary_text", "text": " need"}],
}
]
},
"index": 0,
}
],
"object": "chat.completion.chunk",
},
{
"id": "chatcmpl_fd04a20f-f348-45e1-af37-68cf3bb08bdb",
"choices": [{"delta": {"content": ""}, "index": 0}],
"object": "chat.completion.chunk",
},
{
"id": "chatcmpl_fd04a20f-f348-45e1-af37-68cf3bb08bdb",
"choices": [{"delta": {"content": "Hello"}, "index": 0}],
"object": "chat.completion.chunk",
},
{
"id": "chatcmpl_fd04a20f-f348-45e1-af37-68cf3bb08bdb",
"choices": [{"delta": {"content": "!"}, "index": 0}],
"object": "chat.completion.chunk",
},
],
[
ResponsesAgentStreamEvent(
type="response.output_text.delta",
custom_outputs=None,
item_id="chatcmpl_fd04a20f-f348-45e1-af37-68cf3bb08bdb",
delta="",
),
ResponsesAgentStreamEvent(
type="response.output_item.done",
custom_outputs=None,
item={
"type": "reasoning",
"summary": [{"type": "summary_text", "text": "We need"}],
"id": "chatcmpl_fd04a20f-f348-45e1-af37-68cf3bb08bdb",
},
),
ResponsesAgentStreamEvent(
type="response.output_text.delta",
custom_outputs=None,
item_id="chatcmpl_fd04a20f-f348-45e1-af37-68cf3bb08bdb",
delta="",
),
ResponsesAgentStreamEvent(
type="response.output_text.delta",
custom_outputs=None,
item_id="chatcmpl_fd04a20f-f348-45e1-af37-68cf3bb08bdb",
delta="Hello",
),
ResponsesAgentStreamEvent(
type="response.output_text.delta",
custom_outputs=None,
item_id="chatcmpl_fd04a20f-f348-45e1-af37-68cf3bb08bdb",
delta="!",
),
ResponsesAgentStreamEvent(
type="response.output_item.done",
custom_outputs=None,
item={
"id": "chatcmpl_fd04a20f-f348-45e1-af37-68cf3bb08bdb",
"content": [{"text": "Hello!", "type": "output_text", "annotations": []}],
"role": "assistant",
"type": "message",
},
),
],
),
(
[
{
"id": "chatcmpl_fd04a20f-f348-45e1-af37-68cf3bb08bdb",
"choices": [
{
"delta": {"content": "", "role": "assistant"},
"finish_reason": None,
"index": 0,
"logprobs": None,
}
],
"object": "chat.completion.chunk",
},
{
"id": "chatcmpl_fd04a20f-f348-45e1-af37-68cf3bb08bdb",
"choices": [
{
"delta": {
"content": [
{
"type": "reasoning",
"summary": [
{
"type": "summary_text",
"text": "We need to respond. The user just says "
'"hi". We can reply friendly.',
}
],
},
{"type": "text", "text": "Hello! How can I help you today?"},
]
},
"finish_reason": None,
"index": 0,
"logprobs": None,
}
],
"object": "chat.completion.chunk",
},
{
"id": "chatcmpl_fd04a20f-f348-45e1-af37-68cf3bb08bdb",
"choices": [
{
"delta": {"content": ""},
"finish_reason": "stop",
"index": 0,
"logprobs": None,
}
],
"object": "chat.completion.chunk",
},
],
[
ResponsesAgentStreamEvent(
type="response.output_text.delta",
custom_outputs=None,
item_id="chatcmpl_fd04a20f-f348-45e1-af37-68cf3bb08bdb",
delta="",
),
ResponsesAgentStreamEvent(
type="response.output_text.delta",
custom_outputs=None,
item_id="chatcmpl_fd04a20f-f348-45e1-af37-68cf3bb08bdb",
delta="Hello! How can I help you today?",
),
ResponsesAgentStreamEvent(
type="response.output_item.done",
custom_outputs=None,
item={
"type": "reasoning",
"summary": [
{
"type": "summary_text",
"text": 'We need to respond. The user just says "hi". '
"We can reply friendly.",
}
],
"id": "chatcmpl_fd04a20f-f348-45e1-af37-68cf3bb08bdb",
},
),
ResponsesAgentStreamEvent(
type="response.output_text.delta",
custom_outputs=None,
item_id="chatcmpl_fd04a20f-f348-45e1-af37-68cf3bb08bdb",
delta="",
),
ResponsesAgentStreamEvent(
type="response.output_item.done",
custom_outputs=None,
item={
"id": "chatcmpl_fd04a20f-f348-45e1-af37-68cf3bb08bdb",
"content": [
{
"text": "Hello! How can I help you today?",
"type": "output_text",
"annotations": [],
}
],
"role": "assistant",
"type": "message",
},
),
],
),
(
[
{
"id": "msg_bdrk_016AC1ojH743YLHDfgnf4B7Y",
"choices": [
{
"delta": {"content": "Hello", "role": "assistant"},
"finish_reason": None,
"index": 0,
}
],
"object": "chat.completion.chunk",
},
{
"id": "msg_bdrk_016AC1ojH743YLHDfgnf4B7Y",
"choices": [
{
"delta": {"content": " there! I'", "role": "assistant"},
"finish_reason": None,
"index": 0,
}
],
"object": "chat.completion.chunk",
},
],
[
ResponsesAgentStreamEvent(
type="response.output_text.delta",
custom_outputs=None,
item_id="msg_bdrk_016AC1ojH743YLHDfgnf4B7Y",
delta="Hello",
),
ResponsesAgentStreamEvent(
type="response.output_text.delta",
custom_outputs=None,
item_id="msg_bdrk_016AC1ojH743YLHDfgnf4B7Y",
delta=" there! I'",
),
ResponsesAgentStreamEvent(
type="response.output_item.done",
custom_outputs=None,
item={
"id": "msg_bdrk_016AC1ojH743YLHDfgnf4B7Y",
"content": [
{"text": "Hello there! I'", "type": "output_text", "annotations": []}
],
"role": "assistant",
"type": "message",
},
),
],
),
(
[
{
"id": "msg_bdrk_015YdA8hjVSHWxpAdecgHqj3",
"choices": [
{
"delta": {"content": "I", "role": "assistant"},
"finish_reason": None,
"index": 0,
}
],
"object": "chat.completion.chunk",
},
{
"id": "msg_bdrk_015YdA8hjVSHWxpAdecgHqj3",
"choices": [
{
"delta": {"content": " can help you calculate 4*", "role": "assistant"},
"finish_reason": None,
"index": 0,
}
],
"object": "chat.completion.chunk",
},
{
"id": "msg_bdrk_015YdA8hjVSHWxpAdecgHqj3",
"choices": [
{
"delta": {
"content": None,
"role": "assistant",
"tool_calls": [
{
"index": 0,
"id": "toolu_bdrk_01XKD5j3Ru1dk3jnm69xkXUL",
"function": {
"arguments": "",
"name": "system__ai__python_exec",
},
"type": "function",
}
],
},
"finish_reason": None,
"index": 0,
}
],
"object": "chat.completion.chunk",
},
{
"id": "msg_bdrk_015YdA8hjVSHWxpAdecgHqj3",
"choices": [
{
"delta": {
"content": None,
"role": "assistant",
"tool_calls": [{"index": 0, "function": {"arguments": ""}}],
},
"finish_reason": None,
"index": 0,
}
],
"object": "chat.completion.chunk",
},
{
"id": "msg_bdrk_015YdA8hjVSHWxpAdecgHqj3",
"choices": [
{
"delta": {
"content": None,
"role": "assistant",
"tool_calls": [
{"index": 0, "function": {"arguments": '{"code": "#'}}
],
},
"finish_reason": None,
"index": 0,
}
],
"created": 1757977465,
"model": "us.anthropic.claude-3-7-sonnet-20250219-v1:0",
"object": "chat.completion.chunk",
},
{
"id": "msg_bdrk_015YdA8hjVSHWxpAdecgHqj3",
"choices": [
{
"delta": {
"content": None,
"role": "assistant",
"tool_calls": [{"index": 0, "function": {"arguments": " Calc"}}],
},
"finish_reason": None,
"index": 0,
}
],
"object": "chat.completion.chunk",
},
{
"id": "msg_bdrk_015YdA8hjVSHWxpAdecgHqj3",
"choices": [
{
"delta": {
"content": None,
"role": "assistant",
"tool_calls": [
{"index": 0, "function": {"arguments": "ulate 4*3"}}
],
},
"finish_reason": None,
"index": 0,
}
],
"object": "chat.completion.chunk",
},
{
"id": "msg_bdrk_015YdA8hjVSHWxpAdecgHqj3",
"choices": [
{
"delta": {"content": "", "role": "assistant"},
"finish_reason": "tool_calls",
"index": 0,
}
],
"object": "chat.completion.chunk",
},
],
[
ResponsesAgentStreamEvent(
type="response.output_text.delta",
custom_outputs=None,
item_id="msg_bdrk_015YdA8hjVSHWxpAdecgHqj3",
delta="I",
),
ResponsesAgentStreamEvent(
type="response.output_text.delta",
custom_outputs=None,
item_id="msg_bdrk_015YdA8hjVSHWxpAdecgHqj3",
delta=" can help you calculate 4*",
),
ResponsesAgentStreamEvent(
type="response.output_text.delta",
custom_outputs=None,
item_id="msg_bdrk_015YdA8hjVSHWxpAdecgHqj3",
delta="",
),
ResponsesAgentStreamEvent(
type="response.output_item.done",
custom_outputs=None,
item={
"id": "msg_bdrk_015YdA8hjVSHWxpAdecgHqj3",
"content": [
{
"text": "I can help you calculate 4*",
"type": "output_text",
"annotations": [],
}
],
"role": "assistant",
"type": "message",
},
),
ResponsesAgentStreamEvent(
type="response.output_item.done",
custom_outputs=None,
item={
"type": "function_call",
"id": "msg_bdrk_015YdA8hjVSHWxpAdecgHqj3",
"call_id": "toolu_bdrk_01XKD5j3Ru1dk3jnm69xkXUL",
"name": "system__ai__python_exec",
"arguments": '{"code": "# Calculate 4*3',
},
),
],
),
(
[
{
"id": "chatcmpl_56a443d8-bf71-4f71-aff5-082191c4db1e",
"choices": [
{
"delta": {
"content": None,
"role": "assistant",
"tool_calls": [
{
"index": 0,
"id": "call_39565342-e7d7-4ed5-a3e3-ea115a7f9fc6",
"function": {
"arguments": "",
"name": "system__ai__python_exec",
},
"type": "function",
}
],
},
"finish_reason": None,
"index": 0,
"logprobs": None,
}
],
"object": "chat.completion.chunk",
},
{
"id": "chatcmpl_56a443d8-bf71-4f71-aff5-082191c4db1e",
"choices": [
{
"delta": {
"content": None,
"tool_calls": [
{
"index": 0,
"function": {
"arguments": '{\n "code": "result = 4 * 3\\n'
'print(result)"\n}'
},
}
],
},
"finish_reason": None,
"index": 0,
"logprobs": None,
}
],
"object": "chat.completion.chunk",
},
{
"id": "chatcmpl_56a443d8-bf71-4f71-aff5-082191c4db1e",
"choices": [
{
"delta": {
"content": None,
"tool_calls": [{"index": 0, "function": {"arguments": ""}}],
},
"finish_reason": "tool_calls",
"index": 0,
"logprobs": None,
}
],
"object": "chat.completion.chunk",
},
],
[
ResponsesAgentStreamEvent(
type="response.output_item.done",
custom_outputs=None,
item={
"type": "function_call",
"id": "chatcmpl_56a443d8-bf71-4f71-aff5-082191c4db1e",
"call_id": "call_39565342-e7d7-4ed5-a3e3-ea115a7f9fc6",
"name": "system__ai__python_exec",
"arguments": '{\n "code": "result = 4 * 3\\nprint(result)"\n}',
},
)
],
),
# Parallel tool calls: verifies arguments are assembled per tool call index
# Before fix, all arguments were concatenated into first tool call, causing JSON errors
(
[
# Text content
{
"id": "msg1",
"choices": [{"delta": {"content": "Calling tools."}, "index": 0}],
"object": "chat.completion.chunk",
},
# Tool 0: search - init + args
{
"id": "msg1",
"choices": [
{
"delta": {
"tool_calls": [
{
"index": 0,
"id": "call_0",
"function": {"name": "search", "arguments": ""},
}
]
},
"index": 0,
}
],
"object": "chat.completion.chunk",
},
{
"id": "msg1",
"choices": [
{
"delta": {
"tool_calls": [
{
"index": 0,
"function": {"arguments": '{"query": "ML best practices"}'},
}
]
},
"index": 0,
}
],
"object": "chat.completion.chunk",
},
# Tool 1: weather - init + args
{
"id": "msg1",
"choices": [
{
"delta": {
"tool_calls": [
{
"index": 1,
"id": "call_1",
"function": {"name": "weather", "arguments": ""},
}
]
},
"index": 0,
}
],
"object": "chat.completion.chunk",
},
{
"id": "msg1",
"choices": [
{
"delta": {
"tool_calls": [
{
"index": 1,
"function": {"arguments": '{"location": "Seattle"}'},
}
]
},
"index": 0,
}
],
"object": "chat.completion.chunk",
},
# Tool 2: calculate - init + args
{
"id": "msg1",
"choices": [
{
"delta": {
"tool_calls": [
{
"index": 2,
"id": "call_2",
"function": {"name": "calc", "arguments": ""},
}
]
},
"index": 0,
}
],
"object": "chat.completion.chunk",
},
{
"id": "msg1",
"choices": [
{
"delta": {
"tool_calls": [
{"index": 2, "function": {"arguments": '{"expr": "42*17"}'}}
]
},
"index": 0,
}
],
"object": "chat.completion.chunk",
},
# Final chunk
{
"id": "msg1",
"choices": [
{"delta": {"content": ""}, "finish_reason": "tool_calls", "index": 0}
],
"object": "chat.completion.chunk",
},
],
[
ResponsesAgentStreamEvent(
type="response.output_text.delta", item_id="msg1", delta="Calling tools."
),
ResponsesAgentStreamEvent(
type="response.output_text.delta", item_id="msg1", delta=""
),
ResponsesAgentStreamEvent(
type="response.output_item.done",
item={
"id": "msg1",
"content": [
{"text": "Calling tools.", "type": "output_text", "annotations": []}
],
"role": "assistant",
"type": "message",
},
),
ResponsesAgentStreamEvent(
type="response.output_item.done",
item={
"type": "function_call",
"id": "msg1",
"call_id": "call_0",
"name": "search",
"arguments": '{"query": "ML best practices"}',
},
),
ResponsesAgentStreamEvent(
type="response.output_item.done",
item={
"type": "function_call",
"id": "msg1",
"call_id": "call_1",
"name": "weather",
"arguments": '{"location": "Seattle"}',
},
),
ResponsesAgentStreamEvent(
type="response.output_item.done",
item={
"type": "function_call",
"id": "msg1",
"call_id": "call_2",
"name": "calc",
"arguments": '{"expr": "42*17"}',
},
),
],
),
],
)
def test_responses_agent_output_to_responses_items_stream(chunks, expected_output):
"""
In order of the parameters:
1. gpt oss with no tools streaming
- other models don't differentiate between w/ and w/o tools streaming
2. gpt oss with tools streaming
3. claude no tool call streaming
4. claude tool call streaming
"""
aggregator = []
converted_output = list(ResponsesAgent.output_to_responses_items_stream(chunks, aggregator))
assert converted_output == expected_output
expected_aggregator = [
event.item for event in expected_output if event.type == "response.output_item.done"
]
assert aggregator == expected_aggregator
def test_create_text_delta():
result = ResponsesAgent.create_text_delta("Hello", "test-id")
expected = {
"type": "response.output_text.delta",
"item_id": "test-id",
"delta": "Hello",
}
assert result == expected
def test_create_annotation_added():
annotation = {"type": "citation", "text": "Reference"}
result = ResponsesAgent.create_annotation_added("test-id", annotation, 1)
expected = {
"type": "response.output_text.annotation.added",
"item_id": "test-id",
"annotation_index": 1,
"annotation": annotation,
}
assert result == expected
# Test with default annotation_index
result_default = ResponsesAgent.create_annotation_added("test-id", annotation)
expected_default = {
"type": "response.output_text.annotation.added",
"item_id": "test-id",
"annotation_index": 0,
"annotation": annotation,
}
assert result_default == expected_default
def test_create_text_output_item():
# Test without annotations
result = ResponsesAgent.create_text_output_item("Hello world", "test-id")
expected = {
"id": "test-id",
"content": [
{
"text": "Hello world",
"type": "output_text",
"annotations": [],
}
],
"role": "assistant",
"type": "message",
}
assert result == expected
# Test with annotations
annotations = [{"type": "citation", "text": "Reference"}]
result_with_annotations = ResponsesAgent.create_text_output_item(
"Hello world", "test-id", annotations
)
expected_with_annotations = {
"id": "test-id",
"content": [
{
"text": "Hello world",
"type": "output_text",
"annotations": annotations,
}
],
"role": "assistant",
"type": "message",
}
assert result_with_annotations == expected_with_annotations
def test_create_reasoning_item():
result = ResponsesAgent.create_reasoning_item("test-id", "This is my reasoning")
expected = {
"type": "reasoning",
"summary": [
{
"type": "summary_text",
"text": "This is my reasoning",
}
],
"id": "test-id",
}
assert result == expected
def test_create_function_call_item():
result = ResponsesAgent.create_function_call_item(
"test-id", "call-123", "get_weather", '{"location": "Boston"}'
)
expected = {
"type": "function_call",
"id": "test-id",
"call_id": "call-123",
"name": "get_weather",
"arguments": '{"location": "Boston"}',
}
assert result == expected
def test_create_function_call_output_item():
result = ResponsesAgent.create_function_call_output_item("call-123", "Sunny, 75°F")
expected = {
"type": "function_call_output",
"call_id": "call-123",
"output": "Sunny, 75°F",
}
assert result == expected
@pytest.mark.parametrize(
("responses_input", "cc_msgs"),
[
(
[
{"type": "user", "content": "what is 4*3 in python"},
{"type": "reasoning", "summary": "I can help you calculate 4*3"},
{
"id": "msg_bdrk_015YdA8hjVSHWxpAdecgHqj3",
"content": [{"text": "I can help you calculate 4*", "type": "output_text"}],
"role": "assistant",
"type": "message",
},
{
"type": "mcp_approval_request",
"id": "mcp_approval_request_123",
"arguments": "{}",
"name": "system__ai__python_exec",
"server_label": "python_exec",
},
{
"type": "mcp_approval_response",
"id": "mcp_approval_response_123",
"approval_request_id": "mcp_approval_request_123",
"approve": True,
"reason": "The request was approved",
},
{
"type": "function_call",
"id": "chatcmpl_56a443d8-bf71-4f71-aff5-082191c4db1e",
"call_id": "call_39565342-e7d7-4ed5-a3e3-ea115a7f9fc6",
"name": "system__ai__python_exec",
"arguments": '{\n "code": "result = 4 * 3\\nprint(result)"\n}',
},
{
"type": "function_call_output",
"call_id": "call_39565342-e7d7-4ed5-a3e3-ea115a7f9fc6",
"output": "12\n",
},
],
[
{"content": "what is 4*3 in python"},
{"role": "assistant", "content": '"I can help you calculate 4*3"'},
{"role": "assistant", "content": "I can help you calculate 4*"},
{
"role": "assistant",
"content": "mcp approval request",
"tool_calls": [
{
"id": "mcp_approval_request_123",
"type": "function",
"function": {
"arguments": "{}",
"name": "system__ai__python_exec",
},
}
],
},
{
"role": "tool",
"content": "True",
"tool_call_id": "mcp_approval_request_123",
},
{
"role": "assistant",
"content": "tool call",
"tool_calls": [
{
"id": "call_39565342-e7d7-4ed5-a3e3-ea115a7f9fc6",
"type": "function",
"function": {
"arguments": '{\n "code": "result = 4 * 3\\nprint(result)"\n}',
"name": "system__ai__python_exec",
},
}
],
},
{
"role": "tool",
"content": "12\n",
"tool_call_id": "call_39565342-e7d7-4ed5-a3e3-ea115a7f9fc6",
},
],
)
],
)
def test_prep_msgs_for_cc_llm(responses_input, cc_msgs):
result = ResponsesAgent.prep_msgs_for_cc_llm(responses_input)
assert result == cc_msgs
@pytest.mark.parametrize(
("responses_input", "cc_msgs"),
[
(
[
{"type": "user", "content": "what is 4*3 in python"},
{"type": "reasoning", "summary": "I can help you calculate 4*3"},
{
"id": "msg_bdrk_015YdA8hjVSHWxpAdecgHqj3",
"content": [{"text": "I can help you calculate 4*", "type": "output_text"}],
"role": "assistant",
"type": "message",
},
{
"type": "function_call",
"id": "chatcmpl_56a443d8-bf71-4f71-aff5-082191c4db1e",
"call_id": "call_39565342-e7d7-4ed5-a3e3-ea115a7f9fc6",
"name": "system__ai__python_exec",
"arguments": "",
},
{
"type": "function_call_output",
"call_id": "call_39565342-e7d7-4ed5-a3e3-ea115a7f9fc6",
"output": "12\n",
},
],
[
{"content": "what is 4*3 in python"},
{"role": "assistant", "content": '"I can help you calculate 4*3"'},
{"role": "assistant", "content": "I can help you calculate 4*"},
{
"role": "assistant",
"content": "tool call",
"tool_calls": [
{
"id": "call_39565342-e7d7-4ed5-a3e3-ea115a7f9fc6",
"type": "function",
"function": {
"arguments": "{}",
"name": "system__ai__python_exec",
},
}
],
},
{
"role": "tool",
"content": "12\n",
"tool_call_id": "call_39565342-e7d7-4ed5-a3e3-ea115a7f9fc6",
},
],
)
],
)
def test_prep_msgs_for_cc_llm_empty_arguments(responses_input, cc_msgs):
result = ResponsesAgent.prep_msgs_for_cc_llm(responses_input)
assert result == cc_msgs
def test_cc_stream_to_responses_stream_handles_multiple_invalid_chunks():
chunks_with_mixed_validity = [
{"choices": None, "id": "msg-1"},
{"choices": [], "id": "msg-2"},
{"choices": [{"delta": {"content": "valid"}}], "id": "msg-3"},
{"choices": None, "id": "msg-4"},
{"choices": [{"delta": {"content": " content"}}], "id": "msg-5"},
]
events = list(output_to_responses_items_stream(iter(chunks_with_mixed_validity)))
# Should only process chunks with valid choices
# Expected: 2 delta events + 1 done event (content gets aggregated)
assert len(events) == 3
assert events[0].type == "response.output_text.delta"
assert events[0].delta == "valid"
assert events[1].type == "response.output_text.delta"
assert events[1].delta == " content"
assert events[2].type == "response.output_item.done"