diff --git a/src/agents/run_state.py b/src/agents/run_state.py index b5bc887297..aeb5a929dd 100644 --- a/src/agents/run_state.py +++ b/src/agents/run_state.py @@ -192,7 +192,6 @@ _FUNCTION_OUTPUT_ADAPTER: TypeAdapter[FunctionCallOutput] = TypeAdapter(FunctionCallOutput) _COMPUTER_OUTPUT_ADAPTER: TypeAdapter[ComputerCallOutput] = TypeAdapter(ComputerCallOutput) -_LOCAL_SHELL_OUTPUT_ADAPTER: TypeAdapter[LocalShellCallOutput] = TypeAdapter(LocalShellCallOutput) _TOOL_CALL_OUTPUT_UNION_ADAPTER: TypeAdapter[ FunctionCallOutput | ComputerCallOutput | LocalShellCallOutput ] = TypeAdapter(FunctionCallOutput | ComputerCallOutput | LocalShellCallOutput) @@ -2370,14 +2369,20 @@ def _deserialize_tool_call_output_raw_item( return _FUNCTION_OUTPUT_ADAPTER.validate_python(normalized_raw_item) if output_type == "computer_call_output": return _COMPUTER_OUTPUT_ADAPTER.validate_python(normalized_raw_item) - if output_type == "local_shell_call_output": - return _LOCAL_SHELL_OUTPUT_ADAPTER.validate_python(normalized_raw_item) if output_type == "program_output": try: return ProgramOutput(**normalized_raw_item) except Exception: return normalized_raw_item - if output_type in {"shell_call_output", "apply_patch_call_output", "custom_tool_call_output"}: + if output_type in { + "shell_call_output", + "apply_patch_call_output", + "custom_tool_call_output", + # LocalShellAction writes ``call_id`` (the key the runner pairs calls and outputs on) and + # no ``id``, so validating against the Responses ``LocalShellCallOutput`` shape both + # rejects SDK-produced items and strips ``call_id`` from API-shaped ones. + "local_shell_call_output", + }: return normalized_raw_item try: diff --git a/tests/test_local_shell_tool.py b/tests/test_local_shell_tool.py index cdc0d9a7f1..444eff8952 100644 --- a/tests/test_local_shell_tool.py +++ b/tests/test_local_shell_tool.py @@ -4,6 +4,7 @@ and that Runner.run executes local shell calls and records their outputs. """ +import json from typing import Any, cast import pytest @@ -21,6 +22,7 @@ ) from agents.items import ToolCallOutputItem from agents.run_internal.run_loop import LocalShellAction, ToolRunLocalShellCall +from agents.run_state import RunState from .fake_model import FakeModel from .test_responses import get_text_message @@ -156,3 +158,112 @@ async def test_runner_executes_local_shell_calls() -> None: assert result.final_output == "shell complete" assert len(result.raw_responses) == 2 + + +def _local_shell_call() -> LocalShellCall: + return 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", + ) + + +async def _run_with_local_shell(agent: Agent[Any], model: FakeModel) -> Any: + model.add_multiple_turn_outputs( + [ + [get_text_message("running shell"), _local_shell_call()], + [get_text_message("shell complete")], + ] + ) + return await Runner.run(agent, input="please run shell") + + +@pytest.mark.asyncio +async def test_local_shell_output_survives_run_state_roundtrip() -> None: + """A serialized run that used a local shell tool must keep its shell output on resume.""" + executor = RecordingLocalShellExecutor(output="shell result") + tool = LocalShellTool(executor=executor) + model = FakeModel() + agent = Agent(name="shell-agent", model=model, tools=[tool]) + + result = await _run_with_local_shell(agent, model) + state = result.to_state() + restored = await RunState.from_json(agent, json.loads(json.dumps(state.to_json()))) + + shell_outputs = [ + cast(dict[str, Any], item.raw_item) + for item in restored._generated_items + if isinstance(item, ToolCallOutputItem) + and isinstance(item.raw_item, dict) + and item.raw_item.get("type") == "local_shell_call_output" + ] + assert len(shell_outputs) == 1 + # The runner pairs calls with outputs on call_id, so it has to survive the round trip. + assert shell_outputs[0]["call_id"] == "call_local_shell" + assert shell_outputs[0]["output"] == "shell result" + + +@pytest.mark.asyncio +async def test_resumed_local_shell_run_replays_call_and_output() -> None: + """Resuming keeps the shell call paired with its output instead of pruning both.""" + executor = RecordingLocalShellExecutor(output="shell result") + tool = LocalShellTool(executor=executor) + model = FakeModel() + agent = Agent(name="shell-agent", model=model, tools=[tool]) + + result = await _run_with_local_shell(agent, model) + serialized = json.loads(json.dumps(result.to_state().to_json())) + + resumed_model = FakeModel() + resumed_agent = Agent(name="shell-agent", model=resumed_model, tools=[tool]) + resumed_state = await RunState.from_json(resumed_agent, serialized) + resumed_model.add_multiple_turn_outputs([[get_text_message("resumed")]]) + await Runner.run(resumed_agent, resumed_state) + + replayed = [ + entry + for entry in (resumed_model.last_turn_args.get("input") or []) + if isinstance(entry, dict) + ] + replayed_types = [entry.get("type") for entry in replayed] + assert "local_shell_call" in replayed_types + assert "local_shell_call_output" in replayed_types + call = next(entry for entry in replayed if entry.get("type") == "local_shell_call") + output = next(entry for entry in replayed if entry.get("type") == "local_shell_call_output") + assert call["call_id"] == output["call_id"] == "call_local_shell" + + +@pytest.mark.asyncio +async def test_api_shaped_local_shell_output_still_restores() -> None: + """A snapshot whose shell output carries the Responses API `id` keeps all of its fields.""" + executor = RecordingLocalShellExecutor(output="shell result") + tool = LocalShellTool(executor=executor) + model = FakeModel() + agent = Agent(name="shell-agent", model=model, tools=[tool]) + + result = await _run_with_local_shell(agent, model) + serialized = json.loads(json.dumps(result.to_state().to_json())) + for item in serialized["generated_items"]: + raw_item = item.get("raw_item") + if isinstance(raw_item, dict) and raw_item.get("type") == "local_shell_call_output": + raw_item["id"] = raw_item["call_id"] + + restored = await RunState.from_json(agent, serialized) + shell_outputs = [ + cast(dict[str, Any], item.raw_item) + for item in restored._generated_items + if isinstance(item, ToolCallOutputItem) + and isinstance(item.raw_item, dict) + and item.raw_item.get("type") == "local_shell_call_output" + ] + assert len(shell_outputs) == 1 + assert shell_outputs[0]["id"] == "call_local_shell" + assert shell_outputs[0]["call_id"] == "call_local_shell"