chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any, Literal, TypeVar, cast
|
||||
|
||||
from openai.types.responses import (
|
||||
ResponseFunctionToolCall,
|
||||
ResponseOutputMessage,
|
||||
ResponseOutputText,
|
||||
)
|
||||
|
||||
from agents import Agent
|
||||
from agents._tool_identity import FunctionToolLookupKey, get_function_tool_lookup_key
|
||||
from agents.items import ToolApprovalItem
|
||||
from agents.run_context import RunContextWrapper
|
||||
from agents.run_state import RunState
|
||||
from agents.sandbox.session.sandbox_session_state import SandboxSessionState
|
||||
|
||||
TContext = TypeVar("TContext")
|
||||
_AUTO_LOOKUP_KEY = object()
|
||||
|
||||
|
||||
class TestSessionState(SandboxSessionState):
|
||||
"""Concrete ``SandboxSessionState`` subclass for tests that don't need a real backend."""
|
||||
|
||||
__test__ = False
|
||||
type: Literal["test"] = "test"
|
||||
|
||||
|
||||
def make_tool_call(
|
||||
call_id: str = "call_1",
|
||||
*,
|
||||
name: str = "test_tool",
|
||||
namespace: str | None = None,
|
||||
status: Literal["in_progress", "completed", "incomplete"] | None = "completed",
|
||||
arguments: str = "{}",
|
||||
call_type: Literal["function_call"] = "function_call",
|
||||
) -> ResponseFunctionToolCall:
|
||||
"""Build a ResponseFunctionToolCall with common defaults."""
|
||||
|
||||
kwargs: dict[str, Any] = {
|
||||
"type": call_type,
|
||||
"name": name,
|
||||
"call_id": call_id,
|
||||
"status": status,
|
||||
"arguments": arguments,
|
||||
}
|
||||
if namespace is not None:
|
||||
kwargs["namespace"] = namespace
|
||||
return ResponseFunctionToolCall(**kwargs)
|
||||
|
||||
|
||||
def make_tool_approval_item(
|
||||
agent: Agent[Any],
|
||||
*,
|
||||
call_id: str = "call_1",
|
||||
name: str = "test_tool",
|
||||
namespace: str | None = None,
|
||||
allow_bare_name_alias: bool = False,
|
||||
status: Literal["in_progress", "completed", "incomplete"] | None = "completed",
|
||||
arguments: str = "{}",
|
||||
tool_lookup_key: FunctionToolLookupKey | None | object = _AUTO_LOOKUP_KEY,
|
||||
) -> ToolApprovalItem:
|
||||
"""Create a ToolApprovalItem backed by a function call."""
|
||||
|
||||
resolved_tool_lookup_key: FunctionToolLookupKey | None
|
||||
if tool_lookup_key is _AUTO_LOOKUP_KEY:
|
||||
resolved_tool_lookup_key = get_function_tool_lookup_key(name, namespace)
|
||||
else:
|
||||
resolved_tool_lookup_key = cast(FunctionToolLookupKey | None, tool_lookup_key)
|
||||
|
||||
return ToolApprovalItem(
|
||||
agent=agent,
|
||||
raw_item=make_tool_call(
|
||||
call_id=call_id,
|
||||
name=name,
|
||||
namespace=namespace,
|
||||
status=status,
|
||||
arguments=arguments,
|
||||
),
|
||||
tool_namespace=namespace,
|
||||
tool_lookup_key=resolved_tool_lookup_key,
|
||||
_allow_bare_name_alias=allow_bare_name_alias,
|
||||
)
|
||||
|
||||
|
||||
def make_message_output(
|
||||
*,
|
||||
message_id: str = "msg_1",
|
||||
text: str = "Hello",
|
||||
role: Literal["assistant"] = "assistant",
|
||||
status: Literal["in_progress", "completed", "incomplete"] = "completed",
|
||||
) -> ResponseOutputMessage:
|
||||
"""Create a minimal ResponseOutputMessage."""
|
||||
|
||||
return ResponseOutputMessage(
|
||||
id=message_id,
|
||||
type="message",
|
||||
role=role,
|
||||
status=status,
|
||||
content=[ResponseOutputText(type="output_text", text=text, annotations=[], logprobs=[])],
|
||||
)
|
||||
|
||||
|
||||
def make_run_state(
|
||||
agent: Agent[Any],
|
||||
*,
|
||||
context: RunContextWrapper[TContext] | dict[str, Any] | None = None,
|
||||
original_input: Any = "input",
|
||||
max_turns: int | None = 3,
|
||||
) -> RunState[TContext, Agent[Any]]:
|
||||
"""Create a RunState with sensible defaults for tests."""
|
||||
|
||||
wrapper: RunContextWrapper[TContext]
|
||||
if isinstance(context, RunContextWrapper):
|
||||
wrapper = context
|
||||
else:
|
||||
wrapper = RunContextWrapper(context=context or {}) # type: ignore[arg-type]
|
||||
|
||||
return RunState(
|
||||
context=wrapper,
|
||||
original_input=original_input,
|
||||
starting_agent=agent,
|
||||
max_turns=max_turns,
|
||||
)
|
||||
|
||||
|
||||
async def roundtrip_state(
|
||||
agent: Agent[Any],
|
||||
state: RunState[TContext, Agent[Any]],
|
||||
mutate_json: Callable[[dict[str, Any]], dict[str, Any]] | None = None,
|
||||
) -> RunState[TContext, Agent[Any]]:
|
||||
"""Serialize and restore a RunState, optionally mutating the JSON in between."""
|
||||
|
||||
json_data = state.to_json()
|
||||
if mutate_json is not None:
|
||||
json_data = mutate_json(json_data)
|
||||
return await RunState.from_json(agent, json_data)
|
||||
@@ -0,0 +1,493 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable, Iterable, Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, cast
|
||||
|
||||
from openai.types.responses import ResponseFunctionToolCall
|
||||
|
||||
from agents import Agent, Runner, RunResult, RunResultStreaming
|
||||
from agents.items import ToolApprovalItem, ToolCallOutputItem, TResponseOutputItem
|
||||
from agents.run_context import RunContextWrapper
|
||||
from agents.run_internal.run_loop import NextStepInterruption, SingleStepResult
|
||||
from agents.run_state import RunState as RunStateClass
|
||||
|
||||
from ..fake_model import FakeModel
|
||||
|
||||
HITL_REJECTION_MSG = "Tool execution was not approved."
|
||||
|
||||
|
||||
@dataclass
|
||||
class ApprovalScenario:
|
||||
"""Container for approval-driven tool scenarios."""
|
||||
|
||||
tool: Any
|
||||
raw_call: TResponseOutputItem
|
||||
final_output: TResponseOutputItem
|
||||
assert_result: Callable[[RunResult], None]
|
||||
|
||||
|
||||
@dataclass
|
||||
class PendingScenario:
|
||||
"""Container for scenarios with pending approvals."""
|
||||
|
||||
tool: Any
|
||||
raw_call: TResponseOutputItem
|
||||
assert_result: Callable[[RunResult], None] | None = None
|
||||
|
||||
|
||||
async def roundtrip_interruptions_via_run(
|
||||
agent: Agent[Any],
|
||||
model: FakeModel,
|
||||
raw_call: Any,
|
||||
*,
|
||||
user_input: str = "test",
|
||||
) -> list[ToolApprovalItem]:
|
||||
"""Run once with a tool call, serialize state, and deserialize it."""
|
||||
model.set_next_output([raw_call])
|
||||
result = await Runner.run(agent, user_input)
|
||||
assert result.interruptions, "expected an interruption"
|
||||
state = result.to_state()
|
||||
deserialized_state = await RunStateClass.from_json(agent, state.to_json())
|
||||
return deserialized_state.get_interruptions()
|
||||
|
||||
|
||||
async def assert_roundtrip_tool_name(
|
||||
agent: Agent[Any],
|
||||
model: FakeModel,
|
||||
raw_call: TResponseOutputItem,
|
||||
expected_tool_name: str,
|
||||
*,
|
||||
user_input: str,
|
||||
) -> None:
|
||||
"""Assert that deserialized interruptions keep the tool name intact."""
|
||||
interruptions = await roundtrip_interruptions_via_run(
|
||||
agent, model, raw_call, user_input=user_input
|
||||
)
|
||||
assert interruptions, "Interruptions should be preserved after deserialization"
|
||||
assert interruptions[0].tool_name == expected_tool_name, (
|
||||
f"{expected_tool_name} tool approval should be preserved, not converted to function"
|
||||
)
|
||||
|
||||
|
||||
def make_state_with_interruptions(
|
||||
agent: Agent[Any],
|
||||
interruptions: list[ToolApprovalItem],
|
||||
*,
|
||||
original_input: str = "test",
|
||||
max_turns: int = 10,
|
||||
) -> RunStateClass[Any, Agent[Any]]:
|
||||
"""Create a RunState primed with interruptions."""
|
||||
context = make_context_wrapper()
|
||||
state = RunStateClass(
|
||||
context=context,
|
||||
original_input=original_input,
|
||||
starting_agent=agent,
|
||||
max_turns=max_turns,
|
||||
)
|
||||
state._current_step = NextStepInterruption(interruptions=interruptions)
|
||||
return state
|
||||
|
||||
|
||||
async def assert_tool_output_roundtrip(
|
||||
agent: Agent[Any],
|
||||
raw_output: Any,
|
||||
expected_type: str,
|
||||
*,
|
||||
output: Any = "command output",
|
||||
) -> None:
|
||||
"""Ensure tool outputs keep their type through serialization and deserialization."""
|
||||
context = make_context_wrapper()
|
||||
state = RunStateClass(context=context, original_input="test", starting_agent=agent, max_turns=3)
|
||||
state._generated_items = [
|
||||
ToolCallOutputItem(
|
||||
agent=agent,
|
||||
raw_item=raw_output,
|
||||
output=output,
|
||||
)
|
||||
]
|
||||
|
||||
json_data = state.to_json()
|
||||
|
||||
generated_items_json = json_data.get("generated_items", [])
|
||||
assert len(generated_items_json) == 1, f"{expected_type} item should be serialized"
|
||||
serialized_type = generated_items_json[0].get("raw_item", {}).get("type")
|
||||
|
||||
assert serialized_type == expected_type, (
|
||||
f"Expected {expected_type} in serialized JSON, but got {serialized_type}. "
|
||||
"Serialization should not coerce tool outputs."
|
||||
)
|
||||
|
||||
deserialized_state = await RunStateClass.from_json(agent, json_data)
|
||||
|
||||
assert len(deserialized_state._generated_items) == 1, (
|
||||
f"{expected_type} item should be deserialized."
|
||||
)
|
||||
deserialized_item = deserialized_state._generated_items[0]
|
||||
assert isinstance(deserialized_item, ToolCallOutputItem)
|
||||
|
||||
raw_item = deserialized_item.raw_item
|
||||
output_type = raw_item.get("type") if isinstance(raw_item, dict) else raw_item.type
|
||||
|
||||
assert output_type == expected_type, (
|
||||
f"Expected {expected_type}, but got {output_type}. "
|
||||
"Serialization should preserve the tool output type."
|
||||
)
|
||||
|
||||
|
||||
async def run_and_resume(
|
||||
agent: Agent[Any],
|
||||
model: Any,
|
||||
raw_call: Any,
|
||||
*,
|
||||
user_input: str,
|
||||
) -> RunResult:
|
||||
"""Run once, then resume from the produced state."""
|
||||
model.set_next_output([raw_call])
|
||||
first = await Runner.run(agent, user_input)
|
||||
return await Runner.run(agent, first.to_state())
|
||||
|
||||
|
||||
def approve_first_interruption(
|
||||
result: Any,
|
||||
*,
|
||||
always_approve: bool = False,
|
||||
) -> RunStateClass[Any, Agent[Any]]:
|
||||
"""Approve the first interruption on the result and return the updated state."""
|
||||
assert getattr(result, "interruptions", None), "expected an approval interruption"
|
||||
state = cast(RunStateClass[Any, Agent[Any]], result.to_state())
|
||||
state.approve(result.interruptions[0], always_approve=always_approve)
|
||||
return state
|
||||
|
||||
|
||||
async def resume_after_first_approval(
|
||||
agent: Agent[Any],
|
||||
result: Any,
|
||||
*,
|
||||
always_approve: bool = False,
|
||||
) -> RunResult:
|
||||
"""Approve the first interruption and resume the run."""
|
||||
state = approve_first_interruption(result, always_approve=always_approve)
|
||||
return await Runner.run(agent, state)
|
||||
|
||||
|
||||
async def resume_streamed_after_first_approval(
|
||||
agent: Agent[Any],
|
||||
result: Any,
|
||||
*,
|
||||
always_approve: bool = False,
|
||||
) -> RunResultStreaming:
|
||||
"""Approve the first interruption and resume a streamed run to completion."""
|
||||
state = approve_first_interruption(result, always_approve=always_approve)
|
||||
resumed = Runner.run_streamed(agent, state)
|
||||
await consume_stream(resumed)
|
||||
return resumed
|
||||
|
||||
|
||||
async def run_and_resume_after_approval(
|
||||
agent: Agent[Any],
|
||||
model: Any,
|
||||
raw_call: Any,
|
||||
final_output: Any,
|
||||
*,
|
||||
user_input: str,
|
||||
) -> RunResult:
|
||||
"""Run, approve the first interruption, and resume."""
|
||||
model.set_next_output([raw_call])
|
||||
first = await Runner.run(agent, user_input)
|
||||
state = approve_first_interruption(first, always_approve=True)
|
||||
model.set_next_output([final_output])
|
||||
return await Runner.run(agent, state)
|
||||
|
||||
|
||||
def collect_tool_outputs(
|
||||
items: Iterable[Any],
|
||||
*,
|
||||
output_type: str,
|
||||
) -> list[ToolCallOutputItem]:
|
||||
"""Return ToolCallOutputItems matching a raw_item type."""
|
||||
return [
|
||||
item
|
||||
for item in items
|
||||
if isinstance(item, ToolCallOutputItem)
|
||||
and isinstance(item.raw_item, dict)
|
||||
and item.raw_item.get("type") == output_type
|
||||
]
|
||||
|
||||
|
||||
async def consume_stream(result: Any) -> None:
|
||||
"""Drain all stream events to completion."""
|
||||
async for _ in result.stream_events():
|
||||
pass
|
||||
|
||||
|
||||
def assert_single_approval_interruption(
|
||||
result: SingleStepResult,
|
||||
*,
|
||||
tool_name: str | None = None,
|
||||
) -> ToolApprovalItem:
|
||||
"""Assert the result contains exactly one approval interruption and return it."""
|
||||
assert isinstance(result.next_step, NextStepInterruption)
|
||||
assert len(result.next_step.interruptions) == 1
|
||||
interruption = result.next_step.interruptions[0]
|
||||
assert isinstance(interruption, ToolApprovalItem)
|
||||
if tool_name:
|
||||
assert interruption.tool_name == tool_name
|
||||
return interruption
|
||||
|
||||
|
||||
async def require_approval(
|
||||
_ctx: Any | None = None, _params: Any = None, _call_id: str | None = None
|
||||
) -> bool:
|
||||
"""Approval helper that always requires a HITL decision."""
|
||||
return True
|
||||
|
||||
|
||||
class RecordingEditor:
|
||||
"""Editor that records operations for testing."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.operations: list[Any] = []
|
||||
|
||||
def create_file(self, operation: Any) -> Any:
|
||||
self.operations.append(operation)
|
||||
return {"output": f"Created {operation.path}", "status": "completed"}
|
||||
|
||||
def update_file(self, operation: Any) -> Any:
|
||||
self.operations.append(operation)
|
||||
return {"output": f"Updated {operation.path}", "status": "completed"}
|
||||
|
||||
def delete_file(self, operation: Any) -> Any:
|
||||
self.operations.append(operation)
|
||||
return {"output": f"Deleted {operation.path}", "status": "completed"}
|
||||
|
||||
|
||||
def make_shell_call(
|
||||
call_id: str,
|
||||
*,
|
||||
id_value: str | None = None,
|
||||
commands: list[str] | None = None,
|
||||
status: str = "in_progress",
|
||||
) -> TResponseOutputItem:
|
||||
"""Build a shell_call payload with optional overrides."""
|
||||
return cast(
|
||||
TResponseOutputItem,
|
||||
{
|
||||
"type": "shell_call",
|
||||
"id": id_value or call_id,
|
||||
"call_id": call_id,
|
||||
"status": status,
|
||||
"action": {"type": "exec", "commands": commands or ["echo test"], "timeout_ms": 1000},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def make_apply_patch_dict(call_id: str, diff: str = "-a\n+b\n") -> TResponseOutputItem:
|
||||
"""Create an apply_patch_call dict payload."""
|
||||
return cast(
|
||||
TResponseOutputItem,
|
||||
{
|
||||
"type": "apply_patch_call",
|
||||
"call_id": call_id,
|
||||
"operation": {"type": "update_file", "path": "test.md", "diff": diff},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def make_function_tool_call(
|
||||
name: str,
|
||||
*,
|
||||
call_id: str = "call-1",
|
||||
arguments: str = "{}",
|
||||
namespace: str | None = None,
|
||||
) -> ResponseFunctionToolCall:
|
||||
"""Create a ResponseFunctionToolCall for HITL scenarios."""
|
||||
if namespace is None:
|
||||
return ResponseFunctionToolCall(
|
||||
type="function_call",
|
||||
name=name,
|
||||
call_id=call_id,
|
||||
arguments=arguments,
|
||||
)
|
||||
return ResponseFunctionToolCall(
|
||||
type="function_call",
|
||||
name=name,
|
||||
call_id=call_id,
|
||||
arguments=arguments,
|
||||
namespace=namespace,
|
||||
)
|
||||
|
||||
|
||||
def queue_function_call_and_text(
|
||||
model: FakeModel,
|
||||
function_call: TResponseOutputItem,
|
||||
*,
|
||||
first_turn_extra: Sequence[TResponseOutputItem] | None = None,
|
||||
followup: Sequence[TResponseOutputItem] | None = None,
|
||||
) -> None:
|
||||
"""Queue a function call turn followed by a follow-up turn on the fake model."""
|
||||
raw_type = (
|
||||
function_call.get("type")
|
||||
if isinstance(function_call, dict)
|
||||
else getattr(function_call, "type", None)
|
||||
)
|
||||
assert raw_type == "function_call", "queue_function_call_and_text expects a function call item"
|
||||
model.add_multiple_turn_outputs(
|
||||
[
|
||||
[function_call, *(first_turn_extra or [])],
|
||||
list(followup or []),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def run_and_resume_with_mutation(
|
||||
agent: Agent[Any],
|
||||
model: Any,
|
||||
turn_outputs: Sequence[Sequence[Any]],
|
||||
*,
|
||||
user_input: str,
|
||||
mutate_state: Callable[[RunStateClass[Any, Agent[Any]], ToolApprovalItem], None] | None = None,
|
||||
) -> tuple[RunResult, RunResult]:
|
||||
"""Run until interruption, optionally mutate state, then resume."""
|
||||
model.add_multiple_turn_outputs(turn_outputs)
|
||||
first = await Runner.run(agent, input=user_input)
|
||||
assert first.interruptions, "expected an approval interruption"
|
||||
state = first.to_state()
|
||||
if mutate_state and first.interruptions:
|
||||
mutate_state(state, first.interruptions[0])
|
||||
resumed = await Runner.run(agent, input=state)
|
||||
return first, resumed
|
||||
|
||||
|
||||
async def assert_pending_resume(
|
||||
tool: Any,
|
||||
model: Any,
|
||||
raw_call: TResponseOutputItem,
|
||||
*,
|
||||
user_input: str,
|
||||
output_type: str,
|
||||
) -> RunResult:
|
||||
"""Run, resume, and assert pending approvals stay pending."""
|
||||
agent = make_agent(model=model, tools=[tool])
|
||||
|
||||
resumed = await run_and_resume(agent, model, raw_call, user_input=user_input)
|
||||
|
||||
assert resumed.interruptions, "pending approval should remain after resuming"
|
||||
assert any(
|
||||
isinstance(item, ToolApprovalItem) and item.tool_name == tool.name
|
||||
for item in resumed.interruptions
|
||||
)
|
||||
assert not collect_tool_outputs(resumed.new_items, output_type=output_type), (
|
||||
f"{output_type} should not execute without approval"
|
||||
)
|
||||
return resumed
|
||||
|
||||
|
||||
def make_mcp_raw_item(
|
||||
*,
|
||||
call_id: str = "call_mcp_1",
|
||||
include_provider_data: bool = True,
|
||||
tool_name: str = "test_mcp_tool",
|
||||
provider_data: dict[str, Any] | None = None,
|
||||
include_name: bool = True,
|
||||
use_call_id: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a hosted MCP tool call payload for approvals."""
|
||||
|
||||
raw_item: dict[str, Any] = {"type": "hosted_tool_call"}
|
||||
if include_name:
|
||||
raw_item["name"] = tool_name
|
||||
if include_provider_data:
|
||||
if use_call_id:
|
||||
raw_item["call_id"] = call_id
|
||||
else:
|
||||
raw_item["id"] = call_id
|
||||
raw_item["provider_data"] = provider_data or {
|
||||
"type": "mcp_approval_request",
|
||||
"id": "req-1",
|
||||
"server_label": "test_server",
|
||||
}
|
||||
else:
|
||||
raw_item["id"] = call_id
|
||||
return raw_item
|
||||
|
||||
|
||||
def make_mcp_approval_item(
|
||||
agent: Agent[Any],
|
||||
*,
|
||||
call_id: str = "call_mcp_1",
|
||||
include_provider_data: bool = True,
|
||||
tool_name: str | None = "test_mcp_tool",
|
||||
provider_data: dict[str, Any] | None = None,
|
||||
include_name: bool = True,
|
||||
use_call_id: bool = True,
|
||||
) -> ToolApprovalItem:
|
||||
"""Create a ToolApprovalItem for MCP or hosted tool calls."""
|
||||
|
||||
raw_item = make_mcp_raw_item(
|
||||
call_id=call_id,
|
||||
include_provider_data=include_provider_data,
|
||||
tool_name=tool_name or "unknown_mcp_tool",
|
||||
provider_data=provider_data,
|
||||
include_name=include_name,
|
||||
use_call_id=use_call_id,
|
||||
)
|
||||
return ToolApprovalItem(agent=agent, raw_item=raw_item, tool_name=tool_name)
|
||||
|
||||
|
||||
def make_context_wrapper() -> RunContextWrapper[dict[str, Any]]:
|
||||
"""Create an empty RunContextWrapper for HITL tests."""
|
||||
return RunContextWrapper(context={})
|
||||
|
||||
|
||||
def make_agent(
|
||||
*,
|
||||
model: Any | None = None,
|
||||
tools: Sequence[Any] | None = None,
|
||||
name: str = "TestAgent",
|
||||
) -> Agent[Any]:
|
||||
"""Build a test Agent with optional model and tools."""
|
||||
return Agent(name=name, model=model, tools=list(tools or []))
|
||||
|
||||
|
||||
def make_model_and_agent(
|
||||
*,
|
||||
tools: Sequence[Any] | None = None,
|
||||
name: str = "TestAgent",
|
||||
) -> tuple[FakeModel, Agent[Any]]:
|
||||
"""Build a FakeModel with a paired Agent for HITL tests."""
|
||||
model = FakeModel()
|
||||
agent = make_agent(model=model, tools=tools, name=name)
|
||||
return model, agent
|
||||
|
||||
|
||||
def reject_tool_call(
|
||||
context_wrapper: RunContextWrapper[Any],
|
||||
agent: Agent[Any],
|
||||
raw_item: Any,
|
||||
tool_name: str,
|
||||
*,
|
||||
rejection_message: str | None = None,
|
||||
) -> ToolApprovalItem:
|
||||
"""Reject a tool call in the context and return the approval item used."""
|
||||
approval_item = ToolApprovalItem(agent=agent, raw_item=raw_item, tool_name=tool_name)
|
||||
context_wrapper.reject_tool(approval_item, rejection_message=rejection_message)
|
||||
return approval_item
|
||||
|
||||
|
||||
def make_on_approval_callback(
|
||||
approve: bool,
|
||||
*,
|
||||
reason: str | None = None,
|
||||
) -> Callable[[RunContextWrapper[Any], ToolApprovalItem], Awaitable[Any]]:
|
||||
"""Build an on_approval callback that always approves or rejects."""
|
||||
|
||||
async def on_approval(
|
||||
_ctx: RunContextWrapper[Any], _approval_item: ToolApprovalItem
|
||||
) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {"approve": approve}
|
||||
if reason:
|
||||
payload["reason"] = reason
|
||||
return payload
|
||||
|
||||
return on_approval
|
||||
@@ -0,0 +1,82 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import cast
|
||||
|
||||
from agents.items import TResponseInputItem
|
||||
from agents.memory.session import Session
|
||||
from agents.memory.session_settings import SessionSettings
|
||||
|
||||
|
||||
class SimpleListSession(Session):
|
||||
"""A minimal in-memory session implementation for tests."""
|
||||
|
||||
session_settings: SessionSettings | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session_id: str = "test",
|
||||
history: list[TResponseInputItem] | None = None,
|
||||
) -> None:
|
||||
self.session_id = session_id
|
||||
self._items: list[TResponseInputItem] = list(history) if history else []
|
||||
# Some session implementations strip IDs on write; tests can opt-in via attribute.
|
||||
self._ignore_ids_for_matching = False
|
||||
# Mirror saved_items used by some tests for inspection.
|
||||
self.saved_items: list[TResponseInputItem] = self._items
|
||||
|
||||
async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]:
|
||||
if limit is None:
|
||||
return list(self._items)
|
||||
if limit <= 0:
|
||||
return []
|
||||
return self._items[-limit:]
|
||||
|
||||
async def add_items(self, items: list[TResponseInputItem]) -> None:
|
||||
self._items.extend(items)
|
||||
|
||||
async def pop_item(self) -> TResponseInputItem | None:
|
||||
if not self._items:
|
||||
return None
|
||||
return self._items.pop()
|
||||
|
||||
async def clear_session(self) -> None:
|
||||
self._items.clear()
|
||||
|
||||
|
||||
class CountingSession(SimpleListSession):
|
||||
"""Session that tracks how many times pop_item is invoked (for rewind tests)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session_id: str = "test",
|
||||
history: list[TResponseInputItem] | None = None,
|
||||
) -> None:
|
||||
super().__init__(session_id=session_id, history=history)
|
||||
self.pop_calls = 0
|
||||
|
||||
async def pop_item(self) -> TResponseInputItem | None:
|
||||
self.pop_calls += 1
|
||||
return await super().pop_item()
|
||||
|
||||
|
||||
class IdStrippingSession(CountingSession):
|
||||
"""Session that strips IDs on add to mimic hosted stores that reassign IDs."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session_id: str = "test",
|
||||
history: list[TResponseInputItem] | None = None,
|
||||
) -> None:
|
||||
super().__init__(session_id=session_id, history=history)
|
||||
self._ignore_ids_for_matching = True
|
||||
|
||||
async def add_items(self, items: list[TResponseInputItem]) -> None:
|
||||
sanitized: list[TResponseInputItem] = []
|
||||
for item in items:
|
||||
if isinstance(item, dict):
|
||||
clean = dict(item)
|
||||
clean.pop("id", None)
|
||||
sanitized.append(cast(TResponseInputItem, clean))
|
||||
else:
|
||||
sanitized.append(item)
|
||||
await super().add_items(sanitized)
|
||||
@@ -0,0 +1,43 @@
|
||||
import json
|
||||
from types import MappingProxyType
|
||||
|
||||
from openai.types.responses.response_output_message_param import ResponseOutputMessageParam
|
||||
from openai.types.responses.response_output_text_param import ResponseOutputTextParam
|
||||
|
||||
from agents.util._json import _to_dump_compatible
|
||||
|
||||
|
||||
def test_to_dump_compatible():
|
||||
# Given a list of message dictionaries, ensure the returned list is a deep copy.
|
||||
input_iter = [
|
||||
ResponseOutputMessageParam(
|
||||
id="a75654dc-7492-4d1c-bce0-89e8312fbdd7",
|
||||
content=[
|
||||
ResponseOutputTextParam(
|
||||
type="output_text",
|
||||
text="Hey, what's up?",
|
||||
annotations=[],
|
||||
logprobs=[],
|
||||
)
|
||||
].__iter__(),
|
||||
role="assistant",
|
||||
status="completed",
|
||||
type="message",
|
||||
)
|
||||
].__iter__()
|
||||
# this fails if any of the properties are Iterable objects.
|
||||
# result = json.dumps(input_iter)
|
||||
result = json.dumps(_to_dump_compatible(input_iter))
|
||||
assert (
|
||||
result
|
||||
== """[{"id": "a75654dc-7492-4d1c-bce0-89e8312fbdd7", "content": [{"type": "output_text", "text": "Hey, what's up?", "annotations": [], "logprobs": []}], "role": "assistant", "status": "completed", "type": "message"}]""" # noqa: E501
|
||||
)
|
||||
|
||||
|
||||
def test_to_dump_compatible_preserves_non_dict_mapping_values():
|
||||
# A non-dict Mapping (e.g. MappingProxyType) must be preserved as an object,
|
||||
# recursing into its values, instead of collapsing to a list of its keys.
|
||||
out = _to_dump_compatible({"config": MappingProxyType({"timeout": 30, "retries": 3})})
|
||||
assert out == {"config": {"timeout": 30, "retries": 3}}
|
||||
# A top-level mapping is preserved as an object, not flattened to its keys.
|
||||
assert _to_dump_compatible(MappingProxyType({"a": 1, "b": 2})) == {"a": 1, "b": 2}
|
||||
@@ -0,0 +1,88 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from openai.types.responses import ResponseOutputMessage, ResponseOutputText
|
||||
|
||||
from agents import Agent
|
||||
from agents.exceptions import RunErrorDetails
|
||||
from agents.items import ItemHelpers, MessageOutputItem
|
||||
from agents.util._pretty_print import pretty_print_run_error_details
|
||||
|
||||
|
||||
def _make_message_item(text: str | None) -> MessageOutputItem:
|
||||
msg = ResponseOutputMessage.model_construct(
|
||||
id="msg_1",
|
||||
role="assistant",
|
||||
status="completed",
|
||||
content=[ResponseOutputText.model_construct(type="output_text", text=text, annotations=[])],
|
||||
)
|
||||
agent = Agent(name="test")
|
||||
return MessageOutputItem(agent=agent, raw_item=msg)
|
||||
|
||||
|
||||
def test_text_message_output_returns_empty_string_for_none_text():
|
||||
"""text_message_output must not crash when a content item has text=None."""
|
||||
item = _make_message_item(None)
|
||||
assert ItemHelpers.text_message_output(item) == ""
|
||||
|
||||
|
||||
def test_text_message_output_returns_text_normally():
|
||||
item = _make_message_item("hello")
|
||||
assert ItemHelpers.text_message_output(item) == "hello"
|
||||
|
||||
|
||||
def test_text_message_outputs_handles_none_text_across_items():
|
||||
"""text_message_outputs must tolerate None text in any item."""
|
||||
from agents.items import RunItem
|
||||
|
||||
items: list[RunItem] = [_make_message_item(None), _make_message_item("world")]
|
||||
assert ItemHelpers.text_message_outputs(items) == "world"
|
||||
|
||||
|
||||
def _make_output_message(text: str | None) -> ResponseOutputMessage:
|
||||
return ResponseOutputMessage.model_construct(
|
||||
id="msg_1",
|
||||
role="assistant",
|
||||
status="completed",
|
||||
content=[ResponseOutputText.model_construct(type="output_text", text=text, annotations=[])],
|
||||
)
|
||||
|
||||
|
||||
def test_extract_last_content_returns_empty_string_for_none_text():
|
||||
"""extract_last_content is declared `-> str` and must not return None even if
|
||||
the underlying ResponseOutputText.text is None (observed via LiteLLM gateways
|
||||
and ``model_construct`` paths during streaming, per items.py:714-720)."""
|
||||
msg = _make_output_message(None)
|
||||
result = ItemHelpers.extract_last_content(msg)
|
||||
assert isinstance(result, str)
|
||||
assert result == ""
|
||||
|
||||
|
||||
def test_extract_last_content_returns_text_normally():
|
||||
msg = _make_output_message("hello")
|
||||
assert ItemHelpers.extract_last_content(msg) == "hello"
|
||||
|
||||
|
||||
def _make_run_error_details(n_input: int = 0, n_output: int = 0) -> RunErrorDetails:
|
||||
return RunErrorDetails(
|
||||
input="hi",
|
||||
new_items=[],
|
||||
raw_responses=[],
|
||||
last_agent=Agent(name="test"),
|
||||
context_wrapper=None, # type: ignore[arg-type]
|
||||
input_guardrail_results=[None] * n_input, # type: ignore[list-item]
|
||||
output_guardrail_results=[None] * n_output, # type: ignore[list-item]
|
||||
)
|
||||
|
||||
|
||||
def test_pretty_print_run_error_details_includes_output_guardrail_count():
|
||||
"""pretty_print_run_error_details must report output_guardrail_results like its siblings."""
|
||||
details = _make_run_error_details(n_input=1, n_output=2)
|
||||
text = pretty_print_run_error_details(details)
|
||||
assert "1 input guardrail result(s)" in text
|
||||
assert "2 output guardrail result(s)" in text
|
||||
|
||||
|
||||
def test_pretty_print_run_error_details_zero_output_guardrails():
|
||||
details = _make_run_error_details(n_input=0, n_output=0)
|
||||
text = pretty_print_run_error_details(details)
|
||||
assert "0 output guardrail result(s)" in text
|
||||
@@ -0,0 +1,54 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
|
||||
from agents.items import TResponseInputItem
|
||||
from tests.utils.simple_session import CountingSession, IdStrippingSession, SimpleListSession
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_simple_list_session_preserves_history_and_saved_items() -> None:
|
||||
history: list[TResponseInputItem] = [
|
||||
cast(TResponseInputItem, {"id": "msg1", "content": "hi", "role": "user"}),
|
||||
cast(TResponseInputItem, {"id": "msg2", "content": "hello", "role": "assistant"}),
|
||||
]
|
||||
session = SimpleListSession(history=history)
|
||||
|
||||
items = await session.get_items()
|
||||
# get_items should return a copy, not the original list.
|
||||
assert items == history
|
||||
assert items is not history
|
||||
# saved_items should mirror the stored list.
|
||||
assert session.saved_items == history
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_counting_session_tracks_pop_calls() -> None:
|
||||
session = CountingSession(
|
||||
history=[cast(TResponseInputItem, {"id": "x", "content": "hi", "role": "user"})]
|
||||
)
|
||||
|
||||
assert session.pop_calls == 0
|
||||
await session.pop_item()
|
||||
assert session.pop_calls == 1
|
||||
await session.pop_item()
|
||||
assert session.pop_calls == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_id_stripping_session_removes_ids_on_add() -> None:
|
||||
session = IdStrippingSession()
|
||||
items: list[TResponseInputItem] = [
|
||||
cast(TResponseInputItem, {"id": "keep-removed", "content": "hello", "role": "user"}),
|
||||
cast(TResponseInputItem, {"content": "no-id", "role": "assistant"}),
|
||||
]
|
||||
|
||||
await session.add_items(items)
|
||||
stored = await session.get_items()
|
||||
|
||||
assert all("id" not in item for item in stored if isinstance(item, dict))
|
||||
# pop_calls should increment when rewinding.
|
||||
await session.pop_item()
|
||||
assert session.pop_calls == 1
|
||||
Reference in New Issue
Block a user