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
80 changes: 78 additions & 2 deletions python/packages/openai/agent_framework_openai/_chat_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2825,15 +2825,63 @@ def _shell_item_to_contents(self, item: Any, local_shell_tool_name: str | None)
)
)
else:
provider_status = _provider_status(getattr(item, "status", None))
empty_local_call = bool(
local_shell_tool_name
and local_call_id
and local_call_item_id
and isinstance(raw_local_command_parts, list)
and not raw_local_command_parts
)
contents.append(
Content.from_shell_tool_call(
call_id=local_call_id,
commands=[local_command] if local_command else [],
timeout_ms=getattr(getattr(item, "action", None), "timeout_ms", None),
status=_provider_status(getattr(item, "status", None)),
raw_representation=item,
status=provider_status,
# An empty ``local_shell_call`` has no replayable local form: its provider
# payload only validates as ``local_shell_call``, which this client never
# emits on replay. A call that ran no commands is equivalent to a
# ``shell_call`` with an empty action, so record that shape and pair it with
# the terminal output below instead of leaving an orphaned shell call.
# The item id also goes into the serialized metadata, because
# ``raw_representation`` does not survive ``Content.to_dict()``.
additional_properties=(
_openai_additional_properties(
{
_OPENAI_SHELL_OUTPUT_TYPE_NAMESPACE_KEY: OPENAI_SHELL_OUTPUT_TYPE_SHELL_CALL,
_OPENAI_LOCAL_SHELL_CALL_ITEM_ID_NAMESPACE_KEY: local_call_item_id,
},
legacy={
OPENAI_SHELL_OUTPUT_TYPE_KEY: OPENAI_SHELL_OUTPUT_TYPE_SHELL_CALL,
OPENAI_LOCAL_SHELL_CALL_ITEM_ID_KEY: local_call_item_id,
},
)
if empty_local_call
else None
),
raw_representation=(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

DZH166 Persist the reconstructed call for history replay — The reconstructed shell call exists only in raw_representation, which Content.to_dict() omits. After the whole message is serialized and restored, stateless replay raises ChatClientInvalidRequestException because it cannot reconstruct the call. The new round-trip test serializes only the result, so it misses this path. Preserve sufficient call data across message serialization and test the complete message round trip.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Implemented in dcc0045, on top of the focused issue you asked for (#10373):

  • the empty local_shell_call now records the provider item id in additional_properties (which Content.to_dict() does serialize) instead of relying on raw_representation;
  • _prepare_shell_transcript_item_for_openai() falls back to _rebuild_shell_call_payload() when raw_representation is None, rebuilding {type, call_id, status, action.commands} plus the item id and environment: {"type": "local"}, i.e. exactly the payload this branch already emits in memory and the provider already validates;
  • test_empty_local_shell_call_item_replays_with_empty_output now round-trips the whole message (Message.from_json(message.to_json())) instead of only the result, so it exercises the persisted-conversation path that used to raise ChatClientInvalidRequestException.

The pair still serializes as shell_call + shell_call_output keyed by call_id (option 2 from my earlier note), which is the shape the empty-action path can rebuild from typed fields alone.

{
"id": local_call_item_id,
"type": "shell_call",
"call_id": local_call_id,
"status": provider_status or "completed",
"environment": {"type": "local"},
"action": {"commands": []},
}
if empty_local_call
else item
),
)
)
if empty_local_call:
contents.append(
Content.from_shell_tool_result(
call_id=local_call_id,
outputs=[],
additional_properties={_LOCAL_TOOL_RESULT_KEY: True},
Comment on lines +2879 to +2882

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Reading the serializer again, the mismatch you describe is real: the call content keeps the local-shell metadata (local_shell_call_output + the provider item id) that this branch writes before the raw_representation swap, so it still leaves through _prepare_shell_transcript_item_for_openai(..., expected_type="local_shell_call") (line 2353), while the paired result I add carries only _LOCAL_TOOL_RESULT_KEY and therefore leaves through the shell_tool_result arm as shell_call_output keyed by call_id (line 2384). So the pair can come out as local_shell_call + shell_call_output.

That is exactly the inconsistency to fix, but the fix changes what the service-side continuation stores, and the two candidates are not equivalent:

  1. give the result the same local metadata as the call (OPENAI_SHELL_OUTPUT_TYPE_KEY / local_shell_call_item_id), so the pair stays local_shell_call + local_shell_call_output keyed by the item id, and drop the raw_representation rewrite; or
  2. keep the rewrite, and give both the call and the result the shell_call metadata so the pair is shell_call + shell_call_output keyed by call_id.

Option 1 matches the native item the service already stores; option 2 matches the "an empty call that ran no commands is equivalent to shell_call" comment. My concern with 1 is that it puts back the empty local_shell_call payload that stateless replay rejects today (ChatClientInvalidRequestException), which is what this PR exists to avoid — so 1 only works if the empty action is replayed differently for a continuation than for a stateless request.

I don't want to guess at the wire contract: the repo tests can assert either shape, so whichever one the service actually accepts decides it. I've asked in the thread; if option 1 is the intended contract I'll swap the metadata and update test_empty_local_shell_call_item_replays_with_empty_output to assert local_shell_call_output keyed by the item id, and if option 2 is right the result needs the shell_call metadata instead of the current bare _LOCAL_TOOL_RESULT_KEY. Point me at the expected payload and I'll land it either way.

)
)
elif item_type == "shell_call_output":
shell_output_call_id = getattr(item, "call_id", None) or ""
shell_outputs: list[Content] = []
Expand Down Expand Up @@ -3072,6 +3120,8 @@ def _prepare_shell_transcript_item_for_openai(
output["max_output_length"] = content.max_output_length
return output
payload = cls._serialize_provider_payload(content.raw_representation)
if payload is None:
payload = cls._rebuild_shell_call_payload(content, expected_type)
if isinstance(payload, Mapping):
typed_payload = cast("Mapping[str, Any]", payload)
raw_call_id = typed_payload.get("call_id")
Expand All @@ -3092,6 +3142,32 @@ def _prepare_shell_transcript_item_for_openai(
"Use service-side continuation or preserve the original provider response item."
)

@classmethod
def _rebuild_shell_call_payload(cls, content: Content, expected_type: str) -> dict[str, Any] | None:
"""Rebuild a shell call payload from the fields ``Content.to_dict()`` keeps.

``raw_representation`` is dropped on serialization, so a call restored from a checkpoint
only carries its typed fields. An empty local shell call has everything needed to rebuild
the wire payload, which is what keeps a restored conversation replayable.
"""
if expected_type != "shell_call" or not content.call_id:
return None
item_id = _get_openai_metadata(
content.additional_properties,
_OPENAI_LOCAL_SHELL_CALL_ITEM_ID_NAMESPACE_KEY,
OPENAI_LOCAL_SHELL_CALL_ITEM_ID_KEY,
)
payload: dict[str, Any] = {
"type": "shell_call",
"call_id": content.call_id,
"status": content.status or "completed",
"action": {"commands": list(content.commands or [])},
}
if isinstance(item_id, str) and item_id:
payload["id"] = item_id
payload["environment"] = {"type": "local"}
return payload

@staticmethod
def _serialize_provider_payload(value: Any) -> Any:
"""Convert OpenAI SDK objects into JSON-serializable Python values."""
Expand Down
37 changes: 37 additions & 0 deletions python/packages/openai/tests/openai/test_openai_chat_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
ResponseOutputTextAnnotationAddedEvent,
ResponseStreamEvent,
)
from openai.types.responses.response_output_item import LocalShellCall, LocalShellCallAction
from openai.types.responses.response_reasoning_item import ResponseReasoningItem, Summary
from openai.types.responses.response_reasoning_summary_text_delta_event import (
ResponseReasoningSummaryTextDeltaEvent,
Expand Down Expand Up @@ -2979,6 +2980,42 @@ def test_empty_local_shell_call_replays_with_empty_output(
assert shell_outputs == [expected_output]


@pytest.mark.parametrize("round_trip", [False, True])
@pytest.mark.parametrize("service_storage", [False, True])
def test_empty_local_shell_call_item_replays_with_empty_output(round_trip: bool, service_storage: bool) -> None:
"""An empty provider ``local_shell_call`` must replay as a matched empty shell pair."""
client = OpenAIChatClient(model="test-model", api_key="test-key")
item = LocalShellCall(
id="sh_local_empty",
call_id="call_local_empty",
status="completed",
type="local_shell_call",
action=LocalShellCallAction(command=[], env={}, type="exec"),
)

contents = client._shell_item_to_contents(item, "run_shell")

assert [content.type for content in contents] == ["shell_tool_call", "shell_tool_result"]
message = Message("assistant", contents)
if round_trip:
# Round-trip the whole message, not only the result: ``Content.to_dict()`` drops
# ``raw_representation``, so this is the shape a persisted conversation takes.
message = Message.from_json(message.to_json())
replay = client._prepare_messages_for_openai(
[message, Message("user", ["Continue"])],
request_uses_service_side_storage=service_storage,
)

shell_calls = [entry for entry in replay if entry["type"] == "shell_call"]
assert len(shell_calls) == (0 if service_storage else 1)
if not service_storage:
assert shell_calls[0]["call_id"] == "call_local_empty"
assert shell_calls[0]["action"]["commands"] == []
assert [entry for entry in replay if entry["type"] == "shell_call_output"] == [
{"type": "shell_call_output", "call_id": "call_local_empty", "output": []}
]


@pytest.mark.parametrize("streaming", [False, True])
def test_empty_local_shell_call_parsing_emits_pair_without_execution(streaming: bool) -> None:
client = OpenAIChatClient(model="test-model", api_key="test-key")
Expand Down
Loading