"""Anthropic messages <-> Agent SDK transcript entries. :func:`build_entries` renders a message list as the entries the Claude CLI itself writes (reference: ``t/spike_sdk/entries_reference.json``, CLI 2.1.248 via ``import_session_to_store``): one ``user`` entry per prompt, one ``assistant`` entry per content block sharing a message id, one ``user`` entry per ``tool_result`` parented on the matching ``tool_use`` entry. Only ``user``/``assistant`` entries are produced - no attachments, titles or queue markers. Appending the result to a session store and resuming that session id seeds an external history into the SDK. :func:`messages_from_entries` is the projection back, used by tests and by anything that needs Anthropic-shape history out of a mirrored transcript. """ from __future__ import annotations import uuid as _uuid from datetime import UTC, datetime from typing import TYPE_CHECKING, Any try: from claude_agent_sdk._cli_version import __cli_version__ as _cli_version except ImportError: # pragma: no cover _cli_version = "2.1.248" if TYPE_CHECKING: from collections.abc import Iterable, Mapping __all__ = ["CLI_VERSION", "build_entries", "messages_from_entries"] CLI_VERSION = _cli_version _ENTRYPOINT = "sdk-py" _USER_TYPE = "external" _PROMPT_SOURCE = "sdk" _GIT_BRANCH = "HEAD" def build_entries( messages: Iterable[Mapping[str, Any]], *, session_id: str, cwd: str, model: str, permission_mode: str = "bypassPermissions", now: datetime | None = None, ) -> list[dict[str, Any]]: stamp = (now or datetime.now(UTC)).strftime("%Y-%m-%dT%H:%M:%S.") + ( f"{(now or datetime.now(UTC)).microsecond // 1000:03d}Z" ) common = { "isSidechain": False, "userType": _USER_TYPE, "entrypoint": _ENTRYPOINT, "cwd": cwd, "sessionId": session_id, "version": CLI_VERSION, "gitBranch": _GIT_BRANCH, } entries: list[dict[str, Any]] = [] parent: str | None = None prompt_id: str | None = None tool_use_owner: dict[str, str] = {} for message in messages: role = message.get("role") content = message.get("content") if role == "user": results = _tool_results(content) if results: for block in results: owner = tool_use_owner.get( str(block.get("tool_use_id", "")), parent ) uid = _new_uuid() entries.append( { "parentUuid": owner, "promptId": prompt_id, "type": "user", "message": {"role": "user", "content": [block]}, "uuid": uid, "timestamp": stamp, "toolUseResult": _result_text(block), "sourceToolAssistantUUID": owner, **common, } ) parent = uid continue prompt_id = _new_uuid() uid = _new_uuid() entries.append( { "parentUuid": parent, "promptId": prompt_id, "type": "user", "message": {"role": "user", "content": _user_content(content)}, "uuid": uid, "timestamp": stamp, "permissionMode": permission_mode, "promptSource": _PROMPT_SOURCE, **common, } ) parent = uid elif role == "assistant": blocks = _assistant_blocks(content) message_id = f"msg_{_uuid.uuid4().hex[:24]}" request_id = f"req_{_uuid.uuid4().hex[:24]}" stop_reason = ( "tool_use" if any(b.get("type") == "tool_use" for b in blocks) else "end_turn" ) for block in blocks: uid = _new_uuid() entries.append( { "parentUuid": parent, "message": { "model": model, "id": message_id, "type": "message", "role": "assistant", "content": [block], "stop_reason": stop_reason, "stop_sequence": None, "stop_details": None, "usage": _zero_usage(), "diagnostics": None, }, "requestId": request_id, "type": "assistant", "uuid": uid, "timestamp": stamp, **common, } ) if block.get("type") == "tool_use" and block.get("id"): tool_use_owner[str(block["id"])] = uid parent = uid else: msg = f"message role must be user or assistant, got {role!r}" raise ValueError(msg) return entries def messages_from_entries(entries: Iterable[Mapping[str, Any]]) -> list[dict[str, Any]]: out: list[dict[str, Any]] = [] last_message_id: str | None = None for entry in entries: kind = entry.get("type") message = entry.get("message") if kind not in ("user", "assistant") or not isinstance(message, dict): continue content = message.get("content") if kind == "user": results = _tool_results(content) if ( results and out and out[-1]["role"] == "user" and _tool_results(out[-1]["content"]) ): out[-1]["content"].extend(results) else: out.append({"role": "user", "content": _user_content(content)}) last_message_id = None continue blocks = _assistant_blocks(content) message_id = message.get("id") if ( out and out[-1]["role"] == "assistant" and message_id is not None and message_id == last_message_id ): out[-1]["content"].extend(blocks) else: out.append({"role": "assistant", "content": blocks}) last_message_id = message_id return out def _new_uuid() -> str: return str(_uuid.uuid4()) def _tool_results(content: Any) -> list[dict[str, Any]]: if not isinstance(content, list): return [] return [ _clean_block(b) for b in content if isinstance(b, dict) and b.get("type") == "tool_result" ] def _user_content(content: Any) -> Any: if isinstance(content, str): return content if isinstance(content, list): return [_clean_block(b) for b in content if isinstance(b, dict)] msg = f"user content must be str or list, got {type(content).__name__}" raise TypeError(msg) def _assistant_blocks(content: Any) -> list[dict[str, Any]]: if isinstance(content, str): return [{"type": "text", "text": content}] if isinstance(content, list): blocks = [_clean_block(b) for b in content if isinstance(b, dict)] if blocks: return blocks msg = "assistant content must be a non-empty list of blocks" raise ValueError(msg) def _clean_block(block: dict[str, Any]) -> dict[str, Any]: kind = block.get("type") if kind == "tool_use": return { "type": "tool_use", "id": block.get("id", ""), "name": block.get("name", ""), "input": block.get("input", {}), } if kind == "tool_result": out: dict[str, Any] = { "type": "tool_result", "tool_use_id": block.get("tool_use_id", ""), "content": block.get("content", ""), } if block.get("is_error") is not None: out["is_error"] = bool(block["is_error"]) return out if kind == "thinking": return { "type": "thinking", "thinking": block.get("thinking", ""), "signature": block.get("signature", ""), } if kind == "text": return {"type": "text", "text": str(block.get("text", ""))} return dict(block) def _result_text(block: dict[str, Any]) -> str: content = block.get("content", "") if isinstance(content, str): return content if isinstance(content, list): return "\n".join( str(b.get("text", "")) for b in content if isinstance(b, dict) and b.get("type") == "text" ) return str(content) def _zero_usage() -> dict[str, Any]: return { "input_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 0, "service_tier": "standard", "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "cache_creation": { "ephemeral_1h_input_tokens": 0, "ephemeral_5m_input_tokens": 0, }, "inference_geo": "not_available", "iterations": [], "speed": "standard", }