Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions coding_bridge/claude_transcript.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
"""Non-destructive compatibility repair for Claude Code transcripts."""
from __future__ import annotations

import contextlib
import hashlib
import json
import os
import stat
import uuid
from pathlib import Path
from typing import Any

from . import history

_REPAIR_VERSION = "unsigned-thinking-v1"
_REPAIR_NAMESPACE = uuid.UUID("ae5aa1df-998a-48a8-8c1f-c449262e573e")


class TranscriptRecoveryError(RuntimeError):
"""A transcript could not be repaired without risking conversation loss."""


def prepare_resume(session_id: str) -> str:
"""Return a resumable session id, repairing unsigned thinking in a fork."""
source = history.claude_path(session_id)
if source is None:
return session_id

try:
raw = source.read_bytes()
records = _parse_records(raw)
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
raise TranscriptRecoveryError(f"cannot read Claude transcript {session_id}") from exc

repaired, changed = _repair_records(records)
if not changed:
return session_id

digest = hashlib.sha256(raw).hexdigest()
repaired_id = str(uuid.uuid5(_REPAIR_NAMESPACE, f"{_REPAIR_VERSION}:{session_id}:{digest}"))
output = _encode_records(repaired, repaired_id)
target = source.with_name(f"{repaired_id}.jsonl")
_atomic_write(target, output)
return repaired_id


def _parse_records(raw: bytes) -> list[dict[str, Any]]:
text = raw.decode("utf-8")
records: list[dict[str, Any]] = []
for line in text.splitlines():
if not line.strip():
continue
record = json.loads(line)
if not isinstance(record, dict):
raise TranscriptRecoveryError("Claude transcript contains a non-object record")
records.append(record)
return records


def _repair_records(records: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], bool]:
changed = False
for record in records:
if record.get("type") != "assistant":
continue
message = record.get("message")
if not isinstance(message, dict):
continue
content = message.get("content")
if not isinstance(content, list):
continue
kept = [block for block in content if not _unsigned_thinking(block)]
if len(kept) == len(content):
continue
if not kept:
raise TranscriptRecoveryError(
"unsigned thinking is the assistant message's only content"
)
message["content"] = kept
changed = True
return records, changed


def _unsigned_thinking(block: Any) -> bool:
if not isinstance(block, dict) or block.get("type") != "thinking":
return False
signature = block.get("signature")
return not isinstance(signature, str) or not signature.strip()


def _encode_records(records: list[dict[str, Any]], session_id: str) -> bytes:
lines: list[str] = []
for record in records:
if "sessionId" in record:
record["sessionId"] = session_id
lines.append(json.dumps(record, ensure_ascii=False, separators=(",", ":")))
return ("\n".join(lines) + "\n").encode()


def _atomic_write(target: Path, content: bytes) -> None:
try:
if target.read_bytes() == content:
return
except FileNotFoundError:
pass
except OSError as exc:
raise TranscriptRecoveryError(f"cannot inspect repaired transcript {target.name}") from exc

temporary = target.with_name(f".{target.name}.{os.getpid()}.tmp")
try:
with open(temporary, "wb") as handle:
handle.write(content)
handle.flush()
os.fsync(handle.fileno())
os.chmod(temporary, stat.S_IRUSR | stat.S_IWUSR)
os.replace(temporary, target)
except OSError as exc:
raise TranscriptRecoveryError(f"cannot write repaired transcript {target.name}") from exc
finally:
with contextlib.suppress(FileNotFoundError):
temporary.unlink()
6 changes: 5 additions & 1 deletion coding_bridge/history.py
Original file line number Diff line number Diff line change
Expand Up @@ -320,13 +320,17 @@ def _claude_assistant_events(content: Any, ts: int | None, events: list[dict[str
)


def _claude_path(session_id: str) -> Path | None:
def claude_path(session_id: str) -> Path | None:
"""Return the newest transcript matching a safe Claude session id."""
if not _safe_id(session_id):
return None
matches = sorted(CLAUDE_ROOT.glob(f"*/{session_id}.jsonl"), key=_safe_mtime, reverse=True)
return matches[0] if matches else None


_claude_path = claude_path


# --- Codex -----------------------------------------------------------------
def _list_codex(limit: int) -> list[dict[str, Any]]:
if not CODEX_ROOT.exists():
Expand Down
5 changes: 3 additions & 2 deletions coding_bridge/providers/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from typing import TYPE_CHECKING, Any

from .. import attachments as attachment_store
from .. import capabilities
from .. import capabilities, claude_transcript
from .. import images as image_store
from ..protocol import Event, event_payload
from .base import slash_name
Expand Down Expand Up @@ -238,6 +238,7 @@ async def _ensure_client(
raise RuntimeError(
"claude-agent-sdk is not installed; run `pip install claude-agent-sdk`"
) from exc
resume_id = claude_transcript.prepare_resume(resume) if resume else None
options = ClaudeAgentOptions(
cwd=cwd or None,
# Empty means "no --model flag", which lets the CLI apply the user's
Expand All @@ -248,7 +249,7 @@ async def _ensure_client(
can_use_tool=self._can_use_tool,
system_prompt={"type": "preset", "preset": "claude_code"},
setting_sources=["user", "project", "local"],
resume=resume or None,
resume=resume_id,
)
# The SDK stamps CLAUDE_CODE_ENTRYPOINT=sdk-py, which both the VSCode
# extension and `claude --resume` treat as "programmatic" and hide from
Expand Down
30 changes: 28 additions & 2 deletions tests/test_claude_entrypoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ def fake_sdk(monkeypatch):
return module


async def _connect(settings, fake_sdk):
async def _connect(settings, fake_sdk, **kwargs):
async def emit(_payload):
return None

Expand All @@ -56,7 +56,9 @@ async def ask(*_args):
provider = ClaudeProvider("s1", emit, ask, settings)
# _load_server_info would query the stub client; the options are all we assert on.
provider._load_server_info = lambda: _noop() # type: ignore[method-assign]
await provider._ensure_client(cwd="/tmp", model=None, permission_mode="default")
await provider._ensure_client(
cwd="/tmp", model=None, permission_mode="default", **kwargs
)
return _FakeClient.last_options


Expand All @@ -71,6 +73,30 @@ async def test_default_entrypoint_is_visible_in_pickers(fake_sdk):
assert options.env["CLAUDE_CODE_ENTRYPOINT"] not in {"sdk-py", "sdk-ts", "sdk-cli"}


async def test_resume_uses_compatibility_repair(fake_sdk, monkeypatch):
monkeypatch.setattr(
"coding_bridge.providers.claude.claude_transcript.prepare_resume",
lambda session_id: "77777777-7777-7777-7777-777777777777",
)

options = await _connect(Settings(), fake_sdk, resume="legacy-session")

assert options.resume == "77777777-7777-7777-7777-777777777777"


async def test_fresh_session_skips_compatibility_repair(fake_sdk, monkeypatch):
def fail(_session_id):
raise AssertionError("fresh sessions must not inspect transcripts")

monkeypatch.setattr(
"coding_bridge.providers.claude.claude_transcript.prepare_resume", fail
)

options = await _connect(Settings(), fake_sdk)

assert options.resume is None


async def test_entrypoint_is_overridable(fake_sdk):
"""Operators can opt back into the hidden, SDK-native entrypoint."""
options = await _connect(Settings(claude_entrypoint="sdk-py"), fake_sdk)
Expand Down
149 changes: 149 additions & 0 deletions tests/test_claude_transcript.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
"""Compatibility repair for Claude transcripts with unsigned thinking blocks."""

import json
import uuid

import pytest

from coding_bridge import claude_transcript, history


def _write(path, records):
path.parent.mkdir(parents=True)
path.write_text("".join(json.dumps(record) + "\n" for record in records), encoding="utf-8")


def _records(path):
return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()]


def _assistant(session_id, content):
return {
"type": "assistant",
"sessionId": session_id,
"uuid": "assistant-1",
"parentUuid": "user-1",
"message": {"role": "assistant", "content": content},
}


def test_repair_forks_and_removes_only_unsigned_thinking(monkeypatch, tmp_path):
session_id = "11111111-1111-1111-1111-111111111111"
transcript = tmp_path / "project" / f"{session_id}.jsonl"
records = [
{"type": "user", "sessionId": session_id, "uuid": "user-1", "message": "go"},
_assistant(
session_id,
[
{"type": "thinking", "thinking": "unsigned"},
{"type": "thinking", "thinking": "signed", "signature": "opaque"},
{"type": "text", "text": "answer"},
{"type": "tool_use", "id": "tool-1", "name": "Bash", "input": {}},
],
),
{
"type": "user",
"sessionId": session_id,
"uuid": "result-1",
"parentUuid": "assistant-1",
"message": {
"role": "user",
"content": [{"type": "tool_result", "tool_use_id": "tool-1", "content": "ok"}],
},
},
{"type": "file-history-snapshot", "messageId": "assistant-1", "snapshot": {}},
]
_write(transcript, records)
original = transcript.read_bytes()
monkeypatch.setattr(history, "CLAUDE_ROOT", tmp_path)

repaired_id = claude_transcript.prepare_resume(session_id)

assert repaired_id != session_id
uuid.UUID(repaired_id)
assert transcript.read_bytes() == original
repaired = _records(transcript.with_name(f"{repaired_id}.jsonl"))
content = repaired[1]["message"]["content"]
assert content == records[1]["message"]["content"][1:]
assert repaired[2]["message"]["content"][0]["tool_use_id"] == "tool-1"
assert repaired[3] == records[3]
assert {record["sessionId"] for record in repaired if "sessionId" in record} == {
repaired_id
}


def test_healthy_transcript_is_unchanged(monkeypatch, tmp_path):
session_id = "22222222-2222-2222-2222-222222222222"
transcript = tmp_path / "project" / f"{session_id}.jsonl"
_write(
transcript,
[_assistant(session_id, [{"type": "thinking", "thinking": "ok", "signature": "sig"}])],
)
monkeypatch.setattr(history, "CLAUDE_ROOT", tmp_path)

assert claude_transcript.prepare_resume(session_id) == session_id
assert list(transcript.parent.glob("*.jsonl")) == [transcript]


def test_repair_is_idempotent_for_same_source(monkeypatch, tmp_path):
session_id = "33333333-3333-3333-3333-333333333333"
transcript = tmp_path / "project" / f"{session_id}.jsonl"
_write(
transcript,
[
_assistant(
session_id,
[{"type": "thinking", "thinking": "bad"}, {"type": "text", "text": "ok"}],
)
],
)
monkeypatch.setattr(history, "CLAUDE_ROOT", tmp_path)

first = claude_transcript.prepare_resume(session_id)
second = claude_transcript.prepare_resume(session_id)

assert first == second
assert len(list(transcript.parent.glob("*.jsonl"))) == 2


@pytest.mark.parametrize("signature", [None, "", " ", 123])
def test_invalid_signature_values_are_removed(monkeypatch, tmp_path, signature):
session_id = "44444444-4444-4444-4444-444444444444"
transcript = tmp_path / "project" / f"{session_id}.jsonl"
thinking = {"type": "thinking", "thinking": "bad"}
if signature is not None:
thinking["signature"] = signature
_write(transcript, [_assistant(session_id, [thinking, {"type": "text", "text": "kept"}])])
monkeypatch.setattr(history, "CLAUDE_ROOT", tmp_path)

repaired_id = claude_transcript.prepare_resume(session_id)
content = _records(transcript.with_name(f"{repaired_id}.jsonl"))[0]["message"]["content"]
assert content == [{"type": "text", "text": "kept"}]


def test_malformed_transcript_leaves_no_repair(monkeypatch, tmp_path):
session_id = "55555555-5555-5555-5555-555555555555"
transcript = tmp_path / "project" / f"{session_id}.jsonl"
transcript.parent.mkdir(parents=True)
transcript.write_text('{"type":"assistant"}\nnot-json\n', encoding="utf-8")
monkeypatch.setattr(history, "CLAUDE_ROOT", tmp_path)

with pytest.raises(claude_transcript.TranscriptRecoveryError):
claude_transcript.prepare_resume(session_id)
assert list(transcript.parent.glob("*.jsonl")) == [transcript]


def test_thinking_only_message_fails_instead_of_breaking_parent_chain(monkeypatch, tmp_path):
session_id = "66666666-6666-6666-6666-666666666666"
transcript = tmp_path / "project" / f"{session_id}.jsonl"
_write(transcript, [_assistant(session_id, [{"type": "thinking", "thinking": "bad"}])])
monkeypatch.setattr(history, "CLAUDE_ROOT", tmp_path)

with pytest.raises(claude_transcript.TranscriptRecoveryError):
claude_transcript.prepare_resume(session_id)
assert list(transcript.parent.glob("*.jsonl")) == [transcript]


def test_missing_transcript_keeps_requested_resume(monkeypatch, tmp_path):
monkeypatch.setattr(history, "CLAUDE_ROOT", tmp_path)
assert claude_transcript.prepare_resume("not-present") == "not-present"
Loading