Skip to content

fix(handoffs): keep empty turns in the nested conversation history - #4230

Merged
seratch merged 2 commits into
openai:mainfrom
abhay-codes07:fix/handoff-history-drops-empty-turns
Aug 6, 2026
Merged

fix(handoffs): keep empty turns in the nested conversation history#4230
seratch merged 2 commits into
openai:mainfrom
abhay-codes07:fix/handoff-history-drops-empty-turns

Conversation

@abhay-codes07

Copy link
Copy Markdown
Contributor

Summary

nest_handoff_history summarizes 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 the role: content separator omitted. The parser splits each record on that separator and discards anything without one, so the turn was dropped.

original turns: 3   ['user', 'assistant', 'user']
nested turns  : 2   ['user', 'user']

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="" and content=None, for any role, with or without a name. 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 over content="" 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 main and 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:

# main
FAILED ...test_nested_history_keeps_turns_with_no_content[user_empty]
FAILED ...test_nested_history_keeps_turns_with_no_content[assistant_empty]
FAILED ...test_nested_history_keeps_turns_with_no_content[user_none]
FAILED ...test_nested_history_keeps_turns_with_no_content[named_empty]
FAILED ...test_nested_history_survives_repeated_handoffs
FAILED ...test_bare_role_record_from_an_older_summary_is_recovered
6 failed, 1 passed, 83 deselected

Verification from the repository root:

Command Result
make format clean
make lint all checks passed
make mypy 5 errors, all pre-existing on main, none in the touched files
make pyright 1 error, pre-existing on main (src/agents/sandbox/util/tar_utils.py:161)
uv run pytest tests/test_handoff_history_duplication.py 90 passed
uv run pytest tests/test_extension_filters.py tests/test_run_step_processing.py tests/test_agent_runner.py 338 passed with the file above
make tests 5825 passed

The 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 main checkout in the same environment. The only difference was tests/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 clean main as well, so it is pre-existing and unrelated.

Issue number

Closes #4229

Checks

  • I've added new tests, if relevant
  • I've run .agents/skills/code-change-verification/scripts/run.sh
  • I've confirmed all verification steps pass
  • If using Codex, I've run /review before submitting this PR

The verification script is a bash script that shells out to make. I ran the underlying steps individually instead, with the results above.

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.
Copilot AI review requested due to automatic review settings August 5, 2026 23:19

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment thread src/agents/handoffs/history.py

@seratch seratch left a comment

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.

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.
@abhay-codes07

Copy link
Copy Markdown
Contributor Author

All three addressed in d2b4fbf.

Explicit content: "". Both the separator-only role: record and the recovered bare-role record now set content explicitly. You are right that a role-only item is not replayable; keeping the turn present but unconvertible would have left the original defect half fixed.

Second-pass provider validity. test_second_pass_nesting_keeps_empty_turns_provider_valid nests twice, asserts the reconstructed item is {"role": "assistant", "content": ""}, and then runs the transcript through Converter.items_to_messages to confirm it converts to three messages with the empty one intact. Pinning it against a real adapter rather than just asserting the dict shape.

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 _split_summary_records, so it never reached the rejection branch at all. It is now numbered, which makes it an independent separator-less record that actually exercises the guard, and I also assert the surviving contents so a silently mangled record cannot pass.

I re-checked that the tests discriminate: 8 of the 9 fail on main and pass with the fix, with the prose guard passing in both runs as the control. Full stack is clean, with the suite failure set matching the Windows baseline and mypy at its 5 pre-existing errors.

@abhay-codes07
abhay-codes07 requested a review from seratch August 6, 2026 00:09
@seratch seratch added this to the 0.20.x milestone Aug 6, 2026
@seratch
seratch enabled auto-merge (squash) August 6, 2026 00:21
@seratch
seratch merged commit 37b7a03 into openai:main Aug 6, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Nested handoff history drops turns that have no content

3 participants