From e365f7cc0a5d9e46f6274c687db74462d3cfb5d2 Mon Sep 17 00:00:00 2001 From: chintan-diwakar <48587709+chintan-diwakar@users.noreply.github.com> Date: Fri, 14 Aug 2026 23:08:49 +0530 Subject: [PATCH] fix(sessions): pick the last JSON field match by position, not per pattern _extract_last_json_string_field scans the two accepted serializations one after the other ('"key":"' then '"key": "'). The second pattern's last match overwrote the first's unconditionally, so in a transcript that contains both spacings an EARLIER spaced-form value beat a LATER compact-form one, which contradicts the function's documented "finds the LAST occurrence" contract. User-visible effect: rename_session() and tag_session() append compact JSON, but a transcript written by a host tool with a bare json.dumps carries the spaced form. When both are present the rename is silently ignored and get_session_info()/list_sessions() keep reporting the stale title forever. lastPrompt, summary and gitBranch go stale the same way. Track the winning match's index and only overwrite when the new match starts later in the text. Co-Authored-By: Claude Opus 5 --- src/claude_agent_sdk/_internal/sessions.py | 8 ++++- tests/test_session_mutations.py | 41 ++++++++++++++++++++++ tests/test_sessions.py | 19 ++++++++++ 3 files changed, 67 insertions(+), 1 deletion(-) 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!"