はじめに
Claude Agent SDK(Python版)に、セッション中のファイル変更を任意の時点まで巻き戻せる「file checkpointing」機能があります。ClaudeAgentOptions(enable_file_checkpointing=True) で有効化し、client.rewind_files(user_message_id) を呼ぶだけ、というのが公式のうたい文句です。
この記事では、実際にクラウド実行環境で pip install claude-agent-sdk して動かし、「どのメッセージの uuid を渡せば巻き戻せて、どれを渡すと失敗するか」を実機で検証しました。結論から言うと、公式サンプルコードのパターンをそのまま実装すると、意図したところで rewind_files() が失敗するケースがある ことが分かりました。
この記事で学べること
-
enable_file_checkpointingの有効化に必要な2つの設定 -
rewind_files()が実際に効くuuidと効かないuuidの違い(実機検証結果) - 複数ターンをまたいだ巻き戻しの挙動
対象読者
- Claude Agent SDK でマルチターンのコーディングエージェントを組みたい方
- 「変更を試して、ダメなら戻す」を自前実装しようとしている方
前提環境
- Python 3.11.15
-
claude-agent-sdk0.2.120(PyPI 最新版) - 実行環境: Claude Code on the web のクラウドコンテナ(rootユーザー)
-
claudeCLI 2.1.211 がインストール済みで、SDK はこれをサブプロセスとして起動する構成
TL;DR
-
enable_file_checkpointing=Trueに加えてextra_args={"replay-user-messages": None}を渡さないと、UserMessageにuuidが乗ってこない(=巻き戻し先を取得できない) -
rewind_files()に渡せるのは 各query()呼び出しの最初のユーザーメッセージのuuidだけ - ツール実行結果として発行される中間の
UserMessage(contentがリスト型になっているもの)のuuidを渡すとException: No file checkpoint found for this message.で失敗する - 複数回
query()を呼んでも、各ターン開始時点のチェックポイントへ正しく戻れる(v3 → v2 → v1 を実機確認)
セットアップ
まずインストールします。
pip install claude-agent-sdk
python3 -c "import claude_agent_sdk; print(claude_agent_sdk.__version__)"
# 0.2.120
ClaudeAgentOptions のフィールドを見ると、enable_file_checkpointing: bool = False が確認できます。
import dataclasses
import claude_agent_sdk
for f in dataclasses.fields(claude_agent_sdk.ClaudeAgentOptions):
print(f.name)
# ... enable_file_checkpointing, session_store, task_budget ...
ClaudeSDKClient 側には rewind_files(user_message_id: str) -> None が生えています。
やったこと
ステップ1: 最初の実行で permission_mode に注意(実行環境固有の制約)
クラウドコンテナは root ユーザーで動作しているため、permission_mode="bypassPermissions" を指定すると次のエラーで接続に失敗しました。
claude_agent_sdk._errors.ProcessError: Command failed with exit code 1
--dangerously-skip-permissions cannot be used with root/sudo privileges for security reasons
これは Claude Code CLI 側の安全機構(root 権限での無条件承認スキップを拒否する)が働いているためで、SDK 固有の不具合ではありません。permission_mode="acceptEdits"(ファイル編集のみ自動承認)に変えると接続できました。root 環境で SDK を試す場合はこの制約を踏まえてモードを選ぶ必要があります。
ステップ2: checkpointingを有効化して1ターン編集する
import asyncio
from claude_agent_sdk import ClaudeSDKClient, ClaudeAgentOptions, UserMessage
async def main():
options = ClaudeAgentOptions(
cwd="/tmp/checkpoint-test",
enable_file_checkpointing=True,
extra_args={"replay-user-messages": None}, # これが無いとUserMessageにuuidが乗らない
permission_mode="acceptEdits",
max_turns=5,
)
checkpoints = []
async with ClaudeSDKClient(options) as client:
await client.query("test.txtの中身をEditツールで 'hello v2' に書き換えて。")
async for msg in client.receive_response():
if isinstance(msg, UserMessage) and getattr(msg, "uuid", None):
checkpoints.append(msg.uuid)
print(checkpoints)
asyncio.run(main())
実行すると、1ターンの中で 6個 の UserMessage(uuid付き)が発行されました。中身は「ユーザーの入力」1件と、Bash / Read / Edit といったツール実行結果を運ぶ UserMessage 5件です。
['9d6f...', '8b21...', '28b8...', '4328...', '38aa...', 'acec...']
ここでドキュメントのサンプルコード通りに checkpoints[-1](最後に受け取った uuid)を rewind_files() に渡すと失敗します。
await client.rewind_files(checkpoints[-1])
# Exception: No file checkpoint found for this message.
checkpoints[0](そのターンの最初のユーザーメッセージの uuid)を渡すと成功し、ファイルが編集前の状態に戻りました。
await client.rewind_files(checkpoints[0])
# OK. test.txt は 'hello v2' → 'hello v1' に巻き戻る
ステップ3: 複数ターンでの巻き戻しを検証
「途中のターンまで戻す」という本来のユースケースを想定し、2回 query() を呼んで確認しました。
async def verify_multi_turn_rewind(options):
async with ClaudeSDKClient(options) as client:
await client.query("test.txtをEditツールで 'hello v2' に書き換えて。")
turn1_uuid = None
async for msg in client.receive_response():
if isinstance(msg, UserMessage) and msg.uuid and turn1_uuid is None:
turn1_uuid = msg.uuid
await client.query("test.txtをEditツールで 'hello v3' に書き換えて。")
turn2_uuid = None
async for msg in client.receive_response():
if isinstance(msg, UserMessage) and msg.uuid and turn2_uuid is None:
turn2_uuid = msg.uuid
# ここで test.txt は 'hello v3'
await client.rewind_files(turn2_uuid)
# -> 'hello v2' に戻る
await client.rewind_files(turn1_uuid)
# -> 'hello v1' に戻る
実行結果は次の通りで、想定通りターンごとに正確に巻き戻せました。
| 操作 | ファイル内容 |
|---|---|
| turn1 実行後 | hello v2 |
| turn2 実行後 | hello v3 |
rewind_files(turn2_uuid) 後 |
hello v2 |
rewind_files(turn1_uuid) 後 |
hello v1 |
ハマりポイント
ポイント1: uuid はメッセージ種別で区別が必要
receive_response() のループで受け取る UserMessage は1種類ではありません。人間が入力したメッセージと、ツール実行結果を運ぶメッセージの両方が同じ UserMessage 型で流れてきます。SDK の型定義上、content フィールドの型で見分けられます。
if isinstance(msg, UserMessage) and isinstance(msg.content, str):
# 人間の入力メッセージ(= checkpoint に使えるuuid)
checkpoint_id = msg.uuid
ツール結果側は content が list(ToolResultBlock 等を含む)になっているため、isinstance(msg.content, str) で判定すれば取り違えを防げます。公式ドキュメントのサンプルは「UserMessage を受け取ったら uuid を保存する」とだけ書かれており、この区別には触れていません。
rewind_files()の失敗は例外(Exception)で返るため、try/exceptを挟んでおかないと巻き戻し失敗がそのままクラッシュにつながります。
ポイント2: extra_args={"replay-user-messages": None} を忘れると詰む
extra_args を外して同じコードを実行すると、UserMessage.uuid が常に None になり、rewind_files() に渡す値そのものが手に入りません。enable_file_checkpointing=True だけでは不十分で、CLI 側に --replay-user-messages フラグを渡す extra_args が必須という組み合わせでした。この要件は Web版の公式ドキュメントには見当たりませんでしたが、inspect.getsource(ClaudeSDKClient.rewind_files) でSDK同梱のdocstringを読むと明記されています。GitHub上のリポジトリを読まずにパッケージ単体をインストールしただけでは気づきにくいポイントです。
著者視点の発見ポイント
公式サンプルコードは「receive_response() ループで最後に見つけた uuid を checkpoint_id として保存する」という書き方をしています。しかし実際に動かすと、ツール呼び出しを1回でも挟んだターンでは、ループの最後に流れてくる UserMessage はほぼ確実にツール結果側(content がリスト型)になり、そのまま rewind_files() に渡すと失敗します。実務でこの機能を使うなら、isinstance(msg.content, str) でメッセージ種別を判定し、各ターンの最初の UserMessage の uuid を保存する 実装にする必要があります。この点はドキュメントの疑似コード(docs/agent-sdk の該当ページ)には明記されていませんでした。
まとめ
-
enable_file_checkpointing=Trueとextra_args={"replay-user-messages": None}はセットで必要 -
rewind_files()に渡せるのは各ターンの 最初のユーザーメッセージ のuuidのみ。ツール結果側のuuidでは失敗する - 複数ターンにまたがる巻き戻しは正しく機能する(実機確認済み)
- 「エージェントに試行錯誤させて、ダメならターンごと戻す」というワークフローを組む場合は、
contentの型でメッセージ種別を判定してからuuidを保存する実装にすること
参考リンク
- Claude Agent SDK Python - CHANGELOG — file checkpointing・structured outputs等の変更履歴
- Claude Code Docs - What's new — Claude Code / Agent SDK の更新情報