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/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..63d65bef --- /dev/null +++ b/docs/superpowers/specs/2026-07-10-session-transcript-viewer-design.md @@ -0,0 +1,352 @@ +# 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`. +- 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: + +- 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 + +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) + +| 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 + 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 + 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: `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: **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`). + +## 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). diff --git a/src/vouch/capabilities.py b/src/vouch/capabilities.py index c0a1cc0a..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", @@ -68,6 +69,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/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 e52b6b3f..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(), @@ -412,6 +426,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(), @@ -761,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, @@ -785,6 +809,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..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( *, @@ -556,6 +582,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/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/transcript.py b/src/vouch/transcript.py new file mode 100644 index 00000000..5ca10d34 --- /dev/null +++ b/src/vouch/transcript.py @@ -0,0 +1,406 @@ +"""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 + +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. +_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 + + +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} + + +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]: + """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]: + 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/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_session_transcript.py b/tests/test_session_transcript.py new file mode 100644 index 00000000..19c0a6d9 --- /dev/null +++ b/tests/test_session_transcript.py @@ -0,0 +1,277 @@ +"""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 capture, transcript +from vouch.storage import KBStore + + +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 + + +# --- 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"}] + + +# --- 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"] + + +# --- 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 + + +# --- 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" 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", diff --git a/webapp/e2e/review-transcript.spec.ts b/webapp/e2e/review-transcript.spec.ts new file mode 100644 index 00000000..03a02464 --- /dev/null +++ b/webapp/e2e/review-transcript.spec.ts @@ -0,0 +1,95 @@ +import { expect, test } from '@playwright/test' + +// 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 +// 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.summarize_session', 'kb.session_transcript'], + review_gated: true, +} + +test('review tab renders a picked session 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('/review') + await page.getByText('e2e session').click() + await expect(page.getByText('e2e transcript body')).toBeVisible() +}) 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 c51e9a6c..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 }, @@ -19,10 +20,11 @@ const NAV = [ const TITLES: Record = { '/chat': 'Chat', - '/review': 'Review — sessions to summarize', + '/review': 'Review — session transcripts & summaries', '/pending': 'Pending review', '/claims': 'Approved claims', '/browse': 'Knowledge', + '/dashboard': 'Dashboard — KB activity', '/stats': 'Stats & health', } 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/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/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/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 && ( + + )} +
+ )} +
+ ) +} 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() + }) +}) 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) +} 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) => ( + + ))} +
+ ) +} 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/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()) + }) +}) 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 }])} + /> + ))} + + )} +
+ ) +}