Files
2026-08-16 11:15:36 +09:00

2239 lines
70 KiB
Python

from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator
from typing import Any, cast
import httpx
import pytest
from openai import APIConnectionError
from openai.types.responses import (
ResponseApplyPatchToolCall,
ResponseCodeInterpreterToolCall,
ResponseCompletedEvent,
ResponseContentPartAddedEvent,
ResponseContentPartDoneEvent,
ResponseCreatedEvent,
ResponseCustomToolCall,
ResponseFunctionCallArgumentsDeltaEvent,
ResponseFunctionCallArgumentsDoneEvent,
ResponseFunctionToolCall,
ResponseInProgressEvent,
ResponseOutputItemAddedEvent,
ResponseOutputItemDoneEvent,
ResponseOutputMessage,
ResponseOutputRefusal,
ResponseOutputText,
ResponseOutputTextAnnotationAddedEvent,
ResponseReasoningSummaryPartAddedEvent,
ResponseReasoningTextDeltaEvent,
ResponseReasoningTextDoneEvent,
ResponseRefusalDeltaEvent,
ResponseRefusalDoneEvent,
ResponseTextDeltaEvent,
ResponseTextDoneEvent,
)
from openai.types.responses.response_output_item import ImageGenerationCall, McpCall
from openai.types.responses.response_output_text import (
AnnotationFilePath,
AnnotationURLCitation,
Logprob,
LogprobTopLogprob,
)
from openai.types.responses.response_prompt_param import ResponsePromptParam, Variables
from openai.types.responses.response_reasoning_item import Content, ResponseReasoningItem, Summary
from openai.types.responses.response_usage import InputTokensDetails, OutputTokensDetails
from pydantic import ValidationError
from agents import (
Agent,
ModelBehaviorError,
ModelRetryAdvice,
ModelRetryAdviceRequest,
ModelRetrySettings,
ModelTimeoutError,
RunConfig,
Runner,
handoff,
retry_policies,
)
from agents.agent_output import AgentOutputSchema
from agents.items import TResponseInputItem, TResponseOutputItem, TResponseStreamEvent
from agents.model_settings import ModelSettings
from agents.models.interface import ModelTracing
from agents.testing import (
InvalidModelStep,
ModelCall,
ModelStep,
ModelStepSpec,
ScriptedModel,
UnconsumedModelSteps,
UnexpectedModelCall,
assistant_message,
function_call,
)
from agents.tracing import SpanError
from agents.tracing.scope import Scope
from agents.usage import RequestUsage, Usage, _extract_raw_usage_snapshot
from agents.util._error_tracing import REDACTED_TRACE_ERROR_MESSAGE
from .model_test_helpers import get_response_obj
from .test_responses import get_function_tool
from .testing_processor import fetch_span_errors
@pytest.mark.asyncio
async def test_scripted_model_runs_tool_workflow_and_records_calls() -> None:
model = ScriptedModel(
[
[function_call("lookup", {"city": "Tokyo"}, call_id="call_1")],
[assistant_message("sunny")],
]
)
agent = Agent(
name="weather",
model=model,
tools=[get_function_tool("lookup", "tool result")],
)
result = await Runner.run(agent, "weather?")
assert result.final_output == "sunny"
assert len(model.calls) == 2
assert model.first_call is not None
assert model.first_call.input == [{"content": "weather?", "role": "user"}]
assert model.last_call is not None
assert model.last_call.streamed is False
model.assert_complete()
@pytest.mark.asyncio
async def test_scripted_model_generates_stream_events() -> None:
model = ScriptedModel([[assistant_message("hello")]])
result = Runner.run_streamed(Agent(name="test", model=model), "hi")
events = [event async for event in result.stream_events()]
assert result.final_output == "hello"
assert events
assert model.last_call is not None
assert model.last_call.streamed is True
model.assert_complete()
@pytest.mark.asyncio
async def test_scripted_model_stream_preserves_request_usage_details() -> None:
request_entries = [
RequestUsage(
input_tokens=3,
output_tokens=2,
total_tokens=5,
input_tokens_details=InputTokensDetails.model_validate(
{"cached_tokens": 1, "cache_write_tokens": 0}
),
output_tokens_details=OutputTokensDetails(reasoning_tokens=1),
),
RequestUsage(
input_tokens=7,
output_tokens=4,
total_tokens=11,
input_tokens_details=InputTokensDetails.model_validate(
{"cached_tokens": 2, "cache_write_tokens": 3}
),
output_tokens_details=OutputTokensDetails(reasoning_tokens=2),
),
]
usage = Usage(
requests=5,
input_tokens=10,
output_tokens=6,
total_tokens=16,
input_tokens_details=InputTokensDetails.model_validate(
{"cached_tokens": 3, "cache_write_tokens": 3}
),
output_tokens_details=OutputTokensDetails(reasoning_tokens=3),
request_usage_entries=request_entries,
)
streamed_model = ScriptedModel([ModelStep(output=[assistant_message("hello")], usage=usage)])
non_streamed_model = ScriptedModel(
[ModelStep(output=[assistant_message("hello")], usage=usage)]
)
streamed_result = Runner.run_streamed(Agent(name="streamed", model=streamed_model), "hi")
async for _event in streamed_result.stream_events():
pass
non_streamed_result = await Runner.run(
Agent(name="non-streamed", model=non_streamed_model), "hi"
)
propagated = streamed_result.context_wrapper.usage
assert propagated.requests == 5
assert propagated.input_tokens == 10
assert propagated.output_tokens == 6
assert propagated.total_tokens == 16
assert propagated.request_usage_entries == request_entries
assert propagated == non_streamed_result.context_wrapper.usage
@pytest.mark.asyncio
async def test_scripted_model_stream_counts_default_usage_as_one_request() -> None:
streamed_model = ScriptedModel([[assistant_message("streamed")]])
non_streamed_model = ScriptedModel([[assistant_message("non-streamed")]])
streamed_result = Runner.run_streamed(Agent(name="streamed", model=streamed_model), "hi")
async for _event in streamed_result.stream_events():
pass
non_streamed_result = await Runner.run(
Agent(name="non-streamed", model=non_streamed_model), "hi"
)
assert streamed_result.context_wrapper.usage.requests == 1
assert non_streamed_result.context_wrapper.usage.requests == 1
@pytest.mark.asyncio
async def test_scripted_model_preserves_explicit_zero_request_usage_across_run_modes() -> None:
usage = Usage(
input_tokens=2,
output_tokens=1,
total_tokens=3,
request_usage_entries=[
RequestUsage(
input_tokens=1,
output_tokens=1,
total_tokens=2,
input_tokens_details=InputTokensDetails.model_validate(
{"cached_tokens": 0, "cache_write_tokens": 0}
),
output_tokens_details=OutputTokensDetails(reasoning_tokens=0),
)
],
)
streamed_model = ScriptedModel([ModelStep(output=[assistant_message("streamed")], usage=usage)])
non_streamed_model = ScriptedModel(
[ModelStep(output=[assistant_message("non-streamed")], usage=usage)]
)
streamed_result = Runner.run_streamed(Agent(name="streamed", model=streamed_model), "hi")
async for _event in streamed_result.stream_events():
pass
non_streamed_result = await Runner.run(
Agent(name="non-streamed", model=non_streamed_model), "hi"
)
assert streamed_result.context_wrapper.usage == usage
assert non_streamed_result.context_wrapper.usage == usage
@pytest.mark.asyncio
async def test_scripted_model_copies_default_usage_before_retry_accounting() -> None:
default_usage = Usage(requests=1, input_tokens=2, total_tokens=2)
model = ScriptedModel(
[
APIConnectionError(
message="connection error",
request=httpx.Request("POST", "https://example.com"),
),
[assistant_message("first")],
[assistant_message("second")],
],
default_usage=default_usage,
)
agent = Agent(
name="test",
model=model,
model_settings=ModelSettings(
retry=ModelRetrySettings(
max_retries=1,
policy=retry_policies.network_error(),
)
),
)
first_result = await Runner.run(agent, "first")
second_result = await Runner.run(agent, "second")
assert first_result.context_wrapper.usage.requests == 2
assert second_result.context_wrapper.usage.requests == 1
assert default_usage == Usage(requests=1, input_tokens=2, total_tokens=2)
@pytest.mark.asyncio
@pytest.mark.parametrize("response_id", [None, ""])
async def test_scripted_model_stream_preserves_response_id(response_id: str | None) -> None:
streamed_model = ScriptedModel(
[ModelStep(output=[assistant_message("streamed")], response_id=response_id)]
)
non_streamed_model = ScriptedModel(
[ModelStep(output=[assistant_message("non-streamed")], response_id=response_id)]
)
streamed_result = Runner.run_streamed(Agent(name="streamed", model=streamed_model), "hi")
async for _event in streamed_result.stream_events():
pass
non_streamed_result = await Runner.run(
Agent(name="non-streamed", model=non_streamed_model), "hi"
)
assert streamed_result.last_response_id == response_id
assert non_streamed_result.last_response_id == response_id
@pytest.mark.asyncio
async def test_scripted_model_stream_does_not_chain_absent_response_id() -> None:
def respond(call: ModelCall):
assert call.previous_response_id is None
return [assistant_message("done")]
model = ScriptedModel(
[
ModelStep(
output=[function_call("lookup", {}, call_id="call_1")],
response_id=None,
),
ModelStep.respond(respond),
]
)
agent = Agent(
name="test",
model=model,
tools=[get_function_tool("lookup", "tool result")],
)
result = Runner.run_streamed(agent, "hi", auto_previous_response_id=True)
async for _event in result.stream_events():
pass
assert result.final_output == "done"
model.assert_complete()
@pytest.mark.asyncio
async def test_scripted_model_supports_dynamic_responder() -> None:
def respond(call):
assert isinstance(call.input, list)
return [assistant_message(str(call.input[0]["content"]))]
model = ScriptedModel([ModelStep.respond(respond)])
result = await Runner.run(Agent(name="test", model=model), "hello")
assert result.final_output == "hello"
@pytest.mark.asyncio
async def test_scripted_model_accepts_step_mapping() -> None:
usage = Usage(requests=2, input_tokens=3, output_tokens=4, total_tokens=7)
model = ScriptedModel(
[
{
"output": [assistant_message("mapped")],
"usage": usage,
"response_id": "resp_mapped",
"request_id": "req_mapped",
"raw_usage": {"source": "mapping"},
}
]
)
response = await model.get_response(
None,
[],
ModelSettings(),
[],
None,
[],
ModelTracing.DISABLED,
previous_response_id=None,
conversation_id=None,
prompt=None,
)
assert response.output == [assistant_message("mapped")]
assert response.usage == usage
assert response.response_id == "resp_mapped"
assert response.request_id == "req_mapped"
assert response.raw_usage is None
model.assert_complete()
@pytest.mark.asyncio
async def test_scripted_model_snapshots_model_settings_in_recorded_calls() -> None:
settings = ModelSettings(
tool_choice="auto",
extra_args={"provider": {"mode": "first"}},
)
model = ScriptedModel([[assistant_message("first")], [assistant_message("second")]])
async for _event in model.stream_response(
None,
[],
settings,
[],
None,
[],
ModelTracing.DISABLED,
previous_response_id=None,
conversation_id=None,
prompt=None,
):
pass
settings.tool_choice = "none"
assert settings.extra_args is not None
settings.extra_args["provider"]["mode"] = "second"
await model.get_response(
None,
[],
settings,
[],
None,
[],
ModelTracing.DISABLED,
previous_response_id=None,
conversation_id=None,
prompt=None,
)
settings.extra_args["provider"]["mode"] = "after"
first_settings = model.calls[0].model_settings
second_settings = model.calls[1].model_settings
assert first_settings is not settings
assert first_settings.tool_choice == "auto"
assert first_settings.extra_args == {"provider": {"mode": "first"}}
assert second_settings.tool_choice == "none"
assert second_settings.extra_args == {"provider": {"mode": "second"}}
@pytest.mark.asyncio
async def test_scripted_model_snapshots_input_and_prompt_in_recorded_calls() -> None:
input_item: dict[str, Any] = {
"role": "user",
"content": [{"type": "input_text", "text": "first"}],
}
input_items = cast(list[TResponseInputItem], [input_item])
prompt_variables: dict[str, Variables] = {"topic": "first"}
prompt: ResponsePromptParam = {
"id": "pmpt_1",
"variables": prompt_variables,
}
model = ScriptedModel([[assistant_message("done")]])
await model.get_response(
None,
input_items,
ModelSettings(),
[],
None,
[],
ModelTracing.DISABLED,
previous_response_id=None,
conversation_id=None,
prompt=prompt,
)
input_item["content"][0]["text"] = "second"
prompt_variables["topic"] = "second"
assert model.last_call is not None
assert model.last_call.input == [
{
"role": "user",
"content": [{"type": "input_text", "text": "first"}],
}
]
assert model.last_call.prompt == {
"id": "pmpt_1",
"variables": {"topic": "first"},
}
@pytest.mark.asyncio
async def test_scripted_model_exposes_detached_read_only_call_history() -> None:
input_item: dict[str, Any] = {
"role": "user",
"content": [{"type": "input_text", "text": "first"}],
}
settings = ModelSettings(extra_args={"provider": {"mode": "first"}})
tool = get_function_tool("lookup", "tool result")
target = Agent(name="delegate")
handoff_value = handoff(target)
output_schema = AgentOutputSchema(str)
model = ScriptedModel([[assistant_message("done")]])
await model.get_response(
None,
cast(list[TResponseInputItem], [input_item]),
settings,
[tool],
output_schema,
[handoff_value],
ModelTracing.DISABLED,
previous_response_id=None,
conversation_id=None,
prompt=None,
)
history = model.calls
assert isinstance(history, tuple)
with pytest.raises(AttributeError):
cast(Any, history).append(history[0])
returned = history[0]
returned.input[0]["content"][0]["text"] = "changed"
assert returned.model_settings.extra_args is not None
returned.model_settings.extra_args["provider"]["mode"] = "changed"
returned.tools.clear()
returned.handoffs.clear()
retained = model.calls[0]
assert retained.input[0]["content"][0]["text"] == "first"
assert retained.model_settings.extra_args == {"provider": {"mode": "first"}}
assert retained.tools == [tool]
assert retained.handoffs == [handoff_value]
assert retained.tools[0] is tool
assert retained.handoffs[0] is handoff_value
assert retained.output_schema is output_schema
assert retained.tracing is ModelTracing.DISABLED
@pytest.mark.asyncio
@pytest.mark.parametrize("streamed", [False, True])
async def test_scripted_model_snapshot_failure_has_no_recording_side_effects(
streamed: bool,
) -> None:
expected = RuntimeError("settings snapshot failed")
class Uncopyable:
def __deepcopy__(self, _memo: dict[int, Any]) -> Any:
raise expected
responder_calls: list[ModelCall] = []
def respond(call: ModelCall) -> list[TResponseOutputItem]:
responder_calls.append(call)
return [assistant_message("unexpected")]
model = ScriptedModel([ModelStep.respond(respond)])
settings = ModelSettings(extra_args={"sentinel": Uncopyable()})
with pytest.raises(RuntimeError, match="settings snapshot failed") as exc_info:
if streamed:
async for _event in model.stream_response(
None,
[],
settings,
[],
None,
[],
ModelTracing.DISABLED,
previous_response_id=None,
conversation_id=None,
prompt=None,
):
pass
else:
await model.get_response(
None,
[],
settings,
[],
None,
[],
ModelTracing.DISABLED,
previous_response_id=None,
conversation_id=None,
prompt=None,
)
assert exc_info.value is expected
assert model.calls == ()
assert model.remaining_steps == 1
assert responder_calls == []
@pytest.mark.asyncio
@pytest.mark.parametrize("streamed", [False, True])
@pytest.mark.parametrize("preserve_raw_usage", [None, False, True])
async def test_scripted_model_gates_and_snapshots_raw_usage(
streamed: bool,
preserve_raw_usage: bool | None,
) -> None:
raw_usage = {"provider": {"input_tokens": 3}}
model = ScriptedModel([ModelStep(output=[assistant_message("done")], raw_usage=raw_usage)])
agent = Agent(
name="test",
model=model,
model_settings=ModelSettings(preserve_raw_usage=preserve_raw_usage),
)
if streamed:
result = Runner.run_streamed(agent, "hi")
terminal_raw_usage = None
saw_terminal = False
async for event in result.stream_events():
if event.type == "raw_response_event" and isinstance(
event.data, ResponseCompletedEvent
):
saw_terminal = True
terminal_raw_usage = _extract_raw_usage_snapshot(event.data.response)
assert saw_terminal is True
else:
result = await Runner.run(agent, "hi")
terminal_raw_usage = None
raw_usage["provider"]["input_tokens"] = 99
expected_raw_usage = {"provider": {"input_tokens": 3}} if preserve_raw_usage is True else None
assert result.raw_responses[0].raw_usage == expected_raw_usage
if streamed:
assert terminal_raw_usage == expected_raw_usage
@pytest.mark.asyncio
async def test_scripted_model_withholds_raw_usage_until_stream_completion() -> None:
raw_usage = {"provider": {"input_tokens": 3}}
model = ScriptedModel([ModelStep(output=[assistant_message("done")], raw_usage=raw_usage)])
events = [
event
async for event in model.stream_response(
None,
[],
ModelSettings(preserve_raw_usage=True),
[],
None,
[],
ModelTracing.DISABLED,
previous_response_id=None,
conversation_id=None,
prompt=None,
)
]
response_events = [
event
for event in events
if isinstance(
event,
ResponseCreatedEvent | ResponseInProgressEvent | ResponseCompletedEvent,
)
]
assert [_extract_raw_usage_snapshot(event.response) for event in response_events] == [
None,
None,
raw_usage,
]
@pytest.mark.asyncio
async def test_scripted_model_accepts_step_mappings_in_queue_methods() -> None:
model = ScriptedModel()
model.enqueue({"output": [assistant_message("first")]})
model.extend([{"output": [assistant_message("second")]}])
agent = Agent(name="test", model=model)
first = await Runner.run(agent, "first")
second = await Runner.run(agent, "second")
assert first.final_output == "first"
assert second.final_output == "second"
model.assert_complete()
@pytest.mark.asyncio
async def test_scripted_model_responder_can_return_step_mapping() -> None:
def respond(call: ModelCall) -> ModelStepSpec:
assert isinstance(call.input, list)
return {"output": [assistant_message(str(call.input[0]["content"]))]}
model = ScriptedModel([ModelStep.respond(respond)])
result = await Runner.run(Agent(name="test", model=model), "mapped")
assert result.final_output == "mapped"
model.assert_complete()
def test_scripted_model_rejects_unknown_step_mapping_keys() -> None:
invalid_step = cast(ModelStepSpec, {"unknown": True})
with pytest.raises(InvalidModelStep, match=r"step #1") as exc_info:
ScriptedModel([invalid_step])
assert exc_info.value.reason == "unsupported_field"
assert exc_info.value.input_index == 0
@pytest.mark.parametrize(
("step", "reason"),
[
({"error": "not an exception"}, "invalid_error"),
({"responder": "not callable"}, "invalid_responder"),
({"stream_events": 1}, "invalid_stream_events"),
(
{"error": RuntimeError("failed"), "responder": lambda _call: []},
"conflicting_outcomes",
),
({"output": "not an output sequence"}, "invalid_input"),
({"usage": {"requests": 1}}, "invalid_input"),
({"response_id": 1}, "invalid_input"),
({"raw_usage": []}, "invalid_input"),
({"retry_advice": ModelRetryAdvice(suggested=True)}, "invalid_retry_advice"),
(
{"error": RuntimeError("failed"), "retry_advice": "not advice"},
"invalid_retry_advice",
),
],
)
def test_scripted_model_validates_step_envelopes_before_queuing(
step: object,
reason: str,
) -> None:
model = ScriptedModel([[assistant_message("retained")]])
with pytest.raises(InvalidModelStep, match=r"step #1") as exc_info:
model.extend(cast(Any, [step]))
assert exc_info.value.reason == reason
assert exc_info.value.input_index == 0
assert model.remaining_steps == 1
def test_scripted_model_reports_the_zero_origin_index_of_an_invalid_step() -> None:
with pytest.raises(InvalidModelStep, match=r"step #2") as exc_info:
ScriptedModel([[assistant_message("valid")], cast(Any, {"unknown": True})])
assert exc_info.value.reason == "unsupported_field"
assert exc_info.value.input_index == 1
@pytest.mark.asyncio
async def test_scripted_model_supports_awaitable_recursive_responder() -> None:
async def outer_responder(call: ModelCall) -> ModelStep:
def inner_responder(inner_call: ModelCall) -> list[TResponseOutputItem]:
assert inner_call is call
return [assistant_message("nested")]
return ModelStep.respond(inner_responder)
model = ScriptedModel([ModelStep.respond(outer_responder)])
result = await Runner.run(Agent(name="test", model=model), "hello")
assert result.final_output == "nested"
assert len(model.calls) == 1
model.assert_complete()
@pytest.mark.asyncio
async def test_scripted_model_retry_advice_is_error_scoped_and_detached() -> None:
error = RuntimeError("failed")
advice = ModelRetryAdvice(suggested=True, replay_safety="safe")
model = ScriptedModel([ModelStep.raise_error(error, retry_advice=advice)])
advice.suggested = False
with pytest.raises(RuntimeError, match="failed"):
await model.get_response(
None,
[],
ModelSettings(),
[],
None,
[],
ModelTracing.DISABLED,
previous_response_id=None,
conversation_id=None,
prompt=None,
)
request = ModelRetryAdviceRequest(error=error, attempt=1, stream=False)
first = model.get_retry_advice(request)
assert first is not None
assert first.suggested is True
first.suggested = False
second = model.get_retry_advice(request)
assert second is not None
assert second.suggested is True
other_request = ModelRetryAdviceRequest(
error=RuntimeError("other"),
attempt=1,
stream=False,
)
assert model.get_retry_advice(other_request) is None
@pytest.mark.parametrize("streamed", [False, True])
@pytest.mark.asyncio
async def test_scripted_model_clears_stale_retry_advice_after_responder_failure(
streamed: bool,
) -> None:
error = RuntimeError("failed")
def raise_same_error(_call: ModelCall) -> ModelStep:
raise error
model = ScriptedModel(
[
ModelStep.raise_error(error, retry_advice=ModelRetryAdvice(suggested=True)),
ModelStep.respond(raise_same_error),
[assistant_message("must remain unconsumed")],
]
)
async def invoke() -> None:
if streamed:
async for _event in model.stream_response(
None,
[],
ModelSettings(),
[],
None,
[],
ModelTracing.DISABLED,
previous_response_id=None,
conversation_id=None,
prompt=None,
):
pass
else:
await model.get_response(
None,
[],
ModelSettings(),
[],
None,
[],
ModelTracing.DISABLED,
previous_response_id=None,
conversation_id=None,
prompt=None,
)
request = ModelRetryAdviceRequest(error=error, attempt=1, stream=streamed)
with pytest.raises(RuntimeError, match="failed"):
await invoke()
assert model.get_retry_advice(request) is not None
with pytest.raises(RuntimeError, match="failed"):
await invoke()
assert model.get_retry_advice(request) is None
assert model.remaining_steps == 1
@pytest.mark.asyncio
async def test_scripted_model_supports_exact_normalized_stream() -> None:
output = [assistant_message("exact")]
async def stream(_call) -> AsyncIterator[TResponseStreamEvent]:
yield ResponseCompletedEvent(
type="response.completed",
response=get_response_obj(output),
sequence_number=0,
)
model = ScriptedModel([ModelStep.stream(stream, output=output)])
result = Runner.run_streamed(Agent(name="test", model=model), "hi")
async for _event in result.stream_events():
pass
assert result.final_output == "exact"
def test_model_step_freezes_exact_stream_event_sequence() -> None:
event = ResponseCompletedEvent(
type="response.completed",
response=get_response_obj([]),
sequence_number=0,
)
events = [event]
step = ModelStep.stream(events)
events.clear()
assert step.stream_events == (event,)
@pytest.mark.asyncio
@pytest.mark.parametrize(
("output_item", "item_kind"),
[
(
ResponseCustomToolCall(
call_id="call_1",
id="item_1",
input="payload",
name="custom",
type="custom_tool_call",
),
"ResponseCustomToolCall",
),
(
ResponseCodeInterpreterToolCall(
id="item_1",
code="print('hello')",
container_id="container_1",
outputs=None,
status="completed",
type="code_interpreter_call",
),
"ResponseCodeInterpreterToolCall",
),
(
McpCall(
id="item_1",
arguments='{"query":"hello"}',
name="search",
server_label="docs",
status="completed",
type="mcp_call",
),
"McpCall",
),
(
ImageGenerationCall(
id="item_1",
result="base64-image",
status="completed",
type="image_generation_call",
),
"ImageGenerationCall",
),
],
)
async def test_scripted_model_rejects_unsupported_automatic_tool_streams(
output_item: TResponseOutputItem,
item_kind: str,
) -> None:
model = ScriptedModel([[output_item]])
yielded: list[TResponseStreamEvent] = []
with pytest.raises(
ModelBehaviorError,
match=rf"Automatic streaming does not support {item_kind}.*ModelStep\.stream",
):
async for event in model.stream_response(
None,
[],
ModelSettings(),
[],
None,
[],
ModelTracing.DISABLED,
previous_response_id=None,
conversation_id=None,
prompt=None,
):
yielded.append(event)
assert yielded == []
@pytest.mark.asyncio
@pytest.mark.parametrize("queue_method", ["constructor", "enqueue", "extend"])
async def test_scripted_model_snapshots_static_steps_when_queued(queue_method: str) -> None:
output_item = assistant_message("before")
usage = Usage(requests=2, input_tokens=3, output_tokens=4, total_tokens=7)
raw_usage = {"provider": {"cached_tokens": 1}}
step = ModelStep(
output=[output_item],
usage=usage,
response_id="resp_before",
raw_usage=raw_usage,
)
if queue_method == "constructor":
model = ScriptedModel([step])
else:
model = ScriptedModel()
if queue_method == "enqueue":
model.enqueue(step)
else:
model.extend([step])
message = cast(ResponseOutputMessage, output_item)
text = cast(ResponseOutputText, message.content[0])
text.text = "after"
usage.requests = 99
raw_usage["provider"]["cached_tokens"] = 99
step.response_id = "resp_after"
response = await model.get_response(
None,
[],
ModelSettings(preserve_raw_usage=True),
[],
None,
[],
ModelTracing.DISABLED,
previous_response_id=None,
conversation_id=None,
prompt=None,
)
queued_message = cast(ResponseOutputMessage, response.output[0])
queued_text = cast(ResponseOutputText, queued_message.content[0])
assert queued_text.text == "before"
assert response.usage == Usage(requests=2, input_tokens=3, output_tokens=4, total_tokens=7)
assert response.response_id == "resp_before"
assert response.raw_usage == {"provider": {"cached_tokens": 1}}
@pytest.mark.asyncio
async def test_scripted_model_snapshots_output_shorthand_when_queued() -> None:
output_item = assistant_message("before")
output = [output_item]
model = ScriptedModel([output])
message = cast(ResponseOutputMessage, output_item)
text = cast(ResponseOutputText, message.content[0])
text.text = "after"
output.clear()
response = await model.get_response(
None,
[],
ModelSettings(),
[],
None,
[],
ModelTracing.DISABLED,
previous_response_id=None,
conversation_id=None,
prompt=None,
)
queued_message = cast(ResponseOutputMessage, response.output[0])
queued_text = cast(ResponseOutputText, queued_message.content[0])
assert queued_text.text == "before"
@pytest.mark.asyncio
async def test_scripted_model_records_stream_factory_errors_on_generation_span() -> None:
async def stream(_call) -> AsyncIterator[TResponseStreamEvent]:
yield ResponseCreatedEvent(
type="response.created",
response=get_response_obj([]),
sequence_number=0,
)
raise RuntimeError("stream failed")
model = ScriptedModel([ModelStep.stream(stream)], emit_traces=True)
with pytest.raises(RuntimeError, match="stream failed"):
result = Runner.run_streamed(Agent(name="test", model=model), "hi")
async for _event in result.stream_events():
pass
assert fetch_span_errors("generation") == [
{
"message": "Error",
"data": {"name": "RuntimeError", "message": "stream failed"},
}
]
@pytest.mark.asyncio
async def test_scripted_model_marks_span_current_only_while_advancing_stream_factory() -> None:
observed_spans: list[Any] = []
async def stream(_call) -> AsyncIterator[TResponseStreamEvent]:
observed_spans.append(Scope.get_current_span())
yield ResponseCreatedEvent(
type="response.created",
response=get_response_obj([]),
sequence_number=0,
)
observed_spans.append(Scope.get_current_span())
model = ScriptedModel([ModelStep.stream(stream)], emit_traces=True)
async for _event in model.stream_response(
None,
[],
ModelSettings(),
[],
None,
[],
ModelTracing.DISABLED,
previous_response_id=None,
conversation_id=None,
prompt=None,
):
assert Scope.get_current_span() is None
assert len(observed_spans) == 2
assert observed_spans[0] is observed_spans[1]
assert observed_spans[0] is not None
assert observed_spans[0].span_data.type == "generation"
@pytest.mark.asyncio
async def test_scripted_model_closes_exact_stream_when_outer_stream_is_closed() -> None:
closed = False
close_span: Any = None
async def stream(_call) -> AsyncIterator[TResponseStreamEvent]:
nonlocal closed, close_span
try:
yield ResponseCreatedEvent(
type="response.created",
response=get_response_obj([]),
sequence_number=0,
)
await asyncio.Event().wait()
finally:
closed = True
close_span = Scope.get_current_span()
model = ScriptedModel([ModelStep.stream(stream)], emit_traces=True)
outer = model.stream_response(
None,
[],
ModelSettings(),
[],
None,
[],
ModelTracing.DISABLED,
previous_response_id=None,
conversation_id=None,
prompt=None,
)
await anext(outer)
assert closed is False
await outer.aclose()
assert closed is True
assert close_span is not None
assert close_span.span_data.type == "generation"
assert Scope.get_current_span() is None
@pytest.mark.asyncio
async def test_scripted_model_closes_exact_stream_when_consumer_is_cancelled() -> None:
blocked = asyncio.Event()
closed = asyncio.Event()
async def stream(_call) -> AsyncIterator[TResponseStreamEvent]:
try:
yield ResponseCreatedEvent(
type="response.created",
response=get_response_obj([]),
sequence_number=0,
)
blocked.set()
await asyncio.Event().wait()
finally:
closed.set()
model = ScriptedModel([ModelStep.stream(stream)])
outer = model.stream_response(
None,
[],
ModelSettings(),
[],
None,
[],
ModelTracing.DISABLED,
previous_response_id=None,
conversation_id=None,
prompt=None,
)
await anext(outer)
pending = asyncio.create_task(anext(outer))
await blocked.wait()
pending.cancel()
with pytest.raises(asyncio.CancelledError):
await pending
assert closed.is_set()
@pytest.mark.asyncio
async def test_scripted_model_preserves_cancellation_when_exact_stream_close_fails() -> None:
class FailingCloseStream:
def __init__(self) -> None:
self.blocked = asyncio.Event()
self.close_calls = 0
def __aiter__(self) -> FailingCloseStream:
return self
async def __anext__(self) -> TResponseStreamEvent:
self.blocked.set()
await asyncio.Event().wait()
raise AssertionError("unreachable")
async def aclose(self) -> None:
self.close_calls += 1
raise RuntimeError("close failed")
inner = FailingCloseStream()
model = ScriptedModel([ModelStep.stream(lambda _call: inner)])
outer = model.stream_response(
None,
[],
ModelSettings(),
[],
None,
[],
ModelTracing.DISABLED,
previous_response_id=None,
conversation_id=None,
prompt=None,
)
pending = asyncio.create_task(anext(outer))
await inner.blocked.wait()
pending.cancel()
with pytest.raises(asyncio.CancelledError):
await pending
assert inner.close_calls == 1
@pytest.mark.asyncio
async def test_scripted_model_surfaces_exact_stream_close_failure_after_exhaustion() -> None:
close_error = RuntimeError("close failed")
class FailingCloseStream:
def __aiter__(self) -> FailingCloseStream:
return self
async def __anext__(self) -> TResponseStreamEvent:
raise StopAsyncIteration
async def aclose(self) -> None:
raise close_error
inner = FailingCloseStream()
model = ScriptedModel([ModelStep.stream(lambda _call: inner)], emit_traces=True)
agent = Agent(name="test", model=model)
with pytest.raises(RuntimeError, match="close failed") as exc_info:
result = Runner.run_streamed(agent, "hi")
async for _event in result.stream_events():
pass
assert exc_info.value is close_error
assert fetch_span_errors("generation") == [
{
"message": "Error",
"data": {"name": "RuntimeError", "message": "close failed"},
}
]
@pytest.mark.asyncio
async def test_scripted_model_preserves_exact_stream_error_when_close_fails() -> None:
stream_error = RuntimeError("stream failed")
class FailingStream:
def __init__(self) -> None:
self.close_calls = 0
def __aiter__(self) -> FailingStream:
return self
async def __anext__(self) -> TResponseStreamEvent:
raise stream_error
async def aclose(self) -> None:
self.close_calls += 1
raise RuntimeError("close failed")
inner = FailingStream()
model = ScriptedModel([ModelStep.stream(lambda _call: inner)], emit_traces=True)
agent = Agent(name="test", model=model)
with pytest.raises(RuntimeError, match="stream failed") as exc_info:
result = Runner.run_streamed(agent, "hi")
async for _event in result.stream_events():
pass
assert exc_info.value is stream_error
assert inner.close_calls == 1
assert fetch_span_errors("generation") == [
{
"message": "Error",
"data": {"name": "RuntimeError", "message": "stream failed"},
}
]
@pytest.mark.parametrize("emit_traces", [False, True])
def test_scripted_model_early_stream_exit_has_task_safe_span_cleanup(emit_traces: bool) -> None:
loop = asyncio.new_event_loop()
errors: list[dict[str, Any]] = []
loop.set_exception_handler(lambda _loop, context: errors.append(context))
async def consume_one_event() -> None:
model = ScriptedModel([[assistant_message("hello")]], emit_traces=emit_traces)
async for _event in model.stream_response(
None,
[],
ModelSettings(),
[],
None,
[],
ModelTracing.DISABLED,
previous_response_id=None,
conversation_id=None,
prompt=None,
):
assert Scope.get_current_span() is None
break
try:
loop.run_until_complete(consume_one_event())
loop.run_until_complete(loop.shutdown_asyncgens())
finally:
loop.close()
assert errors == []
@pytest.mark.asyncio
@pytest.mark.parametrize("streamed", [False, True])
async def test_scripted_model_preserves_unformattable_responder_error(streamed: bool) -> None:
class UnformattableError(RuntimeError):
def __str__(self) -> str:
raise RuntimeError("format failed")
expected = UnformattableError()
def respond(_call: ModelCall) -> Any:
raise expected
model = ScriptedModel([ModelStep.respond(respond)], emit_traces=True)
agent = Agent(name="test", model=model)
with pytest.raises(UnformattableError) as exc_info:
if streamed:
result = Runner.run_streamed(agent, "hi")
async for _event in result.stream_events():
pass
else:
await Runner.run(agent, "hi")
assert exc_info.value is expected
assert fetch_span_errors("generation") == [
{
"message": "Error",
"data": {
"name": "UnformattableError",
"message": "Unrenderable UnformattableError",
},
}
]
@pytest.mark.asyncio
@pytest.mark.parametrize("streamed", [False, True])
async def test_scripted_model_timeout_records_generation_span_error(streamed: bool) -> None:
async def respond(_call: ModelCall) -> Any:
await asyncio.Event().wait()
raise AssertionError("unreachable")
model = ScriptedModel([ModelStep.respond(respond)], emit_traces=True)
agent = Agent(
name="test",
model=model,
model_settings=ModelSettings(timeout=0.01),
)
with pytest.raises(ModelTimeoutError):
if streamed:
result = Runner.run_streamed(agent, "hi")
async for _event in result.stream_events():
pass
else:
await Runner.run(agent, "hi")
assert fetch_span_errors("generation") == [
{
"message": "Error",
"data": {"error": "Model call timed out after 0.01 seconds."},
}
]
def test_scripted_model_ignores_span_attachment_failure() -> None:
class FailingSpan:
def set_error(self, _error: SpanError) -> None:
raise KeyboardInterrupt
ScriptedModel._set_span_error(FailingSpan(), RuntimeError("model failed"), ModelTracing.ENABLED)
def test_scripted_model_contains_base_exception_from_error_formatting() -> None:
class UnformattableError(RuntimeError):
def __str__(self) -> str:
raise KeyboardInterrupt
class RecordingSpan:
def __init__(self) -> None:
self.error: SpanError | None = None
def set_error(self, error: SpanError) -> None:
self.error = error
span = RecordingSpan()
ScriptedModel._set_span_error(span, UnformattableError(), ModelTracing.ENABLED)
assert span.error == SpanError(
message="Error",
data={
"name": "UnformattableError",
"message": "Unrenderable UnformattableError",
},
)
def test_scripted_model_redacts_span_error_without_rendering_exception() -> None:
class SensitiveError(RuntimeError):
def __init__(self) -> None:
self.str_calls = 0
def __str__(self) -> str:
self.str_calls += 1
return "sensitive payload"
class RecordingSpan:
def __init__(self) -> None:
self.error: SpanError | None = None
def set_error(self, error: SpanError) -> None:
self.error = error
error = SensitiveError()
span = RecordingSpan()
ScriptedModel._set_span_error(span, error, ModelTracing.ENABLED_WITHOUT_DATA)
assert error.str_calls == 0
assert span.error == SpanError(
message="Error",
data={
"name": "SensitiveError",
"message": REDACTED_TRACE_ERROR_MESSAGE,
},
)
@pytest.mark.asyncio
@pytest.mark.parametrize("streamed", [False, True])
@pytest.mark.parametrize("awaitable", [False, True])
async def test_scripted_model_records_responder_errors_on_generation_span(
streamed: bool,
awaitable: bool,
) -> None:
expected = RuntimeError("responder failed")
def respond(_call: ModelCall) -> Any:
raise expected
async def respond_async(_call: ModelCall) -> Any:
raise expected
model = ScriptedModel(
[ModelStep.respond(respond_async if awaitable else respond)],
emit_traces=True,
)
agent = Agent(name="test", model=model)
with pytest.raises(RuntimeError, match="responder failed") as exc_info:
if streamed:
result = Runner.run_streamed(agent, "hi")
async for _event in result.stream_events():
pass
else:
await Runner.run(agent, "hi")
assert exc_info.value is expected
assert fetch_span_errors("generation") == [
{
"message": "Error",
"data": {"name": "RuntimeError", "message": "responder failed"},
}
]
@pytest.mark.asyncio
@pytest.mark.parametrize("streamed", [False, True])
async def test_scripted_model_redacts_generation_span_errors_from_run_config(
streamed: bool,
) -> None:
expected = RuntimeError("sensitive provider payload")
model = ScriptedModel([ModelStep.raise_error(expected)], emit_traces=True)
agent = Agent(name="test", model=model)
run_config = RunConfig(trace_include_sensitive_data=False)
with pytest.raises(RuntimeError) as exc_info:
if streamed:
result = Runner.run_streamed(agent, "hi", run_config=run_config)
async for _event in result.stream_events():
pass
else:
await Runner.run(agent, "hi", run_config=run_config)
assert exc_info.value is expected
assert fetch_span_errors("generation") == [
{
"message": "Error",
"data": {
"name": "RuntimeError",
"message": REDACTED_TRACE_ERROR_MESSAGE,
},
}
]
@pytest.mark.asyncio
@pytest.mark.parametrize("call_id", [None, "", 123])
async def test_scripted_model_rejects_invalid_apply_patch_call_id(call_id: Any) -> None:
output = cast(
TResponseOutputItem,
{
"type": "apply_patch_call",
"id": "patch_item",
"call_id": call_id,
"operation": {"type": "delete_file", "path": "example.txt"},
},
)
model = ScriptedModel([[output]])
with pytest.raises(
ModelBehaviorError,
match="Tool invocations require a non-empty string call ID before execution",
):
await Runner.run(Agent(name="test", model=model), "hi")
@pytest.mark.asyncio
@pytest.mark.parametrize("item_id", [None, "", 123])
async def test_scripted_model_rejects_invalid_apply_patch_item_id(item_id: Any) -> None:
output = cast(
TResponseOutputItem,
{
"type": "apply_patch_call",
"id": item_id,
"call_id": "call_1",
"operation": {"type": "delete_file", "path": "example.txt"},
},
)
model = ScriptedModel([[output]])
with pytest.raises(
ModelBehaviorError,
match="Apply-patch tool calls require a non-empty string item ID when provided",
):
await Runner.run(Agent(name="test", model=model), "hi")
@pytest.mark.asyncio
async def test_scripted_model_defaults_omitted_apply_patch_item_id_to_call_id() -> None:
output = cast(
TResponseOutputItem,
{
"type": "apply_patch_call",
"call_id": "call_1",
"operation": {"type": "delete_file", "path": "example.txt"},
},
)
model = ScriptedModel([[output]])
response = await model.get_response(
None,
[],
ModelSettings(),
[],
None,
[],
ModelTracing.DISABLED,
previous_response_id=None,
conversation_id=None,
prompt=None,
)
item = response.output[0]
assert isinstance(item, ResponseApplyPatchToolCall)
assert item.id == "call_1"
assert item.status == "completed"
@pytest.mark.asyncio
@pytest.mark.parametrize("status", [None, "", "invalid"])
async def test_scripted_model_rejects_invalid_explicit_apply_patch_status(status: Any) -> None:
output = cast(
TResponseOutputItem,
{
"type": "apply_patch_call",
"id": "patch_item",
"call_id": "call_1",
"status": status,
"operation": {"type": "delete_file", "path": "example.txt"},
},
)
model = ScriptedModel([[output]])
with pytest.raises(ValidationError):
await model.get_response(
None,
[],
ModelSettings(),
[],
None,
[],
ModelTracing.DISABLED,
previous_response_id=None,
conversation_id=None,
prompt=None,
)
@pytest.mark.asyncio
async def test_scripted_model_function_argument_events_use_output_item_id() -> None:
model = ScriptedModel([[function_call("lookup", "{}", call_id="call_1", item_id="item_1")]])
events = [
event
async for event in model.stream_response(
None,
[],
ModelSettings(),
[],
None,
[],
ModelTracing.DISABLED,
previous_response_id=None,
conversation_id=None,
prompt=None,
)
]
argument_events = [
event
for event in events
if isinstance(
event,
ResponseFunctionCallArgumentsDeltaEvent | ResponseFunctionCallArgumentsDoneEvent,
)
]
assert [event.item_id for event in argument_events] == ["item_1", "item_1"]
@pytest.mark.asyncio
async def test_scripted_model_function_argument_events_preserve_empty_output_item_id() -> None:
output = function_call("lookup", "{}", call_id="call_1", item_id="")
model = ScriptedModel([[output]])
events = [
event
async for event in model.stream_response(
None,
[],
ModelSettings(),
[],
None,
[],
ModelTracing.DISABLED,
previous_response_id=None,
conversation_id=None,
prompt=None,
)
]
item_events = [
event
for event in events
if isinstance(event, ResponseOutputItemAddedEvent | ResponseOutputItemDoneEvent)
]
argument_events = [
event
for event in events
if isinstance(
event,
ResponseFunctionCallArgumentsDeltaEvent | ResponseFunctionCallArgumentsDoneEvent,
)
]
assert [event.item.id for event in item_events] == ["", ""]
assert [event.item_id for event in argument_events] == ["", ""]
@pytest.mark.asyncio
async def test_scripted_model_function_argument_events_fall_back_to_call_id() -> None:
output = ResponseFunctionToolCall(
type="function_call",
name="lookup",
arguments="{}",
call_id="call_1",
)
model = ScriptedModel([[output]])
events = [
event
async for event in model.stream_response(
None,
[],
ModelSettings(),
[],
None,
[],
ModelTracing.DISABLED,
previous_response_id=None,
conversation_id=None,
prompt=None,
)
]
argument_events = [
event
for event in events
if isinstance(
event,
ResponseFunctionCallArgumentsDeltaEvent | ResponseFunctionCallArgumentsDoneEvent,
)
]
assert [event.item_id for event in argument_events] == ["call_1", "call_1"]
@pytest.mark.asyncio
async def test_scripted_model_automatic_stream_uses_in_progress_added_payloads() -> None:
reasoning = ResponseReasoningItem(
id="reasoning_1",
summary=[Summary(text="summary", type="summary_text")],
encrypted_content="encrypted-reasoning",
type="reasoning",
status="completed",
)
message = assistant_message("hello", item_id="message_1")
function = function_call(
"lookup",
{"city": "Tokyo"},
call_id="call_1",
item_id="function_1",
)
completed_items = [reasoning, message, function]
usage = Usage(requests=3, input_tokens=4, output_tokens=5, total_tokens=9)
model = ScriptedModel([ModelStep(output=completed_items, usage=usage)])
events = [
event
async for event in model.stream_response(
None,
[],
ModelSettings(),
[],
None,
[],
ModelTracing.DISABLED,
previous_response_id=None,
conversation_id=None,
prompt=None,
)
]
opening_responses = [
event.response
for event in events
if isinstance(event, ResponseCreatedEvent | ResponseInProgressEvent)
]
assert [response.output for response in opening_responses] == [[], []]
assert [response.status for response in opening_responses] == ["in_progress", "in_progress"]
assert [response.usage for response in opening_responses] == [None, None]
added_items = [
event.item for event in events if isinstance(event, ResponseOutputItemAddedEvent)
]
assert isinstance(added_items[0], ResponseReasoningItem)
assert added_items[0].encrypted_content is None
assert added_items[0].summary == []
assert added_items[0].status == "in_progress"
assert isinstance(added_items[1], ResponseOutputMessage)
assert added_items[1].content == []
assert added_items[1].status == "in_progress"
assert isinstance(added_items[2], ResponseFunctionToolCall)
assert added_items[2].arguments == ""
assert added_items[2].status == "in_progress"
added_summary_parts = [
event.part for event in events if isinstance(event, ResponseReasoningSummaryPartAddedEvent)
]
assert [part.text for part in added_summary_parts] == [""]
added_content_parts = [
event.part for event in events if isinstance(event, ResponseContentPartAddedEvent)
]
assert [part.text for part in added_content_parts] == [""]
done_items = [event.item for event in events if isinstance(event, ResponseOutputItemDoneEvent)]
assert done_items == completed_items
assert cast(ResponseReasoningItem, done_items[0]).encrypted_content == "encrypted-reasoning"
completed_event = next(event for event in events if isinstance(event, ResponseCompletedEvent))
assert completed_event.response.status == "completed"
assert completed_event.response.output == completed_items
assert (
cast(ResponseReasoningItem, completed_event.response.output[0]).encrypted_content
== "encrypted-reasoning"
)
assert completed_event.response.usage is not None
assert completed_event.response.usage.input_tokens == 4
assert completed_event.response.usage.output_tokens == 5
assert completed_event.response.usage.total_tokens == 9
request_count_attribute = "_agents_sdk_request_count"
assert getattr(completed_event.response.usage, request_count_attribute) == 3
@pytest.mark.asyncio
async def test_scripted_model_automatic_stream_detaches_done_item_from_terminal_response() -> None:
model = ScriptedModel([[assistant_message("original", item_id="message_1")]])
completed_event: ResponseCompletedEvent | None = None
async for event in model.stream_response(
None,
[],
ModelSettings(),
[],
None,
[],
ModelTracing.DISABLED,
previous_response_id=None,
conversation_id=None,
prompt=None,
):
if isinstance(event, ResponseOutputItemDoneEvent):
assert isinstance(event.item, ResponseOutputMessage)
assert isinstance(event.item.content[0], ResponseOutputText)
event.item.content[0].text = "mutated"
elif isinstance(event, ResponseCompletedEvent):
completed_event = event
assert completed_event is not None
terminal_item = completed_event.response.output[0]
assert isinstance(terminal_item, ResponseOutputMessage)
assert isinstance(terminal_item.content[0], ResponseOutputText)
assert terminal_item.content[0].text == "original"
@pytest.mark.asyncio
async def test_scripted_model_automatic_stream_detaches_content_part_event_payloads() -> None:
text = ResponseOutputText.model_validate(
{
"type": "output_text",
"text": "original text",
"annotations": [],
"logprobs": [],
"provider_data": {"nested": ["original text"]},
}
)
refusal = ResponseOutputRefusal.model_validate(
{
"type": "refusal",
"refusal": "original refusal",
"provider_data": {"nested": ["original refusal"]},
}
)
message = ResponseOutputMessage(
id="message_1",
type="message",
role="assistant",
status="completed",
content=[text, refusal],
)
model = ScriptedModel([[message]])
done_parts: list[ResponseOutputText | ResponseOutputRefusal] = []
done_item: ResponseOutputMessage | None = None
completed_item: ResponseOutputMessage | None = None
async for event in model.stream_response(
None,
[],
ModelSettings(),
[],
None,
[],
ModelTracing.DISABLED,
previous_response_id=None,
conversation_id=None,
prompt=None,
):
if isinstance(event, ResponseContentPartAddedEvent):
cast(Any, event.part).provider_data["nested"][0] = "mutated"
elif isinstance(event, ResponseContentPartDoneEvent):
done_parts.append(event.part)
elif isinstance(event, ResponseOutputItemDoneEvent):
assert isinstance(event.item, ResponseOutputMessage)
done_item = event.item
elif isinstance(event, ResponseCompletedEvent):
assert isinstance(event.response.output[0], ResponseOutputMessage)
completed_item = event.response.output[0]
expected_provider_data = [
{"nested": ["original text"]},
{"nested": ["original refusal"]},
]
assert [cast(Any, part).provider_data for part in done_parts] == expected_provider_data
assert done_item is not None
assert [cast(Any, part).provider_data for part in done_item.content] == expected_provider_data
assert completed_item is not None
assert [
cast(Any, part).provider_data for part in completed_item.content
] == expected_provider_data
@pytest.mark.asyncio
async def test_scripted_model_automatic_stream_preserves_text_logprobs() -> None:
output_logprobs = [
Logprob(
token="hello",
bytes=[104, 101, 108, 108, 111],
logprob=-0.25,
top_logprobs=[
LogprobTopLogprob(
token="hi",
bytes=[104, 105],
logprob=-1.5,
)
],
),
Logprob(
token="!",
bytes=[33],
logprob=-0.1,
top_logprobs=[],
),
]
text = ResponseOutputText(
type="output_text",
text="hello",
annotations=[],
logprobs=output_logprobs,
)
message = ResponseOutputMessage(
id="message_1",
type="message",
role="assistant",
status="completed",
content=[text],
)
model = ScriptedModel([[message]])
events = [
event
async for event in model.stream_response(
None,
[],
ModelSettings(),
[],
None,
[],
ModelTracing.DISABLED,
previous_response_id=None,
conversation_id=None,
prompt=None,
)
]
added_event = next(
event for event in events if isinstance(event, ResponseContentPartAddedEvent)
)
delta_event = next(event for event in events if isinstance(event, ResponseTextDeltaEvent))
done_event = next(event for event in events if isinstance(event, ResponseTextDoneEvent))
assert isinstance(added_event.part, ResponseOutputText)
assert added_event.part.logprobs == []
assert [(logprob.token, logprob.logprob) for logprob in delta_event.logprobs] == [
("hello", -0.25),
("!", -0.1),
]
assert delta_event.logprobs[0].top_logprobs is not None
assert [logprob.token for logprob in delta_event.logprobs[0].top_logprobs] == ["hi"]
assert delta_event.logprobs[1].top_logprobs == []
assert [(logprob.token, logprob.logprob) for logprob in done_event.logprobs] == [
("hello", -0.25),
("!", -0.1),
]
assert done_event.logprobs[0].top_logprobs is not None
assert [logprob.token for logprob in done_event.logprobs[0].top_logprobs] == ["hi"]
assert done_event.logprobs[1].top_logprobs == []
completed_event = next(event for event in events if isinstance(event, ResponseCompletedEvent))
completed_message = completed_event.response.output[0]
assert isinstance(completed_message, ResponseOutputMessage)
completed_text = completed_message.content[0]
assert isinstance(completed_text, ResponseOutputText)
assert completed_text.logprobs == output_logprobs
@pytest.mark.asyncio
async def test_scripted_model_automatic_stream_emits_text_annotation_events() -> None:
annotations = [
AnnotationURLCitation(
end_index=5,
start_index=0,
title="Example",
type="url_citation",
url="https://example.test",
),
AnnotationFilePath(
file_id="file_1",
index=6,
type="file_path",
),
]
text = ResponseOutputText(
type="output_text",
text="hello file",
annotations=annotations,
logprobs=[],
)
message = ResponseOutputMessage(
id="message_1",
type="message",
role="assistant",
status="completed",
content=[text],
)
model = ScriptedModel([[message]])
events = [
event
async for event in model.stream_response(
None,
[],
ModelSettings(),
[],
None,
[],
ModelTracing.DISABLED,
previous_response_id=None,
conversation_id=None,
prompt=None,
)
]
added_part = next(event for event in events if isinstance(event, ResponseContentPartAddedEvent))
annotation_events = [
event for event in events if isinstance(event, ResponseOutputTextAnnotationAddedEvent)
]
text_done = next(event for event in events if isinstance(event, ResponseTextDoneEvent))
assert isinstance(added_part.part, ResponseOutputText)
assert added_part.part.annotations == []
assert [event.annotation_index for event in annotation_events] == [0, 1]
assert [event.model_dump()["annotation"] for event in annotation_events] == [
annotation.model_dump() for annotation in annotations
]
assert all(event.item_id == "message_1" for event in annotation_events)
assert all(event.output_index == 0 for event in annotation_events)
assert all(event.content_index == 0 for event in annotation_events)
assert annotation_events[0].sequence_number == added_part.sequence_number + 2
assert annotation_events[1].sequence_number == annotation_events[0].sequence_number + 1
assert text_done.sequence_number == annotation_events[1].sequence_number + 1
@pytest.mark.asyncio
async def test_scripted_model_automatic_stream_emits_reasoning_content_events() -> None:
reasoning = ResponseReasoningItem(
id="reasoning_1",
summary=[],
content=[Content(text="think carefully", type="reasoning_text")],
type="reasoning",
status="completed",
)
model = ScriptedModel([[reasoning]])
events = [
event
async for event in model.stream_response(
None,
[],
ModelSettings(),
[],
None,
[],
ModelTracing.DISABLED,
previous_response_id=None,
conversation_id=None,
prompt=None,
)
]
added_event = next(event for event in events if isinstance(event, ResponseOutputItemAddedEvent))
assert isinstance(added_event.item, ResponseReasoningItem)
assert added_event.item.content == []
delta_event = next(
event for event in events if isinstance(event, ResponseReasoningTextDeltaEvent)
)
done_event = next(
event for event in events if isinstance(event, ResponseReasoningTextDoneEvent)
)
assert delta_event.item_id == "reasoning_1"
assert delta_event.output_index == 0
assert delta_event.content_index == 0
assert delta_event.delta == "think carefully"
assert done_event.item_id == "reasoning_1"
assert done_event.output_index == 0
assert done_event.content_index == 0
assert done_event.text == "think carefully"
assert done_event.sequence_number == delta_event.sequence_number + 1
@pytest.mark.asyncio
async def test_scripted_model_automatic_stream_marks_added_apply_patch_call_in_progress() -> None:
apply_patch_call = ResponseApplyPatchToolCall(
type="apply_patch_call",
id="apply_patch_1",
call_id="call_1",
status="completed",
operation=cast(
Any,
{"type": "update_file", "path": "test.md", "diff": "-old\n+new\n"},
),
)
model = ScriptedModel([[apply_patch_call]])
events = [
event
async for event in model.stream_response(
None,
[],
ModelSettings(),
[],
None,
[],
ModelTracing.DISABLED,
previous_response_id=None,
conversation_id=None,
prompt=None,
)
]
added_event = next(event for event in events if isinstance(event, ResponseOutputItemAddedEvent))
done_event = next(event for event in events if isinstance(event, ResponseOutputItemDoneEvent))
completed_event = next(event for event in events if isinstance(event, ResponseCompletedEvent))
assert isinstance(added_event.item, ResponseApplyPatchToolCall)
assert added_event.item.status == "in_progress"
assert isinstance(done_event.item, ResponseApplyPatchToolCall)
assert done_event.item.status == "completed"
assert isinstance(completed_event.response.output[0], ResponseApplyPatchToolCall)
assert completed_event.response.output[0].status == "completed"
@pytest.mark.asyncio
async def test_scripted_model_automatic_stream_emits_refusal_content_events() -> None:
refusal = ResponseOutputRefusal(type="refusal", refusal="I cannot help with that.")
message = ResponseOutputMessage(
id="message_1",
type="message",
role="assistant",
status="completed",
content=[refusal],
)
model = ScriptedModel([[message]])
events = [
event
async for event in model.stream_response(
None,
[],
ModelSettings(),
[],
None,
[],
ModelTracing.DISABLED,
previous_response_id=None,
conversation_id=None,
prompt=None,
)
]
assert [event.type for event in events] == [
"response.created",
"response.in_progress",
"response.output_item.added",
"response.content_part.added",
"response.refusal.delta",
"response.refusal.done",
"response.content_part.done",
"response.output_item.done",
"response.completed",
]
assert [event.sequence_number for event in events] == list(range(len(events)))
content_events = [
event
for event in events
if isinstance(
event,
ResponseContentPartAddedEvent
| ResponseRefusalDeltaEvent
| ResponseRefusalDoneEvent
| ResponseContentPartDoneEvent,
)
]
assert [event.type for event in content_events] == [
"response.content_part.added",
"response.refusal.delta",
"response.refusal.done",
"response.content_part.done",
]
assert all(event.item_id == "message_1" for event in content_events)
assert all(event.output_index == 0 for event in content_events)
assert all(event.content_index == 0 for event in content_events)
added, delta, refusal_done, content_done = content_events
assert isinstance(added, ResponseContentPartAddedEvent)
assert isinstance(added.part, ResponseOutputRefusal)
assert added.part.refusal == ""
assert isinstance(delta, ResponseRefusalDeltaEvent)
assert delta.delta == "I cannot help with that."
assert isinstance(refusal_done, ResponseRefusalDoneEvent)
assert refusal_done.refusal == "I cannot help with that."
assert isinstance(content_done, ResponseContentPartDoneEvent)
assert content_done.part == refusal
@pytest.mark.asyncio
async def test_scripted_model_rejects_unexpected_call() -> None:
model = ScriptedModel()
with pytest.raises(UnexpectedModelCall, match="no scripted steps remain") as exc_info:
await Runner.run(Agent(name="test", model=model), "hi")
assert exc_info.value.call_index == 0
assert "call #1" in str(exc_info.value)
assert exc_info.value.call.streamed is False
assert exc_info.value.call.input == [{"content": "hi", "role": "user"}]
exc_info.value.call.input[0]["content"] = "changed"
assert model.calls[0].input == [{"content": "hi", "role": "user"}]
@pytest.mark.asyncio
async def test_scripted_model_unexpected_streaming_call_records_streamed_attribute() -> None:
model = ScriptedModel()
with pytest.raises(UnexpectedModelCall) as exc_info:
async for _event in model.stream_response(
None,
[],
ModelSettings(),
[],
None,
[],
ModelTracing.DISABLED,
previous_response_id=None,
conversation_id=None,
prompt=None,
):
pass
assert exc_info.value.call_index == 0
assert exc_info.value.call.streamed is True
def test_scripted_model_reports_unconsumed_steps() -> None:
model = ScriptedModel([[assistant_message("unused")]])
with pytest.raises(UnconsumedModelSteps, match="1 scripted model step") as exc_info:
model.assert_complete()
assert exc_info.value.remaining_steps == 1