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/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,10 @@ def _extract_last_json_string_field(text: str, key: str) -> str | None:
"""Like _extract_json_string_field but finds the LAST occurrence."""
patterns = [f'"{key}":"', f'"{key}": "']
last_value: str | None = None
# Each pattern is scanned separately, so compare positions rather than
# letting the later pattern's match overwrite an earlier one's: with both
# spacings present, the last match by POSITION has to win.
last_idx = -1
for pattern in patterns:
search_from = 0
while True:
Expand All @@ -244,7 +248,9 @@ def _extract_last_json_string_field(text: str, key: str) -> str | None:
i += 2
continue
if text[i] == '"':
last_value = _unescape_json_string(text[value_start:i])
if idx > last_idx:
last_idx = idx
last_value = _unescape_json_string(text[value_start:i])
break
i += 1
search_from = i + 1
Expand Down
41 changes: 41 additions & 0 deletions tests/test_session_mutations.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,47 @@ def test_last_wins_via_list_sessions(self, claude_config_dir: Path, tmp_path: Pa
assert sessions[0].custom_title == "Final Title"
assert sessions[0].summary == "Final Title"

def test_rename_wins_over_earlier_spaced_title(
self, claude_config_dir: Path, tmp_path: Path
):
"""A rename must win over a pre-existing spaced-form custom-title line.

rename_session appends compact JSON. A transcript written by a host
tool with a bare ``json.dumps`` carries the spaced form instead, so a
file can hold both. The rename is physically the last line and has to
be the one that list_sessions reports.
"""
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, file_path = _make_session_file(project_dir)

# json.dumps defaults -> '"customTitle": "Imported Title"' (spaced).
with file_path.open("a", encoding="utf-8") as f:
f.write(
json.dumps(
{
"type": "custom-title",
"customTitle": "Imported Title",
"sessionId": sid,
}
)
+ "\n"
)

rename_session(sid, "Renamed Title", directory=project_path)

assert (
json.loads(file_path.read_text().strip().split("\n")[-1])["customTitle"]
== "Renamed Title"
)

sessions = list_sessions(directory=project_path, include_worktrees=False)
assert sessions[0].custom_title == "Renamed Title"
assert sessions[0].summary == "Renamed Title"

def test_search_all_projects(self, claude_config_dir: Path):
"""When no directory given, searches all project directories."""
project_dir = _make_project_dir(claude_config_dir, "/some/project")
Expand Down
19 changes: 19 additions & 0 deletions tests/test_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,25 @@ def test_extract_last_json_string_field(self):
text = '{"summary":"first"}\n{"summary":"second"}\n{"summary":"third"}'
assert _extract_last_json_string_field(text, "summary") == "third"

def test_extract_last_json_string_field_mixed_spacing(self):
"""The last occurrence by POSITION wins, whichever spacing it uses.

A transcript can mix both serializations: the CLI and this SDK write
compact (``json.dumps(..., separators=(",", ":"))``), while a host
tool writing the same file with a bare ``json.dumps`` produces the
spaced form. Scanning the patterns one after another must not let an
earlier spaced match outrank a later compact one.
"""
spaced_then_compact = '{"customTitle": "old"}\n{"customTitle":"new"}'
assert _extract_last_json_string_field(spaced_then_compact, "customTitle") == (
"new"
)

compact_then_spaced = '{"customTitle":"old"}\n{"customTitle": "new"}'
assert _extract_last_json_string_field(compact_then_spaced, "customTitle") == (
"new"
)

def test_extract_first_prompt_simple(self):
head = json.dumps({"type": "user", "message": {"content": "Hello!"}}) + "\n"
assert _extract_first_prompt_from_head(head) == "Hello!"
Expand Down