fix(handoffs): keep empty turns in the nested conversation history - #4230
Conversation
A transcript turn with no content was rendered as a bare role, with the "role: content" separator omitted. The parser that flattens the summary on the next handoff splits each record on that separator and discards anything without one, so the turn was dropped. nest_handoff_history on a three item history containing one empty turn produces a nested transcript of two, and it stays lost across further handoffs. The next agent sees a transcript with a turn missing, which also breaks the user/assistant alternation. It applies to content="" and content=None, for any role, with or without a name. Always emit the separator so the record round trips. Summaries written before this change still contain bare roles, so also recover a separator-less record when it is a lone known role, optionally with a "(name)" suffix. That stays narrow enough that prose inside the block is still rejected rather than becoming a fabricated turn, which a test pins.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c21c323fca
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
seratch
left a comment
There was a problem hiding this comment.
Before we can merge this, please preserve an explicit content: "" when parsing both role: records and recovered legacy bare-role records. At the current head, the parser returns a role-only item, which is not replayable through supported adapters such as the Chat Completions converter.
Please add a second-pass handoff_history_mapper regression that verifies the reconstructed item retains empty content and remains provider-valid. Also make the prose guard an independently numbered separator-less record; the current unnumbered line is treated as continuation text and never exercises the new rejection branch.
Review follow-up. Reconstructing an empty turn as a role-only item left it unreplayable: adapters such as the Chat Completions converter only recognize a message when both role and content are present, and Responses would forward a role-only message. Set content explicitly for both separator-only records and recovered bare-role records. Also fix the prose guard test, which passed for the wrong reason. An unnumbered line is folded into the previous record as continuation text, so it never reached the rejection branch. Numbering it makes it an independent record that actually exercises the guard. Add a second-pass regression that nests twice and converts the reconstructed transcript through the Chat Completions converter, so the empty turn is pinned as provider valid rather than merely present.
|
All three addressed in d2b4fbf. Explicit Second-pass provider validity. Prose guard. Good catch, that test was passing for the wrong reason. The unnumbered line was folded into the previous record as continuation text by I re-checked that the tests discriminate: 8 of the 9 fail on |
Summary
nest_handoff_historysummarizes the previous transcript into an assistant message, and the next handoff flattens that summary back into items so history does not nest repeatedly. A turn with no content was rendered as a bare role, with therole: contentseparator omitted. The parser splits each record on that separator and discards anything without one, so the turn was dropped.The next agent receives a transcript with a turn missing, and the user/assistant alternation is broken. The turn stays lost across further handoffs. This affects
content=""andcontent=None, for any role, with or without aname. Whitespace content and non-string content such as[]were unaffected, because those take a different formatting path.The fix emits the separator unconditionally, so the record parses like every other one. Summaries written before this change still contain bare roles, so the parser also recovers a separator-less record when it is a lone known role, optionally with a
(name)suffix. That is deliberately narrow: prose inside the block is still rejected rather than becoming a fabricated turn, and a test pins that.Test plan
Added to
tests/test_handoff_history_duplication.py:test_nested_history_keeps_turns_with_no_content, parametrized overcontent=""for user and assistant,content=None, and a named turn, asserting the turn count and the role sequence survive.test_nested_history_survives_repeated_handoffs, nesting four times to show the turn is not shed on each hop.test_bare_role_record_from_an_older_summary_is_recovered, covering a summary written before this change.test_prose_inside_the_summary_block_is_still_rejected, the guard on the recovery.6 fail on
mainand pass with the fix. The prose test passes in both runs, which is the control that the recovery is narrow rather than accepting anything without a separator:Verification from the repository root:
make formatmake lintmake mypymain, none in the touched filesmake pyrightmain(src/agents/sandbox/util/tar_utils.py:161)uv run pytest tests/test_handoff_history_duplication.pyuv run pytest tests/test_extension_filters.py tests/test_run_step_processing.py tests/test_agent_runner.pymake testsThe full suite run was done on Windows, where some sandbox symlink and tracing timing tests fail independently of this change. I diffed the failing set against a clean
maincheckout in the same environment. The only difference wastests/test_tracing_errors.py::test_multiple_final_output_doesnt_error, which I then ran in isolation on both: it fails three times out of three on cleanmainas well, so it is pre-existing and unrelated.Issue number
Closes #4229
Checks
.agents/skills/code-change-verification/scripts/run.sh/reviewbefore submitting this PRThe verification script is a bash script that shells out to
make. I ran the underlying steps individually instead, with the results above.