Skip to content

fix(run): work on copies of the lists a resumed run adopts from RunState - #4251

Merged
seratch merged 2 commits into
openai:mainfrom
hsusul:fix/resume-state-list-aliasing
Aug 7, 2026
Merged

fix(run): work on copies of the lists a resumed run adopts from RunState#4251
seratch merged 2 commits into
openai:mainfrom
hsusul:fix/resume-state-list-aliasing

Conversation

@hsusul

@hsusul hsusul commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Summary

Component: src/agents/run.pyAgentRunner.run / AgentRunner.run_streamed, the resumed-RunState setup.

Problem. When a run is resumed from a RunState, the runner adopts three of the state's lists by reference and then appends to them for the rest of the run. RunState is the durable pause/resume boundary and the caller still owns it, so running from a checkpoint silently rewrites that checkpoint — including its serialized form.

result1 = await Runner.run(agent, "First input")
state = result1.to_state()

len(state.to_json()["model_responses"])          # 1

await Runner.run(agent, state)                   # resume

len(state.to_json()["model_responses"])          # 2  <- the state grew

The two entry points corrupt different fields, so the damage differs by path:

list adopted from the state Runner.run Runner.run_streamed
_model_responses appended to (run.py:695) copied (loop rebinds)
_session_items copied already appended to (run.py:2010)
_generated_items appended to (run.py:693) copied already

Because history accumulates in the checkpoint, a second run from that same checkpoint replays work that does not belong to it. On the streamed path that leaks into the model input:

result3 = Runner.run_streamed(agent, state)      # same state as above
...
len(result3.to_input_list())                     # 4 on main, 3 expected —
                                                 # the abandoned attempt's message is replayed

Root cause. session_items was already taken as list(run_state._session_items) on the non-streamed path; its two neighbours in the same block were not, and the streamed constructor passes run_state._session_items straight into RunResultStreaming.new_items. Every downstream write-back to the state is an explicit assignment (update_run_state_for_interruption, and run_state._session_items = list(streamed_result.new_items) in run_loop.py), so nothing depended on the sharing — the aliases only ever leaked appends backwards.

This is the same defect #4237 fixed in the other direction (state._model_responses = list(result.raw_responses) when building a state); these are the three remaining sites, on the path that consumes one.

Fix + why minimal. Take a copy of each list the resumed run adopts, matching what session_items already did. Three list(...) calls, no new helper, no behavior change for a run that starts without a state, and no change to the serialized RunState shape — so no schema version bump.

Deliberately not changed: raw_responses=run_state._model_responses in the RunResultStreaming constructor. It is the visible sibling of the new_items line, but the streamed loop rebinds (streamed_result.raw_responses = streamed_result.raw_responses + [...]) rather than appending, so copying it is provably inert — I verified by reverting each of the four candidate lines individually and rerunning the focused tests: the three in this patch each fail a distinct test, that one changes nothing. It is left alone rather than added as no-op churn.

Non-goals. The runner also writes scalar bookkeeping back onto a resumed state (_current_step, _current_turn, _original_input, and the explicit update_run_state_for_interruption sync). That is deliberate runner behavior and out of scope here; this PR only stops history from accumulating in the caller's lists.

Test plan

Four tests in tests/test_run_state.py::TestRunStateResumption, next to the #4237 test they mirror. All go through the public Runner.run / Runner.run_streamed API with FakeModel — no API key, no network, no sleeps.

  • test_resume_does_not_append_to_the_state_it_resumed_from — non-streamed: _model_responses and the serialized model_responses are unchanged by a resume, and a second run from the same checkpoint returns only its own two responses.
  • test_streamed_resume_does_not_append_to_the_state_it_resumed_from — streamed: same for _session_items / serialized session_items, plus to_input_list() on a re-run contains 3 items rather than 4.
  • test_resumed_max_turns_handler_does_not_append_to_state_items — the _generated_items site, reached by resuming a state whose max_turns is already spent with a max_turns error handler installed.
  • test_fresh_runs_still_report_their_own_history — boundary: a non-resumed run (both paths) still reports exactly its own response and item. Passes before and after, proving the copies did not change the fresh-run path.

The first three fail on clean upstream/main @ f3b6c617 (3 failed, 2 passed for the selection, counting the pre-existing #4237 test); all 5 pass after. Red/green was also proved in place by reverting only the source lines. Focused selection repeated 5× under -W error::RuntimeWarning: stable, no pending-task or unclosed warnings.

Exact commands and results:

  • uv run pytest tests/test_run_state.py -q -k "does_not_append or fresh_runs_still_report or does_not_mutate_source_result" — red 3 failed, 2 passed, 238 deselected; green 5 passed, 238 deselected (×5 runs).
  • uv run pytest tests/test_run_state.py -q243 passed.
  • uv run pytest tests/test_run_state.py tests/test_agent_runner_streamed.py tests/test_hitl_error_scenarios.py tests/test_hitl_session_scenario.py tests/test_stream_events.py tests/test_max_turns.py tests/test_cancel_streaming.py tests/test_streamed_terminal_output_backfill.py -q446 passed.
  • env UV_DEFAULT_INDEX=https://pypi.org/simple bash .agents/skills/code-change-verification/scripts/run.sh — all commands passed (make format 862 files unchanged, make lint all checks passed, make typecheck passed in 223s, make tests passed in 63s).
  • make typecheck — mypy Success: no issues found in 849 source files, pyright 0 errors, 0 warnings, 0 informations.
  • make tests6745 passed, 29 skipped (parallel) and 77 passed, 5 skipped, 57 deselected (serial).
  • git diff --check — clean.

Not run: make coverage, make build-docs (no docs touched), and the Python 3.10 matrix environment.

Issue number

None — reporting and fixing together, as the fix is three lines.

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

@seratch seratch added this to the 0.20.x milestone Aug 7, 2026
@seratch
seratch enabled auto-merge (squash) August 7, 2026 00:36
@seratch
seratch merged commit b42ead5 into openai:main Aug 7, 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.

2 participants