Skip to content

Commit 0288f13

Browse files
committed
refactor: collapse SessionSummaryEntry to {session_id, mtime, data} — opaque to stores
1 parent 2701492 commit 0288f13

4 files changed

Lines changed: 130 additions & 128 deletions

File tree

src/claude_agent_sdk/_internal/session_summary.py

Lines changed: 43 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -68,15 +68,15 @@ def _entry_text_blocks(entry: dict[str, Any]) -> list[str]:
6868
return texts
6969

7070

71-
def _fold_first_prompt(summary: SessionSummaryEntry, entry: dict[str, Any]) -> None:
71+
def _fold_first_prompt(data: dict[str, Any], entry: dict[str, Any]) -> None:
7272
"""Replicate ``_extract_first_prompt_from_head`` for a single parsed entry.
7373
74-
Mutates ``summary`` in place: sets ``first_prompt`` +
75-
``_first_prompt_locked`` on a real match, or stashes a
76-
``_command_fallback`` for slash-command messages. Skips tool_result,
77-
isMeta, isCompactSummary, and auto-generated patterns.
74+
Mutates ``data`` in place: sets ``first_prompt`` + ``first_prompt_locked``
75+
on a real match, or stashes a ``command_fallback`` for slash-command
76+
messages. Skips tool_result, isMeta, isCompactSummary, and auto-generated
77+
patterns.
7878
"""
79-
if summary.get("_first_prompt_locked"):
79+
if data.get("first_prompt_locked"):
8080
return
8181
if entry.get("type") != "user":
8282
return
@@ -97,15 +97,15 @@ def _fold_first_prompt(summary: SessionSummaryEntry, entry: dict[str, Any]) -> N
9797
continue
9898
cmd_match = _COMMAND_NAME_RE.search(result)
9999
if cmd_match:
100-
if not summary.get("_command_fallback"):
101-
summary["_command_fallback"] = cmd_match.group(1)
100+
if not data.get("command_fallback"):
101+
data["command_fallback"] = cmd_match.group(1)
102102
continue
103103
if _SKIP_FIRST_PROMPT_PATTERN.match(result):
104104
continue
105105
if len(result) > 200:
106106
result = result[:200].rstrip() + "\u2026"
107-
summary["first_prompt"] = result
108-
summary["_first_prompt_locked"] = True
107+
data["first_prompt"] = result
108+
data["first_prompt_locked"] = True
109109
return
110110

111111

@@ -121,15 +121,19 @@ def fold_session_summary(
121121
transcript. ``prev`` is the previous summary for the same key (or ``None``
122122
for the first append).
123123
124-
Set-once fields (``is_sidechain``, ``created_at``, ``cwd``,
125-
``first_prompt``) freeze on first sight; last-wins fields
126-
(``custom_title``, ``ai_title``, ``last_prompt``, ``summary_hint``,
127-
``git_branch``, ``tag``, ``mtime``) overwrite on every appearance.
124+
All derived state lives in the opaque ``data`` dict; stores persist it
125+
verbatim and do not interpret it. ``mtime`` stays top-level so stores
126+
can index on it.
128127
"""
129128
if prev is not None:
130-
summary = cast(SessionSummaryEntry, dict(prev))
129+
summary: SessionSummaryEntry = {
130+
"session_id": prev["session_id"],
131+
"mtime": prev["mtime"],
132+
"data": dict(prev["data"]),
133+
}
131134
else:
132-
summary = {"session_id": key["session_id"], "mtime": 0}
135+
summary = {"session_id": key["session_id"], "mtime": 0, "data": {}}
136+
data = summary["data"]
133137

134138
for raw in entries:
135139
# SessionStoreEntry is a permissive TypedDict; widen to a plain dict
@@ -140,30 +144,30 @@ def fold_session_summary(
140144
if ms is not None and ms > summary["mtime"]:
141145
summary["mtime"] = ms
142146

143-
if "is_sidechain" not in summary:
144-
summary["is_sidechain"] = entry.get("isSidechain") is True
145-
if "created_at" not in summary and ms is not None:
146-
summary["created_at"] = ms
147+
if "is_sidechain" not in data:
148+
data["is_sidechain"] = entry.get("isSidechain") is True
149+
if "created_at" not in data and ms is not None:
150+
data["created_at"] = ms
147151

148-
if "cwd" not in summary:
152+
if "cwd" not in data:
149153
cwd = entry.get("cwd")
150154
if isinstance(cwd, str) and cwd:
151-
summary["cwd"] = cwd
155+
data["cwd"] = cwd
152156

153-
_fold_first_prompt(summary, entry)
157+
_fold_first_prompt(data, entry)
154158

155159
for src, dst in _LAST_WINS_FIELDS.items():
156160
val = entry.get(src)
157161
if isinstance(val, str):
158-
summary[dst] = val # type: ignore[literal-required]
162+
data[dst] = val
159163

160164
if entry.get("type") == "tag":
161165
tag_val = entry.get("tag")
162166
if isinstance(tag_val, str) and tag_val:
163-
summary["tag"] = tag_val
167+
data["tag"] = tag_val
164168
else:
165169
# Empty string or absent tag clears the tag.
166-
summary.pop("tag", None)
170+
data.pop("tag", None)
167171

168172
return summary
169173

@@ -176,19 +180,20 @@ def summary_entry_to_sdk_info(
176180
Returns ``None`` for sidechain sessions or sessions with no extractable
177181
summary, matching ``_parse_session_info_from_lite``'s filtering.
178182
"""
179-
if entry.get("is_sidechain"):
183+
data = entry["data"]
184+
if data.get("is_sidechain"):
180185
return None
181186

182187
first_prompt = (
183-
entry.get("first_prompt")
184-
if entry.get("_first_prompt_locked")
185-
else entry.get("_command_fallback")
188+
data.get("first_prompt")
189+
if data.get("first_prompt_locked")
190+
else data.get("command_fallback")
186191
) or None
187-
custom_title = entry.get("custom_title") or entry.get("ai_title") or None
192+
custom_title = data.get("custom_title") or data.get("ai_title") or None
188193
summary = (
189194
custom_title
190-
or entry.get("last_prompt")
191-
or entry.get("summary_hint")
195+
or data.get("last_prompt")
196+
or data.get("summary_hint")
192197
or first_prompt
193198
)
194199
if not summary:
@@ -198,11 +203,11 @@ def summary_entry_to_sdk_info(
198203
session_id=entry["session_id"],
199204
summary=summary,
200205
last_modified=entry["mtime"],
201-
file_size=entry.get("file_size"),
206+
file_size=data.get("file_size"),
202207
custom_title=custom_title,
203208
first_prompt=first_prompt,
204-
git_branch=entry.get("git_branch") or None,
205-
cwd=entry.get("cwd") or project_path or None,
206-
tag=entry.get("tag") or None,
207-
created_at=entry.get("created_at"),
209+
git_branch=data.get("git_branch") or None,
210+
cwd=data.get("cwd") or project_path or None,
211+
tag=data.get("tag") or None,
212+
created_at=data.get("created_at"),
208213
)

src/claude_agent_sdk/testing/session_store_conformance.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,11 @@ async def fresh() -> SessionStore:
167167
# --- Optional: list_session_summaries ----------------------------------
168168

169169
if has_list_summaries:
170-
# 14. list_session_summaries reflects incremental fold on append
170+
# 14. list_session_summaries returns persisted fold output that
171+
# round-trips through fold_session_summary again. Stores must NOT
172+
# interpret ``data`` — only persist it verbatim.
173+
from .._internal.session_summary import fold_session_summary
174+
171175
store = await fresh()
172176
key: SessionKey = {"project_key": "proj", "session_id": "summ-sess"}
173177
await store.append(
@@ -191,8 +195,13 @@ async def fresh() -> SessionStore:
191195
summ = by_id["summ-sess"]
192196
# mtime must be epoch-ms; >1e12 rules out epoch-seconds.
193197
assert math.isfinite(summ["mtime"]) and summ["mtime"] > 1e12
194-
# custom_title is last-wins across append calls.
195-
assert summ.get("custom_title") == "second"
198+
# data is opaque; the contract is that it round-trips into the fold.
199+
assert isinstance(summ["data"], dict)
200+
refolded = fold_session_summary(
201+
summ, key, [_e({"timestamp": "2024-01-01T00:00:03.000Z"})]
202+
)
203+
assert refolded["session_id"] == "summ-sess"
204+
assert refolded["mtime"] >= summ["mtime"]
196205
assert await store.list_session_summaries("never-appended-project") == []
197206
if has_delete:
198207
await store.delete(key)

src/claude_agent_sdk/types.py

Lines changed: 11 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1159,41 +1159,21 @@ class SessionStoreListEntry(TypedDict):
11591159
modification time (e.g. Redis) must maintain their own index."""
11601160

11611161

1162-
class SessionSummaryEntry(TypedDict, total=False):
1162+
class SessionSummaryEntry(TypedDict):
11631163
"""Incrementally-maintained session summary.
11641164
1165-
Stores update this on :meth:`SessionStore.append` via
1166-
:func:`fold_session_summary` and return the full set from
1167-
:meth:`SessionStore.list_session_summaries`. Every field is
1168-
append-incremental (set-once or last-wins) so adapters never re-read.
1169-
1170-
Fields prefixed ``_`` are opaque fold state — stores MUST persist them
1171-
verbatim across :func:`fold_session_summary` calls but SHOULD NOT
1172-
interpret them.
1165+
Stores obtain this from :func:`fold_session_summary` inside
1166+
:meth:`SessionStore.append` and persist it verbatim; they return the
1167+
full set from :meth:`SessionStore.list_session_summaries`. The ``data``
1168+
field is opaque SDK-owned state — stores MUST NOT interpret it.
11731169
"""
11741170

1175-
session_id: Required[str]
1176-
mtime: Required[int]
1177-
"""Last-modified time in Unix epoch milliseconds (last entry timestamp)."""
1178-
is_sidechain: bool
1179-
created_at: int
1180-
"""First entry timestamp in Unix epoch milliseconds."""
1181-
cwd: str
1182-
first_prompt: str
1183-
"""First meaningful user prompt, truncated to 200 chars."""
1184-
custom_title: str
1185-
ai_title: str
1186-
last_prompt: str
1187-
summary_hint: str
1188-
"""Raw ``summary`` key from JSONL."""
1189-
git_branch: str
1190-
tag: str
1191-
file_size: int
1192-
_first_prompt_locked: bool
1193-
"""Opaque fold state: ``True`` once a non-command prompt has been found."""
1194-
_command_fallback: str
1195-
"""Opaque fold state: first ``<command-name>`` seen, used when no real
1196-
prompt appears."""
1171+
session_id: str
1172+
mtime: int
1173+
"""Last-modified time in Unix epoch milliseconds (last entry timestamp).
1174+
Stores may index on this."""
1175+
data: dict[str, Any]
1176+
"""Opaque SDK-owned summary state. Persist verbatim; do not interpret."""
11971177

11981178

11991179
class SessionListSubkeysKey(TypedDict):

0 commit comments

Comments
 (0)