Skip to content

Add resume_session_at / resume_drops_turn for truncating resume - #1198

Merged
qing-ant merged 2 commits into
mainfrom
qing/resume-session-at-drops-turn
Aug 11, 2026
Merged

Add resume_session_at / resume_drops_turn for truncating resume#1198
qing-ant merged 2 commits into
mainfrom
qing/resume-session-at-drops-turn

Conversation

@qing-ant

Copy link
Copy Markdown
Contributor

Summary

TypeScript-parity gap (resumeSessionAt / resumeDropsTurn, the latter added in @anthropic-ai/claude-agent-sdk 0.3.223). The CLI's headless lane can resume a session truncated at an earlier transcript entry (--resume-session-at=<uuid>) and validate that truncation against a declared discarded turn (--resume-drops-turn=<prompt uuid>): every entry past the fork point must be attributable to that turn, otherwise the resume is refused with an error_during_execution result whose message starts with Resume rejected by --resume-drops-turn:. That lets a caller rewind to "before my last prompt" without silently discarding a queued message or task notification the session absorbed mid-turn that the caller never observed. The Python SDK had neither option.

  • ClaudeAgentOptions.resume_session_at / resume_drops_turn, forwarded in equals form with the same Windows cmd-metacharacter rejection as resume / session_id. resume_drops_turn is forwarded whenever it is not None — an empty string reaches the CLI and is rejected as malformed rather than being dropped by the SDK and silently disarming the guard (same as the TS transport).
  • _internal/query.py: a refused resume is reported by the CLI as an error result on stdout followed by exit 1, before it answers the SDK's initialize request. The read loop already replaced the bare "exit code 1" ProcessError with the result's error text for the message stream, but pending control requests — including that in-flight initialize — still received the raw exception, so callers saw Command failed with exit code 1 with the actual reason discarded. Pending control requests now get a ProcessError carrying the same Claude Code returned an error result: ... text. (This also improves e.g. resume of a nonexistent session, which takes the same path.)

No SDK-side validation of the option combination is added; like the TS SDK this defers to the CLI.

Test plan

  • tests/test_transport.py: equals-form emission of both flags, resume_drops_turn omitted when None and forwarded when "", Windows metacharacter rejection for both.
  • tests/test_query.py: an error result followed by a ProcessError while initialize() is pending raises ProcessError with the result text and exit code.
  • e2e-tests/test_truncating_resume.py (runs in CI against the real CLI): build a two-turn session; fork at the last assistant entry of turn 1 with resume_drops_turn = turn 2's prompt UUID → succeeds, forked transcript is [turn 1, new turn], source untouched; same fork with a random resume_drops_turnProcessError containing Resume rejected by --resume-drops-turn:, nothing written. Passed locally against CLI 2.1.227.
  • ruff check, ruff format --check, mypy src/, full pytest tests/ green locally.

The CLI's headless lane can resume a session truncated at an earlier
transcript entry (`--resume-session-at=<uuid>`) and, since 2.1.223,
validate that truncation against a declared discarded turn
(`--resume-drops-turn=<prompt uuid>`): every entry past the fork point
must be attributable to that turn, otherwise the resume is refused with
an `error_during_execution` result whose message starts with
`Resume rejected by --resume-drops-turn:` -- so a caller rewinding to
"before my last prompt" cannot silently discard a queued message or
task notification the session absorbed mid-turn that the caller never
saw. The TypeScript SDK exposes the pair as resumeSessionAt /
resumeDropsTurn; the Python SDK had neither.

Add both options to ClaudeAgentOptions and forward them in equals form
with the same Windows cmd metacharacter rejection as resume/session_id.
resume_drops_turn is forwarded whenever it is not None (an empty string
reaches the CLI and is rejected as malformed rather than being dropped
here and silently disarming the guard), matching the TypeScript
transport.

A refused resume is reported by the CLI as an error result on stdout
followed by exit 1, before it answers the SDK's `initialize` request.
The read loop already replaced the bare "exit code 1" ProcessError with
the result's error text for the message stream, but pending control
requests -- including that in-flight initialize -- still received the
raw exception, so callers saw "Command failed with exit code 1" with the
actual reason discarded. Pending control requests now get a ProcessError
carrying the same "Claude Code returned an error result: ..." text.

Unit tests cover flag emission (incl. the empty-string case and Windows
rejection) and the pending-initialize error text. E2E tests build a
two-turn session, fork it at the end of turn 1 with a matching
resume_drops_turn (succeeds; forked transcript is turn 1 + the new turn)
and with a non-matching one (refused; ProcessError carries the prefix;
source session untouched).
@qing-ant

Copy link
Copy Markdown
Contributor Author

@claude please review this PR for correctness, edge cases, and test coverage.

@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Claude finished @qing-ant's task in 2m 13s —— View job


Code Review: Add resume_session_at / resume_drops_turn for truncating resume

  • Gather context and read all changed files
  • Review types.py changes
  • Review subprocess_cli.py transport changes
  • Review _internal/query.py error handling changes
  • Review unit tests (test_transport.py, test_query.py)
  • Review e2e test (test_truncating_resume.py)
  • Run CI checks locally (tests, ruff, mypy — all pass)
  • Post review findings

Overall this is a clean, well-structured PR with thorough documentation and good test coverage. The implementation correctly follows the patterns already established for resume / session_id and matches the TypeScript SDK's behavior. A few observations:

types.py — New fields on ClaudeAgentOptions

The two new dataclass fields are well-placed (after fork_session) and have excellent docstrings that explain the relationship between resume_session_at and resume_drops_turn, how to choose the fork point, and what to expect on refusal. No issues here.

subprocess_cli.py — Command building

Correct patterns used:

  • Both options use the --flag=value equals form (preventing value-as-flag injection), consistent with resume and session_id.
  • Windows cmd metacharacter rejection is applied to both.
  • The _reject_windows_cmd_metacharacters docstring is updated to name the new options.

Deliberate asymmetry is well-reasoned: resume_session_at uses truthiness (if self._options.resume_session_at:) while resume_drops_turn uses is not None (if self._options.resume_drops_turn is not None:). The comment explains why: an empty resume_drops_turn must reach the CLI to be rejected as malformed rather than silently disarming the safety guard. This matches the TS transport's behavior and is the correct choice.

_internal/query.py — Error propagation to pending control requests

This is the subtlest change and the one that fixes a real bug. Previously, the error-handling block in _read_messages signaled pending control requests with the raw exception e before the ProcessError replacement logic ran. So initialize() — which is in flight as a pending control request during startup — received the generic "Command failed with exit code 1" even when the CLI reported a specific refusal reason like "Resume rejected by --resume-drops-turn: ...".

The fix reorders the logic:

  1. Compute pending_error (either the replaced ProcessError with actionable text, or the original exception)
  2. Then signal all pending control requests with pending_error

This is correct and the new test_pending_initialize_gets_result_error_text test covers the exact scenario. The pending_error variable also carries the original exit_code which is good for downstream consumers.

One observation: The pending_error assigned to pending control requests is the ProcessError with the replaced message, but error_text (used for the stream's {"type": "error"} message) is computed separately and is just the string. These two paths could in theory diverge if the logic changes later, but this is a minor concern and the current state is correct.

Test coverage

Unit tests (transport):

  • test_build_command_resume_session_at_and_drops_turn — both flags emitted in equals form ✓
  • test_build_command_resume_drops_turn_omitted_by_default — None → absent ✓
  • test_build_command_empty_resume_drops_turn_is_forwarded""--resume-drops-turn=
  • test_bad_truncating_resume_values_raise_on_windows — metacharacter rejection ✓

Unit tests (query):

  • test_pending_initialize_gets_result_error_text — the key fix: pending initialize() gets the replaced error text with exit code preserved ✓

E2e tests:

  • Matching resume_drops_turn → successful fork with turn 2 discarded, source untouched ✓
  • Wrong resume_drops_turnProcessError containing refusal text ✓

Minor nit (non-blocking)

The _reject_windows_cmd_metacharacters docstring edit at subprocess_cli.py:450 introduces a line slightly over the project's 88-char ruff limit (109 chars). This is consistent with pre-existing violations in the same file (6 others), and E501 is not enforced, so it's fine — just noting it for completeness. If you want to fix it:

session_id / resume_session_at / resume_drops_turn values,
which applications commonly take from external input, stay
inert even if a cmd.exe hop is ever reintroduced

Verdict

Looks good — clean implementation, correct patterns, thorough tests, and the query.py fix is a real improvement for the error-reporting path. No blocking issues.

No-Verification-Needed: docstring-only change
@qing-ant
qing-ant enabled auto-merge (squash) August 11, 2026 22:58

@chrislloyd chrislloyd left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Stamp via #claude-code-shadow-stamps

@qing-ant
qing-ant merged commit be2d0df into main Aug 11, 2026
10 of 11 checks passed
@qing-ant
qing-ant deleted the qing/resume-session-at-drops-turn branch August 11, 2026 23:08
Flohs pushed a commit to Flohs/claude-agent-sdk-go that referenced this pull request Aug 12, 2026
Flohs added a commit to Flohs/claude-agent-sdk-go that referenced this pull request Aug 12, 2026
)

Port of Python SDK commit be2d0df (anthropics/claude-agent-sdk-python#1198).

Fixes #586

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants