From e89e1cbce5e951e8e677f93cc7a26bf220077937 Mon Sep 17 00:00:00 2001 From: atoz96 Date: Wed, 29 Jul 2026 20:02:05 +0300 Subject: [PATCH] fix(transcript): degrade session_transcript when no kb resolves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the jsonl and mcp handlers resolved the store eagerly, so kb.session_transcript raised instead of returning the degraded envelope its own docstring promises when no .vouch/ was discoverable. the raw transcript is read from the agent's own directory, not from the kb — the kb only supplies capture observations for the fallback — so a missing kb should cost the observations, not the whole call. load_transcript now takes an optional store and _degraded returns an empty observation list when it is absent. a _store_or_none() helper on both surfaces makes that leniency opt-in: everything that reads or writes knowledge keeps calling _store(), so a missing kb stays a hard error there. test_handler_returns_degraded_when_absent has been red on test since 9d7b37a stopped tracking the owner-local .vouch kb — it passed only because that committed kb was always discoverable from the checkout. it now chdirs to a tmp dir so it exercises the kb-absent path regardless of ambient state, with the mcp surface and the store=None contract covered alongside it. --- src/vouch/jsonl_server.py | 15 ++++++++++- src/vouch/server.py | 18 +++++++++++-- src/vouch/transcript.py | 13 ++++++--- tests/test_session_transcript.py | 46 ++++++++++++++++++++++++++++++++ 4 files changed, 86 insertions(+), 6 deletions(-) diff --git a/src/vouch/jsonl_server.py b/src/vouch/jsonl_server.py index 7ddf58e7..a88eaf1b 100644 --- a/src/vouch/jsonl_server.py +++ b/src/vouch/jsonl_server.py @@ -84,6 +84,19 @@ def _store() -> KBStore: raise RuntimeError(str(e)) from e +def _store_or_none() -> KBStore | None: + """The KB when one resolves, else None. + + Only for reads whose data source is outside `.vouch/` — the KB is an + enrichment, not the subject. Every method that reads or writes knowledge + must keep using `_store()` so a missing KB stays a hard error. + """ + try: + return _store() + except RuntimeError: + return None + + def _agent() -> str: # An authenticated bearer subject is the principal's real identity; it must # win over the client-supplied X-Vouch-Agent header (and VOUCH_AGENT env), @@ -477,7 +490,7 @@ def _h_session_transcript(p: dict) -> dict: agent = p.get("agent") if agent is not None and agent not in ("claude", "codex"): raise ValueError(f"unknown agent: {agent!r} (expected 'claude' or 'codex')") - return transcript.load_transcript(_store(), session_id, agent=agent) + return transcript.load_transcript(_store_or_none(), session_id, agent=agent) def _h_propose_entity(p: dict) -> dict: diff --git a/src/vouch/server.py b/src/vouch/server.py index 4ac58574..15ee6613 100644 --- a/src/vouch/server.py +++ b/src/vouch/server.py @@ -70,6 +70,19 @@ def _store() -> KBStore: ) from e +def _store_or_none() -> KBStore | None: + """The KB when one resolves, else None. + + Only for reads whose data source is outside `.vouch/` — the KB is an + enrichment, not the subject. Every method that reads or writes knowledge + must keep using `_store()` so a missing KB stays a hard error. + """ + try: + return _store() + except RuntimeError: + return None + + def _agent() -> str: # An authenticated bearer subject (set by the /mcp transport) is the # principal's real identity and must be what proposals/audit attribute to, @@ -685,12 +698,13 @@ def kb_session_transcript(session_id: str, agent: str | None = None) -> dict[str Read-only. Locates the raw Claude Code / Codex file on disk and normalizes it into message blocks (text, thinking, tool_use with paired results). ``agent`` restricts the search ("claude" | "codex"); omit to try both. - Degrades to compact capture observations when the raw file is unavailable. + Degrades to compact capture observations when the raw file is unavailable, + and to a bare unavailable result when no KB resolves at all. """ from . import transcript if agent is not None and agent not in ("claude", "codex"): raise ValueError(f"unknown agent: {agent!r} (expected 'claude' or 'codex')") - return transcript.load_transcript(_store(), session_id, agent=agent) + return transcript.load_transcript(_store_or_none(), session_id, agent=agent) @mcp.tool() diff --git a/src/vouch/transcript.py b/src/vouch/transcript.py index 5ca10d34..a67aef19 100644 --- a/src/vouch/transcript.py +++ b/src/vouch/transcript.py @@ -364,19 +364,26 @@ def flush() -> None: return {"session": session, "messages": messages, "truncated": truncated} -def _degraded(store: KBStore, session_id: str, reason: str) -> dict[str, Any]: - obs = _read_observations(buffer_path(store, session_id)) +def _degraded(store: KBStore | None, session_id: str, reason: str) -> dict[str, Any]: + # No KB resolved: the raw transcript lives outside `.vouch/`, so the + # lookup still answers — there is just no capture buffer to fall back on. + obs = _read_observations(buffer_path(store, session_id)) if store is not None else [] return {"available": False, "reason": reason, "observations": obs} def load_transcript( - store: KBStore, session_id: str, *, agent: str | None = None + store: KBStore | None, session_id: str, *, agent: str | None = None ) -> dict[str, Any]: """Locate + parse the raw transcript for ``session_id``. ``agent`` restricts the search ("claude" | "codex"); when None both are tried. Returns the normalized schema on success, or a degraded result (compact capture observations) when the raw file is missing/too large. + + ``store`` may be None when no KB resolves from the caller's cwd. The raw + transcript is read from the agent's own directory, not from the KB, so the + normalized result is unaffected; only the degraded path loses its + observations. """ path: Path | None = None source_agent = "" diff --git a/tests/test_session_transcript.py b/tests/test_session_transcript.py index 5af1a244..6521658c 100644 --- a/tests/test_session_transcript.py +++ b/tests/test_session_transcript.py @@ -215,6 +215,52 @@ def test_handler_returns_degraded_when_absent( assert resp["result"]["available"] is False +def test_handler_degrades_when_no_kb_resolves( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # The sibling test above covers "KB present, raw transcript missing". This + # one covers "no KB at all", which the handler used to answer with an + # internal error rather than the degraded envelope. + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("VOUCH_KB_PATH", raising=False) + monkeypatch.delenv("VOUCH_PROJECT_DIR", raising=False) + monkeypatch.setenv("VOUCH_CLAUDE_PROJECTS_DIR", str(tmp_path / "no-claude")) + monkeypatch.setenv("CODEX_HOME", str(tmp_path / "no-codex")) + from vouch.jsonl_server import handle_request + + resp = handle_request({ + "id": "3", "method": "kb.session_transcript", + "params": {"session_id": "11111111-1111-1111-1111-111111111111"}, + }) + assert resp["ok"] is True + assert resp["result"]["available"] is False + assert resp["result"]["observations"] == [] + + +def test_load_transcript_without_store_degrades( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("VOUCH_CLAUDE_PROJECTS_DIR", str(tmp_path / "projects")) + out = transcript.load_transcript(None, "11111111-1111-1111-1111-111111111111") + assert out["available"] is False + assert out["observations"] == [] + + +def test_mcp_session_transcript_degrades_without_kb( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("VOUCH_KB_PATH", raising=False) + monkeypatch.delenv("VOUCH_PROJECT_DIR", raising=False) + monkeypatch.setenv("VOUCH_CLAUDE_PROJECTS_DIR", str(tmp_path / "no-claude")) + monkeypatch.setenv("CODEX_HOME", str(tmp_path / "no-codex")) + from vouch.server import kb_session_transcript + + out = kb_session_transcript("11111111-1111-1111-1111-111111111111") + assert out["available"] is False + assert out["observations"] == [] + + # --- Task 9: Codex parser ------------------------------------------------- _CODEX_LINES = [