Skip to content

fix(handoffs): preserve message name when nesting handoff history - #4300

Closed
hsusul wants to merge 1 commit into
openai:mainfrom
hsusul:fix/nested-history-preserve-message-name
Closed

fix(handoffs): preserve message name when nesting handoff history#4300
hsusul wants to merge 1 commit into
openai:mainfrom
hsusul:fix/nested-history-preserve-message-name

Conversation

@hsusul

@hsusul hsusul commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Summary

When nest_handoff_history (nested handoff history) summarizes the previous transcript, each turn is rendered as a compact role (name): content line. On a subsequent handoff the summary is flattened back into structured items by _extract_nested_history_transcript, which reverses that format.

That compact form is ambiguous when the role or the message name contains one of the characters the parser splits on:

  • : is consumed by the role: content split.
  • ( / ) confuse the (name) extraction (which uses rfind("(")).

So a message whose name contained those characters was silently corrupted on the next handoff. For example, with {"role": "assistant", "name": "a:b", "content": "answer here"}:

  • before: {"role": "assistant (a", "content": "b): answer here"} — the role, name, and content are all wrong
  • after: the item round-trips unchanged

The SDK already renders items losslessly via the JSON encoding used for content that contains newlines. This change routes items to that JSON encoding whenever the compact line would not be reversible (role/name containing :, (, ), \n, or \r), and keeps the readable compact form for everything else (e.g. a plain name like "bob", which is unaffected).

The fix is confined to _format_transcript_item's legacy-vs-JSON choice; parsing is unchanged. The JSON path is already exercised by existing tests, and the change is idempotent across repeated handoffs.

Test plan

Added test_nested_history_preserves_message_name, parametrized over names bob, O(1), a:b, foo(bar), x)y. It nests a 3-turn history whose middle message carries the name, then re-flattens across several handoffs and asserts the transcript is unchanged each time.

  • Fails on main for the colon, parens, and trailing_call cases (role/name/content corrupted); passes with this change.

Commands run (from a clean upstream/main worktree):

  • uv run pytest tests/test_handoff_history_duplication.py -q → 97 passed
  • uv run pytest tests/test_handoff_history_duplication.py tests/test_run_internal_items.py tests/test_handoff_tool.py tests/test_run_step_processing.py -q → 198 passed
  • uv run ruff check src/agents/handoffs/history.py tests/test_handoff_history_duplication.py → All checks passed
  • uv run ruff format --check … → already formatted
  • uv run pyright src/agents/handoffs/history.py → 0 errors
  • TYPECHECK_SRC_ONLY=1 uv run mypy src --exclude site → no new errors in handoffs/history.py; the 44 reported errors are pre-existing on main in unrelated optional-dependency modules (extensions/sandbox/*, extensions/models/litellm_model.py, extensions/memory/sqlalchemy_session.py, voice/imports.py, etc.).

Issue number

N/A

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 (ran the ruff / pyright / mypy / pytest commands listed above individually rather than via run.sh)
  • If using Codex, I've run /review before submitting this PR

The compact "role (name): content" summary line used for nested handoff
history cannot be reversed when the role or name contains the delimiters
the flattening parser splits on. A ":" is consumed by the "role: content"
split and "("/")" confuse the "(name)" extraction, so on the next handoff
a message whose name contained those characters was silently corrupted
(e.g. name "a:b" turned into role "assistant (a" with content "b): ...").

Route such items to the existing JSON encoding, which round-trips
losslessly, leaving the readable compact form for names without those
delimiters.

@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: 060e8b2beb

ℹ️ 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 on lines +429 to +431
name = item.get("name")
if isinstance(name, str) and any(char in name for char in _LEGACY_SUMMARY_DELIMITERS):
return False

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Route whitespace-padded names through JSON

When a named message has leading or trailing whitespace (for example, "name": " bob "), this predicate classifies the compact representation as reversible, but _split_role_and_name() strips that whitespace while parsing, so the next handoff silently changes the name to "bob"; a whitespace-only name corrupts the role to "assistant ( )". Treat names that differ from name.strip() as non-reversible and use the JSON path, with a round-trip test for this supported string edge case.

AGENTS.md reference: AGENTS.md:L70-L70

Useful? React with 👍 / 👎.

@seratch

seratch commented Aug 8, 2026

Copy link
Copy Markdown
Member

Thanks for the careful investigation and focused patch. I confirmed the direct helper round-trip failure, but the PR does not establish a supported runtime path for the affected input. TResponseInputItem uses the Responses input-item schema, whose message shapes do not include name, and the Chat Completions converter rejects the same named message before it can reach nested handoff handling. The regression test therefore demonstrates a cast-only helper case, not user-visible SDK impact.

I am going to close this PR for now. If you can provide an end-to-end reproduction using a supported Runner input and provider path that accepts a message name containing these delimiters and demonstrates the downstream effect, we can revisit the narrow serialization fix.

@seratch seratch closed this Aug 8, 2026
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.

2 participants