Files
2026-08-13 11:20:33 +09:00

350 lines
12 KiB
Python

"""Tests for local shell tool execution.
These confirm that LocalShellAction.execute forwards the command to the executor
and that Runner.run executes local shell calls and records their outputs.
"""
import json
from typing import Any, cast
import httpx
import pytest
from openai import AsyncOpenAI
from openai.types.responses import ResponseOutputText
from openai.types.responses.response_input_param import LocalShellCallOutput
from openai.types.responses.response_output_item import LocalShellCall, LocalShellCallAction
from agents import (
Agent,
LocalShellCommandRequest,
LocalShellTool,
OpenAIResponsesModel,
RunConfig,
RunContextWrapper,
RunHooks,
Runner,
UserError,
)
from agents.items import ToolCallOutputItem
from agents.run_internal.run_loop import LocalShellAction, ToolRunLocalShellCall
from agents.run_state import RunState
from agents.testing import ScriptedModel
from tests.model_test_helpers import get_response_obj
from .test_responses import get_text_message
class RecordingLocalShellExecutor:
"""A `LocalShellTool` executor that records the requests it receives."""
def __init__(self, output: str = "shell output") -> None:
self.output = output
self.calls: list[LocalShellCommandRequest] = []
def __call__(self, request: LocalShellCommandRequest) -> str:
self.calls.append(request)
return self.output
async def _create_serialized_local_shell_state() -> tuple[LocalShellTool, dict[str, Any]]:
tool = LocalShellTool(executor=RecordingLocalShellExecutor(output="shell result"))
initial_model = ScriptedModel()
initial_agent = Agent(name="shell-agent", model=initial_model, tools=[tool])
local_shell_call = LocalShellCall(
id="lsh_test",
action=LocalShellCallAction(
command=["bash", "-c", "echo shell"],
env={},
type="exec",
timeout_ms=1000,
working_directory="/tmp",
),
call_id="call_local_shell",
status="completed",
type="local_shell_call",
)
initial_model.extend(
[
[get_text_message("running shell"), local_shell_call],
[get_text_message("shell complete")],
]
)
result = await Runner.run(initial_agent, input="please run shell")
return tool, json.loads(json.dumps(result.to_state().to_json()))
def _create_recording_responses_model() -> tuple[
OpenAIResponsesModel, list[httpx.Request], httpx.AsyncClient
]:
requests: list[httpx.Request] = []
async def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
return httpx.Response(
200,
content=get_response_obj([get_text_message("resumed")]).model_dump_json(),
headers={"content-type": "application/json"},
request=request,
)
http_client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
client = AsyncOpenAI(
api_key="test-key",
base_url="https://example.test/v1",
http_client=http_client,
)
return (
OpenAIResponsesModel(model="codex-mini-latest", openai_client=client),
requests,
http_client,
)
@pytest.mark.asyncio
async def test_local_shell_action_execute_invokes_executor() -> None:
executor = RecordingLocalShellExecutor(output="test output")
tool = LocalShellTool(executor=executor)
action = LocalShellCallAction(
command=["bash", "-c", "ls"],
env={"TEST": "value"},
type="exec",
timeout_ms=5000,
working_directory="/tmp",
)
tool_call = LocalShellCall(
id="lsh_123",
action=action,
call_id="call_456",
status="completed",
type="local_shell_call",
)
tool_run = ToolRunLocalShellCall(tool_call=tool_call, local_shell_tool=tool)
agent = Agent(name="test_agent", tools=[tool])
context_wrapper: RunContextWrapper[Any] = RunContextWrapper(context=None)
output_item = await LocalShellAction.execute(
agent=agent,
call=tool_run,
hooks=RunHooks[Any](),
context_wrapper=context_wrapper,
config=RunConfig(),
)
assert len(executor.calls) == 1
request = executor.calls[0]
assert isinstance(request, LocalShellCommandRequest)
assert request.ctx_wrapper is context_wrapper
assert request.data is tool_call
assert request.data.action.command == ["bash", "-c", "ls"]
assert request.data.action.env == {"TEST": "value"}
assert request.data.action.timeout_ms == 5000
assert request.data.action.working_directory == "/tmp"
assert isinstance(output_item, ToolCallOutputItem)
assert output_item.agent is agent
assert output_item.output == "test output"
raw_item = output_item.raw_item
assert isinstance(raw_item, dict)
raw = cast(dict[str, Any], raw_item)
assert raw["type"] == "local_shell_call_output"
assert raw["call_id"] == "call_456"
assert raw["output"] == "test output"
@pytest.mark.asyncio
async def test_runner_executes_local_shell_calls() -> None:
executor = RecordingLocalShellExecutor(output="shell result")
tool = LocalShellTool(executor=executor)
model = ScriptedModel()
agent = Agent(name="shell-agent", model=model, tools=[tool])
action = LocalShellCallAction(
command=["bash", "-c", "echo shell"],
env={},
type="exec",
timeout_ms=1000,
working_directory="/tmp",
)
local_shell_call = LocalShellCall(
id="lsh_test",
action=action,
call_id="call_local_shell",
status="completed",
type="local_shell_call",
)
model.extend(
[
[get_text_message("running shell"), local_shell_call],
[get_text_message("shell complete")],
]
)
result = await Runner.run(agent, input="please run shell")
assert len(executor.calls) == 1
request = executor.calls[0]
assert isinstance(request, LocalShellCommandRequest)
assert request.data == local_shell_call
assert request.data is not local_shell_call
items = result.new_items
assert len(items) == 4
message_before = items[0]
assert message_before.type == "message_output_item"
first_content = message_before.raw_item.content[0]
assert isinstance(first_content, ResponseOutputText)
assert first_content.text == "running shell"
tool_call_item = items[1]
assert tool_call_item.type == "tool_call_item"
assert tool_call_item.raw_item == local_shell_call
assert tool_call_item.raw_item is not local_shell_call
local_shell_output = items[2]
assert isinstance(local_shell_output, ToolCallOutputItem)
assert isinstance(local_shell_output.raw_item, dict)
assert local_shell_output.raw_item.get("type") == "local_shell_call_output"
assert local_shell_output.output == "shell result"
message_after = items[3]
assert message_after.type == "message_output_item"
last_content = message_after.raw_item.content[0]
assert isinstance(last_content, ResponseOutputText)
assert last_content.text == "shell complete"
assert result.final_output == "shell complete"
assert len(result.raw_responses) == 2
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
@pytest.mark.parametrize(
"schema_version",
[None, "1.13"],
ids=["current", "v0.19.4"],
)
async def test_local_shell_output_survives_run_state_resume(schema_version: str | None) -> None:
tool, serialized = await _create_serialized_local_shell_state()
if schema_version is not None:
serialized["$schemaVersion"] = schema_version
resumed_model, requests, http_client = _create_recording_responses_model()
try:
resumed_agent = Agent(name="shell-agent", model=resumed_model, tools=[tool])
resumed_state = await RunState.from_json(resumed_agent, serialized)
shell_outputs = [
item.raw_item
for item in resumed_state._generated_items
if isinstance(item, ToolCallOutputItem)
and isinstance(item.raw_item, dict)
and item.raw_item.get("type") == "local_shell_call_output"
]
assert shell_outputs == [
{
"type": "local_shell_call_output",
"call_id": "call_local_shell",
"output": "shell result",
}
]
await Runner.run(resumed_agent, resumed_state)
finally:
await http_client.aclose()
assert len(requests) == 1
request_body = json.loads(requests[0].content)
replayed = [item for item in request_body["input"] if isinstance(item, dict)]
replayed_call = next(item for item in replayed if item.get("type") == "local_shell_call")
replayed_output = next(
item for item in replayed if item.get("type") == "local_shell_call_output"
)
assert replayed_call["call_id"] == replayed_output["call_id"] == "call_local_shell"
assert "id" not in replayed_output
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
@pytest.mark.parametrize(
"schema_version",
[None, "1.13"],
ids=["current", "v0.19.4"],
)
async def test_run_state_rejects_id_only_local_shell_output(schema_version: str | None) -> None:
tool, serialized = await _create_serialized_local_shell_state()
invalid_output = {
"type": "local_shell_call_output",
"id": "legacy-only",
"output": "shell result",
}
for item_group in ("generated_items", "session_items"):
output_items = [
item
for item in serialized[item_group]
if item.get("raw_item", {}).get("type") == "local_shell_call_output"
]
assert len(output_items) == 1
output_items[0]["raw_item"] = invalid_output.copy()
if schema_version is not None:
serialized["$schemaVersion"] = schema_version
resumed_model, requests, http_client = _create_recording_responses_model()
resumed_agent = Agent(name="shell-agent", model=resumed_model, tools=[tool])
try:
if schema_version is None:
with pytest.raises(
UserError,
match="completed tool invocation does not match a restored tool call and output",
) as exc_info:
await RunState.from_json(resumed_agent, serialized)
assert "call_local_shell" not in str(exc_info.value)
else:
resumed_state = await RunState.from_json(resumed_agent, serialized)
await Runner.run(resumed_agent, resumed_state)
finally:
await http_client.aclose()
if schema_version is None:
assert requests == []
else:
assert len(requests) == 1
request_body = json.loads(requests[0].content)
replayed_types = {
item.get("type") for item in request_body["input"] if isinstance(item, dict)
}
assert "local_shell_call" not in replayed_types
assert "local_shell_call_output" not in replayed_types
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
@pytest.mark.parametrize(
"schema_version",
[None, "1.13"],
ids=["current", "v0.19.4"],
)
async def test_run_state_preserves_official_local_shell_original_input(
schema_version: str | None,
) -> None:
original_input: LocalShellCallOutput = {
"type": "local_shell_call_output",
"id": "lsh_output_123",
"output": "shell result",
}
model = ScriptedModel()
model.extend([[get_text_message("complete")]])
agent = Agent(name="shell-agent", model=model)
result = await Runner.run(agent, input=[original_input])
serialized = json.loads(json.dumps(result.to_state().to_json()))
if schema_version is not None:
serialized["$schemaVersion"] = schema_version
restored_state = await RunState.from_json(agent, serialized)
assert restored_state.to_json()["original_input"] == [original_input]