diff --git a/coding_bridge/claude_transcript.py b/coding_bridge/claude_transcript.py new file mode 100644 index 0000000..b5bfe88 --- /dev/null +++ b/coding_bridge/claude_transcript.py @@ -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() diff --git a/coding_bridge/history.py b/coding_bridge/history.py index f844771..c33505b 100644 --- a/coding_bridge/history.py +++ b/coding_bridge/history.py @@ -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(): diff --git a/coding_bridge/providers/claude.py b/coding_bridge/providers/claude.py index 25d5194..1e7f60b 100644 --- a/coding_bridge/providers/claude.py +++ b/coding_bridge/providers/claude.py @@ -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 @@ -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 @@ -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 diff --git a/tests/test_claude_entrypoint.py b/tests/test_claude_entrypoint.py index 5a25126..9588536 100644 --- a/tests/test_claude_entrypoint.py +++ b/tests/test_claude_entrypoint.py @@ -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 @@ -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 @@ -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) diff --git a/tests/test_claude_transcript.py b/tests/test_claude_transcript.py new file mode 100644 index 0000000..7db19fd --- /dev/null +++ b/tests/test_claude_transcript.py @@ -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"