Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/claude_agent_sdk/_internal/session_mutations.py
Original file line number Diff line number Diff line change
Expand Up @@ -598,7 +598,13 @@ def _parse_fork_transcript(
transcript: list[dict[str, Any]] = []
content_replacements: list[Any] = []

for line in content.decode("utf-8", errors="replace").splitlines():
# Split on "\n" only (not str.splitlines()) to match the read path,
# _parse_transcript_entries. splitlines() additionally breaks on U+2028,
# U+2029 and U+0085, which are valid *unescaped* inside a JSON string and
# are emitted raw by the CLI's JSON.stringify. Splitting on them would
# fragment an otherwise-valid JSONL line into pieces that each fail
# json.loads, silently dropping that message from the fork.
for line in content.decode("utf-8", errors="replace").split("\n"):
line = line.strip()
if not line:
continue
Expand Down
77 changes: 77 additions & 0 deletions tests/test_session_mutations.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
tag_session,
)
from claude_agent_sdk._internal.session_mutations import (
_parse_fork_transcript,
_sanitize_unicode,
_try_append,
)
Expand Down Expand Up @@ -670,6 +671,82 @@ def test_fork_preserves_message_count(
# Fork has same visible messages + 1 custom-title entry (not visible)
assert len(fork_msgs) == len(original_msgs)

@pytest.mark.parametrize("sep", ["\u2028", "\u2029", "\u0085"])
def test_parse_fork_transcript_keeps_unicode_line_separators(
self, sep: str
) -> None:
"""A JSONL line containing a raw U+2028/U+2029/U+0085 must be kept.

These are valid unescaped inside a JSON string and are emitted raw by
the CLI's JSON.stringify; str.splitlines() would fragment the line and
drop it, so the fork parser must split on "\\n" only (like the read
path, _parse_transcript_entries).
"""
entry = {
"type": "user",
"uuid": str(uuid.uuid4()),
"parentUuid": None,
"sessionId": "s",
"message": {"role": "user", "content": f"a{sep}b"},
}
content = (json.dumps(entry, ensure_ascii=False) + "\n").encode("utf-8")

transcript, _ = _parse_fork_transcript(content, "s")

assert len(transcript) == 1
assert transcript[0]["message"]["content"] == f"a{sep}b"

def test_fork_preserves_message_with_line_separator(
self, claude_config_dir: Path, tmp_path: Path
):
"""Fork keeps a message whose content contains a raw U+2028 (issue: fork
dropped it because str.splitlines() split the JSONL line)."""
project_path = str(tmp_path / "proj")
Path(project_path).mkdir(parents=True)
project_dir = _make_project_dir(
claude_config_dir, os.path.realpath(project_path)
)

sid = str(uuid.uuid4())
u1, u2 = str(uuid.uuid4()), str(uuid.uuid4())
lines = [
json.dumps(
{
"type": "user",
"uuid": u1,
"parentUuid": None,
"sessionId": sid,
"timestamp": "2026-03-01T00:00:00Z",
"message": {"role": "user", "content": "question"},
},
ensure_ascii=False,
),
json.dumps(
{
"type": "assistant",
"uuid": u2,
"parentUuid": u1,
"sessionId": sid,
"timestamp": "2026-03-01T00:00:00Z",
# Raw U+2028 LINE SEPARATOR inside the text: valid JSON,
# not a JSONL line break.
"message": {
"role": "assistant",
"content": [{"type": "text", "text": "answer\u2028more"}],
},
},
ensure_ascii=False,
),
]
(project_dir / f"{sid}.jsonl").write_text(
"\n".join(lines) + "\n", encoding="utf-8"
)

original_msgs = get_session_messages(sid, directory=project_path)
result = fork_session(sid, directory=project_path)
fork_msgs = get_session_messages(result.session_id, directory=project_path)
assert len(fork_msgs) == len(original_msgs)

def test_fork_up_to_message_id(self, claude_config_dir: Path, tmp_path: Path):
"""Fork slices at up_to_message_id (inclusive)."""
project_path = str(tmp_path / "proj")
Expand Down