Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions src/agents/run_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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",
Comment on lines +2381 to +2384

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Convert restored local-shell outputs to the API shape

When this state is resumed through OpenAIResponsesModel, this branch retains the SDK-produced {type, call_id, output} mapping and ToolCallOutputItem.to_input_item() forwards it unchanged. The Responses LocalShellCallOutput request contract requires id and does not define call_id, so the newly restored item is rejected by the real provider instead of completing the resume; the FakeModel test cannot expose that failure. Preserve call_id for internal orphan matching, but translate it to id and remove the internal field at the provider boundary.

AGENTS.md reference: AGENTS.md:L136-L136

Useful? React with 👍 / 👎.

}:
return normalized_raw_item

try:
Expand Down
111 changes: 111 additions & 0 deletions tests/test_local_shell_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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"