Repository navigation
Conversation
A local_shell_call with an empty command list was converted to a lone shell_tool_call with no matching result, so stateless replay raised ChatClientInvalidRequestException and service-side replay lost the empty shell_call_output. Record the paired empty shell_tool_result, as the managed shell_call branch already does.
There was a problem hiding this comment.
🟡 Changes recommended
Service-side continuation serializes local-shell results with the wrong output type and identifier, requiring preservation of the native call kind and item ID and test assertions for the correct wire format.
1 open issue
What changed in this PR
This PR updates the Python OpenAI client to record empty provider local_shell_call items as paired shell calls and results, aiming to make their history replayable.
Changes:
- Normalize empty local shell calls to a replayable shell-call representation and append an empty result.
- Add regression cases for stateless replay, service-side continuation, and result serialization round trips.
| File | Description |
|---|---|
python/packages/openai/tests/openai/test_openai_chat_client.py |
Adds parameterized empty local-shell replay tests. |
python/packages/openai/agent_framework_openai/_chat_client.py |
Normalizes empty local shell calls and records paired empty results. |
Review effort: Balanced
| Content.from_shell_tool_result( | ||
| call_id=local_call_id, | ||
| outputs=[], | ||
| additional_properties={_LOCAL_TOOL_RESULT_KEY: True}, |
There was a problem hiding this comment.
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:
- give the result the same local metadata as the call (
OPENAI_SHELL_OUTPUT_TYPE_KEY/local_shell_call_item_id), so the pair stayslocal_shell_call+local_shell_call_outputkeyed by the item id, and drop theraw_representationrewrite; or - keep the rewrite, and give both the call and the result the
shell_callmetadata so the pair isshell_call+shell_call_outputkeyed bycall_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.
|
DZH166 Thanks for the contribution. This changes function-call history and replay behavior in a stable contract area, so it needs an agreed issue with explicit maintainer direction before implementation review resumes. Please link that issue and confirm the intended replay behavior against the function-calling loop contract. Approval remains blocked until that direction is established. |
|
Status note after the latest rebase, because the related issue record changed while this PR was open:
So the question is whether the remaining gap is in scope after #9803: if it is, I will open a focused issue (state snapshot style, with a reproduction that does not need a live deployment) and link it here; if #9803 is considered to have covered this path, feel free to close this PR as redundant — I would rather not argue the point with a merged fix in the tree. Everything else is green on the rebased head; the earlier |
| # 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. | ||
| raw_representation=( |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Implemented in dcc0045, on top of the focused issue you asked for (#10373):
- the empty
local_shell_callnow records the provider item id inadditional_properties(whichContent.to_dict()does serialize) instead of relying onraw_representation; _prepare_shell_transcript_item_for_openai()falls back to_rebuild_shell_call_payload()whenraw_representationisNone, rebuilding{type, call_id, status, action.commands}plus the item id andenvironment: {"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_outputnow 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 raiseChatClientInvalidRequestException.
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.
|
DZH166 The remaining |
…ialization `Content.to_dict()` drops `raw_representation`, so a call rebuilt for an empty `local_shell_call` vanished once the message was persisted: replaying the restored message raised `ChatClientInvalidRequestException: Stateless replay cannot reconstruct shell_call`. Record the provider item id in the serialized metadata and rebuild the payload from the typed fields when `raw_representation` is gone. The round-trip test now serializes the whole message instead of only the result, which is the path a checkpoint takes. Addresses the review note on microsoft#10155; tracked by microsoft#10373.
Keep only the fields the provider payload this branch already emits carries, so a rebuilt call and the in-memory one serialize identically.

Fixes #10373
Problem
RawOpenAIChatClient._shell_item_to_contentsconverts a providerlocal_shell_callwhoseaction.commandlist is empty into ashell_tool_callcarrying the raw local item, and appends no terminal result. Two things then break:_prepare_shell_transcript_item_for_openaiexpects aResponseFunctionShellToolCall), so it raisesChatClientInvalidRequestException: Stateless replay cannot reconstruct shell_call ...;shell_call_outputbecause the recorded item never had a matching output.The sibling managed
shell_callbranch already recordsshell_tool_callplus a paired emptyshell_tool_result(the orphaned-call fix); the local branch was left behind.Fix
Record the paired empty
shell_tool_resultfor an emptylocal_shell_call, using the same marker as the managed branch, so history stays replayable.A local shell call that does run commands still converts to a
function_call, unchanged.Tests
tests/openai/test_openai_chat_client.pygains tests for the recorded contents, stateless replay, and service-side replay of an empty local shell call.