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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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 b7ea9f5f9c8fd0040698a1683fa20606b16fdced Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Fri, 10 Jul 2026 18:53:20 +0900 Subject: [PATCH 15/16] feat(activity): expose kb.activity audit buckets across all surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit one pass over the audit log returning per-day counts (with proposal/decision breakdowns), an hour-of-week matrix, and actor/event histograms — the data a dashboard needs that kb.audit's tail-limited raw events and kb.stats' review aggregates don't cover. windowed in viewer-local calendar days so the oldest heatmap cell is never partially counted (days=0 for all-time, negatives rejected). local bucketing prefers an iana tz name (dst-correct across the year) and falls back to a clamped fixed utc offset. scope-filtered through the same viewer context as kb.audit so multi-project kbs don't leak actor names or activity timing across project boundaries. registered at all four sites (mcp tool, jsonl handler, capabilities methods, vouch activity cli) plus trust read methods. --- CHANGELOG.md | 9 +++ src/vouch/capabilities.py | 1 + src/vouch/cli.py | 53 ++++++++++++++ src/vouch/jsonl_server.py | 17 ++++- src/vouch/server.py | 28 ++++++- src/vouch/stats.py | 104 +++++++++++++++++++++++++- src/vouch/trust.py | 1 + tests/test_stats.py | 149 ++++++++++++++++++++++++++++++++++++++ tests/test_trust.py | 1 + 9 files changed, 360 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 986dcb0e..68f01b10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ All notable changes to vouch are documented here. Format follows ## [Unreleased] +### Added +- `kb.activity` read method (+ `vouch activity` CLI mirror): audit-log + activity buckets for dashboards — per-day counts with proposal/decision + breakdowns, an hour-of-week matrix, and actor/event histograms. windowed + in viewer-local calendar days (IANA `tz` or a fixed utc offset), scope- + filtered like `kb.audit`. +- console Dashboard view: 12-month activity calendar, last-30-days bars, + hour-of-week heatmap, top actors and event mix, driven by `kb.activity`. + ## [1.2.2] — 2026-07-07 ### Packaging diff --git a/src/vouch/capabilities.py b/src/vouch/capabilities.py index 0d8528a5..1f101219 100644 --- a/src/vouch/capabilities.py +++ b/src/vouch/capabilities.py @@ -31,6 +31,7 @@ "kb.capabilities", "kb.status", "kb.stats", + "kb.activity", "kb.digest", "kb.search", "kb.neighbors", diff --git a/src/vouch/cli.py b/src/vouch/cli.py index 5034ea8b..0267852a 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -319,6 +319,59 @@ def stats(days: int, as_json: bool) -> None: _echo(f" invalid: {cites['invalid_claim']}, broken: {cites['broken_citation']}") +@cli.command() +@click.option( + "--days", + default=365, + show_default=True, + type=click.IntRange(min=0), + help="Window (local calendar days). Use 0 for all-time.", +) +@click.option( + "--tz-offset-minutes", + default=0, + show_default=True, + type=int, + help="Viewer's UTC offset in minutes for local-time bucketing.", +) +@click.option("--tz", default=None, help="IANA zone for local-time bucketing (wins over offset).") +@click.option("--project", default=None, help="Viewer project for audit scope filtering.") +@click.option("--agent", default=None, help="Viewer agent for audit scope filtering.") +@click.option("--json", "as_json", is_flag=True, help="Emit JSON instead of a table.") +def activity( + days: int, + tz_offset_minutes: int, + tz: str | None, + project: str | None, + agent: str | None, + as_json: bool, +) -> None: + """Audit activity buckets: per-day counts, hour-of-week matrix, actors.""" + from .scoping import viewer_from + + store = _load_store() + viewer = viewer_from( + config_path=store.config_path, + project=project, + agent=agent, + ) + body = stats_mod.collect_activity( + store, days=days, tz_offset_minutes=tz_offset_minutes, tz=tz, viewer=viewer, + ) + if as_json: + _emit_json(body) + return + window = "all time" if body["window_days"] is None else f"last {body['window_days']}d" + _echo( + f"activity ({window}): {_style(str(body['total_events']), fg='cyan')} events " + f"on {body['active_days']} day(s)" + ) + if body["first_event_day"]: + _echo(f" span: {body['first_event_day']} → {body['last_event_day']}") + for actor, count in list(body["by_actor"].items())[:8]: + _echo(f" {actor}: {count}") + + @cli.command(name="digest") @click.option( "--since", diff --git a/src/vouch/jsonl_server.py b/src/vouch/jsonl_server.py index 6f34c9cf..85be9cd5 100644 --- a/src/vouch/jsonl_server.py +++ b/src/vouch/jsonl_server.py @@ -55,7 +55,7 @@ reject, reject_auto_extracted, ) -from .stats import collect_stats +from .stats import collect_activity, collect_stats from .storage import ( ArtifactNotFoundError, KBNotFoundError, @@ -102,6 +102,20 @@ def _h_stats(p: dict) -> dict: return collect_stats(_store(), since_days=since) +def _h_activity(p: dict) -> dict: + from .scoping import viewer_from_params + + s = _store() + viewer = viewer_from_params(s, p) + return collect_activity( + s, + days=int(p.get("days", 365)), + tz_offset_minutes=int(p.get("tz_offset_minutes", 0)), + tz=p.get("tz"), + viewer=viewer, + ) + + def _h_digest(p: dict) -> dict: d = digest_mod.build( _store(), @@ -770,6 +784,7 @@ def _h_propose_theme(p: dict) -> dict: "kb.capabilities": _h_capabilities, "kb.status": _h_status, "kb.stats": _h_stats, + "kb.activity": _h_activity, "kb.digest": _h_digest, "kb.search": _h_search, "kb.neighbors": _h_neighbors, diff --git a/src/vouch/server.py b/src/vouch/server.py index 6bfd8b35..ff25a4b9 100644 --- a/src/vouch/server.py +++ b/src/vouch/server.py @@ -47,7 +47,7 @@ reject_auto_extracted, ) from .scoping import filter_hits, scoped_fetch_limit, viewer_from -from .stats import collect_stats +from .stats import collect_activity, collect_stats from .storage import ( ArtifactNotFoundError, KBNotFoundError, @@ -97,6 +97,32 @@ def kb_stats(*, days: int = 30) -> dict[str, Any]: return collect_stats(_store(), since_days=since) +@mcp.tool() +def kb_activity( + *, + days: int = 365, + tz_offset_minutes: int = 0, + tz: str | None = None, + project: str | None = None, + agent: str | None = None, +) -> dict[str, Any]: + """Audit activity buckets for dashboards: per-day counts, hour-of-week + matrix, actor and event histograms. Scope-filtered like kb.audit. + + days: window in local calendar days; 0 means all-time. + tz: IANA zone for local-time bucketing; falls back to tz_offset_minutes. + """ + store = _store() + viewer = viewer_from( + config_path=store.config_path, + project=project, + agent=agent, + ) + return collect_activity( + store, days=days, tz_offset_minutes=tz_offset_minutes, tz=tz, viewer=viewer, + ) + + @mcp.tool() def kb_digest( *, diff --git a/src/vouch/stats.py b/src/vouch/stats.py index 5e5ca35c..db30754f 100644 --- a/src/vouch/stats.py +++ b/src/vouch/stats.py @@ -7,14 +7,19 @@ from __future__ import annotations import statistics +from collections.abc import Callable from datetime import UTC, datetime, timedelta -from typing import Any +from typing import TYPE_CHECKING, Any +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from . import audit, health from .models import Proposal, ProposalStatus from .proposals import EXPIRE_REASON from .storage import KBStore, _yaml_load +if TYPE_CHECKING: + from .scoping import ViewerContext + def _utc(dt: datetime) -> datetime: if dt.tzinfo is None: @@ -186,6 +191,103 @@ def bump(agent: str, bucket: str) -> None: } +# Real-world UTC offsets span -12:00..+14:00; clamp so a bad client can't +# shift events into arbitrary buckets. +_MAX_TZ_OFFSET_MINUTES = 14 * 60 + + +def _is_proposal_create(event: str) -> bool: + return event.startswith("proposal.") and event.endswith(".create") + + +def _local_clock(tz: str | None, offset_minutes: int) -> Callable[[datetime], datetime]: + """Viewer-local conversion: an IANA zone when resolvable (DST-correct), + otherwise the fixed offset.""" + if tz: + try: + zone = ZoneInfo(tz) + except (ZoneInfoNotFoundError, ValueError): + pass + else: + return lambda dt: _utc(dt).astimezone(zone) + shift = timedelta(minutes=offset_minutes) + return lambda dt: _utc(dt) + shift + + +def collect_activity( + store: KBStore, + *, + days: int = 365, + tz_offset_minutes: int = 0, + tz: str | None = None, + viewer: ViewerContext | None = None, +) -> dict[str, Any]: + """Bucket audit events for the console dashboard — `kb.activity`. + + One pass over the audit log: per-day totals (with proposal/decision + breakdowns), an hour-of-week matrix, and actor/event histograms. + ``days=0`` means all-time; otherwise the window is the last ``days`` + viewer-local calendar days including today, so the oldest day in a + dashboard heatmap is never partially counted. ``tz`` (IANA name) wins + over ``tz_offset_minutes`` for local bucketing. ``viewer`` applies the + same scope filtering as `kb.audit`. + """ + if days < 0: + raise ValueError("days must be >= 0") + window = None if days == 0 else days + offset = max(-_MAX_TZ_OFFSET_MINUTES, min(_MAX_TZ_OFFSET_MINUTES, tz_offset_minutes)) + to_local = _local_clock(tz, offset) + cutoff_day = ( + None + if window is None + else to_local(datetime.now(UTC)).date() - timedelta(days=window - 1) + ) + + by_day: dict[str, dict[str, int]] = {} + # by_hour[weekday][hour], Monday = 0 — matches datetime.weekday(). + by_hour = [[0] * 24 for _ in range(7)] + by_actor: dict[str, int] = {} + by_event: dict[str, int] = {} + total = 0 + + for ev in audit.read_events(store.kb_dir, store=store, viewer=viewer): + local = to_local(ev.created_at) + if cutoff_day is not None and local.date() < cutoff_day: + continue + day = by_day.setdefault( + local.date().isoformat(), + {"total": 0, "proposals": 0, "decisions": 0}, + ) + day["total"] += 1 + if _is_proposal_create(ev.event): + day["proposals"] += 1 + elif _audit_decision_kind(ev.event) is not None: + day["decisions"] += 1 + by_hour[local.weekday()][local.hour] += 1 + by_actor[ev.actor] = by_actor.get(ev.actor, 0) + 1 + by_event[ev.event] = by_event.get(ev.event, 0) + 1 + total += 1 + + days_seen = sorted(by_day) + return { + "generated_at": datetime.now(UTC).isoformat(), + "window_days": window, + "tz_offset_minutes": offset, + "viewer": { + "project": viewer.project if viewer else None, + "agent": viewer.agent if viewer else None, + }, + "total_events": total, + "active_days": len(by_day), + "first_event_day": days_seen[0] if days_seen else None, + "last_event_day": days_seen[-1] if days_seen else None, + "by_day": {d: by_day[d] for d in days_seen}, + "by_hour": by_hour, + "by_actor": dict(sorted(by_actor.items(), key=lambda kv: (-kv[1], kv[0]))), + "by_event": dict(sorted(by_event.items(), key=lambda kv: (-kv[1], kv[0]))), + } + + def collect_stats(store: KBStore, *, since_days: int | None = 30) -> dict[str, Any]: """Aggregate observability metrics for the KB at ``store``.""" return { diff --git a/src/vouch/trust.py b/src/vouch/trust.py index 3bda4e8a..8d764d50 100644 --- a/src/vouch/trust.py +++ b/src/vouch/trust.py @@ -31,6 +31,7 @@ "kb.capabilities", "kb.status", "kb.stats", + "kb.activity", "kb.search", "kb.context", "kb.read_page", diff --git a/tests/test_stats.py b/tests/test_stats.py index 08aa5da5..fdda64f4 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -136,3 +136,152 @@ def test_stats_marks_expired_decisions(store: KBStore) -> None: review = stats.review_summary(store, since_days=None) assert review["expired"] == 1 assert review["by_agent"]["a"]["expired"] == 1 + + +# --- kb.activity ----------------------------------------------------------- + + +def _append_event(store: KBStore, *, event: str, actor: str, created_at: str) -> None: + import json + + line = { + "id": "0" * 32, + "event": event, + "actor": actor, + "created_at": created_at, + "object_ids": [], + "dry_run": False, + "reversible": True, + "data": {}, + "prev_hash": None, + "hash": None, + } + with (store.kb_dir / "audit.log.jsonl").open("a", encoding="utf-8") as f: + f.write(json.dumps(line) + "\n") + + +def test_collect_activity_buckets_events(store: KBStore) -> None: + src = store.put_source(b"x") + p1 = propose_claim(store, text="a", evidence=[src.id], proposed_by="agent-a") + propose_claim(store, text="b", evidence=[src.id], proposed_by="agent-b") + approve(store, p1.id, approved_by="human") + + body = stats.collect_activity(store) + assert body["by_event"]["proposal.claim.create"] == 2 + assert body["by_event"]["proposal.claim.approve"] == 1 + assert body["active_days"] == 1 + day = body["by_day"][body["first_event_day"]] + assert day["proposals"] == 2 + assert day["decisions"] == 1 + assert day["total"] == body["total_events"] + assert len(body["by_hour"]) == 7 + assert all(len(row) == 24 for row in body["by_hour"]) + assert sum(sum(row) for row in body["by_hour"]) == body["total_events"] + assert body["by_actor"] + + +def test_collect_activity_tz_offset_shifts_buckets(store: KBStore) -> None: + from vouch import audit as audit_mod + + store.put_source(b"x") + events = list(audit_mod.read_events(store.kb_dir)) + for offset in (840, -840): + body = stats.collect_activity(store, tz_offset_minutes=offset) + expected = { + (stats._utc(e.created_at) + timedelta(minutes=offset)).date().isoformat() + for e in events + } + assert set(body["by_day"]) == expected + + +def test_collect_activity_clamps_tz_offset(store: KBStore) -> None: + body = stats.collect_activity(store, tz_offset_minutes=10_000) + assert body["tz_offset_minutes"] == 840 + + +def test_collect_activity_window_excludes_old_events(store: KBStore) -> None: + _append_event( + store, event="proposal.claim.create", actor="ancient", + created_at=(datetime.now(UTC) - timedelta(days=400)).isoformat(), + ) + + windowed = stats.collect_activity(store, days=365) + all_time = stats.collect_activity(store, days=0) + assert "ancient" not in windowed["by_actor"] + assert all_time["by_actor"]["ancient"] == 1 + assert all_time["window_days"] is None + + +def test_collect_activity_rejects_negative_days(store: KBStore) -> None: + with pytest.raises(ValueError, match="days"): + stats.collect_activity(store, days=-5) + + +def test_collect_activity_iana_tz_is_dst_correct(store: KBStore) -> None: + # 23:30 UTC on a January Thursday is 17:30 the same Thursday in Chicago + # (CST, UTC-6); the viewer's *current* summer offset (-5) would put it at + # 18:30 — the IANA path must use the offset in effect at the event. + _append_event( + store, event="kb.init", actor="winter", + created_at="2026-01-15T23:30:00+00:00", + ) + body = stats.collect_activity(store, days=0, tz="America/Chicago") + assert body["by_day"] == {"2026-01-15": {"total": 1, "proposals": 0, "decisions": 0}} + assert body["by_hour"][3][17] == 1 # Thursday row, 17:00 column + + +def test_collect_activity_bad_tz_falls_back_to_offset(store: KBStore) -> None: + _append_event( + store, event="kb.init", actor="x", + created_at="2026-01-15T23:30:00+00:00", + ) + body = stats.collect_activity(store, days=0, tz="Not/AZone", tz_offset_minutes=60) + assert body["by_day"] == {"2026-01-16": {"total": 1, "proposals": 0, "decisions": 0}} + + +def test_collect_activity_respects_viewer_scope(store: KBStore) -> None: + from vouch.models import ArtifactScope, Visibility + from vouch.scoping import ViewerContext + + src = store.put_source(b"evidence") + store.put_claim(Claim( + id="billing-secret", + text="billing", + evidence=[src.id], + scope=ArtifactScope(visibility=Visibility.PROJECT, project="billing"), + )) + from vouch import audit as audit_mod + + audit_mod.log_event( + store.kb_dir, event="claim.create", actor="billing-bot", + object_ids=["billing-secret"], + ) + + unscoped = stats.collect_activity(store, days=0) + scoped = stats.collect_activity( + store, days=0, viewer=ViewerContext(project="design"), + ) + assert "billing-bot" in unscoped["by_actor"] + assert "billing-bot" not in scoped["by_actor"] + assert scoped["viewer"] == {"project": "design", "agent": None} + + +def test_jsonl_kb_activity(store: KBStore) -> None: + src = store.put_source(b"x") + propose_claim(store, text="a", evidence=[src.id], proposed_by="agent-a") + resp = handle_request({"id": "1", "method": "kb.activity", "params": {"days": 0}}) + assert resp["ok"] is True + assert resp["result"]["total_events"] >= 1 + assert len(resp["result"]["by_hour"]) == 7 + + +def test_cli_activity_json(store: KBStore) -> None: + import json + + src = store.put_source(b"x") + propose_claim(store, text="a", evidence=[src.id], proposed_by="agent-a") + result = CliRunner().invoke(cli, ["activity", "--json"]) + assert result.exit_code == 0, result.output + data = json.loads(result.output) + assert data["total_events"] >= 1 + assert data["active_days"] >= 1 diff --git a/tests/test_trust.py b/tests/test_trust.py index 848d6148..60182e7c 100644 --- a/tests/test_trust.py +++ b/tests/test_trust.py @@ -181,6 +181,7 @@ def test_read_methods_constant_covers_declared_reads() -> None: if m.startswith(read_prefixes) or m in { "kb.capabilities", "kb.stats", + "kb.activity", "kb.audit", "kb.why", "kb.trace", From bc75fcb0b9b1539a67c3f848fcefdf2d07cccf57 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Fri, 10 Jul 2026 18:53:46 +0900 Subject: [PATCH 16/16] feat(webapp): dashboard view of kb activity, landing on / new /dashboard view driven by kb.activity: a 12-month activity calendar (ember heat ramp themed for dark and light via --heat-* css vars), last-30-days bars, an hour-of-week heatmap, and top-actor / event-mix bar lists, with stat tiles fed by kb.status and kb.stats. the calendar fetches 371 days so its oldest drawn column always sits inside the server window, sends the browser's iana timezone for dst-correct bucketing, and degrades cleanly: endpoints that don't advertise kb.activity get an upgrade hint, empty audit logs get an empty state, and a malformed stats payload no longer crashes the view. / now redirects to the dashboard instead of chat and the tab leads the sidebar; the e2e smoke flow clicks through to chat accordingly. --- webapp/e2e/smoke.spec.ts | 4 + webapp/src/App.test.tsx | 35 +- webapp/src/App.tsx | 4 +- webapp/src/components/Shell.tsx | 4 +- webapp/src/lib/types.ts | 18 + webapp/src/styles.css | 12 + webapp/src/views/DashboardView.test.tsx | 166 ++++++++ webapp/src/views/DashboardView.tsx | 513 ++++++++++++++++++++++++ 8 files changed, 752 insertions(+), 4 deletions(-) create mode 100644 webapp/src/views/DashboardView.test.tsx create mode 100644 webapp/src/views/DashboardView.tsx diff --git a/webapp/e2e/smoke.spec.ts b/webapp/e2e/smoke.spec.ts index 6c02d90b..ff7ac2cd 100644 --- a/webapp/e2e/smoke.spec.ts +++ b/webapp/e2e/smoke.spec.ts @@ -10,6 +10,10 @@ test('connect → ask → citation drawer → review approve', async ({ page }) await page.getByRole('button', { name: /connect/i }).click() await expect(page.getByText('127.0.0.1:8971')).toBeVisible() + // / lands on the Dashboard; move to Chat for the ask flow + await expect(page.getByRole('heading', { name: /dashboard — kb activity/i })).toBeVisible() + await page.getByRole('link', { name: /chat/i }).click() + // Chat: cited answer from the approved claim await page.getByPlaceholder(/ask the kb/i).fill('what does the vouch http server bind') await page.keyboard.press('Enter') diff --git a/webapp/src/App.test.tsx b/webapp/src/App.test.tsx index e9db04da..18b98fd1 100644 --- a/webapp/src/App.test.tsx +++ b/webapp/src/App.test.tsx @@ -1,10 +1,41 @@ import { render, screen } from '@testing-library/react' -import { beforeEach, expect, test } from 'vitest' +import { beforeEach, expect, test, vi } from 'vitest' + +vi.mock('./lib/rpc', async () => { + const actual = await vi.importActual('./lib/rpc') + return { + ...actual, + rpc: vi.fn().mockResolvedValue([]), + fetchHealth: vi.fn(), + fetchCapabilities: vi.fn(), + } +}) import App from './App' +import { fetchCapabilities, fetchHealth } from './lib/rpc' +import { seedConnection } from './test/utils' -beforeEach(() => localStorage.clear()) +beforeEach(() => { + localStorage.clear() + vi.clearAllMocks() + window.history.replaceState(null, '', '/') +}) test('boots to the connect dialog when no endpoint is stored', () => { render() expect(screen.getByText(/connect to your knowledge base/i)).toBeInTheDocument() }) + +test('boots to the dashboard when connected', async () => { + vi.mocked(fetchHealth).mockResolvedValue(true) + vi.mocked(fetchCapabilities).mockResolvedValue({ + name: 'vouch', + level: 3, + methods: ['kb.list_pending'], + review_gated: true, + }) + seedConnection() + render() + expect( + await screen.findByRole('heading', { name: /dashboard — kb activity/i }), + ).toBeInTheDocument() +}) diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 76860246..7b7278c8 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -6,6 +6,7 @@ import { ConnectionProvider } from './connection/ConnectionContext' import { BrowseView } from './views/BrowseView' import { ChatView } from './views/ChatView' import { ClaimsView } from './views/ClaimsView' +import { DashboardView } from './views/DashboardView' import { PendingView } from './views/PendingView' import { ReviewView } from './views/ReviewView' import { StatsView } from './views/StatsView' @@ -18,12 +19,13 @@ export default function App() { }> - } /> + } /> } /> } /> } /> } /> } /> + } /> } /> diff --git a/webapp/src/components/Shell.tsx b/webapp/src/components/Shell.tsx index 443f0621..f0d53b37 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, LayoutDashboard, 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' @@ -9,6 +9,7 @@ import type { Proposal, SessionEntry } from '../lib/types' const THEME_KEY = 'vouch-ui.theme' const NAV = [ + { to: '/dashboard', label: 'Dashboard', icon: LayoutDashboard }, { to: '/chat', label: 'Chat', icon: MessageSquare }, { to: '/review', label: 'Review', icon: FileClock }, { to: '/pending', label: 'Pending', icon: Inbox }, @@ -23,6 +24,7 @@ const TITLES: Record = { '/pending': 'Pending review', '/claims': 'Approved claims', '/browse': 'Knowledge', + '/dashboard': 'Dashboard — KB activity', '/stats': 'Stats & health', } diff --git a/webapp/src/lib/types.ts b/webapp/src/lib/types.ts index 6bbed659..ae3bd673 100644 --- a/webapp/src/lib/types.ts +++ b/webapp/src/lib/types.ts @@ -147,6 +147,24 @@ export interface KbStats { } } +/** kb.activity — audit-log buckets for the Dashboard view. */ +export interface KbActivity { + generated_at: string + window_days: number | null + tz_offset_minutes: number + viewer?: { project: string | null; agent: string | null } + total_events: number + active_days: number + first_event_day: string | null + last_event_day: string | null + /** Keyed by local date "YYYY-MM-DD" (per tz_offset_minutes). */ + by_day: Record + /** [weekday][hour] counts, weekday 0 = Monday, hour 0-23 local. */ + by_hour: number[][] + by_actor: Record + by_event: Record +} + export interface Capabilities { name: string level: number diff --git a/webapp/src/styles.css b/webapp/src/styles.css index 1115e53a..ccababe7 100644 --- a/webapp/src/styles.css +++ b/webapp/src/styles.css @@ -11,6 +11,13 @@ --accent-2: #ff7c60; --rule: #2a2320; --ok: #86c06c; + /* Sequential "ember" ramp for activity heatmaps (Dashboard). One hue, + monotonic lightness against the dark paper surface; step 0 = no data. */ + --heat-0: #201917; + --heat-1: #4a2018; + --heat-2: #7d3020; + --heat-3: #b84428; + --heat-4: #ff5a3d; } :root[data-theme="light"] { @@ -24,6 +31,11 @@ --accent-2: #ff5a3d; --rule: #e5ddd2; --ok: #4e8a37; + --heat-0: #f0e9e0; + --heat-1: #ffd9cf; + --heat-2: #ff9e85; + --heat-3: #f06542; + --heat-4: #c23a1d; } @theme inline { diff --git a/webapp/src/views/DashboardView.test.tsx b/webapp/src/views/DashboardView.test.tsx new file mode 100644 index 00000000..1571d681 --- /dev/null +++ b/webapp/src/views/DashboardView.test.tsx @@ -0,0 +1,166 @@ +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, expect, test, 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 { DashboardView } from './DashboardView' + +const CAPS = { + name: 'vouch', + level: 3, + methods: ['kb.status', 'kb.stats', 'kb.activity'], + review_gated: true, +} + +// Numbers chosen to be unique on the page — the hour axis renders +// 0,3,6,…,21 as text, so fixture counts must avoid those. +const STATUS = { + kb_dir: '/tmp/demo/.vouch', + claims: 13, + pages: 3, + sources: 5, + entities: 4, + relations: 2, + evidence: 1, + sessions: 6, + pending_proposals: 2, + audit_events: 40, + index_present: true, +} + +const STATS = { + generated_at: '2026-07-04T02:18:21+00:00', + counts: STATUS, + pending: { total: 2, by_agent: { 'agent-a': 2 }, age_days: { median: 1, max: 3, oldest_id: 'x' } }, + review: { + window_days: 30, + decided_in_window: 10, + approved: 8, + rejected: 2, + expired: 0, + approval_rate: 0.8, + by_agent: { 'agent-a': { approved: 8, rejected: 2, expired: 0, pending: 2 } }, + }, + citations: { claims_total: 12, claims_with_valid_citation: 11, broken_citation: 1, invalid_claim: 0, coverage_rate: 0.9167 }, +} + +/** Local "YYYY-MM-DD" for n days ago — must match the view's bucketing. */ +function localKey(daysAgo: number): string { + const d = new Date() + d.setHours(0, 0, 0, 0) + d.setDate(d.getDate() - daysAgo) + const m = String(d.getMonth() + 1).padStart(2, '0') + const day = String(d.getDate()).padStart(2, '0') + return `${d.getFullYear()}-${m}-${day}` +} + +const BY_HOUR = Array.from({ length: 7 }, () => Array(24).fill(0)) +BY_HOUR[1][14] = 3 + +const ACTIVITY = { + generated_at: '2026-07-10T08:00:00+00:00', + window_days: 365, + tz_offset_minutes: 0, + total_events: 41, + active_days: 2, + first_event_day: localKey(7), + last_event_day: localKey(0), + by_day: { + [localKey(7)]: { total: 15, proposals: 9, decisions: 2 }, + [localKey(0)]: { total: 26, proposals: 5, decisions: 4 }, + }, + by_hour: BY_HOUR, + by_actor: { 'wiki-compiler': 23, a: 11, 'vouch-capture': 8 }, + by_event: { 'proposal.page.create': 14, 'proposal.page.approve': 5 }, +} + +beforeEach(() => { + localStorage.clear() + vi.clearAllMocks() + vi.mocked(fetchHealth).mockResolvedValue(true) + vi.mocked(fetchCapabilities).mockResolvedValue(CAPS) + vi.mocked(rpc).mockImplementation(async (_c, method) => { + if (method === 'kb.status') return STATUS + if (method === 'kb.stats') return STATS + if (method === 'kb.activity') return ACTIVITY + throw new Error(`unexpected ${method}`) + }) + seedConnection() +}) + +test('renders tiles from kb.activity, kb.status and kb.stats', async () => { + renderWithProviders() + expect(await screen.findByText('41')).toBeInTheDocument() // events · 12 mo + expect(screen.getByText('events · 12 mo')).toBeInTheDocument() + expect(screen.getByText('active days')).toBeInTheDocument() + expect(await screen.findByText('13')).toBeInTheDocument() // claims + expect(await screen.findByText('80%')).toBeInTheDocument() // approval rate +}) + +test('renders the activity calendar with a metric toggle', async () => { + renderWithProviders() + expect( + await screen.findByRole('img', { + name: /activity calendar, last 12 months: 41 events across 2 active days/i, + }), + ).toBeInTheDocument() + expect(vi.mocked(rpc)).toHaveBeenCalledWith( + expect.anything(), + 'kb.activity', + expect.objectContaining({ days: 371, tz: expect.any(String) }), + ) + const proposals = screen.getByRole('button', { name: 'Proposals' }) + expect(proposals).toHaveAttribute('aria-pressed', 'false') + await userEvent.click(proposals) + expect(proposals).toHaveAttribute('aria-pressed', 'true') + expect(screen.getByRole('button', { name: 'All events' })).toHaveAttribute('aria-pressed', 'false') +}) + +test('renders hour-of-week heatmap and 30-day bars', async () => { + renderWithProviders() + expect(await screen.findByRole('img', { name: /events by hour of week/i })).toBeInTheDocument() + expect(screen.getByRole('img', { name: /events per day, last 30 days/i })).toBeInTheDocument() + expect(screen.getByText('today')).toBeInTheDocument() +}) + +test('renders top actors and event mix with counts', async () => { + renderWithProviders() + expect(await screen.findByText('wiki-compiler')).toBeInTheDocument() + expect(screen.getByText('23')).toBeInTheDocument() + expect(screen.getByText('proposal.page.create')).toBeInTheDocument() + expect(screen.getByText('14')).toBeInTheDocument() +}) + +test('shows an upgrade hint when the endpoint lacks kb.activity', async () => { + vi.mocked(fetchCapabilities).mockResolvedValue({ ...CAPS, methods: ['kb.status', 'kb.stats'] }) + renderWithProviders() + expect(await screen.findByText(/doesn't advertise kb.activity/i)).toBeInTheDocument() + expect(vi.mocked(rpc)).not.toHaveBeenCalledWith(expect.anything(), 'kb.activity', expect.anything()) +}) + +test('shows an empty state when the audit log has no events', async () => { + vi.mocked(rpc).mockImplementation(async (_c, method) => { + if (method === 'kb.status') return STATUS + if (method === 'kb.stats') return STATS + if (method === 'kb.activity') + return { + ...ACTIVITY, + total_events: 0, + active_days: 0, + first_event_day: null, + last_event_day: null, + by_day: {}, + by_hour: Array.from({ length: 7 }, () => Array(24).fill(0)), + by_actor: {}, + by_event: {}, + } + throw new Error(`unexpected ${method}`) + }) + renderWithProviders() + expect(await screen.findByText(/no audit activity yet/i)).toBeInTheDocument() +}) diff --git a/webapp/src/views/DashboardView.tsx b/webapp/src/views/DashboardView.tsx new file mode 100644 index 00000000..9c583a52 --- /dev/null +++ b/webapp/src/views/DashboardView.tsx @@ -0,0 +1,513 @@ +import { useQuery } from '@tanstack/react-query' +import { useState } from 'react' +import { EmptyState } from '../components/EmptyState' +import { ErrorCard } from '../components/ErrorCard' +import { useErrorToast } from '../components/Toast' +import { useConnection } from '../connection/ConnectionContext' +import type { ProjectState } from '../connection/ConnectionContext' +import { rpc } from '../lib/rpc' +import type { KbActivity, KbStats, KbStatus } from '../lib/types' + +type DayMetric = 'total' | 'proposals' | 'decisions' + +const METRICS: { key: DayMetric; label: string; noun: string }[] = [ + { key: 'total', label: 'All events', noun: 'event' }, + { key: 'proposals', label: 'Proposals', noun: 'proposal' }, + { key: 'decisions', label: 'Decisions', noun: 'decision' }, +] + +const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] + +// Steps of the ember ramp defined in styles.css — theme-aware via CSS vars. +const HEAT = [0, 1, 2, 3, 4].map((i) => `var(--heat-${i})`) + +/** Quartile bucket into the 5-step ramp; 0 is reserved for "no events". */ +function heatLevel(count: number, max: number): number { + if (count <= 0 || max <= 0) return 0 + const t = max / 4 + if (count > 3 * t) return 4 + if (count > 2 * t) return 3 + if (count > t) return 2 + return 1 +} + +/** Local-calendar key matching the server's by_day bucketing. */ +function dateKey(d: Date): string { + const m = String(d.getMonth() + 1).padStart(2, '0') + const day = String(d.getDate()).padStart(2, '0') + return `${d.getFullYear()}-${m}-${day}` +} + +function daysAgo(base: Date, n: number): Date { + const d = new Date(base) + d.setDate(d.getDate() - n) + return d +} + +function localToday(): Date { + const d = new Date() + d.setHours(0, 0, 0, 0) + return d +} + +/** Monday-first weekday index, matching by_hour rows. */ +function weekdayRow(d: Date): number { + return (d.getDay() + 6) % 7 +} + +type Tip = { x: number; y: number; text: string } | null + +function ChartTip({ tip }: { tip: Tip }) { + if (!tip) return null + return ( +
+ {tip.text} +
+ ) +} + +function Tile({ label, value }: { label: string; value: string | number }) { + return ( +
+

{value}

+

{label}

+
+ ) +} + +function Card({ + title, + right, + children, +}: { + title: string + right?: React.ReactNode + children: React.ReactNode +}) { + return ( +
+
+

{title}

+ {right} +
+ {children} +
+ ) +} + +const CELL = 11 +const STEP = 13 +const CAL_WEEKS = 53 +const CAL_GUTTER_X = 30 +const CAL_GUTTER_Y = 16 + +/** GitHub-style year calendar over by_day, colored with the ember ramp. */ +function CalendarHeatmap({ byDay, metric }: { byDay: KbActivity['by_day']; metric: DayMetric }) { + const [tip, setTip] = useState(null) + const today = localToday() + const start = daysAgo(today, (CAL_WEEKS - 1) * 7 + weekdayRow(today)) + + const cells: { w: number; r: number; date: Date; count: number }[] = [] + let max = 0 + for (let w = 0; w < CAL_WEEKS; w++) { + for (let r = 0; r < 7; r++) { + const date = new Date(start) + date.setDate(start.getDate() + w * 7 + r) + if (date.getTime() > today.getTime()) break + const count = byDay[dateKey(date)]?.[metric] ?? 0 + if (count > max) max = count + cells.push({ w, r, date, count }) + } + } + + const boundaries: { w: number; label: string }[] = [] + let prevMonth = -1 + for (let w = 0; w < CAL_WEEKS; w++) { + const monday = new Date(start) + monday.setDate(start.getDate() + w * 7) + if (monday.getTime() > today.getTime()) break + if (monday.getMonth() !== prevMonth) { + prevMonth = monday.getMonth() + boundaries.push({ w, label: monday.toLocaleDateString(undefined, { month: 'short' }) }) + } + } + // The grid usually starts mid-month; labeling that sliver would put the + // wrong month over the first real columns, so drop it (GitHub does too). + if (boundaries.length > 1 && boundaries[1].w - boundaries[0].w < 3) boundaries.shift() + const monthLabels = boundaries.map((b) => ({ x: CAL_GUTTER_X + b.w * STEP, label: b.label })) + + const active = cells.filter((c) => c.count > 0).length + const shown = cells.reduce((sum, c) => sum + c.count, 0) + const noun = METRICS.find((m) => m.key === metric)?.noun ?? 'event' + const busiest = cells.reduce((top, c) => (c.count > top.count ? c : top), cells[0]) + const summary = + `Activity calendar, last 12 months: ${shown} ${noun}${shown === 1 ? '' : 's'} ` + + `across ${active} active day${active === 1 ? '' : 's'}` + + (busiest && busiest.count > 0 + ? `, busiest ${busiest.date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} with ${busiest.count}` + : '') + const width = CAL_GUTTER_X + CAL_WEEKS * STEP + const height = CAL_GUTTER_Y + 7 * STEP + + return ( +
+ + {monthLabels.map((m) => ( + + {m.label} + + ))} + {[0, 2, 4].map((r) => ( + + {WEEKDAYS[r]} + + ))} + {cells.map(({ w, r, date, count }) => ( + + setTip({ + x: e.clientX, + y: e.clientY, + text: `${count} ${noun}${count === 1 ? '' : 's'} — ${date.toLocaleDateString(undefined, { + weekday: 'short', + month: 'short', + day: 'numeric', + })}`, + }) + } + onMouseLeave={() => setTip(null)} + /> + ))} + + +
+ ) +} + +function RampLegend() { + return ( +
+ less + {HEAT.map((c) => ( + + ))} + more +
+ ) +} + +const HOUR_CELL = 12 +const HOUR_STEP = 14 +const HOUR_GUTTER_X = 30 +const HOUR_GUTTER_Y = 14 + +/** Hour-of-week matrix (Mon-first rows, 24 hour columns). */ +function HourHeatmap({ byHour }: { byHour: number[][] }) { + const [tip, setTip] = useState(null) + const max = Math.max(0, ...byHour.flat()) + let peak = '' + if (max > 0) { + const r = byHour.findIndex((row) => row.includes(max)) + peak = `; busiest ${WEEKDAYS[r]} ${String(byHour[r].indexOf(max)).padStart(2, '0')}:00 with ${max}` + } + const width = HOUR_GUTTER_X + 24 * HOUR_STEP + const height = HOUR_GUTTER_Y + 7 * HOUR_STEP + + return ( +
+ + {[0, 3, 6, 9, 12, 15, 18, 21].map((h) => ( + + {h} + + ))} + {WEEKDAYS.map((d, r) => ( + + {d} + + ))} + {byHour.slice(0, 7).map((row, r) => + row.slice(0, 24).map((count, h) => ( + + setTip({ + x: e.clientX, + y: e.clientY, + text: `${count} event${count === 1 ? '' : 's'} — ${WEEKDAYS[r]} ${String(h).padStart(2, '0')}:00`, + }) + } + onMouseLeave={() => setTip(null)} + /> + )), + )} + + +
+ ) +} + +/** Last 30 days as thin baseline-anchored bars. */ +function DailyBars({ byDay }: { byDay: KbActivity['by_day'] }) { + const [tip, setTip] = useState(null) + const today = localToday() + const days = Array.from({ length: 30 }, (_, i) => { + const date = daysAgo(today, 29 - i) + return { date, count: byDay[dateKey(date)]?.total ?? 0 } + }) + const max = Math.max(0, ...days.map((d) => d.count)) + const total = days.reduce((sum, d) => sum + d.count, 0) + + return ( +
+
+ {days.map(({ date, count }) => ( +
0 && count > 0 ? `${Math.max(4, (count / max) * 100)}%` : '2px', + background: count > 0 ? 'var(--accent)' : 'var(--paper-3)', + }} + onMouseMove={(e) => + setTip({ + x: e.clientX, + y: e.clientY, + text: `${count} event${count === 1 ? '' : 's'} — ${date.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + })}`, + }) + } + onMouseLeave={() => setTip(null)} + /> + ))} +
+
+ {daysAgo(today, 29).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} + today +
+ +
+ ) +} + +/** Ranked label + magnitude bar + count rows (top actors, event mix). */ +function BarList({ items, empty }: { items: [string, number][]; empty: string }) { + if (items.length === 0) return

{empty}

+ const max = Math.max(...items.map(([, n]) => n)) + return ( +
    + {items.map(([name, count]) => ( +
  • + + {name} + + + + + {count} +
  • + ))} +
+ ) +} + +function pct(rate: number | null): string { + return rate === null ? '—' : `${Math.round(rate * 100)}%` +} + +/** One project's dashboard — the aggregated page stacks one per project. */ +function ProjectDashboard({ project, titled }: { project: ProjectState; titled: boolean }) { + const { conn, caps, label } = project + const endpoint = conn.endpoint + const [metric, setMetric] = useState('total') + // null = capabilities still loading; don't call or complain until known. + const supportsActivity = caps === null ? null : caps.methods.includes('kb.activity') + + const status = useQuery({ + queryKey: ['status', endpoint], + queryFn: () => rpc(conn, 'kb.status'), + refetchInterval: 15_000, + }) + const stats = useQuery({ + queryKey: ['stats', endpoint], + queryFn: () => rpc(conn, 'kb.stats', { days: 30 }), + refetchInterval: 30_000, + }) + const activity = useQuery({ + queryKey: ['activity', endpoint], + queryFn: () => + rpc(conn, 'kb.activity', { + // 53 weeks so the oldest drawn calendar column is always inside the window. + days: 371, + tz_offset_minutes: -new Date().getTimezoneOffset(), + tz: Intl.DateTimeFormat().resolvedOptions().timeZone, + }), + // kb.activity scans the whole audit log server-side — poll gently. + refetchInterval: 120_000, + enabled: supportsActivity === true, + }) + useErrorToast(status.isError, status.error) + useErrorToast(stats.isError, stats.error) + useErrorToast(activity.isError, activity.error) + + if (status.isError) + return ( + + ) + + const s = status.data + const t = stats.data + const a = activity.data + + return ( +
+ {titled && ( +

+ + {label} + + {endpoint} +

+ )} + +
+ + + {s && } + {s && } + {s && } + {t?.review && } +
+ + {supportsActivity === false && ( + + + + )} + + {activity.isError && ( + + )} + + {a && a.total_events === 0 && ( + + + + )} + + {a && a.total_events > 0 && ( + <> + +
+ {METRICS.map((m) => ( + + ))} +
+ +
+ } + > + + + +
+
+ + + + + + +
+
+ + + + + + +
+
+ + )} +
+ ) +} + +export function DashboardView() { + const { scoped, aggregated } = useConnection() + return ( +
+ {scoped.map((p) => ( + + ))} +
+ ) +}