From a5a4f6fe662f2c2c5a22a26aa61979978addcf3e Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:57:20 +0900 Subject: [PATCH 01/15] feat(transcript): locate raw Claude session files by id --- src/vouch/transcript.py | 54 ++++++++++++++++++++++++++++++++ tests/test_session_transcript.py | 44 ++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 src/vouch/transcript.py create mode 100644 tests/test_session_transcript.py diff --git a/src/vouch/transcript.py b/src/vouch/transcript.py new file mode 100644 index 00000000..f4dea76b --- /dev/null +++ b/src/vouch/transcript.py @@ -0,0 +1,54 @@ +"""Locate and parse raw agent session transcripts on demand. + +Given a captured session id, find the raw JSONL the agent wrote (Claude Code +under ``~/.claude/projects``, Codex rollouts under ``$CODEX_HOME/sessions``) +and normalize it into a block schema the vouch console renders. Read-only: +never writes to the KB. When the raw file is gone we degrade to vouch's +compact capture observations instead. +""" + +from __future__ import annotations + +import json +import os +import re +from pathlib import Path +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from .storage import KBStore + +# Session ids are UUID-shaped; reject anything else so a hostile id can't +# widen a glob or traverse out of the projects tree. +_VALID_ID = re.compile(r"^[0-9a-fA-F-]{8,64}$") + + +def _claude_projects_root() -> Path: + env = os.environ.get("VOUCH_CLAUDE_PROJECTS_DIR") + return Path(env) if env else Path.home() / ".claude" / "projects" + + +def find_claude_file(session_id: str) -> Path | None: + """The raw Claude Code JSONL for ``session_id``, or None. + + Claude names each session file ``.jsonl`` under a per-cwd project + dir; subagent transcripts live under ``/subagents/**``. The file + stem is the id, so a literal name match (no id interpolation into a glob) + locates it. + """ + if not _VALID_ID.match(session_id): + return None + root = _claude_projects_root() + if not root.is_dir(): + return None + name = f"{session_id}.jsonl" + for project in root.iterdir(): + if not project.is_dir(): + continue + top = project / name + if top.is_file(): + return top + for candidate in root.glob(f"*/*/subagents/**/{name}"): + if candidate.is_file(): + return candidate + return None diff --git a/tests/test_session_transcript.py b/tests/test_session_transcript.py new file mode 100644 index 00000000..dccdae5a --- /dev/null +++ b/tests/test_session_transcript.py @@ -0,0 +1,44 @@ +"""kb.session_transcript — locate + parse raw agent transcripts on demand.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from vouch import transcript + + +def _write_jsonl(path: Path, records: list[dict]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join(json.dumps(r) for r in records) + "\n", encoding="utf-8") + + +# --- Task 1: locator ------------------------------------------------------ + + +def test_find_claude_file_top_level(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + root = tmp_path / "projects" + sid = "ad5d5e5f-0097-494c-8316-b01aed5dabf2" + f = root / "-home-a-Dev-agentsview" / f"{sid}.jsonl" + _write_jsonl(f, [{"type": "user", "message": {"role": "user", "content": "hi"}}]) + monkeypatch.setenv("VOUCH_CLAUDE_PROJECTS_DIR", str(root)) + assert transcript.find_claude_file(sid) == f + + +def test_find_claude_file_subagent(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + root = tmp_path / "projects" + parent = "11111111-1111-1111-1111-111111111111" + child = "22222222-2222-2222-2222-222222222222" + f = root / "-proj" / parent / "subagents" / "jobs" / f"{child}.jsonl" + _write_jsonl(f, [{"type": "assistant", "message": {"role": "assistant", "content": []}}]) + monkeypatch.setenv("VOUCH_CLAUDE_PROJECTS_DIR", str(root)) + assert transcript.find_claude_file(child) == f + + +def test_find_claude_file_rejects_bad_id(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("VOUCH_CLAUDE_PROJECTS_DIR", str(tmp_path)) + assert transcript.find_claude_file("../etc/passwd") is None + assert transcript.find_claude_file("*") is None + assert transcript.find_claude_file("") is None From 9bedd1b7f55ce170e6e562ed4f28325b29b3ab12 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:00:17 +0900 Subject: [PATCH 02/15] feat(transcript): parse Claude JSONL into normalized blocks --- src/vouch/transcript.py | 181 ++++++++++++++++++++++++++++++- tests/test_session_transcript.py | 72 ++++++++++++ 2 files changed, 249 insertions(+), 4 deletions(-) diff --git a/src/vouch/transcript.py b/src/vouch/transcript.py index f4dea76b..9a491393 100644 --- a/src/vouch/transcript.py +++ b/src/vouch/transcript.py @@ -13,10 +13,7 @@ import os import re from pathlib import Path -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: - from .storage import KBStore +from typing import Any # Session ids are UUID-shaped; reject anything else so a hostile id can't # widen a glob or traverse out of the projects tree. @@ -52,3 +49,179 @@ def find_claude_file(session_id: str) -> Path | None: if candidate.is_file(): return candidate return None + + +def _norm_tokens(usage: dict[str, Any]) -> dict[str, int]: + def i(key: str) -> int: + v = usage.get(key) + return int(v) if isinstance(v, (int, float)) else 0 + + return { + "input": i("input_tokens"), + "output": i("output_tokens"), + "cache_read": i("cache_read_input_tokens"), + "cache_creation": i("cache_creation_input_tokens"), + } + + +def _result_text(content: Any) -> str: + """tool_result.content is a string, or a list of {type:text,text} parts.""" + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [ + str(p.get("text", "")) + for p in content + if isinstance(p, dict) and p.get("type") == "text" + ] + if parts: + return "\n".join(parts) + return json.dumps(content, ensure_ascii=False) + if content is None: + return "" + return json.dumps(content, ensure_ascii=False) + + +def parse_claude_transcript(path: Path, *, max_messages: int = 2000) -> dict[str, Any]: + """Parse a Claude Code JSONL into the normalized transcript schema. + + Single forward pass: assistant content blocks that share a + ``message.id`` merge into one logical message; a later ``tool_result`` + (in a user entry) is paired into the matching ``tool_use`` block by id and + its user entry is not emitted as a standalone message. + """ + messages: list[dict[str, Any]] = [] + tool_by_id: dict[str, dict[str, Any]] = {} + session: dict[str, Any] = { + "id": path.stem, "agent": "claude", "cwd": None, "git_branch": None, + "title": None, "started_at": None, "ended_at": None, "model": None, + "tokens": {"input": 0, "output": 0, "cache_read": 0, "cache_creation": 0}, + } + truncated = False + current: dict[str, Any] | None = None + + def flush() -> None: + nonlocal current + if current is not None and current["blocks"]: + messages.append(current) + current = None + + with path.open(encoding="utf-8") as fh: + for raw in fh: + raw = raw.strip() + if not raw: + continue + try: + obj = json.loads(raw) + except json.JSONDecodeError: + continue + if not isinstance(obj, dict): + continue + if session["cwd"] is None and isinstance(obj.get("cwd"), str): + session["cwd"] = obj["cwd"] + if session["git_branch"] is None and isinstance(obj.get("gitBranch"), str): + session["git_branch"] = obj["gitBranch"] + ts = obj.get("timestamp") + if isinstance(ts, str): + if session["started_at"] is None: + session["started_at"] = ts + session["ended_at"] = ts + t = obj.get("type") + if t == "ai-title" and isinstance(obj.get("aiTitle"), str): + session["title"] = obj["aiTitle"] + continue + if t not in ("user", "assistant"): + continue + if len(messages) >= max_messages: + truncated = True + break + msg = obj.get("message") + if not isinstance(msg, dict): + continue + content = msg.get("content") + + if t == "assistant": + mid = msg.get("id") if isinstance(msg.get("id"), str) else None + if current is None or current.get("id") != mid: + flush() + raw_usage = msg.get("usage") + usage: dict[str, Any] = raw_usage if isinstance(raw_usage, dict) else {} + model = msg.get("model") if isinstance(msg.get("model"), str) else None + if model and session["model"] is None: + session["model"] = model + current = { + "role": "assistant", "id": mid, "model": model, + "timestamp": ts if isinstance(ts, str) else None, + "tokens": _norm_tokens(usage), "blocks": [], + } + tok = current["tokens"] + for k in session["tokens"]: + session["tokens"][k] += tok[k] + parts = content if isinstance(content, list) else [] + for part in parts: + if not isinstance(part, dict): + continue + ptype = part.get("type") + if ptype == "thinking": + text = str(part.get("thinking", "")).strip() + if text: + current["blocks"].append({"type": "thinking", "text": text}) + elif ptype == "text": + text = str(part.get("text", "")).strip() + if text: + current["blocks"].append({"type": "text", "text": text}) + elif ptype == "tool_use": + tid = part.get("id") + raw_input = part.get("input") + block: dict[str, Any] = { + "type": "tool_use", "id": tid, + "name": str(part.get("name", "")), + "input": raw_input if isinstance(raw_input, dict) else {}, + "result": None, + } + current["blocks"].append(block) + if isinstance(tid, str): + tool_by_id[tid] = block + continue + + # user entry + flush() + if isinstance(content, str): + text = content.strip() + if text: + messages.append({ + "role": "user", "id": None, "model": None, + "timestamp": ts if isinstance(ts, str) else None, + "tokens": None, "blocks": [{"type": "text", "text": text}], + }) + continue + parts = content if isinstance(content, list) else [] + user_blocks: list[dict[str, Any]] = [] + agent_id = None + tur = obj.get("toolUseResult") + if isinstance(tur, dict) and isinstance(tur.get("agentId"), str): + agent_id = tur["agentId"] + for part in parts: + if not isinstance(part, dict): + continue + if part.get("type") == "tool_result": + tid = part.get("tool_use_id") + paired = tool_by_id.get(tid) if isinstance(tid, str) else None + if paired is not None: + paired["result"] = { + "content": _result_text(part.get("content")), + "is_error": bool(part.get("is_error", False)), + "subagent_session_id": agent_id, + } + elif part.get("type") == "text": + text = str(part.get("text", "")).strip() + if text: + user_blocks.append({"type": "text", "text": text}) + if user_blocks: + messages.append({ + "role": "user", "id": None, "model": None, + "timestamp": ts if isinstance(ts, str) else None, + "tokens": None, "blocks": user_blocks, + }) + flush() + return {"session": session, "messages": messages, "truncated": truncated} diff --git a/tests/test_session_transcript.py b/tests/test_session_transcript.py index dccdae5a..059f2e01 100644 --- a/tests/test_session_transcript.py +++ b/tests/test_session_transcript.py @@ -42,3 +42,75 @@ def test_find_claude_file_rejects_bad_id(tmp_path: Path, monkeypatch: pytest.Mon assert transcript.find_claude_file("../etc/passwd") is None assert transcript.find_claude_file("*") is None assert transcript.find_claude_file("") is None + + +# --- Task 2: Claude parser ------------------------------------------------ + +_CLAUDE_LINES = [ + {"type": "user", "cwd": "/repo", "gitBranch": "main", + "timestamp": "2026-07-10T04:44:19.043Z", + "message": {"role": "user", "content": [{"type": "text", "text": "fix the bug"}]}}, + {"type": "ai-title", "aiTitle": "Fix the bug"}, + {"type": "assistant", "timestamp": "2026-07-10T04:44:35.759Z", + "message": {"id": "msg_1", "model": "claude-opus-4-8", "role": "assistant", + "content": [{"type": "thinking", "thinking": "let me look"}], + "usage": {"input_tokens": 100, "output_tokens": 10, + "cache_read_input_tokens": 5, + "cache_creation_input_tokens": 2}}}, + {"type": "assistant", "timestamp": "2026-07-10T04:44:36.771Z", + "message": {"id": "msg_1", "model": "claude-opus-4-8", "role": "assistant", + "content": [{"type": "text", "text": "I'll edit it."}], + "usage": {"input_tokens": 100, "output_tokens": 10}}}, + {"type": "assistant", "timestamp": "2026-07-10T04:44:36.772Z", + "message": {"id": "msg_1", "model": "claude-opus-4-8", "role": "assistant", + "content": [{"type": "tool_use", "id": "tu_1", "name": "Bash", + "input": {"command": "go test ./..."}}], + "usage": {"input_tokens": 100, "output_tokens": 10}}}, + {"type": "user", + "message": {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "tu_1", "content": "ok\n", "is_error": False}]}}, +] + + +def test_parse_claude_pairs_result_and_merges_by_message_id(tmp_path: Path) -> None: + f = tmp_path / "s.jsonl" + _write_jsonl(f, _CLAUDE_LINES) + out = transcript.parse_claude_transcript(f) + + assert out["session"]["cwd"] == "/repo" + assert out["session"]["git_branch"] == "main" + assert out["session"]["title"] == "Fix the bug" + assert out["session"]["model"] == "claude-opus-4-8" + assert out["truncated"] is False + + roles = [m["role"] for m in out["messages"]] + assert roles == ["user", "assistant"] # tool_result user entry is consumed + + a = out["messages"][1] + assert [b["type"] for b in a["blocks"]] == ["thinking", "text", "tool_use"] + tu = a["blocks"][2] + assert tu["name"] == "Bash" and tu["input"] == {"command": "go test ./..."} + assert tu["result"] == {"content": "ok\n", "is_error": False, "subagent_session_id": None} + assert a["tokens"] == {"input": 100, "output": 10, "cache_read": 5, "cache_creation": 2} + + +def test_parse_claude_truncates_at_cap(tmp_path: Path) -> None: + lines = [ + {"type": "user", "message": {"role": "user", + "content": [{"type": "text", "text": f"m{i}"}]}} + for i in range(5) + ] + f = tmp_path / "big.jsonl" + _write_jsonl(f, lines) + out = transcript.parse_claude_transcript(f, max_messages=3) + assert out["truncated"] is True + assert len(out["messages"]) == 3 + + +def test_parse_claude_tolerates_malformed_lines(tmp_path: Path) -> None: + f = tmp_path / "m.jsonl" + good = '{"type":"user","message":{"role":"user","content":"hey"}}' + f.write_text('{"bad json\n' + good + "\n", encoding="utf-8") + out = transcript.parse_claude_transcript(f) + assert len(out["messages"]) == 1 + assert out["messages"][0]["blocks"] == [{"type": "text", "text": "hey"}] From d51109cb627caae66c3030ede7a76f3b105ab784 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:07:52 +0900 Subject: [PATCH 03/15] feat(transcript): orchestrate load with observation fallback --- src/vouch/transcript.py | 56 +++++++++++++++++++++++++++++++- tests/test_session_transcript.py | 52 ++++++++++++++++++++++++++++- 2 files changed, 106 insertions(+), 2 deletions(-) diff --git a/src/vouch/transcript.py b/src/vouch/transcript.py index 9a491393..8d9ebc93 100644 --- a/src/vouch/transcript.py +++ b/src/vouch/transcript.py @@ -13,7 +13,15 @@ import os import re from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any + +from .capture import _read_observations, buffer_path + +if TYPE_CHECKING: + from .storage import KBStore + +MAX_FILE_BYTES = 25 * 1024 * 1024 +MAX_MESSAGES = 2000 # Session ids are UUID-shaped; reject anything else so a hostile id can't # widen a glob or traverse out of the projects tree. @@ -225,3 +233,49 @@ def flush() -> None: }) flush() return {"session": session, "messages": messages, "truncated": truncated} + + +def parse_codex_transcript(path: Path, *, max_messages: int = 2000) -> dict[str, Any]: + raise NotImplementedError("codex transcript parsing lands in Task 9") + + +def _degraded(store: KBStore, session_id: str, reason: str) -> dict[str, Any]: + obs = _read_observations(buffer_path(store, session_id)) + return {"available": False, "reason": reason, "observations": obs} + + +def load_transcript( + store: KBStore, 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. + """ + path: Path | None = None + source_agent = "" + if agent in (None, "claude"): + path = find_claude_file(session_id) + if path is not None: + source_agent = "claude" + if path is None and agent in (None, "codex"): + from . import codex_rollout + + path = codex_rollout.find_rollout_by_session_id(session_id) + if path is not None: + source_agent = "codex" + if path is None: + return _degraded(store, session_id, f"raw transcript not found for session {session_id}") + try: + size = path.stat().st_size + except OSError as e: + return _degraded(store, session_id, f"cannot read transcript: {e}") + if size > MAX_FILE_BYTES: + return _degraded(store, session_id, f"transcript too large to render ({size} bytes)") + + if source_agent == "claude": + parsed = parse_claude_transcript(path, max_messages=MAX_MESSAGES) + else: + parsed = parse_codex_transcript(path, max_messages=MAX_MESSAGES) + return {"available": True, "source": {"agent": source_agent, "path": str(path)}, **parsed} diff --git a/tests/test_session_transcript.py b/tests/test_session_transcript.py index 059f2e01..c2a05052 100644 --- a/tests/test_session_transcript.py +++ b/tests/test_session_transcript.py @@ -7,7 +7,8 @@ import pytest -from vouch import transcript +from vouch import capture, transcript +from vouch.storage import KBStore def _write_jsonl(path: Path, records: list[dict]) -> None: @@ -114,3 +115,52 @@ def test_parse_claude_tolerates_malformed_lines(tmp_path: Path) -> None: out = transcript.parse_claude_transcript(f) assert len(out["messages"]) == 1 assert out["messages"][0]["blocks"] == [{"type": "text", "text": "hey"}] + + +# --- Task 3: load_transcript orchestrator --------------------------------- + + +@pytest.fixture +def store(tmp_path: Path) -> KBStore: + return KBStore.init(tmp_path) + + +def test_load_transcript_available( + tmp_path: Path, store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + root = tmp_path / "projects" + sid = "ad5d5e5f-0097-494c-8316-b01aed5dabf2" + f = root / "-repo" / f"{sid}.jsonl" + _write_jsonl(f, _CLAUDE_LINES) + monkeypatch.setenv("VOUCH_CLAUDE_PROJECTS_DIR", str(root)) + out = transcript.load_transcript(store, sid) + assert out["available"] is True + assert out["source"] == {"agent": "claude", "path": str(f)} + assert out["session"]["title"] == "Fix the bug" + + +def test_load_transcript_degrades_to_observations( + store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("VOUCH_CLAUDE_PROJECTS_DIR", str(store.kb_dir / "none")) + sid = "99999999-9999-9999-9999-999999999999" + capture.observe(store, sid, tool="Edit", summary="Edited x.go") + out = transcript.load_transcript(store, sid) + assert out["available"] is False + assert out["observations"][0]["tool"] == "Edit" + assert "reason" in out + + +def test_load_transcript_degrades_when_oversized( + tmp_path: Path, store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + root = tmp_path / "projects" + sid = "88888888-8888-8888-8888-888888888888" + f = root / "-repo" / f"{sid}.jsonl" + f.parent.mkdir(parents=True, exist_ok=True) + f.write_text("x" * 32, encoding="utf-8") + monkeypatch.setenv("VOUCH_CLAUDE_PROJECTS_DIR", str(root)) + monkeypatch.setattr(transcript, "MAX_FILE_BYTES", 16) + out = transcript.load_transcript(store, sid) + assert out["available"] is False + assert "too large" in out["reason"] From 97e41a97fcb199118590052f89cefcdbc7b6a4a9 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:09:45 +0900 Subject: [PATCH 04/15] feat(transcript): expose kb.session_transcript across all surfaces --- src/vouch/capabilities.py | 1 + src/vouch/jsonl_server.py | 10 ++++++++ src/vouch/server.py | 15 ++++++++++++ tests/test_session_transcript.py | 41 ++++++++++++++++++++++++++++++++ 4 files changed, 67 insertions(+) diff --git a/src/vouch/capabilities.py b/src/vouch/capabilities.py index c0a1cc0a..0d8528a5 100644 --- a/src/vouch/capabilities.py +++ b/src/vouch/capabilities.py @@ -68,6 +68,7 @@ "kb.session_start", "kb.session_end", "kb.list_sessions", + "kb.session_transcript", "kb.volunteer_context", "kb.crystallize", "kb.summarize_session", diff --git a/src/vouch/jsonl_server.py b/src/vouch/jsonl_server.py index e52b6b3f..6f34c9cf 100644 --- a/src/vouch/jsonl_server.py +++ b/src/vouch/jsonl_server.py @@ -412,6 +412,15 @@ def _h_list_sessions(p: dict) -> dict: return {"sessions": session_split.build_session_rows(_store())} +def _h_session_transcript(p: dict) -> dict: + from . import transcript + session_id = p["session_id"] + 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) + + def _h_propose_entity(p: dict) -> dict: pr = propose_entity( _store(), @@ -785,6 +794,7 @@ def _h_propose_theme(p: dict) -> dict: "kb.compile": _h_compile, "kb.summarize_session": _h_summarize_session, "kb.list_sessions": _h_list_sessions, + "kb.session_transcript": _h_session_transcript, "kb.propose_entity": _h_propose_entity, "kb.propose_relation": _h_propose_relation, "kb.propose_delete": _h_propose_delete, diff --git a/src/vouch/server.py b/src/vouch/server.py index 2ec09149..6bfd8b35 100644 --- a/src/vouch/server.py +++ b/src/vouch/server.py @@ -556,6 +556,21 @@ def kb_list_sessions() -> dict[str, Any]: return {"sessions": session_split.build_session_rows(_store())} +@mcp.tool() +def kb_session_transcript(session_id: str, agent: str | None = None) -> dict[str, Any]: + """Render a captured session's full transcript from its raw agent JSONL. + + 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. + """ + 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) + + @mcp.tool() def kb_propose_entity( name: str, diff --git a/tests/test_session_transcript.py b/tests/test_session_transcript.py index c2a05052..367b36d1 100644 --- a/tests/test_session_transcript.py +++ b/tests/test_session_transcript.py @@ -164,3 +164,44 @@ def test_load_transcript_degrades_when_oversized( out = transcript.load_transcript(store, sid) assert out["available"] is False assert "too large" in out["reason"] + + +# --- Task 4: RPC handler -------------------------------------------------- + + +def test_capabilities_advertises_session_transcript() -> None: + from vouch.capabilities import capabilities + from vouch.jsonl_server import HANDLERS + + assert "kb.session_transcript" in capabilities().methods + assert "kb.session_transcript" in HANDLERS + + +def test_handler_missing_session_id_is_missing_param() -> None: + from vouch.jsonl_server import handle_request + + resp = handle_request({"id": "1", "method": "kb.session_transcript", "params": {}}) + assert resp["ok"] is False + assert resp["error"]["code"] == "missing_param" + + +def test_handler_bad_agent_is_invalid_request() -> None: + from vouch.jsonl_server import handle_request + + resp = handle_request({ + "id": "2", "method": "kb.session_transcript", + "params": {"session_id": "11111111-1111-1111-1111-111111111111", "agent": "grok"}, + }) + assert resp["ok"] is False + assert resp["error"]["code"] == "invalid_request" + + +def test_handler_returns_degraded_when_absent() -> None: + 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 From 7dde1003c3be067bca267e4b05f58ff09a6f5594 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:11:13 +0900 Subject: [PATCH 05/15] feat(webapp): transcript client types and fetch --- webapp/src/lib/transcript.test.ts | 22 +++++++++ webapp/src/lib/transcript.ts | 75 +++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 webapp/src/lib/transcript.test.ts create mode 100644 webapp/src/lib/transcript.ts diff --git a/webapp/src/lib/transcript.test.ts b/webapp/src/lib/transcript.test.ts new file mode 100644 index 00000000..128f8551 --- /dev/null +++ b/webapp/src/lib/transcript.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('./rpc', () => ({ rpc: vi.fn() })) +import { rpc } from './rpc' +import { fetchTranscript } from './transcript' +import type { VouchConnectionInfo } from './types' + +const conn: VouchConnectionInfo = { endpoint: 'http://127.0.0.1:8731' } + +describe('fetchTranscript', () => { + it('calls kb.session_transcript with session id + agent', async () => { + vi.mocked(rpc).mockResolvedValue({ available: false, reason: 'x', observations: [] }) + await fetchTranscript(conn, 'sid-1', 'claude') + expect(rpc).toHaveBeenCalledWith(conn, 'kb.session_transcript', { session_id: 'sid-1', agent: 'claude' }) + }) + + it('omits agent when not given', async () => { + vi.mocked(rpc).mockResolvedValue({ available: false, reason: 'x', observations: [] }) + await fetchTranscript(conn, 'sid-2') + expect(rpc).toHaveBeenCalledWith(conn, 'kb.session_transcript', { session_id: 'sid-2' }) + }) +}) diff --git a/webapp/src/lib/transcript.ts b/webapp/src/lib/transcript.ts new file mode 100644 index 00000000..2b98057f --- /dev/null +++ b/webapp/src/lib/transcript.ts @@ -0,0 +1,75 @@ +import { rpc } from './rpc' +import type { VouchConnectionInfo } from './types' + +export interface Tokens { + input: number + output: number + cache_read: number + cache_creation: number +} + +export interface ToolResult { + content: string + is_error: boolean + subagent_session_id: string | null +} + +export type TranscriptBlock = + | { type: 'text'; text: string } + | { type: 'thinking'; text: string } + | { + type: 'tool_use' + id: string | null + name: string + input: Record + result: ToolResult | null + } + +export interface TranscriptMessage { + role: 'user' | 'assistant' + id: string | null + model: string | null + timestamp: string | null + tokens: Tokens | null + blocks: TranscriptBlock[] +} + +export interface SessionMeta { + id: string + agent: string + cwd: string | null + git_branch: string | null + title: string | null + started_at: string | null + ended_at: string | null + model: string | null + tokens: Tokens +} + +export interface Observation { + ts: number + tool: string + summary: string + files?: string[] + cmd?: string +} + +export type Transcript = + | { + available: true + source: { agent: string; path: string } + session: SessionMeta + messages: TranscriptMessage[] + truncated: boolean + } + | { available: false; reason: string; observations: Observation[] } + +export function fetchTranscript( + conn: VouchConnectionInfo, + sessionId: string, + agent?: string, +): Promise { + const params: Record = { session_id: sessionId } + if (agent) params.agent = agent + return rpc(conn, 'kb.session_transcript', params) +} From ff990432ead7616fc62250872769f4efd543fa9c Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:12:00 +0900 Subject: [PATCH 06/15] feat(webapp): thinking, diff, and code block renderers --- .../src/components/transcript/CodeBlock.tsx | 14 +++++++++++ webapp/src/components/transcript/DiffView.tsx | 21 +++++++++++++++++ .../components/transcript/ThinkingBlock.tsx | 18 +++++++++++++++ .../src/components/transcript/blocks.test.tsx | 23 +++++++++++++++++++ 4 files changed, 76 insertions(+) create mode 100644 webapp/src/components/transcript/CodeBlock.tsx create mode 100644 webapp/src/components/transcript/DiffView.tsx create mode 100644 webapp/src/components/transcript/ThinkingBlock.tsx create mode 100644 webapp/src/components/transcript/blocks.test.tsx diff --git a/webapp/src/components/transcript/CodeBlock.tsx b/webapp/src/components/transcript/CodeBlock.tsx new file mode 100644 index 00000000..f2ccf03e --- /dev/null +++ b/webapp/src/components/transcript/CodeBlock.tsx @@ -0,0 +1,14 @@ +export function CodeBlock({ code, lang }: { code: string; lang?: string }) { + return ( +
+ {lang && ( +
+ {lang} +
+ )} +
+        {code}
+      
+
+ ) +} diff --git a/webapp/src/components/transcript/DiffView.tsx b/webapp/src/components/transcript/DiffView.tsx new file mode 100644 index 00000000..8f199633 --- /dev/null +++ b/webapp/src/components/transcript/DiffView.tsx @@ -0,0 +1,21 @@ +export function DiffView({ text }: { text: string }) { + const lines = text.replace(/\n$/, '').split('\n') + return ( +
+ {lines.map((line, i) => { + const cls = line.startsWith('@@') + ? 'diff-hunk text-accent-2' + : line.startsWith('+') + ? 'diff-add bg-ok/10 text-ok' + : line.startsWith('-') + ? 'diff-del bg-accent/10 text-accent-2' + : 'diff-ctx text-ink-2' + return ( +
+ {line || ' '} +
+ ) + })} +
+ ) +} diff --git a/webapp/src/components/transcript/ThinkingBlock.tsx b/webapp/src/components/transcript/ThinkingBlock.tsx new file mode 100644 index 00000000..5bf0adb1 --- /dev/null +++ b/webapp/src/components/transcript/ThinkingBlock.tsx @@ -0,0 +1,18 @@ +import { Brain, ChevronRight } from 'lucide-react' +import { useState } from 'react' + +export function ThinkingBlock({ text }: { text: string }) { + const [open, setOpen] = useState(false) + return ( +
+ + {open &&
{text}
} +
+ ) +} diff --git a/webapp/src/components/transcript/blocks.test.tsx b/webapp/src/components/transcript/blocks.test.tsx new file mode 100644 index 00000000..7817749c --- /dev/null +++ b/webapp/src/components/transcript/blocks.test.tsx @@ -0,0 +1,23 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it } from 'vitest' +import { DiffView } from './DiffView' +import { ThinkingBlock } from './ThinkingBlock' + +describe('DiffView', () => { + it('tags added and removed lines', () => { + const { container } = render() + expect(container.querySelector('.diff-add')?.textContent).toContain('+new') + expect(container.querySelector('.diff-del')?.textContent).toContain('-old') + expect(container.querySelector('.diff-hunk')?.textContent).toContain('@@') + }) +}) + +describe('ThinkingBlock', () => { + it('is collapsed by default and expands on click', async () => { + render() + expect(screen.queryByText('secret reasoning')).toBeNull() + await userEvent.click(screen.getByRole('button', { name: /thinking/i })) + expect(screen.getByText('secret reasoning')).toBeInTheDocument() + }) +}) From 0eee24710025efda7802226c1c017ad3a0c90f0d Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:13:21 +0900 Subject: [PATCH 07/15] feat(webapp): tool block with per-tool rendering and diffs --- .../components/transcript/ToolBlock.test.tsx | 51 ++++++++++ .../src/components/transcript/ToolBlock.tsx | 99 +++++++++++++++++++ 2 files changed, 150 insertions(+) create mode 100644 webapp/src/components/transcript/ToolBlock.test.tsx create mode 100644 webapp/src/components/transcript/ToolBlock.tsx diff --git a/webapp/src/components/transcript/ToolBlock.test.tsx b/webapp/src/components/transcript/ToolBlock.test.tsx new file mode 100644 index 00000000..3990dc69 --- /dev/null +++ b/webapp/src/components/transcript/ToolBlock.test.tsx @@ -0,0 +1,51 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import type { TranscriptBlock } from '../../lib/transcript' +import { ToolBlock } from './ToolBlock' + +type ToolUse = Extract + +function tool(over: Partial): ToolUse { + return { type: 'tool_use', id: 't1', name: 'Bash', input: {}, result: null, ...over } +} + +describe('ToolBlock', () => { + it('shows the tool name and a Bash command header', () => { + render() + expect(screen.getByText('Bash')).toBeInTheDocument() + expect(screen.getByText('go test ./...')).toBeInTheDocument() + }) + + it('renders a diff for Edit results and reveals output on expand', async () => { + const block = tool({ + name: 'Edit', + input: { file_path: '/x.go' }, + result: { content: '@@ -1 +1 @@\n-a\n+b', is_error: false, subagent_session_id: null }, + }) + const { container } = render() + await userEvent.click(screen.getByRole('button', { name: /Edit/i })) + expect(container.querySelector('.diff-add')).not.toBeNull() + }) + + it('marks errored results', async () => { + const block = tool({ result: { content: 'boom', is_error: true, subagent_session_id: null } }) + render() + await userEvent.click(screen.getByRole('button', { name: /Bash/i })) + expect(screen.getByText('boom')).toBeInTheDocument() + expect(screen.getByTestId('tool-error')).toBeInTheDocument() + }) + + it('offers a subagent link and fires the callback', async () => { + const onOpen = vi.fn() + const block = tool({ + name: 'Task', + input: { subagent_type: 'Explore', prompt: 'find x' }, + result: { content: 'done', is_error: false, subagent_session_id: 'child-9' }, + }) + render() + await userEvent.click(screen.getByRole('button', { name: /Task/i })) + await userEvent.click(screen.getByRole('button', { name: /view subagent/i })) + expect(onOpen).toHaveBeenCalledWith('child-9') + }) +}) diff --git a/webapp/src/components/transcript/ToolBlock.tsx b/webapp/src/components/transcript/ToolBlock.tsx new file mode 100644 index 00000000..1b3fd4a2 --- /dev/null +++ b/webapp/src/components/transcript/ToolBlock.tsx @@ -0,0 +1,99 @@ +import { ChevronRight, CornerDownRight, Wrench } from 'lucide-react' +import { useState } from 'react' +import type { TranscriptBlock } from '../../lib/transcript' +import { CodeBlock } from './CodeBlock' +import { DiffView } from './DiffView' + +type ToolUse = Extract + +/** One-line summary of the tool's input, agentsview-style. */ +function headline(block: ToolUse): string { + const i = block.input + const s = (k: string) => (typeof i[k] === 'string' ? (i[k] as string) : '') + switch (block.name) { + case 'Bash': + case 'run_command': + return s('command') || s('cmd') + case 'Read': + case 'Edit': + case 'MultiEdit': + case 'Write': + case 'Update': + case 'NotebookEdit': + return s('file_path') || s('path') || s('notebook_path') + case 'Grep': + return s('pattern') + case 'Glob': + return s('pattern') || s('glob') + case 'Task': + case 'Agent': + return s('subagent_type') || s('description') || s('prompt').slice(0, 80) + default: + return '' + } +} + +function ResultBody({ block }: { block: ToolUse }) { + const r = block.result + if (!r) return

no output captured

+ const isEdit = ['Edit', 'MultiEdit', 'Write', 'Update'].includes(block.name) + if (isEdit && /^@@|\n[+-]/.test(r.content)) return + if (r.is_error) { + return ( +
+        {r.content}
+      
+ ) + } + return +} + +export function ToolBlock({ + block, + onOpenSubagent, +}: { + block: ToolUse + onOpenSubagent?: (sessionId: string) => void +}) { + const [open, setOpen] = useState(false) + const head = headline(block) + const child = block.result?.subagent_session_id ?? null + return ( +
+ + {open && ( +
+ {Object.keys(block.input).length > 0 && ( +
+ + input + + +
+ )} + + {child && onOpenSubagent && ( + + )} +
+ )} +
+ ) +} From 47ae3545d1c5274ef87532824871ce13d0071007 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:15:27 +0900 Subject: [PATCH 08/15] feat(webapp): sessions tab with full transcript viewer --- webapp/src/App.tsx | 2 + webapp/src/components/Shell.tsx | 4 +- .../components/transcript/MessageBlock.tsx | 35 ++++++ webapp/src/views/SessionsView.test.tsx | 83 ++++++++++++++ webapp/src/views/SessionsView.tsx | 71 ++++++++++++ webapp/src/views/TranscriptView.tsx | 104 ++++++++++++++++++ 6 files changed, 298 insertions(+), 1 deletion(-) create mode 100644 webapp/src/components/transcript/MessageBlock.tsx create mode 100644 webapp/src/views/SessionsView.test.tsx create mode 100644 webapp/src/views/SessionsView.tsx create mode 100644 webapp/src/views/TranscriptView.tsx diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 76860246..b3d0f228 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -8,6 +8,7 @@ import { ChatView } from './views/ChatView' import { ClaimsView } from './views/ClaimsView' import { PendingView } from './views/PendingView' import { ReviewView } from './views/ReviewView' +import { SessionsView } from './views/SessionsView' import { StatsView } from './views/StatsView' export default function App() { @@ -24,6 +25,7 @@ export default function App() { } /> } /> } /> + } /> } /> diff --git a/webapp/src/components/Shell.tsx b/webapp/src/components/Shell.tsx index c51e9a6c..da5edb2d 100644 --- a/webapp/src/components/Shell.tsx +++ b/webapp/src/components/Shell.tsx @@ -1,4 +1,4 @@ -import { Activity, BadgeCheck, FileClock, Inbox, Library, MessageSquare, Plug, SunMoon } from 'lucide-react' +import { Activity, BadgeCheck, FileClock, Inbox, Library, MessageSquare, Plug, ScrollText, SunMoon } from 'lucide-react' import { useEffect, useState } from 'react' import { NavLink, Outlet, useLocation } from 'react-router-dom' import { ConnectDialog } from '../connection/ConnectDialog' @@ -14,6 +14,7 @@ const NAV = [ { to: '/pending', label: 'Pending', icon: Inbox }, { to: '/claims', label: 'Claims', icon: BadgeCheck }, { to: '/browse', label: 'Browse', icon: Library }, + { to: '/sessions', label: 'Sessions', icon: ScrollText }, { to: '/stats', label: 'Stats', icon: Activity }, ] @@ -23,6 +24,7 @@ const TITLES: Record = { '/pending': 'Pending review', '/claims': 'Approved claims', '/browse': 'Knowledge', + '/sessions': 'Session transcripts', '/stats': 'Stats & health', } diff --git a/webapp/src/components/transcript/MessageBlock.tsx b/webapp/src/components/transcript/MessageBlock.tsx new file mode 100644 index 00000000..689260d2 --- /dev/null +++ b/webapp/src/components/transcript/MessageBlock.tsx @@ -0,0 +1,35 @@ +import { Bot, User } from 'lucide-react' +import type { TranscriptMessage } from '../../lib/transcript' +import { Markdown } from '../Markdown' +import { ThinkingBlock } from './ThinkingBlock' +import { ToolBlock } from './ToolBlock' + +export function MessageBlock({ + message, + onOpenSubagent, +}: { + message: TranscriptMessage + onOpenSubagent?: (sessionId: string) => void +}) { + const isUser = message.role === 'user' + return ( +
+
+ {isUser ? : } + {isUser ? 'user' : 'assistant'} + {message.model && {message.model}} +
+
+ {message.blocks.map((b, i) => { + if (b.type === 'thinking') return + if (b.type === 'tool_use') return + return ( +
+ {b.text} +
+ ) + })} +
+
+ ) +} diff --git a/webapp/src/views/SessionsView.test.tsx b/webapp/src/views/SessionsView.test.tsx new file mode 100644 index 00000000..62d128d8 --- /dev/null +++ b/webapp/src/views/SessionsView.test.tsx @@ -0,0 +1,83 @@ +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('../lib/rpc', async () => { + const actual = await vi.importActual('../lib/rpc') + return { ...actual, rpc: vi.fn(), fetchHealth: vi.fn(), fetchCapabilities: vi.fn() } +}) +import { fetchCapabilities, fetchHealth, rpc } from '../lib/rpc' +import { renderWithProviders, seedConnection } from '../test/utils' +import { SessionsView } from './SessionsView' + +const CAPS = { + name: 'vouch', + version: '1', + level: 3, + methods: ['kb.list_sessions', 'kb.session_transcript'], + review_gated: true, +} + +beforeEach(() => { + localStorage.clear() + vi.clearAllMocks() + vi.mocked(fetchHealth).mockResolvedValue(true) + vi.mocked(fetchCapabilities).mockResolvedValue(CAPS as never) + seedConnection() +}) + +describe('SessionsView', () => { + it('lists sessions and renders a picked transcript', async () => { + vi.mocked(rpc).mockImplementation(async (_c, method) => { + if (method === 'kb.list_sessions') { + return { + sessions: [ + { + session_id: 'sid-1', + stage: 'buffer', + proposal_id: null, + kind: null, + title: 'Fix parser', + summarized: false, + observations: 3, + last_activity: '2026-07-10T00:00:00Z', + }, + ], + } + } + if (method === 'kb.session_transcript') { + return { + available: true, + source: { agent: 'claude', path: '/x' }, + session: { + id: 'sid-1', + agent: 'claude', + cwd: '/repo', + git_branch: 'main', + title: 'Fix parser', + started_at: null, + ended_at: null, + model: 'claude-opus-4-8', + tokens: { input: 1, output: 1, cache_read: 0, cache_creation: 0 }, + }, + messages: [ + { + role: 'assistant', + id: 'm1', + model: 'claude-opus-4-8', + timestamp: null, + tokens: null, + blocks: [{ type: 'text', text: 'hello from claude' }], + }, + ], + truncated: false, + } + } + return {} + }) + renderWithProviders(, { route: '/sessions' }) + await waitFor(() => expect(screen.getByText('Fix parser')).toBeInTheDocument()) + await userEvent.click(screen.getByText('Fix parser')) + await waitFor(() => expect(screen.getByText('hello from claude')).toBeInTheDocument()) + }) +}) diff --git a/webapp/src/views/SessionsView.tsx b/webapp/src/views/SessionsView.tsx new file mode 100644 index 00000000..2f2cc60b --- /dev/null +++ b/webapp/src/views/SessionsView.tsx @@ -0,0 +1,71 @@ +import { useState } from 'react' +import { EmptyState } from '../components/EmptyState' +import { useConnection } from '../connection/ConnectionContext' +import { useFanout } from '../lib/fanout' +import type { SessionEntry, VouchConnectionInfo } from '../lib/types' +import { TranscriptView } from './TranscriptView' + +interface Row { + conn: VouchConnectionInfo + label: string + s: SessionEntry +} + +export function SessionsView() { + const { hasMethod } = useConnection() + const sessions = useFanout<{ sessions: SessionEntry[] }>(['sessions'], 'kb.list_sessions', {}, { + refetchInterval: 10_000, + }) + const rows: Row[] = sessions.rows.flatMap((r) => + (r.data?.sessions ?? []).map((s) => ({ conn: r.project.conn, label: r.project.label, s })), + ) + const [sel, setSel] = useState(null) + + return ( +
+ +
+ {sel && sel.s.session_id ? ( + + ) : ( +
+ +
+ )} +
+
+ ) +} diff --git a/webapp/src/views/TranscriptView.tsx b/webapp/src/views/TranscriptView.tsx new file mode 100644 index 00000000..0ca86be1 --- /dev/null +++ b/webapp/src/views/TranscriptView.tsx @@ -0,0 +1,104 @@ +import { useQuery } from '@tanstack/react-query' +import { useState } from 'react' +import { EmptyState } from '../components/EmptyState' +import { ErrorCard } from '../components/ErrorCard' +import { MessageBlock } from '../components/transcript/MessageBlock' +import { VouchRpcError } from '../lib/rpc' +import { fetchTranscript } from '../lib/transcript' +import type { Observation } from '../lib/transcript' +import type { VouchConnectionInfo } from '../lib/types' + +function Degraded({ reason, observations }: { reason: string; observations: Observation[] }) { + return ( +
+
+ original transcript unavailable — {reason}. Showing captured activity. +
+ {observations.length === 0 ? ( + + ) : ( +
    + {observations.map((o, i) => ( +
  1. + {o.tool} + {o.summary} +
  2. + ))} +
+ )} +
+ ) +} + +export function TranscriptView({ + conn, + sessionId, + agent, +}: { + conn: VouchConnectionInfo + sessionId: string + agent?: string +}) { + // Subagent drill-down replaces the shown transcript with the child's, + // keeping a back stack to the parent session. + const [stack, setStack] = useState<{ id: string; agent?: string }[]>([{ id: sessionId, agent }]) + const top = stack[stack.length - 1] + const q = useQuery({ + queryKey: ['transcript', conn.endpoint, top.id], + queryFn: () => fetchTranscript(conn, top.id, top.agent), + }) + + if (q.isPending) return
Loading transcript…
+ if (q.isError) { + const e = q.error + return ( +
+ +
+ ) + } + const t = q.data + return ( +
+ {stack.length > 1 && ( + + )} + {!t.available ? ( + + ) : ( + <> +
+ {t.session.model && {t.session.model}} + {t.session.cwd && {t.session.cwd}} + {t.session.git_branch && ⎇ {t.session.git_branch}} + {t.session.tokens.input + t.session.tokens.output} tokens + {t.source.agent} +
+ {t.truncated && ( +
+ transcript truncated at {t.messages.length} messages +
+ )} + {t.messages.map((m, i) => ( + setStack((s) => [...s, { id, agent: t.source.agent }])} + /> + ))} + + )} +
+ ) +} From b1d10b72945ac1096e7ad849d4175e7b0878058b Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:18:55 +0900 Subject: [PATCH 09/15] feat(transcript): parse Codex rollouts into normalized blocks --- src/vouch/transcript.py | 127 ++++++++++++++++++++++++++++++- tests/test_session_transcript.py | 70 +++++++++++++++++ 2 files changed, 196 insertions(+), 1 deletion(-) diff --git a/src/vouch/transcript.py b/src/vouch/transcript.py index 8d9ebc93..5ca10d34 100644 --- a/src/vouch/transcript.py +++ b/src/vouch/transcript.py @@ -235,8 +235,133 @@ def flush() -> None: return {"session": session, "messages": messages, "truncated": truncated} +def _codex_message_text(content: Any) -> str: + """Join a codex message's content parts (input_text / output_text / text).""" + if isinstance(content, str): + return content.strip() + if not isinstance(content, list): + return "" + parts = [ + str(p.get("text", "")) + for p in content + if isinstance(p, dict) and p.get("type") in ("input_text", "output_text", "text") + ] + return "\n".join(x for x in parts if x).strip() + + def parse_codex_transcript(path: Path, *, max_messages: int = 2000) -> dict[str, Any]: - raise NotImplementedError("codex transcript parsing lands in Task 9") + """Parse a Codex rollout JSONL into the normalized transcript schema. + + The canonical conversation is the sequence of ``response_item`` records: + ``message`` (role user/assistant; developer/system boilerplate skipped), + ``function_call`` / ``custom_tool_call`` and their ``*_output`` pairs, and + ``reasoning`` (encrypted, so dropped). Assistant activity between user + messages groups into one assistant message; an output pairs into its call + by ``call_id``. ``session_meta`` supplies cwd / branch / timestamps. + """ + session: dict[str, Any] = { + "id": path.stem, "agent": "codex", "cwd": None, "git_branch": None, + "title": None, "started_at": None, "ended_at": None, "model": None, + "tokens": {"input": 0, "output": 0, "cache_read": 0, "cache_creation": 0}, + } + messages: list[dict[str, Any]] = [] + tool_by_call: dict[str, dict[str, Any]] = {} + truncated = False + current: dict[str, Any] | None = None + + def new_assistant() -> dict[str, Any]: + return { + "role": "assistant", "id": None, "model": None, + "timestamp": None, "tokens": None, "blocks": [], + } + + def flush() -> None: + nonlocal current + if current is not None and current["blocks"]: + messages.append(current) + current = None + + with path.open(encoding="utf-8") as fh: + for raw in fh: + raw = raw.strip() + if not raw: + continue + try: + rec = json.loads(raw) + except json.JSONDecodeError: + continue + if not isinstance(rec, dict): + continue + payload = rec.get("payload") + if not isinstance(payload, dict): + continue + rtype = rec.get("type") + + if rtype == "session_meta": + sid = payload.get("id") or payload.get("session_id") + if isinstance(sid, str) and sid.strip(): + session["id"] = sid.strip() + if isinstance(payload.get("cwd"), str): + session["cwd"] = payload["cwd"] + ts = payload.get("timestamp") + if isinstance(ts, str): + session["started_at"] = ts + session["ended_at"] = ts + git = payload.get("git") + if isinstance(git, dict) and isinstance(git.get("branch"), str): + session["git_branch"] = git["branch"] + continue + + if rtype != "response_item": + continue + if len(messages) >= max_messages: + truncated = True + break + ptype = payload.get("type") + + if ptype == "message": + role = payload.get("role") + text = _codex_message_text(payload.get("content")) + if role == "user": + flush() + if text: + messages.append({ + "role": "user", "id": None, "model": None, + "timestamp": None, "tokens": None, + "blocks": [{"type": "text", "text": text}], + }) + elif role == "assistant": + if current is None: + current = new_assistant() + if text: + current["blocks"].append({"type": "text", "text": text}) + # developer / system messages are instruction boilerplate: skip. + elif ptype in ("function_call", "custom_tool_call"): + if current is None: + current = new_assistant() + cid = payload.get("call_id") + if ptype == "function_call": + tool_input: dict[str, Any] = {"arguments": payload.get("arguments")} + else: + tool_input = {"input": payload.get("input")} + block: dict[str, Any] = { + "type": "tool_use", "id": cid, + "name": str(payload.get("name", "")), + "input": tool_input, "result": None, + } + current["blocks"].append(block) + if isinstance(cid, str): + tool_by_call[cid] = block + elif ptype in ("function_call_output", "custom_tool_call_output"): + cid = payload.get("call_id") + paired = tool_by_call.get(cid) if isinstance(cid, str) else None + if paired is not None: + paired["result"] = { + "content": str(payload.get("output", "")), + "is_error": False, "subagent_session_id": None, + } + flush() + return {"session": session, "messages": messages, "truncated": truncated} def _degraded(store: KBStore, session_id: str, reason: str) -> dict[str, Any]: diff --git a/tests/test_session_transcript.py b/tests/test_session_transcript.py index 367b36d1..19c0a6d9 100644 --- a/tests/test_session_transcript.py +++ b/tests/test_session_transcript.py @@ -205,3 +205,73 @@ def test_handler_returns_degraded_when_absent() -> None: }) assert resp["ok"] is True assert resp["result"]["available"] is False + + +# --- Task 9: Codex parser ------------------------------------------------- + +_CODEX_LINES = [ + {"type": "session_meta", "payload": { + "id": "cx-1", "cwd": "/repo", "timestamp": "2026-06-22T08:01:54Z", + "git": {"branch": "feat/x"}}}, + {"type": "response_item", "payload": { + "type": "message", "role": "developer", + "content": [{"type": "input_text", "text": "boilerplate"}]}}, + {"type": "response_item", "payload": { + "type": "message", "role": "user", + "content": [{"type": "input_text", "text": "run the tests"}]}}, + {"type": "response_item", "payload": { + "type": "reasoning", "encrypted_content": "gAAA...", "summary": []}}, + {"type": "response_item", "payload": { + "type": "message", "role": "assistant", + "content": [{"type": "output_text", "text": "Running them now."}]}}, + {"type": "response_item", "payload": { + "type": "function_call", "name": "exec_command", + "arguments": "{\"cmd\": \"pytest\"}", "call_id": "call_1"}}, + {"type": "response_item", "payload": { + "type": "function_call_output", "call_id": "call_1", "output": "1 passed"}}, + {"type": "response_item", "payload": { + "type": "custom_tool_call", "name": "apply_patch", + "input": "*** Begin Patch", "call_id": "call_2"}}, + {"type": "response_item", "payload": { + "type": "custom_tool_call_output", "call_id": "call_2", "output": "Success"}}, +] + + +def test_parse_codex_pairs_calls_and_skips_boilerplate(tmp_path: Path) -> None: + f = tmp_path / "rollout-x.jsonl" + _write_jsonl(f, _CODEX_LINES) + out = transcript.parse_codex_transcript(f) + + assert out["session"]["agent"] == "codex" + assert out["session"]["cwd"] == "/repo" + assert out["session"]["git_branch"] == "feat/x" + + roles = [m["role"] for m in out["messages"]] + assert roles == ["user", "assistant"] # developer message skipped + + assistant = out["messages"][1] + types = [b["type"] for b in assistant["blocks"]] + assert types == ["text", "tool_use", "tool_use"] # reasoning (encrypted) skipped + + exec_call = assistant["blocks"][1] + assert exec_call["name"] == "exec_command" + assert exec_call["result"]["content"] == "1 passed" + patch_call = assistant["blocks"][2] + assert patch_call["name"] == "apply_patch" + assert patch_call["result"]["content"] == "Success" + + +def test_load_transcript_codex_source( + tmp_path: Path, store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + # No Claude file exists; a Codex rollout does -> load_transcript finds it. + codex_home = tmp_path / "codex" + sid = "019eec6b-4a0c-7ad0-afd1-68973c902231" + day = codex_home / "sessions" / "2026" / "06" / "22" + roll = day / f"rollout-2026-06-22T08-01-54-{sid}.jsonl" + _write_jsonl(roll, _CODEX_LINES) + monkeypatch.setenv("VOUCH_CLAUDE_PROJECTS_DIR", str(tmp_path / "no-claude")) + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + out = transcript.load_transcript(store, sid) + assert out["available"] is True + assert out["source"]["agent"] == "codex" From b496ce803f68fc7f1fa87f888ef3abfcf0871c01 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:19:38 +0900 Subject: [PATCH 10/15] test(webapp): cover degraded render and subagent drill-down --- webapp/src/views/TranscriptView.test.tsx | 74 ++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 webapp/src/views/TranscriptView.test.tsx diff --git a/webapp/src/views/TranscriptView.test.tsx b/webapp/src/views/TranscriptView.test.tsx new file mode 100644 index 00000000..6558ee06 --- /dev/null +++ b/webapp/src/views/TranscriptView.test.tsx @@ -0,0 +1,74 @@ +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('../lib/rpc', async () => { + const actual = await vi.importActual('../lib/rpc') + return { ...actual, rpc: vi.fn(), fetchHealth: vi.fn(), fetchCapabilities: vi.fn() } +}) +import { rpc } from '../lib/rpc' +import { renderWithProviders } from '../test/utils' +import { TranscriptView } from './TranscriptView' + +const conn = { endpoint: 'http://127.0.0.1:8731' } + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('TranscriptView', () => { + it('renders the degraded observation timeline', async () => { + vi.mocked(rpc).mockResolvedValue({ + available: false, + reason: 'raw transcript not found', + observations: [{ ts: 1, tool: 'Edit', summary: 'Edited types.go' }], + }) + renderWithProviders() + await waitFor(() => expect(screen.getByText('Edited types.go')).toBeInTheDocument()) + expect(screen.getByText(/original transcript unavailable/i)).toBeInTheDocument() + }) + + it('drills into a subagent and back', async () => { + vi.mocked(rpc).mockImplementation(async (_c, _m, params) => { + const id = (params as { session_id: string }).session_id + const blocks = + id === 'child-9' + ? [{ type: 'text', text: 'child says hi' }] + : [ + { + type: 'tool_use', + id: 't1', + name: 'Task', + input: { prompt: 'go' }, + result: { content: 'done', is_error: false, subagent_session_id: 'child-9' }, + }, + ] + return { + available: true, + source: { agent: 'claude', path: '/x' }, + session: { + id, + agent: 'claude', + cwd: null, + git_branch: null, + title: null, + started_at: null, + ended_at: null, + model: null, + tokens: { input: 0, output: 0, cache_read: 0, cache_creation: 0 }, + }, + messages: [ + { role: 'assistant', id: 'm', model: null, timestamp: null, tokens: null, blocks }, + ], + truncated: false, + } + }) + renderWithProviders() + await waitFor(() => expect(screen.getByText('Task')).toBeInTheDocument()) + await userEvent.click(screen.getByRole('button', { name: /Task/i })) + await userEvent.click(screen.getByRole('button', { name: /view subagent/i })) + await waitFor(() => expect(screen.getByText('child says hi')).toBeInTheDocument()) + await userEvent.click(screen.getByRole('button', { name: /back to parent/i })) + await waitFor(() => expect(screen.getByText('Task')).toBeInTheDocument()) + }) +}) From 7adc267b1a3c630945f128d53f6a9986a915db59 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:24:21 +0900 Subject: [PATCH 11/15] test(webapp): e2e smoke for the sessions transcript tab --- webapp/e2e/sessions.spec.ts | 95 +++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 webapp/e2e/sessions.spec.ts diff --git a/webapp/e2e/sessions.spec.ts b/webapp/e2e/sessions.spec.ts new file mode 100644 index 00000000..d6759e8c --- /dev/null +++ b/webapp/e2e/sessions.spec.ts @@ -0,0 +1,95 @@ +import { expect, test } from '@playwright/test' + +// Drives the real frontend (routing, SessionsView, TranscriptView, block +// renderers) against stubbed /proxy responses, so it is independent of the +// backend build. The connection is seeded into localStorage; health and +// capabilities are stubbed so the project comes up "ok" with the transcript +// method advertised, then list_sessions + session_transcript are served. +const ENDPOINT = 'http://127.0.0.1:8971' +const CAPS = { + name: 'vouch', + version: '1.2.2', + level: 3, + methods: ['kb.list_sessions', 'kb.session_transcript'], + review_gated: true, +} + +test('sessions tab renders a picked transcript', async ({ page }) => { + await page.addInitScript((endpoint) => { + localStorage.setItem( + 'vouch-ui.connections.v2', + JSON.stringify({ projects: [{ endpoint }], scope: 'all' }), + ) + }, ENDPOINT) + + await page.route('**/proxy/health', (route) => route.fulfill({ json: { ok: true } })) + await page.route('**/proxy/capabilities', (route) => route.fulfill({ json: CAPS })) + await page.route('**/proxy/rpc', async (route) => { + const body = route.request().postDataJSON() as { method: string } + if (body.method === 'kb.list_pending') { + // Shell fans this out for its nav badge; it expects an array. + return route.fulfill({ json: { ok: true, id: '1', result: [] } }) + } + if (body.method === 'kb.list_sessions') { + return route.fulfill({ + json: { + ok: true, + id: '1', + result: { + sessions: [ + { + session_id: 'e2e-sid', + stage: 'buffer', + proposal_id: null, + kind: null, + title: 'e2e session', + summarized: false, + observations: 2, + last_activity: '2026-07-10T00:00:00Z', + }, + ], + }, + }, + }) + } + if (body.method === 'kb.session_transcript') { + return route.fulfill({ + json: { + ok: true, + id: '1', + result: { + available: true, + source: { agent: 'claude', path: '/x' }, + session: { + id: 'e2e-sid', + agent: 'claude', + cwd: '/repo', + git_branch: 'main', + title: 'e2e session', + started_at: null, + ended_at: null, + model: 'claude-opus-4-8', + tokens: { input: 1, output: 1, cache_read: 0, cache_creation: 0 }, + }, + messages: [ + { + role: 'assistant', + id: 'm', + model: 'claude-opus-4-8', + timestamp: null, + tokens: null, + blocks: [{ type: 'text', text: 'e2e transcript body' }], + }, + ], + truncated: false, + }, + }, + }) + } + return route.fulfill({ json: { ok: true, id: '1', result: {} } }) + }) + + await page.goto('/sessions') + await page.getByText('e2e session').click() + await expect(page.getByText('e2e transcript body')).toBeVisible() +}) From 7c39f74677c1d3b5ecb0e191bb4ce6cb465e2f9c Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:26:27 +0900 Subject: [PATCH 12/15] docs(transcript): session transcript viewer design and plan --- .../2026-07-10-session-transcript-viewer.md | 1745 +++++++++++++++++ ...-07-10-session-transcript-viewer-design.md | 334 ++++ 2 files changed, 2079 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-10-session-transcript-viewer.md create mode 100644 docs/superpowers/specs/2026-07-10-session-transcript-viewer-design.md diff --git a/docs/superpowers/plans/2026-07-10-session-transcript-viewer.md b/docs/superpowers/plans/2026-07-10-session-transcript-viewer.md new file mode 100644 index 00000000..08bde17e --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-session-transcript-viewer.md @@ -0,0 +1,1745 @@ +# Session Transcript Viewer Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a read-only Session Transcript Viewer to the vouch console that opens a captured Claude Code / Codex session and renders its full transcript (thinking, assistant text, tool calls with inputs + outputs, diffs, subagents), faithfully reproducing agentsview's rendering vocabulary. + +**Architecture:** A new `kb.session_transcript` RPC locates the raw agent JSONL on disk on demand, parses it into a normalized block schema, and returns it (degrading to vouch's compact capture observations when the raw file is gone). A new React **Sessions** tab lists sessions (`kb.list_sessions`, fanned out over scoped projects) and renders the selected session's blocks. No sync engine, no database — parsing happens per-open. + +**Tech Stack:** Backend — Python 3.11+, stdlib `json`/`pathlib`, `KBStore`, pytest. Frontend — React 19, TypeScript, Tailwind v4, `@tanstack/react-query`, `react-markdown`, `lucide-react`, vitest + Testing Library, Playwright. + +## Global Constraints + +- Repo: `vouch` at `/home/a/Dev/plind-junior/vouch`. Backend under `src/vouch/`, tests under `tests/`. Frontend under `webapp/`. +- Follow `vouch/AGENTS.md`, NOT agentsview's CLAUDE.md. Conventional commits `(): ` (types: feat|fix|refactor|test|docs|chore|perf|ci|style|build|revert), ≤72-char summary. +- **No `Co-Authored-By: ` trailer** in commits. No secrets or absolute machine paths as PII in commit messages. +- Do NOT switch/create branches without explicit user permission. Current branch is `test`; there are pre-existing uncommitted changes that are NOT ours — `git add` only our own files, never `git add -A`. +- Backend gates before every commit that touches Python: `.venv/bin/python -m pytest tests/ -q --ignore=tests/embeddings`, `.venv/bin/python -m mypy src`, `.venv/bin/python -m ruff check src tests`. +- Frontend gates before every commit that touches `webapp/`: `npm run test` and `npm run build` (from `webapp/`). +- Every new `kb.*` method MUST be added to both `capabilities.METHODS` and `jsonl_server.HANDLERS` or `test_capabilities_matches_jsonl_handlers` fails. +- Tests assert observable behavior, not implementation strings (testing-without-tautologies). Backend uses plain pytest `assert`. + +## Normalized transcript schema (the contract both sides share) + +Returned by `kb.session_transcript`. Tool results are paired into their tool_use block server-side. + +```jsonc +// available === true +{ + "available": true, + "source": { "agent": "claude", "path": "" }, + "session": { + "id": "…", "agent": "claude", + "cwd": "…"|null, "git_branch": "…"|null, "title": "…"|null, + "started_at": "ISO"|null, "ended_at": "ISO"|null, "model": "…"|null, + "tokens": { "input": 0, "output": 0, "cache_read": 0, "cache_creation": 0 } + }, + "messages": [ + { "role": "user"|"assistant", "id": "…"|null, "model": "…"|null, + "timestamp": "ISO"|null, + "tokens": { "input": 0, "output": 0, "cache_read": 0, "cache_creation": 0 }|null, + "blocks": [ + { "type": "text", "text": "…" }, + { "type": "thinking", "text": "…" }, + { "type": "tool_use", "id": "…", "name": "Bash", "input": {…}, + "result": { "content": "…", "is_error": false, + "subagent_session_id": "…"|null }|null } + ] } + ], + "truncated": false +} +// available === false +{ "available": false, "reason": "…", + "observations": [ { "ts": 0.0, "tool": "Edit", "summary": "Edited x.go", + "files": [...]|undefined, "cmd": "…"|undefined } ] } +``` + +--- + +## File Structure + +Backend: +- Create `src/vouch/transcript.py` — locator + Claude parser + Codex parser + `load_transcript` orchestrator. One responsibility: turn a session id into the normalized schema (or a degraded result). +- Modify `src/vouch/jsonl_server.py` — add `_h_session_transcript` handler + register in `HANDLERS`. +- Modify `src/vouch/capabilities.py` — add `"kb.session_transcript"` to `METHODS`. +- Create `tests/test_session_transcript.py` — parser/locator/handler/degradation tests + fixtures inline. + +Frontend (`webapp/`): +- Create `src/lib/transcript.ts` — TS types mirroring the schema + `fetchTranscript(conn, sessionId, agent?)`. +- Create `src/components/transcript/DiffView.tsx`, `CodeBlock.tsx`, `ThinkingBlock.tsx`, `ToolBlock.tsx`, `MessageBlock.tsx` — the block renderers. +- Create `src/views/TranscriptView.tsx` — fetches + renders one session (incl. degraded + subagent lazy-load). +- Create `src/views/SessionsView.tsx` — master–detail list + selection. +- Modify `src/App.tsx` — add `/sessions` route. +- Modify `src/components/Shell.tsx` — add Sessions nav item. +- Create colocated `*.test.tsx` for each component/view. +- Create `webapp/e2e/sessions.spec.ts` — smoke. + +--- + +## PHASE 1 — Backend: Claude locator, parser, RPC + +### Task 1: Claude file locator + +**Files:** +- Create: `src/vouch/transcript.py` +- Test: `tests/test_session_transcript.py` + +**Interfaces:** +- Produces: `find_claude_file(session_id: str) -> Path | None`; `_VALID_ID = re.compile(r"^[0-9a-fA-F-]{8,64}$")`; env override `VOUCH_CLAUDE_PROJECTS_DIR`. + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_session_transcript.py +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from vouch import transcript + + +def _write_jsonl(path: Path, records: list[dict]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join(json.dumps(r) for r in records) + "\n", encoding="utf-8") + + +def test_find_claude_file_top_level(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + root = tmp_path / "projects" + sid = "ad5d5e5f-0097-494c-8316-b01aed5dabf2" + f = root / "-home-a-Dev-agentsview" / f"{sid}.jsonl" + _write_jsonl(f, [{"type": "user", "message": {"role": "user", "content": "hi"}}]) + monkeypatch.setenv("VOUCH_CLAUDE_PROJECTS_DIR", str(root)) + assert transcript.find_claude_file(sid) == f + + +def test_find_claude_file_subagent(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + root = tmp_path / "projects" + parent = "11111111-1111-1111-1111-111111111111" + child = "22222222-2222-2222-2222-222222222222" + f = root / "-proj" / parent / "subagents" / "jobs" / f"{child}.jsonl" + _write_jsonl(f, [{"type": "assistant", "message": {"role": "assistant", "content": []}}]) + monkeypatch.setenv("VOUCH_CLAUDE_PROJECTS_DIR", str(root)) + assert transcript.find_claude_file(child) == f + + +def test_find_claude_file_rejects_bad_id(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("VOUCH_CLAUDE_PROJECTS_DIR", str(tmp_path)) + assert transcript.find_claude_file("../etc/passwd") is None + assert transcript.find_claude_file("*") is None + assert transcript.find_claude_file("") is None +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/python -m pytest tests/test_session_transcript.py -q` +Expected: FAIL with `ModuleNotFoundError: No module named 'vouch.transcript'` / `AttributeError`. + +- [ ] **Step 3: Write minimal implementation** + +```python +# src/vouch/transcript.py +"""Locate and parse raw agent session transcripts on demand. + +Given a captured session id, find the raw JSONL the agent wrote +(Claude Code under ~/.claude/projects, Codex rollouts under +$CODEX_HOME/sessions) and normalize it into a block schema the vouch +console renders. Read-only: never writes to the KB. When the raw file is +gone we degrade to vouch's compact capture observations instead. +""" + +from __future__ import annotations + +import json +import os +import re +from pathlib import Path +from typing import Any + +# Session ids are UUID-shaped; reject anything else so a hostile id can't +# widen a glob or traverse out of the projects tree. +_VALID_ID = re.compile(r"^[0-9a-fA-F-]{8,64}$") + + +def _claude_projects_root() -> Path: + env = os.environ.get("VOUCH_CLAUDE_PROJECTS_DIR") + return Path(env) if env else Path.home() / ".claude" / "projects" + + +def find_claude_file(session_id: str) -> Path | None: + """The raw Claude Code JSONL for ``session_id``, or None. + + Claude names each session file ``.jsonl`` under a per-cwd project + dir; subagent transcripts live under ``/subagents/**``. The + file stem is the id, so a literal name match (no id interpolation into + a glob) locates it. + """ + if not _VALID_ID.match(session_id): + return None + root = _claude_projects_root() + if not root.is_dir(): + return None + name = f"{session_id}.jsonl" + for project in root.iterdir(): + if not project.is_dir(): + continue + top = project / name + if top.is_file(): + return top + for candidate in root.glob(f"*/*/subagents/**/{name}"): + if candidate.is_file(): + return candidate + return None +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `.venv/bin/python -m pytest tests/test_session_transcript.py -q` +Expected: PASS (3 passed). + +- [ ] **Step 5: Commit** + +```bash +git add src/vouch/transcript.py tests/test_session_transcript.py +git commit -m "feat(transcript): locate raw Claude session files by id" +``` + +--- + +### Task 2: Claude transcript parser + +**Files:** +- Modify: `src/vouch/transcript.py` +- Test: `tests/test_session_transcript.py` + +**Interfaces:** +- Consumes: nothing new. +- Produces: `parse_claude_transcript(path: Path, *, max_messages: int = 2000) -> dict[str, Any]` returning `{"session": {...}, "messages": [...], "truncated": bool}` per the schema; helper `_norm_tokens(usage: dict) -> dict`. + +- [ ] **Step 1: Write the failing test** + +```python +# append to tests/test_session_transcript.py + +_CLAUDE_LINES = [ + {"type": "user", "cwd": "/repo", "gitBranch": "main", + "timestamp": "2026-07-10T04:44:19.043Z", + "message": {"role": "user", "content": [{"type": "text", "text": "fix the bug"}]}}, + {"type": "ai-title", "aiTitle": "Fix the bug"}, + {"type": "assistant", "timestamp": "2026-07-10T04:44:35.759Z", + "message": {"id": "msg_1", "model": "claude-opus-4-8", "role": "assistant", + "content": [{"type": "thinking", "thinking": "let me look"}], + "usage": {"input_tokens": 100, "output_tokens": 10, + "cache_read_input_tokens": 5, "cache_creation_input_tokens": 2}}}, + {"type": "assistant", "timestamp": "2026-07-10T04:44:36.771Z", + "message": {"id": "msg_1", "model": "claude-opus-4-8", "role": "assistant", + "content": [{"type": "text", "text": "I'll edit it."}], + "usage": {"input_tokens": 100, "output_tokens": 10}}}, + {"type": "assistant", "timestamp": "2026-07-10T04:44:36.772Z", + "message": {"id": "msg_1", "model": "claude-opus-4-8", "role": "assistant", + "content": [{"type": "tool_use", "id": "tu_1", "name": "Bash", + "input": {"command": "go test ./..."}}], + "usage": {"input_tokens": 100, "output_tokens": 10}}}, + {"type": "user", + "message": {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "tu_1", "content": "ok\n", "is_error": False}]}}, +] + + +def test_parse_claude_pairs_result_and_merges_by_message_id(tmp_path: Path) -> None: + f = tmp_path / "s.jsonl" + _write_jsonl(f, _CLAUDE_LINES) + out = transcript.parse_claude_transcript(f) + + assert out["session"]["cwd"] == "/repo" + assert out["session"]["git_branch"] == "main" + assert out["session"]["title"] == "Fix the bug" + assert out["session"]["model"] == "claude-opus-4-8" + assert out["truncated"] is False + + roles = [m["role"] for m in out["messages"]] + assert roles == ["user", "assistant"] # tool_result user entry is consumed, not a message + + # the three msg_1 assistant lines merged into one message, in order + a = out["messages"][1] + assert [b["type"] for b in a["blocks"]] == ["thinking", "text", "tool_use"] + tu = a["blocks"][2] + assert tu["name"] == "Bash" and tu["input"] == {"command": "go test ./..."} + assert tu["result"] == {"content": "ok\n", "is_error": False, "subagent_session_id": None} + assert a["tokens"] == {"input": 100, "output": 10, "cache_read": 5, "cache_creation": 2} + + +def test_parse_claude_truncates_at_cap(tmp_path: Path) -> None: + lines = [{"type": "user", "message": {"role": "user", "content": [{"type": "text", "text": f"m{i}"}]}} + for i in range(5)] + f = tmp_path / "big.jsonl" + _write_jsonl(f, lines) + out = transcript.parse_claude_transcript(f, max_messages=3) + assert out["truncated"] is True + assert len(out["messages"]) == 3 + + +def test_parse_claude_tolerates_malformed_lines(tmp_path: Path) -> None: + f = tmp_path / "m.jsonl" + f.write_text('{"bad json\n{"type":"user","message":{"role":"user","content":"hey"}}\n', encoding="utf-8") + out = transcript.parse_claude_transcript(f) + assert len(out["messages"]) == 1 + assert out["messages"][0]["blocks"] == [{"type": "text", "text": "hey"}] +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/python -m pytest tests/test_session_transcript.py -k parse_claude -q` +Expected: FAIL with `AttributeError: module 'vouch.transcript' has no attribute 'parse_claude_transcript'`. + +- [ ] **Step 3: Write minimal implementation** + +```python +# add to src/vouch/transcript.py + +def _norm_tokens(usage: dict[str, Any]) -> dict[str, int]: + def i(key: str) -> int: + v = usage.get(key) + return int(v) if isinstance(v, (int, float)) else 0 + return { + "input": i("input_tokens"), + "output": i("output_tokens"), + "cache_read": i("cache_read_input_tokens"), + "cache_creation": i("cache_creation_input_tokens"), + } + + +def _result_text(content: Any) -> str: + """tool_result.content is a string, or a list of {type:text,text} parts.""" + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [str(p.get("text", "")) for p in content + if isinstance(p, dict) and p.get("type") == "text"] + if parts: + return "\n".join(parts) + return json.dumps(content, ensure_ascii=False) + if content is None: + return "" + return json.dumps(content, ensure_ascii=False) + + +def parse_claude_transcript(path: Path, *, max_messages: int = 2000) -> dict[str, Any]: + """Parse a Claude Code JSONL into the normalized transcript schema. + + Single forward pass: assistant content blocks that share a + ``message.id`` merge into one logical message; a later ``tool_result`` + (in a user entry) is paired into the matching ``tool_use`` block by id + and its user entry is not emitted as a standalone message. + """ + messages: list[dict[str, Any]] = [] + tool_by_id: dict[str, dict[str, Any]] = {} + session: dict[str, Any] = { + "id": path.stem, "agent": "claude", "cwd": None, "git_branch": None, + "title": None, "started_at": None, "ended_at": None, "model": None, + "tokens": {"input": 0, "output": 0, "cache_read": 0, "cache_creation": 0}, + } + truncated = False + current: dict[str, Any] | None = None + + def flush() -> None: + nonlocal current + if current is not None and current["blocks"]: + messages.append(current) + current = None + + with path.open(encoding="utf-8") as fh: + for raw in fh: + raw = raw.strip() + if not raw: + continue + try: + obj = json.loads(raw) + except json.JSONDecodeError: + continue + if not isinstance(obj, dict): + continue + if session["cwd"] is None and isinstance(obj.get("cwd"), str): + session["cwd"] = obj["cwd"] + if session["git_branch"] is None and isinstance(obj.get("gitBranch"), str): + session["git_branch"] = obj["gitBranch"] + ts = obj.get("timestamp") + if isinstance(ts, str): + if session["started_at"] is None: + session["started_at"] = ts + session["ended_at"] = ts + t = obj.get("type") + if t == "ai-title" and isinstance(obj.get("aiTitle"), str): + session["title"] = obj["aiTitle"] + continue + if t not in ("user", "assistant"): + continue + if len(messages) >= max_messages: + truncated = True + break + msg = obj.get("message") + if not isinstance(msg, dict): + continue + content = msg.get("content") + + if t == "assistant": + mid = msg.get("id") if isinstance(msg.get("id"), str) else None + if current is None or current.get("id") != mid: + flush() + usage = msg.get("usage") if isinstance(msg.get("usage"), dict) else {} + model = msg.get("model") if isinstance(msg.get("model"), str) else None + if model and session["model"] is None: + session["model"] = model + current = {"role": "assistant", "id": mid, "model": model, + "timestamp": ts if isinstance(ts, str) else None, + "tokens": _norm_tokens(usage), "blocks": []} + tok = current["tokens"] + for k in session["tokens"]: + session["tokens"][k] += tok[k] + parts = content if isinstance(content, list) else [] + for part in parts: + if not isinstance(part, dict): + continue + ptype = part.get("type") + if ptype == "thinking": + text = str(part.get("thinking", "")).strip() + if text: + current["blocks"].append({"type": "thinking", "text": text}) + elif ptype == "text": + text = str(part.get("text", "")).strip() + if text: + current["blocks"].append({"type": "text", "text": text}) + elif ptype == "tool_use": + tid = part.get("id") + block = {"type": "tool_use", "id": tid, + "name": str(part.get("name", "")), + "input": part.get("input") if isinstance(part.get("input"), dict) else {}, + "result": None} + current["blocks"].append(block) + if isinstance(tid, str): + tool_by_id[tid] = block + continue + + # user entry + flush() + if isinstance(content, str): + text = content.strip() + if text: + messages.append({"role": "user", "id": None, "model": None, + "timestamp": ts if isinstance(ts, str) else None, + "tokens": None, "blocks": [{"type": "text", "text": text}]}) + continue + parts = content if isinstance(content, list) else [] + user_blocks: list[dict[str, Any]] = [] + agent_id = None + tur = obj.get("toolUseResult") + if isinstance(tur, dict) and isinstance(tur.get("agentId"), str): + agent_id = tur["agentId"] + for part in parts: + if not isinstance(part, dict): + continue + if part.get("type") == "tool_result": + tid = part.get("tool_use_id") + block = tool_by_id.get(tid) if isinstance(tid, str) else None + if block is not None: + block["result"] = { + "content": _result_text(part.get("content")), + "is_error": bool(part.get("is_error", False)), + "subagent_session_id": agent_id, + } + elif part.get("type") == "text": + text = str(part.get("text", "")).strip() + if text: + user_blocks.append({"type": "text", "text": text}) + if user_blocks: + messages.append({"role": "user", "id": None, "model": None, + "timestamp": ts if isinstance(ts, str) else None, + "tokens": None, "blocks": user_blocks}) + flush() + return {"session": session, "messages": messages, "truncated": truncated} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `.venv/bin/python -m pytest tests/test_session_transcript.py -k parse_claude -q` +Expected: PASS (3 passed). + +- [ ] **Step 5: Run mypy + ruff** + +Run: `.venv/bin/python -m mypy src && .venv/bin/python -m ruff check src tests` +Expected: no errors. (Fix any typing nits inline, e.g. annotate `parts: list[Any]`.) + +- [ ] **Step 6: Commit** + +```bash +git add src/vouch/transcript.py tests/test_session_transcript.py +git commit -m "feat(transcript): parse Claude JSONL into normalized blocks" +``` + +--- + +### Task 3: `load_transcript` orchestrator with degradation + size cap + +**Files:** +- Modify: `src/vouch/transcript.py` +- Test: `tests/test_session_transcript.py` + +**Interfaces:** +- Consumes: `find_claude_file`, `parse_claude_transcript`, `capture.buffer_path`, `capture._read_observations`. +- Produces: `load_transcript(store: KBStore, session_id: str, *, agent: str | None = None) -> dict[str, Any]` returning either the available schema (with `source`) or the degraded schema. +- Constants: `MAX_FILE_BYTES = 25 * 1024 * 1024`, `MAX_MESSAGES = 2000`. + +- [ ] **Step 1: Write the failing test** + +```python +# append to tests/test_session_transcript.py +from vouch import capture +from vouch.storage import KBStore + + +@pytest.fixture +def store(tmp_path: Path) -> KBStore: + return KBStore.init(tmp_path) + + +def test_load_transcript_available(tmp_path: Path, store: KBStore, monkeypatch: pytest.MonkeyPatch) -> None: + root = tmp_path / "projects" + sid = "ad5d5e5f-0097-494c-8316-b01aed5dabf2" + f = root / "-repo" / f"{sid}.jsonl" + _write_jsonl(f, _CLAUDE_LINES) + monkeypatch.setenv("VOUCH_CLAUDE_PROJECTS_DIR", str(root)) + out = transcript.load_transcript(store, sid) + assert out["available"] is True + assert out["source"] == {"agent": "claude", "path": str(f)} + assert out["session"]["title"] == "Fix the bug" + + +def test_load_transcript_degrades_to_observations(store: KBStore, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("VOUCH_CLAUDE_PROJECTS_DIR", str(store.kb_dir / "none")) + sid = "99999999-9999-9999-9999-999999999999" + capture.observe(store, sid, tool="Edit", summary="Edited x.go") + out = transcript.load_transcript(store, sid) + assert out["available"] is False + assert out["observations"][0]["tool"] == "Edit" + assert "reason" in out + + +def test_load_transcript_degrades_when_oversized(tmp_path: Path, store: KBStore, monkeypatch: pytest.MonkeyPatch) -> None: + root = tmp_path / "projects" + sid = "88888888-8888-8888-8888-888888888888" + f = root / "-repo" / f"{sid}.jsonl" + f.parent.mkdir(parents=True, exist_ok=True) + f.write_text("x" * 32, encoding="utf-8") + monkeypatch.setenv("VOUCH_CLAUDE_PROJECTS_DIR", str(root)) + monkeypatch.setattr(transcript, "MAX_FILE_BYTES", 16) + out = transcript.load_transcript(store, sid) + assert out["available"] is False + assert "too large" in out["reason"] +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/python -m pytest tests/test_session_transcript.py -k load_transcript -q` +Expected: FAIL (`load_transcript` undefined). + +- [ ] **Step 3: Write minimal implementation** + +```python +# add to src/vouch/transcript.py (add import at top: from .capture import _read_observations, buffer_path) +# and: from .storage import KBStore (TYPE_CHECKING is fine, but a runtime import is used by callers) + +MAX_FILE_BYTES = 25 * 1024 * 1024 +MAX_MESSAGES = 2000 + + +def _degraded(store: "KBStore", session_id: str, reason: str) -> dict[str, Any]: + obs = _read_observations(buffer_path(store, session_id)) + return {"available": False, "reason": reason, "observations": obs} + + +def load_transcript( + store: "KBStore", 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. + """ + path: Path | None = None + source_agent = "" + if agent in (None, "claude"): + path = find_claude_file(session_id) + if path is not None: + source_agent = "claude" + if path is None and agent in (None, "codex"): + from . import codex_rollout + path = codex_rollout.find_rollout_by_session_id(session_id) + if path is not None: + source_agent = "codex" + if path is None: + return _degraded(store, session_id, f"raw transcript not found for session {session_id}") + try: + if path.stat().st_size > MAX_FILE_BYTES: + return _degraded(store, session_id, f"transcript too large to render ({path.stat().st_size} bytes)") + except OSError as e: + return _degraded(store, session_id, f"cannot read transcript: {e}") + + if source_agent == "claude": + parsed = parse_claude_transcript(path, max_messages=MAX_MESSAGES) + else: + parsed = parse_codex_transcript(path, max_messages=MAX_MESSAGES) + return {"available": True, "source": {"agent": source_agent, "path": str(path)}, **parsed} +``` + +Note: `parse_codex_transcript` lands in Task 9. Until then, guard the codex branch so Phase 1 imports cleanly — add a temporary stub at the bottom of the module that Task 9 replaces: + +```python +def parse_codex_transcript(path: Path, *, max_messages: int = 2000) -> dict[str, Any]: + raise NotImplementedError("codex transcript parsing lands in Task 9") +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `.venv/bin/python -m pytest tests/test_session_transcript.py -k load_transcript -q` +Expected: PASS (3 passed). + +- [ ] **Step 5: Run full backend gates** + +Run: `.venv/bin/python -m pytest tests/test_session_transcript.py -q && .venv/bin/python -m mypy src && .venv/bin/python -m ruff check src tests` +Expected: all green. (Import `KBStore` under `TYPE_CHECKING` and quote the annotation to avoid a runtime cycle; `_read_observations` is a private-but-stable helper already used across the package.) + +- [ ] **Step 6: Commit** + +```bash +git add src/vouch/transcript.py tests/test_session_transcript.py +git commit -m "feat(transcript): orchestrate load with observation fallback" +``` + +--- + +### Task 4: `kb.session_transcript` RPC handler + +**Files:** +- Modify: `src/vouch/jsonl_server.py` (add handler near `_h_list_sessions` ~line 410; register in `HANDLERS` dict ~line 787) +- Modify: `src/vouch/capabilities.py` (add to `METHODS` list ~line 70, after `"kb.list_sessions"`) +- Test: `tests/test_session_transcript.py` + +**Interfaces:** +- Consumes: `transcript.load_transcript`, `_store()`. +- Produces: RPC method `kb.session_transcript(session_id, agent?)`. + +- [ ] **Step 1: Write the failing test** + +```python +# append to tests/test_session_transcript.py +from vouch.capabilities import capabilities +from vouch.jsonl_server import HANDLERS, handle_request + + +def test_capabilities_advertises_session_transcript() -> None: + assert "kb.session_transcript" in capabilities().methods + assert "kb.session_transcript" in HANDLERS + + +def test_handler_missing_session_id_is_missing_param() -> None: + resp = handle_request({"id": "1", "method": "kb.session_transcript", "params": {}}) + assert resp["ok"] is False + assert resp["error"]["code"] == "missing_param" + + +def test_handler_bad_agent_is_invalid_request() -> None: + resp = handle_request({"id": "2", "method": "kb.session_transcript", + "params": {"session_id": "11111111-1111-1111-1111-111111111111", "agent": "grok"}}) + assert resp["ok"] is False + assert resp["error"]["code"] == "invalid_request" + + +def test_handler_returns_degraded_when_absent(monkeypatch: pytest.MonkeyPatch) -> None: + 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 +``` + +Note: `test_handler_returns_degraded_when_absent` runs against the real KB discovered by `_store()`. It asserts only the shape (degraded), which holds regardless of on-disk files, because that id will not exist. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/python -m pytest tests/test_session_transcript.py -k "handler or advertises" -q` +Expected: FAIL — `test_capabilities_advertises_session_transcript` fails and `test_capabilities_matches_jsonl_handlers` (existing) fails once you add to only one list; handler tests fail with `method_not_found`. + +- [ ] **Step 3: Write minimal implementation** + +In `src/vouch/capabilities.py`, add to the `METHODS` list right after `"kb.list_sessions",`: + +```python + "kb.session_transcript", +``` + +In `src/vouch/jsonl_server.py`, add the handler (place it just after `_h_list_sessions`): + +```python +def _h_session_transcript(p: dict) -> dict: + from . import transcript + session_id = p["session_id"] + 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) +``` + +Register it in the `HANDLERS` dict, right after the `"kb.list_sessions": _h_list_sessions,` line: + +```python + "kb.session_transcript": _h_session_transcript, +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `.venv/bin/python -m pytest tests/test_session_transcript.py -q` +Expected: PASS (all). Also run `.venv/bin/python -m pytest tests/test_capabilities.py -q` → PASS. + +- [ ] **Step 5: Full backend gates** + +Run: `.venv/bin/python -m pytest tests/ -q --ignore=tests/embeddings && .venv/bin/python -m mypy src && .venv/bin/python -m ruff check src tests` +Expected: all green. + +- [ ] **Step 6: Commit** + +```bash +git add src/vouch/jsonl_server.py src/vouch/capabilities.py tests/test_session_transcript.py +git commit -m "feat(transcript): expose kb.session_transcript RPC" +``` + +--- + +## PHASE 2 — Frontend: rendering + Sessions view + +Work from `webapp/`. Run `npm install` once if `node_modules` is absent. + +### Task 5: Transcript client types + fetch + +**Files:** +- Create: `webapp/src/lib/transcript.ts` +- Test: `webapp/src/lib/transcript.test.ts` + +**Interfaces:** +- Produces: types `TranscriptBlock`, `TranscriptMessage`, `SessionMeta`, `Transcript` (union of available/degraded); `fetchTranscript(conn, sessionId, agent?) -> Promise`. + +- [ ] **Step 1: Write the failing test** + +```ts +// webapp/src/lib/transcript.test.ts +import { describe, expect, it, vi } from 'vitest' + +vi.mock('./rpc', () => ({ rpc: vi.fn() })) +import { rpc } from './rpc' +import { fetchTranscript } from './transcript' +import type { VouchConnectionInfo } from './types' + +const conn: VouchConnectionInfo = { endpoint: 'http://127.0.0.1:8731' } + +describe('fetchTranscript', () => { + it('calls kb.session_transcript with session id + agent', async () => { + vi.mocked(rpc).mockResolvedValue({ available: false, reason: 'x', observations: [] }) + await fetchTranscript(conn, 'sid-1', 'claude') + expect(rpc).toHaveBeenCalledWith(conn, 'kb.session_transcript', { session_id: 'sid-1', agent: 'claude' }) + }) + + it('omits agent when not given', async () => { + vi.mocked(rpc).mockResolvedValue({ available: false, reason: 'x', observations: [] }) + await fetchTranscript(conn, 'sid-2') + expect(rpc).toHaveBeenCalledWith(conn, 'kb.session_transcript', { session_id: 'sid-2' }) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm run test -- transcript.test.ts` +Expected: FAIL (module `./transcript` not found). + +- [ ] **Step 3: Write minimal implementation** + +```ts +// webapp/src/lib/transcript.ts +import { rpc } from './rpc' +import type { VouchConnectionInfo } from './types' + +export interface Tokens { input: number; output: number; cache_read: number; cache_creation: number } + +export interface ToolResult { + content: string + is_error: boolean + subagent_session_id: string | null +} + +export type TranscriptBlock = + | { type: 'text'; text: string } + | { type: 'thinking'; text: string } + | { type: 'tool_use'; id: string | null; name: string; input: Record; result: ToolResult | null } + +export interface TranscriptMessage { + role: 'user' | 'assistant' + id: string | null + model: string | null + timestamp: string | null + tokens: Tokens | null + blocks: TranscriptBlock[] +} + +export interface SessionMeta { + id: string + agent: string + cwd: string | null + git_branch: string | null + title: string | null + started_at: string | null + ended_at: string | null + model: string | null + tokens: Tokens +} + +export interface Observation { ts: number; tool: string; summary: string; files?: string[]; cmd?: string } + +export type Transcript = + | { available: true; source: { agent: string; path: string }; session: SessionMeta; messages: TranscriptMessage[]; truncated: boolean } + | { available: false; reason: string; observations: Observation[] } + +export function fetchTranscript( + conn: VouchConnectionInfo, + sessionId: string, + agent?: string, +): Promise { + const params: Record = { session_id: sessionId } + if (agent) params.agent = agent + return rpc(conn, 'kb.session_transcript', params) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm run test -- transcript.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add webapp/src/lib/transcript.ts webapp/src/lib/transcript.test.ts +git commit -m "feat(webapp): transcript client types and fetch" +``` + +--- + +### Task 6: Leaf block renderers (DiffView, CodeBlock, ThinkingBlock) + +**Files:** +- Create: `webapp/src/components/transcript/DiffView.tsx`, `CodeBlock.tsx`, `ThinkingBlock.tsx` +- Test: `webapp/src/components/transcript/blocks.test.tsx` + +**Interfaces:** +- Produces: ``; ``; ``. + +- [ ] **Step 1: Write the failing test** + +```tsx +// webapp/src/components/transcript/blocks.test.tsx +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it } from 'vitest' +import { DiffView } from './DiffView' +import { ThinkingBlock } from './ThinkingBlock' + +describe('DiffView', () => { + it('tags added and removed lines', () => { + const { container } = render() + expect(container.querySelector('.diff-add')?.textContent).toContain('+new') + expect(container.querySelector('.diff-del')?.textContent).toContain('-old') + expect(container.querySelector('.diff-hunk')?.textContent).toContain('@@') + }) +}) + +describe('ThinkingBlock', () => { + it('is collapsed by default and expands on click', async () => { + render() + expect(screen.queryByText('secret reasoning')).toBeNull() + await userEvent.click(screen.getByRole('button', { name: /thinking/i })) + expect(screen.getByText('secret reasoning')).toBeInTheDocument() + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm run test -- blocks.test.tsx` +Expected: FAIL (modules not found). + +- [ ] **Step 3: Write minimal implementation** + +```tsx +// webapp/src/components/transcript/DiffView.tsx +export function DiffView({ text }: { text: string }) { + const lines = text.replace(/\n$/, '').split('\n') + return ( +
+ {lines.map((line, i) => { + const cls = line.startsWith('@@') + ? 'diff-hunk text-accent-2' + : line.startsWith('+') + ? 'diff-add bg-ok/10 text-ok' + : line.startsWith('-') + ? 'diff-del bg-accent/10 text-accent-2' + : 'diff-ctx text-ink-2' + return ( +
+ {line || ' '} +
+ ) + })} +
+ ) +} +``` + +```tsx +// webapp/src/components/transcript/CodeBlock.tsx +export function CodeBlock({ code, lang }: { code: string; lang?: string }) { + return ( +
+ {lang && ( +
+ {lang} +
+ )} +
{code}
+
+ ) +} +``` + +```tsx +// webapp/src/components/transcript/ThinkingBlock.tsx +import { Brain, ChevronRight } from 'lucide-react' +import { useState } from 'react' + +export function ThinkingBlock({ text }: { text: string }) { + const [open, setOpen] = useState(false) + return ( +
+ + {open &&
{text}
} +
+ ) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm run test -- blocks.test.tsx` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add webapp/src/components/transcript/DiffView.tsx webapp/src/components/transcript/CodeBlock.tsx webapp/src/components/transcript/ThinkingBlock.tsx webapp/src/components/transcript/blocks.test.tsx +git commit -m "feat(webapp): thinking, diff, and code block renderers" +``` + +--- + +### Task 7: ToolBlock (per-tool rendering + collapsible + result) + +**Files:** +- Create: `webapp/src/components/transcript/ToolBlock.tsx` +- Test: `webapp/src/components/transcript/ToolBlock.test.tsx` + +**Interfaces:** +- Consumes: `DiffView`, `CodeBlock`, block type from `lib/transcript`, optional `onOpenSubagent(sessionId)` callback. +- Produces: ` void} />`. + +- [ ] **Step 1: Write the failing test** + +```tsx +// webapp/src/components/transcript/ToolBlock.test.tsx +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { ToolBlock } from './ToolBlock' +import type { TranscriptBlock } from '../../lib/transcript' + +function tool(over: Partial>): Extract { + return { type: 'tool_use', id: 't1', name: 'Bash', input: {}, result: null, ...over } +} + +describe('ToolBlock', () => { + it('shows the tool name and a Bash command header', () => { + render() + expect(screen.getByText('Bash')).toBeInTheDocument() + expect(screen.getByText('go test ./...')).toBeInTheDocument() + }) + + it('renders a diff for Edit results and reveals output on expand', async () => { + const block = tool({ + name: 'Edit', + input: { file_path: '/x.go' }, + result: { content: '@@ -1 +1 @@\n-a\n+b', is_error: false, subagent_session_id: null }, + }) + const { container } = render() + await userEvent.click(screen.getByRole('button', { name: /Edit/i })) + expect(container.querySelector('.diff-add')).not.toBeNull() + }) + + it('marks errored results', async () => { + const block = tool({ result: { content: 'boom', is_error: true, subagent_session_id: null } }) + render() + await userEvent.click(screen.getByRole('button', { name: /Bash/i })) + expect(screen.getByText('boom')).toBeInTheDocument() + expect(screen.getByTestId('tool-error')).toBeInTheDocument() + }) + + it('offers a subagent link and fires the callback', async () => { + const onOpen = vi.fn() + const block = tool({ name: 'Task', input: { subagent_type: 'Explore', prompt: 'find x' }, + result: { content: 'done', is_error: false, subagent_session_id: 'child-9' } }) + render() + await userEvent.click(screen.getByRole('button', { name: /view subagent/i })) + expect(onOpen).toHaveBeenCalledWith('child-9') + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm run test -- ToolBlock.test.tsx` +Expected: FAIL (module not found). + +- [ ] **Step 3: Write minimal implementation** + +```tsx +// webapp/src/components/transcript/ToolBlock.tsx +import { ChevronRight, CornerDownRight, Wrench } from 'lucide-react' +import { useState } from 'react' +import type { TranscriptBlock } from '../../lib/transcript' +import { CodeBlock } from './CodeBlock' +import { DiffView } from './DiffView' + +type ToolUse = Extract + +/** One-line summary of the tool's input, agentsview-style. */ +function headline(block: ToolUse): string { + const i = block.input as Record + const s = (k: string) => (typeof i[k] === 'string' ? (i[k] as string) : '') + switch (block.name) { + case 'Bash': + case 'run_command': + return s('command') || s('cmd') + case 'Read': + case 'Edit': + case 'MultiEdit': + case 'Write': + case 'Update': + case 'NotebookEdit': + return s('file_path') || s('path') || s('notebook_path') + case 'Grep': + return s('pattern') + case 'Glob': + return s('pattern') || s('glob') + case 'Task': + case 'Agent': + return s('subagent_type') || s('description') || s('prompt').slice(0, 80) + default: + return '' + } +} + +function ResultBody({ block }: { block: ToolUse }) { + const r = block.result + if (!r) return

no output captured

+ const isEdit = ['Edit', 'MultiEdit', 'Write', 'Update'].includes(block.name) + if (isEdit && /^@@|\n[+-]/.test(r.content)) return + if (r.is_error) { + return ( +
+        {r.content}
+      
+ ) + } + return +} + +export function ToolBlock({ + block, + onOpenSubagent, +}: { + block: ToolUse + onOpenSubagent?: (sessionId: string) => void +}) { + const [open, setOpen] = useState(false) + const head = headline(block) + const child = block.result?.subagent_session_id ?? null + return ( +
+ + {open && ( +
+ {Object.keys(block.input).length > 0 && ( +
+ input + +
+ )} + + {child && onOpenSubagent && ( + + )} +
+ )} +
+ ) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm run test -- ToolBlock.test.tsx` +Expected: PASS (4 passed). + +- [ ] **Step 5: Commit** + +```bash +git add webapp/src/components/transcript/ToolBlock.tsx webapp/src/components/transcript/ToolBlock.test.tsx +git commit -m "feat(webapp): tool block with per-tool rendering and diffs" +``` + +--- + +### Task 8: MessageBlock + TranscriptView + SessionsView + route/nav + +**Files:** +- Create: `webapp/src/components/transcript/MessageBlock.tsx` +- Create: `webapp/src/views/TranscriptView.tsx` +- Create: `webapp/src/views/SessionsView.tsx` +- Modify: `webapp/src/App.tsx` (add route) +- Modify: `webapp/src/components/Shell.tsx` (add nav item + title) +- Test: `webapp/src/views/SessionsView.test.tsx` + +**Interfaces:** +- Consumes: `ThinkingBlock`, `ToolBlock`, `Markdown`, `fetchTranscript`, `useConnection`, `useFanout`, `SessionEntry`. +- Produces: ``; ``; ``. + +- [ ] **Step 1: Write the failing test** + +```tsx +// webapp/src/views/SessionsView.test.tsx +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('../lib/rpc', async () => { + const actual = await vi.importActual('../lib/rpc') + return { ...actual, rpc: vi.fn(), fetchHealth: vi.fn(), fetchCapabilities: vi.fn() } +}) +import { fetchCapabilities, fetchHealth, rpc } from '../lib/rpc' +import { renderWithProviders, seedConnection } from '../test/utils' +import { SessionsView } from './SessionsView' + +const CAPS = { name: 'vouch', version: '1', level: 3, methods: ['kb.list_sessions', 'kb.session_transcript'], review_gated: true } + +beforeEach(() => { + localStorage.clear() + vi.clearAllMocks() + vi.mocked(fetchHealth).mockResolvedValue(true) + vi.mocked(fetchCapabilities).mockResolvedValue(CAPS as never) + seedConnection() +}) + +describe('SessionsView', () => { + it('lists sessions and renders a picked transcript', async () => { + vi.mocked(rpc).mockImplementation(async (_c, method) => { + if (method === 'kb.list_sessions') { + return { sessions: [{ session_id: 'sid-1', stage: 'buffer', proposal_id: null, kind: null, + title: 'Fix parser', summarized: false, observations: 3, last_activity: '2026-07-10T00:00:00Z' }] } + } + if (method === 'kb.session_transcript') { + return { available: true, source: { agent: 'claude', path: '/x' }, + session: { id: 'sid-1', agent: 'claude', cwd: '/repo', git_branch: 'main', title: 'Fix parser', + started_at: null, ended_at: null, model: 'claude-opus-4-8', + tokens: { input: 1, output: 1, cache_read: 0, cache_creation: 0 } }, + messages: [{ role: 'assistant', id: 'm1', model: 'claude-opus-4-8', timestamp: null, tokens: null, + blocks: [{ type: 'text', text: 'hello from claude' }] }], truncated: false } + } + return {} + }) + renderWithProviders(, { route: '/sessions' }) + await waitFor(() => expect(screen.getByText('Fix parser')).toBeInTheDocument()) + await userEvent.click(screen.getByText('Fix parser')) + await waitFor(() => expect(screen.getByText('hello from claude')).toBeInTheDocument()) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm run test -- SessionsView.test.tsx` +Expected: FAIL (module not found). + +- [ ] **Step 3: Write minimal implementation** + +```tsx +// webapp/src/components/transcript/MessageBlock.tsx +import { Bot, User } from 'lucide-react' +import { Markdown } from '../Markdown' +import type { TranscriptMessage } from '../../lib/transcript' +import { ThinkingBlock } from './ThinkingBlock' +import { ToolBlock } from './ToolBlock' + +export function MessageBlock({ + message, + onOpenSubagent, +}: { + message: TranscriptMessage + onOpenSubagent?: (sessionId: string) => void +}) { + const isUser = message.role === 'user' + return ( +
+
+ {isUser ? : } + {isUser ? 'user' : 'assistant'} + {message.model && {message.model}} +
+
+ {message.blocks.map((b, i) => { + if (b.type === 'thinking') return + if (b.type === 'tool_use') return + return ( +
+ {b.text} +
+ ) + })} +
+
+ ) +} +``` + +```tsx +// webapp/src/views/TranscriptView.tsx +import { useQuery } from '@tanstack/react-query' +import { useState } from 'react' +import { EmptyState } from '../components/EmptyState' +import { ErrorCard } from '../components/ErrorCard' +import { MessageBlock } from '../components/transcript/MessageBlock' +import { fetchTranscript } from '../lib/transcript' +import type { Observation } from '../lib/transcript' +import type { VouchConnectionInfo } from '../lib/types' +import { VouchRpcError } from '../lib/rpc' + +function Degraded({ reason, observations }: { reason: string; observations: Observation[] }) { + return ( +
+
+ original transcript unavailable — {reason}. Showing captured activity. +
+ {observations.length === 0 ? ( + + ) : ( +
    + {observations.map((o, i) => ( +
  1. + {o.tool} + {o.summary} +
  2. + ))} +
+ )} +
+ ) +} + +export function TranscriptView({ + conn, + sessionId, + agent, +}: { + conn: VouchConnectionInfo + sessionId: string + agent?: string +}) { + // Subagent drill-down replaces the shown transcript with the child's, with a back stack. + const [stack, setStack] = useState<{ id: string; agent?: string }[]>([{ id: sessionId, agent }]) + const top = stack[stack.length - 1] + const q = useQuery({ + queryKey: ['transcript', conn.endpoint, top.id], + queryFn: () => fetchTranscript(conn, top.id, top.agent), + }) + + if (q.isPending) return
Loading transcript…
+ if (q.isError) { + const e = q.error + return
+ } + const t = q.data + return ( +
+ {stack.length > 1 && ( + + )} + {!t.available ? ( + + ) : ( + <> +
+ {t.session.model && {t.session.model}} + {t.session.cwd && {t.session.cwd}} + {t.session.git_branch && ⎇ {t.session.git_branch}} + {t.session.tokens.input + t.session.tokens.output} tokens + {t.source.agent} +
+ {t.truncated && ( +
+ transcript truncated at {t.messages.length} messages +
+ )} + {t.messages.map((m, i) => ( + setStack((s) => [...s, { id, agent: t.source.agent }])} /> + ))} + + )} +
+ ) +} +``` + +```tsx +// webapp/src/views/SessionsView.tsx +import { useState } from 'react' +import { EmptyState } from '../components/EmptyState' +import { useConnection } from '../connection/ConnectionContext' +import { useFanout } from '../lib/fanout' +import type { SessionEntry } from '../lib/types' +import type { VouchConnectionInfo } from '../lib/types' +import { TranscriptView } from './TranscriptView' + +interface Row { conn: VouchConnectionInfo; label: string; s: SessionEntry } + +export function SessionsView() { + const { hasMethod } = useConnection() + const sessions = useFanout<{ sessions: SessionEntry[] }>(['sessions'], 'kb.list_sessions', {}, { + refetchInterval: 10_000, + }) + const rows: Row[] = sessions.rows.flatMap((r) => + (r.data?.sessions ?? []).map((s) => ({ conn: r.project.conn, label: r.project.label, s })), + ) + const [sel, setSel] = useState(null) + + return ( +
+ +
+ {sel && sel.s.session_id ? ( + + ) : ( +
+ )} +
+
+ ) +} +``` + +In `webapp/src/App.tsx`, add the import and route (after the `/stats` route): + +```tsx +import { SessionsView } from './views/SessionsView' +// ... + } /> +``` + +In `webapp/src/components/Shell.tsx`, import an icon and add nav + title: + +```tsx +// add ScrollText to the lucide-react import +import { Activity, BadgeCheck, FileClock, Inbox, Library, MessageSquare, Plug, ScrollText, SunMoon } from 'lucide-react' +// add to NAV array (before /stats): + { to: '/sessions', label: 'Sessions', icon: ScrollText }, +// add to TITLES: + '/sessions': 'Session transcripts', +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm run test -- SessionsView.test.tsx` +Expected: PASS. + +- [ ] **Step 5: Full frontend gates** + +Run: `npm run test && npm run build` +Expected: all tests pass; `tsc && vite build` succeeds with no type errors. + +- [ ] **Step 6: Commit** + +```bash +git add webapp/src/components/transcript/MessageBlock.tsx webapp/src/views/TranscriptView.tsx webapp/src/views/SessionsView.tsx webapp/src/views/SessionsView.test.tsx webapp/src/App.tsx webapp/src/components/Shell.tsx +git commit -m "feat(webapp): sessions tab with full transcript viewer" +``` + +--- + +## PHASE 3 — Codex, subagents polish, e2e + +### Task 9: Codex full-transcript parser + +**Files:** +- Modify: `src/vouch/transcript.py` (replace the `parse_codex_transcript` stub) +- Test: `tests/test_session_transcript.py` + +**Interfaces:** +- Consumes: `codex_rollout._iter_rollout_lines` is NOT reused (it raises on zstd); parse plain records directly. +- Produces: `parse_codex_transcript(path, *, max_messages=2000) -> dict[str, Any]` returning the same schema, `session.agent == "codex"`. + +Codex rollout records (verified in `codex_rollout.py`): `{"type":"session_meta","payload":{"id","cwd","timestamp"}}`; `{"type":"event_msg","payload":{"type":"user_message","message":"…"}}` and `agent_message` (assistant text, field `message`); `{"type":"response_item","payload":{"type":"function_call","name","arguments","call_id"}}` and `{"type":"response_item","payload":{"type":"function_call_output","call_id","output"}}`. + +- [ ] **Step 1: Write the failing test** + +```python +# append to tests/test_session_transcript.py +_CODEX_LINES = [ + {"type": "session_meta", "payload": {"id": "cx-1", "cwd": "/repo", "timestamp": "2026-06-22T08:01:54Z"}}, + {"type": "event_msg", "payload": {"type": "user_message", "message": "run the tests"}}, + {"type": "event_msg", "payload": {"type": "agent_message", "message": "Running them now."}}, + {"type": "response_item", "payload": {"type": "function_call", "name": "shell", + "arguments": "{\"command\": \"pytest\"}", "call_id": "c1"}}, + {"type": "response_item", "payload": {"type": "function_call_output", "call_id": "c1", + "output": "1 passed"}}, +] + + +def test_parse_codex_pairs_calls(tmp_path: Path) -> None: + f = tmp_path / "rollout-x.jsonl" + _write_jsonl(f, _CODEX_LINES) + out = transcript.parse_codex_transcript(f) + assert out["session"]["agent"] == "codex" + assert out["session"]["cwd"] == "/repo" + roles = [m["role"] for m in out["messages"]] + assert roles[0] == "user" + # the assistant message carries the agent text and the paired tool call + assistant = next(m for m in out["messages"] if m["role"] == "assistant") + types = [b["type"] for b in assistant["blocks"]] + assert "tool_use" in types and "text" in types + tu = next(b for b in assistant["blocks"] if b["type"] == "tool_use") + assert tu["name"] == "shell" + assert tu["result"]["content"] == "1 passed" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/python -m pytest tests/test_session_transcript.py -k codex -q` +Expected: FAIL — `NotImplementedError` from the stub. + +- [ ] **Step 3: Write minimal implementation** (replace the stub) + +```python +def parse_codex_transcript(path: Path, *, max_messages: int = 2000) -> dict[str, Any]: + """Parse a Codex rollout JSONL into the normalized transcript schema. + + Codex interleaves user/agent messages (``event_msg``) with tool calls + and outputs (``response_item``). Consecutive assistant activity — agent + text and its function calls — is grouped into one assistant message; + a ``function_call_output`` pairs into its call by ``call_id``. + """ + session: dict[str, Any] = { + "id": path.stem, "agent": "codex", "cwd": None, "git_branch": None, + "title": None, "started_at": None, "ended_at": None, "model": None, + "tokens": {"input": 0, "output": 0, "cache_read": 0, "cache_creation": 0}, + } + messages: list[dict[str, Any]] = [] + tool_by_call: dict[str, dict[str, Any]] = {} + truncated = False + current: dict[str, Any] | None = None + + def new_assistant() -> dict[str, Any]: + return {"role": "assistant", "id": None, "model": None, "timestamp": None, + "tokens": None, "blocks": []} + + with path.open(encoding="utf-8") as fh: + for raw in fh: + raw = raw.strip() + if not raw: + continue + try: + rec = json.loads(raw) + except json.JSONDecodeError: + continue + if not isinstance(rec, dict): + continue + payload = rec.get("payload") + if not isinstance(payload, dict): + continue + rtype = rec.get("type") + if len(messages) >= max_messages: + truncated = True + break + + if rtype == "session_meta": + if isinstance(payload.get("id"), str) and session["id"] == path.stem: + session["id"] = payload["id"] + if isinstance(payload.get("cwd"), str): + session["cwd"] = payload["cwd"] + if isinstance(payload.get("timestamp"), str): + session["started_at"] = payload["timestamp"] + session["ended_at"] = payload["timestamp"] + elif rtype == "event_msg" and payload.get("type") == "user_message": + if current is not None: + messages.append(current) + current = None + text = str(payload.get("message", "")).strip() + if text: + messages.append({"role": "user", "id": None, "model": None, + "timestamp": None, "tokens": None, + "blocks": [{"type": "text", "text": text}]}) + elif rtype == "event_msg" and payload.get("type") == "agent_message": + if current is None: + current = new_assistant() + text = str(payload.get("message", "")).strip() + if text: + current["blocks"].append({"type": "text", "text": text}) + elif rtype == "response_item" and payload.get("type") == "function_call": + if current is None: + current = new_assistant() + block = {"type": "tool_use", "id": payload.get("call_id"), + "name": str(payload.get("name", "")), + "input": {"arguments": payload.get("arguments")}, "result": None} + current["blocks"].append(block) + cid = payload.get("call_id") + if isinstance(cid, str): + tool_by_call[cid] = block + elif rtype == "response_item" and payload.get("type") == "function_call_output": + cid = payload.get("call_id") + block = tool_by_call.get(cid) if isinstance(cid, str) else None + if block is not None: + block["result"] = {"content": str(payload.get("output", "")), + "is_error": False, "subagent_session_id": None} + if current is not None: + messages.append(current) + return {"session": session, "messages": messages, "truncated": truncated} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `.venv/bin/python -m pytest tests/test_session_transcript.py -k codex -q` +Expected: PASS. + +- [ ] **Step 5: Full backend gates + commit** + +```bash +.venv/bin/python -m pytest tests/ -q --ignore=tests/embeddings && .venv/bin/python -m mypy src && .venv/bin/python -m ruff check src tests +git add src/vouch/transcript.py tests/test_session_transcript.py +git commit -m "feat(transcript): parse Codex rollouts into normalized blocks" +``` + +--- + +### Task 10: Degraded-render + subagent test coverage (frontend) + +**Files:** +- Create: `webapp/src/views/TranscriptView.test.tsx` + +**Interfaces:** consumes existing `TranscriptView`. + +- [ ] **Step 1: Write the failing test** + +```tsx +// webapp/src/views/TranscriptView.test.tsx +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('../lib/rpc', async () => { + const actual = await vi.importActual('../lib/rpc') + return { ...actual, rpc: vi.fn(), fetchHealth: vi.fn(), fetchCapabilities: vi.fn() } +}) +import { rpc } from '../lib/rpc' +import { renderWithProviders } from '../test/utils' +import { TranscriptView } from './TranscriptView' + +const conn = { endpoint: 'http://127.0.0.1:8731' } + +beforeEach(() => { vi.clearAllMocks() }) + +describe('TranscriptView', () => { + it('renders the degraded observation timeline', async () => { + vi.mocked(rpc).mockResolvedValue({ available: false, reason: 'raw transcript not found', + observations: [{ ts: 1, tool: 'Edit', summary: 'Edited types.go' }] }) + renderWithProviders() + await waitFor(() => expect(screen.getByText('Edited types.go')).toBeInTheDocument()) + expect(screen.getByText(/original transcript unavailable/i)).toBeInTheDocument() + }) + + it('drills into a subagent and back', async () => { + vi.mocked(rpc).mockImplementation(async (_c, _m, params) => { + const id = (params as { session_id: string }).session_id + const text = id === 'child-9' ? 'child says hi' : 'parent turn' + const blocks = id === 'child-9' + ? [{ type: 'text', text }] + : [{ type: 'tool_use', id: 't1', name: 'Task', input: { prompt: 'go' }, + result: { content: 'done', is_error: false, subagent_session_id: 'child-9' } }] + return { available: true, source: { agent: 'claude', path: '/x' }, + session: { id, agent: 'claude', cwd: null, git_branch: null, title: null, started_at: null, + ended_at: null, model: null, tokens: { input: 0, output: 0, cache_read: 0, cache_creation: 0 } }, + messages: [{ role: 'assistant', id: 'm', model: null, timestamp: null, tokens: null, blocks }], + truncated: false } + }) + renderWithProviders() + await waitFor(() => expect(screen.getByText('Task')).toBeInTheDocument()) + await userEvent.click(screen.getByRole('button', { name: /Task/i })) + await userEvent.click(screen.getByRole('button', { name: /view subagent/i })) + await waitFor(() => expect(screen.getByText('child says hi')).toBeInTheDocument()) + await userEvent.click(screen.getByRole('button', { name: /back to parent/i })) + await waitFor(() => expect(screen.getByText('Task')).toBeInTheDocument()) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails, then passes** + +Run: `npm run test -- TranscriptView.test.tsx` +Expected: These should PASS against the Task 8 implementation. If the subagent-back or degraded rendering fails, fix `TranscriptView` (this task is the regression net that proves both paths). Do not modify tests to fit bugs. + +- [ ] **Step 3: Full frontend gates + commit** + +```bash +npm run test && npm run build +git add webapp/src/views/TranscriptView.test.tsx +git commit -m "test(webapp): cover degraded render and subagent drill-down" +``` + +--- + +### Task 11: E2E smoke + +**Files:** +- Create: `webapp/e2e/sessions.spec.ts` + +**Interfaces:** exercises the running app against the existing e2e fixture server (see `webapp/e2e/global-setup.ts`). + +- [ ] **Step 1: Inspect the existing e2e harness** + +Run: `sed -n '1,60p' webapp/e2e/smoke.spec.ts webapp/e2e/global-setup.ts` +Learn how the fixture endpoint is seeded and how `kb.*` responses are stubbed/served, then mirror that to stub `kb.list_sessions` + `kb.session_transcript`. (The spec must drive the real UI — navigate to Sessions, click a row, assert a rendered block — not assert on source.) + +- [ ] **Step 2: Write the spec** + +```ts +// webapp/e2e/sessions.spec.ts +import { expect, test } from '@playwright/test' + +// Follow the pattern established in smoke.spec.ts for seeding a connection +// and stubbing /proxy/rpc. Route kb.list_sessions -> one row with a +// session_id, and kb.session_transcript -> an available transcript with a +// single assistant text block "e2e transcript body". +test('sessions tab renders a picked transcript', async ({ page }) => { + // ...seed + route stubs mirroring smoke.spec.ts... + await page.goto('/sessions') + await page.getByText('e2e session').click() + await expect(page.getByText('e2e transcript body')).toBeVisible() +}) +``` + +- [ ] **Step 3: Run it** + +Run: `npm run e2e -- sessions.spec.ts` +Expected: PASS. (If the harness cannot stub per-method easily, assert the empty-state path instead: navigating to `/sessions` shows "No sessions" — still real behavior, no tautology.) + +- [ ] **Step 4: Commit** + +```bash +git add webapp/e2e/sessions.spec.ts +git commit -m "test(webapp): e2e smoke for the sessions transcript tab" +``` + +--- + +### Task 12: Spec sync + docs + +**Files:** +- Modify: `docs/superpowers/specs/2026-07-10-session-transcript-viewer-design.md` + +- [ ] **Step 1:** Update the spec's "Backend contract" to reflect server-side tool_result pairing (blocks carry `result` inline; no separate `tool_result` block) and resolve the open questions to what shipped (Sessions tab, master–detail, Codex in v1). Run `mdformat --wrap 80 docs/superpowers/specs/2026-07-10-session-transcript-viewer-design.md` if available. + +- [ ] **Step 2: Commit** + +```bash +git add docs/superpowers/specs/2026-07-10-session-transcript-viewer-design.md +git commit -m "docs(transcript): align spec with shipped contract" +``` + +--- + +## Self-Review + +**Spec coverage:** Backend RPC + `transcript.py` (Tasks 1-4, 9). Locator Claude + Codex (1, 9). Normalized schema (2, 9). Degradation to observations (3, 10). Frontend SessionsView + TranscriptView + block renderers (5-8). Per-tool rendering + diffs (7). Subagent lazy expansion (7, 8, 10). Capability gating (8). Tests every task; e2e (11). Conventions/guardrails in Global Constraints. All spec sections map to a task. + +**Placeholder scan:** No TBD/TODO. The only forward reference is `parse_codex_transcript` (stub in Task 3, implemented Task 9) — explicitly flagged with a raising stub so Phase 1 stays green. E2E spec body references the existing harness pattern (Task 11 Step 1 reads it first) rather than inventing an unknown stub API. + +**Type consistency:** Block schema identical across backend (dicts) and `lib/transcript.ts` (`TranscriptBlock`). `tool_use.result` is `{content, is_error, subagent_session_id}` everywhere. `fetchTranscript(conn, sessionId, agent?)`, `load_transcript(store, session_id, *, agent=None)`, and the handler param names (`session_id`, `agent`) match. `useFanout` row shape (`{project, data}`) matches Task 8 usage. + +--- + +## Execution Handoff + +Recommended: **Subagent-Driven** (fresh subagent per task, two-stage review between tasks). Phase 1 (backend, Tasks 1-4) is the critical path and must be green before Phase 2 consumes the RPC. Alternative: **Inline Execution** with checkpoints after each phase. diff --git a/docs/superpowers/specs/2026-07-10-session-transcript-viewer-design.md b/docs/superpowers/specs/2026-07-10-session-transcript-viewer-design.md new file mode 100644 index 00000000..5fcb87d0 --- /dev/null +++ b/docs/superpowers/specs/2026-07-10-session-transcript-viewer-design.md @@ -0,0 +1,334 @@ +# Session Transcript Viewer — Design + +Date: 2026-07-10 +Status: Implemented (see `docs/superpowers/plans/2026-07-10-session-transcript-viewer.md`) +Repo: `vouch` (backend `src/vouch`, frontend `webapp`) + +## Problem + +The vouch console (`webapp`) can list captured agent sessions +(`kb.list_sessions`) but cannot show what actually happened inside one. A row +gives a title, a stage, and an observation count — nothing more. When an agent +files claims a reviewer often needs to see the reasoning: the prompts, the +assistant's replies, the tools it ran, and the diffs it made. + +The `agentsview` project already renders exactly this for Claude Code / Codex +sessions. This feature ports **agentsview's rendering experience** into the +vouch console so a reviewer can open a session and read its full transcript. + +### What this is NOT + +- Not live-run rendering. The chat's "Claude mode" (`ChatView`) stays as-is; + this feature is a read-only viewer for **already-captured** sessions. +- Not a re-implementation of agentsview's storage architecture. See + "Relationship to agentsview" below — we copy the rendering, not the + sync-into-a-database pipeline. + +## Relationship to agentsview (what we copy, what we don't) + +agentsview works in two stages: + +1. **Ingest → SQLite.** A sync engine + file watcher parse the raw agent JSONL + into normalized rows in a SQLite DB (`messages`, `tool_calls`, + `tool_result_events`, FTS5). The raw file is parsed once at sync time. +2. **Serve → render.** A paginated REST API reads from the DB; the Svelte + frontend segments `content` + `tool_calls` into typed blocks + (`thinking` / `tool` / `code` / `skill` / `text`) client-side and renders + them with per-block components. + +We faithfully copy **stage 2's rendering vocabulary** (block types, per-tool +rendering, diffs, collapsibles, lazy subagents). We deliberately do **not** +copy stage 1: instead of syncing raw files into a database, the vouch backend +**parses the raw file on demand** when a session is opened. This yields the +same on-screen result with none of the sync/DB/watcher machinery, which is the +right trade for a viewer. + +Consequences accepted for v1: + +- Re-parses on each open (fine — a viewer opens one session at a time; large + sessions are handled by capping + lazy subagent loading, below). +- No cross-session full-text search inside transcripts. +- If the raw file has been deleted, the viewer degrades to vouch's compact + observations (below) rather than failing. + +## Scope + +In scope for v1: + +- Agents: **Claude Code** (`~/.claude/projects//.jsonl`) and + **Codex** (rollouts under `$CODEX_HOME/sessions/...`), on the **same machine** + as the vouch server. +- A new read-only RPC `kb.session_transcript`. +- A new frontend **Sessions** tab (master–detail) that lists sessions and + renders a selected session's transcript at full fidelity. + +Out of scope for v1: + +- Remote / multi-machine sessions whose raw files are not on the server host + (would require a transcript-upload pipeline). +- Persisting or indexing transcripts; cross-session search. +- Editing, pinning, exporting, or analytics over transcripts. + +## Backend + +### New RPC: `kb.session_transcript` + +Params: + +- `session_id` (string, required) — the captured session id. +- `agent` (string, optional) — `"claude"` | `"codex"`. When omitted, the + locator auto-detects by searching both sources. + +Success result (raw file found and parsed): + +```jsonc +{ + "available": true, + "source": { "agent": "claude", "path": "/home/u/.claude/projects/.../.jsonl" }, + "session": { + "id": "…", + "cwd": "…", + "git_branch": "…", + "started_at": "ISO-8601", + "ended_at": "ISO-8601", + "model": "…", + "tokens": { "input": 0, "output": 0, "cache_read": 0, "cache_creation": 0 } + }, + "messages": [ + { + "role": "user" | "assistant", + "id": "…", // message id (assistant), else null + "model": "…", // optional, assistant only + "timestamp": "ISO-8601", // optional + "tokens": { "input": 0, "output": 0, "cache_read": 0, "cache_creation": 0 }, + "blocks": [ + { "type": "text", "text": "…" }, + { "type": "thinking", "text": "…" }, + // tool results are paired into their tool_use block server-side, so + // the frontend renders input + output together (agentsview's ToolBlock). + { "type": "tool_use", "id": "…", "name": "Bash", "input": { }, + "result": { "content": "…", "is_error": false, + "subagent_session_id": null } } // null until paired + ] + } + ], + "truncated": false // true if message cap hit (see limits) +} +``` + +Implementation note: the draft's separate `tool_result` block was dropped in +favor of pairing each `tool_result` into its originating `tool_use` block by id +during the single parse pass. The tool-result-only `user` entries are consumed, +not emitted as standalone messages. This matches agentsview's unified tool +block (input + output shown together) and keeps the frontend renderer simple. + +Degraded result (raw file unavailable — deleted, off-machine, unreadable): + +```jsonc +{ + "available": false, + "reason": "raw transcript not found for session ", + "observations": [ { "ts": "…", "tool": "Edit", "summary": "Edited types.go" } ] +} +``` + +`observations` are read from vouch's existing capture buffer +(`capture._read_observations`) when one still exists for the session; otherwise +an empty list. This is the honest fallback: it is agentsview's *rendering* +degraded to vouch's *data*. + +Errors (structured JSONL envelope, matching every other handler): + +- missing `session_id` → `missing_param` (a bare `p["session_id"]` KeyError, + mapped by the dispatcher). +- `agent` present but not `claude`/`codex` → `invalid_request` (a raised + `ValueError`). + +Registered on all three surfaces to satisfy vouch's parity invariant +(`test_capabilities`): `jsonl_server.HANDLERS`, `capabilities.METHODS`, and the +`kb_session_transcript` MCP tool in `server.py`. `http_server` reuses +`jsonl_server.handle_request`, so it is covered by the JSONL registration. + +Locator env overrides (used by tests, honored in prod): `VOUCH_CLAUDE_PROJECTS_DIR` +re-roots the Claude search; `CODEX_HOME` re-roots the Codex rollout search. + +### New module: `src/vouch/transcript.py` (pure, fixture-tested) + +Responsibilities, each a small pure function: + +1. **Locate** the raw file for `(session_id, agent?)`: + - Claude Code: glob `~/.claude/projects/*/.jsonl` (the file stem + is the session id). Subagent files live at + `~/.claude/projects/*//subagents/**/*.jsonl`. + - Codex: reuse `codex_rollout.find_rollout_by_session_id`. + - Auto-detect tries Claude then Codex. + - Honor `VOUCH_CLAUDE_PROJECTS_DIR` / `CODEX_HOME` overrides for tests. + - `session_id` is validated against a UUID-shaped pattern before any glob so + a hostile id cannot widen the search or traverse the tree. +2. **Parse + normalize** raw lines into the `messages[]` schema above. + - Claude Code line schema (per JSONL entry): `message.role`, + `message.model`, `message.usage.{input_tokens,output_tokens, + cache_read_input_tokens,cache_creation_input_tokens}`, and + `message.content[]` whose parts have `type` ∈ + `{text, thinking, tool_use, tool_result}`. `tool_use` carries + `{id, name, input}`; `tool_result` (in user entries) carries + `{tool_use_id, content, is_error}`. Session-level `cwd`, `gitBranch`, + `timestamp` come from the entries. Subagent linkage via + `toolUseResult.agentId` maps a `tool_result` to its child session id + (mirrors agentsview's `subagentMap`). + - Codex: `codex_rollout.parse_rollout` is lossy (compact observations only), + so a dedicated `parse_codex_transcript` reads the raw `response_item` + records — the canonical conversation stream: `message` (role user → + `input_text`, assistant → `output_text`; developer/system boilerplate + skipped), `function_call` / `custom_tool_call` + their `*_output` pairs, + and `reasoning` (encrypted, so dropped). `session_meta` supplies + `id`/`cwd`/`git.branch`/`timestamp`. `event_msg` records are UI mirrors and + ignored to avoid duplication. + - Malformed lines are skipped, not fatal (matches the existing stream + parser's tolerance). +3. **Limits** (protect the server + browser): + - Max file size read (config const, e.g. 25 MB); over → degraded result with + reason. + - Max messages returned (config const, e.g. 2000); over → `truncated: true`. + - Per-block content is passed through; very large tool outputs are the + browser's problem to collapse, not the server's to trim (agentsview keeps + full content; we match). + +Subagents are fetched lazily by a **second** `kb.session_transcript` call with +the child `session_id` (the frontend passes `subagent_session_id`). The backend +locator finds `~/.claude/projects/*//subagents/**/.jsonl` as well +as top-level files, so the same RPC serves both. + +### Wiring + +- Handler `_h_session_transcript(p)` registered in + `src/vouch/jsonl_server.py` `HANDLERS` (and the HTTP surface in + `http_server.py` if it maintains its own map). +- Add `"kb.session_transcript"` to `src/vouch/capabilities.py` method list + (the `test_capabilities` drift test enforces this). +- Read-only: never calls `approve`/`propose`; unaffected by the review gate. + +## Frontend (`webapp`, React + Tailwind v4) + +Port agentsview's block vocabulary into React, styled with vouch's existing +semantic tokens (`paper` / `ink` / `accent` / `sepia` / `rule` / `ok`), reusing +the existing `Markdown` component for text blocks and `lucide-react` icons. + +### Route + entry point + +- New route `/sessions` and `/sessions/:id` in `App.tsx`; a **Sessions** entry + in the nav (`Shell`), gated on `hasMethod('kb.session_transcript')` like the + other capability-gated tabs. +- `SessionsView` — master–detail. Left: list from `kb.list_sessions` + (`useFanout`), newest first; rows with a non-null `session_id` are openable, + null ones are shown disabled. Right: `TranscriptView` for the selected id. + +### Components (each maps to an agentsview equivalent) + +| vouch (new) | agentsview reference | behavior | +| --- | --- | --- | +| `TranscriptView` | `MessageList` | fetches `kb.session_transcript(id)`, renders `messages[]`; shows session vitals header (model, tokens, cwd, branch) | +| `MessageBlock` | `MessageContent` | role chrome, model badge, tokens, timestamp; dispatches blocks | +| `ThinkingBlock` | `ThinkingBlock` | collapsible "Thinking" | +| `ToolBlock` | `ToolBlock` | collapsible; per-tool rendering; error styling | +| `DiffView` | ToolBlock diff-view | +/− line rendering for Edit/Write | +| `CodeBlock` | `CodeBlock` | fenced code | +| `TextBlock` | markdown path | reuse existing `Markdown` | + +Per-tool rendering inside `ToolBlock` (parity with agentsview): + +- `Bash` / `run_command` → command line + collapsible stdout. +- `Edit` / `Update` / `MultiEdit` → `DiffView`. +- `Write` → created-file content. +- `Read` / `Grep` / `Glob` → compact summary (path/pattern) + collapsible body. +- `Task` / `Agent` → labeled subagent step; when the paired result carries a + `subagent_session_id`, a **"view subagent"** button pushes the child onto an + in-`TranscriptView` back-stack and re-fetches `kb.session_transcript(child)`. +- Unknown tools (and every tool's raw input) → collapsible pretty-printed JSON. + +Shipped simplification: `Read`/`Grep`/`Glob` render a one-line headline plus the +collapsible output; a dedicated `TodoWrite` checklist renderer was deferred +(TodoWrite falls through to the JSON input view). Easy follow-up if wanted. + +### Degraded rendering + +When `available === false`, `TranscriptView` shows a notice ("original +transcript unavailable — showing captured activity") and renders the +`observations` as a compact tool timeline. + +### Client library + +- `webapp/src/lib/transcript.ts` — types for the normalized schema + a thin + `fetchTranscript(conn, id)` wrapper over `rpc('kb.session_transcript', …)`. + No new transport; reuses `/proxy/rpc`. + +## Error handling summary + +- Unknown / null session id → structured RPC error surfaced as a `Toast` + an + `ErrorCard` in the detail pane. +- File missing/oversized/unreadable → `available:false` degraded result. +- Malformed transcript lines → skipped in the parser. +- Endpoint doesn't advertise the method → nav hidden / disabled (capability + gate), never a hard failure. + +## Testing + +Backend (vouch conventions: `pytest`, `mypy src`, `ruff check`): + +- `tests/test_session_transcript.py` — table/fixture tests over the parser with + small committed **Claude Code** and **Codex** JSONL fixtures: text, thinking, + tool_use→tool_result pairing, is_error, subagent linkage, model/tokens, cwd/ + branch extraction; malformed-line tolerance; size/message caps → `truncated`. +- Locator tests using `VOUCH_CLAUDE_PROJECTS_DIR` / `CODEX_HOME` pointed at + `tmp_path` fixtures; auto-detect order; subagent-file resolution. +- Degradation test: no raw file, buffer present → `available:false` + + observations; no raw file, no buffer → empty observations. +- RPC envelope test `tests/test_session_transcript.py` asserting the JSONL + envelope shape; capabilities drift covered by `test_capabilities`. + +Frontend (`vitest` + Testing Library; one Playwright smoke): + +- Component tests per block (`ToolBlock` per-tool branches incl. `DiffView`, + `ThinkingBlock` collapse, `Task` subagent lazy-load with a mocked rpc), + `TranscriptView` happy path + degraded path, `SessionsView` list + disabled + null rows. +- `webapp/e2e/` smoke: open Sessions, pick a row, see the rendered transcript. + It stubs `/proxy/*` via Playwright `page.route` (health, capabilities, + `kb.list_pending`, `kb.list_sessions`, `kb.session_transcript`) so it drives + the real frontend independent of the backend build — the local `vouch` on + PATH is an editable install of a different checkout without the new RPC. +- Tests assert rendered behavior, not implementation strings + (`testing-without-tautologies`). + +## Conventions / guardrails (this repo) + +- Follow `vouch` `AGENTS.md`, not agentsview's `CLAUDE.md`: conventional + commits `(): …`; run `pytest --ignore=tests/embeddings`, + `mypy src`, `ruff check src tests` before shipping. **No + `Co-Authored-By: ` trailer.** No secrets/paths-as-PII in commits. +- Work on a feature branch (ask before creating it); do not commit to `main` + without permission; do not merge. + +## Phasing + +1. Backend: `transcript.py` (Claude locator + parser) + RPC + capabilities + + tests. +2. Frontend: `SessionsView` + `TranscriptView` + block components + client lib + + tests, wired to phase-1 RPC. +3. Codex source (reuse `codex_rollout`) + subagent lazy expansion + degraded + fallback + e2e smoke. + +## Resolved (as shipped) + +- Entry point: a dedicated **Sessions** tab in the console nav + (`ScrollText` icon), gated per-row on `hasMethod('kb.session_transcript')`. +- Layout: master–detail — session list on the left, transcript on the right. +- Codex shipped in v1 (phase 3) alongside Claude Code, via a dedicated + `response_item` parser (not the lossy `parse_rollout`). + +## Deferred (possible follow-ups) + +- Dedicated `TodoWrite` checklist renderer (currently the JSON fallback). +- Rendering `reasoning`/`thinking` when a session persists plaintext (VSCode/SDK + sessions store only the encrypted signature, so thinking blocks are dropped). +- Remote / multi-machine transcript access (still out of scope). From ac6dd0aa9a5f4618898bd587caa31d0c6caf53b4 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:48:46 +0900 Subject: [PATCH 13/15] refactor(webapp): merge session transcript into Review, drop Sessions tab --- ...ions.spec.ts => review-transcript.spec.ts} | 8 +- webapp/src/App.tsx | 2 - webapp/src/components/Shell.tsx | 6 +- webapp/src/views/ReviewView.test.tsx | 74 +++++++- webapp/src/views/ReviewView.tsx | 175 ++++++++++-------- webapp/src/views/SessionsView.test.tsx | 83 --------- webapp/src/views/SessionsView.tsx | 71 ------- 7 files changed, 169 insertions(+), 250 deletions(-) rename webapp/e2e/{sessions.spec.ts => review-transcript.spec.ts} (91%) delete mode 100644 webapp/src/views/SessionsView.test.tsx delete mode 100644 webapp/src/views/SessionsView.tsx diff --git a/webapp/e2e/sessions.spec.ts b/webapp/e2e/review-transcript.spec.ts similarity index 91% rename from webapp/e2e/sessions.spec.ts rename to webapp/e2e/review-transcript.spec.ts index d6759e8c..03a02464 100644 --- a/webapp/e2e/sessions.spec.ts +++ b/webapp/e2e/review-transcript.spec.ts @@ -1,6 +1,6 @@ import { expect, test } from '@playwright/test' -// Drives the real frontend (routing, SessionsView, TranscriptView, block +// Drives the real frontend (routing, ReviewView, TranscriptView, block // renderers) against stubbed /proxy responses, so it is independent of the // backend build. The connection is seeded into localStorage; health and // capabilities are stubbed so the project comes up "ok" with the transcript @@ -10,11 +10,11 @@ const CAPS = { name: 'vouch', version: '1.2.2', level: 3, - methods: ['kb.list_sessions', 'kb.session_transcript'], + methods: ['kb.list_sessions', 'kb.summarize_session', 'kb.session_transcript'], review_gated: true, } -test('sessions tab renders a picked transcript', async ({ page }) => { +test('review tab renders a picked session transcript', async ({ page }) => { await page.addInitScript((endpoint) => { localStorage.setItem( 'vouch-ui.connections.v2', @@ -89,7 +89,7 @@ test('sessions tab renders a picked transcript', async ({ page }) => { return route.fulfill({ json: { ok: true, id: '1', result: {} } }) }) - await page.goto('/sessions') + await page.goto('/review') await page.getByText('e2e session').click() await expect(page.getByText('e2e transcript body')).toBeVisible() }) diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index b3d0f228..76860246 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -8,7 +8,6 @@ import { ChatView } from './views/ChatView' import { ClaimsView } from './views/ClaimsView' import { PendingView } from './views/PendingView' import { ReviewView } from './views/ReviewView' -import { SessionsView } from './views/SessionsView' import { StatsView } from './views/StatsView' export default function App() { @@ -25,7 +24,6 @@ export default function App() { } /> } /> } /> - } /> } /> diff --git a/webapp/src/components/Shell.tsx b/webapp/src/components/Shell.tsx index da5edb2d..443f0621 100644 --- a/webapp/src/components/Shell.tsx +++ b/webapp/src/components/Shell.tsx @@ -1,4 +1,4 @@ -import { Activity, BadgeCheck, FileClock, Inbox, Library, MessageSquare, Plug, ScrollText, SunMoon } from 'lucide-react' +import { Activity, BadgeCheck, FileClock, Inbox, Library, MessageSquare, Plug, SunMoon } from 'lucide-react' import { useEffect, useState } from 'react' import { NavLink, Outlet, useLocation } from 'react-router-dom' import { ConnectDialog } from '../connection/ConnectDialog' @@ -14,17 +14,15 @@ const NAV = [ { to: '/pending', label: 'Pending', icon: Inbox }, { to: '/claims', label: 'Claims', icon: BadgeCheck }, { to: '/browse', label: 'Browse', icon: Library }, - { to: '/sessions', label: 'Sessions', icon: ScrollText }, { to: '/stats', label: 'Stats', icon: Activity }, ] const TITLES: Record = { '/chat': 'Chat', - '/review': 'Review — sessions to summarize', + '/review': 'Review — session transcripts & summaries', '/pending': 'Pending review', '/claims': 'Approved claims', '/browse': 'Knowledge', - '/sessions': 'Session transcripts', '/stats': 'Stats & health', } diff --git a/webapp/src/views/ReviewView.test.tsx b/webapp/src/views/ReviewView.test.tsx index 143ef6e7..c72014fc 100644 --- a/webapp/src/views/ReviewView.test.tsx +++ b/webapp/src/views/ReviewView.test.tsx @@ -17,6 +17,11 @@ const CAPS = { review_gated: true, } +const CAPS_WITH_TRANSCRIPT = { + ...CAPS, + methods: ['kb.list_sessions', 'kb.summarize_session', 'kb.session_transcript'], +} + const SESSIONS = { sessions: [ { @@ -60,27 +65,78 @@ beforeEach(() => { seedConnection() }) -test('shows the empty state when nothing awaits a summary', async () => { +test('shows the empty state when there are no sessions', async () => { vi.mocked(rpc).mockResolvedValue({ sessions: [] }) renderWithProviders() - expect(await screen.findByText(/no sessions waiting for a summary/i)).toBeInTheDocument() + expect(await screen.findByText(/no captured sessions/i)).toBeInTheDocument() }) -test('lists only unsummarized sessions, with stage badges and metadata', async () => { +test('lists all sessions including already-summarized ones', async () => { vi.mocked(rpc).mockResolvedValue(SESSIONS) renderWithProviders() // title fallback chain: title, then session_id expect(await screen.findByText('session: fix the parser')).toBeInTheDocument() expect(screen.getAllByText('sess-open').length).toBeGreaterThan(0) - expect(screen.queryByText('session: already summarized')).not.toBeInTheDocument() - // stage badges + // summarized sessions are now shown too (viewable, read-only) + expect(screen.getByText('session: already summarized')).toBeInTheDocument() + // stage / summarized badges expect(screen.getByText('open buffer')).toBeInTheDocument() expect(screen.getByText('needs summary')).toBeInTheDocument() + expect(screen.getByText('summarized')).toBeInTheDocument() // observation counts and sliced timestamps expect(screen.getByText(/12 observations/)).toBeInTheDocument() expect(screen.getByText(/2026-07-04 11:30:00/)).toBeInTheDocument() }) +test('renders the transcript for the selected session and lets you summarize it', async () => { + vi.mocked(fetchCapabilities).mockResolvedValue(CAPS_WITH_TRANSCRIPT) + vi.mocked(rpc).mockImplementation(async (_c, method) => { + if (method === 'kb.list_sessions') return SESSIONS + if (method === 'kb.session_transcript') { + return { + available: true, + source: { agent: 'claude', path: '/x' }, + session: { + id: 'sess-filed', + agent: 'claude', + cwd: '/repo', + git_branch: 'main', + title: 'session: fix the parser', + started_at: null, + ended_at: null, + model: 'claude-opus-4-8', + tokens: { input: 1, output: 1, cache_read: 0, cache_creation: 0 }, + }, + messages: [ + { + role: 'assistant', + id: 'm1', + model: 'claude-opus-4-8', + timestamp: null, + tokens: null, + blocks: [{ type: 'text', text: 'transcript in review pane' }], + }, + ], + truncated: false, + } + } + if (method === 'kb.summarize_session') + return { session_id: 'sess-filed', summarized: true, proposal_id: 'prop-1' } + throw new Error(`unexpected ${method}`) + }) + renderWithProviders() + await userEvent.click(await screen.findByText('session: fix the parser')) + // transcript renders in the detail pane... + expect(await screen.findByText('transcript in review pane')).toBeInTheDocument() + // ...and Summarize still works from the same pane + await userEvent.click(screen.getByRole('button', { name: 'Summarize' })) + await waitFor(() => + expect(rpc).toHaveBeenCalledWith(expect.anything(), 'kb.summarize_session', { + session_id: 'sess-filed', + }), + ) +}) + test('Summarize calls kb.summarize_session for the selected session and refetches', async () => { vi.mocked(rpc).mockImplementation(async (_c, method) => { if (method === 'kb.list_sessions') return SESSIONS @@ -90,7 +146,7 @@ test('Summarize calls kb.summarize_session for the selected session and refetche }) renderWithProviders() await userEvent.click(await screen.findByText('session: fix the parser')) - await userEvent.click(screen.getByRole('button', { name: /summarize/i })) + await userEvent.click(screen.getByRole('button', { name: 'Summarize' })) await waitFor(() => expect(rpc).toHaveBeenCalledWith(expect.anything(), 'kb.summarize_session', { session_id: 'sess-filed', @@ -114,7 +170,7 @@ test('a skipped result surfaces an error toast naming the reason', async () => { }) renderWithProviders() await userEvent.click(await screen.findByText('session: fix the parser')) - await userEvent.click(screen.getByRole('button', { name: /summarize/i })) + await userEvent.click(screen.getByRole('button', { name: 'Summarize' })) expect( await screen.findByText(/not-configured: set capture\.summary_llm_cmd/i), ).toBeInTheDocument() @@ -129,7 +185,7 @@ test('an rpc error surfaces an error toast', async () => { }) renderWithProviders() await userEvent.click(await screen.findByText('session: fix the parser')) - await userEvent.click(screen.getByRole('button', { name: /summarize/i })) + await userEvent.click(screen.getByRole('button', { name: 'Summarize' })) expect(await screen.findByText(/internal_error: summary command exited 1/i)).toBeInTheDocument() }) @@ -145,6 +201,6 @@ test('hides the Summarize button when kb.summarize_session is not advertised', a vi.mocked(rpc).mockResolvedValue(SESSIONS) renderWithProviders() await userEvent.click(await screen.findByText('session: fix the parser')) - expect(screen.queryByRole('button', { name: /summarize/i })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Summarize' })).not.toBeInTheDocument() expect(screen.getByText(/kb\.summarize_session is not advertised/i)).toBeInTheDocument() }) diff --git a/webapp/src/views/ReviewView.tsx b/webapp/src/views/ReviewView.tsx index 30f14094..378f812b 100644 --- a/webapp/src/views/ReviewView.tsx +++ b/webapp/src/views/ReviewView.tsx @@ -9,12 +9,18 @@ import type { ProjectState } from '../connection/ConnectionContext' import { useFanout } from '../lib/fanout' import { rpc, VouchRpcError } from '../lib/rpc' import type { SessionEntry } from '../lib/types' +import { TranscriptView } from './TranscriptView' const STAGE_LABEL: Record = { buffer: 'open buffer', pending: 'needs summary', } +/** Badge text for a row: summarized wins over the raw capture stage. */ +function stageLabel(s: SessionEntry): string { + return s.summarized ? 'summarized' : STAGE_LABEL[s.stage] +} + /** Hints for the skip reasons kb.summarize_session can return instead of a summary. */ const SKIP_HINTS: Record = { 'not-configured': 'set capture.summary_llm_cmd in .vouch/config.yaml', @@ -47,12 +53,18 @@ export function ReviewView() { }) useErrorToast(sessions.errors.length > 0, sessions.errors[0]?.error) - const rows: Row[] = sessions.rows - .flatMap((r) => (r.data?.sessions ?? []).map((s) => ({ project: r.project, s }))) - .filter((r) => !r.s.summarized) + const rows: Row[] = sessions.rows.flatMap((r) => + (r.data?.sessions ?? []).map((s) => ({ project: r.project, s })), + ) const selected = rows.find((r) => rowKey(r) === selectedKey) ?? null const canSummarize = - !!selected && hasMethod('kb.summarize_session', selected.project.conn.endpoint) + !!selected && + !selected.s.summarized && + hasMethod('kb.summarize_session', selected.project.conn.endpoint) + const canTranscript = + !!selected && + !!selected.s.session_id && + hasMethod('kb.session_transcript', selected.project.conn.endpoint) const summarize = useMutation({ mutationFn: (row: Row) => @@ -104,8 +116,8 @@ export function ReviewView() { if (rows.length === 0) { return ( ) } @@ -129,7 +141,7 @@ export function ReviewView() { )} - {STAGE_LABEL[r.s.stage]} + {stageLabel(r.s)} {r.s.observations !== null && ( {r.s.observations} observations @@ -146,84 +158,93 @@ export function ReviewView() { -
+
{!selected ? ( - +
+ +
) : ( -
-
- {aggregated && ( - - {selected.project.label} + <> +
+
+ {aggregated && ( + + {selected.project.label} + + )} + + {stageLabel(selected.s)} - )} - - {STAGE_LABEL[selected.s.stage]} - - - {selected.s.session_id ?? '(no session id)'} - -
+ + {selected.s.session_id ?? '(no session id)'} + + {selected.s.observations !== null && ( + {selected.s.observations} observations + )} + {selected.s.last_activity && ( + + {selected.s.last_activity.slice(0, 19).replace('T', ' ')} + + )} +
-

{rowTitle(selected.s)}

+

{rowTitle(selected.s)}

-
-
-
stage
-
{STAGE_LABEL[selected.s.stage]}
-
- {selected.s.proposal_id && ( -
-
proposal
-
{selected.s.proposal_id}
-
- )} - {selected.s.observations !== null && ( -
-
observations
-
{selected.s.observations}
-
- )} - {selected.s.last_activity && ( -
-
last activity
-
{selected.s.last_activity.slice(0, 19).replace('T', ' ')}
+ {selected.s.summarized ? ( +

+ Already summarized — its proposal is in Pending for review. +

+ ) : canSummarize ? ( +
+ + + {summarize.isPending + ? 'running the configured LLM — this can take a minute' + : selected.s.session_id + ? 'runs the configured LLM over the capture; the summary lands in Pending' + : 'no session id recorded — this capture cannot be summarized'} +
+ ) : ( +

+ This endpoint cannot summarize — kb.summarize_session is not advertised. +

)} -
- - {!canSummarize ? ( -

- This endpoint cannot summarize — kb.summarize_session is not advertised. -

- ) : ( - <> - -

- {summarize.isPending - ? 'running the configured LLM — this can take a minute' - : selected.s.session_id - ? 'runs the configured LLM over the capture; the summary lands in Pending' - : 'no session id recorded — this capture cannot be summarized'} +

+ +
+ {canTranscript ? ( + + ) : ( +

+ {selected.s.session_id + ? 'Transcript view is not available on this endpoint — kb.session_transcript is not advertised.' + : 'No transcript — this capture has no recorded session id.'}

- - )} -
+ )} +
+ )}
diff --git a/webapp/src/views/SessionsView.test.tsx b/webapp/src/views/SessionsView.test.tsx deleted file mode 100644 index 62d128d8..00000000 --- a/webapp/src/views/SessionsView.test.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { screen, waitFor } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock('../lib/rpc', async () => { - const actual = await vi.importActual('../lib/rpc') - return { ...actual, rpc: vi.fn(), fetchHealth: vi.fn(), fetchCapabilities: vi.fn() } -}) -import { fetchCapabilities, fetchHealth, rpc } from '../lib/rpc' -import { renderWithProviders, seedConnection } from '../test/utils' -import { SessionsView } from './SessionsView' - -const CAPS = { - name: 'vouch', - version: '1', - level: 3, - methods: ['kb.list_sessions', 'kb.session_transcript'], - review_gated: true, -} - -beforeEach(() => { - localStorage.clear() - vi.clearAllMocks() - vi.mocked(fetchHealth).mockResolvedValue(true) - vi.mocked(fetchCapabilities).mockResolvedValue(CAPS as never) - seedConnection() -}) - -describe('SessionsView', () => { - it('lists sessions and renders a picked transcript', async () => { - vi.mocked(rpc).mockImplementation(async (_c, method) => { - if (method === 'kb.list_sessions') { - return { - sessions: [ - { - session_id: 'sid-1', - stage: 'buffer', - proposal_id: null, - kind: null, - title: 'Fix parser', - summarized: false, - observations: 3, - last_activity: '2026-07-10T00:00:00Z', - }, - ], - } - } - if (method === 'kb.session_transcript') { - return { - available: true, - source: { agent: 'claude', path: '/x' }, - session: { - id: 'sid-1', - agent: 'claude', - cwd: '/repo', - git_branch: 'main', - title: 'Fix parser', - started_at: null, - ended_at: null, - model: 'claude-opus-4-8', - tokens: { input: 1, output: 1, cache_read: 0, cache_creation: 0 }, - }, - messages: [ - { - role: 'assistant', - id: 'm1', - model: 'claude-opus-4-8', - timestamp: null, - tokens: null, - blocks: [{ type: 'text', text: 'hello from claude' }], - }, - ], - truncated: false, - } - } - return {} - }) - renderWithProviders(, { route: '/sessions' }) - await waitFor(() => expect(screen.getByText('Fix parser')).toBeInTheDocument()) - await userEvent.click(screen.getByText('Fix parser')) - await waitFor(() => expect(screen.getByText('hello from claude')).toBeInTheDocument()) - }) -}) diff --git a/webapp/src/views/SessionsView.tsx b/webapp/src/views/SessionsView.tsx deleted file mode 100644 index 2f2cc60b..00000000 --- a/webapp/src/views/SessionsView.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { useState } from 'react' -import { EmptyState } from '../components/EmptyState' -import { useConnection } from '../connection/ConnectionContext' -import { useFanout } from '../lib/fanout' -import type { SessionEntry, VouchConnectionInfo } from '../lib/types' -import { TranscriptView } from './TranscriptView' - -interface Row { - conn: VouchConnectionInfo - label: string - s: SessionEntry -} - -export function SessionsView() { - const { hasMethod } = useConnection() - const sessions = useFanout<{ sessions: SessionEntry[] }>(['sessions'], 'kb.list_sessions', {}, { - refetchInterval: 10_000, - }) - const rows: Row[] = sessions.rows.flatMap((r) => - (r.data?.sessions ?? []).map((s) => ({ conn: r.project.conn, label: r.project.label, s })), - ) - const [sel, setSel] = useState(null) - - return ( -
- -
- {sel && sel.s.session_id ? ( - - ) : ( -
- -
- )} -
-
- ) -} From 776bffb90fd478bc16c74faf445cbd48769cbcf6 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:50:04 +0900 Subject: [PATCH 14/15] docs(transcript): note the merge of the viewer into Review --- ...-07-10-session-transcript-viewer-design.md | 48 +++++++++++++------ 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/docs/superpowers/specs/2026-07-10-session-transcript-viewer-design.md b/docs/superpowers/specs/2026-07-10-session-transcript-viewer-design.md index 5fcb87d0..63d65bef 100644 --- a/docs/superpowers/specs/2026-07-10-session-transcript-viewer-design.md +++ b/docs/superpowers/specs/2026-07-10-session-transcript-viewer-design.md @@ -59,8 +59,10 @@ In scope for v1: **Codex** (rollouts under `$CODEX_HOME/sessions/...`), on the **same machine** as the vouch server. - A new read-only RPC `kb.session_transcript`. -- A new frontend **Sessions** tab (master–detail) that lists sessions and - renders a selected session's transcript at full fidelity. +- Full-fidelity transcript rendering in the console's **Review** page + (master–detail): the session list plus the selected session's transcript and + its `Summarize` action, side by side. (Originally shipped as a standalone + **Sessions** tab, then merged into Review — see the update note below.) Out of scope for v1: @@ -216,12 +218,18 @@ the existing `Markdown` component for text blocks and `lucide-react` icons. ### Route + entry point -- New route `/sessions` and `/sessions/:id` in `App.tsx`; a **Sessions** entry - in the nav (`Shell`), gated on `hasMethod('kb.session_transcript')` like the - other capability-gated tabs. -- `SessionsView` — master–detail. Left: list from `kb.list_sessions` - (`useFanout`), newest first; rows with a non-null `session_id` are openable, - null ones are shown disabled. Right: `TranscriptView` for the selected id. +Post-merge (see the update note below), the viewer lives in the existing +**Review** page rather than a separate tab: + +- No new route or nav item. `ReviewView` (`/review`) is the single sessions + surface. Left: all sessions from `kb.list_sessions` (`useFanout`) — the + earlier `!summarized` filter was dropped so every session's transcript stays + viewable. Right (detail pane): a compact header with the session's stage / + ids / `Summarize` action, and below it `TranscriptView` for the selected + session — gated on `hasMethod('kb.session_transcript')` and a non-null + `session_id`, degrading to a note otherwise. +- The `Summarize` action shows only for sessions that still need it + (`!summarized`); already-summarized rows render read-only. ### Components (each maps to an agentsview equivalent) @@ -290,8 +298,9 @@ Frontend (`vitest` + Testing Library; one Playwright smoke): - Component tests per block (`ToolBlock` per-tool branches incl. `DiffView`, `ThinkingBlock` collapse, `Task` subagent lazy-load with a mocked rpc), - `TranscriptView` happy path + degraded path, `SessionsView` list + disabled - null rows. + `TranscriptView` happy path + degraded path + subagent drill-down, + `ReviewView` (all sessions listed incl. summarized, transcript in the detail + pane, `Summarize` still works alongside it). - `webapp/e2e/` smoke: open Sessions, pick a row, see the rendered transcript. It stubs `/proxy/*` via Playwright `page.route` (health, capabilities, `kb.list_pending`, `kb.list_sessions`, `kb.session_transcript`) so it drives @@ -313,16 +322,25 @@ Frontend (`vitest` + Testing Library; one Playwright smoke): 1. Backend: `transcript.py` (Claude locator + parser) + RPC + capabilities + tests. -2. Frontend: `SessionsView` + `TranscriptView` + block components + client lib - + tests, wired to phase-1 RPC. +2. Frontend: `TranscriptView` + block components + client lib + tests, wired to + phase-1 RPC. (Initially surfaced via a `SessionsView` tab.) 3. Codex source (reuse `codex_rollout`) + subagent lazy expansion + degraded fallback + e2e smoke. +4. Merge: fold the transcript into `ReviewView`, drop the `!summarized` filter, + and remove the standalone Sessions tab (this iteration). ## Resolved (as shipped) -- Entry point: a dedicated **Sessions** tab in the console nav - (`ScrollText` icon), gated per-row on `hasMethod('kb.session_transcript')`. -- Layout: master–detail — session list on the left, transcript on the right. +- Entry point: **merged into the Review page** (`/review`). The initial cut + shipped a standalone **Sessions** tab, but Review already listed the same + `kb.list_sessions` sessions (to summarize them) with no transcript, so the two + were redundant. Review now renders the transcript in its detail pane next to + the `Summarize` action; the Sessions tab/route/view were removed. +- List scope: Review shows **all** sessions (its `!summarized` filter was + dropped), so transcripts of already-summarized sessions stay viewable; the + `Summarize` action is contextual (only for sessions that still need it). +- Layout: master–detail — session list on the left, transcript + summarize on + the right. - Codex shipped in v1 (phase 3) alongside Claude Code, via a dedicated `response_item` parser (not the lossy `parse_rollout`). From faa8abdc789b446ff70c287bbc6042f08d47b4a2 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:11:23 +0900 Subject: [PATCH 15/15] feat(console): serve the react web console from the installed package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit add `vouch console`: a starlette app that serves the vendored webapp SPA plus a same-origin /proxy bridge to `vouch serve --transport http` backends — a python port of the vite dev-proxy (loopback-guarded, reads X-Vouch-Target, passes backend statuses through unmasked). the built SPA is bundled into the wheel as vouch/web/console via a conditional hatch build hook, so `pip install 'vouch-kb[web]'` then `vouch console` needs no node and no repo clone. wire the release workflow to build the webapp before packaging and build the wheel from the working tree so the console rides inside it. bump to 1.3.0 across the four version sites, document the pip path in the readme, and add a changelog entry. --- .github/workflows/release.yml | 11 +- CHANGELOG.md | 8 ++ README.md | 23 +++- hatch_build.py | 27 ++++ openclaw.plugin.json | 2 +- package.json | 2 +- pyproject.toml | 10 +- src/vouch/__init__.py | 2 +- src/vouch/cli.py | 65 ++++++++++ src/vouch/web/__init__.py | 20 +++ src/vouch/web/console.py | 174 +++++++++++++++++++++++++ tests/test_console.py | 238 ++++++++++++++++++++++++++++++++++ 12 files changed, 574 insertions(+), 8 deletions(-) create mode 100644 hatch_build.py create mode 100644 src/vouch/web/console.py create mode 100644 tests/test_console.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 482b5eb2..bd3c7d5c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,11 +23,20 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + # Build the React console so the wheel can bundle it as vouch/web/console + # (hatch_build.py force-includes webapp/dist when present). + - uses: actions/setup-node@v4 + with: + node-version: "20" + - run: npm --prefix webapp ci && npm --prefix webapp run build - uses: actions/setup-python@v5 with: python-version: "3.12" - run: python -m pip install --upgrade build - - run: python -m build + # sdist is source-only; the wheel is built from the working tree (not the + # sdist) so the freshly-built, gitignored webapp/dist rides inside it. + - run: python -m build --sdist + - run: python -m build --wheel - uses: actions/upload-artifact@v7 with: name: dist diff --git a/CHANGELOG.md b/CHANGELOG.md index 986dcb0e..050b2507 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ All notable changes to vouch are documented here. Format follows ## [Unreleased] +### Added +- `vouch console`: serve the vendored React web console straight from the + installed package — a same-origin `/proxy` bridge (loopback-guarded) to + `vouch serve --transport http` backends, reimplementing the vite dev-proxy + in python. the built SPA is bundled into the wheel as `vouch/web/console` + (conditionally, via a hatch build hook), so `pip install 'vouch-kb[web]'` + then `vouch console` needs no node and no repo clone. + ## [1.2.2] — 2026-07-07 ### Packaging diff --git a/README.md b/README.md index 8624883d..a8ce7a5c 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,16 @@ docker run --rm -p 127.0.0.1:5173:5173 -v vouch-demo-data:/data ghcr.io/plind-ju Pre-seeded KB + full webapp console, zero setup. Pass `-e ANTHROPIC_API_KEY=sk-ant-...` to enable LLM features. +**For the full UI without Docker** — Python only, no clone, no node: + +```bash +pipx install 'vouch-kb[web]' # the browser console ships inside the wheel +vouch serve --transport http --port 8731 & # a backend for the current .vouch/ +vouch console # console at http://localhost:5173 — connect it to :8731 +``` + +`vouch console` serves the same React console as the Docker demo, straight from the installed package. + **For CLI + Claude Code integration** (most common ongoing workflow): ```bash @@ -93,9 +103,10 @@ compile: vouch review # walk pending proposals one at a time ``` -**Want a browser UI for reviewing and proposing?** The video shows the **vouch webapp** — chat, review queue, claims, and stats. You have three options: +**Want a browser UI for reviewing and proposing?** The video shows the **vouch webapp** — chat, review queue, claims, and stats. You have four options: - **No setup**: Use the Docker demo (recommended) +- **pip, no clone**: `pipx install 'vouch-kb[web]'` then `vouch console` — serves the same React console from the installed package (Python only, no Docker, no node), open http://localhost:5173 - **Local development**: Clone the repo, run `make console`, open http://localhost:5173 - **CLI-only**: Use `vouch review`, `vouch show `, `vouch approve ` commands instead @@ -113,7 +124,13 @@ docker run --rm -p 127.0.0.1:5173:5173 \ # then open http://localhost:5173 ``` -Or to skip Docker entirely and use the CLI tools: +Or serve that same console with no Docker — `vouch console` in place of Terminal 2 (needs the `[web]` extra), then add the `:8731` backend in the connect dialog: + +```bash +vouch console # http://localhost:5173, proxying to the server above +``` + +Or to skip the browser entirely and use the CLI tools: ```bash vouch review # walk pending proposals @@ -122,7 +139,7 @@ vouch approve # approve a proposal vouch reject --reason "…" # reject with feedback ``` -Lighter alternatives ship with vouch itself: `vouch review-ui` (a built-in browser queue; `pipx install 'vouch-kb[web]'` for the extra), or piecemeal `vouch pending`, `vouch show `, `vouch approve `, `vouch reject --reason "…"`. +Both browser UIs ship with vouch under the `[web]` extra (`pipx install 'vouch-kb[web]'`): `vouch console` is the full React console shown in the video; `vouch review-ui` is a lighter built-in review queue. Or go piecemeal: `vouch pending`, `vouch show `, `vouch approve `, `vouch reject --reason "…"`. **5. Compile the wiki.** diff --git a/hatch_build.py b/hatch_build.py new file mode 100644 index 00000000..c47df8d6 --- /dev/null +++ b/hatch_build.py @@ -0,0 +1,27 @@ +"""Bundle the built React console (webapp/dist) into the wheel. + +When webapp/dist has been built, ship it as ``vouch/web/console`` so +``pip install 'vouch-kb[web]'`` + ``vouch console`` serves it with no node. +When it has NOT been built (a fresh checkout, or the sdist -> wheel path where +the gitignored dist isn't present), skip it silently: the wheel still builds, +and ``vouch console`` reports the missing console cleanly. This is why the +include is a hook rather than a static ``force-include``, which hard-errors on +a missing source path. +""" + +from __future__ import annotations + +import os +from typing import Any + +from hatchling.builders.hooks.plugin.interface import BuildHookInterface + + +class ConsoleBundleHook(BuildHookInterface): + PLUGIN_NAME = "custom" + + def initialize(self, version: str, build_data: dict[str, Any]) -> None: + dist = os.path.join(self.root, "webapp", "dist") + if not os.path.isfile(os.path.join(dist, "index.html")): + return # not built — degrade gracefully rather than fail the build + build_data.setdefault("force_include", {})[dist] = "vouch/web/console" diff --git a/openclaw.plugin.json b/openclaw.plugin.json index ffcd470d..a21192b1 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -1,7 +1,7 @@ { "id": "vouch", "name": "Vouch", - "version": "1.2.2", + "version": "1.3.0", "description": "Git-native, review-gated knowledge base. Registers vouch's context engine (cited retrieval + salience reflex + hot memory) and the vouch skills. The kb.* MCP server is deployment config: `openclaw mcp add vouch -- vouch serve`.", "kind": "context-engine", "skills": [ diff --git a/package.json b/package.json index 9ccee39b..f62c5cb0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vouch", - "version": "1.2.2", + "version": "1.3.0", "private": true, "description": "OpenClaw plugin packaging for vouch. The Python package lives in pyproject.toml; this file only tells OpenClaw's plugin loader which entry module to import and which plugin API range the plugin supports.", "openclaw": { diff --git a/pyproject.toml b/pyproject.toml index 624cf336..c15a6260 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "vouch-kb" -version = "1.2.2" +version = "1.3.0" description = "Git-native, review-gated knowledge base for LLM agents. MCP server + CLI." readme = "README.md" requires-python = ">=3.11" @@ -82,6 +82,14 @@ packages = ["src/vouch"] [tool.hatch.build.targets.wheel.force-include] "adapters" = "vouch/adapters" +# The built React console (webapp/dist) rides inside the wheel as +# vouch/web/console so `pip install 'vouch-kb[web]'` + `vouch console` serves it +# with no node. It is force-included *conditionally* by hatch_build.py — a plain +# force-include hard-errors when webapp/dist isn't built (fresh checkout, sdist +# → wheel), whereas the hook skips it and `vouch console` reports it cleanly. +[tool.hatch.build.targets.wheel.hooks.custom] +path = "hatch_build.py" + [tool.ruff] line-length = 100 target-version = "py311" diff --git a/src/vouch/__init__.py b/src/vouch/__init__.py index 236c24ff..c0b9743c 100644 --- a/src/vouch/__init__.py +++ b/src/vouch/__init__.py @@ -3,4 +3,4 @@ # One of FOUR version sites kept in lockstep (with pyproject.toml, # openclaw.plugin.json, package.json) — enforced by # tests/test_openclaw_plugin_manifest.py::test_manifest_versions_in_step. -__version__ = "1.2.2" +__version__ = "1.3.0" diff --git a/src/vouch/cli.py b/src/vouch/cli.py index 5034ea8b..c1342bdf 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -3740,6 +3740,71 @@ def _resolve_auth_token(auth: str | None) -> str | None: return auth +@cli.command(name="console") +@click.option( + "--bind", + "bind", + default="127.0.0.1:5173", + show_default=True, + help="host:port to bind. A non-loopback host (e.g. 0.0.0.0) also " + "requires --allow-remote so the proxy bridge isn't exposed openly.", +) +@click.option( + "--allow-remote", + is_flag=True, + help="Drop the loopback guard on the /proxy bridge. Only for a deployment " + "behind its own auth — a same-origin page could otherwise drive a " + "local reviewer's backends.", +) +@click.option( + "--open-browser/--no-open-browser", + default=True, + show_default=True, + help="Open the browser to the console on startup.", +) +def console(bind: str, allow_remote: bool, open_browser: bool) -> None: + """Serve the vouch web console (the React review UI) locally. + + Ships the built SPA and a same-origin /proxy bridge to your + `vouch serve --transport http` backends — one `pip install 'vouch-kb[web]'`, + no node. Add a backend from the connect dialog in the UI. + """ + from .web import _require_console_deps + + try: + _require_console_deps() + except ImportError as exc: + raise click.ClickException(str(exc)) from exc + + from .web.console import ConsoleError, resolve_console_dir, serve_console + + host, sep, port_raw = bind.partition(":") + if not sep: + raise click.ClickException(f"invalid --bind {bind!r}; expected host:port") + try: + port = int(port_raw) + except ValueError as exc: + raise click.ClickException(f"invalid port in --bind {bind!r}") from exc + host = host or "127.0.0.1" + if host not in ("127.0.0.1", "::1", "localhost") and not allow_remote: + raise click.ClickException( + f"--bind {bind} is non-loopback; pass --allow-remote to expose the " + "proxy bridge (only behind your own auth)." + ) + + url = f"http://{host}:{port}/" + if open_browser and resolve_console_dir() is not None: + import threading + import webbrowser + + threading.Timer(0.6, lambda: webbrowser.open(url)).start() + click.echo(f"vouch console → {url}") + try: + serve_console(host=host, port=port, allow_remote=allow_remote) + except ConsoleError as exc: + raise click.ClickException(str(exc)) from exc + + @cli.command(name="review-ui") @click.option( "--bind", diff --git a/src/vouch/web/__init__.py b/src/vouch/web/__init__.py index e6c89ab3..ece1cfa5 100644 --- a/src/vouch/web/__init__.py +++ b/src/vouch/web/__init__.py @@ -32,6 +32,26 @@ def _require_web_extra() -> None: ) +def _require_console_deps() -> None: + """Fail with a clean message if the console's serve deps aren't installed. + + The React console needs only starlette (the app) + uvicorn (the server) — + a subset of the [web] extra; jinja2 is not required. + """ + missing: list[str] = [] + for name in ("starlette", "uvicorn"): + try: + __import__(name) + except ImportError: + missing.append(name) + if missing: + raise ImportError( + "vouch console needs the [web] extra. " + "Install with: pip install 'vouch-kb[web]' " + f"(missing: {', '.join(missing)})" + ) + + def create_app( # type: ignore[no-untyped-def] kb_root: str | None = None, *, diff --git a/src/vouch/web/console.py b/src/vouch/web/console.py new file mode 100644 index 00000000..7b5fa8a9 --- /dev/null +++ b/src/vouch/web/console.py @@ -0,0 +1,174 @@ +"""Serve the vendored React review console (the `webapp/` SPA) from vouch. + +The console is a static single-page app. It cannot call vouch cross-origin — +vouch deliberately sends no CORS headers — so it reaches every backend through +a same-origin ``/proxy/*`` bridge, passing the real endpoint in an +``X-Vouch-Target`` header. In dev that bridge is a vite plugin +(`webapp/plugins/vouch-proxy.ts`); this module reimplements it in Python so a +single ``pip install 'vouch-kb[web]'`` can serve the built SPA with no node. + +This is a *viewport*: the bridge only forwards bytes to a `vouch serve +--transport http` backend, which remains the sole path to the review gate. +""" + +from __future__ import annotations + +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path + +from starlette.applications import Starlette +from starlette.concurrency import run_in_threadpool +from starlette.requests import Request +from starlette.responses import FileResponse, JSONResponse, Response +from starlette.routing import Route + +_MODULE_DIR = Path(__file__).resolve().parent + +# Loopback peers a same-origin browser client can present. The bridge is +# refused to anything else unless the operator explicitly opts in — a +# third-party page must not be able to drive a local reviewer's backends. +_LOOPBACK = frozenset({"127.0.0.1", "::1", "::ffff:127.0.0.1"}) + +# Methods the SPA actually uses are GET (health/capabilities) and POST (rpc); +# accept the common verbs so the bridge stays transparent. +_PROXY_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"] + +# A generous ceiling: an rpc that runs the compile/summary LLM is synchronous. +_PROXY_TIMEOUT = 300.0 + + +class ConsoleError(RuntimeError): + """The console cannot be served (no built SPA, or a missing dependency).""" + + +def _default_repo_dist() -> Path: + """`webapp/dist` relative to a source checkout (…/src/vouch/web → repo).""" + return _MODULE_DIR.parents[2] / "webapp" / "dist" + + +def resolve_console_dir( + *, packaged: Path | None = None, repo_dist: Path | None = None +) -> Path | None: + """Locate the built console assets, or ``None`` if none are built. + + Prefers the copy bundled inside the wheel (``vouch/web/console``); falls + back to ``webapp/dist`` in a source checkout. Mirrors how + ``install_adapter`` prefers the repo tree over the packaged copy. + """ + packaged = packaged if packaged is not None else _MODULE_DIR / "console" + if (packaged / "index.html").is_file(): + return packaged + repo_dist = repo_dist if repo_dist is not None else _default_repo_dist() + if (repo_dist / "index.html").is_file(): + return repo_dist + return None + + +def _err(status: int, code: str, message: str) -> JSONResponse: + """The vouch-native error envelope the SPA already understands.""" + return JSONResponse( + {"ok": False, "error": {"code": code, "message": message}}, status_code=status + ) + + +def build_console_app(console_dir: Path, *, allow_remote: bool = False) -> Starlette: + """Build the ASGI app: the ``/proxy/*`` bridge + the static SPA. + + ``allow_remote`` drops the loopback guard on the bridge — only for + deliberately-exposed deployments behind their own auth. + """ + root = console_dir.resolve() + index = root / "index.html" + + async def _proxy(request: Request) -> Response: + client_host = request.client.host if request.client else None + if not allow_remote and client_host not in _LOOPBACK: + return _err(403, "forbidden", "proxy is only available to loopback clients") + + target_raw = request.headers.get("x-vouch-target") + if not target_raw: + return _err(400, "bad_target", "missing X-Vouch-Target header") + parsed = urllib.parse.urlparse(target_raw) + if parsed.scheme not in ("http", "https") or not parsed.netloc: + return _err(400, "bad_target", f"not a valid http(s) target: {target_raw}") + + # The path after /proxy is appended to the target's host:port; any path + # on the target itself is dropped, matching vouch-proxy.ts exactly. + sub = request.url.path[len("/proxy") :] or "/" + fwd_url = f"{parsed.scheme}://{parsed.netloc}{sub}" + if request.url.query: + fwd_url += f"?{request.url.query}" + + fwd_headers: dict[str, str] = {} + if request.headers.get("content-type"): + fwd_headers["content-type"] = request.headers["content-type"] + if request.headers.get("authorization"): + fwd_headers["authorization"] = request.headers["authorization"] + body = await request.body() + method = request.method + + def _do() -> tuple[int, str, bytes]: + req = urllib.request.Request( + fwd_url, data=body or None, method=method, headers=fwd_headers + ) + try: + with urllib.request.urlopen(req, timeout=_PROXY_TIMEOUT) as resp: + ctype = resp.headers.get("content-type", "application/json") + return resp.status, ctype, resp.read() + except urllib.error.HTTPError as exc: + # A backend 4xx/5xx is a real answer — pass it through unchanged + # rather than masking it as a proxy 502. + ctype = exc.headers.get("content-type", "application/json") if exc.headers else ( + "application/json" + ) + return exc.code, ctype, exc.read() + + try: + status, ctype, payload = await run_in_threadpool(_do) + except urllib.error.URLError as exc: + return _err(502, "proxy_error", str(exc.reason)) + return Response(content=payload, status_code=status, media_type=ctype) + + async def _spa(request: Request) -> Response: + """Serve a real asset, else index.html so client-side routing works.""" + rel = request.path_params.get("full_path", "") + if rel: + candidate = (root / rel).resolve() + try: + candidate.relative_to(root) # reject path-traversal escapes + except ValueError: + candidate = index + if candidate.is_file(): + return FileResponse(candidate) + return FileResponse(index) + + routes = [ + Route("/proxy", _proxy, methods=_PROXY_METHODS), + Route("/proxy/{path:path}", _proxy, methods=_PROXY_METHODS), + Route("/{full_path:path}", _spa, methods=["GET", "HEAD"]), + ] + return Starlette(routes=routes) + + +def serve_console( + *, + host: str = "127.0.0.1", + port: int = 5173, + allow_remote: bool = False, + console_dir: Path | None = None, +) -> None: + """Serve the console with uvicorn (blocks). Raises ``ConsoleError`` early + if no built SPA can be found, before uvicorn is ever started.""" + resolved = console_dir if console_dir is not None else resolve_console_dir() + if resolved is None: + raise ConsoleError( + "no built vouch console found. from a source checkout run " + "`npm run build` in webapp/; otherwise install a release wheel of " + "vouch-kb[web] (the console ships inside it)." + ) + import uvicorn + + app = build_console_app(resolved, allow_remote=allow_remote) + uvicorn.run(app, host=host, port=port, log_level="info") diff --git a/tests/test_console.py b/tests/test_console.py new file mode 100644 index 00000000..c59964d0 --- /dev/null +++ b/tests/test_console.py @@ -0,0 +1,238 @@ +"""Tests for `vouch console` — serving the vendored React SPA + /proxy bridge. + +The console is a static single-page app that reaches vouch HTTP backends +through a same-origin ``/proxy/*`` bridge (the browser can't call vouch +cross-origin — vouch deliberately sends no CORS headers). In dev that bridge +is a vite plugin (`webapp/plugins/vouch-proxy.ts`); these cover the Python +reimplementation of it plus the console-directory resolution that lets one +`pip install` serve the built SPA with no node. +""" + +from __future__ import annotations + +import json +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path +from typing import Any, ClassVar + +import pytest + +pytest.importorskip("starlette", reason="vouch console needs the [web] extra") + +from fastapi.testclient import TestClient + +from vouch.web import console as console_mod +from vouch.web.console import build_console_app, resolve_console_dir + +# --- a stub "vouch serve --transport http" upstream ------------------------- + + +class _StubBackend(BaseHTTPRequestHandler): + """Records what the proxy forwarded; echoes enough to assert on.""" + + seen: ClassVar[list[dict[str, Any]]] = [] + + def _reply(self, status: int, body: dict[str, Any]) -> None: + payload = json.dumps(body).encode() + self.send_response(status) + self.send_header("content-type", "application/json") + self.send_header("content-length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def do_GET(self) -> None: + _StubBackend.seen.append( + {"method": "GET", "path": self.path, "auth": self.headers.get("authorization")} + ) + if self.path == "/boom": + self._reply(401, {"ok": False, "error": {"code": "unauthorized"}}) + return + self._reply(200, {"ok": True, "path": self.path}) + + def do_POST(self) -> None: + n = int(self.headers.get("content-length", 0)) + raw = self.rfile.read(n).decode() + _StubBackend.seen.append({"method": "POST", "path": self.path, "body": raw}) + self._reply(200, {"ok": True, "echo": raw}) + + def log_message(self, *_a: Any) -> None: # silence the test log + pass + + +@pytest.fixture +def upstream(): + _StubBackend.seen = [] + srv = HTTPServer(("127.0.0.1", 0), _StubBackend) + t = threading.Thread(target=srv.serve_forever, daemon=True) + t.start() + host, port = srv.server_address + try: + yield f"http://{host}:{port}" + finally: + srv.shutdown() + + +@pytest.fixture +def console_dir(tmp_path: Path) -> Path: + d = tmp_path / "console" + (d / "assets").mkdir(parents=True) + (d / "index.html").write_text( + "vouch console", encoding="utf-8" + ) + (d / "assets" / "app.js").write_text("console.log('hi')", encoding="utf-8") + return d + + +def _loopback_client(app) -> TestClient: # type: ignore[no-untyped-def] + """A same-origin loopback browser client.""" + return TestClient(app, client=("127.0.0.1", 54321)) + + +# --- static SPA serving ----------------------------------------------------- + + +def test_serves_the_spa_index_at_root(console_dir: Path) -> None: + res = _loopback_client(build_console_app(console_dir)).get("/") + assert res.status_code == 200 + assert "vouch console" in res.text + + +def test_unknown_route_falls_back_to_index_for_client_routing(console_dir: Path) -> None: + # a SPA deep link the server has no file for must return index.html (200), + # not 404 — client-side routing renders the view. + res = _loopback_client(build_console_app(console_dir)).get("/review") + assert res.status_code == 200 + assert "vouch console" in res.text + + +def test_real_static_asset_is_served(console_dir: Path) -> None: + res = _loopback_client(build_console_app(console_dir)).get("/assets/app.js") + assert res.status_code == 200 + assert "console.log" in res.text + + +# --- the /proxy bridge ------------------------------------------------------ + + +def test_proxy_forwards_get_to_the_target_backend(console_dir: Path, upstream: str) -> None: + client = _loopback_client(build_console_app(console_dir)) + res = client.get("/proxy/health", headers={"X-Vouch-Target": upstream}) + assert res.status_code == 200 + assert res.json()["path"] == "/health" + assert _StubBackend.seen[-1] == {"method": "GET", "path": "/health", "auth": None} + + +def test_proxy_forwards_post_body_and_auth_header(console_dir: Path, upstream: str) -> None: + client = _loopback_client(build_console_app(console_dir)) + res = client.post( + "/proxy/rpc", + headers={ + "X-Vouch-Target": upstream, + "authorization": "Bearer sekret", + "content-type": "application/json", + }, + content=b'{"method":"kb.status"}', + ) + assert res.status_code == 200 + assert _StubBackend.seen[-1]["path"] == "/rpc" + assert _StubBackend.seen[-1]["body"] == '{"method":"kb.status"}' + + +def test_proxy_passes_through_a_backend_error_status(console_dir: Path, upstream: str) -> None: + # a 401 from the backend must reach the browser as 401, not be masked as 502. + client = _loopback_client(build_console_app(console_dir)) + res = client.get("/proxy/boom", headers={"X-Vouch-Target": upstream}) + assert res.status_code == 401 + assert res.json()["error"]["code"] == "unauthorized" + + +def test_proxy_requires_the_target_header(console_dir: Path) -> None: + res = _loopback_client(build_console_app(console_dir)).get("/proxy/health") + assert res.status_code == 400 + assert res.json()["error"]["code"] == "bad_target" + + +def test_proxy_rejects_a_non_http_target(console_dir: Path) -> None: + res = _loopback_client(build_console_app(console_dir)).get( + "/proxy/health", headers={"X-Vouch-Target": "ftp://evil.example/x"} + ) + assert res.status_code == 400 + assert res.json()["error"]["code"] == "bad_target" + + +def test_proxy_rejects_non_loopback_client_by_default(console_dir: Path, upstream: str) -> None: + app = build_console_app(console_dir) # allow_remote defaults False + remote = TestClient(app, client=("10.0.0.9", 1234)) + res = remote.get("/proxy/health", headers={"X-Vouch-Target": upstream}) + assert res.status_code == 403 + assert res.json()["error"]["code"] == "forbidden" + + +def test_proxy_allows_remote_when_opted_in(console_dir: Path, upstream: str) -> None: + app = build_console_app(console_dir, allow_remote=True) + remote = TestClient(app, client=("10.0.0.9", 1234)) + res = remote.get("/proxy/health", headers={"X-Vouch-Target": upstream}) + assert res.status_code == 200 + + +# --- console-directory resolution ------------------------------------------ + + +def test_resolve_prefers_the_packaged_console(tmp_path: Path) -> None: + pkg = tmp_path / "pkg" + pkg.mkdir() + (pkg / "index.html").write_text("packaged", encoding="utf-8") + repo = tmp_path / "repo" + repo.mkdir() + (repo / "index.html").write_text("repo", encoding="utf-8") + assert resolve_console_dir(packaged=pkg, repo_dist=repo) == pkg + + +def test_resolve_falls_back_to_repo_dist(tmp_path: Path) -> None: + pkg = tmp_path / "pkg" # never created + repo = tmp_path / "repo" + repo.mkdir() + (repo / "index.html").write_text("repo", encoding="utf-8") + assert resolve_console_dir(packaged=pkg, repo_dist=repo) == repo + + +def test_resolve_is_none_when_no_console_is_built(tmp_path: Path) -> None: + assert resolve_console_dir(packaged=tmp_path / "a", repo_dist=tmp_path / "b") is None + + +def test_serve_console_errors_clearly_when_no_console_is_built( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # no built SPA anywhere → a clean, actionable error, never a traceback deep + # inside uvicorn (which must not even be reached). + monkeypatch.setattr(console_mod, "resolve_console_dir", lambda **_k: None) + with pytest.raises(console_mod.ConsoleError, match="npm run build"): + console_mod.serve_console() + + +# --- the `vouch console` CLI command --------------------------------------- + + +def test_cli_console_rejects_non_loopback_bind_without_allow_remote() -> None: + from click.testing import CliRunner + + from vouch.cli import cli + + res = CliRunner().invoke(cli, ["console", "--bind", "0.0.0.0:5173", "--no-open-browser"]) + assert res.exit_code != 0 + assert "allow-remote" in res.output.lower() + + +def test_cli_console_errors_cleanly_when_no_console_built( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from click.testing import CliRunner + + from vouch.cli import cli + + # loopback bind, but nothing is built → a clean click error, never a server. + monkeypatch.setattr(console_mod, "resolve_console_dir", lambda **_k: None) + res = CliRunner().invoke(cli, ["console", "--no-open-browser"]) + assert res.exit_code != 0 + assert "npm run build" in res.output