Skip to content

fix(sessions): pick the last JSON field match by position, not per pattern - #1208

Open
chintan-diwakar wants to merge 1 commit into
anthropics:mainfrom
chintan-diwakar:fix/last-json-field-position
Open

chintan-diwakar wants to merge 1 commit into
anthropics:mainfrom
chintan-diwakar:fix/last-json-field-position

Conversation

@chintan-diwakar

@chintan-diwakar chintan-diwakar commented Aug 15, 2026 •

Copy link
Copy Markdown

Summary

_extract_last_json_string_field (_internal/sessions.py:229) scans the two accepted serializations one after the other, "key":" then "key": ", and lets the second pattern's last match overwrite the first's unconditionally. So it returns the last match per pattern, not the last match by position, contradicting its own docstring ("finds the LAST occurrence").

When a transcript holds both spacings, an earlier "key": "value" beats a later "key":"value". The user-visible result is that rename_session() is silently ignored: the new title is physically the last line of the file, and get_session_info() / list_sessions() keep returning the old one permanently.

Both spacings occur in practice. rename_session() appends compact JSON (session_mutations.py:98 uses separators=(",", ":")), while anything that serialized the transcript with a bare json.dumps(entry) writes the spaced form, since Python's defaults are ", " / ": ". This repo's own fixtures do exactly that at tests/test_sessions.py:88.

>>> _extract_last_json_string_field('{"customTitle": "old"}\n{"customTitle":"new"}', "customTitle")
'old'      # expected 'new'

_parse_session_info_from_lite routes customTitle, aiTitle, lastPrompt, summary, gitBranch and tag through this helper, so tag_session() and the last-prompt, summary and branch fields go stale the same way.

Fix

  • src/claude_agent_sdk/_internal/sessions.py: track the winning match's start index and only overwrite when a later one is found. Four lines. Scanning, escape handling and the truncated-line break are unchanged, so files using a single spacing behave exactly as before.
  • tests/test_sessions.py: test_extract_last_json_string_field_mixed_spacing covers both orderings at the unit level.
  • tests/test_session_mutations.py: test_rename_wins_over_earlier_spaced_title is the end-to-end regression, renaming over a pre-existing spaced title and asserting list_sessions() reports the rename.

Tests

  • Both new tests fail on main and pass with the fix (2 failed, 152 passed with sessions.py reverted).
  • Full suite: python -m pytest tests/: 1368 passed, 5 skipped, same baseline as main.
  • python -m ruff check src/ tests/, ruff format --check and python -m mypy src/: clean.

A public-API reproduction, using only rename_session, get_session_info, list_sessions and project_key_for_directory, is in the first comment below.

Note on the sibling helper

_extract_json_string_field (sessions.py:205) has the mirrored flaw: it documents "returns the first match" but tries the compact pattern to completion first, so a later compact match can beat an earlier spaced one. It feeds created_at, cwd and the gitBranch head fallback. I left it out to keep this to one defect with one demonstrated symptom, and am happy to fold in the same-shape fix if you would prefer both together.

Used AI assistance; reviewed and tested by me.

@chintan-diwakar chintan-diwakar changed the title Fix rename_session being silently ignored on mixed-spacing transcripts fix(sessions): pick the last JSON field match by position, not per pattern Aug 15, 2026
@chintan-diwakar

Copy link
Copy Markdown
Author

Public-API reproduction. Uses only rename_session, get_session_info, list_sessions and project_key_for_directory, plus CLAUDE_CONFIG_DIR.

import json, os, tempfile
from pathlib import Path

tmp = tempfile.mkdtemp(prefix="sdk-repro-")
config_dir = Path(tmp) / "claude-config"
project = Path(tmp) / "myproject"
project.mkdir()
os.environ["CLAUDE_CONFIG_DIR"] = str(config_dir)

from claude_agent_sdk import (  # env var must be set first
    get_session_info, list_sessions, project_key_for_directory, rename_session,
)

sid = "550e8400-e29b-41d4-a716-446655440000"

# A host tool writes the transcript with plain json.dumps -> SPACED form.
entries = [
    {"type": "user", "uuid": "u1", "sessionId": sid,
     "timestamp": "2026-08-01T10:00:00Z", "cwd": str(project),
     "message": {"role": "user", "content": "help me refactor the parser"}},
    {"type": "custom-title", "customTitle": "Imported Title", "sessionId": sid},
]
f = config_dir / "projects" / project_key_for_directory(project) / f"{sid}.jsonl"
f.parent.mkdir(parents=True)
f.write_text("".join(json.dumps(e) + "\n" for e in entries))

# The user renames through the SDK. This appends a COMPACT line at EOF.
rename_session(sid, "Renamed Title", directory=str(project))

print("last line of file:", f.read_text().rstrip().rsplit("\n", 1)[-1])
info = get_session_info(sid, directory=str(project))
print("get_session_info custom_title:", info.custom_title)
print("list_sessions[0] summary:     ",
      list_sessions(directory=str(project), include_worktrees=False)[0].summary)

On main:

last line of file: {"type":"custom-title","customTitle":"Renamed Title","sessionId":"550e8400-e29b-41d4-a716-446655440000"}
get_session_info custom_title: Imported Title
list_sessions[0] summary:      Imported Title

With this PR:

last line of file: {"type":"custom-title","customTitle":"Renamed Title","sessionId":"550e8400-e29b-41d4-a716-446655440000"}
get_session_info custom_title: Renamed Title
list_sessions[0] summary:      Renamed Title

…ttern

_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 <noreply@anthropic.com>
@chintan-diwakar
chintan-diwakar force-pushed the fix/last-json-field-position branch from f3cee4f to e365f7c Compare August 15, 2026 08:46
feiiiiii5 added a commit to feiiiiii5/claude-agent-sdk-python that referenced this pull request Sep 20, 2026
The paragraph added in the previous commit overstated in two ways.

It said this is "the same ordering flaw _extract_last_json_string_field
guards against". It does not guard against it: that helper still loops the
two patterns in the outer loop and overwrites last_value without comparing
positions, so the spaced pass wins over a later compact hit. Measured on
this branch:

  _extract_last_json_string_field('{"summary": "spaced-EARLY"}\n'
                                  '{"summary":"compact-LATE"}', "summary")
    -> 'spaced-EARLY'   (want 'compact-LATE')
  _extract_last_json_string_field('{"summary":"c1"}\n{"summary": "s2"}\n'
                                  '{"summary":"c3-LATEST"}', "summary")
    -> 's2'             (want 'c3-LATEST')

That helper is anthropics#1208's subject, which is open and unmerged. anthropics#1208 states it
left this function out on purpose, so this PR keeps the inverse boundary and
the sentence becomes an accurate pointer instead of a guarantee. Fixing it
here would duplicate anthropics#1208 and collide with it in the same hunk.

It also promised the first match "no matter which spacing it uses", but only
"key":"v" and "key": "v" are ever compiled, so a value written with two
spaces, a tab or a newline after the colon is not a candidate at all and a
later recognized spelling is returned instead:

  _extract_json_string_field('{"cwd":  "/two/spaces"}\n'
                             '{"cwd":"/compact/later"}', "cwd")
    -> '/compact/later'

Narrowed the wording to the two spellings listed above and added a guard test
pinning that scope. No behaviour change: tests/test_sessions.py 112 passed,
full suite 1504 passed / 5 skipped, ruff check, ruff format --check and
mypy src/ scripts/ clean.
@feiiiiii5

Copy link
Copy Markdown

Picking up from #1274, where this PR is the referenced follow-up for the mirrored case. Independent confirmation, plus two things that may help this land.

The flaw reproduces. Measured on CPython 3.11.15 with _extract_last_json_string_field and its two helpers lifted out of _internal/sessions.py (no package import, so the result doesn't depend on my environment):

  • {"summary": "spaced-EARLY"}\n{"summary":"compact-LATE"} → returns 'spaced-EARLY', although the last occurrence in the text is the compact compact-LATE
  • {"summary":"compact-EARLY"}\n{"summary": "spaced-LATE"} → returns 'spaced-LATE', correct only because the spaced hit happens to be last

i.e. today the function returns "last spaced, else last compact", not "last".

Two notes on scope, which I think is the part that makes this easy to under-rate:

  1. There is a call site outside _parse_session_info_from_lite. _internal/session_mutations.py:40 imports this helper, and _derive_title (:312, inside fork_session at :240) calls it four times at :321-324. fork_session is exported from __init__.py, so the wrong-value outcome is user-visible through a second public API, not only through listing sessions. Worth one line in the description — "custom title" on its own reads like a display concern, which understates it.
  2. Your patch applies cleanly onto current main (f7547d7) and onto fix(sessions): pick the first JSON field match by position, not per pattern #1274's head (88103e0f1) — the two changes are in different functions, so this does not have to wait for that PR.

I am not opening a competing PR for this; #1274 points here.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants