diff --git a/src/claude_agent_sdk/_internal/sessions.py b/src/claude_agent_sdk/_internal/sessions.py index cb1cb1342..1cd92db6b 100644 --- a/src/claude_agent_sdk/_internal/sessions.py +++ b/src/claude_agent_sdk/_internal/sessions.py @@ -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: @@ -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 diff --git a/tests/test_session_mutations.py b/tests/test_session_mutations.py index 18ad8aeb5..ff3ca2a9b 100644 --- a/tests/test_session_mutations.py +++ b/tests/test_session_mutations.py @@ -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") diff --git a/tests/test_sessions.py b/tests/test_sessions.py index 1f324f001..cf0a8a914 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -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!"