From e46761e8a965c0b21b6780087b4369fb55b9d36d Mon Sep 17 00:00:00 2001 From: MervinPraison <454862+MervinPraison@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:36:14 +0000 Subject: [PATCH 1/8] feat(replay): PraisonAI Replay feature and dashboard view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `replay` feature (contract v1 §5), read-only: - GET /api/replay/runs: run-picker rows, newest first, with a live flag - GET /api/replay/runs/{id}/events?after_seq=N: {events, graph, run} - GET /api/replay/runs/{id}/stream: SSE tail of the JSONL (stdlib seeks), `ctx` events with id=sequence_num, ping every 15 s, `end` after the run finishes plus 2 s quiet; honours after_seq and Last-Event-ID - ids must match ^[A-Za-z0-9._-]+$ and resolve inside the traces dir - traces dir: PRAISONAI_TRACES_DIR > PRAISON_HOME/traces > ~/.praison/traces - half-written last lines, unknown fields/types and schema 1.0 are tolerated Plain-JS view `replay` (no React, no CDN): replay-core.js holds pure buildModel/narrate/layoutMap/deriveGraph (node --test); replay.js draws the timeline (agent, Manager and You lanes, LLM/tool blocks, hand-off arrows, parallel sub-lanes, xN repeats, task/step chapters, scrubber, live follow), the narration list, What happened/Why/Raw JSON drawer, cost/latency overlay, map view, and Approve/Deny/Edit-args for pending approvals. Registered in BUILTIN_VIEWS and as a Control page; /#/replay?run= deep links resolve to /replay?run=. It also boots standalone from window.__REPLAY_DATA__; standalone_html() inlines both files for exports. praisonaiui/replay_app.py + configure_replay_app() open aiui straight on Replay for `praisonai replay view`. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/praisonaiui/features/__init__.py | 1 + src/praisonaiui/features/_trace_jsonl.py | 517 +++++ src/praisonaiui/features/replay.py | 287 +++ src/praisonaiui/replay_app.py | 15 + src/praisonaiui/server.py | 9 + .../templates/frontend/plugins/dashboard.js | 8 + .../frontend/plugins/views/replay-core.js | 949 +++++++++ .../frontend/plugins/views/replay.js | 1730 +++++++++++++++++ tests/browser/test_replay_browser.py | 320 +++ .../fixtures/replay/flow_parallel_loop.jsonl | 42 + tests/fixtures/replay/legacy_v10.jsonl | 16 + tests/fixtures/replay/make_fixtures.py | 312 +++ tests/fixtures/replay/team_handoff.jsonl | 36 + tests/js/replay-core.test.mjs | 345 ++++ tests/unit/test_replay_feature.py | 439 +++++ tests/unit/test_replay_view.py | 123 ++ 16 files changed, 5149 insertions(+) create mode 100644 src/praisonaiui/features/_trace_jsonl.py create mode 100644 src/praisonaiui/features/replay.py create mode 100644 src/praisonaiui/replay_app.py create mode 100644 src/praisonaiui/templates/frontend/plugins/views/replay-core.js create mode 100644 src/praisonaiui/templates/frontend/plugins/views/replay.js create mode 100644 tests/browser/test_replay_browser.py create mode 100644 tests/fixtures/replay/flow_parallel_loop.jsonl create mode 100644 tests/fixtures/replay/legacy_v10.jsonl create mode 100644 tests/fixtures/replay/make_fixtures.py create mode 100644 tests/fixtures/replay/team_handoff.jsonl create mode 100644 tests/js/replay-core.test.mjs create mode 100644 tests/unit/test_replay_feature.py create mode 100644 tests/unit/test_replay_view.py diff --git a/src/praisonaiui/features/__init__.py b/src/praisonaiui/features/__init__.py index 881833f8..dd97c5d4 100644 --- a/src/praisonaiui/features/__init__.py +++ b/src/praisonaiui/features/__init__.py @@ -80,6 +80,7 @@ def auto_register_defaults() -> None: (".tts", "TTSFeature"), (".usage", "UsageFeature"), (".realtime", "RealtimeFeature"), + (".replay", "ReplayFeature"), (".workflows", "WorkflowsFeature"), (".guardrails", "GuardrailsFeature"), (".eval", "EvalFeature"), diff --git a/src/praisonaiui/features/_trace_jsonl.py b/src/praisonaiui/features/_trace_jsonl.py new file mode 100644 index 00000000..fe464c0d --- /dev/null +++ b/src/praisonaiui/features/_trace_jsonl.py @@ -0,0 +1,517 @@ +"""Stdlib reader for PraisonAI context-trace JSONL files (Replay contract v1, section 1). + +One file per run at ``/.jsonl``; one JSON object +(``ContextEvent.to_dict()``) per line, in append order. Readers must tolerate: + +* a partially written last line (it is skipped until it is complete); +* unknown fields and unknown ``event_type`` values (passed through untouched); +* schema 1.0 files (no ``run_id`` / ``parent_seq`` / ``node_id``). + +Shared by the ``replay`` feature (runs, events, SSE tail) and the ``tracing`` +feature (spans from start/end pairs). No third-party imports. +""" + +from __future__ import annotations + +import json +import os +import re +import threading +import time +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional, Tuple + +__all__ = [ + "RUN_ID_RE", + "LIVE_WINDOW_S", + "traces_dir", + "is_valid_run_id", + "run_path", + "list_run_files", + "read_events", + "TailReader", + "EndTracker", + "find_graph", + "summarize_run", + "build_spans", +] + +#: Allowed run ids: one path segment, no separators (path-traversal safe). +RUN_ID_RE = re.compile(r"^[A-Za-z0-9._-]+$") + +#: A run without an end marker counts as live while its file changed this recently. +LIVE_WINDOW_S = 15 * 60 + +#: start event type -> the end event type that closes it. +START_END: Dict[str, str] = { + "session_start": "session_end", + "workflow_start": "workflow_end", + "task_start": "task_end", + "step_start": "step_end", + "agent_start": "agent_end", + "tool_call_start": "tool_call_end", + "llm_request": "llm_response", + "approval_requested": "approval_decided", +} +END_START: Dict[str, str] = {v: k for k, v in START_END.items()} + +_SPAN_KIND = { + "session_start": "session", + "workflow_start": "workflow", + "task_start": "task", + "step_start": "step", + "agent_start": "agent", + "tool_call_start": "tool", + "llm_request": "llm", + "approval_requested": "approval", +} + + +# ── Location ────────────────────────────────────────────────────────── + + +def traces_dir() -> Path: + """Resolve the traces directory. + + Precedence: ``$PRAISONAI_TRACES_DIR`` > ``$PRAISON_HOME/traces`` > ``~/.praison/traces``. + Never creates the directory (this module is read-only). + """ + override = os.environ.get("PRAISONAI_TRACES_DIR") + if override: + return Path(override).expanduser() + home = os.environ.get("PRAISON_HOME") + base = Path(home).expanduser() if home else Path.home() / ".praison" + return base / "traces" + + +def is_valid_run_id(run_id: Any) -> bool: + """True when *run_id* is a single safe path segment.""" + return ( + isinstance(run_id, str) + and 0 < len(run_id) <= 200 + and run_id not in (".", "..") + and bool(RUN_ID_RE.match(run_id)) + ) + + +def run_path(run_id: str, directory: Optional[Path] = None) -> Optional[Path]: + """Return the JSONL path for *run_id*, or ``None`` when the id is unsafe. + + The resolved path is additionally checked to sit directly inside the + traces directory, so a symlinked or odd id can never escape it. + """ + if not is_valid_run_id(run_id): + return None + base = directory or traces_dir() + candidate = base / f"{run_id}.jsonl" + try: + if candidate.resolve().parent != base.resolve(): + return None + except OSError: + return None + return candidate + + +def list_run_files(directory: Optional[Path] = None) -> List[Path]: + """All ``*.jsonl`` files with a valid id, newest (mtime) first.""" + base = directory or traces_dir() + if not base.is_dir(): + return [] + items: List[Tuple[float, Path]] = [] + for p in base.iterdir(): + if p.suffix != ".jsonl" or not is_valid_run_id(p.stem): + continue + try: + st = p.stat() + except OSError: + continue + if p.is_file(): + items.append((st.st_mtime, p)) + items.sort(key=lambda it: (it[0], it[1].name), reverse=True) + return [p for _, p in items] + + +# ── Reading ─────────────────────────────────────────────────────────── + + +def _parse_line(raw: bytes) -> Optional[Dict[str, Any]]: + raw = raw.strip() + if not raw: + return None + try: + obj = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, ValueError): + return None + return obj if isinstance(obj, dict) else None + + +class TailReader: + """Incrementally read complete JSON lines from a growing file. + + Keeps a byte offset; each :meth:`poll` returns only events whose line is + complete. A trailing fragment without a newline is consumed only when it + already parses as a JSON object (a proper prefix of a JSON object never + does), so a half-written line is never surfaced. If the file shrinks + (the writer truncates on restart), reading restarts from the top. + """ + + def __init__(self, path: Path) -> None: + self.path = Path(path) + self.offset = 0 + + def poll(self) -> List[Dict[str, Any]]: + try: + size = self.path.stat().st_size + except OSError: + return [] + if size < self.offset: + self.offset = 0 + if size == self.offset: + return [] + try: + with open(self.path, "rb") as fh: + fh.seek(self.offset) + chunk = fh.read(size - self.offset) + except OSError: + return [] + events: List[Dict[str, Any]] = [] + consumed = 0 + start = 0 + while True: + nl = chunk.find(b"\n", start) + if nl < 0: + break + ev = _parse_line(chunk[start:nl]) + if ev is not None: + events.append(ev) + start = nl + 1 + consumed = start + tail = chunk[start:] + if tail.strip(): + ev = _parse_line(tail) + if ev is not None: # complete object, newline not written yet + events.append(ev) + consumed = len(chunk) + self.offset += consumed + return events + + +_CACHE_LOCK = threading.Lock() +_EVENT_CACHE: Dict[str, Tuple[Tuple[int, int], List[Dict[str, Any]]]] = {} +_SUMMARY_CACHE: Dict[str, Tuple[Tuple[int, int, int], Dict[str, Any]]] = {} +_EVENT_CACHE_MAX = 8 +_SUMMARY_CACHE_MAX = 512 + + +def _sig(path: Path) -> Optional[Tuple[int, int]]: + try: + st = path.stat() + except OSError: + return None + return (st.st_mtime_ns, st.st_size) + + +def _seq(ev: Dict[str, Any], default: int = -1) -> int: + s = ev.get("sequence_num") + return s if isinstance(s, int) and not isinstance(s, bool) else default + + +def read_events(path: Path, after_seq: Optional[int] = None) -> List[Dict[str, Any]]: + """All complete events in *path* (append order), optionally ``sequence_num > after_seq``.""" + path = Path(path) + sig = _sig(path) + if sig is None: + return [] + key = str(path) + with _CACHE_LOCK: + hit = _EVENT_CACHE.get(key) + if hit and hit[0] == sig: + events = hit[1] + else: + events = TailReader(path).poll() + with _CACHE_LOCK: + _EVENT_CACHE.pop(key, None) + _EVENT_CACHE[key] = (sig, events) + while len(_EVENT_CACHE) > _EVENT_CACHE_MAX: + _EVENT_CACHE.pop(next(iter(_EVENT_CACHE))) + if after_seq is None: + return list(events) + return [ev for ev in events if _seq(ev) > after_seq] + + +# ── Interpretation ──────────────────────────────────────────────────── + + +class EndTracker: + """Decide when a run has finished, feeding events one at a time. + + The outermost scope is the first ``session_start`` or, failing that, the + first top-level ``workflow_start``. The run has ended once that scope's + end event arrives (``session_end`` always ends it). + """ + + def __init__(self) -> None: + self.outer: Optional[Tuple[str, int]] = None + self.first_workflow: Optional[int] = None + self.ended = False + self.status: Optional[str] = None + self.ended_at: Optional[float] = None + + def feed(self, ev: Dict[str, Any]) -> None: + et = ev.get("event_type") + seq = _seq(ev) + if et == "session_start" and self.outer is None: + self.outer = ("session", seq) + elif et == "workflow_start": + if self.first_workflow is None: + self.first_workflow = seq + if self.outer is None and ev.get("parent_seq") is None: + self.outer = ("workflow", seq) + elif et == "workflow_end": + data = ev.get("data") or {} + ps = ev.get("parent_seq") + if ps is None or ps == self.first_workflow: + if isinstance(data, dict) and data.get("status"): + self.status = str(data["status"]) + if self.outer is None or (self.outer[0] == "workflow" and ps in (None, self.outer[1])): + self._end(ev) + elif et == "session_end": + self._end(ev) + + def _end(self, ev: Dict[str, Any]) -> None: + self.ended = True + ts = ev.get("timestamp") + if isinstance(ts, (int, float)): + self.ended_at = float(ts) + + +def find_graph(events: Iterable[Dict[str, Any]]) -> Optional[Dict[str, Any]]: + """The graph from the first ``workflow_start`` carrying one, else ``None``.""" + for ev in events: + if ev.get("event_type") == "workflow_start": + data = ev.get("data") + if isinstance(data, dict) and isinstance(data.get("graph"), dict): + return data["graph"] + return None + + +def _num(v: Any) -> float: + return float(v) if isinstance(v, (int, float)) and not isinstance(v, bool) else 0.0 + + +def summarize_run(path: Path, now: Optional[float] = None) -> Optional[Dict[str, Any]]: + """Run-picker row for one trace file (cached on mtime/size).""" + path = Path(path) + try: + st = path.stat() + except OSError: + return None + now = time.time() if now is None else now + sig = (st.st_mtime_ns, st.st_size, int(now - st.st_mtime < LIVE_WINDOW_S)) + key = str(path) + with _CACHE_LOCK: + hit = _SUMMARY_CACHE.get(key) + if hit and hit[0] == sig: + return dict(hit[1]) + + events = read_events(path) + tracker = EndTracker() + agents: List[str] = [] + cost = 0.0 + session_id = None + event_run_id = None + started_at = None + kind, name, process = "session", None, None + schema = None + for ev in events: + tracker.feed(ev) + if session_id is None and ev.get("session_id"): + session_id = ev.get("session_id") + if event_run_id is None and ev.get("run_id"): + event_run_id = ev.get("run_id") + if schema is None and ev.get("schema_version"): + schema = str(ev.get("schema_version")) + ts = ev.get("timestamp") + if started_at is None and isinstance(ts, (int, float)): + started_at = float(ts) + agent = ev.get("agent_name") + if isinstance(agent, str) and agent and agent not in agents: + agents.append(agent) + cost += _num(ev.get("cost_usd")) + if ev.get("event_type") == "workflow_start" and name is None: + data = ev.get("data") if isinstance(ev.get("data"), dict) else {} + kind = str(data.get("kind") or "workflow") + name = data.get("name") + process = data.get("process") + live = (not tracker.ended) and (now - st.st_mtime) < LIVE_WINDOW_S + if tracker.ended: + status = tracker.status or "completed" + else: + status = "running" if live else "incomplete" + summary: Dict[str, Any] = { + "run_id": path.stem, + "session_id": session_id, + "path": str(path), + "started_at": started_at, + "status": status, + "kind": kind, + "name": name or (agents[0] if agents else path.stem), + "agents": agents, + "events": len(events), + "cost_usd": round(cost, 6), + "live": live, + "schema_version": schema, + } + if tracker.ended_at is not None: + summary["ended_at"] = tracker.ended_at + if process: + summary["process"] = process + if event_run_id and event_run_id != path.stem: + summary["event_run_id"] = event_run_id + with _CACHE_LOCK: + _SUMMARY_CACHE[key] = (sig, summary) + while len(_SUMMARY_CACHE) > _SUMMARY_CACHE_MAX: + _SUMMARY_CACHE.pop(next(iter(_SUMMARY_CACHE))) + return dict(summary) + + +def _span_name(ev: Dict[str, Any]) -> str: + et = ev.get("event_type") + data = ev.get("data") if isinstance(ev.get("data"), dict) else {} + agent = ev.get("agent_name") or "" + if et == "tool_call_start": + return str(data.get("tool_name") or "tool") + if et == "llm_request": + model = data.get("model") + return f"llm {model}" if model else "llm" + if et == "approval_requested": + return f"approval {data.get('tool') or ''}".strip() + if et in ("task_start", "step_start", "workflow_start"): + return str(data.get("name") or ev.get("node_id") or et.split("_")[0]) + if et == "agent_start": + return agent or "agent" + return "session" + + +def _match_key(ev: Dict[str, Any]) -> Tuple[Any, ...]: + """Identity used to pair a start with its end when ``parent_seq`` is absent.""" + et = ev.get("event_type") + data = ev.get("data") if isinstance(ev.get("data"), dict) else {} + base = END_START.get(et, et) + extra: Any = None + if base == "tool_call_start": + extra = data.get("tool_name") + elif base == "approval_requested": + extra = data.get("approval_id") + elif base in ("task_start", "step_start"): + extra = ev.get("node_id") or data.get("name") + agent = ev.get("agent_name") or "" + if base in ("approval_requested", "session_start", "workflow_start"): + agent = "" # decisions/ends are often emitted by a different emitter name + return (base, agent, ev.get("branch_id"), extra) + + +def build_spans(events: List[Dict[str, Any]], trace_id: str) -> List[Dict[str, Any]]: + """Spans from start/end pairs. + + Schema 1.1: an end event's ``parent_seq`` names its start; a start's + ``parent_seq`` names the enclosing span. Schema 1.0 (no ``parent_seq``): + ends pair with the oldest open start of the same kind/agent/tool, and the + parent is the innermost span open when the start was seen (sequence order). + ``llm_response`` is a point event whose ``parent_seq`` is its enclosing + scope, so LLM spans always pair by order. + """ + ordered = sorted(enumerate(events), key=lambda it: (_seq(it[1], it[0]), it[0])) + spans: Dict[int, Dict[str, Any]] = {} + start_type: Dict[int, str] = {} + start_key: Dict[int, Tuple[Any, ...]] = {} + open_by_key: Dict[Tuple[Any, ...], List[int]] = {} + stack: List[int] = [] + order: List[int] = [] + + for idx, ev in ordered: + et = ev.get("event_type") + seq = _seq(ev, idx) + ts = _num(ev.get("timestamp")) + if et in START_END: + parent = ev.get("parent_seq") + if not (isinstance(parent, int) and parent in spans): + parent = next((s for s in reversed(stack) if spans[s]["status"] == "open"), None) + data = ev.get("data") if isinstance(ev.get("data"), dict) else {} + span = { + "id": f"{trace_id}:{seq}", + "trace_id": trace_id, + "parent_span_id": f"{trace_id}:{parent}" if parent is not None else None, + "agent_id": ev.get("agent_name") or "", + "name": _span_name(ev), + "kind": _SPAN_KIND[et], + "status": "open", + "start_time": ts, + "end_time": None, + "duration_ms": None, + "timestamp": ts, + "attributes": { + k: v + for k, v in { + "node_id": ev.get("node_id"), + "branch_id": ev.get("branch_id"), + "start_seq": seq, + "input": data.get("tool_args") if et == "tool_call_start" else None, + }.items() + if v is not None + }, + "events": [], + } + spans[seq] = span + start_type[seq] = et + start_key[seq] = _match_key(ev) + order.append(seq) + stack.append(seq) + open_by_key.setdefault(start_key[seq], []).append(seq) + continue + if et not in END_START: + continue + start_seq = None + ps = ev.get("parent_seq") + key = _match_key(ev) + if ( + et != "llm_response" + and isinstance(ps, int) + and ps in spans + and spans[ps]["status"] == "open" + and START_END.get(start_type[ps]) == et + ): + start_seq = ps + else: + queue = open_by_key.get(key) or [] + while queue and spans[queue[0]]["status"] != "open": + queue.pop(0) + if queue: + start_seq = queue[0] + if start_seq is None: + continue + q = open_by_key.get(start_key[start_seq]) + if q and start_seq in q: + q.remove(start_seq) + span = spans[start_seq] + data = ev.get("data") if isinstance(ev.get("data"), dict) else {} + dur = data.get("duration_ms") + span["end_time"] = ts + span["duration_ms"] = ( + float(dur) if isinstance(dur, (int, float)) and not isinstance(dur, bool) + else round(max(0.0, ts - span["start_time"]) * 1000, 3) + ) + failed = bool(data.get("error")) or data.get("status") == "failed" or data.get("approved") is False + span["status"] = "error" if failed else "ok" + attrs = span["attributes"] + for k in ("cost_usd", "prompt_tokens", "completion_tokens"): + v = ev.get(k) + if isinstance(v, (int, float)) and not isinstance(v, bool) and v: + attrs[k] = v + if data.get("error"): + attrs["error"] = data.get("error") + if start_seq in stack: + stack.remove(start_seq) + + return [spans[s] for s in order] + diff --git a/src/praisonaiui/features/replay.py b/src/praisonaiui/features/replay.py new file mode 100644 index 00000000..6c7a64bb --- /dev/null +++ b/src/praisonaiui/features/replay.py @@ -0,0 +1,287 @@ +"""PraisonAI Replay feature: read-only access to recorded agent runs. + +Routes (contract v1, section 5): + +* ``GET /api/replay/runs`` run picker rows, newest first +* ``GET /api/replay/runs/{run_id}/events`` ``{events, graph, run}``; ``?after_seq=N`` +* ``GET /api/replay/runs/{run_id}/stream`` SSE tail of the JSONL file + +The trace directory is ``$PRAISONAI_TRACES_DIR``, else ``$PRAISON_HOME/traces``, +else ``~/.praison/traces``. Run ids must match ``^[A-Za-z0-9._-]+$``. + +The browser side lives in ``templates/frontend/plugins/views/replay-core.js`` +(pure functions) and ``replay.js`` (DOM/SVG). :func:`standalone_html` inlines +both into one self-contained page for ``praisonai replay export --format html``. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import time +from pathlib import Path +from typing import Any, Dict, List, Optional + +from starlette.concurrency import run_in_threadpool +from starlette.requests import Request +from starlette.responses import JSONResponse, StreamingResponse +from starlette.routing import Route + +from . import _trace_jsonl as tj +from ._base import BaseFeatureProtocol + +logger = logging.getLogger(__name__) + +#: Page id of the dashboard view; open ``/replay?run=`` (or ``/#/replay?run=``). +REPLAY_PAGE_ID = "replay" + +#: Pages a replay-only app should whitelist (Replay first, then its companions). +REPLAY_PAGES = ("replay", "approvals", "traces") + +_VIEWS_DIR = Path(__file__).resolve().parent.parent / "templates" / "frontend" / "plugins" / "views" +#: Stable on-disk locations of the renderer (also served at /plugins/views/...). +REPLAY_CORE_JS = _VIEWS_DIR / "replay-core.js" +REPLAY_VIEW_JS = _VIEWS_DIR / "replay.js" + + +def configure_replay_app() -> None: + """Configure aiui as a Replay-first dashboard (call before the server starts). + + Equivalent to ``aiui.set_style("dashboard")`` plus + ``aiui.set_pages(["replay", "approvals", "traces"])``; Replay sorts first, + so ``/`` lands on it. Used by ``praisonaiui/replay_app.py``. + """ + from praisonaiui.server import set_pages, set_style + + set_style("dashboard") + set_pages(list(REPLAY_PAGES)) + + +def _json_for_script(obj: Any) -> str: + """JSON that is safe inside an inline ``\n" + f"\n" + f"\n" + "\n\n" + ) + + +def _bad_request(msg: str) -> JSONResponse: + return JSONResponse({"error": msg}, status_code=400) + + +def _parse_after_seq(request: Request) -> tuple[Optional[int], Optional[JSONResponse]]: + """``?after_seq=N``; an SSE reconnect's ``Last-Event-ID`` wins when it is later.""" + values = [] + for raw, strict in ( + (request.query_params.get("after_seq"), True), + (request.headers.get("last-event-id"), False), + ): + if raw in (None, ""): + continue + try: + values.append(int(raw)) + except (TypeError, ValueError): + if strict: + return None, _bad_request("after_seq must be an integer") + return (max(values) if values else None), None + + +def _resolve(request: Request) -> tuple[Optional[Path], Optional[JSONResponse]]: + run_id = request.path_params.get("run_id", "") + path = tj.run_path(run_id) + if path is None: + return None, _bad_request("invalid run id (allowed: A-Z a-z 0-9 . _ -)") + if not path.is_file(): + return None, JSONResponse({"error": "run not found"}, status_code=404) + return path, None + + +def _seq_of(ev: Dict[str, Any]) -> Optional[int]: + s = ev.get("sequence_num") + return s if isinstance(s, int) and not isinstance(s, bool) else None + + +class ReplayFeature(BaseFeatureProtocol): + """Recorded-run timeline, map and live tail (PraisonAI Replay).""" + + feature_name = "replay" + feature_description = "Replay recorded agent runs (timeline, map, live tail)" + + # Stream timings; instance attributes so tests can shorten them. + poll_interval = 0.25 + ping_interval = 15.0 + quiet_period = 2.0 + + @property + def name(self) -> str: + return self.feature_name + + @property + def description(self) -> str: + return self.feature_description + + def routes(self) -> List[Route]: + return [ + Route("/api/replay/runs", self._runs, methods=["GET"]), + Route("/api/replay/runs/{run_id}/events", self._events, methods=["GET"]), + Route("/api/replay/runs/{run_id}/stream", self._stream, methods=["GET"]), + ] + + async def health(self) -> Dict[str, Any]: + d = tj.traces_dir() + return { + "status": "ok", + "feature": self.name, + "traces_dir": str(d), + "runs": len(tj.list_run_files(d)), + } + + def cli_commands(self) -> List[Dict[str, Any]]: + return [ + { + "name": "replay", + "help": "List recorded runs (PraisonAI Replay)", + "commands": {"list": {"help": "List recorded runs", "handler": self._cli_list}}, + } + ] + + # ── data ────────────────────────────────────────────────────────── + + @staticmethod + def list_runs(limit: int = 200) -> List[Dict[str, Any]]: + rows = [] + now = time.time() + for path in tj.list_run_files()[: max(1, limit)]: + row = tj.summarize_run(path, now=now) + if row is not None: + rows.append(row) + rows.sort(key=lambda r: (r.get("started_at") or 0.0, r["run_id"]), reverse=True) + return rows + + def _cli_list(self) -> str: + rows = self.list_runs() + if not rows: + return f"No recorded runs in {tj.traces_dir()}" + lines = [f"Runs ({len(rows)}):"] + for r in rows: + live = " LIVE" if r.get("live") else "" + lines.append( + f" {r['run_id']} {r.get('name')} {r['status']}{live} " + f"events={r['events']} ${r['cost_usd']:.4f}" + ) + return "\n".join(lines) + + # ── handlers ────────────────────────────────────────────────────── + + async def _runs(self, request: Request) -> JSONResponse: + try: + limit = int(request.query_params.get("limit", "200")) + except ValueError: + return _bad_request("limit must be an integer") + rows = await run_in_threadpool(self.list_runs, limit) + return JSONResponse(rows) + + async def _events(self, request: Request) -> JSONResponse: + path, err = _resolve(request) + if err is not None: + return err + after_seq, err = _parse_after_seq(request) + if err is not None: + return err + + def load() -> Dict[str, Any]: + all_events = tj.read_events(path) + events = all_events if after_seq is None else tj.read_events(path, after_seq) + return { + "events": events, + "graph": tj.find_graph(all_events), + "run": tj.summarize_run(path), + } + + return JSONResponse(await run_in_threadpool(load)) + + async def _stream(self, request: Request) -> StreamingResponse: + path, err = _resolve(request) + if err is not None: + return err # type: ignore[return-value] + after_seq, err = _parse_after_seq(request) + if err is not None: + return err # type: ignore[return-value] + + poll, ping, quiet = self.poll_interval, self.ping_interval, self.quiet_period + + async def gen(): + tail = tj.TailReader(path) + tracker = tj.EndTracker() + last_seq = after_seq + loop = asyncio.get_running_loop() + last_activity = last_ping = loop.time() + yield "retry: 3000\n\n" + while True: + events = await run_in_threadpool(tail.poll) + now = loop.time() + if events: + last_activity = now + for ev in events: + tracker.feed(ev) + seq = _seq_of(ev) + if last_seq is not None and seq is not None and seq <= last_seq: + continue + if seq is not None: + last_seq = seq + sid = f"id: {seq}\n" if seq is not None else "" + yield f"{sid}event: ctx\ndata: {json.dumps(ev, default=str)}\n\n" + idle = now - last_activity + if tracker.ended and idle >= quiet: + yield "event: end\ndata: {}\n\n" + return + if not path.exists() or idle >= tj.LIVE_WINDOW_S: + yield 'event: end\ndata: {"reason": "stale"}\n\n' + return + if now - last_ping >= ping: + last_ping = now + yield "event: ping\ndata: {}\n\n" + await asyncio.sleep(poll) + + return StreamingResponse( + gen(), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"}, + ) diff --git a/src/praisonaiui/replay_app.py b/src/praisonaiui/replay_app.py new file mode 100644 index 00000000..68bd76a1 --- /dev/null +++ b/src/praisonaiui/replay_app.py @@ -0,0 +1,15 @@ +"""Ready-made aiui app that opens straight to PraisonAI Replay. + +Usage (what ``praisonai replay view`` runs):: + + aiui run "$(python -c 'import praisonaiui.replay_app as m; print(m.__file__)')" \ + --style dashboard --host 127.0.0.1 --port 8090 + +then open ``http://127.0.0.1:8090/replay?run=`` (``/#/replay?run=`` +is accepted too). The trace directory follows ``PRAISONAI_TRACES_DIR`` > +``PRAISON_HOME/traces`` > ``~/.praison/traces``. +""" + +from praisonaiui.features.replay import configure_replay_app + +configure_replay_app() diff --git a/src/praisonaiui/server.py b/src/praisonaiui/server.py index d8a8f59b..058eb292 100644 --- a/src/praisonaiui/server.py +++ b/src/praisonaiui/server.py @@ -3345,6 +3345,14 @@ def _on_config_reload(): "description": "Run flight recorder — timeline debugger", "order": 22, }, + { + "id": "replay", + "title": "Replay", + "icon": "⏮️", + "group": "Control", + "description": "Replay recorded runs: who did what, when, why and at what cost", + "order": 21, + }, { "id": "usage", "title": "Usage", @@ -3582,6 +3590,7 @@ def _on_config_reload(): _page_api_overrides = { "sessions": "/sessions", "runs": "/api/sessions", + "replay": "/api/replay/runs", "cron": "/api/schedules", "channels": "/api/channels", "mcp": "/api/mcp/servers", diff --git a/src/praisonaiui/templates/frontend/plugins/dashboard.js b/src/praisonaiui/templates/frontend/plugins/dashboard.js index 52d26220..af293781 100644 --- a/src/praisonaiui/templates/frontend/plugins/dashboard.js +++ b/src/praisonaiui/templates/frontend/plugins/dashboard.js @@ -147,6 +147,7 @@ const BUILTIN_VIEWS = { agents: '/plugins/views/agents.js', sessions: '/plugins/views/sessions.js', runs: '/plugins/views/run-recorder.js', + replay: '/plugins/views/replay.js', memory: '/plugins/views/memory.js', knowledge: '/plugins/views/knowledge.js', logs: '/plugins/views/logs.js', @@ -1752,6 +1753,13 @@ async function buildDashboard() { renderSlot('shell:header', shellHeader); if (shellHeader.children.length) root.insertBefore(shellHeader, main); + // Hash deep links (e.g. /#/replay?run= from `praisonai replay view`) + // become the path form (/replay?run=) before the page is resolved. + const hashRoute = location.hash.match(/^#\/([A-Za-z0-9_-]+)(\?[^#]*)?$/); + if (hashRoute && pagesData.find(p => p.id === hashRoute[1])) { + history.replaceState(null, '', `/${hashRoute[1]}${hashRoute[2] || ''}`); + } + // Resolve initial page from URL path or default to first page const pathId = location.pathname.replace(/^\//, '') || ''; const initialPage = pathId && pagesData.find(p => p.id === pathId) diff --git a/src/praisonaiui/templates/frontend/plugins/views/replay-core.js b/src/praisonaiui/templates/frontend/plugins/views/replay-core.js new file mode 100644 index 00000000..bf2068b0 --- /dev/null +++ b/src/praisonaiui/templates/frontend/plugins/views/replay-core.js @@ -0,0 +1,949 @@ +/** + * PraisonAI Replay — pure model functions (no DOM, no network, no deps). + * + * buildModel(events, graph) -> { lanes, blocks, arrows, chapters, totals, narration, ... } + * narrate(event, related?) -> one deterministic sentence ('' when not worth saying) + * layoutMap(graph) -> { nodes:[{x,y,w,h,shape,...}], edges:[{points,...}], width, height } + * deriveGraph(events) -> who-talked-to-whom graph for runs without a recorded graph + * + * Input is the trace event stream of contract v1 (ContextEvent.to_dict(), schema + * 1.0 or 1.1). Unknown event types and fields are ignored, never fatal. + * + * Loaded three ways: ES import by the dashboard view (replay.js), `node --test`, + * and inlined into a standalone HTML export (it then also sets + * globalThis.PraisonReplayCore so the inlined replay.js can find it). + */ + +export const PALETTE = Object.freeze({ + agent: '#8B0000', + tool: '#0F7287', + human: '#8A5A00', + manager: '#3C4A5C', +}); + +/** Shape per node/lane kind. Colour is never the only signal. */ +export const SHAPES = Object.freeze({ + agent: 'circle', + tool: 'rounded', + human: 'diamond', + manager: 'square', + task: 'note', + step: 'rect', + start: 'chevron', + end: 'flag', + fork: 'bar', + join: 'bar', + decision: 'hexagon', +}); + +export const HUMAN_LANE = 'human:you'; +export const MANAGER_LANE = 'manager:Manager'; + +const PAIRS = { + tool_call_start: 'tool_call_end', + llm_request: 'llm_response', + approval_requested: 'approval_decided', + task_start: 'task_end', + step_start: 'step_end', + agent_start: 'agent_end', + workflow_start: 'workflow_end', + session_start: 'session_end', +}; +const END_OF = Object.fromEntries(Object.entries(PAIRS).map(([s, e]) => [e, s])); + +/** Approvers that mean "a person decided", anything else is an automatic source. */ +const HUMAN_APPROVERS = new Set(['user', 'human', 'you', 'ui', 'replay', 'console', 'web', 'dashboard']); + +const HANDOFF_VERBS = { + handoff: ['handed off to', 'hands off'], + delegate: ['delegated to', 'delegates'], + route: ['routed to', 'routes'], + spawn: ['spawned', 'spawns'], +}; + +// ── small helpers ───────────────────────────────────────────────────── + +const isNum = (v) => typeof v === 'number' && Number.isFinite(v); +const num = (v) => (isNum(v) ? v : 0); +const str = (v) => (v == null ? '' : String(v)); +const dataOf = (ev) => (ev && ev.data && typeof ev.data === 'object' && !Array.isArray(ev.data) ? ev.data : {}); + +export function eventId(ev, index = 0) { + const seq = isNum(ev && ev.sequence_num) ? ev.sequence_num : `i${index}`; + return `${str(ev && ev.session_id)}:${seq}`; +} + +export function truncate(text, max = 60) { + const s = str(text).replace(/\s+/g, ' ').trim(); + return s.length > max ? `${s.slice(0, max - 1)}…` : s; +} + +/** "850 ms", "1.2 s", "2 min 05 s". Deterministic (no locale). */ +export function formatDuration(ms) { + if (!isNum(ms) || ms < 0) return ''; + if (ms < 1000) return `${Math.round(ms)} ms`; + if (ms < 60000) return `${(ms / 1000).toFixed(1)} s`; + const total = Math.round(ms / 1000); + const m = Math.floor(total / 60); + const s = total % 60; + return `${m} min ${String(s).padStart(2, '0')} s`; +} + +/** "$0.002", "$1.25", "<$0.0001", '' for zero/unknown. */ +export function formatCost(usd) { + if (!isNum(usd) || usd <= 0) return ''; + if (usd < 0.0001) return '<$0.0001'; + if (usd >= 1) return `$${usd.toFixed(2)}`; + return `$${usd.toFixed(4).replace(/0+$/, '').replace(/\.$/, '')}`; +} + +export function stripPrefix(id) { + const s = str(id); + const i = s.indexOf(':'); + return i >= 0 ? s.slice(i + 1) : s; +} + +const ARG_KEYS = ['query', 'q', 'search', 'url', 'path', 'file', 'expression', 'command', 'cmd', 'title', 'text', 'prompt', 'input']; + +/** The most telling argument of a tool call, for one sentence. */ +export function argPreview(args) { + if (args == null) return ''; + if (typeof args !== 'object') return truncate(args, 48); + for (const k of ARG_KEYS) { + if (typeof args[k] === 'string' && args[k]) return truncate(args[k], 48); + } + for (const v of Object.values(args)) { + if (typeof v === 'string' && v) return truncate(v, 48); + } + const keys = Object.keys(args); + return keys.length ? truncate(JSON.stringify(args), 48) : ''; +} + +/** Stable order: sequence_num, then arrival; drops exact duplicate event ids. */ +export function sortEvents(events) { + const seen = new Set(); + const out = []; + (Array.isArray(events) ? events : []).forEach((ev, i) => { + if (!ev || typeof ev !== 'object') return; + const id = eventId(ev, i); + if (isNum(ev.sequence_num)) { + if (seen.has(id)) return; + seen.add(id); + } + out.push({ ev, i }); + }); + out.sort((a, b) => { + const sa = isNum(a.ev.sequence_num) ? a.ev.sequence_num : Infinity; + const sb = isNum(b.ev.sequence_num) ? b.ev.sequence_num : Infinity; + return sa - sb || a.i - b.i; + }); + return out.map((o) => o.ev); +} + +// ── narration ───────────────────────────────────────────────────────── + +/** Quote user text; trailing sentence punctuation is dropped so lines end cleanly. */ +function q(text) { + const t = truncate(str(text).replace(/[\s.!?]+$/, ''), 70); + return t ? `'${t}'` : ''; +} + +function costTail(parts) { + const bits = parts.filter(Boolean); + return bits.length ? ` — ${bits.join(', ')}` : ''; +} + +function durOf(ev, related) { + const d = dataOf(ev); + if (isNum(d.duration_ms)) return d.duration_ms; + if (related && isNum(related.timestamp) && isNum(ev.timestamp)) { + return Math.abs(ev.timestamp - related.timestamp) * 1000; + } + return null; +} + +/** + * One sentence for a meaningful event, from fixed templates (no LLM). + * `related` is the paired event when known (the end for a start, or vice versa), + * so a tool call can be told with its duration and cost in one line. + * Returns '' for events not worth a line (snapshots, unknown types). + */ +export function narrate(ev, related = null) { + if (!ev || typeof ev !== 'object') return ''; + const d = dataOf(ev); + const rd = dataOf(related); + const who = str(ev.agent_name) || 'The run'; + switch (ev.event_type) { + case 'session_start': + return 'Recording started.'; + case 'session_end': + return 'Recording ended.'; + case 'workflow_start': { + const kind = { team: 'Team', flow: 'Flow', agent: 'Agent' }[d.kind] || 'Run'; + const name = d.name ? ` '${truncate(d.name, 60)}'` : ''; + const proc = d.process && d.process !== d.kind ? ` (${d.process})` : ''; + return `${kind}${name} started${proc}.`; + } + case 'workflow_end': { + const dur = formatDuration(durOf(ev, related)); + const verb = d.status === 'failed' ? 'failed' : 'completed'; + return `The run ${verb}${dur ? ` after ${dur}` : ''}.`; + } + case 'task_start': { + const name = d.name || stripPrefix(ev.node_id) || 'task'; + const to = d.agent || ev.agent_name; + return to ? `Task '${truncate(name, 50)}' went to ${to}.` : `Task '${truncate(name, 50)}' started.`; + } + case 'task_end': { + const name = rd.name || d.name || stripPrefix(ev.node_id) || 'task'; + const dur = formatDuration(durOf(ev, related)); + const retries = num(d.retries); + const r = retries ? `, ${retries} ${retries === 1 ? 'retry' : 'retries'}` : ''; + if (d.status === 'failed') return `Task '${truncate(name, 50)}' failed${dur ? ` after ${dur}` : ''}${r}.`; + if (d.status === 'skipped') return `Task '${truncate(name, 50)}' was skipped.`; + return `Task '${truncate(name, 50)}' finished${dur ? ` in ${dur}` : ''}${r}.`; + } + case 'step_start': { + const name = truncate(d.name || stripPrefix(ev.node_id) || 'step', 50); + const pass = isNum(d.iteration) ? ` (pass ${d.iteration})` : ''; + if (d.pattern === 'parallel') return `Step '${name}' fanned out in parallel.`; + if (d.pattern === 'loop' || d.pattern === 'repeat') return `Loop '${name}' started.`; + if (d.pattern === 'route' || d.pattern === 'if') return `Step '${name}' is choosing a route.`; + if (d.pattern === 'discussion') return `Discussion '${name}' started.`; + return `Step '${name}' started${pass}.`; + } + case 'step_end': { + const name = truncate(rd.name || d.name || stripPrefix(ev.node_id) || 'step', 50); + const dur = formatDuration(durOf(ev, related)); + if (d.route) return `Step '${name}' chose route '${truncate(d.route, 40)}'.`; + if (d.status === 'cached') return `Step '${name}' reused a cached result.`; + if (d.status === 'skipped') return `Step '${name}' was skipped.`; + if (d.status === 'failed') return `Step '${name}' failed${dur ? ` after ${dur}` : ''}.`; + return `Step '${name}' finished${dur ? ` in ${dur}` : ''}.`; + } + case 'agent_start': + return `${who} started.`; + case 'agent_end': + return d.status === 'failed' ? `${who} stopped with an error.` : `${who} finished.`; + case 'agent_handoff': { + const kind = HANDOFF_VERBS[d.kind] ? d.kind : 'handoff'; + const from = d.from_agent || ev.agent_name || 'An agent'; + const to = d.to_agent || 'another agent'; + const reason = d.reason ? `: ${q(d.reason)}` : ''; + return `${from} ${HANDOFF_VERBS[kind][0]} ${to}${reason}.`; + } + case 'tool_call_start': { + const tool = d.tool_name || 'a tool'; + const arg = argPreview(d.tool_args); + const forArg = arg ? ` for '${arg}'` : ''; + if (!related) return `${who} is asking ${tool}${forArg}…`; + const dur = formatDuration(durOf(related, ev)); + if (rd.error) return `${who} asked ${tool}${forArg} — failed${dur ? ` after ${dur}` : ''}: ${truncate(rd.error, 60)}.`; + return `${who} asked ${tool}${forArg}${costTail([dur, formatCost(related.cost_usd)])}.`; + } + case 'tool_call_end': { + const tool = d.tool_name || 'A tool'; + const dur = formatDuration(durOf(ev, related)); + if (d.error) return `${tool} failed for ${who}${dur ? ` after ${dur}` : ''}: ${truncate(d.error, 60)}.`; + return `${tool} answered ${who}${costTail([dur, formatCost(ev.cost_usd)])}.`; + } + case 'llm_request': { + const model = d.model ? ` with ${d.model}` : ''; + return `${who} is thinking${model}…`; + } + case 'llm_response': { + const model = d.model || rd.model; + const tokens = num(ev.prompt_tokens) + num(ev.completion_tokens); + const parts = [formatDuration(durOf(ev, related)), tokens ? `${tokens} tokens` : '', formatCost(ev.cost_usd)]; + return `${who} thought${model ? ` with ${model}` : ''}${costTail(parts)}.`; + } + case 'approval_requested': { + const tool = d.tool || 'an action'; + const risk = d.risk ? ` (${d.risk} risk)` : ''; + return d.kind === 'task_output_review' + ? `${who} asked you to review the output of ${tool}${risk}.` + : `${who} asked you to approve ${tool}${risk}.`; + } + case 'approval_decided': { + const tool = rd.tool || d.tool || 'the request'; + const approver = str(d.approver).toLowerCase(); + const auto = approver && !HUMAN_APPROVERS.has(approver); + if (approver === 'timeout') return `${tool} was denied: no answer before the timeout.`; + if (d.approved) { + if (auto) return `${tool} was approved automatically (${d.approver}).`; + return `You approved ${tool}${d.modified ? ' with edited arguments' : ''}.`; + } + const reason = d.reason ? `: ${q(d.reason)}` : ''; + if (auto) return `${tool} was denied automatically (${d.approver})${reason}.`; + return `You denied ${tool}${reason}.`; + } + case 'message_added': { + const text = q(d.content); + if (!text) return ''; + if (d.role === 'user' && !ev.agent_name) return `You said: ${text}.`; + if (d.role === 'user') return `${who} was asked: ${text}.`; + return ''; + } + case 'memory_store': + return `${who} saved a memory.`; + case 'memory_search': + return `${who} searched memory${d.query ? ` for ${q(d.query)}` : ''}.`; + case 'knowledge_search': + return `${who} searched knowledge${d.query ? ` for ${q(d.query)}` : ''}.`; + case 'knowledge_add': + return `${who} added knowledge.`; + default: + return ''; + } +} + +// ── model ───────────────────────────────────────────────────────────── + +function pairKey(ev) { + const t = END_OF[ev.event_type] || ev.event_type; + const d = dataOf(ev); + let extra = ''; + if (t === 'tool_call_start') extra = str(d.tool_name); + else if (t === 'approval_requested') return `${t}|${str(d.approval_id)}`; + else if (t === 'task_start' || t === 'step_start') extra = str(ev.node_id || d.name); + if (t === 'session_start' || t === 'workflow_start') return t; + return `${t}|${str(ev.agent_name)}|${str(ev.branch_id)}|${extra}`; +} + +/** + * Pair start/end events. 1.1: an end's parent_seq names its start. 1.0, or + * llm_response (a point event whose parent_seq is its scope): oldest open start + * with the same kind/agent/branch/tool. Returns Map startIndex -> endIndex. + */ +export function pairEvents(evs) { + const pairs = new Map(); + const bySeq = new Map(); + const open = new Map(); // key -> [index] + evs.forEach((ev, i) => { + const t = ev.event_type; + if (PAIRS[t]) { + if (isNum(ev.sequence_num)) bySeq.set(ev.sequence_num, i); + const k = pairKey(ev); + if (!open.has(k)) open.set(k, []); + open.get(k).push(i); + return; + } + const startType = END_OF[t]; + if (!startType) return; + let si = null; + if (t !== 'llm_response' && isNum(ev.parent_seq) && bySeq.has(ev.parent_seq)) { + const cand = bySeq.get(ev.parent_seq); + if (evs[cand].event_type === startType && !pairs.has(cand)) si = cand; + } + if (si == null) { + const list = open.get(pairKey(ev)) || []; + while (list.length && pairs.has(list[0])) list.shift(); + if (list.length) si = list[0]; + } + if (si == null) return; + pairs.set(si, i); + const list = open.get(pairKey(evs[si])); + if (list) { + const j = list.indexOf(si); + if (j >= 0) list.splice(j, 1); + } + }); + return pairs; +} + +function isManagerName(name, graph) { + if (name === 'Manager') return true; + if (!graph || !Array.isArray(graph.nodes)) return false; + return graph.nodes.some((n) => n && n.kind === 'manager' && (n.label === name || stripPrefix(n.id) === name)); +} + +/** + * Build everything the timeline needs from an event list. + * + * Times are seconds from the first event. Lanes: "You" first, then Manager + * (when present), then agents in graph order, then first appearance. Parallel + * branches (branch_id) of one agent become sub-lanes. Consecutive identical + * blocks in a lane (same kind + label), and consecutive passes of one step, + * collapse into one item with count = N. + */ +export function buildModel(events, graph = null) { + const evs = sortEvents(events); + const ids = evs.map((ev, i) => eventId(ev, i)); + const times = evs.map((ev) => (isNum(ev.timestamp) ? ev.timestamp : null)).filter((t) => t != null); + const t0 = times.length ? Math.min(...times) : 0; + const tEnd = times.length ? Math.max(...times) : 0; + const rel = (ev) => (isNum(ev.timestamp) ? ev.timestamp - t0 : 0); + const pairs = pairEvents(evs); + const endToStart = new Map([...pairs].map(([s, e]) => [e, s])); + + // lanes ------------------------------------------------------------- + const lanes = new Map(); + const branchLabels = new Map(); + for (const ev of evs) { + if (ev.branch_id && ev.event_type === 'step_start' && !branchLabels.has(ev.branch_id)) { + branchLabels.set(ev.branch_id, str(dataOf(ev).name) || str(ev.branch_id)); + } + } + const addLane = (id, kind, label, parent = null, branch = null) => { + if (!lanes.has(id)) lanes.set(id, { id, kind, label, shape: SHAPES[kind] || 'rect', parent, branch, order: lanes.size }); + return lanes.get(id); + }; + addLane(HUMAN_LANE, 'human', 'You'); + const agentOrder = []; + if (graph && Array.isArray(graph.nodes)) { + for (const n of graph.nodes) { + if (n && n.kind === 'manager') agentOrder.push(['manager', n.label || stripPrefix(n.id)]); + } + for (const n of graph.nodes) { + if (n && n.kind === 'agent') agentOrder.push(['agent', n.label || stripPrefix(n.id)]); + } + } + const baseLaneId = (name) => (isManagerName(name, graph) ? MANAGER_LANE : `agent:${name}`); + const ensureAgent = (name) => { + if (!name) return null; + const id = baseLaneId(name); + return addLane(id, id === MANAGER_LANE ? 'manager' : 'agent', id === MANAGER_LANE ? 'Manager' : name).id; + }; + const laneOf = (name, branch) => { + const base = ensureAgent(name); + if (!base || !branch) return base; + const id = `${base}#${branch}`; + addLane(id, lanes.get(base).kind, branchLabels.get(branch) || str(branch), base, branch); + return id; + }; + // Pre-seed in a stable order so lanes don't jump while a live run grows. + const usesManager = evs.some((ev) => isManagerName(ev.agent_name, graph) || dataOf(ev).from_agent === 'Manager'); + if (usesManager || agentOrder.some(([k]) => k === 'manager')) addLane(MANAGER_LANE, 'manager', 'Manager'); + for (const [, name] of agentOrder) ensureAgent(name); + for (const ev of evs) { + if (ev.agent_name) laneOf(ev.agent_name, ev.branch_id); + const d = dataOf(ev); + if (ev.event_type === 'agent_handoff') { + ensureAgent(d.from_agent); + ensureAgent(d.to_agent); + } + } + + // blocks ------------------------------------------------------------ + const raw = []; + const blockOfEvent = new Map(); + evs.forEach((ev, i) => { + const t = ev.event_type; + if (t !== 'tool_call_start' && t !== 'llm_request' && t !== 'approval_requested') return; + const d = dataOf(ev); + const ei = pairs.get(i); + const end = ei != null ? evs[ei] : null; + const ed = dataOf(end); + const start = rel(ev); + const stop = end ? rel(end) : tEnd - t0; + let block; + if (t === 'approval_requested') { + const decided = end != null; + block = { + kind: 'approval', + lane: HUMAN_LANE, + fromLane: laneOf(ev.agent_name, ev.branch_id), + label: str(d.tool) || 'approval', + status: decided ? (ed.approved ? 'approved' : 'denied') : 'pending', + approvalId: str(d.approval_id), + cost: 0, + tokens: 0, + }; + } else if (t === 'tool_call_start') { + block = { + kind: 'tool', + lane: laneOf(ev.agent_name, ev.branch_id) || HUMAN_LANE, + label: str(d.tool_name) || 'tool', + status: end ? (ed.error ? 'error' : 'ok') : 'running', + cost: num(end && end.cost_usd), + tokens: 0, + }; + } else { + const laneId = laneOf(ev.agent_name, ev.branch_id) || HUMAN_LANE; + block = { + kind: 'llm', + lane: laneId, + label: str(d.model || ed.model) || 'LLM', + status: end ? 'ok' : 'running', + cost: num(end && end.cost_usd), + tokens: end ? num(end.prompt_tokens) + num(end.completion_tokens) : 0, + }; + } + const durationMs = end ? (isNum(ed.duration_ms) ? ed.duration_ms : (stop - start) * 1000) : (stop - start) * 1000; + Object.assign(block, { + id: `b:${ids[i]}`, + start, + end: Math.max(stop, start), + open: !end, + durationMs, + startSeq: ev.sequence_num, + endSeq: end ? end.sequence_num : null, + eventIds: end ? [ids[i], ids[ei]] : [ids[i]], + nodeId: ev.node_id || null, + branch: ev.branch_id || null, + agent: str(ev.agent_name), + count: 1, + members: [], + }); + raw.push(block); + }); + + // collapse consecutive repeats within a lane -------------------------- + const blocks = []; + const byLane = new Map(); + for (const b of raw) { + if (!byLane.has(b.lane)) byLane.set(b.lane, []); + byLane.get(b.lane).push(b); + } + for (const list of byLane.values()) { + list.sort((a, b) => a.start - b.start || a.startSeq - b.startSeq); + let cur = null; + for (const b of list) { + const same = cur && b.kind !== 'approval' && cur.kind === b.kind && cur.label === b.label + && !b.open && !cur.open && cur.status === b.status; + if (same) { + if (!cur.members.length) cur.members.push({ ...cur, members: [] }); + cur.members.push({ ...b, members: [] }); + cur.count += 1; + cur.end = Math.max(cur.end, b.end); + cur.cost += b.cost; + cur.tokens += b.tokens; + cur.durationMs += b.durationMs; + cur.eventIds = cur.eventIds.concat(b.eventIds); + cur.endSeq = b.endSeq; + } else { + cur = { ...b }; + blocks.push(cur); + } + } + } + blocks.sort((a, b) => a.start - b.start || a.startSeq - b.startSeq); + for (const b of blocks) for (const id of b.eventIds) blockOfEvent.set(id, b.id); + + // arrows ------------------------------------------------------------ + const arrows = []; + const arrowOfEvent = new Map(); + evs.forEach((ev, i) => { + const d = dataOf(ev); + if (ev.event_type === 'agent_handoff') { + const kind = HANDOFF_VERBS[d.kind] ? d.kind : 'handoff'; + const from = ensureAgent(d.from_agent || ev.agent_name); + const to = ensureAgent(d.to_agent); + if (!from || !to) return; + const a = { id: `a:${ids[i]}`, kind, from, to, t: rel(ev), seq: ev.sequence_num, label: HANDOFF_VERBS[kind][1], eventId: ids[i] }; + arrows.push(a); + arrowOfEvent.set(ids[i], a.id); + } else if (ev.event_type === 'approval_requested') { + const from = laneOf(ev.agent_name, ev.branch_id); + if (!from) return; + const a = { id: `a:${ids[i]}`, kind: 'ask', from, to: HUMAN_LANE, t: rel(ev), seq: ev.sequence_num, label: 'asks you', eventId: ids[i] }; + arrows.push(a); + arrowOfEvent.set(ids[i], a.id); + } else if (ev.event_type === 'approval_decided') { + const si = endToStart.get(i); + const req = si != null ? evs[si] : null; + const to = req ? laneOf(req.agent_name, req.branch_id) : null; + if (!to) return; + const a = { + id: `a:${ids[i]}`, kind: 'answer', from: HUMAN_LANE, to, t: rel(ev), seq: ev.sequence_num, + label: d.approved ? 'approved' : 'denied', eventId: ids[i], + }; + arrows.push(a); + arrowOfEvent.set(ids[i], a.id); + } + }); + + // chapters (tasks and steps) ---------------------------------------- + const chapterStarts = []; + const depthBySeq = new Map(); + evs.forEach((ev, i) => { + if (ev.event_type !== 'task_start' && ev.event_type !== 'step_start') return; + const parentDepth = isNum(ev.parent_seq) && depthBySeq.has(ev.parent_seq) ? depthBySeq.get(ev.parent_seq) : -1; + const depth = parentDepth + 1; + if (isNum(ev.sequence_num)) depthBySeq.set(ev.sequence_num, depth); + const d = dataOf(ev); + const ei = pairs.get(i); + const end = ei != null ? evs[ei] : null; + const ed = dataOf(end); + chapterStarts.push({ + id: `c:${ids[i]}`, + kind: ev.event_type === 'task_start' ? 'task' : 'step', + label: str(d.name) || stripPrefix(ev.node_id) || (ev.event_type === 'task_start' ? 'task' : 'step'), + nodeId: ev.node_id || null, + start: rel(ev), + end: end ? Math.max(rel(end), rel(ev)) : tEnd - t0, + open: !end, + depth, + parentSeq: isNum(ev.parent_seq) ? ev.parent_seq : null, + status: end ? str(ed.status) || 'completed' : 'running', + pattern: d.pattern || null, + route: ed.route || null, + agent: str(d.agent || ev.agent_name), + branch: ev.branch_id || null, + seq: ev.sequence_num, + eventIds: end ? [ids[i], ids[ei]] : [ids[i]], + count: 1, + members: [], + }); + }); + const chapters = []; + const lastAt = new Map(); // `${depth}|${parent}` -> chapter + for (const c of chapterStarts) { + const key = `${c.depth}|${c.parentSeq}|${c.branch}`; + const prev = lastAt.get(key); + if (prev && prev.nodeId && prev.nodeId === c.nodeId && !prev.open && !c.open) { + if (!prev.members.length) prev.members.push({ ...prev, members: [] }); + prev.members.push({ ...c, members: [] }); + prev.count += 1; + prev.end = Math.max(prev.end, c.end); + prev.eventIds = prev.eventIds.concat(c.eventIds); + prev.status = c.status; + continue; + } + const copy = { ...c }; + chapters.push(copy); + lastAt.set(key, copy); + } + // pack chapters into rows (overlaps at one depth, e.g. parallel branches, get their own row) + const rowsByDepth = new Map(); + let chapterRows = 0; + const maxDepth = chapters.reduce((m, c) => Math.max(m, c.depth), -1); + for (let depth = 0; depth <= maxDepth; depth++) { + const rows = []; + for (const c of chapters.filter((x) => x.depth === depth).sort((a, b) => a.start - b.start)) { + let r = rows.findIndex((endAt) => endAt <= c.start + 1e-9); + if (r < 0) { rows.push(c.end); r = rows.length - 1; } else rows[r] = c.end; + c.row = chapterRows + r; + } + rowsByDepth.set(depth, rows.length); + chapterRows += rows.length; + } + const chapterOfEvent = new Map(); + for (const c of chapters) for (const id of c.eventIds) chapterOfEvent.set(id, c.id); + + // totals ------------------------------------------------------------ + const agents = {}; + for (const lane of lanes.values()) { + if (lane.parent || lane.kind === 'human') continue; + agents[lane.id] = { label: lane.label, cost_usd: 0, busy_ms: 0, llm_ms: 0, tool_ms: 0, tokens: 0, llm_calls: 0, tool_calls: 0 }; + } + const totals = { cost_usd: 0, duration_ms: (tEnd - t0) * 1000, tokens: 0, llm_calls: 0, tool_calls: 0, events: evs.length, agents }; + for (const b of raw) { + if (b.kind === 'approval') continue; + const lane = lanes.get(b.lane); + const owner = agents[lane && lane.parent ? lane.parent : b.lane]; + totals.cost_usd += b.cost; + totals.tokens += b.tokens; + if (b.kind === 'llm') totals.llm_calls += 1; else totals.tool_calls += 1; + if (!owner) continue; + owner.cost_usd += b.cost; + owner.tokens += b.tokens; + owner.busy_ms += b.durationMs; + if (b.kind === 'llm') { owner.llm_ms += b.durationMs; owner.llm_calls += 1; } else { owner.tool_ms += b.durationMs; owner.tool_calls += 1; } + } + // costs recorded on events outside blocks (e.g. orphan ends) still count toward the run + const blockCost = totals.cost_usd; + const eventCost = evs.reduce((s, ev) => s + num(ev.cost_usd), 0); + if (eventCost > blockCost + 1e-12) totals.cost_usd = eventCost; + + // narration --------------------------------------------------------- + const narration = []; + evs.forEach((ev, i) => { + const t = ev.event_type; + let related = null; + if (PAIRS[t]) { + const ei = pairs.get(i); + related = ei != null ? evs[ei] : null; + } else if (END_OF[t]) { + const si = endToStart.get(i); + related = si != null ? evs[si] : null; + // a paired tool/llm end is already told by its start line + if (related && (t === 'tool_call_end')) return; + } + if (t === 'llm_request' && related) return; // told by the response + const text = narrate(ev, related); + if (!text) return; + const id = ids[i]; + const startId = related && END_OF[t] ? ids[endToStart.get(i)] : null; + narration.push({ + id, + seq: ev.sequence_num, + t: rel(ev), + type: t, + text, + agent: str(ev.agent_name), + lane: ev.agent_name ? laneOf(ev.agent_name, ev.branch_id) : (t.startsWith('approval') ? HUMAN_LANE : null), + blockId: blockOfEvent.get(id) || (startId && blockOfEvent.get(startId)) || null, + arrowId: arrowOfEvent.get(id) || null, + chapterId: chapterOfEvent.get(id) || null, + nodeId: ev.node_id || null, + }); + }); + + return { + t0, + duration: tEnd - t0, + lanes: [...lanes.values()], + blocks, + arrows, + chapters, + chapterRows, + totals, + narration, + events: evs, + eventIndex: new Map(ids.map((id, i) => [id, i])), + pending: blocks.filter((b) => b.kind === 'approval' && b.status === 'pending'), + }; +} + +// ── graph helpers ───────────────────────────────────────────────────── + +/** Graph node ids an event touches (node_id, else derived from agent/tool names). */ +export function nodeIdsForEvent(ev) { + const out = []; + if (!ev) return out; + if (ev.node_id) out.push(ev.node_id); + const d = dataOf(ev); + if (ev.agent_name) { + out.push(ev.agent_name === 'Manager' ? MANAGER_LANE : `agent:${ev.agent_name}`); + if (d.tool_name) out.push(`tool:${ev.agent_name}/${d.tool_name}`); + } + if (String(ev.event_type).startsWith('approval_')) out.push(HUMAN_LANE); + return [...new Set(out)]; +} + +/** + * A who-talked-to-whom graph for runs that carry none (schema 1.0, plain + * sessions): agents, their tools, the manager and you, with hand-off/uses/reviews + * edges labelled with how often they happened. + */ +export function deriveGraph(events) { + const evs = sortEvents(events); + const nodes = new Map(); + const edges = new Map(); + const node = (id, kind, label) => { if (!nodes.has(id)) nodes.set(id, { id, kind, label, parent: null, meta: {} }); return id; }; + const agentNode = (name) => (name === 'Manager' ? node(MANAGER_LANE, 'manager', 'Manager') : node(`agent:${name}`, 'agent', name)); + const edge = (source, target, kind) => { + const id = `${source}->${target}:${kind}`; + const e = edges.get(id) || { id, source, target, kind, label: null, count: 0 }; + e.count += 1; + e.label = e.count > 1 ? `×${e.count}` : null; + edges.set(id, e); + }; + evs.forEach((ev) => { + const d = dataOf(ev); + if (ev.agent_name) agentNode(ev.agent_name); + if (ev.event_type === 'agent_handoff' && (d.from_agent || ev.agent_name) && d.to_agent) { + const kind = { delegate: 'delegates', route: 'routes', spawn: 'handoff' }[d.kind] || 'handoff'; + edge(agentNode(d.from_agent || ev.agent_name), agentNode(d.to_agent), kind); + } else if (ev.event_type === 'tool_call_start' && ev.agent_name && d.tool_name) { + const tool = node(`tool:${ev.agent_name}/${d.tool_name}`, 'tool', d.tool_name); + edge(agentNode(ev.agent_name), tool, 'uses'); + } else if (ev.event_type === 'approval_requested') { + const you = node(HUMAN_LANE, 'human', 'You'); + const target = d.tool && ev.agent_name ? node(`tool:${ev.agent_name}/${d.tool}`, 'tool', d.tool) : (ev.agent_name ? agentNode(ev.agent_name) : null); + if (target) edge(you, target, 'reviews'); + } + }); + return { + schema: 'praisonai.graph/1', + kind: 'derived', + name: 'Who talked to whom', + process: null, + derived: true, + nodes: [...nodes.values()], + edges: [...edges.values()], + }; +} + +/** + * Own layered layout, left to right. Back edges (cycles such as loops) are + * found by DFS and ignored for layering, then drawn underneath. depends_on edges + * point from dependant to dependency, so they are reversed for layering. + * Within a layer, order comes from a few barycentre sweeps (deterministic). + */ +export function layoutMap(graph, opts = {}) { + const nodeW = opts.nodeWidth || 148; + const nodeH = opts.nodeHeight || 56; + const gapX = opts.gapX || 72; + const gapY = opts.gapY || 28; + const margin = opts.margin || 24; + const rawNodes = graph && Array.isArray(graph.nodes) ? graph.nodes.filter((n) => n && n.id) : []; + const index = new Map(); + const nodes = []; + rawNodes.forEach((n) => { + if (index.has(n.id)) return; + index.set(n.id, nodes.length); + nodes.push({ id: n.id, kind: n.kind || 'step', label: str(n.label) || stripPrefix(n.id), parent: n.parent || null, shape: SHAPES[n.kind] || 'rect' }); + }); + const rawEdges = graph && Array.isArray(graph.edges) ? graph.edges.filter((e) => e && index.has(e.source) && index.has(e.target)) : []; + const edges = rawEdges.map((e, k) => { + const reversed = e.kind === 'depends_on'; + return { + id: e.id || `${e.source}->${e.target}:${e.kind}#${k}`, + source: e.source, + target: e.target, + kind: e.kind || 'next', + label: e.label == null ? null : str(e.label), + u: index.get(reversed ? e.target : e.source), + v: index.get(reversed ? e.source : e.target), + back: false, + }; + }); + const n = nodes.length; + const out = Array.from({ length: n }, () => []); + edges.forEach((e, k) => { if (e.u !== e.v) out[e.u].push(k); else e.back = true; }); + + // DFS from sources (in definition order) to mark back edges + const state = new Array(n).fill(0); + const indeg = new Array(n).fill(0); + edges.forEach((e) => { if (e.u !== e.v) indeg[e.v] += 1; }); + const roots = [...nodes.keys()].sort((a, b) => (indeg[a] === 0 ? 0 : 1) - (indeg[b] === 0 ? 0 : 1) || a - b); + const visit = (start) => { + const stack = [[start, 0]]; + state[start] = 1; + while (stack.length) { + const top = stack[stack.length - 1]; + const [u, k] = top; + if (k < out[u].length) { + top[1] += 1; + const e = edges[out[u][k]]; + if (state[e.v] === 1) e.back = true; + else if (state[e.v] === 0) { state[e.v] = 1; stack.push([e.v, 0]); } + } else { + state[u] = 2; + stack.pop(); + } + } + }; + for (const r of roots) if (state[r] === 0) visit(r); + + // longest-path layering over forward edges (Kahn) + const layer = new Array(n).fill(0); + const fwdIn = new Array(n).fill(0); + const fwdOut = Array.from({ length: n }, () => []); + edges.forEach((e) => { if (!e.back && e.u !== e.v) { fwdIn[e.v] += 1; fwdOut[e.u].push(e.v); } }); + const hasIn = fwdIn.map((c) => c > 0); + const queue = [...nodes.keys()].filter((i) => fwdIn[i] === 0); + while (queue.length) { + const u = queue.shift(); + for (const v of fwdOut[u]) { + layer[v] = Math.max(layer[v], layer[u] + 1); + if (--fwdIn[v] === 0) queue.push(v); + } + } + // Pull pure sources (e.g. "You", who only reviews) next to their nearest target. + for (let i = 0; i < n; i++) { + if (nodes[i].kind === 'start' || hasIn[i] || !fwdOut[i].length) continue; + layer[i] = Math.max(layer[i], Math.min(...fwdOut[i].map((v) => layer[v])) - 1); + } + const layerCount = n ? Math.max(...layer) + 1 : 0; + + // Long forward edges get one virtual waypoint per layer they cross, so they + // are ordered like nodes and route between boxes instead of through them. + const itemLayer = [...layer]; + const chains = new Map(); + const links = []; + edges.forEach((e, k) => { + if (e.back || e.u === e.v) return; + const chain = [e.u]; + for (let L = itemLayer[e.u] + 1; L < itemLayer[e.v]; L++) { + chain.push(itemLayer.length); + itemLayer.push(L); + } + chain.push(e.v); + chains.set(k, chain); + for (let j = 0; j + 1 < chain.length; j++) links.push([chain[j], chain[j + 1]]); + }); + const items = itemLayer.length; + const layers = Array.from({ length: layerCount }, () => []); + for (let i = 0; i < items; i++) layers[itemLayer[i]].push(i); + const up = Array.from({ length: items }, () => []); + const down = Array.from({ length: items }, () => []); + for (const [a, b] of links) { down[a].push(b); up[b].push(a); } + + // barycentre ordering sweeps + const pos = new Array(items).fill(0); + layers.forEach((L) => L.forEach((i, k) => { pos[i] = k; })); + for (let sweep = 0; sweep < 6; sweep++) { + const fromLeft = sweep % 2 === 0; + const order = fromLeft ? layers.slice(1) : layers.slice(0, -1).reverse(); + for (const L of order) { + const bary = new Map(L.map((i) => { + const nb = fromLeft ? up[i] : down[i]; + return [i, nb.length ? nb.reduce((acc, j) => acc + pos[j], 0) / nb.length : pos[i]]; + })); + L.sort((a, b) => bary.get(a) - bary.get(b) || pos[a] - pos[b] || a - b); + L.forEach((i, k) => { pos[i] = k; }); + } + } + + const VH = 14; // height of a virtual waypoint slot + const itemH = (i) => (i < n ? nodeH : VH); + const colHeight = (L) => L.reduce((acc, i) => acc + itemH(i), 0) + Math.max(0, L.length - 1) * gapY; + const height = margin * 2 + layers.reduce((m, L) => Math.max(m, colHeight(L)), 0); + const centre = new Array(items); + layers.forEach((L, li) => { + let y = margin + (height - margin * 2 - colHeight(L)) / 2; + const x = margin + li * (nodeW + gapX); + L.forEach((i, k) => { + if (i < n) Object.assign(nodes[i], { layer: li, order: k, w: nodeW, h: nodeH, x, y }); + centre[i] = [x + nodeW / 2, y + itemH(i) / 2]; + y += itemH(i) + gapY; + }); + }); + + const backDrop = 22; + let backLane = height; + const outEdges = edges.map((e, k) => { + const s = nodes[index.get(e.source)]; + const t = nodes[index.get(e.target)]; + let points; + let back = e.back; + if (e.source === e.target) { + points = [[s.x + s.w * 0.7, s.y + s.h], [s.x + s.w * 0.7, s.y + s.h + backDrop], [s.x + s.w * 0.3, s.y + s.h + backDrop], [s.x + s.w * 0.3, s.y + s.h]]; + back = true; + } else if (chains.has(k)) { + const chain = chains.get(k); + const first = nodes[chain[0]]; + const last = nodes[chain[chain.length - 1]]; + points = [[first.x + first.w, first.y + first.h / 2], ...chain.slice(1, -1).map((i) => centre[i]), [last.x, last.y + last.h / 2]]; + if (index.get(e.source) !== chain[0]) points.reverse(); // drawn against layering (depends_on) + } else { + backLane += backDrop; + const yb = Math.max(s.y + s.h, t.y + t.h) + (backLane - height); + points = [[s.x + s.w / 2, s.y + s.h], [s.x + s.w / 2, yb], [t.x + t.w / 2, yb], [t.x + t.w / 2, t.y + t.h]]; + back = true; + } + return { id: e.id, source: e.source, target: e.target, kind: e.kind, label: e.label, back, points }; + }); + const width = n ? margin * 2 + layerCount * nodeW + Math.max(0, layerCount - 1) * gapX : margin * 2; + return { nodes, edges: outEdges, width, height: Math.max(height, backLane) + (backLane > height ? margin : 0), layers: layerCount }; +} + +/** Words for edge kinds on the map (labels never rely on colour). */ +export const EDGE_WORDS = Object.freeze({ + handoff: 'hands off', + delegates: 'delegates', + depends_on: 'depends on', + next: 'then', + routes: 'routes', + parallel: 'in parallel', + loop: 'loops', + assigned: 'assigned', + uses: 'uses', + reviews: 'reviews', +}); + +export function edgeText(edge) { + const word = EDGE_WORDS[edge.kind] || str(edge.kind); + return edge.label ? `${word}: ${edge.label}` : word; +} + +const api = { + PALETTE, SHAPES, HUMAN_LANE, MANAGER_LANE, EDGE_WORDS, + buildModel, narrate, layoutMap, deriveGraph, nodeIdsForEvent, pairEvents, sortEvents, + eventId, formatDuration, formatCost, argPreview, truncate, stripPrefix, edgeText, +}; +if (typeof globalThis !== 'undefined') globalThis.PraisonReplayCore = api; +export default api; diff --git a/src/praisonaiui/templates/frontend/plugins/views/replay.js b/src/praisonaiui/templates/frontend/plugins/views/replay.js new file mode 100644 index 00000000..781000e5 --- /dev/null +++ b/src/praisonaiui/templates/frontend/plugins/views/replay.js @@ -0,0 +1,1730 @@ +/** + * PraisonAI Replay view (/replay) — who did what, when, why, and at what cost. + * + * Run picker : recorded runs from /api/replay/runs (newest first, live badge). + * Timeline : one lane per agent + Manager + You, time left to right, LLM and + * tool blocks, hand-off/delegate arrows, parallel sub-lanes, + * repeats collapsed to ×N, task/step chapters, scrubber (← →). + * Map : the recorded graph (or a derived who-talked-to-whom map). + * Narration : one plain sentence per meaningful event, synced with the canvas. + * Drawer : What happened / Why (recorded evidence only) / Raw JSON. + * You lane : pending approvals can be approved, denied or edited here. + * + * Deep link: /replay?run= (also #/replay?run=). Live runs follow + * /api/replay/runs//stream (SSE). Plain DOM + SVG, no dependencies, no CDN. + * + * Standalone: with window.__REPLAY_DATA__ = {events, graph} set and no aiui + * dashboard present, the module renders itself into #replay-root (or ). + * Pure logic lives in replay-core.js. + */ + +const SVG_NS = 'http://www.w3.org/2000/svg'; +const GUTTER = 184; +const LANE_H = 48; +const SUB_H = 38; +const CH_ROW = 24; +const AXIS_H = 26; +const PAD_R = 96; // room for arrow and cursor labels at the right edge +const MIN_BLOCK = 6; + +let S = null; // the one mounted view's state + +// ── tiny DOM helpers (textContent only, never innerHTML with data) ───── + +function h(tag, attrs, ...kids) { + const node = document.createElement(tag); + setAttrs(node, attrs); + append(node, kids); + return node; +} + +function s(tag, attrs, ...kids) { + const node = document.createElementNS(SVG_NS, tag); + setAttrs(node, attrs); + append(node, kids); + return node; +} + +function setAttrs(node, attrs) { + if (!attrs) return; + for (const [k, v] of Object.entries(attrs)) { + if (v == null || v === false) continue; + if (k === 'class') node.setAttribute('class', v); + else if (k === 'text') node.textContent = v; + else if (k.startsWith('on') && typeof v === 'function') node.addEventListener(k.slice(2), v); + else node.setAttribute(k, v === true ? '' : String(v)); + } +} + +function append(node, kids) { + for (const kid of kids.flat(Infinity)) { + if (kid == null || kid === false) continue; + node.appendChild(typeof kid === 'string' || typeof kid === 'number' ? document.createTextNode(String(kid)) : kid); + } +} + +function clear(node) { + while (node && node.firstChild) node.removeChild(node.firstChild); +} + +const reducedMotion = () => { + try { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; } catch { return false; } +}; + +function storageGet(key) { + try { return window.localStorage.getItem(key); } catch { return null; } +} + +function storageSet(key, value) { + try { window.localStorage.setItem(key, value); } catch { /* private mode */ } +} + +async function getCore() { + if (globalThis.PraisonReplayCore) return globalThis.PraisonReplayCore; + const mod = await import(new URL('./replay-core.js', import.meta.url).href); + return mod.default || mod; +} + +// ── styles ──────────────────────────────────────────────────────────── + +const STYLE = ` +.rp-root{--rp-bg:#FFFFFF;--rp-surface:#F6F7F9;--rp-lane:#F1F3F6;--rp-text:#17181C;--rp-dim:#4F535C;--rp-border:#D5D9E0;--rp-control:#767B85; + --rp-focus:#1A56DB;--rp-agent:#8B0000;--rp-tool:#0F7287;--rp-human:#8A5A00;--rp-manager:#3C4A5C;--rp-neutral:#5B6270;--rp-on-fill:#FFFFFF; + --rp-agent-ink:#8B0000;--rp-tool-ink:#0F7287;--rp-human-ink:#8A5A00;--rp-manager-ink:#3C4A5C;--rp-outline:transparent; + --rp-heat-lo:#C0772F;--rp-heat-hi:#8F2D07;--rp-live:#B3261E;--rp-ok:#1E6B34;--rp-err:#B3261E;--rp-chapter:#E9ECF1;--rp-sel:#DCE7FB; + color:var(--rp-text);background:var(--rp-bg);font:14px/1.45 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif; + border:1px solid var(--rp-border);border-radius:12px;overflow:hidden;color-scheme:light} +.rp-root[data-theme="dark"]{--rp-bg:#16171B;--rp-surface:#1E2026;--rp-lane:#22252C;--rp-text:#F2F3F5;--rp-dim:#B4B8C2;--rp-border:#3A3E48;--rp-control:#8A909C; + --rp-focus:#7FB0FF;--rp-agent-ink:#FF9A9A;--rp-tool-ink:#5FD0E3;--rp-human-ink:#F0BE63;--rp-manager-ink:#B3C0D3;--rp-outline:#E6E8EC; + --rp-heat-lo:#B86A2B;--rp-heat-hi:#FF8A4C;--rp-live:#FF8A80;--rp-ok:#7BD88F;--rp-err:#FF8A80;--rp-chapter:#2A2E37;--rp-sel:#24344F;color-scheme:dark} +.rp-root *{box-sizing:border-box} +.rp-root :focus{outline:none} +.rp-root :focus-visible{outline:3px solid var(--rp-focus);outline-offset:2px} +.rp-sr{position:absolute!important;width:1px;height:1px;margin:-1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0} +.rp-shell{display:grid;grid-template-columns:248px minmax(0,1fr);min-height:72vh} +.rp-shell.rp-solo{grid-template-columns:minmax(0,1fr)} +.rp-runs{border-right:1px solid var(--rp-border);background:var(--rp-surface);overflow:auto;max-height:88vh} +.rp-runs h2{font-size:13px;letter-spacing:.02em;text-transform:uppercase;margin:0;padding:12px 14px 6px;color:var(--rp-dim)} +.rp-runs ul{list-style:none;margin:0;padding:4px 8px 12px} +.rp-run{display:block;width:100%;text-align:left;background:transparent;color:var(--rp-text);border:1px solid transparent;border-radius:8px;padding:8px 10px;margin:2px 0;cursor:pointer;font:inherit} +.rp-run:hover{border-color:var(--rp-control)} +.rp-run[aria-current="true"]{background:var(--rp-sel);border-color:var(--rp-focus)} +.rp-run-name{font-weight:600;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.rp-run-meta{display:block;color:var(--rp-dim);font-size:12px} +.rp-badge{display:inline-flex;align-items:center;gap:5px;font-size:12px;font-weight:600;border:1px solid currentColor;border-radius:999px;padding:0 8px;line-height:18px;white-space:nowrap} +.rp-b-live{color:var(--rp-live)}.rp-b-completed{color:var(--rp-ok)}.rp-b-failed{color:var(--rp-err)}.rp-b-running,.rp-b-incomplete{color:var(--rp-dim)} +.rp-dot{width:8px;height:8px;border-radius:50%;background:currentColor;display:inline-block} +.rp-main{display:flex;flex-direction:column;min-width:0} +.rp-bar{display:flex;flex-wrap:wrap;gap:8px 12px;align-items:center;padding:10px 14px;border-bottom:1px solid var(--rp-border)} +.rp-title{font-size:16px;margin:0;font-weight:650;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:40ch} +.rp-meta{color:var(--rp-dim);font-size:13px} +.rp-spacer{flex:1} +.rp-btn{font:inherit;font-size:13px;color:var(--rp-text);background:var(--rp-bg);border:1px solid var(--rp-control);border-radius:8px;padding:4px 10px;cursor:pointer;min-height:30px} +.rp-btn:hover{background:var(--rp-surface)} +.rp-btn[aria-pressed="true"]{background:var(--rp-text);color:var(--rp-bg);border-color:var(--rp-text)} +.rp-btn[disabled]{opacity:.55;cursor:not-allowed} +.rp-btn-primary{background:var(--rp-human);border-color:var(--rp-human);color:var(--rp-on-fill)} +.rp-seg{display:inline-flex;gap:0}.rp-seg .rp-btn{border-radius:0}.rp-seg .rp-btn:first-child{border-radius:8px 0 0 8px}.rp-seg .rp-btn:last-child{border-radius:0 8px 8px 0;margin-left:-1px} +.rp-select{font:inherit;font-size:13px;color:var(--rp-text);background:var(--rp-bg);border:1px solid var(--rp-control);border-radius:8px;padding:3px 6px;min-height:30px} +.rp-pending{display:flex;flex-direction:column;gap:8px;padding:10px 14px;border-bottom:1px solid var(--rp-border);background:var(--rp-surface)} +.rp-pending[hidden]{display:none} +.rp-ask{border:2px solid var(--rp-human-ink);border-radius:10px;padding:10px 12px;background:var(--rp-bg)} +.rp-ask h3{margin:0 0 4px;font-size:14px;display:flex;gap:8px;align-items:center;flex-wrap:wrap} +.rp-ask pre{margin:6px 0;max-height:120px} +.rp-ask-actions{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-top:6px} +.rp-ask label{font-size:13px;font-weight:600;display:block;margin-top:6px} +.rp-ask textarea,.rp-ask input[type=text]{width:100%;font:12px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--rp-text);background:var(--rp-bg);border:1px solid var(--rp-control);border-radius:8px;padding:6px 8px} +.rp-ask input[type=text]{font:inherit;font-size:13px} +.rp-ask textarea[aria-invalid="true"]{border:2px solid var(--rp-err)} +.rp-err{color:var(--rp-err);font-size:13px;margin:4px 0 0;min-height:1em} +.rp-note{color:var(--rp-dim);font-size:13px;margin:4px 0 0} +.rp-stage{display:grid;grid-template-columns:minmax(0,1fr) 340px;min-height:360px} +.rp-canvas{overflow:auto;position:relative;max-height:62vh;background:var(--rp-bg)} +.rp-canvas svg{display:block} +.rp-narr{position:relative;border-left:1px solid var(--rp-border);overflow:auto;max-height:62vh;background:var(--rp-surface)} +.rp-narr h2{font-size:13px;text-transform:uppercase;letter-spacing:.02em;color:var(--rp-dim);margin:0;padding:10px 12px 4px} +.rp-narr ol{list-style:none;margin:0;padding:0 6px 10px} +.rp-step{display:grid;grid-template-columns:58px 14px minmax(0,1fr);gap:6px;align-items:start;width:100%;text-align:left;font:inherit;font-size:13px;color:var(--rp-text);background:transparent;border:1px solid transparent;border-radius:8px;padding:5px 6px;cursor:pointer} +.rp-step:hover,.rp-step.rp-hl{border-color:var(--rp-control)} +.rp-step[aria-current="step"]{background:var(--rp-sel);border-color:var(--rp-focus);font-weight:600} +.rp-step.rp-future .rp-step-text{font-style:italic} +.rp-step-t{color:var(--rp-dim);font-variant-numeric:tabular-nums;font-size:12px} +.rp-g{width:12px;height:12px;margin-top:3px;display:inline-block;background:var(--rp-neutral)} +.rp-g-agent{border-radius:50%;background:var(--rp-agent)}.rp-g-manager{background:var(--rp-manager)} +.rp-g-tool{border-radius:4px;background:var(--rp-tool);width:14px}.rp-g-human{background:var(--rp-human);transform:rotate(45deg) scale(.8)} +.rp-root[data-theme="dark"] .rp-g{outline:2px solid var(--rp-outline);outline-offset:-1px} +.rp-scrub{display:flex;align-items:center;gap:10px;padding:8px 14px;border-top:1px solid var(--rp-border);flex-wrap:wrap} +.rp-scrub input[type=range]{flex:1;min-width:160px;accent-color:var(--rp-focus)} +.rp-scrub output{font-variant-numeric:tabular-nums;color:var(--rp-dim);font-size:13px;min-width:12ch} +.rp-hint{color:var(--rp-dim);font-size:12px} +.rp-drawer{border-top:1px solid var(--rp-border);padding:0 14px 14px;min-height:180px} +.rp-tabs{display:flex;gap:4px;border-bottom:1px solid var(--rp-border);margin:0 -14px 10px;padding:6px 14px 0} +.rp-tab{font:inherit;font-size:13px;color:var(--rp-dim);background:transparent;border:0;border-bottom:3px solid transparent;padding:6px 10px;cursor:pointer} +.rp-tab[aria-selected="true"]{color:var(--rp-text);border-bottom-color:var(--rp-text);font-weight:650} +.rp-drawer h3{font-size:15px;margin:4px 0 8px} +.rp-facts{display:grid;grid-template-columns:max-content minmax(0,1fr);gap:4px 14px;margin:0 0 8px;font-size:13px} +.rp-facts dt{color:var(--rp-dim)}.rp-facts dd{margin:0;overflow-wrap:anywhere} +.rp-root pre{background:var(--rp-surface);border:1px solid var(--rp-border);border-radius:8px;padding:8px 10px;overflow:auto;max-height:280px;font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace;white-space:pre-wrap;overflow-wrap:anywhere;margin:4px 0 10px;color:var(--rp-text)} +.rp-evidence{list-style:none;margin:0;padding:0} +.rp-evidence li{margin:0 0 10px} +.rp-evidence .rp-src{color:var(--rp-dim);font-size:12px;font-weight:400} +.rp-passes{border-collapse:collapse;font-size:13px;margin:4px 0 10px} +.rp-passes th,.rp-passes td{border-bottom:1px solid var(--rp-border);padding:3px 10px 3px 0;text-align:left;font-variant-numeric:tabular-nums} +.rp-empty{padding:28px 22px;max-width:720px} +.rp-empty h2{font-size:18px;margin:0 0 6px} +.rp-empty p{color:var(--rp-dim);margin:6px 0} +.rp-legend{display:flex;flex-wrap:wrap;gap:6px 14px;padding:6px 14px;font-size:12px;color:var(--rp-dim);border-top:1px solid var(--rp-border)} +.rp-legend span{display:inline-flex;align-items:center;gap:6px} +/* svg */ +.rp-svg text{fill:var(--rp-text);font-size:12px} +.rp-svg .rp-dimtext{fill:var(--rp-dim)} +.rp-svg .rp-lanebg{fill:var(--rp-lane)} +.rp-svg .rp-lanebg-alt{fill:var(--rp-bg)} +.rp-svg .rp-grid{stroke:var(--rp-border);stroke-width:1} +.rp-svg .rp-onfill{fill:var(--rp-on-fill);font-weight:600} +.rp-svg .rp-f-agent{fill:var(--rp-agent)}.rp-svg .rp-f-tool{fill:var(--rp-tool)}.rp-svg .rp-f-human{fill:var(--rp-human)}.rp-svg .rp-f-manager{fill:var(--rp-manager)}.rp-svg .rp-f-neutral{fill:var(--rp-neutral)} +.rp-svg .rp-body{stroke:var(--rp-outline);stroke-width:2} +.rp-svg .rp-glyph{fill:var(--rp-on-fill)} +.rp-svg .rp-ring{fill:none;stroke:none} +.rp-svg .rp-block{cursor:pointer} +.rp-svg .rp-block:focus .rp-ring,.rp-svg .rp-block.rp-sel .rp-ring,.rp-svg .rp-node:focus .rp-ring,.rp-svg .rp-node.rp-sel .rp-ring{stroke:var(--rp-focus);stroke-width:3} +.rp-svg .rp-block.rp-hl .rp-ring,.rp-svg .rp-node.rp-hl .rp-ring{stroke:var(--rp-text);stroke-width:2;stroke-dasharray:4 2} +.rp-svg .rp-future .rp-body{fill:var(--rp-bg);stroke-dasharray:4 3;stroke-width:1.5} +.rp-svg .rp-future.rp-k-llm.rp-o-agent .rp-body{stroke:var(--rp-agent-ink)}.rp-svg .rp-future.rp-k-llm.rp-o-manager .rp-body{stroke:var(--rp-manager-ink)} +.rp-svg .rp-future.rp-k-tool .rp-body{stroke:var(--rp-tool-ink)}.rp-svg .rp-future.rp-k-approval .rp-body{stroke:var(--rp-human-ink)} +.rp-svg .rp-future .rp-glyph{fill:var(--rp-dim)} +.rp-svg .rp-future .rp-onfill{fill:var(--rp-text);font-weight:400} +.rp-svg .rp-open .rp-body{stroke:var(--rp-text);stroke-dasharray:5 3;stroke-width:1.5} +.rp-svg .rp-chapter rect{fill:var(--rp-chapter);stroke:var(--rp-border)} +.rp-svg .rp-chapter.rp-future rect{fill:var(--rp-bg);stroke-dasharray:4 3} +.rp-svg .rp-chapter.rp-hl rect{stroke:var(--rp-text);stroke-width:2} +.rp-svg .rp-arrow path{fill:none;stroke:var(--rp-text);stroke-width:2} +.rp-svg .rp-a-delegate path{stroke-dasharray:7 4}.rp-svg .rp-a-route path{stroke-dasharray:2 4;stroke-linecap:round} +.rp-svg .rp-a-spawn path{stroke-dasharray:9 3 2 3} +.rp-svg .rp-a-ask path,.rp-svg .rp-a-answer path{stroke:var(--rp-human-ink);stroke-width:1.75;stroke-dasharray:4 2} +.rp-svg .rp-arrow.rp-future{opacity:.35} +.rp-svg .rp-arrow.rp-hl path{stroke-width:3.5} +.rp-svg .rp-arrow text{font-size:11px;font-weight:600} +.rp-svg .rp-labelbg{fill:var(--rp-bg);opacity:.92} +.rp-svg .rp-cursor{stroke:var(--rp-focus);stroke-width:2} +.rp-svg .rp-cursor-label{fill:var(--rp-focus);font-weight:700;font-size:11px} +.rp-svg .rp-marker{fill:var(--rp-text)}.rp-svg .rp-marker-human{fill:var(--rp-human-ink)} +.rp-svg .rp-heat{stroke:none} +.rp-svg .rp-node .rp-card{fill:var(--rp-bg);stroke:var(--rp-control);stroke-width:1.5} +.rp-svg .rp-node.rp-unvisited .rp-card{stroke-dasharray:4 3} +.rp-svg .rp-node.rp-now .rp-card{stroke:var(--rp-text);stroke-width:3} +.rp-svg .rp-node{cursor:pointer} +.rp-svg .rp-node .rp-kindword{fill:var(--rp-dim);font-size:11px} +.rp-svg .rp-edge path{fill:none;stroke:var(--rp-control);stroke-width:1.6} +.rp-svg .rp-edge.rp-e-handoff path,.rp-svg .rp-edge.rp-e-delegates path{stroke:var(--rp-text);stroke-width:2} +.rp-svg .rp-edge.rp-e-delegates path{stroke-dasharray:7 4} +.rp-svg .rp-edge.rp-e-routes path{stroke-dasharray:2 4;stroke-linecap:round;stroke:var(--rp-text)} +.rp-svg .rp-edge.rp-e-loop path{stroke-dasharray:6 3} +.rp-svg .rp-edge.rp-e-uses path,.rp-svg .rp-edge.rp-e-assigned path,.rp-svg .rp-edge.rp-e-depends_on path{stroke-dasharray:3 3} +.rp-svg .rp-edge.rp-e-reviews path{stroke:var(--rp-human-ink);stroke-dasharray:4 2} +.rp-svg .rp-edge text{font-size:11px;fill:var(--rp-dim)} +@media (prefers-reduced-motion:no-preference){ + .rp-b-live .rp-dot{animation:rp-pulse 1.6s ease-in-out infinite} + .rp-step,.rp-svg .rp-block,.rp-svg .rp-arrow{transition:opacity .15s ease} +} +@keyframes rp-pulse{0%,100%{opacity:1}50%{opacity:.35}} +@media (max-width:960px){ + .rp-shell{grid-template-columns:minmax(0,1fr)} + .rp-runs{max-height:220px;border-right:0;border-bottom:1px solid var(--rp-border)} + .rp-stage{grid-template-columns:minmax(0,1fr)} + .rp-narr{border-left:0;border-top:1px solid var(--rp-border);max-height:340px} +} +`; + +function ensureStyle() { + if (document.getElementById('rp-style')) return; + const tag = document.createElement('style'); + tag.id = 'rp-style'; + tag.textContent = STYLE; + document.head.appendChild(tag); +} + +// ── theme ───────────────────────────────────────────────────────────── + +function luminance(color) { + const c = String(color || '').trim(); + let rgb = null; + const hex = c.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i); + if (hex) { + const v = hex[1].length === 3 ? hex[1].split('').map((x) => x + x).join('') : hex[1]; + rgb = [0, 2, 4].map((i) => parseInt(v.slice(i, i + 2), 16)); + } else { + const m = c.match(/rgba?\(([^)]+)\)/i); + if (m) { + const parts = m[1].split(/[ ,/]+/).filter(Boolean).map(Number); + if (parts.length >= 4 && parts[3] === 0) return null; // transparent + rgb = parts.slice(0, 3); + } + } + if (!rgb || rgb.some((x) => Number.isNaN(x))) return null; + const lin = rgb.map((v) => { const x = v / 255; return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4; }); + return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2]; +} + +function detectTheme() { + const saved = storageGet('rp-theme'); + if (saved === 'light' || saved === 'dark') return saved; + if (!S.solo) { + const css = getComputedStyle(document.documentElement).getPropertyValue('--db-bg').trim(); + const L = luminance(css) ?? luminance(getComputedStyle(document.body).backgroundColor); + if (L != null) return L < 0.35 ? 'dark' : 'light'; + } + try { return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; } catch { return 'light'; } +} + +// ── URL ─────────────────────────────────────────────────────────────── + +function runFromUrl() { + try { + const qs = new URLSearchParams(window.location.search); + if (qs.get('run')) return qs.get('run'); + const m = window.location.hash.match(/^#\/?replay\?(.*)$/); + if (m) return new URLSearchParams(m[1]).get('run'); + } catch { /* ignore */ } + return null; +} + +function writeRunToUrl(id) { + if (S.solo) return; + try { + const url = new URL(window.location.href); + url.searchParams.set('run', id); + if (/^#\/?replay/.test(url.hash)) url.hash = ''; + window.history.replaceState(window.history.state, '', url.pathname + url.search + url.hash); + } catch { /* ignore */ } +} + +// ── formatting ──────────────────────────────────────────────────────── + +const C = () => S.core; + +function offset(t) { + return `+${t.toFixed(t < 10 ? 2 : 1)} s`; +} + +function prettyJson(value, max = 20000) { + let text; + try { text = JSON.stringify(value, null, 2); } catch { text = String(value); } + return text.length > max ? `${text.slice(0, max)}\n… (${text.length - max} more characters)` : text; +} + +function laneKindClass(lane) { + return lane ? lane.kind : 'agent'; +} + +function statusBadge(row) { + const status = row.live ? 'live' : (row.status || 'completed'); + const text = { live: 'Live', completed: 'Completed', failed: 'Failed', running: 'Running', incomplete: 'Incomplete' }[status] || status; + return h('span', { class: `rp-badge rp-b-${status}` }, status === 'live' ? h('span', { class: 'rp-dot', 'aria-hidden': 'true' }) : null, text); +} + +// ── public API ──────────────────────────────────────────────────────── + +/** + * Mount the view. Dashboard: render(container). Standalone: + * render(container, {standalone: true, events, graph, title}). + */ +export async function render(container, opts = {}) { + cleanup(); + ensureStyle(); + const core = await getCore(); + const solo = Boolean(opts.standalone); + S = { + core, + solo, + container, + abort: new AbortController(), + timers: [], + es: null, + runs: [], + runId: null, + run: null, + events: [], + seen: new Set(), + graph: null, + model: null, + layout: null, + cursor: -1, + follow: true, + view: storageGet('rp-view') === 'map' ? 'map' : 'timeline', + overlay: storageGet('rp-overlay') === '1', + heat: storageGet('rp-heat') === 'latency' ? 'latency' : 'cost', + zoom: null, + tab: 'what', + nodeSel: null, + hover: null, + asks: new Map(), // approvalId -> {editing, text, error, sent, reason} + renderQueued: false, + lastAnnounce: 0, + newSince: 0, + ro: null, + }; + S.theme = detectTheme(); + buildShell(); + if (solo) { + S.run = { run_id: 'export', name: opts.title || 'Recorded run', status: 'completed', live: false }; + setEvents(opts.events || [], opts.graph || null); + S.cursor = S.model.narration.length - 1; + renderAll(); + } else { + await refreshRuns(); + const wanted = runFromUrl(); + const pick = (wanted && S.runs.find((r) => r.run_id === wanted)) || S.runs[0]; + if (pick) await loadRun(pick.run_id); + else renderAll(); + S.timers.push(setInterval(() => { refreshRuns(true); }, 10000)); + } + if (typeof ResizeObserver !== 'undefined') { + S.ro = new ResizeObserver(() => { + if (!S) return; + const w = S.els.canvas.clientWidth; + if (w === S.lastWidth) return; + S.lastWidth = w; + if (S.zoom == null && S.view === 'timeline') queueRender(); + }); + S.ro.observe(S.els.canvas); + } +} + +export function cleanup() { + if (!S) return; + try { S.abort.abort(); } catch { /* ignore */ } + if (S.es) { try { S.es.close(); } catch { /* ignore */ } } + for (const t of S.timers) clearInterval(t); + if (S.ro) { try { S.ro.disconnect(); } catch { /* ignore */ } } + S = null; +} + +// ── data ────────────────────────────────────────────────────────────── + +async function fetchJson(url, init = {}) { + const res = await fetch(url, { signal: S.abort.signal, ...init }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); +} + +async function refreshRuns(autoload = false) { + if (!S || S.solo) return; + try { + const rows = await fetchJson('/api/replay/runs'); + S.runs = Array.isArray(rows) ? rows : []; + } catch (e) { + if (e.name === 'AbortError') return; + S.runs = S.runs || []; + } + if (!S) return; + const row = S.runs.find((r) => r.run_id === S.runId); + if (row && !S.es && S.run && (row.status !== S.run.status || row.live !== S.run.live)) { + S.run = row; + renderBar(); + } + renderRunList(); + if (autoload && !S.runId && S.runs.length) loadRun(S.runs[0].run_id); // first recording appeared +} + +function setEvents(events, graph) { + S.events = []; + S.seen = new Set(); + S.graph = graph || null; // else picked up from workflow_start.data.graph by addEvents + addEvents(events, false); + rebuild(); +} + +function addEvents(list, render = true) { + let added = 0; + for (const ev of list) { + if (!ev || typeof ev !== 'object') continue; + const id = C().eventId(ev, S.events.length); + if (typeof ev.sequence_num === 'number') { + if (S.seen.has(id)) continue; + S.seen.add(id); + } + S.events.push(ev); + if (!S.graph && ev.event_type === 'workflow_start' && ev.data && ev.data.graph) S.graph = ev.data.graph; + added += 1; + } + if (added && render) { + S.newSince += added; + rebuild(); + queueRender(); + } + return added; +} + +function rebuild() { + const wasAtEnd = S.model ? S.cursor >= S.model.narration.length - 1 : true; + S.model = C().buildModel(S.events, S.graph); + S.layout = null; + const n = S.model.narration.length; + if (S.follow && (wasAtEnd || S.run?.live)) S.cursor = n - 1; + S.cursor = Math.min(Math.max(S.cursor, n ? 0 : -1), n - 1); +} + +async function loadRun(id) { + if (!S) return; + if (S.es) { try { S.es.close(); } catch { /* ignore */ } S.es = null; } + S.runId = id; + S.nodeSel = null; + S.asks = new Map(); + writeRunToUrl(id); + renderRunList(); + let data; + try { + data = await fetchJson(`/api/replay/runs/${encodeURIComponent(id)}/events`); + } catch (e) { + if (e.name === 'AbortError') return; + S.run = { run_id: id, name: id, status: 'incomplete', live: false }; + setEvents([], null); + renderAll(`Could not load run ${id} (${e.message}).`); + return; + } + if (!S || S.runId !== id) return; + S.run = data.run || S.runs.find((r) => r.run_id === id) || { run_id: id, name: id }; + S.follow = true; + setEvents(data.events || [], data.graph || null); + S.cursor = S.model.narration.length - 1; + renderAll(); + if (S.run.live) startStream(id); +} + +function maxSeq() { + let m = null; + for (const ev of S.events) if (typeof ev.sequence_num === 'number' && (m == null || ev.sequence_num > m)) m = ev.sequence_num; + return m; +} + +function startStream(id) { + if (typeof EventSource === 'undefined') return; + const after = maxSeq(); + const url = `/api/replay/runs/${encodeURIComponent(id)}/stream${after != null ? `?after_seq=${after}` : ''}`; + const es = new EventSource(url); + S.es = es; + es.addEventListener('ctx', (e) => { + if (!S || S.es !== es) return; + try { addEvents([JSON.parse(e.data)]); } catch { /* skip bad line */ } + }); + es.addEventListener('end', () => { + es.close(); + if (!S || S.es !== es) return; + S.es = null; + if (S.run) S.run = { ...S.run, live: false, status: S.run.status === 'running' ? 'completed' : S.run.status }; + announce('The run finished.'); + refreshRuns(); + queueRender(); + }); +} + +// ── shell ───────────────────────────────────────────────────────────── + +function buildShell() { + const c = S.container; + clear(c); + const els = {}; + els.root = h('section', { class: 'rp-root', 'data-theme': S.theme, 'aria-label': 'PraisonAI Replay' }); + els.shell = h('div', { class: `rp-shell${S.solo ? ' rp-solo' : ''}` }); + if (!S.solo) { + els.runs = h('nav', { class: 'rp-runs', 'aria-label': 'Recorded runs' }); + els.shell.appendChild(els.runs); + } + els.main = h('div', { class: 'rp-main' }); + els.bar = h('div', { class: 'rp-bar' }); + els.pending = h('div', { class: 'rp-pending', role: 'region', 'aria-label': 'Waiting for you', hidden: true }); + els.stage = h('div', { class: 'rp-stage' }); + els.canvas = h('div', { class: 'rp-canvas' }); + els.narr = h('div', { class: 'rp-narr' }); + els.stage.append(els.canvas, els.narr); + els.legend = h('div', { class: 'rp-legend', role: 'group', 'aria-label': 'Legend' }); + els.scrub = h('div', { class: 'rp-scrub' }); + els.drawer = h('div', { class: 'rp-drawer', role: 'region', 'aria-label': 'Details' }); + els.live = h('div', { class: 'rp-sr', 'aria-live': 'polite', role: 'status' }); + els.main.append(els.bar, els.pending, els.stage, els.legend, els.scrub, els.drawer); + els.shell.appendChild(els.main); + els.root.append(els.shell, els.live); + els.root.addEventListener('keydown', onKeyDown); + c.appendChild(els.root); + S.els = els; +} + +function announce(text) { + if (!S) return; + S.els.live.textContent = text; +} + +function queueRender() { + if (!S || S.renderQueued) return; + S.renderQueued = true; + const run = () => { + if (!S) return; + S.renderQueued = false; + renderAll(); + const now = Date.now(); + if (S.run?.live && S.newSince && now - S.lastAnnounce > 4000) { + announce(`${S.newSince} new ${S.newSince === 1 ? 'event' : 'events'}. ${S.model.narration.at(-1)?.text || ''}`); + S.newSince = 0; + S.lastAnnounce = now; + } + }; + if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => setTimeout(run, 120)); + else setTimeout(run, 120); +} + +/** Remember what had focus so a re-render (live updates) doesn't lose it. */ +function focusKey() { + const a = document.activeElement; + if (!a || !S.els.root.contains(a)) return null; + const el = a.closest('[data-key]'); + return el ? el.getAttribute('data-key') : null; +} + +function restoreFocus(key) { + if (!key) return; + const el = S.els.root.querySelector(`[data-key="${CSS.escape(key)}"]`); + if (el) el.focus({ preventScroll: true }); +} + +function renderAll(errorText) { + if (!S) return; + const key = focusKey(); + S.els.root.setAttribute('data-theme', S.theme); + renderRunList(); + renderBar(); + if (!S.solo && !S.runs.length && !S.runId) { + renderEmpty(); + return; + } + renderPending(); + if (S.view === 'map') renderMap(); else renderTimeline(); + renderLegend(); + renderNarration(); + renderScrubber(); + renderDrawer(errorText); + restoreFocus(key); +} + +function renderEmpty() { + for (const k of ['pending', 'narr', 'scrub', 'drawer', 'legend']) clear(S.els[k]); + S.els.pending.hidden = true; + clear(S.els.canvas); + S.els.canvas.appendChild(h('div', { class: 'rp-empty' }, + h('h2', { text: 'No recorded runs yet' }), + h('p', { text: 'Replay shows who did what, when, why and at what cost, once a run has been recorded. Record one and it appears here (live runs update as they go):' }), + h('pre', { text: 'praisonai replay view agents.yaml' }), + h('p', { text: 'or from Python:' }), + h('pre', { text: 'from praisonai.replay import record\n\nwith record(live=True):\n team.start()' }), + h('p', { text: 'Runs are read from $PRAISONAI_TRACES_DIR, else $PRAISON_HOME/traces, else ~/.praison/traces.' }), + )); +} + +// ── run picker ──────────────────────────────────────────────────────── + +function renderRunList() { + if (!S || S.solo || !S.els.runs) return; + const nav = S.els.runs; + const key = focusKey(); + clear(nav); + nav.appendChild(h('h2', { id: 'rp-runs-h', text: 'Runs' })); + if (!S.runs.length) { + nav.appendChild(h('p', { class: 'rp-note', style: 'padding:0 14px', text: 'Nothing recorded yet.' })); + return; + } + const ul = h('ul', { 'aria-labelledby': 'rp-runs-h' }); + for (const r of S.runs) { + const meta = [r.kind, `${(r.agents || []).length} agents`, `${r.events} events`, C().formatCost(r.cost_usd)].filter(Boolean).join(' · '); + const started = typeof r.started_at === 'number' ? new Date(r.started_at * 1000).toLocaleString() : ''; + ul.appendChild(h('li', null, h('button', { + type: 'button', + class: 'rp-run', + 'data-key': `run:${r.run_id}`, + 'aria-current': r.run_id === S.runId ? 'true' : 'false', + onclick: () => loadRun(r.run_id), + }, + h('span', { class: 'rp-run-name', text: r.name || r.run_id }), + h('span', { class: 'rp-run-meta' }, statusBadge(r), ' ', started), + h('span', { class: 'rp-run-meta', text: meta })))); + } + nav.appendChild(ul); + restoreFocus(key); +} + +// ── header bar ──────────────────────────────────────────────────────── + +function renderBar() { + const bar = S.els.bar; + const key = focusKey(); + clear(bar); + try { buildBar(bar); } finally { restoreFocus(key); } +} + +function buildBar(bar) { + const run = S.run; + const m = S.model; + if (run) { + bar.appendChild(h('h2', { class: 'rp-title', text: run.name || run.run_id })); + bar.appendChild(statusBadge(run)); + } + if (m && m.events.length) { + const t = m.totals; + const bits = [C().formatDuration(t.duration_ms), C().formatCost(t.cost_usd), `${t.llm_calls} LLM calls`, `${t.tool_calls} tool calls`].filter(Boolean); + bar.appendChild(h('span', { class: 'rp-meta', text: bits.join(' · ') })); + } + bar.appendChild(h('span', { class: 'rp-spacer' })); + if (!m || !m.events.length) return; + const seg = h('div', { class: 'rp-seg', role: 'group', 'aria-label': 'View' }); + for (const [id, label] of [['timeline', 'Timeline'], ['map', 'Map']]) { + seg.appendChild(h('button', { + type: 'button', class: 'rp-btn', 'aria-pressed': S.view === id ? 'true' : 'false', 'data-key': `view:${id}`, + onclick: () => { S.view = id; storageSet('rp-view', id); renderAll(); }, + }, label)); + } + bar.appendChild(seg); + bar.appendChild(h('button', { + type: 'button', class: 'rp-btn', 'aria-pressed': S.overlay ? 'true' : 'false', 'data-key': 'overlay', + onclick: () => { S.overlay = !S.overlay; storageSet('rp-overlay', S.overlay ? '1' : '0'); renderAll(); }, + }, 'Cost & latency')); + if (S.overlay) { + const sel = h('select', { class: 'rp-select', 'aria-label': 'Heat shows', 'data-key': 'heat', onchange: (e) => { S.heat = e.target.value; storageSet('rp-heat', S.heat); renderAll(); } }, + h('option', { value: 'cost', selected: S.heat === 'cost' }, 'Heat: cost'), + h('option', { value: 'latency', selected: S.heat === 'latency' }, 'Heat: latency')); + bar.appendChild(sel); + } + if (S.view === 'timeline') { + const z = h('div', { class: 'rp-seg', role: 'group', 'aria-label': 'Zoom' }); + z.append( + h('button', { type: 'button', class: 'rp-btn', 'aria-label': 'Zoom out', 'data-key': 'zoom-out', onclick: () => zoomBy(1 / 1.6) }, '−'), + h('button', { type: 'button', class: 'rp-btn', 'aria-label': 'Fit to width', 'data-key': 'zoom-fit', onclick: () => { S.zoom = null; renderAll(); } }, 'Fit'), + h('button', { type: 'button', class: 'rp-btn', 'aria-label': 'Zoom in', 'data-key': 'zoom-in', onclick: () => zoomBy(1.6) }, '+'), + ); + bar.appendChild(z); + } + if (S.run?.live && S.cursor < m.narration.length - 1) { + bar.appendChild(h('button', { type: 'button', class: 'rp-btn', 'data-key': 'golive', onclick: () => { S.follow = true; setCursor(m.narration.length - 1); } }, 'Jump to live')); + } + bar.appendChild(h('button', { + type: 'button', class: 'rp-btn', 'data-key': 'theme', 'aria-label': `Switch to ${S.theme === 'dark' ? 'light' : 'dark'} theme`, + onclick: () => { S.theme = S.theme === 'dark' ? 'light' : 'dark'; storageSet('rp-theme', S.theme); renderAll(); }, + }, S.theme === 'dark' ? 'Light' : 'Dark')); +} + +function zoomBy(f) { + const current = S.zoom || S.fitPps || 60; + S.zoom = Math.min(4000, Math.max(4, current * f)); + renderAll(); +} + +// ── cursor ──────────────────────────────────────────────────────────── + +function cursorTime() { + const n = S.model.narration[S.cursor]; + return n ? n.t : S.model.duration; +} + +function setCursor(i, focusTarget = null) { + if (!S || !S.model) return; + const n = S.model.narration.length; + if (!n) return; + S.cursor = Math.max(0, Math.min(n - 1, i)); + S.follow = S.cursor === n - 1; + S.nodeSel = null; + applyCursor(true); + renderScrubber(); + renderDrawer(); + renderBar(); + if (focusTarget === 'narration') focusStep(S.cursor); + else if (focusTarget === 'canvas') focusCanvasItem(); +} + +function focusStep(i) { + const btn = S.els.narr.querySelector(`[data-i="${i}"]`); + if (btn) btn.focus({ preventScroll: true }); +} + +function focusCanvasItem() { + const item = S.model.narration[S.cursor]; + if (!item) return; + if (S.view === 'timeline' && item.blockId) { + const g = S.els.canvas.querySelector(`[data-block="${CSS.escape(item.blockId)}"]`); + if (g) { g.focus({ preventScroll: true }); return; } + } + const svg = S.els.canvas.querySelector('svg'); + if (svg) svg.focus({ preventScroll: true }); +} + +/** Cheap update of "past / now / future" without rebuilding the canvas. */ +function applyCursor(smooth = false) { + const m = S.model; + const t = cursorTime(); + const cur = m.narration[S.cursor]; + const eps = 1e-6; + for (const g of S.els.canvas.querySelectorAll('[data-start]')) { + const start = Number(g.getAttribute('data-start')); + g.classList.toggle('rp-future', start > t + eps); + } + for (const g of S.els.canvas.querySelectorAll('.rp-block')) { + const on = cur && cur.blockId === g.getAttribute('data-block'); + g.classList.toggle('rp-sel', Boolean(on)); + g.setAttribute('tabindex', on ? '0' : '-1'); + } + if (!S.els.canvas.querySelector('.rp-block[tabindex="0"]')) { + const first = S.els.canvas.querySelector('.rp-block'); + if (first) first.setAttribute('tabindex', '0'); + } + const line = S.els.canvas.querySelector('.rp-cursor-g'); + if (line && S.tl) { + const x = S.tl.x(t); + line.setAttribute('transform', `translate(${x},0)`); + const label = line.querySelector('text'); + if (label) { + label.textContent = offset(t); + const flip = x + 60 > S.tl.width; + label.setAttribute('x', flip ? '-4' : '4'); + label.setAttribute('text-anchor', flip ? 'end' : 'start'); + } + } + if (S.view === 'map') applyMapCursor(); + const list = S.els.narr.querySelectorAll('.rp-step'); + list.forEach((btn) => { + const i = Number(btn.getAttribute('data-i')); + btn.toggleAttribute('aria-current', false); + if (i === S.cursor) btn.setAttribute('aria-current', 'step'); + btn.classList.toggle('rp-future', i > S.cursor); + btn.setAttribute('tabindex', i === S.cursor ? '0' : '-1'); + }); + const curBtn = S.els.narr.querySelector(`[data-i="${S.cursor}"]`); + if (curBtn) { + const box = S.els.narr; + const top = curBtn.offsetTop; // .rp-narr is the offsetParent + if (top < box.scrollTop || top + curBtn.offsetHeight > box.scrollTop + box.clientHeight) { + const target = Math.max(0, top - box.clientHeight / 2); + if (smooth && !reducedMotion() && box.scrollTo) box.scrollTo({ top: target, behavior: 'smooth' }); + else box.scrollTop = target; + } + } +} + +function onKeyDown(e) { + if (!S || !S.model || e.defaultPrevented || e.altKey || e.ctrlKey || e.metaKey) return; + const t = e.target; + const tag = t.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t.isContentEditable) return; + if (t.closest && t.closest('[role="tablist"]')) return; + const inNarr = S.els.narr.contains(t); + const inCanvas = S.els.canvas.contains(t); + const where = inNarr ? 'narration' : (inCanvas ? 'canvas' : null); + const last = S.model.narration.length - 1; + if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { + e.preventDefault(); + setCursor(S.cursor + (e.key === 'ArrowRight' ? 1 : -1), where); + } else if (e.key === 'Home') { + e.preventDefault(); + setCursor(0, where); + } else if (e.key === 'End') { + e.preventDefault(); + setCursor(last, where); + } else if ((e.key === 'ArrowDown' || e.key === 'ArrowUp') && inNarr) { + e.preventDefault(); + setCursor(S.cursor + (e.key === 'ArrowDown' ? 1 : -1), 'narration'); + } else if ((e.key === 'ArrowDown' || e.key === 'ArrowUp') && inCanvas && S.view === 'map') { + e.preventDefault(); + moveMapFocus(e.key === 'ArrowDown' ? 1 : -1); + } else if ((e.key === 'Enter' || e.key === ' ') && inCanvas && t.getAttribute && t.getAttribute('role') === 'button') { + e.preventDefault(); + t.dispatchEvent(new MouseEvent('click', { bubbles: true })); + } +} + +// ── hover / focus sync ──────────────────────────────────────────────── + +function highlight(item) { + const root = S.els.root; + for (const el of root.querySelectorAll('.rp-hl')) el.classList.remove('rp-hl'); + if (!item) return; + const sel = []; + if (item.blockId) sel.push(`[data-block="${CSS.escape(item.blockId)}"]`); + if (item.arrowId) sel.push(`[data-arrow="${CSS.escape(item.arrowId)}"]`); + if (item.chapterId) sel.push(`[data-chapter="${CSS.escape(item.chapterId)}"]`); + if (S.view === 'map') { + const ev = S.model.events[S.model.eventIndex.get(item.id)]; + for (const nid of C().nodeIdsForEvent(ev)) sel.push(`[data-node="${CSS.escape(nid)}"]`); + } + for (const q of sel) for (const el of root.querySelectorAll(q)) el.classList.add('rp-hl'); + for (const btn of S.els.narr.querySelectorAll('.rp-step')) { + const n = S.model.narration[Number(btn.getAttribute('data-i'))]; + const match = n && ((item.blockId && n.blockId === item.blockId) || n.id === item.id); + if (match) btn.classList.add('rp-hl'); + } +} + +function hoverHandlers(item) { + return { + onmouseenter: () => highlight(item), + onmouseleave: () => highlight(null), + onfocus: () => highlight(item), + onblur: () => highlight(null), + }; +} + +// ── timeline ────────────────────────────────────────────────────────── + +function laneRows() { + const m = S.model; + const top = m.lanes.filter((l) => !l.parent); + const rows = []; + for (const lane of top) { + rows.push(lane); + for (const sub of m.lanes.filter((l) => l.parent === lane.id)) rows.push(sub); + } + return rows; +} + +function heatColor(r) { + const css = getComputedStyle(S.els.root); + const lo = css.getPropertyValue('--rp-heat-lo').trim() || '#C0772F'; + const hi = css.getPropertyValue('--rp-heat-hi').trim() || '#8F2D07'; + const p = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)); + const a = p(lo); + const b = p(hi); + const mix = a.map((v, i) => Math.round(v + (b[i] - v) * r)); + return `rgb(${mix.join(',')})`; +} + +function blockText(b) { + const parts = [b.kind === 'llm' ? `LLM ${b.label}` : (b.kind === 'approval' ? `approval: ${b.label}` : b.label)]; + if (b.count > 1) parts.push(`×${b.count}`); + return parts.join(' '); +} + +function blockAria(b, lane) { + const who = lane ? (lane.parent ? `${S.model.lanes.find((l) => l.id === lane.parent)?.label} (${lane.label})` : lane.label) : ''; + const what = b.kind === 'llm' ? `${who} thinking with ${b.label}` : b.kind === 'tool' ? `${who} using ${b.label}` : `Approval for ${b.label}, ${b.status}`; + const bits = [what]; + if (b.count > 1) bits.push(`${b.count} times`); + bits.push(`at ${offset(b.start)}`); + const dur = C().formatDuration(b.durationMs); + if (dur) bits.push(dur); + const cost = C().formatCost(b.cost); + if (cost) bits.push(cost); + if (b.status === 'error') bits.push('failed'); + if (b.open) bits.push('still running'); + return bits.join(', '); +} + +/** Full text clipped to a box (nested viewport), so what is visible is a + * prefix of the real label and the accessible name can contain it verbatim. */ +function clippedText(x, y, w, h, attrs, text) { + return s('svg', { x, y, width: Math.max(0, w), height: h, overflow: 'hidden', 'aria-hidden': 'true' }, + s('text', { ...attrs, text })); +} + +function glyph(kind, x, y, size, cls = 'rp-glyph') { + const r = size / 2; + if (kind === 'agent') return s('circle', { class: cls, cx: x + r, cy: y + r, r: r }); + if (kind === 'manager') return s('rect', { class: cls, x, y, width: size, height: size }); + if (kind === 'human') return s('path', { class: cls, d: `M${x + r},${y} L${x + size},${y + r} L${x + r},${y + size} L${x},${y + r} Z` }); + if (kind === 'tool') return s('rect', { class: cls, x: x - 1, y: y + 1, width: size + 2, height: size - 2, rx: 3 }); + return s('rect', { class: cls, x, y, width: size, height: size, rx: 1 }); +} + +function renderTimeline() { + const m = S.model; + const canvas = S.els.canvas; + const scroll = canvas.scrollLeft; + clear(canvas); + if (!m.events.length) { + canvas.appendChild(h('div', { class: 'rp-empty' }, h('h2', { text: 'No events yet' }), h('p', { text: S.run?.live ? 'This run is live; events appear as they are recorded.' : 'This recording is empty.' }))); + S.tl = null; + return; + } + const avail = Math.max(480, (canvas.clientWidth || 900) - GUTTER - PAD_R); + const dur = Math.max(m.duration, 0.05); + S.fitPps = avail / dur; + const pps = S.zoom || S.fitPps; + const plotW = Math.max(dur * pps, 120); + const x = (t) => GUTTER + t * pps; + const chapterH = m.chapterRows * CH_ROW; + const lanesTop = AXIS_H + chapterH + (chapterH ? 8 : 4); + const rows = laneRows(); + const laneY = new Map(); + let y = lanesTop; + for (const lane of rows) { + const hgt = lane.parent ? SUB_H : LANE_H; + laneY.set(lane.id, { top: y, h: hgt, mid: y + hgt / 2 }); + y += hgt; + } + const height = y + 10; + const width = GUTTER + plotW + PAD_R; + S.tl = { x, laneY, width }; + + const svg = s('svg', { + class: 'rp-svg', width, height, viewBox: `0 0 ${width} ${height}`, role: 'group', tabindex: '-1', + 'aria-label': `Timeline: ${rows.length} lanes, ${m.blocks.length} blocks over ${C().formatDuration(m.duration * 1000)}. Use the step list or the arrow keys to move through it.`, + }); + svg.appendChild(s('defs', null, + s('marker', { id: 'rp-head', viewBox: '0 0 10 10', refX: 9, refY: 5, markerWidth: 7, markerHeight: 7, orient: 'auto-start-reverse' }, s('path', { class: 'rp-marker', d: 'M0,0 L10,5 L0,10 z' })), + s('marker', { id: 'rp-head-h', viewBox: '0 0 10 10', refX: 9, refY: 5, markerWidth: 7, markerHeight: 7, orient: 'auto-start-reverse' }, s('path', { class: 'rp-marker-human', d: 'M0,0 L10,5 L0,10 z' })), + )); + + // lanes and gutter labels + const lanesG = s('g', { 'aria-hidden': 'true' }); + rows.forEach((lane, i) => { + const ly = laneY.get(lane.id); + lanesG.appendChild(s('rect', { class: i % 2 ? 'rp-lanebg-alt' : 'rp-lanebg', x: 0, y: ly.top, width, height: ly.h })); + const label = s('g', null); + if (lane.parent) { + label.appendChild(s('text', { x: 30, y: ly.mid + 4, class: 'rp-dimtext', text: `↳ ${C().truncate(lane.label, 18)}` })); + } else { + label.appendChild(glyph(lane.kind, 10, ly.mid - 7, 14, `rp-body rp-f-${laneKindClass(lane)}`)); + const tot = m.totals.agents[lane.id]; + const showTot = S.overlay && tot && (tot.cost_usd || tot.busy_ms); + label.appendChild(s('text', { x: 32, y: showTot ? ly.mid - 3 : ly.mid + 4, 'font-weight': '600', text: C().truncate(lane.label, 20) })); + if (showTot) { + const bits = [C().formatCost(tot.cost_usd), C().formatDuration(tot.busy_ms) && `${C().formatDuration(tot.busy_ms)} busy`].filter(Boolean).join(' · '); + label.appendChild(s('text', { x: 32, y: ly.mid + 12, class: 'rp-dimtext', 'font-size': '11', text: bits })); + } + } + lanesG.appendChild(label); + }); + lanesG.appendChild(s('line', { class: 'rp-grid', x1: GUTTER - 0.5, x2: GUTTER - 0.5, y1: 0, y2: height })); + svg.appendChild(lanesG); + + // axis ticks + const axis = s('g', { 'aria-hidden': 'true' }); + const step = niceStep(dur, plotW); + for (let t = 0; t <= dur + 1e-9; t += step) { + const tx = x(t); + axis.appendChild(s('line', { class: 'rp-grid', x1: tx, x2: tx, y1: AXIS_H - 6, y2: height, 'stroke-opacity': 0.45 })); + axis.appendChild(s('text', { x: tx + 3, y: AXIS_H - 9, class: 'rp-dimtext', 'font-size': '11', text: tickLabel(t, step) })); + } + svg.appendChild(axis); + + // chapters + const chG = s('g', { 'aria-hidden': 'true' }); + for (const c of m.chapters) { + const cx = x(c.start); + const cw = Math.max(x(c.end) - cx, 10); + const cy = AXIS_H + c.row * CH_ROW; + const prefix = c.kind === 'task' ? 'Task' : ({ parallel: 'Parallel', loop: 'Loop', repeat: 'Repeat', route: 'Route', if: 'If', discussion: 'Discussion' }[c.pattern] || 'Step'); + let text = `${prefix} ${c.label}${c.count > 1 ? ` ×${c.count}` : ''}${c.route ? ` → ${c.route}` : ''}${c.status === 'failed' ? ' (failed)' : ''}`; + const g = s('g', { class: 'rp-chapter', 'data-chapter': c.id, 'data-start': c.start }, + s('rect', { x: cx, y: cy + 2, width: cw, height: CH_ROW - 4, rx: 4 }), + cw > 30 ? clippedText(cx + 5, cy, cw - 8, CH_ROW, { x: 0, y: CH_ROW / 2 + 4, 'font-size': '11' }, text) : null, + s('title', { text })); + chG.appendChild(g); + } + svg.appendChild(chG); + + // blocks + const maxHeat = Math.max(1e-12, ...m.blocks.filter((b) => b.kind !== 'approval').map((b) => (S.heat === 'cost' ? b.cost : b.durationMs))); + const blocksG = s('g', { role: 'group', 'aria-label': 'Work blocks' }); + for (const b of m.blocks) { + const ly = laneY.get(b.lane); + if (!ly) continue; + const lane = m.lanes.find((l) => l.id === b.lane); + const owner = lane && lane.parent ? m.lanes.find((l) => l.id === lane.parent) : lane; + const bx = x(b.start); + const bw = Math.max(x(b.end) - bx, MIN_BLOCK); + const bh = ly.h - 16; + const by = ly.top + 8 - (S.overlay ? 3 : 0); + const fillKind = b.kind === 'tool' ? 'tool' : (b.kind === 'approval' ? 'human' : (owner && owner.kind === 'manager' ? 'manager' : 'agent')); + const cls = ['rp-block', `rp-k-${b.kind}`, `rp-o-${fillKind}`]; + if (b.open) cls.push('rp-open'); + const text = blockText(b) + (b.status === 'error' ? ' failed' : '') + (b.kind === 'approval' && b.status === 'pending' ? ' — waiting for you' : ''); + const heatVal = S.heat === 'cost' ? C().formatCost(b.cost) : C().formatDuration(b.durationMs); + const full = S.overlay && heatVal && b.kind !== 'approval' ? `${text} · ${heatVal}` : text; + const showText = bw - 24 >= 20; + const g = s('g', { + class: cls.join(' '), 'data-block': b.id, 'data-key': `block:${b.id}`, 'data-start': b.start, + role: 'button', tabindex: '-1', 'aria-label': showText ? `${full}. ${blockAria(b, lane)}` : blockAria(b, lane), + onclick: () => selectBlock(b), + ...hoverHandlers({ blockId: b.id, id: b.eventIds[0] }), + }); + g.appendChild(s('rect', { class: 'rp-ring', x: bx - 3, y: by - 3, width: bw + 6, height: bh + 6, rx: 6 })); + g.appendChild(s('rect', { class: `rp-body rp-f-${fillKind}`, x: bx, y: by, width: bw, height: bh, rx: b.kind === 'tool' ? 9 : 3 })); + const glyphKind = b.kind === 'tool' ? 'tool' : (b.kind === 'approval' ? 'human' : fillKind); + if (bw >= 18) g.appendChild(glyph(glyphKind, bx + 5, by + bh / 2 - 5, 10)); + if (showText) g.appendChild(clippedText(bx + 20, by, bw - 24, bh, { class: 'rp-onfill', x: 0, y: bh / 2 + 4 }, full)); + else if (b.count > 1) g.appendChild(s('text', { x: bx + bw + 3, y: by + bh / 2 + 4, 'font-weight': '700', 'aria-hidden': 'true', text: `×${b.count}` })); + if (S.overlay && b.kind !== 'approval') { + const v = S.heat === 'cost' ? b.cost : b.durationMs; + const r = Math.min(1, v / maxHeat); + const hh = 2 + Math.round(6 * r); + g.appendChild(s('rect', { class: 'rp-heat', x: bx, y: by + bh + 2, width: bw, height: hh, fill: heatColor(r) })); + } + g.appendChild(s('title', { text: full })); + blocksG.appendChild(g); + } + svg.appendChild(blocksG); + + // arrows + const arrowsG = s('g', { 'aria-hidden': 'true' }); // told by the step list + for (const a of m.arrows) { + const from = laneY.get(a.from); + const to = laneY.get(a.to); + if (!from || !to || a.from === a.to) continue; + const ax = x(a.t); + const down = to.mid > from.mid; + const y1 = from.mid + (down ? 10 : -10); + const y2 = to.mid + (down ? -12 : 12); + const bend = 22; + const d = `M${ax},${y1} C${ax + bend},${y1} ${ax + bend},${y2} ${ax},${y2}`; + const human = a.kind === 'ask' || a.kind === 'answer'; + const labelY = (y1 + y2) / 2 + 4; + const lw = a.label.length * 6.4 + 8; + const lx = ax + bend + lw > width - 4 ? ax - lw - 4 : ax + bend - 2; + arrowsG.appendChild(s('g', { class: `rp-arrow rp-a-${a.kind}`, 'data-arrow': a.id, 'data-start': a.t, 'aria-hidden': 'true' }, + s('path', { d, 'marker-end': human ? 'url(#rp-head-h)' : 'url(#rp-head)' }), + s('rect', { class: 'rp-labelbg', x: lx, y: labelY - 11, width: lw, height: 15, rx: 3 }), + s('text', { x: lx + 4, y: labelY, text: a.label }))); + } + svg.appendChild(arrowsG); + + // cursor + const t = cursorTime(); + svg.appendChild(s('g', { class: 'rp-cursor-g', transform: `translate(${x(t)},0)`, 'aria-hidden': 'true' }, + s('line', { class: 'rp-cursor', x1: 0, x2: 0, y1: AXIS_H - 4, y2: height }), + s('text', { class: 'rp-cursor-label', x: 4, y: AXIS_H + chapterH + 2, text: offset(t) }))); + canvas.appendChild(svg); + canvas.scrollLeft = scroll; + applyCursor(); +} + +function niceStep(dur, plotW) { + const target = dur / Math.max(2, Math.floor(plotW / 90)); + const pow = 10 ** Math.floor(Math.log10(target || 1)); + for (const m of [1, 2, 5, 10]) if (m * pow >= target) return m * pow; + return 10 * pow; +} + +function tickLabel(t, step) { + if (step >= 60) return `${Math.round(t / 60)} min`; + const digits = step >= 1 ? 0 : Math.min(3, Math.ceil(-Math.log10(step))); + return `${t.toFixed(digits)} s`; +} + +function selectBlock(b) { + const idx = S.model.narration.findIndex((n) => n.blockId === b.id); + if (idx >= 0) setCursor(idx); + S.tab = 'what'; + renderDrawer(); +} + +// ── map ─────────────────────────────────────────────────────────────── + +const KIND_WORD = { agent: 'agent', tool: 'tool', human: 'you', manager: 'manager', task: 'task', step: 'step', start: 'start', end: 'end', fork: 'fork', join: 'join', decision: 'decision' }; + +function mapGraph() { + return S.graph && Array.isArray(S.graph.nodes) && S.graph.nodes.length ? S.graph : C().deriveGraph(S.events); +} + +function nodeVisits() { + const counts = new Map(); + const lastAt = new Map(); + const m = S.model; + m.events.forEach((ev, i) => { + for (const nid of C().nodeIdsForEvent(ev)) { + counts.set(nid, (counts.get(nid) || 0) + 1); + if (!lastAt.has(nid)) lastAt.set(nid, []); + lastAt.get(nid).push(i); + } + }); + // Brief/Delivered are reached when the run starts/ends, though no event names them. + const graph = mapGraph(); + const ended = m.events.some((e) => e.event_type === 'workflow_end' || e.event_type === 'session_end'); + for (const n of graph.nodes || []) { + if (n.kind === 'start' && m.events.length && !counts.has(n.id)) counts.set(n.id, 1); + if (n.kind === 'end' && ended && !counts.has(n.id)) counts.set(n.id, 1); + } + return { counts, at: lastAt }; +} + +function shapePath(shape, x, y, w, h) { + if (shape === 'chevron') return `M${x},${y} L${x + w - 10},${y} L${x + w},${y + h / 2} L${x + w - 10},${y + h} L${x},${y + h} Z`; + if (shape === 'hexagon') return `M${x + 10},${y} L${x + w - 10},${y} L${x + w},${y + h / 2} L${x + w - 10},${y + h} L${x + 10},${y + h} L${x},${y + h / 2} Z`; + if (shape === 'note') return `M${x},${y} L${x + w - 12},${y} L${x + w},${y + 12} L${x + w},${y + h} L${x},${y + h} Z`; + return null; +} + +function nodeGlyph(node, gx, gy) { + const k = node.kind; + const cls = `rp-body rp-f-${['agent', 'tool', 'human', 'manager'].includes(k) ? k : 'neutral'}`; + if (k === 'agent') return s('circle', { class: cls, cx: gx + 11, cy: gy + 11, r: 11 }); + if (k === 'manager') return s('rect', { class: cls, x: gx, y: gy, width: 22, height: 22 }); + if (k === 'human') return s('path', { class: cls, d: `M${gx + 11},${gy - 1} L${gx + 23},${gy + 11} L${gx + 11},${gy + 23} L${gx - 1},${gy + 11} Z` }); + if (k === 'tool') return s('rect', { class: cls, x: gx - 2, y: gy + 2, width: 26, height: 18, rx: 6 }); + if (k === 'fork' || k === 'join') return s('rect', { class: cls, x: gx + 8, y: gy - 2, width: 6, height: 26 }); + if (k === 'decision') return s('path', { class: cls, d: `M${gx + 5},${gy} L${gx + 17},${gy} L${gx + 22},${gy + 11} L${gx + 17},${gy + 22} L${gx + 5},${gy + 22} L${gx},${gy + 11} Z` }); + if (k === 'start') return s('path', { class: cls, d: `M${gx},${gy} L${gx + 14},${gy} L${gx + 22},${gy + 11} L${gx + 14},${gy + 22} L${gx},${gy + 22} Z` }); + if (k === 'end') return s('g', null, s('rect', { class: cls, x: gx + 2, y: gy, width: 3, height: 22 }), s('path', { class: cls, d: `M${gx + 5},${gy} L${gx + 22},${gy + 5} L${gx + 5},${gy + 11} Z` })); + if (k === 'task') return s('path', { class: cls, d: `M${gx},${gy} L${gx + 15},${gy} L${gx + 21},${gy + 6} L${gx + 21},${gy + 22} L${gx},${gy + 22} Z` }); + return s('rect', { class: cls, x: gx, y: gy, width: 22, height: 22, rx: 2 }); +} + +function renderMap() { + const canvas = S.els.canvas; + clear(canvas); + S.tl = null; + const graph = mapGraph(); + if (!S.layout) S.layout = C().layoutMap(graph); + const lay = S.layout; + if (!lay.nodes.length) { + canvas.appendChild(h('div', { class: 'rp-empty' }, h('h2', { text: 'Nothing to map yet' }), h('p', { text: 'The map appears once agents start talking.' }))); + return; + } + const visits = nodeVisits(); + const width = lay.width + 20; + const height = lay.height + 20; + const svg = s('svg', { + class: 'rp-svg', width, height, viewBox: `0 0 ${width} ${height}`, role: 'group', tabindex: '-1', + 'aria-label': `${graph.derived ? 'Who talked to whom' : 'Map of the run'}: ${lay.nodes.length} nodes and ${lay.edges.length} connections. Use up and down arrows to move between nodes.`, + }); + svg.appendChild(s('defs', null, + s('marker', { id: 'rp-mhead', viewBox: '0 0 10 10', refX: 9, refY: 5, markerWidth: 7, markerHeight: 7, orient: 'auto-start-reverse' }, s('path', { class: 'rp-marker', d: 'M0,0 L10,5 L0,10 z' })))); + const edgesG = s('g', { 'aria-hidden': 'true' }); + for (const e of lay.edges) { + const p = e.points; + let d = `M${p[0][0]},${p[0][1]}`; + if (e.back) { + d += p.slice(1).map((q) => ` L${q[0]},${q[1]}`).join(''); + } else { + for (let j = 1; j < p.length; j++) { + const mx = (p[j - 1][0] + p[j][0]) / 2; + d += ` C${mx},${p[j - 1][1]} ${mx},${p[j][1]} ${p[j][0]},${p[j][1]}`; + } + } + const text = C().edgeText(e); + const seg = Math.max(1, Math.floor(p.length / 2)); + const mid = [(p[seg - 1][0] + p[seg][0]) / 2, (p[seg - 1][1] + p[seg][1]) / 2]; + const lw = text.length * 6 + 8; + edgesG.appendChild(s('g', { class: `rp-edge rp-e-${e.kind}`, 'data-edge': e.id }, + s('path', { d, 'marker-end': 'url(#rp-mhead)' }), + s('rect', { class: 'rp-labelbg', x: mid[0] - lw / 2, y: mid[1] - 11, width: lw, height: 15, rx: 3 }), + s('text', { x: mid[0], y: mid[1], 'text-anchor': 'middle', text }))); + } + svg.appendChild(edgesG); + const nodesG = s('g', { role: 'group', 'aria-label': 'Nodes' }); + const ordered = [...lay.nodes].sort((a, b) => a.layer - b.layer || a.order - b.order); + ordered.forEach((n, i) => { + const count = visits.counts.get(n.id) || 0; + const word = KIND_WORD[n.kind] || n.kind; + const kindLine = count ? `${word} · ×${count}` : word; + const name = `${n.label} ${kindLine}${count ? `, ${count} events here` : ', not reached yet'}`; + const g = s('g', { + class: `rp-node${count ? '' : ' rp-unvisited'}${S.nodeSel === n.id ? ' rp-sel' : ''}`, + 'data-node': n.id, 'data-key': `node:${n.id}`, role: 'button', tabindex: i === 0 ? '0' : '-1', + 'aria-label': name, + onclick: () => selectNode(n.id), + onfocus: () => highlight(null), + }); + const path = shapePath(n.shape, n.x, n.y, n.w, n.h); + g.appendChild(s('rect', { class: 'rp-ring', x: n.x - 4, y: n.y - 4, width: n.w + 8, height: n.h + 8, rx: 10 })); + g.appendChild(path ? s('path', { class: 'rp-card', d: path }) : s('rect', { class: 'rp-card', x: n.x, y: n.y, width: n.w, height: n.h, rx: n.kind === 'tool' ? 14 : (n.kind === 'end' ? 2 : 6) })); + if (n.kind === 'end') g.appendChild(s('rect', { class: 'rp-card', x: n.x + 3, y: n.y + 3, width: n.w - 6, height: n.h - 6, rx: 2, fill: 'none' })); + g.appendChild(nodeGlyph(n, n.x + 12, n.y + n.h / 2 - 11)); + g.appendChild(clippedText(n.x + 44, n.y, n.w - 50, n.h, { x: 0, y: n.h / 2 - 2, 'font-weight': '600' }, n.label)); + g.appendChild(s('text', { class: 'rp-kindword', x: n.x + 44, y: n.y + n.h / 2 + 13, 'aria-hidden': 'true', text: ` ${kindLine}` })); + g.appendChild(s('title', { text: name })); + nodesG.appendChild(g); + }); + svg.appendChild(nodesG); + canvas.appendChild(svg); + applyMapCursor(); +} + +function applyMapCursor() { + const item = S.model.narration[S.cursor]; + const ev = item ? S.model.events[S.model.eventIndex.get(item.id)] : null; + const now = new Set(ev ? C().nodeIdsForEvent(ev) : []); + for (const g of S.els.canvas.querySelectorAll('.rp-node')) { + g.classList.toggle('rp-now', now.has(g.getAttribute('data-node'))); + } +} + +function moveMapFocus(dir) { + const nodes = [...S.els.canvas.querySelectorAll('.rp-node')]; + if (!nodes.length) return; + const i = nodes.indexOf(document.activeElement); + const next = nodes[Math.max(0, Math.min(nodes.length - 1, i < 0 ? 0 : i + dir))]; + nodes.forEach((n) => n.setAttribute('tabindex', n === next ? '0' : '-1')); + next.focus(); +} + +function selectNode(id) { + S.nodeSel = id; + for (const g of S.els.canvas.querySelectorAll('.rp-node')) g.classList.toggle('rp-sel', g.getAttribute('data-node') === id); + S.tab = 'what'; + renderDrawer(); +} + +// ── legend ──────────────────────────────────────────────────────────── + +function renderLegend() { + const lg = S.els.legend; + clear(lg); + const item = (cls, text) => h('span', null, h('i', { class: `rp-g ${cls}`, 'aria-hidden': 'true' }), text); + lg.append(item('rp-g-agent', 'agent (circle)'), item('rp-g-tool', 'tool (rounded)'), item('rp-g-human', 'you (diamond)'), item('rp-g-manager', 'manager (square)')); + if (S.view === 'timeline') { + lg.append(h('span', { text: 'solid arrow: hand-off · dashed: delegate · dotted: route' }), h('span', { text: 'outlined block: not reached yet' })); + if (S.overlay) lg.append(h('span', { text: `bar under a block: ${S.heat === 'cost' ? 'cost' : 'latency'} (taller and darker = more)` })); + } else { + lg.append(h('span', { text: 'dashed outline: not reached · bold outline: now' })); + } +} + +// ── narration ───────────────────────────────────────────────────────── + +function stepGlyphClass(item) { + if (item.type.startsWith('approval') || (item.type === 'message_added' && !item.agent)) return 'rp-g-human'; + if (item.type.startsWith('tool')) return 'rp-g-tool'; + if (item.agent === 'Manager') return 'rp-g-manager'; + if (item.agent) return 'rp-g-agent'; + return ''; +} + +function renderNarration() { + const box = S.els.narr; + const scroll = box.scrollTop; + clear(box); + box.appendChild(h('h2', { id: 'rp-narr-h', text: 'What happened' })); + const ol = h('ol', { 'aria-labelledby': 'rp-narr-h' }); + S.model.narration.forEach((n, i) => { + ol.appendChild(h('li', null, h('button', { + type: 'button', class: `rp-step${i > S.cursor ? ' rp-future' : ''}`, 'data-i': i, 'data-key': `step:${n.id}`, + tabindex: i === S.cursor ? '0' : '-1', 'aria-current': i === S.cursor ? 'step' : null, + onclick: () => { S.tab = S.tab || 'what'; setCursor(i, 'narration'); }, + ...hoverHandlers(n), + }, + h('span', { class: 'rp-step-t', text: offset(n.t) }), + h('i', { class: `rp-g ${stepGlyphClass(n)}`, 'aria-hidden': 'true' }), + h('span', { class: 'rp-step-text', text: n.text })))); + }); + box.appendChild(ol); + box.scrollTop = scroll; + applyCursor(); +} + +// ── scrubber ────────────────────────────────────────────────────────── + +function renderScrubber() { + const box = S.els.scrub; + const n = S.model.narration.length; + const item = S.model.narration[S.cursor]; + const existing = box.querySelector('input[type=range]'); + const valueText = item ? `Step ${S.cursor + 1} of ${n}: ${item.text}` : 'No steps'; + if (existing && Number(existing.max) === Math.max(0, n - 1)) { + existing.value = String(Math.max(0, S.cursor)); + existing.setAttribute('aria-valuetext', valueText); + box.querySelector('output').textContent = item ? `${S.cursor + 1} / ${n} · ${offset(item.t)}` : '0 / 0'; + return; + } + clear(box); + if (!n) return; + box.append( + h('button', { type: 'button', class: 'rp-btn', 'data-key': 'prev', onclick: () => setCursor(S.cursor - 1) }, h('span', { 'aria-hidden': 'true' }, '← '), 'Previous'), + h('label', { for: 'rp-scrub-range', class: 'rp-sr', text: 'Step' }), + h('input', { + id: 'rp-scrub-range', type: 'range', min: 0, max: Math.max(0, n - 1), step: 1, value: Math.max(0, S.cursor), + 'aria-valuetext': valueText, 'data-key': 'scrub', + oninput: (e) => setCursor(Number(e.target.value)), + }), + h('button', { type: 'button', class: 'rp-btn', 'data-key': 'next', onclick: () => setCursor(S.cursor + 1) }, 'Next', h('span', { 'aria-hidden': 'true' }, ' →')), + h('output', { for: 'rp-scrub-range', text: item ? `${S.cursor + 1} / ${n} · ${offset(item.t)}` : '' }), + h('span', { class: 'rp-hint', text: '← → step · Home / End' }), + ); +} + +// ── drawer ──────────────────────────────────────────────────────────── + +const TABS = [['what', 'What happened'], ['why', 'Why'], ['raw', 'Raw JSON']]; + +function renderDrawer(errorText) { + const box = S.els.drawer; + const key = focusKey(); + clear(box); + if (errorText) box.appendChild(h('p', { class: 'rp-err', role: 'alert', text: errorText })); + const item = S.model.narration[S.cursor]; + if (!item && !S.nodeSel) return; + const tablist = h('div', { class: 'rp-tabs', role: 'tablist', 'aria-label': 'Details', onkeydown: onTabKey }); + for (const [id, label] of TABS) { + tablist.appendChild(h('button', { + type: 'button', role: 'tab', class: 'rp-tab', id: `rp-tab-${id}`, 'aria-controls': 'rp-tabpanel', 'data-key': `tab:${id}`, + 'aria-selected': S.tab === id ? 'true' : 'false', tabindex: S.tab === id ? '0' : '-1', + onclick: () => { S.tab = id; renderDrawer(); focusTab(id); }, + }, label)); + } + box.appendChild(tablist); + const panel = h('div', { id: 'rp-tabpanel', role: 'tabpanel', 'aria-labelledby': `rp-tab-${S.tab}`, tabindex: '0' }); + const ctx = S.nodeSel ? nodeContext(S.nodeSel) : itemContext(item); + if (S.tab === 'what') panel.append(...ctx.what()); + else if (S.tab === 'why') panel.append(...ctx.why()); + else panel.appendChild(h('pre', { tabindex: '0', 'aria-label': 'Raw event JSON', text: prettyJson(ctx.raw.length === 1 ? ctx.raw[0] : ctx.raw) })); + box.appendChild(panel); + restoreFocus(key); +} + +function focusTab(id) { + const tab = S.els.drawer.querySelector(`#rp-tab-${id}`); + if (tab) tab.focus(); +} + +function onTabKey(e) { + const ids = TABS.map((t) => t[0]); + let i = ids.indexOf(S.tab); + if (e.key === 'ArrowRight') i = (i + 1) % ids.length; + else if (e.key === 'ArrowLeft') i = (i - 1 + ids.length) % ids.length; + else if (e.key === 'Home') i = 0; + else if (e.key === 'End') i = ids.length - 1; + else return; + e.preventDefault(); + e.stopPropagation(); + S.tab = ids[i]; + renderDrawer(); + focusTab(S.tab); +} + +function eventsOf(ids) { + const m = S.model; + return ids.map((id) => m.events[m.eventIndex.get(id)]).filter(Boolean); +} + +function facts(rows) { + const dl = h('dl', { class: 'rp-facts' }); + for (const [k, v] of rows) { + if (v == null || v === '') continue; + dl.append(h('dt', { text: k }), h('dd', { text: String(v) })); + } + return dl; +} + +function outputOf(events) { + for (const ev of [...events].reverse()) { + const d = ev.data || {}; + const out = d.result ?? d.response_content ?? d.output_preview; + if (out != null && out !== '') return typeof out === 'string' ? out : prettyJson(out); + } + return null; +} + +function itemContext(item) { + const m = S.model; + const block = item.blockId ? m.blocks.find((b) => b.id === item.blockId) : null; + const chapter = item.chapterId ? m.chapters.find((c) => c.id === item.chapterId) : null; + const ev = m.events[m.eventIndex.get(item.id)]; + const relatedIds = block ? block.eventIds : (chapter ? chapter.eventIds : [item.id]); + const raw = eventsOf(block && block.count > 1 ? block.eventIds : (block ? block.eventIds : relatedIds)); + return { + raw: raw.length ? raw : [ev], + what: () => whatHappened(item, ev, block, chapter, raw), + why: () => whyPanel(item, ev, block, chapter), + }; +} + +function whatHappened(item, ev, block, chapter, raw) { + const out = [h('h3', { text: item.text })]; + const d = (ev && ev.data) || {}; + out.push(facts([ + ['When', `${offset(item.t)} from the start`], + ['Who', item.agent || (item.type.startsWith('approval') ? 'You' : 'The run')], + ['Event', `${item.type} #${ev ? ev.sequence_num : '?'}`], + ['Duration', block ? C().formatDuration(block.durationMs) : (chapter ? C().formatDuration((chapter.end - chapter.start) * 1000) : C().formatDuration(d.duration_ms))], + ['Cost', block ? C().formatCost(block.cost) : C().formatCost(ev && ev.cost_usd)], + ['Tokens', block && block.tokens ? block.tokens : null], + ['Status', block ? block.status : (chapter ? chapter.status : d.status)], + ['Node', ev && ev.node_id], + ['Branch', ev && ev.branch_id], + ['Part of', chapter ? `${chapter.kind} ${chapter.label}` : null], + ])); + if (block && block.count > 1) { + out.push(h('p', { class: 'rp-note', text: `Repeated ${block.count} times in a row; shown as one block.` })); + const table = h('table', { class: 'rp-passes' }, h('thead', null, h('tr', null, h('th', { scope: 'col', text: 'Pass' }), h('th', { scope: 'col', text: 'Start' }), h('th', { scope: 'col', text: 'Duration' }), h('th', { scope: 'col', text: 'Cost' })))); + const tb = h('tbody'); + block.members.forEach((mb, i) => tb.appendChild(h('tr', null, h('td', { text: String(i + 1) }), h('td', { text: offset(mb.start) }), h('td', { text: C().formatDuration(mb.durationMs) }), h('td', { text: C().formatCost(mb.cost) || '—' })))); + table.appendChild(tb); + out.push(table); + } + if (chapter && chapter.count > 1) out.push(h('p', { class: 'rp-note', text: `${chapter.label} ran ${chapter.count} times in a row (loop).` })); + if (block && block.kind === 'approval' && block.status === 'pending') { + out.push(h('p', { class: 'rp-note', text: S.solo ? 'This request was still waiting when the run was exported.' : 'Waiting for your decision: use the "Waiting for you" panel above the timeline.' })); + } + const output = outputOf(raw); + if (output) out.push(h('h4', { text: 'Output' }), h('pre', { tabindex: '0', text: C().truncate(output, 4000) })); + return out; +} + +function evidenceItem(label, value, ev) { + const li = h('li', null, h('strong', { text: label }), ' ', h('span', { class: 'rp-src', text: ev ? `(recorded in event #${ev.sequence_num}, ${ev.event_type})` : '' })); + if (value != null && value !== '') li.appendChild(typeof value === 'string' ? h('pre', { tabindex: '0', text: value }) : h('pre', { tabindex: '0', text: prettyJson(value) })); + return li; +} + +function findDecision(approvalId) { + return S.model.events.find((e) => e.event_type === 'approval_decided' && e.data && e.data.approval_id === approvalId) || null; +} + +function findRequest(approvalId) { + return S.model.events.find((e) => e.event_type === 'approval_requested' && e.data && e.data.approval_id === approvalId) || null; +} + +function whyPanel(item, ev, block, chapter) { + const list = h('ul', { class: 'rp-evidence' }); + const add = (label, value, src) => list.appendChild(evidenceItem(label, value, src)); + const m = S.model; + const evs = block ? eventsOf(block.eventIds) : [ev]; + const d = (ev && ev.data) || {}; + const type = item.type; + if (type.startsWith('tool_call')) { + for (const e of evs.filter((x) => x.event_type === 'tool_call_start')) add(`Input passed to ${e.data.tool_name}`, e.data.tool_args, e); + } else if (type.startsWith('llm')) { + const req = evs.find((x) => x.event_type === 'llm_request'); + if (req) { + add('Context sent', `${req.messages_count || 0} messages, ${req.tokens_used || 0} of ${req.tokens_budget || '?'} tokens`, req); + const msgs = req.data && req.data.messages; + if (Array.isArray(msgs) && msgs.length) add('Last message sent', msgs[msgs.length - 1], req); + if (req.data && req.data.model) add('Model', req.data.model, req); + } + const resp = evs.find((x) => x.event_type === 'llm_response'); + if (resp && resp.data && resp.data.finish_reason) add('Why it stopped', resp.data.finish_reason, resp); + } else if (type === 'agent_handoff') { + if (d.reason) add('Hand-off reason', d.reason, ev); + const cp = d.context_passed; + if (cp && Object.keys(cp).length) { + const manager = d.kind === 'delegate' || d.from_agent === 'Manager'; + if (manager && cp.task_snapshot) add("Manager's task snapshot", cp.task_snapshot, ev); + add(manager ? 'Context the manager passed' : 'Context passed', cp, ev); + } + } else if (type === 'task_start' || type === 'task_end') { + const start = eventsOf(chapter ? chapter.eventIds : [item.id]).find((x) => x.event_type === 'task_start') || ev; + if (start.data && start.data.description_preview) add('Task description', start.data.description_preview, start); + const agent = start.data && (start.data.agent || start.agent_name); + const idx = m.eventIndex.get(C().eventId(start)); + const delegation = [...m.events.slice(0, idx ?? m.events.length)].reverse().find((x) => x.event_type === 'agent_handoff' && x.data && x.data.to_agent === agent && (x.data.kind === 'delegate' || x.data.from_agent === 'Manager')); + if (delegation) add("Manager's task snapshot", delegation.data.context_passed?.task_snapshot || delegation.data.context_passed || delegation.data.reason, delegation); + if (type === 'task_end' && d.retries) add('Retries', String(d.retries), ev); + } else if (type === 'step_start' || type === 'step_end') { + const end = eventsOf(chapter ? chapter.eventIds : [item.id]).filter((x) => x.event_type === 'step_end').pop(); + if (end && end.data && end.data.route) add('Route chosen', end.data.route, end); + const start = eventsOf(chapter ? chapter.eventIds : [item.id]).find((x) => x.event_type === 'step_start'); + if (start && start.data && start.data.pattern) add('Pattern', start.data.pattern, start); + } else if (type.startsWith('approval')) { + const id = d.approval_id; + const req = findRequest(id); + const dec = findDecision(id); + if (req) add(`Arguments ${req.data.kind === 'task_output_review' ? 'under review' : 'requested'} (redacted)`, req.data.args, req); + if (req && req.data.risk) add('Risk', req.data.risk, req); + if (dec) { + const dd = dec.data; + add('Decision', `${dd.approved ? 'Approved' : 'Denied'}${dd.modified ? ' with edited arguments' : ''}${dd.reason ? ` — ${dd.reason}` : ''}`, dec); + const approver = String(dd.approver || ''); + if (approver && !['user', 'human', 'you', 'ui', 'replay', 'console', 'web', 'dashboard'].includes(approver.toLowerCase())) add('Auto-approval source', approver, dec); + else if (approver) add('Decided by', approver, dec); + } else { + add('Decision', 'None recorded yet', null); + } + } else if (type === 'message_added') { + add('Message', d.content, ev); + } + if (!list.childNodes.length) { + return [h('p', { class: 'rp-note', text: 'Nothing in the recording explains this step. Only recorded evidence is shown here.' })]; + } + return [h('p', { class: 'rp-note', text: 'Evidence recorded with the run (no guesses):' }), list]; +} + +function nodeContext(nodeId) { + const m = S.model; + const graph = mapGraph(); + const node = (graph.nodes || []).find((n) => n.id === nodeId) || { id: nodeId }; + const items = m.narration.map((n, i) => [n, i]).filter(([n]) => { + const ev = m.events[m.eventIndex.get(n.id)]; + return C().nodeIdsForEvent(ev).includes(nodeId); + }); + return { + raw: [node], + what: () => { + const out = [h('h3', { text: `${KIND_WORD[node.kind] || node.kind || 'node'}: ${node.label || nodeId}` }), facts([['Id', nodeId], ['Kind', node.kind], ['Events here', items.length]])]; + if (items.length) { + const ol = h('ol', { class: 'rp-evidence' }); + for (const [n, i] of items.slice(0, 50)) ol.appendChild(h('li', null, h('button', { type: 'button', class: 'rp-btn', onclick: () => setCursor(i, 'narration') }, `${offset(n.t)} ${n.text}`))); + out.push(ol); + } else out.push(h('p', { class: 'rp-note', text: 'This node was not reached in this run.' })); + return out; + }, + why: () => { + const edges = (graph.edges || []).filter((e) => e.source === nodeId || e.target === nodeId); + if (!edges.length) return [h('p', { class: 'rp-note', text: 'No recorded connections.' })]; + const ul = h('ul', { class: 'rp-evidence' }); + for (const e of edges) ul.appendChild(h('li', { text: `${e.source} ${C().edgeText(e)} ${e.target}` })); + return [h('p', { class: 'rp-note', text: graph.derived ? 'Derived from the events (no graph was recorded):' : 'From the graph recorded at the start of the run:' }), ul]; + }, + }; +} + +// ── "You" lane: pending approvals ───────────────────────────────────── + +function renderPending() { + const box = S.els.pending; + const pending = S.model.pending; + // Rebuild only when something visible changed, so a live update never + // resets the caret of someone typing arguments or a reason. + const sig = `${S.solo}|${Boolean(S.run?.live)}|${pending.map((b) => { + const st = S.asks.get(b.approvalId) || {}; + return `${b.approvalId}:${Boolean(st.editing)}:${st.sent || ''}:${Boolean(st.loaded)}`; + }).join(',')}`; + if (sig === S.pendingSig && box.childNodes.length === pending.length) return; + S.pendingSig = sig; + const key = focusKey(); + clear(box); + box.hidden = !pending.length; + if (!pending.length) return; + for (const b of pending) { + const req = findRequest(b.approvalId); + const d = (req && req.data) || {}; + const st = S.asks.get(b.approvalId) || { editing: false, text: '', error: '', sent: '', reason: '', loaded: false }; + S.asks.set(b.approvalId, st); + const hid = `rp-ask-${cssId(b.approvalId)}`; + const card = h('article', { class: 'rp-ask', 'aria-labelledby': hid }); + card.appendChild(h('h3', { id: hid }, + h('i', { class: 'rp-g rp-g-human', 'aria-hidden': 'true' }), + `${req ? req.agent_name || 'An agent' : 'An agent'} asks to ${d.kind === 'task_output_review' ? 'deliver the output of' : 'run'} ${b.label}`, + d.risk ? h('span', { class: 'rp-badge', text: `${d.risk} risk` }) : null)); + card.appendChild(h('pre', { tabindex: '0', 'aria-label': 'Recorded arguments (redacted)', text: prettyJson(d.args || {}) })); + if (S.solo) { + card.appendChild(h('p', { class: 'rp-note', text: 'This export cannot answer. Open the run with praisonai replay view to approve or deny.' })); + box.appendChild(card); + continue; + } + if (st.sent) { + card.appendChild(h('p', { class: 'rp-note', role: 'status', text: st.sent })); + box.appendChild(card); + continue; + } + const reasonId = `${hid}-reason`; + card.append(h('label', { for: reasonId, text: 'Reason (optional)' }), + h('input', { id: reasonId, type: 'text', value: st.reason, 'data-key': `${hid}-reason`, oninput: (e) => { st.reason = e.target.value; } })); + if (st.editing) { + const taId = `${hid}-args`; + const errId = `${hid}-err`; + const hintId = `${hid}-hint`; + card.append( + h('label', { for: taId, text: 'Arguments to run with (JSON object)' }), + h('textarea', { + id: taId, rows: 6, 'data-key': `${hid}-args`, 'aria-describedby': `${hintId} ${errId}`, 'aria-invalid': st.error ? 'true' : 'false', + oninput: (e) => { st.text = e.target.value; st.error = validateArgs(st.text).error; const err = document.getElementById(errId); if (err) err.textContent = st.error; e.target.setAttribute('aria-invalid', st.error ? 'true' : 'false'); }, + }, st.text), + h('p', { id: hintId, class: 'rp-note', text: st.loaded ? 'Started from the arguments this server holds for the request.' : 'Started from the recorded arguments, which are redacted: check every value before approving.' }), + h('p', { id: errId, class: 'rp-err', 'aria-live': 'polite', text: st.error }), + ); + } + const actions = h('div', { class: 'rp-ask-actions' }); + if (st.editing) { + actions.append( + h('button', { type: 'button', class: 'rp-btn rp-btn-primary', 'data-key': `${hid}-approve-edit`, onclick: () => decide(b, 'approve', true) }, 'Approve with these arguments'), + h('button', { type: 'button', class: 'rp-btn', 'data-key': `${hid}-cancel`, onclick: () => { st.editing = false; st.error = ''; renderPending(); } }, 'Cancel editing'), + ); + } else { + actions.append( + h('button', { type: 'button', class: 'rp-btn rp-btn-primary', 'data-key': `${hid}-approve`, onclick: () => decide(b, 'approve', false) }, 'Approve'), + h('button', { type: 'button', class: 'rp-btn', 'data-key': `${hid}-edit`, onclick: () => startEdit(b, d) }, 'Edit arguments…'), + ); + } + actions.append(h('button', { type: 'button', class: 'rp-btn', 'data-key': `${hid}-deny`, onclick: () => decide(b, 'deny', false) }, 'Deny')); + if (!S.run?.live) actions.append(h('span', { class: 'rp-note', text: 'This run is not live; the request may have expired.' })); + card.appendChild(actions); + box.appendChild(card); + } + restoreFocus(key); +} + +function cssId(s) { + return String(s).replace(/[^A-Za-z0-9_-]/g, '_'); +} + +export function validateArgs(text) { + let value; + try { + value = JSON.parse(text); + } catch (e) { + return { error: `Not valid JSON: ${e.message}` }; + } + if (!value || typeof value !== 'object' || Array.isArray(value)) return { error: 'Arguments must be a JSON object, like {"path": "notes.md"}.' }; + return { value, error: '' }; +} + +async function startEdit(b, recorded) { + const st = S.asks.get(b.approvalId); + st.editing = true; + st.text = prettyJson(recorded.args || {}); + st.loaded = false; + try { + const res = await fetch(`/api/approvals/${encodeURIComponent(b.approvalId)}`, { signal: S.abort.signal }); + if (res.ok) { + const entry = await res.json(); + if (entry && entry.arguments && typeof entry.arguments === 'object') { + st.text = prettyJson(entry.arguments); + st.loaded = true; + } + } + } catch { /* keep recorded */ } + if (!S) return; + renderPending(); + const ta = S.els.pending.querySelector(`#rp-ask-${cssId(b.approvalId)}-args`); + if (ta) ta.focus(); +} + +async function decide(b, action, withArgs) { + const st = S.asks.get(b.approvalId); + const body = { reason: st.reason || '', approver: 'user' }; + if (withArgs) { + const { value, error } = validateArgs(st.text); + st.error = error; + if (error) { + const base = `rp-ask-${cssId(b.approvalId)}`; + const ta = document.getElementById(`${base}-args`); + const err = document.getElementById(`${base}-err`); + if (err) err.textContent = error; + if (ta) { ta.setAttribute('aria-invalid', 'true'); ta.focus(); } + return; + } + body.modified_args = value; + } + let msg; + try { + const res = await fetch(`/api/approvals/${encodeURIComponent(b.approvalId)}/${action}`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal: S.abort.signal, + }); + if (res.ok) msg = `${action === 'approve' ? 'Approved' : 'Denied'}. Waiting for the run to continue…`; + else if (res.status === 404) msg = 'This server does not hold that request (it may have expired, or the run uses a different approval backend).'; + else if (res.status === 401 || res.status === 403) msg = 'You are not allowed to decide approvals on this server.'; + else msg = `The server refused the decision (HTTP ${res.status}).`; + } catch (e) { + if (e.name === 'AbortError') return; + msg = `Could not reach the server: ${e.message}`; + } + if (!S) return; + st.sent = msg; + announce(msg); + renderPending(); +} + +// ── standalone boot ─────────────────────────────────────────────────── + +function bootStandalone() { + if (globalThis.__REPLAY_BOOTED__) return; + globalThis.__REPLAY_BOOTED__ = true; + const data = globalThis.__REPLAY_DATA__ || {}; + let mount = document.getElementById('replay-root'); + if (!mount) { + mount = document.createElement('main'); + mount.id = 'replay-root'; + document.body.appendChild(mount); + } + mount.style.padding = '12px'; + render(mount, { standalone: true, events: data.events || [], graph: data.graph || null, title: data.title || document.title }); +} + +if (typeof window !== 'undefined' && typeof document !== 'undefined' && globalThis.__REPLAY_DATA__ && !(window.aiui && window.aiui.selectPage)) { + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', bootStandalone); + else bootStandalone(); +} diff --git a/tests/browser/test_replay_browser.py b/tests/browser/test_replay_browser.py new file mode 100644 index 00000000..b4f0b47d --- /dev/null +++ b/tests/browser/test_replay_browser.py @@ -0,0 +1,320 @@ +"""Browser smoke + axe accessibility check for the Replay view (dev-only, never shipped). + +Opt-in: RUN_BROWSER_TESTS=1 AXE_CORE_JS=/path/to/axe.min.js pytest tests/browser -o addopts="" + +* Needs the ``playwright`` Python package and a Chrome install (``channel="chrome"``) + or a Playwright Chromium (``python -m playwright install chromium``). +* axe-core (MPL-2.0) is loaded from ``AXE_CORE_JS`` (e.g. ``npm i axe-core`` in + any scratch dir); the axe checks are skipped when it is not provided. +""" + +from __future__ import annotations + +import json +import os +import shutil +import socket +import threading +import time +from pathlib import Path + +import pytest + +if os.environ.get("RUN_BROWSER_TESTS") != "1": + pytest.skip("browser tests are opt-in (RUN_BROWSER_TESTS=1)", allow_module_level=True) + +sync_api = pytest.importorskip("playwright.sync_api") + +ROOT = Path(__file__).resolve().parents[2] +FIXTURES = ROOT / "tests" / "fixtures" / "replay" +AXE = os.environ.get("AXE_CORE_JS") +WCAG_TAGS = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"] + + +def _events(name: str) -> list[dict]: + return [json.loads(line) for line in (FIXTURES / f"{name}.jsonl").read_text(encoding="utf-8").splitlines() if line.strip()] + + +@pytest.fixture(scope="module") +def browser(): + with sync_api.sync_playwright() as p: + try: + b = p.chromium.launch(channel="chrome") + except Exception: + try: + b = p.chromium.launch() + except Exception as exc: # no browser available + pytest.skip(f"no Chromium/Chrome available: {exc}") + yield b + b.close() + + +def _page(browser, scheme: str = "light", reduced: bool = False): + page = browser.new_page( + viewport={"width": 1366, "height": 1000}, + color_scheme=scheme, + reduced_motion="reduce" if reduced else "no-preference", + ) + errors: list[str] = [] + page.on("pageerror", lambda e: errors.append(str(e))) + page.on("console", lambda m: errors.append(m.text) if m.type == "error" else None) + return page, errors + + +def _axe(page, selector: str = ".rp-root") -> list[dict]: + if not AXE: + return [] + page.add_script_tag(path=AXE) + result = page.evaluate( + """async ([sel, tags]) => { + const r = await axe.run(document.querySelector(sel), {runOnly: {type: 'tag', values: tags}}); + return r.violations.map(v => ({id: v.id, impact: v.impact, help: v.help, nodes: v.nodes.slice(0, 3).map(n => n.target.join(' '))})); + }""", + [selector, WCAG_TAGS], + ) + return result + + +def _standalone(tmp_path: Path, name: str) -> str: + from praisonaiui.features.replay import standalone_html + + out = tmp_path / f"{name}.html" + out.write_text(standalone_html(_events(name), title=name), encoding="utf-8") + return out.as_uri() + + +def _current_index(page) -> int: + return int(page.get_attribute('.rp-step[aria-current="step"]', "data-i")) + + +@pytest.mark.parametrize("name", ["team_handoff", "flow_parallel_loop", "legacy_v10"]) +def test_standalone_steps_with_arrow_keys(browser, tmp_path, name): + page, errors = _page(browser) + page.goto(_standalone(tmp_path, name)) + page.wait_for_selector(".rp-step") + total = page.locator(".rp-step").count() + assert total > 5 + assert _current_index(page) == total - 1 # opens at the end (whole run visible) + + page.locator(".rp-step").nth(total - 1).focus() + page.keyboard.press("Home") + assert _current_index(page) == 0 + for _ in range(3): + page.keyboard.press("ArrowRight") + assert _current_index(page) == 3 + page.keyboard.press("ArrowLeft") + assert _current_index(page) == 2 + # focus follows the cursor in the step list, the scrubber and the drawer agree + assert page.evaluate("document.activeElement.getAttribute('data-i')") == "2" + assert page.input_value("#rp-scrub-range") == "2" + step_text = page.locator('.rp-step[aria-current="step"] .rp-step-text').inner_text() + assert page.locator(".rp-drawer h3").inner_text() == step_text + # replay: later blocks are drawn as "not reached yet" + assert page.locator(".rp-block.rp-future").count() > 0 + page.keyboard.press("End") + assert _current_index(page) == total - 1 + assert page.locator(".rp-block.rp-future").count() == 0 + assert not errors, errors + + +def test_standalone_drawer_tabs_map_and_axe(browser, tmp_path): + page, errors = _page(browser) + page.goto(_standalone(tmp_path, "team_handoff")) + page.wait_for_selector(".rp-step") + # select the hand-off step and read the Why tab + page.locator(".rp-step", has_text="handed off to Writer").click() + page.locator('[role="tab"]', has_text="Why").click() + why = page.locator("#rp-tabpanel").inner_text() + assert "Hand-off reason" in why and "Facts gathered" in why + # keyboard on the tablist moves between tabs + page.keyboard.press("ArrowRight") + assert page.get_attribute('[role="tab"][aria-selected="true"]', "id") == "rp-tab-raw" + assert '"event_type": "agent_handoff"' in page.locator("#rp-tabpanel pre").inner_text() + # manager's task snapshot shows up for the delegated task + page.locator(".rp-step", has_text="Task 'research' went to Researcher").click() + page.locator('[role="tab"]', has_text="Why").click() + assert "Manager's task snapshot" in page.locator("#rp-tabpanel").inner_text() + # approval evidence + page.locator(".rp-step", has_text="You approved write_file").click() + assert "Decided by" in page.locator("#rp-tabpanel").inner_text() + + violations = _axe(page) + assert violations == [], json.dumps(violations, indent=2) + + page.click("[data-key='overlay']") + page.click("[data-key='view:map']") + page.wait_for_selector(".rp-node") + assert page.locator(".rp-node").count() == 10 + labels = page.eval_on_selector_all(".rp-edge text", "els => els.map(e => e.textContent)") + assert "hands off" in labels and any(t.startswith("delegates") for t in labels) + page.locator(".rp-node").first.focus() + page.keyboard.press("ArrowDown") + page.keyboard.press("Enter") + assert page.locator(".rp-drawer h3").inner_text() + violations = _axe(page) + assert violations == [], json.dumps(violations, indent=2) + + page.click("[data-key='theme']") # dark + assert page.get_attribute(".rp-root", "data-theme") == "dark" + violations = _axe(page) + assert violations == [], json.dumps(violations, indent=2) + page.click("[data-key='view:timeline']") + violations = _axe(page) + assert violations == [], json.dumps(violations, indent=2) + assert not errors, errors + + +def test_reduced_motion_has_no_animation(browser, tmp_path): + page, _ = _page(browser, reduced=True) + page.goto(_standalone(tmp_path, "flow_parallel_loop")) + page.wait_for_selector(".rp-step") + names = page.evaluate( + "[...document.querySelectorAll('.rp-root *')].map(e => getComputedStyle(e).animationName).filter(n => n && n !== 'none')" + ) + assert names == [] + + +# ── dashboard + live run + approval through the You lane ───────────── + + +_PROVIDER_KEYS = ("OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY", "GROQ_API_KEY") + + +def _no_provider_keys(monkeypatch) -> None: + """A real aiui server runs startup hooks; keep them from calling paid APIs.""" + for key in _PROVIDER_KEYS: + monkeypatch.delenv(key, raising=False) + + +def _free_port() -> int: + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +@pytest.fixture() +def live_server(tmp_path, monkeypatch): + import uvicorn + + import praisonaiui.server as server + from praisonaiui.features import approvals + from praisonaiui.features.replay import configure_replay_app + + traces = tmp_path / "traces" + traces.mkdir() + for name in ("team_handoff", "flow_parallel_loop"): + shutil.copy(FIXTURES / f"{name}.jsonl", traces / f"{name}.jsonl") + lines = (FIXTURES / "team_handoff.jsonl").read_text(encoding="utf-8").splitlines(keepends=True) + cut = next(i for i, line in enumerate(lines) if '"approval_decided"' in line) + live = traces / "live-team.jsonl" + live.write_text("".join(lines[:cut]), encoding="utf-8") + monkeypatch.setenv("PRAISONAI_TRACES_DIR", str(traces)) + monkeypatch.setenv("AIUI_DATA_DIR", str(tmp_path / "aiui-data")) + monkeypatch.setenv("PRAISONAI_HOME", str(tmp_path / "praisonai-home")) + _no_provider_keys(monkeypatch) + store = approvals.SimpleApprovalManager() + monkeypatch.setattr(approvals, "_approval_manager", store) + + server.reset_state() + configure_replay_app() + app = server.create_app() + port = _free_port() + srv = uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning")) + t = threading.Thread(target=srv.run, daemon=True) + t.start() + for _ in range(100): + if srv.started: + break + time.sleep(0.05) + yield {"url": f"http://127.0.0.1:{port}", "store": store, "live": live, "lines": lines, "cut": cut} + srv.should_exit = True + t.join(timeout=5) + server.reset_state() + + +def test_dashboard_live_run_and_approval(browser, live_server): + pytest.importorskip("praisonaiagents.approval.protocols") + from praisonaiagents.approval.protocols import ApprovalRequest + + from praisonaiui.features.approvals import _UIStoreBackend + + store = live_server["store"] + backend = _UIStoreBackend(store, timeout=30) + box = {} + req = ApprovalRequest(tool_name="write_file", arguments={"path": "draft.md", "content": "hello"}, + risk_level="high", agent_name="Writer", approval_id="appr-0001") + t = threading.Thread(target=lambda: box.setdefault("d", backend.request_approval_sync(req)), daemon=True) + t.start() + + page, errors = _page(browser) + page.goto(f"{live_server['url']}/#/replay?run=live-team") + page.wait_for_selector(".rp-run") + assert page.evaluate("location.pathname + location.search") == "/replay?run=live-team" + assert page.locator(".rp-run[aria-current='true']").inner_text().startswith("Solar sail brief") + assert page.locator(".rp-badge.rp-b-live").count() >= 1 + + card = page.locator(".rp-ask") + card.wait_for() + assert "Writer asks to run write_file" in card.inner_text() + page.get_by_role("button", name="Edit arguments…").click() + ta = page.locator(".rp-ask textarea") + ta.wait_for() + assert json.loads(ta.input_value()) == {"path": "draft.md", "content": "hello"} # server-held args, not redacted + ta.fill("{not json") + assert ta.get_attribute("aria-invalid") == "true" + assert "Not valid JSON" in page.locator(".rp-ask .rp-err").inner_text() + ta.fill('{"path": "docs/draft.md", "content": "hello"}') + assert ta.get_attribute("aria-invalid") == "false" + violations = _axe(page) + assert violations == [], json.dumps(violations, indent=2) + page.get_by_role("button", name="Approve with these arguments").click() + t.join(timeout=10) + decision = box["d"] + assert decision.approved is True + assert decision.modified_args == {"path": "docs/draft.md", "content": "hello"} + page.locator(".rp-ask [role='status']").wait_for() + + # the run continues: the rest of the file streams in over SSE + with open(live_server["live"], "a", encoding="utf-8") as fh: + fh.write("".join(live_server["lines"][live_server["cut"]:])) + page.wait_for_selector(".rp-step:has-text('You approved write_file')", timeout=10000) + page.wait_for_selector(".rp-step:has-text('Recording ended.')", timeout=10000) + assert page.locator(".rp-ask").count() == 0 + page.wait_for_selector(".rp-bar .rp-b-completed", timeout=15000) + assert not [e for e in errors if "favicon" not in e], errors + + +def test_empty_state_explains_how_to_record(browser, tmp_path, monkeypatch): + import uvicorn + + import praisonaiui.server as server + from praisonaiui.features.replay import configure_replay_app + + monkeypatch.setenv("PRAISONAI_TRACES_DIR", str(tmp_path / "empty")) + monkeypatch.setenv("AIUI_DATA_DIR", str(tmp_path / "aiui-data")) + monkeypatch.setenv("PRAISONAI_HOME", str(tmp_path / "praisonai-home")) + _no_provider_keys(monkeypatch) + server.reset_state() + configure_replay_app() + app = server.create_app() + port = _free_port() + srv = uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning")) + th = threading.Thread(target=srv.run, daemon=True) + th.start() + try: + for _ in range(100): + if srv.started: + break + time.sleep(0.05) + page, _ = _page(browser) + page.goto(f"http://127.0.0.1:{port}/") + page.wait_for_selector(".rp-empty") + text = page.locator(".rp-empty").inner_text() + assert "praisonai replay view agents.yaml" in text + assert "with record(live=True):" in text + violations = _axe(page) + assert violations == [], json.dumps(violations, indent=2) + finally: + srv.should_exit = True + th.join(timeout=5) + server.reset_state() diff --git a/tests/fixtures/replay/flow_parallel_loop.jsonl b/tests/fixtures/replay/flow_parallel_loop.jsonl new file mode 100644 index 00000000..d0d0e9dc --- /dev/null +++ b/tests/fixtures/replay/flow_parallel_loop.jsonl @@ -0,0 +1,42 @@ +{"schema_version":"1.1","event_type":"session_start","timestamp":1767225600.0,"session_id":"sess-flow-01","agent_name":"","sequence_num":0,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01"} +{"schema_version":"1.1","event_type":"workflow_start","timestamp":1767225600.02,"session_id":"sess-flow-01","agent_name":"","sequence_num":1,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"kind":"flow","name":"Weekly digest","process":"flow","graph":{"schema":"praisonai.graph/1","kind":"flow","name":"Weekly digest","process":"flow","nodes":[{"id":"start:begin","kind":"start","label":"Brief","parent":null,"meta":{}},{"id":"step:plan","kind":"step","label":"plan","parent":null,"meta":{"agent":"Planner"}},{"id":"step:gather","kind":"fork","label":"gather","parent":null,"meta":{"pattern":"parallel"}},{"id":"step:news","kind":"step","label":"news","parent":"step:gather","meta":{"agent":"Fetcher"}},{"id":"step:papers","kind":"step","label":"papers","parent":"step:gather","meta":{"agent":"Fetcher"}},{"id":"step:merge","kind":"join","label":"merge","parent":null,"meta":{}},{"id":"step:review_loop","kind":"step","label":"review_loop","parent":null,"meta":{"pattern":"loop"}},{"id":"step:polish","kind":"step","label":"polish","parent":"step:review_loop","meta":{"agent":"Editor"}},{"id":"step:decide","kind":"decision","label":"decide","parent":null,"meta":{"pattern":"route"}},{"id":"step:publish","kind":"step","label":"publish","parent":null,"meta":{"agent":"Publisher"}},{"id":"step:archive","kind":"step","label":"archive","parent":null,"meta":{}},{"id":"end:done","kind":"end","label":"Delivered","parent":null,"meta":{}}],"edges":[{"id":"start:begin->step:plan:next","source":"start:begin","target":"step:plan","kind":"next","label":null},{"id":"step:plan->step:gather:next","source":"step:plan","target":"step:gather","kind":"next","label":null},{"id":"step:gather->step:news:parallel","source":"step:gather","target":"step:news","kind":"parallel","label":null},{"id":"step:gather->step:papers:parallel","source":"step:gather","target":"step:papers","kind":"parallel","label":null},{"id":"step:news->step:merge:next","source":"step:news","target":"step:merge","kind":"next","label":null},{"id":"step:papers->step:merge:next","source":"step:papers","target":"step:merge","kind":"next","label":null},{"id":"step:merge->step:review_loop:next","source":"step:merge","target":"step:review_loop","kind":"next","label":null},{"id":"step:review_loop->step:polish:loop","source":"step:review_loop","target":"step:polish","kind":"loop","label":"until approved"},{"id":"step:polish->step:review_loop:loop","source":"step:polish","target":"step:review_loop","kind":"loop","label":null},{"id":"step:review_loop->step:decide:next","source":"step:review_loop","target":"step:decide","kind":"next","label":null},{"id":"step:decide->step:publish:routes","source":"step:decide","target":"step:publish","kind":"routes","label":"publish"},{"id":"step:decide->step:archive:routes","source":"step:decide","target":"step:archive","kind":"routes","label":"archive"},{"id":"step:publish->end:done:next","source":"step:publish","target":"end:done","kind":"next","label":null},{"id":"step:archive->end:done:next","source":"step:archive","target":"end:done","kind":"next","label":null}]}},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":0} +{"schema_version":"1.1","event_type":"step_start","timestamp":1767225600.05,"session_id":"sess-flow-01","agent_name":"Planner","sequence_num":2,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"name":"plan"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":1,"node_id":"step:plan"} +{"schema_version":"1.1","event_type":"agent_start","timestamp":1767225600.06,"session_id":"sess-flow-01","agent_name":"Planner","sequence_num":3,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":2,"node_id":"agent:Planner"} +{"schema_version":"1.1","event_type":"llm_request","timestamp":1767225600.1,"session_id":"sess-flow-01","agent_name":"Planner","sequence_num":4,"messages_count":3,"tokens_used":150,"tokens_budget":128000,"data":{"model":"gpt-4o-mini"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":3,"node_id":"agent:Planner"} +{"schema_version":"1.1","event_type":"llm_response","timestamp":1767225600.9,"session_id":"sess-flow-01","agent_name":"Planner","sequence_num":5,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"response_tokens":60,"duration_ms":800.0,"finish_reason":"stop","model":"gpt-4o-mini"},"prompt_tokens":150,"completion_tokens":60,"cost_usd":5.9e-05,"run_id":"flow-loop-01","parent_seq":3,"node_id":"agent:Planner"} +{"schema_version":"1.1","event_type":"agent_end","timestamp":1767225600.95,"session_id":"sess-flow-01","agent_name":"Planner","sequence_num":6,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":3,"node_id":"agent:Planner"} +{"schema_version":"1.1","event_type":"step_end","timestamp":1767225601.0,"session_id":"sess-flow-01","agent_name":"Planner","sequence_num":7,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed","duration_ms":950.0,"output_preview":"Gather news and papers."},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":2,"node_id":"step:plan"} +{"schema_version":"1.1","event_type":"step_start","timestamp":1767225601.05,"session_id":"sess-flow-01","agent_name":"","sequence_num":8,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"name":"gather","pattern":"parallel"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":1,"node_id":"step:gather"} +{"schema_version":"1.1","event_type":"step_start","timestamp":1767225601.1,"session_id":"sess-flow-01","agent_name":"Fetcher","sequence_num":9,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"name":"news"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"branch_id":"gather/0","run_id":"flow-loop-01","parent_seq":8,"node_id":"step:news"} +{"schema_version":"1.1","event_type":"step_start","timestamp":1767225601.1,"session_id":"sess-flow-01","agent_name":"Fetcher","sequence_num":10,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"name":"papers"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"branch_id":"gather/1","run_id":"flow-loop-01","parent_seq":8,"node_id":"step:papers"} +{"schema_version":"1.1","event_type":"tool_call_start","timestamp":1767225601.15,"session_id":"sess-flow-01","agent_name":"Fetcher","sequence_num":11,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"fetch_rss","tool_args":{"url":"https://example.org/news.rss"}},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"branch_id":"gather/0","run_id":"flow-loop-01","parent_seq":9,"node_id":"tool:Fetcher/fetch_rss"} +{"schema_version":"1.1","event_type":"tool_call_start","timestamp":1767225601.16,"session_id":"sess-flow-01","agent_name":"Fetcher","sequence_num":12,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"search_papers","tool_args":{"query":"agent tracing"}},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"branch_id":"gather/1","run_id":"flow-loop-01","parent_seq":10,"node_id":"tool:Fetcher/search_papers"} +{"schema_version":"1.1","event_type":"tool_call_end","timestamp":1767225602.4,"session_id":"sess-flow-01","agent_name":"Fetcher","sequence_num":13,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"fetch_rss","result":"12 items","duration_ms":1250.0,"error":null},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"branch_id":"gather/0","run_id":"flow-loop-01","parent_seq":11,"node_id":"tool:Fetcher/fetch_rss"} +{"schema_version":"1.1","event_type":"tool_call_end","timestamp":1767225603.0,"session_id":"sess-flow-01","agent_name":"Fetcher","sequence_num":14,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"search_papers","result":"5 papers","duration_ms":1840.0,"error":null},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.001,"branch_id":"gather/1","run_id":"flow-loop-01","parent_seq":12,"node_id":"tool:Fetcher/search_papers"} +{"schema_version":"1.1","event_type":"step_end","timestamp":1767225602.45,"session_id":"sess-flow-01","agent_name":"Fetcher","sequence_num":15,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed","duration_ms":1350.0,"output_preview":"12 items"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"branch_id":"gather/0","run_id":"flow-loop-01","parent_seq":9,"node_id":"step:news"} +{"schema_version":"1.1","event_type":"step_end","timestamp":1767225603.05,"session_id":"sess-flow-01","agent_name":"Fetcher","sequence_num":16,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed","duration_ms":1950.0,"output_preview":"5 papers"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"branch_id":"gather/1","run_id":"flow-loop-01","parent_seq":10,"node_id":"step:papers"} +{"schema_version":"1.1","event_type":"step_end","timestamp":1767225603.1,"session_id":"sess-flow-01","agent_name":"","sequence_num":17,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed","duration_ms":2050.0},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":8,"node_id":"step:gather"} +{"schema_version":"1.1","event_type":"context_rebalance","timestamp":1767225603.12,"session_id":"sess-flow-01","agent_name":"","sequence_num":18,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"note":"future event type"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":1,"future_field":{"x":1}} +{"schema_version":"1.1","event_type":"step_start","timestamp":1767225603.15,"session_id":"sess-flow-01","agent_name":"","sequence_num":19,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"name":"review_loop","pattern":"loop"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":1,"node_id":"step:review_loop"} +{"schema_version":"1.1","event_type":"step_start","timestamp":1767225603.2,"session_id":"sess-flow-01","agent_name":"Editor","sequence_num":20,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"name":"polish","iteration":1},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":19,"node_id":"step:polish"} +{"schema_version":"1.1","event_type":"llm_request","timestamp":1767225603.22,"session_id":"sess-flow-01","agent_name":"Editor","sequence_num":21,"messages_count":3,"tokens_used":400,"tokens_budget":128000,"data":{"model":"gpt-4o-mini"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":20,"node_id":"agent:Editor"} +{"schema_version":"1.1","event_type":"llm_response","timestamp":1767225603.92,"session_id":"sess-flow-01","agent_name":"Editor","sequence_num":22,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"response_tokens":90,"duration_ms":700.0,"finish_reason":"stop","model":"gpt-4o-mini"},"prompt_tokens":400,"completion_tokens":90,"cost_usd":0.000114,"run_id":"flow-loop-01","parent_seq":20,"node_id":"agent:Editor"} +{"schema_version":"1.1","event_type":"step_end","timestamp":1767225603.95,"session_id":"sess-flow-01","agent_name":"Editor","sequence_num":23,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed","duration_ms":750.0,"output_preview":"Draft v2"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":20,"node_id":"step:polish"} +{"schema_version":"1.1","event_type":"step_start","timestamp":1767225604.0,"session_id":"sess-flow-01","agent_name":"Editor","sequence_num":24,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"name":"polish","iteration":2},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":19,"node_id":"step:polish"} +{"schema_version":"1.1","event_type":"llm_request","timestamp":1767225604.02,"session_id":"sess-flow-01","agent_name":"Editor","sequence_num":25,"messages_count":3,"tokens_used":450,"tokens_budget":128000,"data":{"model":"gpt-4o-mini"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":24,"node_id":"agent:Editor"} +{"schema_version":"1.1","event_type":"llm_response","timestamp":1767225604.72,"session_id":"sess-flow-01","agent_name":"Editor","sequence_num":26,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"response_tokens":90,"duration_ms":700.0,"finish_reason":"stop","model":"gpt-4o-mini"},"prompt_tokens":450,"completion_tokens":90,"cost_usd":0.00012150000000000001,"run_id":"flow-loop-01","parent_seq":24,"node_id":"agent:Editor"} +{"schema_version":"1.1","event_type":"step_end","timestamp":1767225604.75,"session_id":"sess-flow-01","agent_name":"Editor","sequence_num":27,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed","duration_ms":750.0,"output_preview":"Draft v3"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":24,"node_id":"step:polish"} +{"schema_version":"1.1","event_type":"step_start","timestamp":1767225604.8,"session_id":"sess-flow-01","agent_name":"Editor","sequence_num":28,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"name":"polish","iteration":3},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":19,"node_id":"step:polish"} +{"schema_version":"1.1","event_type":"llm_request","timestamp":1767225604.82,"session_id":"sess-flow-01","agent_name":"Editor","sequence_num":29,"messages_count":3,"tokens_used":500,"tokens_budget":128000,"data":{"model":"gpt-4o-mini"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":28,"node_id":"agent:Editor"} +{"schema_version":"1.1","event_type":"llm_response","timestamp":1767225605.52,"session_id":"sess-flow-01","agent_name":"Editor","sequence_num":30,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"response_tokens":90,"duration_ms":700.0,"finish_reason":"stop","model":"gpt-4o-mini"},"prompt_tokens":500,"completion_tokens":90,"cost_usd":0.00012900000000000002,"run_id":"flow-loop-01","parent_seq":28,"node_id":"agent:Editor"} +{"schema_version":"1.1","event_type":"step_end","timestamp":1767225605.55,"session_id":"sess-flow-01","agent_name":"Editor","sequence_num":31,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed","duration_ms":750.0,"output_preview":"Draft v4"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":28,"node_id":"step:polish"} +{"schema_version":"1.1","event_type":"step_end","timestamp":1767225605.6,"session_id":"sess-flow-01","agent_name":"","sequence_num":32,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed","duration_ms":2450.0},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":19,"node_id":"step:review_loop"} +{"schema_version":"1.1","event_type":"step_start","timestamp":1767225605.65,"session_id":"sess-flow-01","agent_name":"","sequence_num":33,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"name":"decide","pattern":"route"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":1,"node_id":"step:decide"} +{"schema_version":"1.1","event_type":"agent_handoff","timestamp":1767225605.7,"session_id":"sess-flow-01","agent_name":"Editor","sequence_num":34,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"from_agent":"Editor","to_agent":"Publisher","kind":"route","reason":"Draft approved by reviewer score 0.92","context_passed":{"score":0.92}},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":33,"node_id":"step:decide"} +{"schema_version":"1.1","event_type":"step_end","timestamp":1767225605.75,"session_id":"sess-flow-01","agent_name":"","sequence_num":35,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed","duration_ms":100.0,"route":"publish"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":33,"node_id":"step:decide"} +{"schema_version":"1.1","event_type":"step_start","timestamp":1767225605.8,"session_id":"sess-flow-01","agent_name":"Publisher","sequence_num":36,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"name":"publish"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":1,"node_id":"step:publish"} +{"schema_version":"1.1","event_type":"tool_call_start","timestamp":1767225605.85,"session_id":"sess-flow-01","agent_name":"Publisher","sequence_num":37,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"post_to_blog","tool_args":{"title":"Weekly digest"}},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":36,"node_id":"tool:Publisher/post_to_blog"} +{"schema_version":"1.1","event_type":"tool_call_end","timestamp":1767225606.45,"session_id":"sess-flow-01","agent_name":"Publisher","sequence_num":38,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"post_to_blog","result":"posted /digest/42","duration_ms":600.0,"error":null},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":37,"node_id":"tool:Publisher/post_to_blog"} +{"schema_version":"1.1","event_type":"step_end","timestamp":1767225606.5,"session_id":"sess-flow-01","agent_name":"Publisher","sequence_num":39,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed","duration_ms":700.0,"output_preview":"posted"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":36,"node_id":"step:publish"} +{"schema_version":"1.1","event_type":"workflow_end","timestamp":1767225606.55,"session_id":"sess-flow-01","agent_name":"","sequence_num":40,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed","duration_ms":6530.0},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":1} +{"schema_version":"1.1","event_type":"session_end","timestamp":1767225606.6,"session_id":"sess-flow-01","agent_name":"","sequence_num":41,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":0} diff --git a/tests/fixtures/replay/legacy_v10.jsonl b/tests/fixtures/replay/legacy_v10.jsonl new file mode 100644 index 00000000..1908214d --- /dev/null +++ b/tests/fixtures/replay/legacy_v10.jsonl @@ -0,0 +1,16 @@ +{"schema_version":"1.0","event_type":"session_start","timestamp":1767225600.0,"session_id":"legacy-sess-10","agent_name":"","sequence_num":0,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0} +{"schema_version":"1.0","event_type":"agent_start","timestamp":1767225600.1,"session_id":"legacy-sess-10","agent_name":"Assistant","sequence_num":1,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"role":"Assistant"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0} +{"schema_version":"1.0","event_type":"message_added","timestamp":1767225600.15,"session_id":"legacy-sess-10","agent_name":"Assistant","sequence_num":2,"messages_count":1,"tokens_used":0,"tokens_budget":0,"data":{"role":"user","content":"What is 17 * 23?"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0} +{"schema_version":"1.0","event_type":"llm_request","timestamp":1767225600.2,"session_id":"legacy-sess-10","agent_name":"Assistant","sequence_num":3,"messages_count":3,"tokens_used":90,"tokens_budget":128000,"data":{"model":"gpt-4o-mini"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"parent_seq":null,"node_id":null} +{"schema_version":"1.0","event_type":"llm_response","timestamp":1767225600.7,"session_id":"legacy-sess-10","agent_name":"Assistant","sequence_num":4,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"response_tokens":20,"duration_ms":500.0,"finish_reason":"tool_calls","model":"gpt-4o-mini"},"prompt_tokens":90,"completion_tokens":20,"cost_usd":2.6e-05,"parent_seq":null,"node_id":null} +{"schema_version":"1.0","event_type":"tool_call_start","timestamp":1767225600.75,"session_id":"legacy-sess-10","agent_name":"Assistant","sequence_num":5,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"calculator","tool_args":{"expression":"17*23"}},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"parent_seq":null,"node_id":null} +{"schema_version":"1.0","event_type":"tool_call_end","timestamp":1767225600.76,"session_id":"legacy-sess-10","agent_name":"Assistant","sequence_num":6,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"calculator","result":"391","duration_ms":10.0,"error":null},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"parent_seq":null,"node_id":null} +{"schema_version":"1.0","event_type":"llm_request","timestamp":1767225600.8,"session_id":"legacy-sess-10","agent_name":"Assistant","sequence_num":7,"messages_count":3,"tokens_used":130,"tokens_budget":128000,"data":{"model":"gpt-4o-mini"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"parent_seq":null,"node_id":null} +{"schema_version":"1.0","event_type":"llm_response","timestamp":1767225601.2,"session_id":"legacy-sess-10","agent_name":"Assistant","sequence_num":8,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"response_tokens":12,"duration_ms":400.0,"finish_reason":"stop","model":"gpt-4o-mini"},"prompt_tokens":130,"completion_tokens":12,"cost_usd":2.7e-05,"parent_seq":null,"node_id":null} +{"schema_version":"1.0","event_type":"agent_handoff","timestamp":1767225601.25,"session_id":"legacy-sess-10","agent_name":"Assistant","sequence_num":9,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"from_agent":"Assistant","to_agent":"Checker","reason":"Double-check the arithmetic","context_passed":{"answer":391}},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0} +{"schema_version":"1.0","event_type":"agent_end","timestamp":1767225601.26,"session_id":"legacy-sess-10","agent_name":"Assistant","sequence_num":10,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0} +{"schema_version":"1.0","event_type":"agent_start","timestamp":1767225601.3,"session_id":"legacy-sess-10","agent_name":"Checker","sequence_num":11,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"role":"Checker"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0} +{"schema_version":"1.0","event_type":"llm_request","timestamp":1767225601.35,"session_id":"legacy-sess-10","agent_name":"Checker","sequence_num":12,"messages_count":3,"tokens_used":60,"tokens_budget":128000,"data":{"model":"gpt-4o-mini"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"parent_seq":null,"node_id":null} +{"schema_version":"1.0","event_type":"llm_response","timestamp":1767225601.8,"session_id":"legacy-sess-10","agent_name":"Checker","sequence_num":13,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"response_tokens":8,"duration_ms":450.0,"finish_reason":"stop","model":"gpt-4o-mini"},"prompt_tokens":60,"completion_tokens":8,"cost_usd":1.4e-05,"parent_seq":null,"node_id":null} +{"schema_version":"1.0","event_type":"agent_end","timestamp":1767225601.85,"session_id":"legacy-sess-10","agent_name":"Checker","sequence_num":14,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0} +{"schema_version":"1.0","event_type":"session_end","timestamp":1767225601.9,"session_id":"legacy-sess-10","agent_name":"","sequence_num":15,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0} diff --git a/tests/fixtures/replay/make_fixtures.py b/tests/fixtures/replay/make_fixtures.py new file mode 100644 index 00000000..5ea4fbd0 --- /dev/null +++ b/tests/fixtures/replay/make_fixtures.py @@ -0,0 +1,312 @@ +"""Regenerate the PraisonAI Replay JSONL fixtures (contract v1, schema 1.1 + 1.0). + +Run from the repo root:: + + python tests/fixtures/replay/make_fixtures.py + +The output is deterministic (fixed timestamps, no uuids) so the files are +committed and the tests compare against exact values. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +HERE = Path(__file__).resolve().parent +T0 = 1767225600.0 # 2026-01-01T00:00:00Z + + +class _Run: + """Tiny emitter that mimics ContextTraceEmitter's to_dict() output.""" + + def __init__(self, session_id: str, run_id: str | None, schema: str = "1.1") -> None: + self.session_id = session_id + self.run_id = run_id + self.schema = schema + self.seq = 0 + self.events: list[dict] = [] + + def emit(self, event_type: str, t: float, agent: str = "", data: dict | None = None, **kw) -> int: + ev = { + "schema_version": self.schema, + "event_type": event_type, + "timestamp": round(T0 + t, 3), + "session_id": self.session_id, + "agent_name": agent, + "sequence_num": self.seq, + "messages_count": kw.pop("messages_count", 0), + "tokens_used": kw.pop("tokens_used", 0), + "tokens_budget": kw.pop("tokens_budget", 0), + "data": data or {}, + "prompt_tokens": kw.pop("prompt_tokens", 0), + "completion_tokens": kw.pop("completion_tokens", 0), + "cost_usd": kw.pop("cost_usd", 0.0), + } + for key in ("branch_id", "content_hash"): + if kw.get(key) is not None: + ev[key] = kw.pop(key) + else: + kw.pop(key, None) + if self.schema != "1.0": + if self.run_id: + ev["run_id"] = self.run_id + for key in ("parent_seq", "node_id"): + if kw.get(key) is not None: + ev[key] = kw.pop(key) + else: + kw.pop(key, None) + ev.update(kw) # unknown/extra fields pass straight through + self.events.append(ev) + self.seq += 1 + return ev["sequence_num"] + + def llm(self, t: float, agent: str, dur: float, parent: int | None, *, prompt: int, completion: int, + cost: float, model: str = "gpt-4o-mini", finish: str = "stop", content: str = "", + branch: str | None = None, node: str | None = None) -> None: + self.emit("llm_request", t, agent, {"model": model}, parent_seq=parent, messages_count=3, + tokens_used=prompt, tokens_budget=128000, branch_id=branch, node_id=node) + data = {"response_tokens": completion, "duration_ms": round(dur * 1000, 1), + "finish_reason": finish, "model": model} + if content: + data["response_content"] = content + self.emit("llm_response", t + dur, agent, data, parent_seq=parent, prompt_tokens=prompt, + completion_tokens=completion, cost_usd=cost, branch_id=branch, node_id=node) + + def tool(self, t: float, agent: str, name: str, args: dict, dur: float, parent: int | None, *, + result: str, cost: float = 0.0, error: str | None = None, branch: str | None = None) -> None: + node = f"tool:{agent}/{name}" if self.schema != "1.0" else None + s = self.emit("tool_call_start", t, agent, {"tool_name": name, "tool_args": args}, + parent_seq=parent, node_id=node, branch_id=branch) + self.emit("tool_call_end", t + dur, agent, + {"tool_name": name, "result": result, "duration_ms": round(dur * 1000, 1), "error": error}, + parent_seq=s if self.schema != "1.0" else None, node_id=node, cost_usd=cost, branch_id=branch) + + def write(self, name: str, *, partial_tail: str | None = None) -> None: + path = HERE / name + with open(path, "w", encoding="utf-8", newline="\n") as fh: + for ev in self.events: + fh.write(json.dumps(ev, separators=(",", ":")) + "\n") + if partial_tail: + fh.write(partial_tail) + print(f"wrote {path.name}: {len(self.events)} events") + + +def team_handoff() -> None: + """Hierarchical 2-agent team: Manager delegates, Researcher hands off to Writer. + + Researcher calls web_search three times in a row (collapses to x3); Writer + asks for approval to run write_file, the human approves with edited args. + """ + r = _Run("sess-team-01", "team-handoff-01") + graph = { + "schema": "praisonai.graph/1", "kind": "team", "name": "Solar sail brief", "process": "hierarchical", + "nodes": [ + {"id": "start:begin", "kind": "start", "label": "Brief", "parent": None, "meta": {}}, + {"id": "manager:Manager", "kind": "manager", "label": "Manager", "parent": None, "meta": {}}, + {"id": "task:research", "kind": "task", "label": "research", "parent": None, "meta": {}}, + {"id": "task:write", "kind": "task", "label": "write", "parent": None, "meta": {}}, + {"id": "agent:Researcher", "kind": "agent", "label": "Researcher", "parent": None, "meta": {}}, + {"id": "agent:Writer", "kind": "agent", "label": "Writer", "parent": None, "meta": {}}, + {"id": "tool:Researcher/web_search", "kind": "tool", "label": "web_search", "parent": None, "meta": {}}, + {"id": "tool:Writer/write_file", "kind": "tool", "label": "write_file", "parent": None, "meta": {}}, + {"id": "human:you", "kind": "human", "label": "You", "parent": None, "meta": {}}, + {"id": "end:done", "kind": "end", "label": "Delivered", "parent": None, "meta": {}}, + ], + "edges": [ + {"id": "start:begin->manager:Manager:next", "source": "start:begin", "target": "manager:Manager", "kind": "next", "label": None}, + {"id": "manager:Manager->task:research:delegates", "source": "manager:Manager", "target": "task:research", "kind": "delegates", "label": None}, + {"id": "manager:Manager->task:write:delegates", "source": "manager:Manager", "target": "task:write", "kind": "delegates", "label": None}, + {"id": "task:write->task:research:depends_on", "source": "task:write", "target": "task:research", "kind": "depends_on", "label": None}, + {"id": "task:research->agent:Researcher:assigned", "source": "task:research", "target": "agent:Researcher", "kind": "assigned", "label": None}, + {"id": "task:write->agent:Writer:assigned", "source": "task:write", "target": "agent:Writer", "kind": "assigned", "label": None}, + {"id": "agent:Researcher->agent:Writer:handoff", "source": "agent:Researcher", "target": "agent:Writer", "kind": "handoff", "label": None}, + {"id": "agent:Researcher->tool:Researcher/web_search:uses", "source": "agent:Researcher", "target": "tool:Researcher/web_search", "kind": "uses", "label": None}, + {"id": "agent:Writer->tool:Writer/write_file:uses", "source": "agent:Writer", "target": "tool:Writer/write_file", "kind": "uses", "label": None}, + {"id": "human:you->tool:Writer/write_file:reviews", "source": "human:you", "target": "tool:Writer/write_file", "kind": "reviews", "label": None}, + {"id": "agent:Writer->end:done:next", "source": "agent:Writer", "target": "end:done", "kind": "next", "label": None}, + ], + } + s0 = r.emit("session_start", 0.0, "", {"source": "record"}) + w = r.emit("workflow_start", 0.05, "", {"kind": "team", "name": "Solar sail brief", "process": "hierarchical", + "graph": graph}, parent_seq=s0) + r.emit("message_added", 0.1, "", {"role": "user", "content": "Write a short brief on solar sails."}, parent_seq=w) + r.llm(0.2, "Manager", 0.6, w, prompt=210, completion=48, cost=0.000060, node="manager:Manager", + content="Delegate research first, then writing.") + r.emit("agent_handoff", 0.85, "Manager", { + "from_agent": "Manager", "to_agent": "Researcher", "kind": "delegate", + "reason": "Research has to come before writing.", + "context_passed": {"task_snapshot": {"task": "research", "description": "Find three facts about solar sails", + "expected_output": "3 bullet facts", "status": "not started"}}, + }, parent_seq=w, node_id="manager:Manager") + t1 = r.emit("task_start", 0.9, "Researcher", {"name": "research", "agent": "Researcher", + "description_preview": "Find three facts about solar sails"}, + parent_seq=w, node_id="task:research") + a1 = r.emit("agent_start", 0.95, "Researcher", {"role": "Researcher", "goal": "Find facts"}, + parent_seq=t1, node_id="agent:Researcher") + r.emit("message_added", 1.0, "Researcher", {"role": "user", "content": "Find three facts about solar sails"}, + parent_seq=a1) + r.llm(1.05, "Researcher", 0.9, a1, prompt=320, completion=40, cost=0.000072, finish="tool_calls", + node="agent:Researcher") + r.tool(2.0, "Researcher", "web_search", {"query": "solar sail facts"}, 1.2, a1, + result="IKAROS (2010) was the first interplanetary solar sail.", cost=0.002) + r.tool(3.25, "Researcher", "web_search", {"query": "solar sail missions"}, 1.1, a1, + result="LightSail 2 raised its orbit using sunlight in 2019.", cost=0.002) + r.tool(4.4, "Researcher", "web_search", {"query": "solar sail physics"}, 0.9, a1, + result="Photon pressure at 1 AU is about 9 micronewtons per square metre.", cost=0.002) + r.llm(5.35, "Researcher", 1.4, a1, prompt=880, completion=160, cost=0.000228, node="agent:Researcher", + content="1. IKAROS ... 2. LightSail 2 ... 3. ~9 uN/m^2") + r.emit("agent_end", 6.8, "Researcher", {"status": "completed"}, parent_seq=a1, node_id="agent:Researcher") + r.emit("task_end", 6.85, "Researcher", {"status": "completed", "retries": 0, "duration_ms": 5950.0, + "output_preview": "1. IKAROS ... 2. LightSail 2 ... 3. ~9 uN/m^2"}, + parent_seq=t1, node_id="task:research") + r.emit("agent_handoff", 6.9, "Researcher", { + "from_agent": "Researcher", "to_agent": "Writer", "kind": "handoff", + "reason": "Facts gathered; needs drafting.", + "context_passed": {"facts": ["IKAROS 2010", "LightSail 2 2019", "~9 uN/m^2 at 1 AU"]}, + }, parent_seq=w, node_id="agent:Researcher") + t2 = r.emit("task_start", 6.95, "Writer", {"name": "write", "agent": "Writer", + "description_preview": "Draft a 100-word brief into draft.md"}, + parent_seq=w, node_id="task:write") + a2 = r.emit("agent_start", 7.0, "Writer", {"role": "Writer"}, parent_seq=t2, node_id="agent:Writer") + r.llm(7.05, "Writer", 1.6, a2, prompt=640, completion=220, cost=0.000228, finish="tool_calls", + node="agent:Writer") + ap = r.emit("approval_requested", 8.7, "Writer", { + "approval_id": "appr-0001", "tool": "write_file", + "args": {"path": "draft.md", "content": "[REDACTED]"}, "risk": "high", "kind": "tool_call", + }, parent_seq=a2, node_id="tool:Writer/write_file") + r.emit("approval_decided", 11.2, "", { + "approval_id": "appr-0001", "approved": True, "reason": "Use the docs folder.", + "approver": "user", "modified": True, + }, parent_seq=ap, node_id="tool:Writer/write_file") + r.tool(11.25, "Writer", "write_file", {"path": "docs/draft.md", "content": "[REDACTED]"}, 0.05, a2, + result="Wrote 642 bytes to docs/draft.md") + r.llm(11.35, "Writer", 0.7, a2, prompt=900, completion=30, cost=0.000153, node="agent:Writer", + content="Saved the brief to docs/draft.md.") + r.emit("agent_end", 12.1, "Writer", {"status": "completed"}, parent_seq=a2, node_id="agent:Writer") + r.emit("task_end", 12.15, "Writer", {"status": "completed", "retries": 0, "duration_ms": 5200.0, + "output_preview": "Saved the brief to docs/draft.md."}, + parent_seq=t2, node_id="task:write") + r.emit("workflow_end", 12.2, "", {"status": "completed", "duration_ms": 12150.0}, parent_seq=w) + r.emit("session_end", 12.25, "", {}, parent_seq=s0) + r.write("team_handoff.jsonl") + + +def flow_parallel_loop() -> None: + """AgentFlow: plan -> parallel(gather news, papers) -> loop(polish x3) -> route(decide) -> publish. + + Both parallel branches run the same agent (Fetcher) so they must appear as + two sub-lanes. Includes an unknown event type and an unknown field to prove + readers tolerate them. + """ + r = _Run("sess-flow-01", "flow-loop-01") + graph = { + "schema": "praisonai.graph/1", "kind": "flow", "name": "Weekly digest", "process": "flow", + "nodes": [ + {"id": "start:begin", "kind": "start", "label": "Brief", "parent": None, "meta": {}}, + {"id": "step:plan", "kind": "step", "label": "plan", "parent": None, "meta": {"agent": "Planner"}}, + {"id": "step:gather", "kind": "fork", "label": "gather", "parent": None, "meta": {"pattern": "parallel"}}, + {"id": "step:news", "kind": "step", "label": "news", "parent": "step:gather", "meta": {"agent": "Fetcher"}}, + {"id": "step:papers", "kind": "step", "label": "papers", "parent": "step:gather", "meta": {"agent": "Fetcher"}}, + {"id": "step:merge", "kind": "join", "label": "merge", "parent": None, "meta": {}}, + {"id": "step:review_loop", "kind": "step", "label": "review_loop", "parent": None, "meta": {"pattern": "loop"}}, + {"id": "step:polish", "kind": "step", "label": "polish", "parent": "step:review_loop", "meta": {"agent": "Editor"}}, + {"id": "step:decide", "kind": "decision", "label": "decide", "parent": None, "meta": {"pattern": "route"}}, + {"id": "step:publish", "kind": "step", "label": "publish", "parent": None, "meta": {"agent": "Publisher"}}, + {"id": "step:archive", "kind": "step", "label": "archive", "parent": None, "meta": {}}, + {"id": "end:done", "kind": "end", "label": "Delivered", "parent": None, "meta": {}}, + ], + "edges": [ + {"id": "start:begin->step:plan:next", "source": "start:begin", "target": "step:plan", "kind": "next", "label": None}, + {"id": "step:plan->step:gather:next", "source": "step:plan", "target": "step:gather", "kind": "next", "label": None}, + {"id": "step:gather->step:news:parallel", "source": "step:gather", "target": "step:news", "kind": "parallel", "label": None}, + {"id": "step:gather->step:papers:parallel", "source": "step:gather", "target": "step:papers", "kind": "parallel", "label": None}, + {"id": "step:news->step:merge:next", "source": "step:news", "target": "step:merge", "kind": "next", "label": None}, + {"id": "step:papers->step:merge:next", "source": "step:papers", "target": "step:merge", "kind": "next", "label": None}, + {"id": "step:merge->step:review_loop:next", "source": "step:merge", "target": "step:review_loop", "kind": "next", "label": None}, + {"id": "step:review_loop->step:polish:loop", "source": "step:review_loop", "target": "step:polish", "kind": "loop", "label": "until approved"}, + {"id": "step:polish->step:review_loop:loop", "source": "step:polish", "target": "step:review_loop", "kind": "loop", "label": None}, + {"id": "step:review_loop->step:decide:next", "source": "step:review_loop", "target": "step:decide", "kind": "next", "label": None}, + {"id": "step:decide->step:publish:routes", "source": "step:decide", "target": "step:publish", "kind": "routes", "label": "publish"}, + {"id": "step:decide->step:archive:routes", "source": "step:decide", "target": "step:archive", "kind": "routes", "label": "archive"}, + {"id": "step:publish->end:done:next", "source": "step:publish", "target": "end:done", "kind": "next", "label": None}, + {"id": "step:archive->end:done:next", "source": "step:archive", "target": "end:done", "kind": "next", "label": None}, + ], + } + s0 = r.emit("session_start", 0.0, "", {}) + w = r.emit("workflow_start", 0.02, "", {"kind": "flow", "name": "Weekly digest", "process": "flow", + "graph": graph}, parent_seq=s0) + sp = r.emit("step_start", 0.05, "Planner", {"name": "plan"}, parent_seq=w, node_id="step:plan") + a = r.emit("agent_start", 0.06, "Planner", {}, parent_seq=sp, node_id="agent:Planner") + r.llm(0.1, "Planner", 0.8, a, prompt=150, completion=60, cost=0.000059, node="agent:Planner") + r.emit("agent_end", 0.95, "Planner", {}, parent_seq=a, node_id="agent:Planner") + r.emit("step_end", 1.0, "Planner", {"status": "completed", "duration_ms": 950.0, + "output_preview": "Gather news and papers."}, parent_seq=sp, node_id="step:plan") + g = r.emit("step_start", 1.05, "", {"name": "gather", "pattern": "parallel"}, parent_seq=w, node_id="step:gather") + # two branches interleaved in time, same agent + b0 = r.emit("step_start", 1.1, "Fetcher", {"name": "news"}, parent_seq=g, node_id="step:news", branch_id="gather/0") + b1 = r.emit("step_start", 1.1, "Fetcher", {"name": "papers"}, parent_seq=g, node_id="step:papers", branch_id="gather/1") + r.emit("tool_call_start", 1.15, "Fetcher", {"tool_name": "fetch_rss", "tool_args": {"url": "https://example.org/news.rss"}}, + parent_seq=b0, node_id="tool:Fetcher/fetch_rss", branch_id="gather/0") + r.emit("tool_call_start", 1.16, "Fetcher", {"tool_name": "search_papers", "tool_args": {"query": "agent tracing"}}, + parent_seq=b1, node_id="tool:Fetcher/search_papers", branch_id="gather/1") + r.emit("tool_call_end", 2.4, "Fetcher", {"tool_name": "fetch_rss", "result": "12 items", "duration_ms": 1250.0, "error": None}, + parent_seq=r.seq - 2, node_id="tool:Fetcher/fetch_rss", branch_id="gather/0") + r.emit("tool_call_end", 3.0, "Fetcher", {"tool_name": "search_papers", "result": "5 papers", "duration_ms": 1840.0, "error": None}, + parent_seq=r.seq - 2, node_id="tool:Fetcher/search_papers", branch_id="gather/1", cost_usd=0.001) + r.emit("step_end", 2.45, "Fetcher", {"status": "completed", "duration_ms": 1350.0, "output_preview": "12 items"}, + parent_seq=b0, node_id="step:news", branch_id="gather/0") + r.emit("step_end", 3.05, "Fetcher", {"status": "completed", "duration_ms": 1950.0, "output_preview": "5 papers"}, + parent_seq=b1, node_id="step:papers", branch_id="gather/1") + r.emit("step_end", 3.1, "", {"status": "completed", "duration_ms": 2050.0}, parent_seq=g, node_id="step:gather") + # an event type this reader has never heard of, plus an unknown field + r.emit("context_rebalance", 3.12, "", {"note": "future event type"}, parent_seq=w, future_field={"x": 1}) + lp = r.emit("step_start", 3.15, "", {"name": "review_loop", "pattern": "loop"}, parent_seq=w, node_id="step:review_loop") + t = 3.2 + for i in range(3): + ps = r.emit("step_start", t, "Editor", {"name": "polish", "iteration": i + 1}, parent_seq=lp, node_id="step:polish") + r.llm(t + 0.02, "Editor", 0.7, ps, prompt=400 + 50 * i, completion=90, cost=0.000114 + 0.0000075 * i, + node="agent:Editor") + r.emit("step_end", t + 0.75, "Editor", {"status": "completed", "duration_ms": 750.0, + "output_preview": f"Draft v{i + 2}"}, parent_seq=ps, node_id="step:polish") + t += 0.8 + r.emit("step_end", t, "", {"status": "completed", "duration_ms": round((t - 3.15) * 1000, 1)}, + parent_seq=lp, node_id="step:review_loop") + d = r.emit("step_start", t + 0.05, "", {"name": "decide", "pattern": "route"}, parent_seq=w, node_id="step:decide") + r.emit("agent_handoff", t + 0.1, "Editor", {"from_agent": "Editor", "to_agent": "Publisher", "kind": "route", + "reason": "Draft approved by reviewer score 0.92", + "context_passed": {"score": 0.92}}, parent_seq=d, node_id="step:decide") + r.emit("step_end", t + 0.15, "", {"status": "completed", "duration_ms": 100.0, "route": "publish"}, + parent_seq=d, node_id="step:decide") + pb = r.emit("step_start", t + 0.2, "Publisher", {"name": "publish"}, parent_seq=w, node_id="step:publish") + r.tool(t + 0.25, "Publisher", "post_to_blog", {"title": "Weekly digest"}, 0.6, pb, result="posted /digest/42") + r.emit("step_end", t + 0.9, "Publisher", {"status": "completed", "duration_ms": 700.0, "output_preview": "posted"}, + parent_seq=pb, node_id="step:publish") + r.emit("workflow_end", t + 0.95, "", {"status": "completed", "duration_ms": round((t + 0.93) * 1000, 1)}, parent_seq=w) + r.emit("session_end", t + 1.0, "", {}, parent_seq=s0) + r.write("flow_parallel_loop.jsonl") + + +def legacy_v10() -> None: + """A schema 1.0 file: no run_id, parent_seq, node_id; no workflow events.""" + r = _Run("legacy-sess-10", None, schema="1.0") + r.emit("session_start", 0.0, "", {}) + r.emit("agent_start", 0.1, "Assistant", {"role": "Assistant"}) + r.emit("message_added", 0.15, "Assistant", {"role": "user", "content": "What is 17 * 23?"}, messages_count=1) + r.llm(0.2, "Assistant", 0.5, None, prompt=90, completion=20, cost=0.000026, finish="tool_calls") + r.tool(0.75, "Assistant", "calculator", {"expression": "17*23"}, 0.01, None, result="391") + r.llm(0.8, "Assistant", 0.4, None, prompt=130, completion=12, cost=0.000027) + r.emit("agent_handoff", 1.25, "Assistant", {"from_agent": "Assistant", "to_agent": "Checker", + "reason": "Double-check the arithmetic", "context_passed": {"answer": 391}}) + r.emit("agent_end", 1.26, "Assistant", {}) + r.emit("agent_start", 1.3, "Checker", {"role": "Checker"}) + r.llm(1.35, "Checker", 0.45, None, prompt=60, completion=8, cost=0.000014) + r.emit("agent_end", 1.85, "Checker", {}) + r.emit("session_end", 1.9, "", {}) + r.write("legacy_v10.jsonl") + + +if __name__ == "__main__": + team_handoff() + flow_parallel_loop() + legacy_v10() diff --git a/tests/fixtures/replay/team_handoff.jsonl b/tests/fixtures/replay/team_handoff.jsonl new file mode 100644 index 00000000..e12792a9 --- /dev/null +++ b/tests/fixtures/replay/team_handoff.jsonl @@ -0,0 +1,36 @@ +{"schema_version":"1.1","event_type":"session_start","timestamp":1767225600.0,"session_id":"sess-team-01","agent_name":"","sequence_num":0,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"source":"record"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01"} +{"schema_version":"1.1","event_type":"workflow_start","timestamp":1767225600.05,"session_id":"sess-team-01","agent_name":"","sequence_num":1,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"kind":"team","name":"Solar sail brief","process":"hierarchical","graph":{"schema":"praisonai.graph/1","kind":"team","name":"Solar sail brief","process":"hierarchical","nodes":[{"id":"start:begin","kind":"start","label":"Brief","parent":null,"meta":{}},{"id":"manager:Manager","kind":"manager","label":"Manager","parent":null,"meta":{}},{"id":"task:research","kind":"task","label":"research","parent":null,"meta":{}},{"id":"task:write","kind":"task","label":"write","parent":null,"meta":{}},{"id":"agent:Researcher","kind":"agent","label":"Researcher","parent":null,"meta":{}},{"id":"agent:Writer","kind":"agent","label":"Writer","parent":null,"meta":{}},{"id":"tool:Researcher/web_search","kind":"tool","label":"web_search","parent":null,"meta":{}},{"id":"tool:Writer/write_file","kind":"tool","label":"write_file","parent":null,"meta":{}},{"id":"human:you","kind":"human","label":"You","parent":null,"meta":{}},{"id":"end:done","kind":"end","label":"Delivered","parent":null,"meta":{}}],"edges":[{"id":"start:begin->manager:Manager:next","source":"start:begin","target":"manager:Manager","kind":"next","label":null},{"id":"manager:Manager->task:research:delegates","source":"manager:Manager","target":"task:research","kind":"delegates","label":null},{"id":"manager:Manager->task:write:delegates","source":"manager:Manager","target":"task:write","kind":"delegates","label":null},{"id":"task:write->task:research:depends_on","source":"task:write","target":"task:research","kind":"depends_on","label":null},{"id":"task:research->agent:Researcher:assigned","source":"task:research","target":"agent:Researcher","kind":"assigned","label":null},{"id":"task:write->agent:Writer:assigned","source":"task:write","target":"agent:Writer","kind":"assigned","label":null},{"id":"agent:Researcher->agent:Writer:handoff","source":"agent:Researcher","target":"agent:Writer","kind":"handoff","label":null},{"id":"agent:Researcher->tool:Researcher/web_search:uses","source":"agent:Researcher","target":"tool:Researcher/web_search","kind":"uses","label":null},{"id":"agent:Writer->tool:Writer/write_file:uses","source":"agent:Writer","target":"tool:Writer/write_file","kind":"uses","label":null},{"id":"human:you->tool:Writer/write_file:reviews","source":"human:you","target":"tool:Writer/write_file","kind":"reviews","label":null},{"id":"agent:Writer->end:done:next","source":"agent:Writer","target":"end:done","kind":"next","label":null}]}},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":0} +{"schema_version":"1.1","event_type":"message_added","timestamp":1767225600.1,"session_id":"sess-team-01","agent_name":"","sequence_num":2,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"role":"user","content":"Write a short brief on solar sails."},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":1} +{"schema_version":"1.1","event_type":"llm_request","timestamp":1767225600.2,"session_id":"sess-team-01","agent_name":"Manager","sequence_num":3,"messages_count":3,"tokens_used":210,"tokens_budget":128000,"data":{"model":"gpt-4o-mini"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":1,"node_id":"manager:Manager"} +{"schema_version":"1.1","event_type":"llm_response","timestamp":1767225600.8,"session_id":"sess-team-01","agent_name":"Manager","sequence_num":4,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"response_tokens":48,"duration_ms":600.0,"finish_reason":"stop","model":"gpt-4o-mini","response_content":"Delegate research first, then writing."},"prompt_tokens":210,"completion_tokens":48,"cost_usd":6e-05,"run_id":"team-handoff-01","parent_seq":1,"node_id":"manager:Manager"} +{"schema_version":"1.1","event_type":"agent_handoff","timestamp":1767225600.85,"session_id":"sess-team-01","agent_name":"Manager","sequence_num":5,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"from_agent":"Manager","to_agent":"Researcher","kind":"delegate","reason":"Research has to come before writing.","context_passed":{"task_snapshot":{"task":"research","description":"Find three facts about solar sails","expected_output":"3 bullet facts","status":"not started"}}},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":1,"node_id":"manager:Manager"} +{"schema_version":"1.1","event_type":"task_start","timestamp":1767225600.9,"session_id":"sess-team-01","agent_name":"Researcher","sequence_num":6,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"name":"research","agent":"Researcher","description_preview":"Find three facts about solar sails"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":1,"node_id":"task:research"} +{"schema_version":"1.1","event_type":"agent_start","timestamp":1767225600.95,"session_id":"sess-team-01","agent_name":"Researcher","sequence_num":7,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"role":"Researcher","goal":"Find facts"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":6,"node_id":"agent:Researcher"} +{"schema_version":"1.1","event_type":"message_added","timestamp":1767225601.0,"session_id":"sess-team-01","agent_name":"Researcher","sequence_num":8,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"role":"user","content":"Find three facts about solar sails"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":7} +{"schema_version":"1.1","event_type":"llm_request","timestamp":1767225601.05,"session_id":"sess-team-01","agent_name":"Researcher","sequence_num":9,"messages_count":3,"tokens_used":320,"tokens_budget":128000,"data":{"model":"gpt-4o-mini"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":7,"node_id":"agent:Researcher"} +{"schema_version":"1.1","event_type":"llm_response","timestamp":1767225601.95,"session_id":"sess-team-01","agent_name":"Researcher","sequence_num":10,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"response_tokens":40,"duration_ms":900.0,"finish_reason":"tool_calls","model":"gpt-4o-mini"},"prompt_tokens":320,"completion_tokens":40,"cost_usd":7.2e-05,"run_id":"team-handoff-01","parent_seq":7,"node_id":"agent:Researcher"} +{"schema_version":"1.1","event_type":"tool_call_start","timestamp":1767225602.0,"session_id":"sess-team-01","agent_name":"Researcher","sequence_num":11,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"web_search","tool_args":{"query":"solar sail facts"}},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":7,"node_id":"tool:Researcher/web_search"} +{"schema_version":"1.1","event_type":"tool_call_end","timestamp":1767225603.2,"session_id":"sess-team-01","agent_name":"Researcher","sequence_num":12,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"web_search","result":"IKAROS (2010) was the first interplanetary solar sail.","duration_ms":1200.0,"error":null},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.002,"run_id":"team-handoff-01","parent_seq":11,"node_id":"tool:Researcher/web_search"} +{"schema_version":"1.1","event_type":"tool_call_start","timestamp":1767225603.25,"session_id":"sess-team-01","agent_name":"Researcher","sequence_num":13,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"web_search","tool_args":{"query":"solar sail missions"}},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":7,"node_id":"tool:Researcher/web_search"} +{"schema_version":"1.1","event_type":"tool_call_end","timestamp":1767225604.35,"session_id":"sess-team-01","agent_name":"Researcher","sequence_num":14,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"web_search","result":"LightSail 2 raised its orbit using sunlight in 2019.","duration_ms":1100.0,"error":null},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.002,"run_id":"team-handoff-01","parent_seq":13,"node_id":"tool:Researcher/web_search"} +{"schema_version":"1.1","event_type":"tool_call_start","timestamp":1767225604.4,"session_id":"sess-team-01","agent_name":"Researcher","sequence_num":15,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"web_search","tool_args":{"query":"solar sail physics"}},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":7,"node_id":"tool:Researcher/web_search"} +{"schema_version":"1.1","event_type":"tool_call_end","timestamp":1767225605.3,"session_id":"sess-team-01","agent_name":"Researcher","sequence_num":16,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"web_search","result":"Photon pressure at 1 AU is about 9 micronewtons per square metre.","duration_ms":900.0,"error":null},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.002,"run_id":"team-handoff-01","parent_seq":15,"node_id":"tool:Researcher/web_search"} +{"schema_version":"1.1","event_type":"llm_request","timestamp":1767225605.35,"session_id":"sess-team-01","agent_name":"Researcher","sequence_num":17,"messages_count":3,"tokens_used":880,"tokens_budget":128000,"data":{"model":"gpt-4o-mini"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":7,"node_id":"agent:Researcher"} +{"schema_version":"1.1","event_type":"llm_response","timestamp":1767225606.75,"session_id":"sess-team-01","agent_name":"Researcher","sequence_num":18,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"response_tokens":160,"duration_ms":1400.0,"finish_reason":"stop","model":"gpt-4o-mini","response_content":"1. IKAROS ... 2. LightSail 2 ... 3. ~9 uN/m^2"},"prompt_tokens":880,"completion_tokens":160,"cost_usd":0.000228,"run_id":"team-handoff-01","parent_seq":7,"node_id":"agent:Researcher"} +{"schema_version":"1.1","event_type":"agent_end","timestamp":1767225606.8,"session_id":"sess-team-01","agent_name":"Researcher","sequence_num":19,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":7,"node_id":"agent:Researcher"} +{"schema_version":"1.1","event_type":"task_end","timestamp":1767225606.85,"session_id":"sess-team-01","agent_name":"Researcher","sequence_num":20,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed","retries":0,"duration_ms":5950.0,"output_preview":"1. IKAROS ... 2. LightSail 2 ... 3. ~9 uN/m^2"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":6,"node_id":"task:research"} +{"schema_version":"1.1","event_type":"agent_handoff","timestamp":1767225606.9,"session_id":"sess-team-01","agent_name":"Researcher","sequence_num":21,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"from_agent":"Researcher","to_agent":"Writer","kind":"handoff","reason":"Facts gathered; needs drafting.","context_passed":{"facts":["IKAROS 2010","LightSail 2 2019","~9 uN/m^2 at 1 AU"]}},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":1,"node_id":"agent:Researcher"} +{"schema_version":"1.1","event_type":"task_start","timestamp":1767225606.95,"session_id":"sess-team-01","agent_name":"Writer","sequence_num":22,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"name":"write","agent":"Writer","description_preview":"Draft a 100-word brief into draft.md"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":1,"node_id":"task:write"} +{"schema_version":"1.1","event_type":"agent_start","timestamp":1767225607.0,"session_id":"sess-team-01","agent_name":"Writer","sequence_num":23,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"role":"Writer"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":22,"node_id":"agent:Writer"} +{"schema_version":"1.1","event_type":"llm_request","timestamp":1767225607.05,"session_id":"sess-team-01","agent_name":"Writer","sequence_num":24,"messages_count":3,"tokens_used":640,"tokens_budget":128000,"data":{"model":"gpt-4o-mini"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":23,"node_id":"agent:Writer"} +{"schema_version":"1.1","event_type":"llm_response","timestamp":1767225608.65,"session_id":"sess-team-01","agent_name":"Writer","sequence_num":25,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"response_tokens":220,"duration_ms":1600.0,"finish_reason":"tool_calls","model":"gpt-4o-mini"},"prompt_tokens":640,"completion_tokens":220,"cost_usd":0.000228,"run_id":"team-handoff-01","parent_seq":23,"node_id":"agent:Writer"} +{"schema_version":"1.1","event_type":"approval_requested","timestamp":1767225608.7,"session_id":"sess-team-01","agent_name":"Writer","sequence_num":26,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"approval_id":"appr-0001","tool":"write_file","args":{"path":"draft.md","content":"[REDACTED]"},"risk":"high","kind":"tool_call"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":23,"node_id":"tool:Writer/write_file"} +{"schema_version":"1.1","event_type":"approval_decided","timestamp":1767225611.2,"session_id":"sess-team-01","agent_name":"","sequence_num":27,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"approval_id":"appr-0001","approved":true,"reason":"Use the docs folder.","approver":"user","modified":true},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":26,"node_id":"tool:Writer/write_file"} +{"schema_version":"1.1","event_type":"tool_call_start","timestamp":1767225611.25,"session_id":"sess-team-01","agent_name":"Writer","sequence_num":28,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"write_file","tool_args":{"path":"docs/draft.md","content":"[REDACTED]"}},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":23,"node_id":"tool:Writer/write_file"} +{"schema_version":"1.1","event_type":"tool_call_end","timestamp":1767225611.3,"session_id":"sess-team-01","agent_name":"Writer","sequence_num":29,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"write_file","result":"Wrote 642 bytes to docs/draft.md","duration_ms":50.0,"error":null},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":28,"node_id":"tool:Writer/write_file"} +{"schema_version":"1.1","event_type":"llm_request","timestamp":1767225611.35,"session_id":"sess-team-01","agent_name":"Writer","sequence_num":30,"messages_count":3,"tokens_used":900,"tokens_budget":128000,"data":{"model":"gpt-4o-mini"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":23,"node_id":"agent:Writer"} +{"schema_version":"1.1","event_type":"llm_response","timestamp":1767225612.05,"session_id":"sess-team-01","agent_name":"Writer","sequence_num":31,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"response_tokens":30,"duration_ms":700.0,"finish_reason":"stop","model":"gpt-4o-mini","response_content":"Saved the brief to docs/draft.md."},"prompt_tokens":900,"completion_tokens":30,"cost_usd":0.000153,"run_id":"team-handoff-01","parent_seq":23,"node_id":"agent:Writer"} +{"schema_version":"1.1","event_type":"agent_end","timestamp":1767225612.1,"session_id":"sess-team-01","agent_name":"Writer","sequence_num":32,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":23,"node_id":"agent:Writer"} +{"schema_version":"1.1","event_type":"task_end","timestamp":1767225612.15,"session_id":"sess-team-01","agent_name":"Writer","sequence_num":33,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed","retries":0,"duration_ms":5200.0,"output_preview":"Saved the brief to docs/draft.md."},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":22,"node_id":"task:write"} +{"schema_version":"1.1","event_type":"workflow_end","timestamp":1767225612.2,"session_id":"sess-team-01","agent_name":"","sequence_num":34,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed","duration_ms":12150.0},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":1} +{"schema_version":"1.1","event_type":"session_end","timestamp":1767225612.25,"session_id":"sess-team-01","agent_name":"","sequence_num":35,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":0} diff --git a/tests/js/replay-core.test.mjs b/tests/js/replay-core.test.mjs new file mode 100644 index 00000000..65b217bc --- /dev/null +++ b/tests/js/replay-core.test.mjs @@ -0,0 +1,345 @@ +// node --test tests/js/replay-core.test.mjs +// Pure-function tests for the PraisonAI Replay model (no DOM, no network). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(here, '..', '..'); +const coreUrl = pathToFileURL(path.join(root, 'src/praisonaiui/templates/frontend/plugins/views/replay-core.js')); +const core = await import(coreUrl.href); +const { buildModel, narrate, layoutMap, deriveGraph, formatCost, formatDuration } = core; + +function load(name) { + const text = fs.readFileSync(path.join(root, 'tests/fixtures/replay', `${name}.jsonl`), 'utf8'); + const events = text.split('\n').filter(Boolean).map((l) => JSON.parse(l)); + const ws = events.find((e) => e.event_type === 'workflow_start'); + return { events, graph: ws && ws.data && ws.data.graph ? ws.data.graph : null }; +} + +// ── buildModel: lanes ──────────────────────────────────────────────── + +test('team run: You and Manager lanes come first, agents follow graph order', () => { + const { events, graph } = load('team_handoff'); + const m = buildModel(events, graph); + assert.deepEqual(m.lanes.map((l) => l.id), ['human:you', 'manager:Manager', 'agent:Researcher', 'agent:Writer']); + assert.deepEqual(m.lanes.map((l) => l.shape), ['diamond', 'square', 'circle', 'circle']); + assert.equal(m.lanes[0].label, 'You'); +}); + +test('parallel branches of one agent become sub-lanes labelled by step', () => { + const { events, graph } = load('flow_parallel_loop'); + const m = buildModel(events, graph); + const subs = m.lanes.filter((l) => l.parent === 'agent:Fetcher'); + assert.deepEqual(subs.map((l) => [l.id, l.branch, l.label]), [ + ['agent:Fetcher#gather/0', 'gather/0', 'news'], + ['agent:Fetcher#gather/1', 'gather/1', 'papers'], + ]); + const tools = m.blocks.filter((b) => b.kind === 'tool' && b.agent === 'Fetcher'); + assert.deepEqual(tools.map((b) => b.lane), ['agent:Fetcher#gather/0', 'agent:Fetcher#gather/1']); + // the two branches overlap in time: that is why they need their own rows + assert.ok(tools[1].start < tools[0].end); +}); + +test('a 1.0 legacy file still yields lanes, blocks and a hand-off', () => { + const { events } = load('legacy_v10'); + const m = buildModel(events, null); + assert.deepEqual(m.lanes.map((l) => l.id), ['human:you', 'agent:Assistant', 'agent:Checker']); + assert.deepEqual(m.blocks.map((b) => `${b.kind}:${b.label}`), ['llm:gpt-4o-mini', 'tool:calculator', 'llm:gpt-4o-mini', 'llm:gpt-4o-mini']); + assert.equal(m.arrows.length, 1); + assert.equal(m.arrows[0].kind, 'handoff'); + assert.equal(m.chapters.length, 0); +}); + +// ── buildModel: arrows ─────────────────────────────────────────────── + +test('delegate and hand-off arrows connect the right lanes', () => { + const { events, graph } = load('team_handoff'); + const m = buildModel(events, graph); + const flows = m.arrows.map((a) => `${a.kind}:${a.from}->${a.to}`); + assert.deepEqual(flows, [ + 'delegate:manager:Manager->agent:Researcher', + 'handoff:agent:Researcher->agent:Writer', + 'ask:agent:Writer->human:you', + 'answer:human:you->agent:Writer', + ]); + assert.equal(m.arrows[0].label, 'delegates'); + assert.equal(m.arrows[3].label, 'approved'); +}); + +test('route arrows from a flow', () => { + const { events, graph } = load('flow_parallel_loop'); + const m = buildModel(events, graph); + assert.deepEqual(m.arrows.map((a) => [a.kind, a.from, a.to]), [['route', 'agent:Editor', 'agent:Publisher']]); +}); + +// ── buildModel: xN collapse ────────────────────────────────────────── + +test('three consecutive web_search calls collapse to one block x3', () => { + const { events, graph } = load('team_handoff'); + const m = buildModel(events, graph); + const ws = m.blocks.filter((b) => b.label === 'web_search'); + assert.equal(ws.length, 1); + assert.equal(ws[0].count, 3); + assert.equal(ws[0].members.length, 3); + assert.ok(Math.abs(ws[0].cost - 0.006) < 1e-9); + assert.ok(Math.abs(ws[0].durationMs - 3200) < 1e-6); + // every member's events map to the collapsed block + const lines = m.narration.filter((n) => n.text.includes('web_search')); + assert.equal(lines.length, 3); + assert.ok(lines.every((n) => n.blockId === ws[0].id)); +}); + +test('loop passes collapse: polish chapter x3 and Editor LLM block x3', () => { + const { events, graph } = load('flow_parallel_loop'); + const m = buildModel(events, graph); + const polish = m.chapters.filter((c) => c.nodeId === 'step:polish'); + assert.equal(polish.length, 1); + assert.equal(polish[0].count, 3); + assert.equal(polish[0].depth, 1); + const editor = m.blocks.filter((b) => b.agent === 'Editor'); + assert.equal(editor.length, 1); + assert.equal(editor[0].count, 3); + assert.equal(editor[0].tokens, 490 + 540 + 590); +}); + +test('non-adjacent repeats are not collapsed', () => { + const { events, graph } = load('team_handoff'); + const m = buildModel(events, graph); + const researcherLlm = m.blocks.filter((b) => b.agent === 'Researcher' && b.kind === 'llm'); + assert.equal(researcherLlm.length, 2); // llm, tool x3, llm + assert.ok(researcherLlm.every((b) => b.count === 1)); +}); + +// ── buildModel: chapters, approvals, totals, live ──────────────────── + +test('chapters carry depth, rows and the chosen route', () => { + const { events, graph } = load('flow_parallel_loop'); + const m = buildModel(events, graph); + const byNode = Object.fromEntries(m.chapters.map((c) => [c.nodeId, c])); + assert.equal(byNode['step:gather'].depth, 0); + assert.equal(byNode['step:news'].depth, 1); + assert.notEqual(byNode['step:news'].row, byNode['step:papers'].row); // overlapping -> own rows + assert.equal(byNode['step:decide'].route, 'publish'); + assert.equal(m.chapterRows, 3); +}); + +test('approval sits in the You lane with its decision', () => { + const { events, graph } = load('team_handoff'); + const m = buildModel(events, graph); + const ap = m.blocks.find((b) => b.kind === 'approval'); + assert.equal(ap.lane, 'human:you'); + assert.equal(ap.status, 'approved'); + assert.equal(ap.approvalId, 'appr-0001'); + assert.equal(m.pending.length, 0); +}); + +test('a run cut before the decision has one pending approval and open blocks', () => { + const { events, graph } = load('team_handoff'); + const cut = events.slice(0, events.findIndex((e) => e.event_type === 'approval_decided')); + const m = buildModel(cut, graph); + assert.equal(m.pending.length, 1); + assert.equal(m.pending[0].approvalId, 'appr-0001'); + assert.equal(m.pending[0].open, true); + const half = events.slice(0, events.findIndex((e) => e.event_type === 'tool_call_end')); + const m2 = buildModel(half, graph); + const running = m2.blocks.find((b) => b.kind === 'tool'); + assert.equal(running.open, true); + assert.equal(running.status, 'running'); + assert.match(m2.narration.at(-1).text, /^Researcher is asking web_search for 'solar sail facts'…$/); +}); + +test('totals per agent and for the run', () => { + const { events, graph } = load('team_handoff'); + const m = buildModel(events, graph); + assert.equal(m.totals.llm_calls, 5); + assert.equal(m.totals.tool_calls, 4); + assert.ok(Math.abs(m.totals.cost_usd - 0.006741) < 1e-9); + const r = m.totals.agents['agent:Researcher']; + assert.ok(Math.abs(r.cost_usd - (0.000072 + 0.006 + 0.000228)) < 1e-9); + assert.equal(r.tool_calls, 3); + assert.ok(Math.abs(m.totals.duration_ms - 12250) < 1e-6); +}); + +test('sub-lane work rolls up to the parent agent total', () => { + const { events, graph } = load('flow_parallel_loop'); + const m = buildModel(events, graph); + const f = m.totals.agents['agent:Fetcher']; + assert.equal(f.tool_calls, 2); + assert.ok(Math.abs(f.cost_usd - 0.001) < 1e-9); + assert.equal(m.totals.agents['agent:Fetcher#gather/0'], undefined); +}); + +test('unknown event types and fields are tolerated and skipped', () => { + const { events, graph } = load('flow_parallel_loop'); + assert.ok(events.some((e) => e.event_type === 'context_rebalance')); + const m = buildModel(events, graph); + assert.ok(!m.narration.some((n) => n.type === 'context_rebalance')); + assert.equal(m.events.length, events.length); +}); + +test('duplicates (same session:seq, e.g. after an SSE reconnect) are dropped; order follows sequence_num', () => { + const { events, graph } = load('legacy_v10'); + const shuffled = [...events].reverse().concat(events.slice(0, 3)); + const m = buildModel(shuffled, graph); + assert.equal(m.events.length, events.length); + assert.deepEqual(m.events.map((e) => e.sequence_num), events.map((e) => e.sequence_num)); +}); + +test('empty input is fine', () => { + const m = buildModel([], null); + assert.equal(m.blocks.length, 0); + assert.equal(m.narration.length, 0); + assert.deepEqual(m.lanes.map((l) => l.id), ['human:you']); +}); + +// ── narrate ────────────────────────────────────────────────────────── + +const ev = (event_type, data = {}, extra = {}) => ({ event_type, data, agent_name: 'Researcher', timestamp: 100, sequence_num: 1, session_id: 's', ...extra }); + +test('narrate: tool call with its end gives duration and cost', () => { + const start = ev('tool_call_start', { tool_name: 'web_search', tool_args: { query: 'X' } }); + const end = ev('tool_call_end', { tool_name: 'web_search', duration_ms: 1200 }, { cost_usd: 0.002, timestamp: 101.2 }); + assert.equal(narrate(start, end), "Researcher asked web_search for 'X' — 1.2 s, $0.002."); + assert.equal(narrate(start), "Researcher is asking web_search for 'X'…"); + const failed = ev('tool_call_end', { tool_name: 'web_search', duration_ms: 300, error: 'HTTP 500' }); + assert.equal(narrate(start, failed), "Researcher asked web_search for 'X' — failed after 300 ms: HTTP 500."); +}); + +test('narrate: hand-off kinds', () => { + assert.equal( + narrate(ev('agent_handoff', { from_agent: 'Researcher', to_agent: 'Writer', reason: 'Facts gathered.' })), + "Researcher handed off to Writer: 'Facts gathered'.", + ); + assert.equal(narrate(ev('agent_handoff', { from_agent: 'Manager', to_agent: 'Writer', kind: 'delegate' }, { agent_name: 'Manager' })), 'Manager delegated to Writer.'); + assert.equal(narrate(ev('agent_handoff', { from_agent: 'A', to_agent: 'B', kind: 'route' })), 'A routed to B.'); + assert.equal(narrate(ev('agent_handoff', { from_agent: 'A', to_agent: 'B', kind: 'spawn' })), 'A spawned B.'); + assert.equal(narrate(ev('agent_handoff', { from_agent: 'A', to_agent: 'B', kind: 'weird' })), 'A handed off to B.'); +}); + +test('narrate: LLM response with model, tokens, cost', () => { + const r = ev('llm_response', { duration_ms: 900, model: 'gpt-4o-mini' }, { prompt_tokens: 120, completion_tokens: 40, cost_usd: 0.0004 }); + assert.equal(narrate(r), 'Researcher thought with gpt-4o-mini — 900 ms, 160 tokens, $0.0004.'); +}); + +test('narrate: approvals, human and automatic', () => { + const req = ev('approval_requested', { approval_id: 'a1', tool: 'write_file', risk: 'high', kind: 'tool_call' }, { agent_name: 'Writer' }); + assert.equal(narrate(req), 'Writer asked you to approve write_file (high risk).'); + assert.equal(narrate(ev('approval_decided', { approved: true, approver: 'user', modified: true }), req), 'You approved write_file with edited arguments.'); + assert.equal(narrate(ev('approval_decided', { approved: true, approver: 'env' }), req), 'write_file was approved automatically (env).'); + assert.equal(narrate(ev('approval_decided', { approved: false, approver: 'user', reason: 'Not now' }), req), "You denied write_file: 'Not now'."); + assert.equal(narrate(ev('approval_decided', { approved: false, approver: 'timeout' }), req), 'write_file was denied: no answer before the timeout.'); +}); + +test('narrate: tasks, steps, routes, workflow', () => { + assert.equal(narrate(ev('task_start', { name: 'research', agent: 'Researcher' })), "Task 'research' went to Researcher."); + assert.equal(narrate(ev('task_end', { status: 'failed', duration_ms: 2500, retries: 2 }, { node_id: 'task:research' })), "Task 'research' failed after 2.5 s, 2 retries."); + assert.equal(narrate(ev('step_end', { route: 'publish' }), ev('step_start', { name: 'decide' })), "Step 'decide' chose route 'publish'."); + assert.equal(narrate(ev('step_end', { status: 'cached' }, { node_id: 'step:fetch' })), "Step 'fetch' reused a cached result."); + assert.equal(narrate(ev('step_start', { name: 'gather', pattern: 'parallel' })), "Step 'gather' fanned out in parallel."); + assert.equal(narrate(ev('workflow_start', { kind: 'team', name: 'Brief', process: 'hierarchical' })), "Team 'Brief' started (hierarchical)."); + assert.equal(narrate(ev('workflow_end', { status: 'completed', duration_ms: 125000 })), 'The run completed after 2 min 05 s.'); +}); + +test('narrate: unknown and quiet events give empty strings', () => { + assert.equal(narrate(ev('context_snapshot')), ''); + assert.equal(narrate(ev('something_new')), ''); + assert.equal(narrate(null), ''); + assert.equal(narrate(ev('message_added', { role: 'assistant', content: 'hi' })), ''); + assert.equal(narrate(ev('message_added', { role: 'user', content: 'Hello!' }, { agent_name: '' })), "You said: 'Hello'."); +}); + +test('narrate is deterministic', () => { + const { events, graph } = load('team_handoff'); + const a = buildModel(events, graph).narration.map((n) => n.text); + const b = buildModel(events, graph).narration.map((n) => n.text); + assert.deepEqual(a, b); + assert.ok(a.includes("Researcher asked web_search for 'solar sail facts' — 1.2 s, $0.002.")); +}); + +test('formatters', () => { + assert.equal(formatDuration(850), '850 ms'); + assert.equal(formatDuration(1200), '1.2 s'); + assert.equal(formatCost(0.002), '$0.002'); + assert.equal(formatCost(0.00001), '<$0.0001'); + assert.equal(formatCost(1.5), '$1.50'); + assert.equal(formatCost(0), ''); +}); + +// ── layoutMap / deriveGraph ────────────────────────────────────────── + +test('layoutMap: layers flow left to right and every node gets a box', () => { + const { graph } = load('team_handoff'); + const lay = layoutMap(graph); + const byId = Object.fromEntries(lay.nodes.map((n) => [n.id, n])); + assert.equal(lay.nodes.length, graph.nodes.length); + assert.ok(byId['start:begin'].layer < byId['manager:Manager'].layer); + assert.ok(byId['manager:Manager'].layer < byId['task:research'].layer); + // depends_on (write depends on research) puts research first + assert.ok(byId['task:research'].layer < byId['task:write'].layer); + assert.ok(byId['agent:Researcher'].layer < byId['agent:Writer'].layer); + for (const n of lay.nodes) { + assert.ok(n.x >= 0 && n.y >= 0 && n.x + n.w <= lay.width && n.y + n.h <= lay.height, n.id); + } + assert.deepEqual([...new Set(lay.nodes.map((n) => n.shape))].sort(), ['chevron', 'circle', 'diamond', 'flag', 'note', 'rounded', 'square']); +}); + +test('layoutMap: no two nodes overlap', () => { + for (const name of ['team_handoff', 'flow_parallel_loop']) { + const lay = layoutMap(load(name).graph); + for (let i = 0; i < lay.nodes.length; i++) { + for (let j = i + 1; j < lay.nodes.length; j++) { + const a = lay.nodes[i]; + const b = lay.nodes[j]; + const apart = a.x + a.w <= b.x || b.x + b.w <= a.x || a.y + a.h <= b.y || b.y + b.h <= a.y; + assert.ok(apart, `${a.id} overlaps ${b.id}`); + } + } + } +}); + +test('layoutMap: a loop edge is a back edge, routed underneath, labels kept', () => { + const lay = layoutMap(load('flow_parallel_loop').graph); + const back = lay.edges.filter((e) => e.back); + assert.deepEqual(back.map((e) => e.id), ['step:polish->step:review_loop:loop']); + assert.equal(back[0].points.length, 4); + const routes = lay.edges.filter((e) => e.kind === 'routes').map((e) => e.label).sort(); + assert.deepEqual(routes, ['archive', 'publish']); + assert.equal(core.edgeText(lay.edges.find((e) => e.kind === 'routes' && e.label === 'publish')), 'routes: publish'); +}); + +test('layoutMap: deterministic and tolerant of bad input', () => { + const g = load('flow_parallel_loop').graph; + assert.deepEqual(layoutMap(g), layoutMap(g)); + const empty = layoutMap(null); + assert.equal(empty.nodes.length, 0); + const dangling = layoutMap({ nodes: [{ id: 'agent:A', kind: 'agent' }], edges: [{ source: 'agent:A', target: 'nope', kind: 'next' }] }); + assert.equal(dangling.edges.length, 0); +}); + +test('deriveGraph: who talked to whom for a 1.0 file', () => { + const g = deriveGraph(load('legacy_v10').events); + assert.equal(g.derived, true); + assert.deepEqual(g.nodes.map((n) => n.id).sort(), ['agent:Assistant', 'agent:Checker', 'tool:Assistant/calculator']); + assert.deepEqual(g.edges.map((e) => e.id).sort(), [ + 'agent:Assistant->agent:Checker:handoff', + 'agent:Assistant->tool:Assistant/calculator:uses', + ]); + const lay = layoutMap(g); + assert.equal(lay.nodes.length, 3); +}); + +test('deriveGraph counts repeats and includes you for approvals', () => { + const { events } = load('team_handoff'); + const g = deriveGraph(events); + const ws = g.edges.find((e) => e.id === 'agent:Researcher->tool:Researcher/web_search:uses'); + assert.equal(ws.label, '×3'); + assert.ok(g.nodes.some((n) => n.id === 'human:you' && n.kind === 'human')); + assert.ok(g.edges.some((e) => e.kind === 'delegates' && e.source === 'manager:Manager')); +}); + +test('module also registers itself on globalThis for the standalone build', () => { + assert.equal(typeof globalThis.PraisonReplayCore.buildModel, 'function'); +}); diff --git a/tests/unit/test_replay_feature.py b/tests/unit/test_replay_feature.py new file mode 100644 index 00000000..45abf702 --- /dev/null +++ b/tests/unit/test_replay_feature.py @@ -0,0 +1,439 @@ +"""PraisonAI Replay feature: runs listing, events, SSE tail, safety (contract v1 §1, §5).""" + +from __future__ import annotations + +import json +import shutil +import threading +import time +from pathlib import Path + +import pytest +from starlette.applications import Starlette +from starlette.testclient import TestClient + +FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "replay" +NAMES = ("team_handoff", "flow_parallel_loop", "legacy_v10") + + +def _lines(name: str) -> list[str]: + return (FIXTURES / f"{name}.jsonl").read_text(encoding="utf-8").splitlines(keepends=True) + + +@pytest.fixture() +def traces(tmp_path, monkeypatch): + d = tmp_path / "traces" + d.mkdir() + monkeypatch.setenv("PRAISONAI_TRACES_DIR", str(d)) + monkeypatch.delenv("PRAISON_HOME", raising=False) + return d + + +@pytest.fixture() +def isolated_server(tmp_path, monkeypatch): + """create_app() writes a config store; keep it out of the developer's home.""" + monkeypatch.setenv("AIUI_DATA_DIR", str(tmp_path / "aiui-data")) + monkeypatch.setenv("PRAISONAI_HOME", str(tmp_path / "praisonai-home")) + + +@pytest.fixture() +def feature(): + from praisonaiui.features.replay import ReplayFeature + + f = ReplayFeature() + f.poll_interval = 0.02 + f.ping_interval = 0.15 + f.quiet_period = 0.3 + return f + + +@pytest.fixture() +def client(feature): + app = Starlette(routes=feature.routes()) + return TestClient(app) + + +def _copy_all(d: Path) -> None: + for name in NAMES: + dst = d / f"{name}.jsonl" + shutil.copy(FIXTURES / f"{name}.jsonl", dst) + + +# ── traces dir resolution ──────────────────────────────────────────── + + +def test_traces_dir_precedence(tmp_path, monkeypatch): + from praisonaiui.features import _trace_jsonl as tj + + monkeypatch.delenv("PRAISONAI_TRACES_DIR", raising=False) + monkeypatch.delenv("PRAISON_HOME", raising=False) + monkeypatch.setenv("HOME", str(tmp_path / "home")) + assert tj.traces_dir() == tmp_path / "home" / ".praison" / "traces" + monkeypatch.setenv("PRAISON_HOME", str(tmp_path / "ph")) + assert tj.traces_dir() == tmp_path / "ph" / "traces" + monkeypatch.setenv("PRAISONAI_TRACES_DIR", str(tmp_path / "explicit")) + assert tj.traces_dir() == tmp_path / "explicit" + + +# ── /api/replay/runs ───────────────────────────────────────────────── + + +def test_runs_lists_fixtures_newest_first(traces, client): + _copy_all(traces) + r = client.get("/api/replay/runs") + assert r.status_code == 200 + rows = r.json() + assert isinstance(rows, list) + by_id = {row["run_id"]: row for row in rows} + assert set(by_id) == set(NAMES) + # newest first by started_at + started = [row["started_at"] for row in rows] + assert started == sorted(started, reverse=True) + + team = by_id["team_handoff"] + assert team["session_id"] == "sess-team-01" + assert team["kind"] == "team" + assert team["name"] == "Solar sail brief" + assert team["status"] == "completed" + assert team["live"] is False + assert team["events"] == 36 + assert team["agents"] == ["Manager", "Researcher", "Writer"] + assert team["cost_usd"] == pytest.approx(0.006741, abs=1e-9) + assert team["ended_at"] == pytest.approx(1767225612.25) + assert team["path"].endswith("team_handoff.jsonl") + assert team["event_run_id"] == "team-handoff-01" + + legacy = by_id["legacy_v10"] # schema 1.0: no workflow_start, still listed + assert legacy["kind"] == "session" + assert legacy["status"] == "completed" + assert legacy["schema_version"] == "1.0" + assert legacy["agents"] == ["Assistant", "Checker"] + assert legacy["events"] == 16 + + +def test_runs_empty_or_missing_dir(tmp_path, monkeypatch, client): + monkeypatch.setenv("PRAISONAI_TRACES_DIR", str(tmp_path / "nope")) + r = client.get("/api/replay/runs") + assert r.status_code == 200 + assert r.json() == [] + + +def test_run_without_end_marker_is_live(traces, client): + lines = _lines("team_handoff") + (traces / "half.jsonl").write_text("".join(lines[:20]), encoding="utf-8") + row = client.get("/api/replay/runs").json()[0] + assert row["run_id"] == "half" + assert row["live"] is True + assert row["status"] == "running" + assert "ended_at" not in row + + +def test_ignores_non_jsonl_and_unsafe_names(traces, client): + _copy_all(traces) + (traces / "notes.txt").write_text("x", encoding="utf-8") + (traces / "bad name.jsonl").write_text(_lines("legacy_v10")[0], encoding="utf-8") + ids = {row["run_id"] for row in client.get("/api/replay/runs").json()} + assert ids == set(NAMES) + + +# ── /api/replay/runs/{id}/events ───────────────────────────────────── + + +def test_events_and_graph(traces, client): + _copy_all(traces) + data = client.get("/api/replay/runs/team_handoff/events").json() + assert len(data["events"]) == 36 + assert data["graph"]["schema"] == "praisonai.graph/1" + assert data["graph"]["process"] == "hierarchical" + assert data["run"]["run_id"] == "team_handoff" + legacy = client.get("/api/replay/runs/legacy_v10/events").json() + assert legacy["graph"] is None + assert len(legacy["events"]) == 16 + + +def test_events_after_seq(traces, client): + _copy_all(traces) + data = client.get("/api/replay/runs/team_handoff/events?after_seq=30").json() + assert [e["sequence_num"] for e in data["events"]] == [31, 32, 33, 34, 35] + # graph still comes from the (earlier) workflow_start + assert data["graph"] is not None + assert client.get("/api/replay/runs/team_handoff/events?after_seq=-1").json()["events"][0]["sequence_num"] == 0 + assert client.get("/api/replay/runs/team_handoff/events?after_seq=abc").status_code == 400 + + +def test_unknown_event_types_and_fields_pass_through(traces, client): + _copy_all(traces) + events = client.get("/api/replay/runs/flow_parallel_loop/events").json()["events"] + odd = [e for e in events if e["event_type"] == "context_rebalance"] + assert len(odd) == 1 + assert odd[0]["future_field"] == {"x": 1} + + +def test_partial_last_line_ignored_until_complete(traces, client): + lines = _lines("legacy_v10") + path = traces / "growing.jsonl" + last = lines[-1] + path.write_text("".join(lines[:-1]) + last[:25], encoding="utf-8") + first = client.get("/api/replay/runs/growing/events").json()["events"] + assert len(first) == 15 # half-written session_end is not surfaced + assert first[-1]["event_type"] == "agent_end" + with open(path, "a", encoding="utf-8") as fh: + fh.write(last[25:]) + second = client.get("/api/replay/runs/growing/events").json()["events"] + assert len(second) == 16 + assert second[-1]["event_type"] == "session_end" + + +def test_complete_last_line_without_newline_is_read(traces, client): + text = "".join(_lines("legacy_v10")).rstrip("\n") + (traces / "nonl.jsonl").write_text(text, encoding="utf-8") + events = client.get("/api/replay/runs/nonl/events").json()["events"] + assert len(events) == 16 + + +def test_garbage_lines_are_skipped(traces, client): + lines = _lines("legacy_v10") + (traces / "dirty.jsonl").write_text(lines[0] + "not json\n[1,2]\n\n" + "".join(lines[1:]), encoding="utf-8") + assert len(client.get("/api/replay/runs/dirty/events").json()["events"]) == 16 + + +# ── safety ─────────────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + "run_id", + ["..", ".", "a b", "a;b", "%2e%2e", "..%2Fsecret", "x%00y", "ü"], +) +def test_path_traversal_ids_rejected(traces, client, run_id): + secret = traces.parent / "secret.jsonl" + secret.write_text(_lines("legacy_v10")[0], encoding="utf-8") + for suffix in ("events", "stream"): + r = client.get(f"/api/replay/runs/{run_id}/{suffix}") + assert r.status_code in (400, 404), (run_id, suffix, r.status_code) + assert "legacy-sess-10" not in r.text + + +def test_run_path_validation_unit(tmp_path): + from praisonaiui.features import _trace_jsonl as tj + + assert tj.run_path("../etc/passwd", tmp_path) is None + assert tj.run_path("..", tmp_path) is None + assert tj.run_path("a/b", tmp_path) is None + assert tj.run_path("ok-run_1.2", tmp_path) == tmp_path / "ok-run_1.2.jsonl" + + +def test_symlink_escaping_dir_is_rejected(traces, client, tmp_path): + outside = tmp_path / "outside" + outside.mkdir() + target = outside / "x.jsonl" + target.write_text(_lines("legacy_v10")[0], encoding="utf-8") + link = traces / "sneaky.jsonl" + try: + link.symlink_to(target) + except (OSError, NotImplementedError): + pytest.skip("symlinks unavailable") + assert client.get("/api/replay/runs/sneaky/events").status_code == 400 + + +def test_unknown_run_is_404(traces, client): + assert client.get("/api/replay/runs/nope/events").status_code == 404 + assert client.get("/api/replay/runs/nope/stream").status_code == 404 + + +# ── SSE stream ─────────────────────────────────────────────────────── + + +def _parse_sse(text: str) -> list[tuple[str, str, str | None]]: + out = [] + for block in text.split("\n\n"): + if not block.strip(): + continue + name, data, ident = "message", [], None + for line in block.split("\n"): + if line.startswith("event:"): + name = line[6:].strip() + elif line.startswith("data:"): + data.append(line[5:].strip()) + elif line.startswith("id:"): + ident = line[3:].strip() + if data or name != "message": + out.append((name, "\n".join(data), ident)) + return out + + +def test_stream_tails_appended_lines(traces, client, monkeypatch): + """Lines written while the stream is open (including one written in two + halves) arrive exactly once, in order; the stream ends after session_end.""" + from praisonaiui.features import _trace_jsonl as tj + + monkeypatch.setattr(tj, "LIVE_WINDOW_S", 8) # safety net: never hang the suite + lines = _lines("team_handoff") + path = traces / "live-run.jsonl" + path.write_text("".join(lines[:10]), encoding="utf-8") + + def writer(): + time.sleep(0.15) + for i, line in enumerate(lines[10:]): + with open(path, "a", encoding="utf-8") as fh: + if i == 5: # a line split across two writes + fh.write(line[:30]) + fh.flush() + time.sleep(0.08) + fh.write(line[30:]) + else: + fh.write(line) + time.sleep(0.01) + + t = threading.Thread(target=writer, daemon=True) + t.start() + started = time.monotonic() + with client.stream("GET", "/api/replay/runs/live-run/stream") as r: + assert r.status_code == 200 + assert r.headers["content-type"].startswith("text/event-stream") + body = "".join(r.iter_text()) + elapsed = time.monotonic() - started + t.join(timeout=5) + + msgs = _parse_sse(body) + ctx = [json.loads(d) for name, d, _ in msgs if name == "ctx"] + assert [e["sequence_num"] for e in ctx] == list(range(36)) + assert [ident for name, _, ident in msgs if name == "ctx"] == [str(i) for i in range(36)] + assert msgs[-1][0] == "end" + assert any(name == "ping" for name, _, _ in msgs) + assert elapsed < 7, "stream should end shortly after session_end + quiet period" + + +def test_stream_after_seq_and_last_event_id(traces, client): + shutil.copy(FIXTURES / "legacy_v10.jsonl", traces / "done.jsonl") + with client.stream("GET", "/api/replay/runs/done/stream?after_seq=12") as r: + body = "".join(r.iter_text()) + seqs = [json.loads(d)["sequence_num"] for n, d, _ in _parse_sse(body) if n == "ctx"] + assert seqs == [13, 14, 15] + # reconnect: Last-Event-ID beats an older after_seq in the URL + with client.stream("GET", "/api/replay/runs/done/stream?after_seq=2", headers={"Last-Event-ID": "14"}) as r: + body = "".join(r.iter_text()) + seqs = [json.loads(d)["sequence_num"] for n, d, _ in _parse_sse(body) if n == "ctx"] + assert seqs == [15] + + +def test_stream_ends_for_stale_unfinished_file(traces, client, monkeypatch): + from praisonaiui.features import _trace_jsonl as tj + + monkeypatch.setattr(tj, "LIVE_WINDOW_S", 0.4) + (traces / "stale.jsonl").write_text("".join(_lines("team_handoff")[:5]), encoding="utf-8") + with client.stream("GET", "/api/replay/runs/stale/stream") as r: + body = "".join(r.iter_text()) + msgs = _parse_sse(body) + assert len([m for m in msgs if m[0] == "ctx"]) == 5 + assert msgs[-1][0] == "end" and "stale" in msgs[-1][1] + + +def test_stream_restarts_when_file_is_truncated(tmp_path): + from praisonaiui.features import _trace_jsonl as tj + + path = tmp_path / "r.jsonl" + lines = _lines("legacy_v10") + path.write_text("".join(lines), encoding="utf-8") + tail = tj.TailReader(path) + assert len(tail.poll()) == 16 + path.write_text(lines[0], encoding="utf-8") # writer restarted the file + again = tail.poll() + assert len(again) == 1 and again[0]["sequence_num"] == 0 + + +# ── registration / entry point ─────────────────────────────────────── + + +def test_feature_registered_by_default(): + from praisonaiui.features import auto_register_defaults, get_feature + + auto_register_defaults() + f = get_feature("replay") + assert f is not None + paths = {r.path for r in f.routes()} + assert paths == { + "/api/replay/runs", + "/api/replay/runs/{run_id}/events", + "/api/replay/runs/{run_id}/stream", + } + + +def test_replay_page_and_routes_mounted_in_server(traces, isolated_server): + import praisonaiui as aiui + import praisonaiui.server as server + + _copy_all(traces) + server.reset_state() + aiui.set_style("dashboard") + app = server.create_app() + c = TestClient(app) + pages = c.get("/api/pages").json()["pages"] + page = next((p for p in pages if p["id"] == "replay"), None) + assert page is not None + assert page["title"] == "Replay" + assert page["group"] == "Control" + assert page["api_endpoint"] == "/api/replay/runs" + assert len(c.get("/api/replay/runs").json()) == 3 + assert c.get("/plugins/views/replay.js").status_code == 200 + assert c.get("/plugins/views/replay-core.js").status_code == 200 + + +def test_replay_app_whitelists_replay_first(traces, isolated_server): + import praisonaiui.server as server + from praisonaiui.features.replay import REPLAY_PAGES, configure_replay_app + + server.reset_state() + configure_replay_app() + try: + app = server.create_app() + pages = TestClient(app).get("/api/pages").json()["pages"] + builtin = [p["id"] for p in pages if p["id"] in REPLAY_PAGES] + assert builtin[0] == "replay" + assert set(builtin) == set(REPLAY_PAGES) + assert "chat" not in {p["id"] for p in pages} + finally: + server.reset_state() + + +def test_replay_app_module_is_importable_and_documented(): + import importlib + + src = (Path(__file__).resolve().parents[2] / "src" / "praisonaiui" / "replay_app.py").read_text(encoding="utf-8") + assert "aiui run" in src and "/replay?run=" in src + import praisonaiui.server as server + + try: + importlib.import_module("praisonaiui.replay_app") + assert server.get_style() == "dashboard" + finally: + server.reset_state() + + +def test_cli_list(traces, feature): + _copy_all(traces) + out = feature._cli_list() + assert "team_handoff" in out and "Solar sail brief" in out + + +# ── standalone export helper ───────────────────────────────────────── + + +def test_standalone_html_is_self_contained(): + from praisonaiui.features.replay import standalone_html + + events = [json.loads(line) for line in _lines("team_handoff")] + events[3]["data"]["evil"] = "" + html = standalone_html(events, title="Run <1>") + assert html.count("", 1)[0] + data = json.loads(payload) + assert data["graph"]["schema"] == "praisonai.graph/1" + assert len(data["events"]) == 36 diff --git a/tests/unit/test_replay_view.py b/tests/unit/test_replay_view.py new file mode 100644 index 00000000..0352adc8 --- /dev/null +++ b/tests/unit/test_replay_view.py @@ -0,0 +1,123 @@ +"""PraisonAI Replay dashboard view: registration and wiring (source-grep, like test_run_recorder).""" + +from __future__ import annotations + +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +FRONTEND = ROOT / "src" / "praisonaiui" / "templates" / "frontend" / "plugins" +VIEWS = FRONTEND / "views" +CORE = VIEWS / "replay-core.js" +VIEW = VIEWS / "replay.js" + + +def test_view_files_exist(): + assert CORE.exists() + assert VIEW.exists() + + +def test_replay_registered_in_builtin_views(): + source = (FRONTEND / "dashboard.js").read_text(encoding="utf-8") + block = source.split("const BUILTIN_VIEWS = {", 1)[1].split("};", 1)[0] + assert re.search(r"^\s*replay:\s*'/plugins/views/replay\.js',", block, re.M) + + +def test_dashboard_accepts_hash_deep_links(): + source = (FRONTEND / "dashboard.js").read_text(encoding="utf-8") + assert "location.hash.match(" in source + assert "history.replaceState(null, '', `/${hashRoute[1]}" in source + + +def test_core_exports_pure_api(): + source = CORE.read_text(encoding="utf-8") + for fn in ( + "export function buildModel", + "export function narrate", + "export function layoutMap", + "export function deriveGraph", + ): + assert fn in source, fn + assert "globalThis.PraisonReplayCore = api" in source + # pure: no DOM and no network + for banned in ("document.", "window.", "fetch(", "EventSource", "localStorage"): + assert banned not in source, banned + + +def test_view_exports_render_and_cleanup(): + source = VIEW.read_text(encoding="utf-8") + assert "export async function render" in source + assert "export function cleanup" in source + assert "abort" in source.lower() + assert "es.close()" in source + + +def test_view_uses_contract_endpoints(): + source = VIEW.read_text(encoding="utf-8") + for url in ( + "/api/replay/runs", + "/api/replay/runs/${encodeURIComponent(id)}/events", + "/api/replay/runs/${encodeURIComponent(id)}/stream", + "/api/approvals/${encodeURIComponent(b.approvalId)}/${action}", + "modified_args", + ): + assert url in source, url + + +def test_no_cdn_or_third_party_assets(): + for path in (CORE, VIEW): + text = path.read_text(encoding="utf-8") + stripped = text.replace("http://www.w3.org/2000/svg", "") + assert "http://" not in stripped and "https://" not in stripped, path.name + assert not re.search(r"cdn[\w-]*\.[a-z]{2,}", stripped.lower()), path.name + assert "mermaid" not in stripped.lower(), path.name + assert not re.search(r"^\s*import .* from ['\"](?!\./|/plugins/)", text, re.M), path.name + + +def test_palette_and_shapes_follow_contract(): + core = CORE.read_text(encoding="utf-8") + for colour in ("#8B0000", "#0F7287", "#8A5A00", "#3C4A5C"): + assert colour in core + assert "#189AB4" not in core + VIEW.read_text(encoding="utf-8") + for pair in ("agent: 'circle'", "tool: 'rounded'", "human: 'diamond'", "manager: 'square'"): + assert pair in core + + +def test_accessibility_hooks_present(): + view = VIEW.read_text(encoding="utf-8") + assert "prefers-reduced-motion" in view + assert ":focus-visible" in view + assert "aria-live" in view + assert "role: 'tablist'" in view and "role: 'tab'" in view and "role: 'tabpanel'" in view + assert "aria-current" in view + assert "'ArrowLeft'" in view and "'ArrowRight'" in view + assert "praisonai replay view agents.yaml" in view + assert "with record(" in view + assert "Studio" not in view and "__start__" not in view + + +def test_standalone_boot_contract(): + view = VIEW.read_text(encoding="utf-8") + assert "globalThis.__REPLAY_DATA__" in view + assert "replay-root" in view + assert "globalThis.PraisonReplayCore" in view + + +def test_replay_core_node_suite(): + """Runs tests/js/replay-core.test.mjs (buildModel, narrate, layoutMap) when Node is present.""" + node = shutil.which("node") + if not node: + pytest.skip("node not installed") + proc = subprocess.run( + [node, "--test", str(ROOT / "tests" / "js" / "replay-core.test.mjs")], + cwd=ROOT, + capture_output=True, + text=True, + timeout=120, + ) + assert proc.returncode == 0, proc.stdout[-4000:] + proc.stderr[-2000:] + assert re.search(r"# fail 0", proc.stdout) From 3da3d3d54774b81380bf8270aecb67cdaf17da1f Mon Sep 17 00:00:00 2001 From: MervinPraison <454862+MervinPraison@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:36:14 +0000 Subject: [PATCH 2/8] fix(tracing): read recorded runs from the trace JSONL directory SDKTracingManager looked up praisonaiagents.trace.list_traces, list_spans and get_trace, which do not exist, so /api/traces never showed a recorded run and silently fell back to an empty in-memory store. JsonlTracingManager reads the same directory as Replay: one file is one trace, spans come from start/end pairs (parent_seq on schema 1.1, sequence order on 1.0), and records posted to /api/traces/record still work. health() reports source: jsonl|memory. SDKTracingManager stays as a deprecated alias. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/praisonaiui/features/tracing.py | 199 +++++++++++++++------------- tests/unit/test_tracing_jsonl.py | 153 +++++++++++++++++++++ 2 files changed, 262 insertions(+), 90 deletions(-) create mode 100644 tests/unit/test_tracing_jsonl.py diff --git a/src/praisonaiui/features/tracing.py b/src/praisonaiui/features/tracing.py index a43e74e5..4793cad9 100644 --- a/src/praisonaiui/features/tracing.py +++ b/src/praisonaiui/features/tracing.py @@ -2,8 +2,8 @@ Architecture: TracingProtocol (ABC) <- any backend implements this - ├── SimpleTracingManager <- default in-memory (no deps) - └── SDKTracingManager <- wraps praisonaiagents.obs + trace + ├── SimpleTracingManager <- in-memory (no deps) + └── JsonlTracingManager <- recorded runs (context-trace JSONL) + in-memory PraisonAITracing (BaseFeatureProtocol) └── delegates to active TracingProtocol implementation @@ -110,56 +110,112 @@ def health(self) -> Dict[str, Any]: } -# ── SDK Tracing Manager ────────────────────────────────────────────── +# ── JSONL Tracing Manager ──────────────────────────────────────────── -class SDKTracingManager(TracingProtocol): - """Wraps praisonaiagents.obs + trace for production use.""" +class JsonlTracingManager(TracingProtocol): + """Reads recorded runs from the context-trace JSONL directory. + + One ``.jsonl`` file is one trace; spans come from start/end event + pairs (``parent_seq`` on schema 1.1, sequence order on 1.0). The + directory is ``$PRAISONAI_TRACES_DIR`` > ``$PRAISON_HOME/traces`` > + ``~/.praison/traces`` (shared with the ``replay`` feature). Traces and + spans posted to ``/api/traces/record`` stay in memory alongside. + """ + + #: Upper bound on files scanned for an unfiltered span listing. + MAX_FILES_FOR_SPANS = 20 def __init__(self) -> None: - self._trace_available = False - self._obs_available = False - try: - from praisonaiagents.trace import ActionEvent # noqa: F401 - - self._trace_available = True - except (ImportError, AttributeError): - pass - try: - from praisonaiagents.obs import Span, Trace # noqa: F401 - - self._obs_available = True - except ImportError: - pass - if not (self._trace_available or self._obs_available): - raise ImportError("Neither praisonaiagents.trace nor praisonaiagents.obs available") self._simple = SimpleTracingManager() - logger.info( - "SDKTracingManager initialized (trace=%s, obs=%s)", - self._trace_available, - self._obs_available, + + # -- JSONL side --------------------------------------------------- + + @staticmethod + def _trace_from_file(path) -> Optional[Dict[str, Any]]: + from . import _trace_jsonl as tj + + row = tj.summarize_run(path) + if row is None: + return None + events = tj.read_events(path) + started = row.get("started_at") or 0.0 + ended = row.get("ended_at") + last_ts = max( + (e["timestamp"] for e in events if isinstance(e.get("timestamp"), (int, float))), + default=started, ) + agents = row.get("agents") or [] + return { + "id": row["run_id"], + "agent_id": agents[0] if agents else "", + "agents": agents, + "name": row.get("name") or row["run_id"], + "status": row["status"], + "start_time": started, + "end_time": ended, + "timestamp": started, + "duration_ms": round(((ended or last_ts) - started) * 1000, 3), + "span_count": len(tj.build_spans(events, row["run_id"])), + "source": "jsonl", + "metadata": { + "path": row["path"], + "session_id": row.get("session_id"), + "kind": row.get("kind"), + "events": row["events"], + "cost_usd": row["cost_usd"], + "live": row["live"], + }, + } + + @staticmethod + def _files(): + from . import _trace_jsonl as tj + + return tj.list_run_files() def list_traces( self, *, limit: int = 50, agent_id: Optional[str] = None ) -> List[Dict[str, Any]]: - sdk_traces = self._list_traces_from_sdk(limit=limit, agent_id=agent_id) - if sdk_traces is not None: - return sdk_traces - return self._simple.list_traces(limit=limit, agent_id=agent_id) + items: List[Dict[str, Any]] = [] + for path in self._files(): + if len(items) >= limit: + break + trace = self._trace_from_file(path) + if trace is None: + continue + if agent_id and agent_id not in trace["agents"]: + continue + items.append(trace) + remaining = max(0, limit - len(items)) + if remaining: + items.extend(self._simple.list_traces(limit=remaining, agent_id=agent_id)) + return items def list_spans( self, *, limit: int = 100, trace_id: Optional[str] = None ) -> List[Dict[str, Any]]: - sdk_spans = self._list_spans_from_sdk(limit=limit, trace_id=trace_id) - if sdk_spans is not None: - return sdk_spans - return self._simple.list_spans(limit=limit, trace_id=trace_id) + from . import _trace_jsonl as tj + + if trace_id: + path = tj.run_path(trace_id) + if path is not None and path.is_file(): + return tj.build_spans(tj.read_events(path), trace_id)[:limit] + return self._simple.list_spans(limit=limit, trace_id=trace_id) + spans: List[Dict[str, Any]] = [] + for path in self._files()[: self.MAX_FILES_FOR_SPANS]: + spans.extend(tj.build_spans(tj.read_events(path), path.stem)) + if len(spans) >= limit: + return spans[:limit] + spans.extend(self._simple.list_spans(limit=limit - len(spans))) + return spans[:limit] def get_trace(self, trace_id: str) -> Optional[Dict[str, Any]]: - sdk_trace = self._get_trace_from_sdk(trace_id) - if sdk_trace is not None: - return sdk_trace + from . import _trace_jsonl as tj + + path = tj.run_path(trace_id) + if path is not None and path.is_file(): + return self._trace_from_file(path) return self._simple.get_trace(trace_id) def record_trace(self, trace: Dict[str, Any]) -> str: @@ -168,69 +224,34 @@ def record_trace(self, trace: Dict[str, Any]) -> str: def record_span(self, span: Dict[str, Any]) -> str: return self._simple.record_span(span) - def _list_traces_from_sdk( - self, *, limit: int, agent_id: Optional[str] - ) -> Optional[List[Dict[str, Any]]]: - try: - from praisonaiagents import trace as trace_module - - getter = getattr(trace_module, "list_traces", None) - if not callable(getter): - return None - items = getter(limit=limit, agent_id=agent_id) - return items if isinstance(items, list) else None - except Exception: - return None - - def _list_spans_from_sdk( - self, *, limit: int, trace_id: Optional[str] - ) -> Optional[List[Dict[str, Any]]]: - try: - from praisonaiagents import trace as trace_module - - getter = getattr(trace_module, "list_spans", None) - if not callable(getter): - return None - items = getter(limit=limit, trace_id=trace_id) - return items if isinstance(items, list) else None - except Exception: - return None - - def _get_trace_from_sdk(self, trace_id: str) -> Optional[Dict[str, Any]]: - try: - from praisonaiagents import trace as trace_module - - getter = getattr(trace_module, "get_trace", None) - if not callable(getter): - return None - trace = getter(trace_id) - return trace if isinstance(trace, dict) else None - except Exception: - return None - def health(self) -> Dict[str, Any]: + from . import _trace_jsonl as tj + h = self._simple.health() - h["provider"] = "SDKTracingManager" - h["trace_available"] = self._trace_available - h["obs_available"] = self._obs_available + files = self._files() + h["provider"] = self.__class__.__name__ + h["source"] = "jsonl" if files else "memory" + h["traces_dir"] = str(tj.traces_dir()) + h["jsonl_traces"] = len(files) return h +#: Deprecated alias. The old class looked up ``praisonaiagents.trace.list_traces`` +#: / ``list_spans`` / ``get_trace``, which never existed, so it always fell back +#: to the in-memory store. Recorded runs are JSONL files; read those instead. +SDKTracingManager = JsonlTracingManager + + # ── Manager singleton ──────────────────────────────────────────────── _tracing_manager: Optional[TracingProtocol] = None def get_tracing_manager() -> TracingProtocol: - """Get the active tracing manager (SDK-first, fallback to Simple).""" + """Get the active tracing manager (recorded JSONL runs + in-memory records).""" global _tracing_manager if _tracing_manager is None: - try: - _tracing_manager = SDKTracingManager() - logger.info("Using SDKTracingManager") - except Exception as e: - logger.debug("SDKTracingManager init failed (%s), using SimpleTracingManager", e) - _tracing_manager = SimpleTracingManager() + _tracing_manager = JsonlTracingManager() return _tracing_manager @@ -301,12 +322,10 @@ def _cli_status(self) -> str: h = mgr.health() lines = ["Tracing Status:"] lines.append(f" Provider: {h.get('provider', 'unknown')}") + if "source" in h: + lines.append(f" Source: {h['source']} ({h.get('traces_dir', '')})") lines.append(f" Total traces: {h.get('total_traces', 0)}") lines.append(f" Total spans: {h.get('total_spans', 0)}") - if "trace_available" in h: - lines.append(f" SDK trace: {'available' if h['trace_available'] else 'unavailable'}") - if "obs_available" in h: - lines.append(f" SDK obs: {'available' if h['obs_available'] else 'unavailable'}") return "\n".join(lines) def _cli_list(self, limit: int = 20) -> str: diff --git a/tests/unit/test_tracing_jsonl.py b/tests/unit/test_tracing_jsonl.py new file mode 100644 index 00000000..32f58fa7 --- /dev/null +++ b/tests/unit/test_tracing_jsonl.py @@ -0,0 +1,153 @@ +"""Tracing feature reads recorded runs from the context-trace JSONL directory. + +The old SDKTracingManager looked up ``praisonaiagents.trace.list_traces`` / +``list_spans`` / ``get_trace``, which do not exist, so recorded runs never +showed up and it silently fell back to an empty in-memory store. +""" + +from __future__ import annotations + +import shutil +from pathlib import Path + +import pytest +from starlette.applications import Starlette +from starlette.testclient import TestClient + +FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "replay" + + +@pytest.fixture() +def traces(tmp_path, monkeypatch): + d = tmp_path / "traces" + d.mkdir() + for name in ("team_handoff", "flow_parallel_loop", "legacy_v10"): + shutil.copy(FIXTURES / f"{name}.jsonl", d / f"{name}.jsonl") + monkeypatch.setenv("PRAISONAI_TRACES_DIR", str(d)) + from praisonaiui.features import tracing + + monkeypatch.setattr(tracing, "_tracing_manager", None) + return d + + +@pytest.fixture() +def client(traces): + from praisonaiui.features.tracing import TracingFeature + + return TestClient(Starlette(routes=TracingFeature().routes())) + + +def test_recorded_runs_are_listed_as_traces(client): + data = client.get("/api/traces").json() + ids = {t["id"] for t in data["traces"]} + assert {"team_handoff", "flow_parallel_loop", "legacy_v10"} <= ids + team = next(t for t in data["traces"] if t["id"] == "team_handoff") + assert team["source"] == "jsonl" + assert team["name"] == "Solar sail brief" + assert team["status"] == "completed" + assert team["duration_ms"] == pytest.approx(12250, abs=1) + assert team["span_count"] > 0 + assert team["metadata"]["session_id"] == "sess-team-01" + + +def test_agent_filter(client): + data = client.get("/api/traces?agent_id=Checker").json() + assert [t["id"] for t in data["traces"]] == ["legacy_v10"] + + +def test_spans_from_start_end_pairs_via_parent_seq(client): + spans = client.get("/api/traces/spans?trace_id=team_handoff&limit=500").json()["spans"] + kinds = {} + for s in spans: + kinds[s["kind"]] = kinds.get(s["kind"], 0) + 1 + assert kinds == {"session": 1, "workflow": 1, "llm": 5, "agent": 2, "task": 2, "tool": 4, "approval": 1} + by_id = {s["id"]: s for s in spans} + # tool span nests under the agent span that was open (parent_seq on the start) + tool = next(s for s in spans if s["kind"] == "tool" and s["name"] == "write_file") + parent = by_id[tool["parent_span_id"]] + assert parent["kind"] == "agent" and parent["agent_id"] == "Writer" + assert tool["status"] == "ok" + assert tool["duration_ms"] == pytest.approx(50.0) + assert tool["attributes"]["input"] == {"path": "docs/draft.md", "content": "[REDACTED]"} + # approval pairs requested -> decided by parent_seq even though the decision has no agent + appr = next(s for s in spans if s["kind"] == "approval") + assert appr["status"] == "ok" + assert appr["end_time"] == pytest.approx(1767225611.2) + # every span closed + assert all(s["status"] != "open" for s in spans) + assert all(s["trace_id"] == "team_handoff" for s in spans) + + +def test_spans_for_legacy_file_fall_back_to_sequence_order(client): + spans = client.get("/api/traces/spans?trace_id=legacy_v10").json()["spans"] + by_id = {s["id"]: s for s in spans} + names = [(s["kind"], s["agent_id"]) for s in spans] + assert names == [ + ("session", ""), + ("agent", "Assistant"), + ("llm", "Assistant"), + ("tool", "Assistant"), + ("llm", "Assistant"), + ("agent", "Checker"), + ("llm", "Checker"), + ] + calc = next(s for s in spans if s["kind"] == "tool") + assert by_id[calc["parent_span_id"]]["kind"] == "agent" + assert by_id[calc["parent_span_id"]]["agent_id"] == "Assistant" + checker_llm = spans[-1] + assert by_id[checker_llm["parent_span_id"]]["agent_id"] == "Checker" + assert all(s["status"] == "ok" for s in spans) + + +def test_parallel_branch_spans_pair_within_branch(client): + spans = client.get("/api/traces/spans?trace_id=flow_parallel_loop&limit=500").json()["spans"] + tools = {s["name"]: s for s in spans if s["kind"] == "tool"} + assert tools["fetch_rss"]["duration_ms"] == pytest.approx(1250.0) + assert tools["search_papers"]["duration_ms"] == pytest.approx(1840.0) + assert tools["search_papers"]["attributes"]["branch_id"] == "gather/1" + assert tools["search_papers"]["attributes"]["cost_usd"] == pytest.approx(0.001) + + +def test_get_trace_and_unknown(client): + r = client.get("/api/traces/legacy_v10") + assert r.status_code == 200 + body = r.json() + assert body["trace"]["id"] == "legacy_v10" + assert len(body["spans"]) == 7 + assert client.get("/api/traces/does-not-exist").status_code == 404 + assert client.get("/api/traces/../../etc/passwd").status_code == 404 + + +def test_health_reports_source(client, tmp_path, monkeypatch): + status = client.get("/api/traces/status").json() + assert status["source"] == "jsonl" + assert status["jsonl_traces"] == 3 + from praisonaiui.features import tracing + + monkeypatch.setenv("PRAISONAI_TRACES_DIR", str(tmp_path / "empty")) + monkeypatch.setattr(tracing, "_tracing_manager", None) + assert client.get("/api/traces/status").json()["source"] == "memory" + + +def test_recorded_in_memory_traces_still_work(client): + r = client.post("/api/traces/record", json={"type": "trace", "id": "mem-1", "name": "manual"}) + assert r.status_code == 200 + ids = [t["id"] for t in client.get("/api/traces?limit=100").json()["traces"]] + assert "mem-1" in ids and "team_handoff" in ids + assert client.get("/api/traces/mem-1").json()["trace"]["name"] == "manual" + + +def test_no_dead_sdk_lookups_remain(): + src = (Path(__file__).resolve().parents[2] / "src" / "praisonaiui" / "features" / "tracing.py").read_text(encoding="utf-8") + for dead in ('"list_traces", None', '"list_spans", None', '"get_trace", None', "trace_module"): + assert dead not in src + from praisonaiui.features.tracing import JsonlTracingManager, SDKTracingManager + + assert SDKTracingManager is JsonlTracingManager # deprecated alias kept for imports + + +def test_cli_status_mentions_source(traces): + from praisonaiui.features.tracing import TracingFeature + + out = TracingFeature()._cli_status() + assert "Source: jsonl" in out From 6a64157d001b26a9fee62e3cd9931e682798af0a Mon Sep 17 00:00:00 2001 From: MervinPraison <454862+MervinPraison@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:36:14 +0000 Subject: [PATCH 3/8] fix(approvals): UI approval backend waits for the human decision _UIStoreBackend returned "Awaiting UI approval" (a denial) immediately, so any tool needing approval was always denied and the UI's Approve button never reached the agent. It also dropped request.approval_id and any edited arguments. - each request is stored under request.approval_id and the caller blocks until /api/approvals/{id}/approve|deny resolves it: sync callers on a threading.Event, async callers on a future of their own loop - no answer before the timeout (registry timeout, default 300 s) is a denial and the entry is resolved as denied (fail-closed) - a sync call made on an event-loop thread is denied at once rather than freezing that loop - POST /approve accepts modified_args (object) and the decision carries it as ApprovalDecision(modified_args=...); the handler change is two lines so the auth checks in #312 rebase cleanly Co-Authored-By: Claude Opus 5.5 (1M context) --- src/praisonaiui/features/approvals.py | 289 ++++++++++++++++++++---- tests/unit/test_approvals_ui_backend.py | 235 +++++++++++++++++++ 2 files changed, 475 insertions(+), 49 deletions(-) create mode 100644 tests/unit/test_approvals_ui_backend.py diff --git a/src/praisonaiui/features/approvals.py b/src/praisonaiui/features/approvals.py index 9b563b84..f252c49c 100644 --- a/src/praisonaiui/features/approvals.py +++ b/src/praisonaiui/features/approvals.py @@ -13,6 +13,7 @@ import asyncio import logging +import threading import time import uuid from abc import ABC, abstractmethod @@ -66,7 +67,13 @@ def request_approval(self, entry: Dict[str, Any]) -> Dict[str, Any]: @abstractmethod def approve( self, approval_id: str, reason: str = "", approver: str = "user", always: bool = False - ) -> Optional[Dict[str, Any]]: ... + ) -> Optional[Dict[str, Any]]: + """Approve a pending request. + + Implementations may also accept ``modified_args: dict`` (replacement + tool arguments); the HTTP handler only passes it when the client sent it. + """ + ... @abstractmethod def deny( @@ -86,6 +93,66 @@ def health(self) -> Dict[str, Any]: return {"status": "ok", "provider": self.__class__.__name__} +# ── Decision waiter ────────────────────────────────────────────────── + + +class _DecisionWaiter: + """One pending decision that both threads and coroutines can wait on. + + ``set()`` may be called from any thread (normally the server's event loop + handling ``POST /api/approvals/{id}/approve``); sync waiters block on a + ``threading.Event`` and async waiters get their future resolved on their + own loop via ``call_soon_threadsafe``, so neither blocks the other. + """ + + def __init__(self) -> None: + self._event = threading.Event() + self._lock = threading.Lock() + self._result: Optional[Dict[str, Any]] = None + self._futures: List[tuple] = [] + + @property + def done(self) -> bool: + return self._event.is_set() + + def set(self, entry: Dict[str, Any]) -> None: + with self._lock: + if self._event.is_set(): + return + self._result = entry + self._event.set() + futures, self._futures = self._futures, [] + for loop, fut in futures: + try: + loop.call_soon_threadsafe(_resolve_future, fut, entry) + except RuntimeError: # loop already closed + pass + + def wait(self, timeout: Optional[float]) -> Optional[Dict[str, Any]]: + return self._result if self._event.wait(timeout) else None + + async def wait_async(self, timeout: Optional[float]) -> Optional[Dict[str, Any]]: + loop = asyncio.get_running_loop() + fut = loop.create_future() + with self._lock: + if self._event.is_set(): + return self._result + self._futures.append((loop, fut)) + try: + return await asyncio.wait_for(fut, timeout) + except asyncio.TimeoutError: + return None + finally: + with self._lock: + if (loop, fut) in self._futures: + self._futures.remove((loop, fut)) + + +def _resolve_future(fut: "asyncio.Future", value: Any) -> None: + if not fut.done(): + fut.set_result(value) + + # ── Simple Approval Manager ────────────────────────────────────────── @@ -95,6 +162,8 @@ class SimpleApprovalManager(ApprovalProtocol): def __init__(self) -> None: self._pending: Dict[str, Dict[str, Any]] = {} self._history: deque = deque(maxlen=500) + self._lock = threading.RLock() + self._waiters: Dict[str, _DecisionWaiter] = {} self._policies: Dict[str, Any] = { "auto_approve_tools": [], "always_deny_tools": [], @@ -136,44 +205,67 @@ def request_approval(self, entry: Dict[str, Any]) -> Dict[str, Any]: entry.get("agent_name", ""), entry.get("risk_level", "medium"), ) - if auto: - entry["status"] = auto - entry["resolved_at"] = time.time() - entry["approver"] = "auto-policy" - entry["reason"] = f"Auto-{auto} by policy" - self._history.appendleft(entry) - else: - self._pending[approval_id] = entry + with self._lock: + if auto: + entry["status"] = auto + entry["resolved_at"] = time.time() + entry["approver"] = "auto-policy" + entry["reason"] = f"Auto-{auto} by policy" + self._history.appendleft(entry) + else: + self._pending[approval_id] = entry + self._waiters.setdefault(approval_id, _DecisionWaiter()) return entry + def waiter(self, approval_id: str) -> Optional[_DecisionWaiter]: + """The waiter for a pending request (``None`` once it is resolved).""" + with self._lock: + return self._waiters.get(approval_id) + + def _signal(self, approval_id: str, entry: Dict[str, Any]) -> None: + waiter = self._waiters.pop(approval_id, None) + if waiter is not None: + waiter.set(entry) + def approve( - self, approval_id: str, reason: str = "", approver: str = "user", always: bool = False + self, + approval_id: str, + reason: str = "", + approver: str = "user", + always: bool = False, + modified_args: Optional[Dict[str, Any]] = None, ) -> Optional[Dict[str, Any]]: - if approval_id not in self._pending: - return None - entry = self._pending.pop(approval_id) - entry["status"] = "approved" - entry["reason"] = reason - entry["resolved_at"] = time.time() - entry["approver"] = approver - if always and entry["tool_name"] not in self._policies["auto_approve_tools"]: - self._policies["auto_approve_tools"].append(entry["tool_name"]) - self._history.appendleft(entry) + with self._lock: + if approval_id not in self._pending: + return None + entry = self._pending.pop(approval_id) + entry["status"] = "approved" + entry["reason"] = reason + entry["resolved_at"] = time.time() + entry["approver"] = approver + if modified_args is not None: + entry["modified_args"] = dict(modified_args) + if always and entry["tool_name"] not in self._policies["auto_approve_tools"]: + self._policies["auto_approve_tools"].append(entry["tool_name"]) + self._history.appendleft(entry) + self._signal(approval_id, entry) return entry def deny( self, approval_id: str, reason: str = "", approver: str = "user", always: bool = False ) -> Optional[Dict[str, Any]]: - if approval_id not in self._pending: - return None - entry = self._pending.pop(approval_id) - entry["status"] = "denied" - entry["reason"] = reason - entry["resolved_at"] = time.time() - entry["approver"] = approver - if always and entry["tool_name"] not in self._policies["always_deny_tools"]: - self._policies["always_deny_tools"].append(entry["tool_name"]) - self._history.appendleft(entry) + with self._lock: + if approval_id not in self._pending: + return None + entry = self._pending.pop(approval_id) + entry["status"] = "denied" + entry["reason"] = reason + entry["resolved_at"] = time.time() + entry["approver"] = approver + if always and entry["tool_name"] not in self._policies["always_deny_tools"]: + self._policies["always_deny_tools"].append(entry["tool_name"]) + self._history.appendleft(entry) + self._signal(approval_id, entry) return entry def get(self, approval_id: str) -> Optional[Dict[str, Any]]: @@ -237,9 +329,14 @@ def request_approval(self, entry: Dict[str, Any]) -> Dict[str, Any]: return result def approve( - self, approval_id: str, reason: str = "", approver: str = "user", always: bool = False + self, + approval_id: str, + reason: str = "", + approver: str = "user", + always: bool = False, + modified_args: Optional[Dict[str, Any]] = None, ) -> Optional[Dict[str, Any]]: - entry = self._simple.approve(approval_id, reason, approver, always) + entry = self._simple.approve(approval_id, reason, approver, always, modified_args) if entry and always and entry.get("tool_name"): self._registry.add_requirement(entry["tool_name"], entry.get("risk_level", "high")) return entry @@ -273,31 +370,121 @@ def health(self) -> Dict[str, Any]: class _UIStoreBackend: - """Routes agent approval requests into the UI SimpleApprovalManager.""" + """Routes agent approval requests into the UI store and waits for a human. + + Each request becomes a pending entry keyed by ``request.approval_id`` and + the caller blocks (sync: on a ``threading.Event``; async: on a future of + its own loop) until ``POST /api/approvals/{id}/approve|deny`` resolves it. + No answer within the timeout is a denial (fail-closed) and the entry is + resolved as ``denied`` with ``approver="timeout"`` so the queue stays honest. + """ - def __init__(self, store: SimpleApprovalManager) -> None: + def __init__(self, store: SimpleApprovalManager, timeout: Optional[float] = None) -> None: self._store = store + self._timeout = timeout - def request_approval_sync(self, request) -> Any: + def _effective_timeout(self, timeout: Optional[float]) -> float: + if timeout is not None: + return float(timeout) + if self._timeout is not None: + return float(self._timeout) + try: + from praisonaiagents.approval import get_approval_registry + + return float(getattr(get_approval_registry(), "timeout", 300.0)) + except Exception: + return 300.0 + + def _submit(self, request) -> Dict[str, Any]: + entry: Dict[str, Any] = { + "tool_name": request.tool_name, + "agent_name": getattr(request, "agent_name", "") or "", + "risk_level": request.risk_level or "medium", + "arguments": request.arguments or {}, + } + approval_id = getattr(request, "approval_id", None) + if approval_id: + entry["id"] = str(approval_id) + session_id = getattr(request, "session_id", None) + if session_id: + entry["session_id"] = session_id + return self._store.request_approval(entry) + + @staticmethod + def _decision(entry: Optional[Dict[str, Any]]) -> Any: from praisonaiagents.approval.protocols import ApprovalDecision - entry = self._store.request_approval( - { - "tool_name": request.tool_name, - "agent_name": getattr(request, "agent_name", "") or "", - "risk_level": request.risk_level or "medium", - "arguments": request.arguments or {}, - } + if entry is None: + return ApprovalDecision( + approved=False, reason="Timed out waiting for UI approval", approver="timeout" + ) + if entry.get("status") == "approved": + return ApprovalDecision( + approved=True, + reason=entry.get("reason", ""), + modified_args=dict(entry.get("modified_args") or {}), + approver=entry.get("approver"), + ) + return ApprovalDecision( + approved=False, + reason=entry.get("reason") or "denied", + approver=entry.get("approver"), ) - status = entry.get("status", "pending") - if status == "approved": - return ApprovalDecision(approved=True, reason=entry.get("reason", "")) - if status == "denied": - return ApprovalDecision(approved=False, reason=entry.get("reason", "denied")) - return ApprovalDecision(approved=False, reason="Awaiting UI approval") + + def _expire( + self, + approval_id: str, + reason: str = "Timed out waiting for UI approval", + approver: str = "timeout", + ) -> Optional[Dict[str, Any]]: + """Deny a request nobody answered; return the entry if a human won the race.""" + denied = self._store.deny(approval_id, reason=reason, approver=approver) + if denied is not None: + return None + return self._store.get(approval_id) + + def request_approval_sync(self, request, timeout: Optional[float] = None) -> Any: + from praisonaiagents.approval.protocols import ApprovalDecision + + entry = self._submit(request) + if entry.get("status") != "pending": + return self._decision(entry) + approval_id = entry["id"] + try: + asyncio.get_running_loop() + except RuntimeError: + pass + else: + # Blocking here would freeze this thread's event loop (and with it, + # possibly the very server that has to deliver the answer). + reason = "UI approval cannot block an event-loop thread; use the async approval path" + self._expire(approval_id, reason=reason, approver="ui") + return ApprovalDecision(approved=False, reason=reason, approver="ui") + waiter = self._store.waiter(approval_id) + result = waiter.wait(self._effective_timeout(timeout)) if waiter else self._store.get(approval_id) + if result is None: + result = self._expire(approval_id) + return self._decision(result) async def request_approval(self, request) -> Any: - return self.request_approval_sync(request) + entry = self._submit(request) + if entry.get("status") != "pending": + return self._decision(entry) + approval_id = entry["id"] + waiter = self._store.waiter(approval_id) + try: + result = ( + await waiter.wait_async(self._effective_timeout(None)) + if waiter + else self._store.get(approval_id) + ) + except asyncio.CancelledError: + # The registry's own wait_for timed out (or the turn was cancelled). + self._expire(approval_id) + raise + if result is None: + result = self._expire(approval_id) + return self._decision(result) # ── Manager singleton ──────────────────────────────────────────────── @@ -449,11 +636,15 @@ async def _approve(self, request: Request) -> JSONResponse: mgr = get_approval_manager() approval_id = request.path_params["approval_id"] body = await request.json() if request.headers.get("content-length") else {} + modified_args = body.get("modified_args") + if modified_args is not None and not isinstance(modified_args, dict): + return JSONResponse({"error": "modified_args must be an object"}, status_code=400) entry = mgr.approve( approval_id, reason=body.get("reason", ""), approver=body.get("approver", "user"), always=body.get("always", False), + **({"modified_args": modified_args} if modified_args is not None else {}), ) if entry is None: return JSONResponse({"error": "Approval not found"}, status_code=404) diff --git a/tests/unit/test_approvals_ui_backend.py b/tests/unit/test_approvals_ui_backend.py new file mode 100644 index 00000000..9005ea58 --- /dev/null +++ b/tests/unit/test_approvals_ui_backend.py @@ -0,0 +1,235 @@ +"""_UIStoreBackend waits for a human decision (contract v1 §5, approvals). + +Before: request_approval_sync returned ``approved=False, "Awaiting UI +approval"`` immediately, so a tool needing approval was always denied and the +UI's Approve button did nothing for the agent; ``request.approval_id`` and +``modified_args`` were dropped. +""" + +from __future__ import annotations + +import asyncio +import threading +import time + +import pytest +from starlette.applications import Starlette +from starlette.testclient import TestClient + +protocols = pytest.importorskip("praisonaiagents.approval.protocols") +ApprovalRequest = protocols.ApprovalRequest + + +def _request(approval_id: str = "appr-1", **kw) -> "ApprovalRequest": + return ApprovalRequest( + tool_name=kw.pop("tool_name", "write_file"), + arguments=kw.pop("arguments", {"path": "draft.md"}), + risk_level=kw.pop("risk_level", "high"), + agent_name=kw.pop("agent_name", "Writer"), + approval_id=approval_id, + **kw, + ) + + +@pytest.fixture() +def store(): + from praisonaiui.features.approvals import SimpleApprovalManager + + return SimpleApprovalManager() + + +@pytest.fixture() +def backend(store): + from praisonaiui.features.approvals import _UIStoreBackend + + return _UIStoreBackend(store, timeout=5.0) + + +def _wait_pending(store, approval_id: str, timeout: float = 3.0) -> dict: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + for entry in store.list_pending(): + if entry["id"] == approval_id: + return entry + time.sleep(0.01) + raise AssertionError(f"{approval_id} never became pending") + + +def _in_thread(fn, *args): + box = {} + + def run(): + box["result"] = fn(*args) + + t = threading.Thread(target=run, daemon=True) + t.start() + return t, box + + +def test_sync_blocks_until_approved_with_modified_args(store, backend): + t, box = _in_thread(backend.request_approval_sync, _request("appr-1")) + entry = _wait_pending(store, "appr-1") # honours request.approval_id + assert entry["tool_name"] == "write_file" and entry["agent_name"] == "Writer" + time.sleep(0.1) + assert t.is_alive(), "backend must block while the decision is pending" + store.approve("appr-1", reason="Use docs/", modified_args={"path": "docs/draft.md"}) + t.join(timeout=3) + assert not t.is_alive() + decision = box["result"] + assert decision.approved is True + assert decision.modified_args == {"path": "docs/draft.md"} + assert decision.reason == "Use docs/" + + +def test_sync_deny_resolves_with_reason(store, backend): + t, box = _in_thread(backend.request_approval_sync, _request("appr-2")) + _wait_pending(store, "appr-2") + store.deny("appr-2", reason="Not today") + t.join(timeout=3) + assert box["result"].approved is False + assert box["result"].reason == "Not today" + + +def test_sync_timeout_denies_and_clears_queue(store): + from praisonaiui.features.approvals import _UIStoreBackend + + backend = _UIStoreBackend(store, timeout=0.2) + started = time.monotonic() + decision = backend.request_approval_sync(_request("appr-3")) + assert time.monotonic() - started >= 0.2 + assert decision.approved is False + assert decision.approver == "timeout" + assert store.list_pending() == [] + assert store.get("appr-3")["status"] == "denied" + + +def test_per_id_isolation(store, backend): + t1, b1 = _in_thread(backend.request_approval_sync, _request("a")) + t2, b2 = _in_thread(backend.request_approval_sync, _request("b")) + _wait_pending(store, "a") + _wait_pending(store, "b") + store.approve("b") + t2.join(timeout=3) + assert b2["result"].approved is True + assert t1.is_alive(), "deciding b must not release a" + store.deny("a") + t1.join(timeout=3) + assert b1["result"].approved is False + + +def test_async_waits_without_blocking_the_loop(store, backend): + async def main(): + task = asyncio.create_task(backend.request_approval(_request("appr-4"))) + ticks = 0 + while not store.list_pending(): + ticks += 1 + await asyncio.sleep(0.01) + # the loop keeps running while waiting + for _ in range(5): + ticks += 1 + await asyncio.sleep(0.01) + assert not task.done() + # decision arrives from another thread (the server handling POST /approve) + threading.Timer(0.05, lambda: store.approve("appr-4", modified_args={"x": 1})).start() + decision = await asyncio.wait_for(task, 3) + return decision, ticks + + decision, ticks = asyncio.run(main()) + assert decision.approved is True + assert decision.modified_args == {"x": 1} + assert ticks >= 5 + + +def test_async_decision_from_same_loop(store, backend): + async def main(): + task = asyncio.create_task(backend.request_approval(_request("appr-5"))) + while not store.list_pending(): + await asyncio.sleep(0.01) + store.approve("appr-5") + return await asyncio.wait_for(task, 3) + + assert asyncio.run(main()).approved is True + + +def test_async_timeout_denies(store): + from praisonaiui.features.approvals import _UIStoreBackend + + backend = _UIStoreBackend(store, timeout=0.15) + decision = asyncio.run(backend.request_approval(_request("appr-6"))) + assert decision.approved is False + assert store.get("appr-6")["status"] == "denied" + + +def test_async_cancel_by_registry_wait_for_expires_entry(store, backend): + async def main(): + with pytest.raises(asyncio.TimeoutError): + await asyncio.wait_for(backend.request_approval(_request("appr-7")), 0.1) + + asyncio.run(main()) + assert store.list_pending() == [] + assert store.get("appr-7")["status"] == "denied" + + +def test_sync_call_on_event_loop_thread_fails_closed_fast(store, backend): + async def main(): + started = time.monotonic() + decision = backend.request_approval_sync(_request("appr-8")) + return decision, time.monotonic() - started + + decision, elapsed = asyncio.run(main()) + assert decision.approved is False + assert elapsed < 1.0 + assert store.list_pending() == [] + + +def test_auto_policy_still_resolves_immediately(store, backend): + store.update_policies({"auto_approve_tools": ["write_file"]}) + decision = backend.request_approval_sync(_request("appr-9")) + assert decision.approved is True + assert decision.approver == "auto-policy" + + +def test_approve_endpoint_passes_modified_args(store, backend, monkeypatch): + from praisonaiui.features import approvals + + monkeypatch.setattr(approvals, "_approval_manager", store) + client = TestClient(Starlette(routes=approvals.ApprovalsFeature().routes())) + t, box = _in_thread(backend.request_approval_sync, _request("appr-10")) + _wait_pending(store, "appr-10") + assert client.get("/api/approvals/appr-10").json()["arguments"] == {"path": "draft.md"} + r = client.post("/api/approvals/appr-10/approve", json={"modified_args": {"path": "docs/x.md"}, "reason": "ok"}) + assert r.status_code == 200 + assert r.json()["modified_args"] == {"path": "docs/x.md"} + t.join(timeout=3) + assert box["result"].approved is True + assert box["result"].modified_args == {"path": "docs/x.md"} + + +def test_approve_endpoint_rejects_non_object_modified_args(store, monkeypatch): + from praisonaiui.features import approvals + + monkeypatch.setattr(approvals, "_approval_manager", store) + store.request_approval({"id": "z", "tool_name": "t", "risk_level": "high"}) + client = TestClient(Starlette(routes=approvals.ApprovalsFeature().routes())) + r = client.post("/api/approvals/z/approve", json={"modified_args": [1, 2]}) + assert r.status_code == 400 + assert store.get("z")["status"] == "pending" + # plain approve without modified_args still works (and keeps old managers working) + assert client.post("/api/approvals/z/approve", json={}).status_code == 200 + + +def test_sdk_manager_approve_forwards_modified_args(): + pytest.importorskip("praisonaiagents.approval") + from praisonaiagents.approval import get_approval_registry + + from praisonaiui.features.approvals import SDKApprovalManager + + registry = get_approval_registry() + previous = registry.get_backend() + try: + mgr = SDKApprovalManager() + mgr._simple.request_approval({"id": "s1", "tool_name": "t", "risk_level": "high"}) + entry = mgr.approve("s1", modified_args={"k": "v"}) + assert entry["modified_args"] == {"k": "v"} + finally: + registry.set_backend(previous) From bc3692ee69dd2151c144fa2cda09f5371b60e99a Mon Sep 17 00:00:00 2001 From: MervinPraison <454862+MervinPraison@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:36:14 +0000 Subject: [PATCH 4/8] docs(replay): user guide and clean-room ADR docs/features/replay.md describes the page, keys, approvals, export and HTTP API. docs/adr/0001-replay-view-clean-room.md records the ideas we took (graph plus trace, score-like swimlanes, OTel GenAI vocabulary), that no third-party UI code, CSS, icons, layouts or screenshots were used, and the palette, accessibility and licence reasoning. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/adr/0001-replay-view-clean-room.md | 92 +++++++++++++++++++++++ docs/features/replay.md | 97 +++++++++++++++++++++++++ mkdocs.yml | 1 + 3 files changed, 190 insertions(+) create mode 100644 docs/adr/0001-replay-view-clean-room.md create mode 100644 docs/features/replay.md diff --git a/docs/adr/0001-replay-view-clean-room.md b/docs/adr/0001-replay-view-clean-room.md new file mode 100644 index 00000000..bb764907 --- /dev/null +++ b/docs/adr/0001-replay-view-clean-room.md @@ -0,0 +1,92 @@ +# ADR 0001: PraisonAI Replay view, a clean-room design + +- **Status:** accepted +- **Date:** 2026-10-26 +- **Scope:** `src/praisonaiui/features/replay.py`, `features/_trace_jsonl.py`, + `templates/frontend/plugins/views/replay-core.js`, `templates/frontend/plugins/views/replay.js` + +## Context + +PraisonAI records agent runs as context-trace JSONL (one `ContextEvent.to_dict()` per +line). People need to see what a team or flow did: who did what, when, why, and at what +cost, both after the fact and live. Other tools have shown that a graph plus a trace is a +useful way to look at agent runs. We want that value without copying anyone's product, +code or look, and without adding dependencies. + +## Decision + +Build our own view, in plain DOM and SVG, with our own layout code, palette and words. + +### Ideas we took (ideas only) + +| Idea | Where it came from | What we did with it | +|---|---|---| +| Show the graph of a multi-agent program next to the trace of a run | The general concept behind LangGraph Studio's graph-plus-trace view, known from public talks and articles | A **Map** view drawn by our own layered layout (`layoutMap`); the default view is a **Timeline**, which Studio does not have | +| Parallel voices on shared time, read left to right | Musical scores and swimlane diagrams | One lane per agent plus Manager and You; parallel branches as sub-lanes; tasks and steps as "chapters" across the top | +| Vocabulary for LLM and tool spans, tokens and cost | OpenTelemetry GenAI semantic conventions (public specification) | Block kinds (LLM, tool), token and cost totals; the wrapper's OTLP export uses the conventions, the UI only borrows the concepts | + +### What we did not use + +- **No third-party UI code, CSS, icons, layouts or screenshots** were used to build this + view. Shapes, palette, spacing, wording ("Brief", "Delivered", "Waiting for you") and + the layout algorithm are our own. +- **LangGraph's server (Elastic License 2.0) and the proprietary LangGraph Studio were + not consulted for code**, and none of their assets, markup or styles were copied. We + do not use the name "Studio", any "Lang…" naming, or `__start__`/`__end__` pills. +- No Mermaid, D3, React Flow, dagre, ELK or other graph libraries. The dashboard's + existing Mermaid CDN plugin is not used by Replay. + +### How it is built + +- `replay-core.js`: pure functions (`buildModel`, `narrate`, `layoutMap`, + `deriveGraph`). No DOM, no network. Tested with `node --test`. +- `replay.js`: DOM/SVG rendering, keyboard handling, SSE follow and approvals. It also + boots by itself when `window.__REPLAY_DATA__ = {events, graph}` is set, which is how + the wrapper's single-file HTML export works (`standalone_html()` inlines both files). +- `layoutMap` is a textbook layered layout written from scratch: depth-first search to + find back edges (loops), longest-path layering, one virtual waypoint per crossed layer + for long edges, and a few barycentre sweeps to order each layer. It is deterministic. +- Narration is template text, chosen per event type. No LLM, no randomness. + +## Palette and accessibility rationale + +| Role | Fill | Shape | Why | +|---|---|---|---| +| Agent | `#8B0000` | circle | PraisonAI's brand red; white text on it is 10.0:1 | +| Tool | `#0F7287` | rounded rectangle | Teal kept from the brand but darkened from `#189AB4` (white text on `#189AB4` fails AA); white on `#0F7287` is 5.6:1 | +| You (human) | `#8A5A00` | diamond | Amber distinct from red and teal for common colour-vision deficiencies; white on it is 5.9:1 | +| Manager | `#3C4A5C` | square | Neutral slate, so the manager reads as coordination, not work; white on it is 9.0:1 | + +- **Never colour alone.** Every lane, block, node and step has a shape and a text label; + arrows differ by dash pattern and carry words ("hands off", "delegates", "routes"). + Heat in the cost/latency overlay is a bar whose height and shade both grow, plus the + value in text. +- **Light theme first.** Text tokens are at least 6.9:1 on their backgrounds in light mode + and 7.7:1 in dark mode. In dark mode the palette fills keep white text at AA, and get a + light 2 px outline so each shape's boundary stays above 3:1 against the dark page. +- **Keyboard throughout.** ← → (and ↑ ↓ in the step list) move the replay cursor, Home + and End jump to the ends, tabs follow the ARIA tabs pattern, map nodes use a roving + tab stop, and every action has a real button. +- **Screen readers.** The narration column is a real ordered list and is the main way + through a run. Timeline blocks and map nodes are named buttons whose names start with + their visible text (WCAG 2.5.3). Live updates are announced through a polite live + region, throttled. +- **Motion.** The only animation (the live dot) runs only under + `prefers-reduced-motion: no-preference`; scrolling is instant when motion is reduced. +- Checked with axe-core against WCAG 2.0, 2.1 and 2.2 A/AA in both themes (dev-only test + in `tests/browser/`). + +## Dependency licences + +No new runtime or build dependencies. The Python side uses only the standard library and +Starlette, which aiui already depends on. The browser side is plain ES modules with no +imports beyond our own file. Dev-only test tools that are not shipped: Playwright +(Apache-2.0, already used in this environment) and axe-core (MPL-2.0, loaded from a path +given by `AXE_CORE_JS`; not vendored). + +## Consequences + +- We own the look and the layout code, and can change them without licence questions. +- The layout is simpler than a dedicated graph engine. It is fine for the team and flow + sizes we see (tens of nodes). Very large graphs may need a better crossing-reduction + step later. diff --git a/docs/features/replay.md b/docs/features/replay.md new file mode 100644 index 00000000..8ad478b6 --- /dev/null +++ b/docs/features/replay.md @@ -0,0 +1,97 @@ +# Replay + +**PraisonAI Replay** shows a recorded agent run as a story: who did what, when, why, and +at what cost. It works for finished runs and for runs that are still going. + +## Record a run, then open it + +```bash +praisonai replay view agents.yaml # records the run and opens Replay on it +``` + +or from Python: + +```python +from praisonai.replay import record + +with record(live=True): + team.start() +``` + +Every recorded run is a file `.jsonl` in the traces folder. Replay reads it from +`$PRAISONAI_TRACES_DIR`, else `$PRAISON_HOME/traces`, else `~/.praison/traces`. + +To open aiui on Replay yourself: + +```bash +aiui run "$(python -c 'import praisonaiui.replay_app as m; print(m.__file__)')" \ + --style dashboard --host 127.0.0.1 --port 8090 +# then open http://127.0.0.1:8090/replay?run= +``` + +`/#/replay?run=` works too. In a full dashboard, Replay is in the sidebar under +**Control**. + +## What you see + +| Part | What it shows | +|---|---| +| **Runs** | Recorded runs, newest first. A run that is still being written has a **Live** badge. | +| **Timeline** (default) | One lane per agent, plus **Manager** and **You**. Time runs left to right. LLM calls and tool calls are blocks in their agent's lane. Arrows show hand-offs, delegation, routing and approval requests. Parallel branches get their own sub-lanes. A call or step repeated back to back is one block marked ×N. Tasks and steps run across the top. | +| **Map** | The team or flow as a graph, drawn from the graph recorded at the start of the run. Older recordings without a graph get a "who talked to whom" map built from the events. Nodes you have not reached yet have a dashed outline; the node the cursor is on has a bold one. | +| **What happened** | One plain sentence per step, for example *"Researcher asked web_search for 'solar sail facts' — 1.2 s, $0.002."* Hovering or focusing a sentence highlights its block, and the other way round. | +| **Details** | Three tabs for the selected step. **What happened**: when, who, how long, cost, tokens, output. **Why**: only what the recording holds, such as the input given to a tool, the context passed in a hand-off and its reason, the manager's task snapshot, the route a step chose, and an approval's decision and where an automatic approval came from. **Raw JSON**: the events themselves. | +| **Cost & latency** | Adds a bar under each block (taller and darker means more cost, or more time) and per-agent totals in the lane labels. | + +Shapes always carry meaning as well as colour: agents are circles, tools are rounded +rectangles, you are a diamond and the manager is a square. + +## Moving through a run + +Replay opens at the end of the run, so you see everything. Move the cursor to replay it: +anything after the cursor is drawn as an outline. + +| Key | Action | +|---|---| +| ← → | Previous / next step | +| ↑ ↓ | Previous / next step in the step list; previous / next node in the map | +| Home / End | First / last step | +| Enter or Space | Open the focused block or node | +| ← → in the tabs | Switch between What happened, Why and Raw JSON | + +The scrubber under the timeline does the same with a mouse or touch. On a live run the +cursor follows new events; if you step back, **Jump to live** takes you to the latest. + +## Approving from Replay + +When an agent asks for approval and the run uses aiui's approval store, a **Waiting for +you** card appears above the timeline: + +- **Approve** or **Deny**, with an optional reason. +- **Edit arguments…** opens the arguments as JSON (taken from the server, not the redacted + recording when the server has them). The JSON must be an object; mistakes are shown + as you type. **Approve with these arguments** sends them as `modified_args`. + +These buttons call `POST /api/approvals/{id}/approve` and `/deny`. The agent waits until +you answer, and is denied if nobody answers before the approval timeout. + +## Sharing a run as one file + +`praisonai replay export RUN --format html` writes a single HTML file with the run and +this viewer inside. It needs no server and no network. Approvals cannot be answered from +an exported file. + +## HTTP API + +| Endpoint | Returns | +|---|---| +| `GET /api/replay/runs` | `[{run_id, session_id, path, started_at, ended_at?, status, kind, name, agents, events, cost_usd, live}]`, newest first | +| `GET /api/replay/runs/{id}/events?after_seq=N` | `{events, graph, run}`; `after_seq` keeps only events with a larger `sequence_num` | +| `GET /api/replay/runs/{id}/stream` | Server-sent events: `event: ctx` per trace event (with `id:` = `sequence_num`), `ping` every 15 s, `end` once the run has finished and gone quiet for 2 s. Honours `after_seq` and `Last-Event-ID`. | + +Run ids must match `^[A-Za-z0-9._-]+$`. Replay is read-only: it never writes trace +files. Half-written last lines, unknown fields and unknown event types are skipped +safely, and schema 1.0 recordings still open. + +The **Traces** page reads the same files: each run is a trace, and its spans come from +matching start and end events. diff --git a/mkdocs.yml b/mkdocs.yml index f0257fd1..3662bb3f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -100,6 +100,7 @@ nav: - BeautifulUI Upstream Pin: frontend/BEAUTIFULUI_UPSTREAM.md - Features: - Dashboard: features/dashboard.md + - Replay: features/replay.md - Gateway Chat: features/gateway-chat.md - Attachments: features/attachments.md - Theme System: features/theme-system.md From 0e26b0ec8d9c07f60e228e2c6cec3d1c82c7b066 Mon Sep 17 00:00:00 2001 From: MervinPraison <454862+MervinPraison@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:38:42 +0000 Subject: [PATCH 5/8] fix(replay): fit narrow canvases and say "still waiting" for open items The fit-to-width minimum (480 px of plot) made the timeline overflow in the dashboard's narrower content column; lower it to 260 px. Pending approvals and running blocks showed "Duration 0 ms"; show "still waiting" or "still running" instead. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/praisonaiui/templates/frontend/plugins/views/replay.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/praisonaiui/templates/frontend/plugins/views/replay.js b/src/praisonaiui/templates/frontend/plugins/views/replay.js index 781000e5..1c119f21 100644 --- a/src/praisonaiui/templates/frontend/plugins/views/replay.js +++ b/src/praisonaiui/templates/frontend/plugins/views/replay.js @@ -939,7 +939,7 @@ function renderTimeline() { S.tl = null; return; } - const avail = Math.max(480, (canvas.clientWidth || 900) - GUTTER - PAD_R); + const avail = Math.max(260, (canvas.clientWidth || 900) - GUTTER - PAD_R); const dur = Math.max(m.duration, 0.05); S.fitPps = avail / dur; const pps = S.zoom || S.fitPps; @@ -1429,7 +1429,8 @@ function whatHappened(item, ev, block, chapter, raw) { ['When', `${offset(item.t)} from the start`], ['Who', item.agent || (item.type.startsWith('approval') ? 'You' : 'The run')], ['Event', `${item.type} #${ev ? ev.sequence_num : '?'}`], - ['Duration', block ? C().formatDuration(block.durationMs) : (chapter ? C().formatDuration((chapter.end - chapter.start) * 1000) : C().formatDuration(d.duration_ms))], + ['Duration', block ? (block.open ? (block.kind === 'approval' ? 'still waiting' : 'still running') : C().formatDuration(block.durationMs)) + : (chapter ? (chapter.open ? 'still running' : C().formatDuration((chapter.end - chapter.start) * 1000)) : C().formatDuration(d.duration_ms))], ['Cost', block ? C().formatCost(block.cost) : C().formatCost(ev && ev.cost_usd)], ['Tokens', block && block.tokens ? block.tokens : null], ['Status', block ? block.status : (chapter ? chapter.status : d.status)], From f1261958c8cf84ce621a5458fc43096641eaf854 Mon Sep 17 00:00:00 2001 From: MervinPraison <454862+MervinPraison@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:24:25 +0000 Subject: [PATCH 6/8] =?UTF-8?q?fix(replay):=20real-run=20review=20?= =?UTF-8?q?=E2=80=94=20rows,=20system=20theme,=20fitted=20map,=20phone,=20?= =?UTF-8?q?export=20boot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From a review against real recorded runs: - Timeline: an agent's LLM span encloses its tool calls in real runs, so tool blocks covered the LLM label. Each lane now has an LLM row with a thinner tool row under it, blocks are packed into tracks in pixel space so nothing overlaps, and labels are ellipsized; the full label is in the title, the accessible name and a hover/focus tip. Visible labels sit in a decorative layer so names never contain a cut-off word. - Theme: defaults to the system setting (prefers-color-scheme, tracked live in CSS) with a System / Light / Dark menu as an override. - Map: fits the whole graph on open (shares -/Fit/+), drag to pan, never below a readable scale, and gets the full width with the step list underneath. Agents seen in events but not in the graph (e.g. a hand-off to an agent outside the team) are added by observedGraph() and laid out by layoutRunMap() as an "Outside the team" group. - Phone: 16 px gutter, stacked layout, no horizontal page scroll. - Standalone boot waits for DOMContentLoaded so the export works with either script order, takes data.run_id as the title, and keeps the documented #replay-root / window.__REPLAY_DATA__ contract. - New fixture team_outside.jsonl with the real-run shape; node tests for observedGraph/layoutRunMap; browser tests for no-overlap, the Why tab by role and keyboard, system theme, map fit, phone width and the wrapper-style export in both script orders. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../frontend/plugins/views/replay-core.js | 105 +++++- .../frontend/plugins/views/replay.js | 353 ++++++++++++------ tests/browser/test_replay_browser.py | 152 +++++++- tests/fixtures/replay/make_fixtures.py | 76 ++++ tests/fixtures/replay/team_outside.jsonl | 29 ++ tests/js/replay-core.test.mjs | 54 +++ 6 files changed, 662 insertions(+), 107 deletions(-) create mode 100644 tests/fixtures/replay/team_outside.jsonl diff --git a/src/praisonaiui/templates/frontend/plugins/views/replay-core.js b/src/praisonaiui/templates/frontend/plugins/views/replay-core.js index bf2068b0..4981f886 100644 --- a/src/praisonaiui/templates/frontend/plugins/views/replay-core.js +++ b/src/praisonaiui/templates/frontend/plugins/views/replay-core.js @@ -921,6 +921,109 @@ export function layoutMap(graph, opts = {}) { return { nodes, edges: outEdges, width, height: Math.max(height, backLane) + (backLane > height ? margin : 0), layers: layerCount }; } +/** + * The graph as recorded, completed with what actually happened: agents and + * tools seen in the events but missing from the graph, and hand-offs the graph + * does not draw. Agents from outside the graph (e.g. a hand-off to an agent + * that is not a team member) are marked ``meta.outside`` so the map can group + * them as "Outside the team". Without a recorded graph this is deriveGraph(). + */ +export function observedGraph(graph, events) { + const derived = deriveGraph(events); + if (!graph || !Array.isArray(graph.nodes) || !graph.nodes.length) return derived; + const nodes = graph.nodes.filter((n) => n && n.id).map((n) => ({ ...n, meta: { ...(n.meta || {}) } })); + const edges = (Array.isArray(graph.edges) ? graph.edges : []).filter((e) => e && e.source && e.target).map((e) => ({ ...e })); + const ids = new Set(nodes.map((n) => n.id)); + const agentByLabel = new Map(); + for (const n of nodes) if (n.kind === 'agent' || n.kind === 'manager') agentByLabel.set(n.label || stripPrefix(n.id), n.id); + const resolve = (id) => { + if (ids.has(id)) return id; + const name = stripPrefix(id); + if ((id.startsWith('agent:') || id.startsWith('manager:')) && agentByLabel.has(name)) return agentByLabel.get(name); + return id; + }; + const outside = new Set(); + const recorded = new Set(ids); + for (const n of derived.nodes) { + const id = resolve(n.id); + if (ids.has(id)) continue; + if (n.kind === 'human' && nodes.some((x) => x.kind === 'human')) continue; + const owner = n.kind === 'tool' ? `agent:${n.id.slice(5).split('/')[0]}` : null; + const isOutside = n.kind === 'agent' || n.kind === 'manager' || Boolean(owner && !recorded.has(resolve(owner))); + nodes.push({ ...n, meta: { ...(n.meta || {}), observed: true, outside: Boolean(isOutside) } }); + ids.add(id); + if (isOutside) outside.add(id); + } + const has = new Set(edges.map((e) => `${e.source}|${e.target}|${e.kind}`)); + const linked = new Set(edges.map((e) => `${e.source}|${e.target}`)); + for (const e of derived.edges) { + const source = resolve(e.source); + const target = resolve(e.target); + if (!ids.has(source) || !ids.has(target)) continue; + if (has.has(`${source}|${target}|${e.kind}`)) continue; + // a "uses"/"reviews" link the graph already draws in another form is not news + if ((e.kind === 'uses' || e.kind === 'reviews') && linked.has(`${source}|${target}`)) continue; + edges.push({ ...e, id: `${source}->${target}:${e.kind}`, source, target, meta: { observed: true } }); + has.add(`${source}|${target}|${e.kind}`); + } + return { ...graph, nodes, edges, outside: [...outside] }; +} + +/** + * layoutMap() for a run: the team/flow graph first, then the agents from + * outside it as their own block underneath, framed as a group. Edges between + * the two blocks are drawn straight. Returns layoutMap()'s shape plus + * ``groups: [{id, label, x, y, w, h}]``. + */ +export function layoutRunMap(graph, events, opts = {}) { + const g = observedGraph(graph, events); + const outside = new Set(g.outside || []); + if (!outside.size) return { ...layoutMap(g, opts), groups: [], graph: g }; + const inner = (set, keep) => ({ + nodes: g.nodes.filter((n) => keep(set.has(n.id))), + edges: g.edges.filter((e) => keep(set.has(e.source)) && keep(set.has(e.target))), + }); + const main = layoutMap(inner(outside, (x) => !x), opts); + const out = layoutMap(inner(outside, (x) => x), opts); + const gap = 40; + const pad = 14; + const dy = main.height + gap; + const dx = 0; + for (const n of out.nodes) { n.y += dy; n.x += dx; n.outside = true; } + for (const e of out.edges) e.points = e.points.map(([x, y]) => [x + dx, y + dy]); + const nodes = [...main.nodes, ...out.nodes]; + const byId = new Map(nodes.map((n) => [n.id, n])); + const cross = g.edges + .filter((e) => outside.has(e.source) !== outside.has(e.target) && byId.has(e.source) && byId.has(e.target)) + .map((e) => { + const s = byId.get(e.source); + const t = byId.get(e.target); + const down = t.y > s.y; + return { + id: e.id, source: e.source, target: e.target, kind: e.kind, label: e.label == null ? null : str(e.label), back: false, cross: true, + points: [[s.x + s.w / 2, down ? s.y + s.h : s.y], [t.x + t.w / 2, down ? t.y : t.y + t.h]], + }; + }); + const xs = out.nodes.map((n) => n.x); + const group = { + id: 'outside', + label: `Outside the ${g.kind === 'flow' ? 'flow' : 'team'}`, + x: Math.min(...xs) - pad, + y: dy - pad + 6, + w: Math.max(...out.nodes.map((n) => n.x + n.w)) - Math.min(...xs) + pad * 2, + h: out.height - 12 + pad, + }; + return { + nodes, + edges: [...main.edges, ...out.edges, ...cross], + width: Math.max(main.width, out.width, group.x + group.w + 8), + height: dy + out.height + 8, + layers: Math.max(main.layers, out.layers), + groups: [group], + graph: g, + }; +} + /** Words for edge kinds on the map (labels never rely on colour). */ export const EDGE_WORDS = Object.freeze({ handoff: 'hands off', @@ -942,7 +1045,7 @@ export function edgeText(edge) { const api = { PALETTE, SHAPES, HUMAN_LANE, MANAGER_LANE, EDGE_WORDS, - buildModel, narrate, layoutMap, deriveGraph, nodeIdsForEvent, pairEvents, sortEvents, + buildModel, narrate, layoutMap, layoutRunMap, observedGraph, deriveGraph, nodeIdsForEvent, pairEvents, sortEvents, eventId, formatDuration, formatCost, argPreview, truncate, stripPrefix, edgeText, }; if (typeof globalThis !== 'undefined') globalThis.PraisonReplayCore = api; diff --git a/src/praisonaiui/templates/frontend/plugins/views/replay.js b/src/praisonaiui/templates/frontend/plugins/views/replay.js index 1c119f21..669d1a1e 100644 --- a/src/praisonaiui/templates/frontend/plugins/views/replay.js +++ b/src/praisonaiui/templates/frontend/plugins/views/replay.js @@ -20,8 +20,6 @@ const SVG_NS = 'http://www.w3.org/2000/svg'; const GUTTER = 184; -const LANE_H = 48; -const SUB_H = 38; const CH_ROW = 24; const AXIS_H = 26; const PAD_R = 96; // room for arrow and cursor labels at the right edge @@ -81,12 +79,21 @@ function storageSet(key, value) { async function getCore() { if (globalThis.PraisonReplayCore) return globalThis.PraisonReplayCore; + if (globalThis.__REPLAY_DATA__) { + // standalone page: the inlined core may simply not have run yet + for (let i = 0; i < 50 && !globalThis.PraisonReplayCore; i++) await new Promise((r) => setTimeout(r, 20)); + if (globalThis.PraisonReplayCore) return globalThis.PraisonReplayCore; + } const mod = await import(new URL('./replay-core.js', import.meta.url).href); return mod.default || mod; } // ── styles ──────────────────────────────────────────────────────────── +const DARK = `--rp-bg:#16171B;--rp-surface:#1E2026;--rp-lane:#22252C;--rp-text:#F2F3F5;--rp-dim:#B4B8C2;--rp-border:#3A3E48;--rp-control:#8A909C; + --rp-focus:#7FB0FF;--rp-agent-ink:#FF9A9A;--rp-tool-ink:#5FD0E3;--rp-human-ink:#F0BE63;--rp-manager-ink:#B3C0D3;--rp-outline:#E6E8EC; + --rp-heat-lo:#B86A2B;--rp-heat-hi:#FF8A4C;--rp-live:#FF8A80;--rp-ok:#7BD88F;--rp-err:#FF8A80;--rp-chapter:#2A2E37;--rp-sel:#24344F;color-scheme:dark`; + const STYLE = ` .rp-root{--rp-bg:#FFFFFF;--rp-surface:#F6F7F9;--rp-lane:#F1F3F6;--rp-text:#17181C;--rp-dim:#4F535C;--rp-border:#D5D9E0;--rp-control:#767B85; --rp-focus:#1A56DB;--rp-agent:#8B0000;--rp-tool:#0F7287;--rp-human:#8A5A00;--rp-manager:#3C4A5C;--rp-neutral:#5B6270;--rp-on-fill:#FFFFFF; @@ -94,9 +101,8 @@ const STYLE = ` --rp-heat-lo:#C0772F;--rp-heat-hi:#8F2D07;--rp-live:#B3261E;--rp-ok:#1E6B34;--rp-err:#B3261E;--rp-chapter:#E9ECF1;--rp-sel:#DCE7FB; color:var(--rp-text);background:var(--rp-bg);font:14px/1.45 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif; border:1px solid var(--rp-border);border-radius:12px;overflow:hidden;color-scheme:light} -.rp-root[data-theme="dark"]{--rp-bg:#16171B;--rp-surface:#1E2026;--rp-lane:#22252C;--rp-text:#F2F3F5;--rp-dim:#B4B8C2;--rp-border:#3A3E48;--rp-control:#8A909C; - --rp-focus:#7FB0FF;--rp-agent-ink:#FF9A9A;--rp-tool-ink:#5FD0E3;--rp-human-ink:#F0BE63;--rp-manager-ink:#B3C0D3;--rp-outline:#E6E8EC; - --rp-heat-lo:#B86A2B;--rp-heat-hi:#FF8A4C;--rp-live:#FF8A80;--rp-ok:#7BD88F;--rp-err:#FF8A80;--rp-chapter:#2A2E37;--rp-sel:#24344F;color-scheme:dark} +.rp-root[data-theme="dark"]{${DARK}} +@media (prefers-color-scheme:dark){.rp-root[data-theme="system"]{${DARK}}} .rp-root *{box-sizing:border-box} .rp-root :focus{outline:none} .rp-root :focus-visible{outline:3px solid var(--rp-focus);outline-offset:2px} @@ -139,6 +145,8 @@ const STYLE = ` .rp-err{color:var(--rp-err);font-size:13px;margin:4px 0 0;min-height:1em} .rp-note{color:var(--rp-dim);font-size:13px;margin:4px 0 0} .rp-stage{display:grid;grid-template-columns:minmax(0,1fr) 340px;min-height:360px} +.rp-stage.rp-wide{grid-template-columns:minmax(0,1fr)} +.rp-stage.rp-wide .rp-narr{border-left:0;border-top:1px solid var(--rp-border);max-height:300px} .rp-canvas{overflow:auto;position:relative;max-height:62vh;background:var(--rp-bg)} .rp-canvas svg{display:block} .rp-narr{position:relative;border-left:1px solid var(--rp-border);overflow:auto;max-height:62vh;background:var(--rp-surface)} @@ -153,6 +161,7 @@ const STYLE = ` .rp-g-agent{border-radius:50%;background:var(--rp-agent)}.rp-g-manager{background:var(--rp-manager)} .rp-g-tool{border-radius:4px;background:var(--rp-tool);width:14px}.rp-g-human{background:var(--rp-human);transform:rotate(45deg) scale(.8)} .rp-root[data-theme="dark"] .rp-g{outline:2px solid var(--rp-outline);outline-offset:-1px} +@media (prefers-color-scheme:dark){.rp-root[data-theme="system"] .rp-g{outline:2px solid var(--rp-outline);outline-offset:-1px}} .rp-scrub{display:flex;align-items:center;gap:10px;padding:8px 14px;border-top:1px solid var(--rp-border);flex-wrap:wrap} .rp-scrub input[type=range]{flex:1;min-width:160px;accent-color:var(--rp-focus)} .rp-scrub output{font-variant-numeric:tabular-nums;color:var(--rp-dim);font-size:13px;min-width:12ch} @@ -206,6 +215,8 @@ const STYLE = ` .rp-svg .rp-arrow.rp-hl path{stroke-width:3.5} .rp-svg .rp-arrow text{font-size:11px;font-weight:600} .rp-svg .rp-labelbg{fill:var(--rp-bg);opacity:.92} +.rp-svg .rp-labels{pointer-events:none} +.rp-svg .rp-tip-bg{fill:var(--rp-text)}.rp-svg .rp-tip-text{fill:var(--rp-bg);font-size:12px;font-weight:600} .rp-svg .rp-cursor{stroke:var(--rp-focus);stroke-width:2} .rp-svg .rp-cursor-label{fill:var(--rp-focus);font-weight:700;font-size:11px} .rp-svg .rp-marker{fill:var(--rp-text)}.rp-svg .rp-marker-human{fill:var(--rp-human-ink)} @@ -223,11 +234,23 @@ const STYLE = ` .rp-svg .rp-edge.rp-e-uses path,.rp-svg .rp-edge.rp-e-assigned path,.rp-svg .rp-edge.rp-e-depends_on path{stroke-dasharray:3 3} .rp-svg .rp-edge.rp-e-reviews path{stroke:var(--rp-human-ink);stroke-dasharray:4 2} .rp-svg .rp-edge text{font-size:11px;fill:var(--rp-dim)} +.rp-svg .rp-group rect{fill:none;stroke:var(--rp-control);stroke-width:1.5;stroke-dasharray:6 4} +.rp-map{cursor:grab}.rp-map.rp-panning{cursor:grabbing} @media (prefers-reduced-motion:no-preference){ .rp-b-live .rp-dot{animation:rp-pulse 1.6s ease-in-out infinite} .rp-step,.rp-svg .rp-block,.rp-svg .rp-arrow{transition:opacity .15s ease} } @keyframes rp-pulse{0%,100%{opacity:1}50%{opacity:.35}} +@media (max-width:600px){ + .rp-root{border-radius:8px} + .rp-bar{padding:8px 10px} + .rp-title{max-width:100%;flex-basis:100%} + .rp-pending,.rp-scrub{padding:8px 10px} + .rp-drawer{padding:0 10px 12px} + .rp-tabs{margin:0 -10px 10px;padding:6px 10px 0} + .rp-hint{display:none} + .rp-step{grid-template-columns:50px 14px minmax(0,1fr)} +} @media (max-width:960px){ .rp-shell{grid-template-columns:minmax(0,1fr)} .rp-runs{max-height:220px;border-right:0;border-bottom:1px solid var(--rp-border)} @@ -246,35 +269,10 @@ function ensureStyle() { // ── theme ───────────────────────────────────────────────────────────── -function luminance(color) { - const c = String(color || '').trim(); - let rgb = null; - const hex = c.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i); - if (hex) { - const v = hex[1].length === 3 ? hex[1].split('').map((x) => x + x).join('') : hex[1]; - rgb = [0, 2, 4].map((i) => parseInt(v.slice(i, i + 2), 16)); - } else { - const m = c.match(/rgba?\(([^)]+)\)/i); - if (m) { - const parts = m[1].split(/[ ,/]+/).filter(Boolean).map(Number); - if (parts.length >= 4 && parts[3] === 0) return null; // transparent - rgb = parts.slice(0, 3); - } - } - if (!rgb || rgb.some((x) => Number.isNaN(x))) return null; - const lin = rgb.map((v) => { const x = v / 255; return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4; }); - return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2]; -} - +/** Theme mode: "system" (default, follows prefers-color-scheme), "light" or "dark". */ function detectTheme() { const saved = storageGet('rp-theme'); - if (saved === 'light' || saved === 'dark') return saved; - if (!S.solo) { - const css = getComputedStyle(document.documentElement).getPropertyValue('--db-bg').trim(); - const L = luminance(css) ?? luminance(getComputedStyle(document.body).backgroundColor); - if (L != null) return L < 0.35 ? 'dark' : 'light'; - } - try { return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; } catch { return 'light'; } + return saved === 'light' || saved === 'dark' ? saved : 'system'; } // ── URL ─────────────────────────────────────────────────────────────── @@ -379,6 +377,12 @@ export async function render(container, opts = {}) { else renderAll(); S.timers.push(setInterval(() => { refreshRuns(true); }, 10000)); } + try { + const mq = window.matchMedia('(prefers-color-scheme: dark)'); + const onScheme = () => { if (S && S.theme === 'system') queueRender(); }; + mq.addEventListener('change', onScheme); + S.unlisten = () => mq.removeEventListener('change', onScheme); + } catch { /* old browsers */ } if (typeof ResizeObserver !== 'undefined') { S.ro = new ResizeObserver(() => { if (!S) return; @@ -397,6 +401,7 @@ export function cleanup() { if (S.es) { try { S.es.close(); } catch { /* ignore */ } } for (const t of S.timers) clearInterval(t); if (S.ro) { try { S.ro.disconnect(); } catch { /* ignore */ } } + if (S.unlisten) { try { S.unlisten(); } catch { /* ignore */ } } S = null; } @@ -471,6 +476,8 @@ async function loadRun(id) { S.runId = id; S.nodeSel = null; S.asks = new Map(); + S.zoom = null; + S.mapZoom = null; writeRunToUrl(id); renderRunList(); let data; @@ -598,6 +605,8 @@ function renderAll(errorText) { return; } renderPending(); + // the map needs the width; its step list moves underneath + S.els.stage.classList.toggle('rp-wide', S.view === 'map'); if (S.view === 'map') renderMap(); else renderTimeline(); renderLegend(); renderNarration(); @@ -692,27 +701,31 @@ function buildBar(bar) { h('option', { value: 'latency', selected: S.heat === 'latency' }, 'Heat: latency')); bar.appendChild(sel); } - if (S.view === 'timeline') { - const z = h('div', { class: 'rp-seg', role: 'group', 'aria-label': 'Zoom' }); - z.append( - h('button', { type: 'button', class: 'rp-btn', 'aria-label': 'Zoom out', 'data-key': 'zoom-out', onclick: () => zoomBy(1 / 1.6) }, '−'), - h('button', { type: 'button', class: 'rp-btn', 'aria-label': 'Fit to width', 'data-key': 'zoom-fit', onclick: () => { S.zoom = null; renderAll(); } }, 'Fit'), - h('button', { type: 'button', class: 'rp-btn', 'aria-label': 'Zoom in', 'data-key': 'zoom-in', onclick: () => zoomBy(1.6) }, '+'), - ); - bar.appendChild(z); - } + const z = h('div', { class: 'rp-seg', role: 'group', 'aria-label': 'Zoom' }); + z.append( + h('button', { type: 'button', class: 'rp-btn', 'aria-label': 'Zoom out', 'data-key': 'zoom-out', onclick: () => zoomBy(1 / 1.5) }, '−'), + h('button', { type: 'button', class: 'rp-btn', 'data-key': 'zoom-fit', onclick: () => { if (S.view === 'map') S.mapZoom = null; else S.zoom = null; renderAll(); } }, 'Fit'), + h('button', { type: 'button', class: 'rp-btn', 'aria-label': 'Zoom in', 'data-key': 'zoom-in', onclick: () => zoomBy(1.5) }, '+'), + ); + bar.appendChild(z); if (S.run?.live && S.cursor < m.narration.length - 1) { bar.appendChild(h('button', { type: 'button', class: 'rp-btn', 'data-key': 'golive', onclick: () => { S.follow = true; setCursor(m.narration.length - 1); } }, 'Jump to live')); } - bar.appendChild(h('button', { - type: 'button', class: 'rp-btn', 'data-key': 'theme', 'aria-label': `Switch to ${S.theme === 'dark' ? 'light' : 'dark'} theme`, - onclick: () => { S.theme = S.theme === 'dark' ? 'light' : 'dark'; storageSet('rp-theme', S.theme); renderAll(); }, - }, S.theme === 'dark' ? 'Light' : 'Dark')); + bar.appendChild(h('select', { + class: 'rp-select', 'aria-label': 'Theme', 'data-key': 'theme', + onchange: (e) => { S.theme = e.target.value; storageSet('rp-theme', S.theme); renderAll(); }, + }, + h('option', { value: 'system', selected: S.theme === 'system' }, 'Theme: system'), + h('option', { value: 'light', selected: S.theme === 'light' }, 'Theme: light'), + h('option', { value: 'dark', selected: S.theme === 'dark' }, 'Theme: dark'))); } function zoomBy(f) { - const current = S.zoom || S.fitPps || 60; - S.zoom = Math.min(4000, Math.max(4, current * f)); + if (S.view === 'map') { + S.mapZoom = Math.min(2.5, Math.max(0.3, (S.mapZoom || S.mapFit || 1) * f)); + } else { + S.zoom = Math.min(4000, Math.max(4, (S.zoom || S.fitPps || 60) * f)); + } renderAll(); } @@ -929,6 +942,37 @@ function glyph(kind, x, y, size, cls = 'rp-glyph') { return s('rect', { class: cls, x, y, width: size, height: size, rx: 1 }); } +/** Lane rows: approvals/LLM calls on top, a thinner row of tool calls under them. */ +const ROW_H = { approval: 24, llm: 22, tool: 18 }; +const ROW_ORDER = ['approval', 'llm', 'tool']; +const LANE_PAD = 7; + +function gutterWidth(canvasWidth) { + return canvasWidth && canvasWidth < 640 ? 116 : GUTTER; +} + +/** Text that fits `px` at roughly `charPx` per character, with an ellipsis. */ +function fitLabel(full, px, charPx = 7) { + const text = String(full); + if (text.length * charPx <= px) return text; + const n = Math.floor((px - charPx) / charPx); + return n >= 3 ? `${text.slice(0, n).trimEnd()}…` : ''; +} + +/** Greedy interval packing in pixel space, so blocks never overlap. */ +function packTracks(list, x) { + const ends = []; + const track = new Map(); + for (const b of list) { + const left = x(b.start); + const right = Math.max(x(b.end), left + MIN_BLOCK) + 3; + let k = ends.findIndex((e) => e <= left); + if (k < 0) { ends.push(right); k = ends.length - 1; } else ends[k] = right; + track.set(b.id, k); + } + return { track, count: ends.length }; +} + function renderTimeline() { const m = S.model; const canvas = S.els.canvas; @@ -939,24 +983,45 @@ function renderTimeline() { S.tl = null; return; } - const avail = Math.max(260, (canvas.clientWidth || 900) - GUTTER - PAD_R); + const cw = canvas.clientWidth || 900; + const gutter = gutterWidth(cw); + const padR = cw < 640 ? 56 : PAD_R; + const avail = Math.max(cw < 640 ? 200 : 260, cw - gutter - padR); const dur = Math.max(m.duration, 0.05); S.fitPps = avail / dur; const pps = S.zoom || S.fitPps; const plotW = Math.max(dur * pps, 120); - const x = (t) => GUTTER + t * pps; + const x = (t) => gutter + t * pps; const chapterH = m.chapterRows * CH_ROW; const lanesTop = AXIS_H + chapterH + (chapterH ? 8 : 4); const rows = laneRows(); + const rowGap = S.overlay ? 12 : 4; + + // geometry: pack each lane's blocks per row kind, then stack the rows + const byLane = new Map(rows.map((l) => [l.id, { approval: [], llm: [], tool: [] }])); + for (const b of m.blocks) if (byLane.has(b.lane)) byLane.get(b.lane)[b.kind].push(b); const laneY = new Map(); + const geom = new Map(); let y = lanesTop; for (const lane of rows) { - const hgt = lane.parent ? SUB_H : LANE_H; + const groups = byLane.get(lane.id); + let cy = y + LANE_PAD; + let used = false; + for (const kind of ROW_ORDER) { + const list = groups[kind].slice().sort((a, b) => a.start - b.start); + if (!list.length) continue; + const { track, count } = packTracks(list, x); + for (const b of list) geom.set(b.id, { y: cy + track.get(b.id) * (ROW_H[kind] + rowGap), h: ROW_H[kind] }); + cy += count * (ROW_H[kind] + rowGap); + used = true; + } + const minH = lane.parent ? 32 : 40; + const hgt = Math.max(minH, (used ? cy - rowGap : cy) - y + LANE_PAD); laneY.set(lane.id, { top: y, h: hgt, mid: y + hgt / 2 }); y += hgt; } const height = y + 10; - const width = GUTTER + plotW + PAD_R; + const width = gutter + plotW + padR; S.tl = { x, laneY, width }; const svg = s('svg', { @@ -975,20 +1040,20 @@ function renderTimeline() { lanesG.appendChild(s('rect', { class: i % 2 ? 'rp-lanebg-alt' : 'rp-lanebg', x: 0, y: ly.top, width, height: ly.h })); const label = s('g', null); if (lane.parent) { - label.appendChild(s('text', { x: 30, y: ly.mid + 4, class: 'rp-dimtext', text: `↳ ${C().truncate(lane.label, 18)}` })); + label.appendChild(s('text', { x: 24, y: ly.mid + 4, class: 'rp-dimtext', text: `↳ ${fitLabel(lane.label, gutter - 44, 6.6)}` }, s('title', { text: lane.label }))); } else { label.appendChild(glyph(lane.kind, 10, ly.mid - 7, 14, `rp-body rp-f-${laneKindClass(lane)}`)); const tot = m.totals.agents[lane.id]; const showTot = S.overlay && tot && (tot.cost_usd || tot.busy_ms); - label.appendChild(s('text', { x: 32, y: showTot ? ly.mid - 3 : ly.mid + 4, 'font-weight': '600', text: C().truncate(lane.label, 20) })); + label.appendChild(s('text', { x: 32, y: showTot ? ly.mid - 3 : ly.mid + 4, 'font-weight': '600', text: fitLabel(lane.label, gutter - 40, 7.2) }, s('title', { text: lane.label }))); if (showTot) { const bits = [C().formatCost(tot.cost_usd), C().formatDuration(tot.busy_ms) && `${C().formatDuration(tot.busy_ms)} busy`].filter(Boolean).join(' · '); - label.appendChild(s('text', { x: 32, y: ly.mid + 12, class: 'rp-dimtext', 'font-size': '11', text: bits })); + label.appendChild(s('text', { x: 32, y: ly.mid + 12, class: 'rp-dimtext', 'font-size': '11', text: fitLabel(bits, gutter - 40, 6) })); } } lanesG.appendChild(label); }); - lanesG.appendChild(s('line', { class: 'rp-grid', x1: GUTTER - 0.5, x2: GUTTER - 0.5, y1: 0, y2: height })); + lanesG.appendChild(s('line', { class: 'rp-grid', x1: gutter - 0.5, x2: gutter - 0.5, y1: 0, y2: height })); svg.appendChild(lanesG); // axis ticks @@ -1005,59 +1070,70 @@ function renderTimeline() { const chG = s('g', { 'aria-hidden': 'true' }); for (const c of m.chapters) { const cx = x(c.start); - const cw = Math.max(x(c.end) - cx, 10); + const cwid = Math.max(x(c.end) - cx, 10); const cy = AXIS_H + c.row * CH_ROW; const prefix = c.kind === 'task' ? 'Task' : ({ parallel: 'Parallel', loop: 'Loop', repeat: 'Repeat', route: 'Route', if: 'If', discussion: 'Discussion' }[c.pattern] || 'Step'); - let text = `${prefix} ${c.label}${c.count > 1 ? ` ×${c.count}` : ''}${c.route ? ` → ${c.route}` : ''}${c.status === 'failed' ? ' (failed)' : ''}`; - const g = s('g', { class: 'rp-chapter', 'data-chapter': c.id, 'data-start': c.start }, - s('rect', { x: cx, y: cy + 2, width: cw, height: CH_ROW - 4, rx: 4 }), - cw > 30 ? clippedText(cx + 5, cy, cw - 8, CH_ROW, { x: 0, y: CH_ROW / 2 + 4, 'font-size': '11' }, text) : null, - s('title', { text })); - chG.appendChild(g); + const text = `${prefix} ${c.label}${c.count > 1 ? ` ×${c.count}` : ''}${c.route ? ` → ${c.route}` : ''}${c.status === 'failed' ? ' (failed)' : ''}`; + const shown = fitLabel(text, cwid - 10, 6.4); + chG.appendChild(s('g', { class: 'rp-chapter', 'data-chapter': c.id, 'data-start': c.start }, + s('rect', { x: cx, y: cy + 2, width: cwid, height: CH_ROW - 4, rx: 4 }), + shown ? clippedText(cx + 5, cy, cwid - 8, CH_ROW, { x: 0, y: CH_ROW / 2 + 4, 'font-size': '11' }, shown) : null, + s('title', { text }))); } svg.appendChild(chG); // blocks const maxHeat = Math.max(1e-12, ...m.blocks.filter((b) => b.kind !== 'approval').map((b) => (S.heat === 'cost' ? b.cost : b.durationMs))); const blocksG = s('g', { role: 'group', 'aria-label': 'Work blocks' }); + // Visible (possibly ellipsized) labels live in their own decorative layer, so + // each block's accessible name is its full label, not a cut-off word. + const labelsG = s('g', { class: 'rp-labels', 'aria-hidden': 'true' }); for (const b of m.blocks) { - const ly = laneY.get(b.lane); - if (!ly) continue; + const gm = geom.get(b.id); + if (!gm) continue; const lane = m.lanes.find((l) => l.id === b.lane); const owner = lane && lane.parent ? m.lanes.find((l) => l.id === lane.parent) : lane; const bx = x(b.start); const bw = Math.max(x(b.end) - bx, MIN_BLOCK); - const bh = ly.h - 16; - const by = ly.top + 8 - (S.overlay ? 3 : 0); + const { y: by, h: bh } = gm; const fillKind = b.kind === 'tool' ? 'tool' : (b.kind === 'approval' ? 'human' : (owner && owner.kind === 'manager' ? 'manager' : 'agent')); const cls = ['rp-block', `rp-k-${b.kind}`, `rp-o-${fillKind}`]; if (b.open) cls.push('rp-open'); const text = blockText(b) + (b.status === 'error' ? ' failed' : '') + (b.kind === 'approval' && b.status === 'pending' ? ' — waiting for you' : ''); const heatVal = S.heat === 'cost' ? C().formatCost(b.cost) : C().formatDuration(b.durationMs); const full = S.overlay && heatVal && b.kind !== 'approval' ? `${text} · ${heatVal}` : text; - const showText = bw - 24 >= 20; + const glyphW = bw >= 18 ? 18 : 4; + const shown = fitLabel(full, bw - glyphW - 4, b.kind === 'tool' ? 6.4 : 7); const g = s('g', { class: cls.join(' '), 'data-block': b.id, 'data-key': `block:${b.id}`, 'data-start': b.start, - role: 'button', tabindex: '-1', 'aria-label': showText ? `${full}. ${blockAria(b, lane)}` : blockAria(b, lane), + role: 'button', tabindex: '-1', 'aria-label': `${full}. ${blockAria(b, lane)}`, onclick: () => selectBlock(b), - ...hoverHandlers({ blockId: b.id, id: b.eventIds[0] }), + onmouseenter: () => { highlight({ blockId: b.id, id: b.eventIds[0] }); showTip(full, bx, by, bw); }, + onmouseleave: () => { highlight(null); hideTip(); }, + onfocus: () => { highlight({ blockId: b.id, id: b.eventIds[0] }); showTip(full, bx, by, bw); }, + onblur: () => { highlight(null); hideTip(); }, }); g.appendChild(s('rect', { class: 'rp-ring', x: bx - 3, y: by - 3, width: bw + 6, height: bh + 6, rx: 6 })); - g.appendChild(s('rect', { class: `rp-body rp-f-${fillKind}`, x: bx, y: by, width: bw, height: bh, rx: b.kind === 'tool' ? 9 : 3 })); + g.appendChild(s('rect', { class: `rp-body rp-f-${fillKind}`, x: bx, y: by, width: bw, height: bh, rx: b.kind === 'tool' ? bh / 2 : 3 })); const glyphKind = b.kind === 'tool' ? 'tool' : (b.kind === 'approval' ? 'human' : fillKind); if (bw >= 18) g.appendChild(glyph(glyphKind, bx + 5, by + bh / 2 - 5, 10)); - if (showText) g.appendChild(clippedText(bx + 20, by, bw - 24, bh, { class: 'rp-onfill', x: 0, y: bh / 2 + 4 }, full)); - else if (b.count > 1) g.appendChild(s('text', { x: bx + bw + 3, y: by + bh / 2 + 4, 'font-weight': '700', 'aria-hidden': 'true', text: `×${b.count}` })); + if (shown) { + labelsG.appendChild(s('g', { class: 'rp-label', 'data-start': b.start, 'data-label-for': b.id }, + clippedText(bx + glyphW, by, bw - glyphW - 2, bh, { class: 'rp-onfill', x: 0, y: bh / 2 + 4, 'font-size': b.kind === 'tool' ? '11' : '12' }, shown))); + } else if (b.count > 1) { + labelsG.appendChild(s('text', { x: bx + bw + 3, y: by + bh / 2 + 4, 'font-weight': '700', text: `×${b.count}` })); + } if (S.overlay && b.kind !== 'approval') { const v = S.heat === 'cost' ? b.cost : b.durationMs; const r = Math.min(1, v / maxHeat); - const hh = 2 + Math.round(6 * r); + const hh = 2 + Math.round(7 * r); g.appendChild(s('rect', { class: 'rp-heat', x: bx, y: by + bh + 2, width: bw, height: hh, fill: heatColor(r) })); } g.appendChild(s('title', { text: full })); blocksG.appendChild(g); } svg.appendChild(blocksG); + svg.appendChild(labelsG); // arrows const arrowsG = s('g', { 'aria-hidden': 'true' }); // told by the step list @@ -1082,16 +1158,38 @@ function renderTimeline() { } svg.appendChild(arrowsG); - // cursor + // cursor, then the hover/focus tip on top of everything const t = cursorTime(); svg.appendChild(s('g', { class: 'rp-cursor-g', transform: `translate(${x(t)},0)`, 'aria-hidden': 'true' }, s('line', { class: 'rp-cursor', x1: 0, x2: 0, y1: AXIS_H - 4, y2: height }), s('text', { class: 'rp-cursor-label', x: 4, y: AXIS_H + chapterH + 2, text: offset(t) }))); + svg.appendChild(s('g', { class: 'rp-tip', 'aria-hidden': 'true', visibility: 'hidden' }, + s('rect', { class: 'rp-tip-bg', rx: 4, height: 20 }), s('text', { class: 'rp-tip-text' }))); canvas.appendChild(svg); canvas.scrollLeft = scroll; applyCursor(); } +/** A visible full label for the hovered/focused block (titles only show on hover). */ +function showTip(text, bx, by, bw) { + const tip = S.els.canvas.querySelector('.rp-tip'); + if (!tip || !S.tl) return; + const tw = text.length * 6.8 + 12; + const x = Math.max(4, Math.min(bx + bw / 2 - tw / 2, S.tl.width - tw - 4)); + const y = Math.max(2, by - 24); + const bg = tip.querySelector('rect'); + bg.setAttribute('x', x); bg.setAttribute('y', y); bg.setAttribute('width', tw); + const label = tip.querySelector('text'); + label.setAttribute('x', x + 6); label.setAttribute('y', y + 14); + label.textContent = text; + tip.setAttribute('visibility', 'visible'); +} + +function hideTip() { + const tip = S && S.els.canvas.querySelector('.rp-tip'); + if (tip) tip.setAttribute('visibility', 'hidden'); +} + function niceStep(dur, plotW) { const target = dur / Math.max(2, Math.floor(plotW / 90)); const pow = 10 ** Math.floor(Math.log10(target || 1)); @@ -1116,29 +1214,29 @@ function selectBlock(b) { const KIND_WORD = { agent: 'agent', tool: 'tool', human: 'you', manager: 'manager', task: 'task', step: 'step', start: 'start', end: 'end', fork: 'fork', join: 'join', decision: 'decision' }; +/** The recorded graph completed with what happened (outside agents etc.), laid out. */ +function mapLayout() { + if (!S.layout) S.layout = C().layoutRunMap(S.graph, S.events, { nodeWidth: 136, gapX: 52, gapY: 22 }); + return S.layout; +} + function mapGraph() { - return S.graph && Array.isArray(S.graph.nodes) && S.graph.nodes.length ? S.graph : C().deriveGraph(S.events); + return mapLayout().graph; } function nodeVisits() { const counts = new Map(); - const lastAt = new Map(); const m = S.model; - m.events.forEach((ev, i) => { - for (const nid of C().nodeIdsForEvent(ev)) { - counts.set(nid, (counts.get(nid) || 0) + 1); - if (!lastAt.has(nid)) lastAt.set(nid, []); - lastAt.get(nid).push(i); - } - }); + for (const ev of m.events) { + for (const nid of C().nodeIdsForEvent(ev)) counts.set(nid, (counts.get(nid) || 0) + 1); + } // Brief/Delivered are reached when the run starts/ends, though no event names them. - const graph = mapGraph(); const ended = m.events.some((e) => e.event_type === 'workflow_end' || e.event_type === 'session_end'); - for (const n of graph.nodes || []) { + for (const n of mapGraph().nodes || []) { if (n.kind === 'start' && m.events.length && !counts.has(n.id)) counts.set(n.id, 1); if (n.kind === 'end' && ended && !counts.has(n.id)) counts.set(n.id, 1); } - return { counts, at: lastAt }; + return { counts }; } function shapePath(shape, x, y, w, h) { @@ -1163,31 +1261,42 @@ function nodeGlyph(node, gx, gy) { return s('rect', { class: cls, x: gx, y: gy, width: 22, height: 22, rx: 2 }); } +const MAP_MIN_SCALE = 0.55; // below this, labels stop being readable: scroll instead + function renderMap() { const canvas = S.els.canvas; + const prev = { left: canvas.scrollLeft, top: canvas.scrollTop }; clear(canvas); S.tl = null; - const graph = mapGraph(); - if (!S.layout) S.layout = C().layoutMap(graph); - const lay = S.layout; + const lay = mapLayout(); + const graph = lay.graph; if (!lay.nodes.length) { canvas.appendChild(h('div', { class: 'rp-empty' }, h('h2', { text: 'Nothing to map yet' }), h('p', { text: 'The map appears once agents start talking.' }))); return; } const visits = nodeVisits(); - const width = lay.width + 20; - const height = lay.height + 20; + const W = lay.width + 20; + const H = lay.height + 20; + const cw = Math.max(200, (canvas.clientWidth || 900) - 8); + const chMax = Math.max(240, Math.min(window.innerHeight * 0.62, 900) - 8); + S.mapFit = Math.max(MAP_MIN_SCALE, Math.min(1, cw / W, chMax / H)); + const scale = S.mapZoom || S.mapFit; const svg = s('svg', { - class: 'rp-svg', width, height, viewBox: `0 0 ${width} ${height}`, role: 'group', tabindex: '-1', - 'aria-label': `${graph.derived ? 'Who talked to whom' : 'Map of the run'}: ${lay.nodes.length} nodes and ${lay.edges.length} connections. Use up and down arrows to move between nodes.`, + class: 'rp-svg rp-map', width: Math.round(W * scale), height: Math.round(H * scale), viewBox: `0 0 ${W} ${H}`, role: 'group', tabindex: '-1', + 'aria-label': `${graph.derived ? 'Who talked to whom' : 'Map of the run'}: ${lay.nodes.length} nodes and ${lay.edges.length} connections. Use up and down arrows to move between nodes; drag to pan.`, }); svg.appendChild(s('defs', null, s('marker', { id: 'rp-mhead', viewBox: '0 0 10 10', refX: 9, refY: 5, markerWidth: 7, markerHeight: 7, orient: 'auto-start-reverse' }, s('path', { class: 'rp-marker', d: 'M0,0 L10,5 L0,10 z' })))); + for (const grp of lay.groups || []) { + svg.appendChild(s('g', { class: 'rp-group', 'aria-hidden': 'true' }, + s('rect', { x: grp.x, y: grp.y, width: grp.w, height: grp.h, rx: 10 }), + s('text', { x: grp.x + 10, y: grp.y - 6, 'font-weight': '600', text: grp.label }))); + } const edgesG = s('g', { 'aria-hidden': 'true' }); for (const e of lay.edges) { const p = e.points; let d = `M${p[0][0]},${p[0][1]}`; - if (e.back) { + if (e.back || e.cross) { d += p.slice(1).map((q) => ` L${q[0]},${q[1]}`).join(''); } else { for (let j = 1; j < p.length; j++) { @@ -1205,8 +1314,9 @@ function renderMap() { s('text', { x: mid[0], y: mid[1], 'text-anchor': 'middle', text }))); } svg.appendChild(edgesG); - const nodesG = s('g', { role: 'group', 'aria-label': 'Nodes' }); - const ordered = [...lay.nodes].sort((a, b) => a.layer - b.layer || a.order - b.order); + const ordered = [...lay.nodes].sort((a, b) => Number(Boolean(a.outside)) - Number(Boolean(b.outside)) || a.layer - b.layer || a.order - b.order); + const groups = { main: s('g', { role: 'group', 'aria-label': 'Nodes' }), outside: null }; + if ((lay.groups || []).length) groups.outside = s('g', { role: 'group', 'aria-label': lay.groups[0].label }); ordered.forEach((n, i) => { const count = visits.counts.get(n.id) || 0; const word = KIND_WORD[n.kind] || n.kind; @@ -1224,16 +1334,39 @@ function renderMap() { g.appendChild(path ? s('path', { class: 'rp-card', d: path }) : s('rect', { class: 'rp-card', x: n.x, y: n.y, width: n.w, height: n.h, rx: n.kind === 'tool' ? 14 : (n.kind === 'end' ? 2 : 6) })); if (n.kind === 'end') g.appendChild(s('rect', { class: 'rp-card', x: n.x + 3, y: n.y + 3, width: n.w - 6, height: n.h - 6, rx: 2, fill: 'none' })); g.appendChild(nodeGlyph(n, n.x + 12, n.y + n.h / 2 - 11)); - g.appendChild(clippedText(n.x + 44, n.y, n.w - 50, n.h, { x: 0, y: n.h / 2 - 2, 'font-weight': '600' }, n.label)); + g.appendChild(s('text', { x: n.x + 44, y: n.y + n.h / 2 - 2, 'font-weight': '600', 'aria-hidden': 'true', text: fitLabel(n.label, n.w - 52, 7.2) })); g.appendChild(s('text', { class: 'rp-kindword', x: n.x + 44, y: n.y + n.h / 2 + 13, 'aria-hidden': 'true', text: ` ${kindLine}` })); g.appendChild(s('title', { text: name })); - nodesG.appendChild(g); + (n.outside && groups.outside ? groups.outside : groups.main).appendChild(g); }); - svg.appendChild(nodesG); + svg.appendChild(groups.main); + if (groups.outside) svg.appendChild(groups.outside); + enablePan(svg, canvas); canvas.appendChild(svg); + canvas.scrollLeft = prev.left; + canvas.scrollTop = prev.top; applyMapCursor(); } +/** Drag the empty canvas to pan (the canvas box scrolls; nodes stay clickable). */ +function enablePan(svg, box) { + let drag = null; + svg.addEventListener('pointerdown', (e) => { + if (e.button !== 0 || (e.target.closest && e.target.closest('.rp-node'))) return; + drag = { x: e.clientX, y: e.clientY, left: box.scrollLeft, top: box.scrollTop }; + try { svg.setPointerCapture(e.pointerId); } catch { /* ignore */ } + svg.classList.add('rp-panning'); + }); + svg.addEventListener('pointermove', (e) => { + if (!drag) return; + box.scrollLeft = drag.left - (e.clientX - drag.x); + box.scrollTop = drag.top - (e.clientY - drag.y); + }); + const end = () => { drag = null; svg.classList.remove('rp-panning'); }; + svg.addEventListener('pointerup', end); + svg.addEventListener('pointercancel', end); +} + function applyMapCursor() { const item = S.model.narration[S.cursor]; const ev = item ? S.model.events[S.model.eventIndex.get(item.id)] : null; @@ -1710,9 +1843,14 @@ async function decide(b, action, withArgs) { } // ── standalone boot ─────────────────────────────────────────────────── +// +// Contract (stable): an HTML page sets `window.__REPLAY_DATA__ = {events, graph, +// run_id?, title?}` and inlines replay-core.js then replay.js as module +// script elements; the view mounts into #replay-root (or a new main element in +// the body), replacing whatever fallback markup is there. No network. function bootStandalone() { - if (globalThis.__REPLAY_BOOTED__) return; + if (globalThis.__REPLAY_BOOTED__ || !document.body) return; globalThis.__REPLAY_BOOTED__ = true; const data = globalThis.__REPLAY_DATA__ || {}; let mount = document.getElementById('replay-root'); @@ -1721,11 +1859,18 @@ function bootStandalone() { mount.id = 'replay-root'; document.body.appendChild(mount); } - mount.style.padding = '12px'; - render(mount, { standalone: true, events: data.events || [], graph: data.graph || null, title: data.title || document.title }); + mount.style.padding = '16px'; + mount.style.boxSizing = 'border-box'; + mount.style.maxWidth = '100%'; + render(mount, { standalone: true, events: data.events || [], graph: data.graph || null, title: data.title || data.run_id || document.title }); } if (typeof window !== 'undefined' && typeof document !== 'undefined' && globalThis.__REPLAY_DATA__ && !(window.aiui && window.aiui.selectPage)) { - if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', bootStandalone); - else bootStandalone(); + // Module scripts run after parsing, before DOMContentLoaded; waiting for it + // also lets an inlined replay-core.js placed after this file register first. + if (document.readyState === 'complete') bootStandalone(); + else { + document.addEventListener('DOMContentLoaded', bootStandalone, { once: true }); + window.addEventListener('load', bootStandalone, { once: true }); + } } diff --git a/tests/browser/test_replay_browser.py b/tests/browser/test_replay_browser.py index b4f0b47d..b66e30d0 100644 --- a/tests/browser/test_replay_browser.py +++ b/tests/browser/test_replay_browser.py @@ -87,7 +87,7 @@ def _current_index(page) -> int: return int(page.get_attribute('.rp-step[aria-current="step"]', "data-i")) -@pytest.mark.parametrize("name", ["team_handoff", "flow_parallel_loop", "legacy_v10"]) +@pytest.mark.parametrize("name", ["team_handoff", "flow_parallel_loop", "legacy_v10", "team_outside"]) def test_standalone_steps_with_arrow_keys(browser, tmp_path, name): page, errors = _page(browser) page.goto(_standalone(tmp_path, name)) @@ -154,7 +154,7 @@ def test_standalone_drawer_tabs_map_and_axe(browser, tmp_path): violations = _axe(page) assert violations == [], json.dumps(violations, indent=2) - page.click("[data-key='theme']") # dark + page.select_option("[data-key='theme']", "dark") assert page.get_attribute(".rp-root", "data-theme") == "dark" violations = _axe(page) assert violations == [], json.dumps(violations, indent=2) @@ -174,6 +174,154 @@ def test_reduced_motion_has_no_animation(browser, tmp_path): assert names == [] +def _boxes(page, selector: str) -> list[dict]: + return page.eval_on_selector_all( + selector, + "els => els.map(e => { const r = e.getBoundingClientRect(); return {x: r.x, y: r.y, w: r.width, h: r.height, id: e.closest('[data-block]')?.getAttribute('data-block')}; })", + ) + + +@pytest.mark.parametrize("name", ["team_outside", "team_handoff", "flow_parallel_loop"]) +def test_timeline_blocks_never_overlap(browser, tmp_path, name): + """An LLM span encloses its tool calls in real runs; tools go on their own row.""" + page, errors = _page(browser) + page.goto(_standalone(tmp_path, name)) + page.wait_for_selector(".rp-block") + boxes = _boxes(page, ".rp-block .rp-body") + for i, a in enumerate(boxes): + for b in boxes[i + 1:]: + apart = a["x"] + a["w"] <= b["x"] + 0.5 or b["x"] + b["w"] <= a["x"] + 0.5 or a["y"] + a["h"] <= b["y"] + 0.5 or b["y"] + b["h"] <= a["y"] + 0.5 + assert apart, f"{a['id']} overlaps {b['id']}" + # a label that does not fit is ellipsized, and the full text is in the title and name + llm = page.locator(".rp-block.rp-k-llm").first + name_attr = llm.get_attribute("aria-label") + title = llm.locator("title").text_content() + assert name_attr.startswith(title) + llm.focus() + assert page.locator(".rp-tip").get_attribute("visibility") == "visible" + assert page.locator(".rp-tip text").text_content() == title + assert not errors, errors + + +def test_why_tab_by_role_and_keyboard(browser, tmp_path): + page, errors = _page(browser) + page.goto(_standalone(tmp_path, "team_outside")) + page.wait_for_selector(".rp-step") + page.locator(".rp-step", has_text="Triage handed off to Billing").click() + page.get_by_role("tab", name="Why").click() + assert page.get_by_role("tab", name="Why").get_attribute("aria-selected") == "true" + why = page.get_by_role("tabpanel").inner_text() + assert "Hand-off reason" in why and "Billing question" in why and "Context passed" in why + # the task input + page.locator(".rp-step", has_text="Task 'research' went to Researcher").click() + assert "Task description" in page.get_by_role("tabpanel").inner_text() + assert "Get one fact about electric vehicles" in page.get_by_role("tabpanel").inner_text() + # keyboard: focus the selected tab, arrow keys move between tabs + page.get_by_role("tab", name="Why").focus() + page.keyboard.press("ArrowLeft") + assert page.get_by_role("tab", name="What happened").get_attribute("aria-selected") == "true" + page.keyboard.press("ArrowRight") + assert page.get_by_role("tab", name="Why").get_attribute("aria-selected") == "true" + assert page.evaluate("document.activeElement.getAttribute('role')") == "tab" + # route chosen and approval decision + page.goto(_standalone(tmp_path, "flow_parallel_loop")) + page.wait_for_selector(".rp-step") + page.locator(".rp-step", has_text="chose route 'publish'").click() + page.get_by_role("tab", name="Why").click() + assert "Route chosen" in page.get_by_role("tabpanel").inner_text() + page.goto(_standalone(tmp_path, "team_handoff")) + page.wait_for_selector(".rp-step") + page.locator(".rp-step", has_text="You approved write_file").click() + page.get_by_role("tab", name="Why").click() + panel = page.get_by_role("tabpanel").inner_text() + assert "Decision" in panel and "Approved with edited arguments" in panel + assert not errors, errors + + +def test_theme_follows_system_with_override(browser, tmp_path): + url = _standalone(tmp_path, "team_handoff") + bg = "getComputedStyle(document.querySelector('.rp-root')).backgroundColor" + page, _ = _page(browser, scheme="dark") + page.goto(url) + page.wait_for_selector(".rp-step") + assert page.get_attribute(".rp-root", "data-theme") == "system" + assert page.evaluate(bg) == "rgb(22, 23, 27)" + page.select_option("[data-key='theme']", "light") + assert page.evaluate(bg) == "rgb(255, 255, 255)" + page.close() + page, _ = _page(browser, scheme="light") + page.goto(url) + page.wait_for_selector(".rp-step") + assert page.evaluate(bg) == "rgb(255, 255, 255)" + + +def test_map_fits_on_open_and_groups_outside_agents(browser, tmp_path): + page, errors = _page(browser) + page.goto(_standalone(tmp_path, "team_outside")) + page.wait_for_selector(".rp-step") + page.get_by_role("button", name="Map").click() + page.wait_for_selector(".rp-node") + box = page.evaluate("(() => { const c = document.querySelector('.rp-canvas'); const r = c.getBoundingClientRect(); return {l: r.left, r: r.right, sw: c.scrollWidth, cw: c.clientWidth}; })()") + assert box["sw"] <= box["cw"] + 1, "map overflows on open" + for n in _boxes(page, ".rp-node .rp-card"): + assert box["l"] - 1 <= n["x"] and n["x"] + n["w"] <= box["r"] + 1 + assert "Outside the team" in page.locator(".rp-group text").text_content() + outside = page.get_by_role("group", name="Outside the team") + assert outside.locator(".rp-node").count() == 3 + labels = page.eval_on_selector_all(".rp-edge text", "els => els.map(e => e.textContent)") + assert "hands off" in labels + before = float(page.get_attribute(".rp-canvas svg", "width")) + page.get_by_role("button", name="Zoom in").click() + assert float(page.get_attribute(".rp-canvas svg", "width")) == pytest.approx(before * 1.5, abs=2) + page.get_by_role("button", name="Fit").click() + assert float(page.get_attribute(".rp-canvas svg", "width")) == pytest.approx(before, abs=2) + assert not errors, errors + + +@pytest.mark.parametrize("name", ["team_outside", "flow_parallel_loop"]) +def test_phone_width_has_no_page_scroll(browser, tmp_path, name): + page, errors = _page(browser) + page.set_viewport_size({"width": 400, "height": 860}) + page.goto(_standalone(tmp_path, name)) + page.wait_for_selector(".rp-step") + assert page.evaluate("document.documentElement.scrollWidth") <= 400 + r = page.evaluate("(() => { const b = document.querySelector('.rp-root').getBoundingClientRect(); return [b.left, b.right]; })()") + assert r[0] == pytest.approx(16, abs=1) and r[1] == pytest.approx(384, abs=1) + page.get_by_role("button", name="Map").click() + assert page.evaluate("document.documentElement.scrollWidth") <= 400 + assert not errors, errors + + +@pytest.mark.parametrize("order", ["core-first", "view-first"]) +def test_wrapper_style_export_boots(browser, tmp_path, order): + """The wrapper inlines both files as module scripts into #replay-root (which holds + a fallback table) after window.__REPLAY_DATA__; the view must replace it.""" + views = ROOT / "src" / "praisonaiui" / "templates" / "frontend" / "plugins" / "views" + core = (views / "replay-core.js").read_text(encoding="utf-8").replace("PraisonAI Replay: x" + "
fallback
" + f"" + + "".join(f"" for js in scripts) + + "" + ) + out = tmp_path / f"export-{order}.html" + out.write_text(html, encoding="utf-8") + page, errors = _page(browser) + page.goto(out.as_uri()) + page.wait_for_selector(".rp-step") + assert page.locator("#replay-root table").count() == 0 + assert page.locator(".rp-title").inner_text() == "team-outside-01" + # graph:null -> taken from workflow_start.data.graph + page.get_by_role("button", name="Map").click() + assert page.locator(".rp-node").count() == 10 + assert page.locator(".rp-ask").count() == 0 + assert not errors, errors + + # ── dashboard + live run + approval through the You lane ───────────── diff --git a/tests/fixtures/replay/make_fixtures.py b/tests/fixtures/replay/make_fixtures.py index 5ea4fbd0..be2599e4 100644 --- a/tests/fixtures/replay/make_fixtures.py +++ b/tests/fixtures/replay/make_fixtures.py @@ -306,7 +306,83 @@ def legacy_v10() -> None: r.write("legacy_v10.jsonl") +def team_outside() -> None: + """Shape of a real recorded run: each agent's LLM span encloses its tool + calls (llm_request -> tool -> llm_response), and after the team finishes a + Triage agent hands off to Billing, neither of which is in the team graph. + """ + r = _Run("sess-outside-01", "team-outside-01") + graph = { + "schema": "praisonai.graph/1", "kind": "team", "name": "EV brief", "process": "sequential", + "nodes": [ + {"id": "start:begin", "kind": "start", "label": "Brief", "parent": None, "meta": {}}, + {"id": "task:research", "kind": "task", "label": "research", "parent": None, "meta": {}}, + {"id": "task:write", "kind": "task", "label": "write", "parent": None, "meta": {}}, + {"id": "agent:Researcher", "kind": "agent", "label": "Researcher", "parent": None, "meta": {}}, + {"id": "agent:Writer", "kind": "agent", "label": "Writer", "parent": None, "meta": {}}, + {"id": "tool:Researcher/lookup_fact", "kind": "tool", "label": "lookup_fact", "parent": None, "meta": {}}, + {"id": "end:done", "kind": "end", "label": "Delivered", "parent": None, "meta": {}}, + ], + "edges": [ + {"id": "start:begin->task:research:next", "source": "start:begin", "target": "task:research", "kind": "next", "label": None}, + {"id": "task:research->task:write:next", "source": "task:research", "target": "task:write", "kind": "next", "label": None}, + {"id": "task:research->agent:Researcher:assigned", "source": "task:research", "target": "agent:Researcher", "kind": "assigned", "label": None}, + {"id": "task:write->agent:Writer:assigned", "source": "task:write", "target": "agent:Writer", "kind": "assigned", "label": None}, + {"id": "agent:Researcher->tool:Researcher/lookup_fact:uses", "source": "agent:Researcher", "target": "tool:Researcher/lookup_fact", "kind": "uses", "label": None}, + {"id": "task:write->end:done:next", "source": "task:write", "target": "end:done", "kind": "next", "label": None}, + ], + } + s0 = r.emit("session_start", 0.0, "", {}) + w = r.emit("workflow_start", 0.01, "", {"kind": "team", "name": "EV brief", "process": "sequential", "graph": graph}, parent_seq=s0) + t1 = r.emit("task_start", 0.02, "Researcher", {"name": "research", "agent": "Researcher", + "description_preview": "Get one fact about electric vehicles"}, + parent_seq=w, node_id="task:research") + a1 = r.emit("agent_start", 0.03, "Researcher", {}, parent_seq=t1, node_id="agent:Researcher") + r.emit("llm_request", 0.05, "Researcher", {"model": "gpt-4o-mini"}, parent_seq=a1, node_id="agent:Researcher") + ts = r.emit("tool_call_start", 2.6, "Researcher", {"tool_name": "lookup_fact", "tool_args": {"topic": "electric vehicles"}}, + parent_seq=a1, node_id="tool:Researcher/lookup_fact") + r.emit("tool_call_end", 3.5, "Researcher", {"tool_name": "lookup_fact", "result": "EV sales passed 17 million in 2024", + "duration_ms": 880.0, "error": None}, + parent_seq=ts, node_id="tool:Researcher/lookup_fact") + r.emit("llm_response", 5.0, "Researcher", {"response_tokens": 14, "duration_ms": 4950.0, "model": "gpt-4o-mini", + "response_content": "EV sales passed 17 million in 2024."}, + parent_seq=a1, node_id="agent:Researcher", prompt_tokens=310, completion_tokens=14, cost_usd=0.000055) + r.emit("agent_end", 5.01, "Researcher", {}, parent_seq=a1, node_id="agent:Researcher") + r.emit("task_end", 5.02, "Researcher", {"status": "completed", "retries": 0, "duration_ms": 5000.0, + "output_preview": "EV sales passed 17 million in 2024."}, parent_seq=t1, node_id="task:research") + t2 = r.emit("task_start", 5.03, "Writer", {"name": "write", "agent": "Writer", + "description_preview": "Write one sentence using the fact"}, + parent_seq=w, node_id="task:write") + a2 = r.emit("agent_start", 5.04, "Writer", {}, parent_seq=t2, node_id="agent:Writer") + r.llm(5.05, "Writer", 1.05, a2, prompt=200, completion=25, cost=0.000045, node="agent:Writer", + content="Global EV sales passed 17 million in 2024.") + r.emit("agent_end", 6.11, "Writer", {}, parent_seq=a2, node_id="agent:Writer") + r.emit("task_end", 6.12, "Writer", {"status": "completed", "retries": 0, "duration_ms": 1090.0, + "output_preview": "Global EV sales passed 17 million in 2024."}, parent_seq=t2, node_id="task:write") + r.emit("workflow_end", 6.13, "", {"status": "completed", "duration_ms": 6120.0}, parent_seq=w) + a3 = r.emit("agent_start", 6.2, "Triage", {}, parent_seq=s0, node_id="agent:Triage") + r.emit("llm_request", 6.25, "Triage", {"model": "gpt-4o-mini"}, parent_seq=a3, node_id="agent:Triage") + tt = r.emit("tool_call_start", 7.7, "Triage", {"tool_name": "transfer_to_billing", "tool_args": {}}, + parent_seq=a3, node_id="tool:Triage/transfer_to_billing") + r.emit("agent_handoff", 7.71, "Triage", {"from_agent": "Triage", "to_agent": "Billing", "kind": "handoff", + "reason": "Billing question", "context_passed": {"context_policy": "summary"}}, + parent_seq=tt, node_id="agent:Billing") + a4 = r.emit("agent_start", 7.72, "Billing", {}, parent_seq=tt, node_id="agent:Billing") + r.llm(7.75, "Billing", 0.92, a4, prompt=150, completion=19, cost=0.00003, node="agent:Billing", + content="Please check your statement for a duplicate charge.") + r.emit("agent_end", 8.68, "Billing", {}, parent_seq=a4, node_id="agent:Billing") + r.emit("tool_call_end", 8.7, "Triage", {"tool_name": "transfer_to_billing", "result": "Handoff successful", + "duration_ms": 1000.0, "error": None}, + parent_seq=tt, node_id="tool:Triage/transfer_to_billing") + r.emit("llm_response", 9.6, "Triage", {"response_tokens": 22, "duration_ms": 3350.0, "model": "gpt-4o-mini"}, + parent_seq=a3, node_id="agent:Triage", prompt_tokens=240, completion_tokens=22, cost_usd=0.00005) + r.emit("agent_end", 9.61, "Triage", {}, parent_seq=a3, node_id="agent:Triage") + r.emit("session_end", 9.7, "", {}, parent_seq=s0) + r.write("team_outside.jsonl") + + if __name__ == "__main__": team_handoff() flow_parallel_loop() legacy_v10() + team_outside() diff --git a/tests/fixtures/replay/team_outside.jsonl b/tests/fixtures/replay/team_outside.jsonl new file mode 100644 index 00000000..43113277 --- /dev/null +++ b/tests/fixtures/replay/team_outside.jsonl @@ -0,0 +1,29 @@ +{"schema_version":"1.1","event_type":"session_start","timestamp":1767225600.0,"session_id":"sess-outside-01","agent_name":"","sequence_num":0,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01"} +{"schema_version":"1.1","event_type":"workflow_start","timestamp":1767225600.01,"session_id":"sess-outside-01","agent_name":"","sequence_num":1,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"kind":"team","name":"EV brief","process":"sequential","graph":{"schema":"praisonai.graph/1","kind":"team","name":"EV brief","process":"sequential","nodes":[{"id":"start:begin","kind":"start","label":"Brief","parent":null,"meta":{}},{"id":"task:research","kind":"task","label":"research","parent":null,"meta":{}},{"id":"task:write","kind":"task","label":"write","parent":null,"meta":{}},{"id":"agent:Researcher","kind":"agent","label":"Researcher","parent":null,"meta":{}},{"id":"agent:Writer","kind":"agent","label":"Writer","parent":null,"meta":{}},{"id":"tool:Researcher/lookup_fact","kind":"tool","label":"lookup_fact","parent":null,"meta":{}},{"id":"end:done","kind":"end","label":"Delivered","parent":null,"meta":{}}],"edges":[{"id":"start:begin->task:research:next","source":"start:begin","target":"task:research","kind":"next","label":null},{"id":"task:research->task:write:next","source":"task:research","target":"task:write","kind":"next","label":null},{"id":"task:research->agent:Researcher:assigned","source":"task:research","target":"agent:Researcher","kind":"assigned","label":null},{"id":"task:write->agent:Writer:assigned","source":"task:write","target":"agent:Writer","kind":"assigned","label":null},{"id":"agent:Researcher->tool:Researcher/lookup_fact:uses","source":"agent:Researcher","target":"tool:Researcher/lookup_fact","kind":"uses","label":null},{"id":"task:write->end:done:next","source":"task:write","target":"end:done","kind":"next","label":null}]}},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":0} +{"schema_version":"1.1","event_type":"task_start","timestamp":1767225600.02,"session_id":"sess-outside-01","agent_name":"Researcher","sequence_num":2,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"name":"research","agent":"Researcher","description_preview":"Get one fact about electric vehicles"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":1,"node_id":"task:research"} +{"schema_version":"1.1","event_type":"agent_start","timestamp":1767225600.03,"session_id":"sess-outside-01","agent_name":"Researcher","sequence_num":3,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":2,"node_id":"agent:Researcher"} +{"schema_version":"1.1","event_type":"llm_request","timestamp":1767225600.05,"session_id":"sess-outside-01","agent_name":"Researcher","sequence_num":4,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"model":"gpt-4o-mini"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":3,"node_id":"agent:Researcher"} +{"schema_version":"1.1","event_type":"tool_call_start","timestamp":1767225602.6,"session_id":"sess-outside-01","agent_name":"Researcher","sequence_num":5,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"lookup_fact","tool_args":{"topic":"electric vehicles"}},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":3,"node_id":"tool:Researcher/lookup_fact"} +{"schema_version":"1.1","event_type":"tool_call_end","timestamp":1767225603.5,"session_id":"sess-outside-01","agent_name":"Researcher","sequence_num":6,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"lookup_fact","result":"EV sales passed 17 million in 2024","duration_ms":880.0,"error":null},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":5,"node_id":"tool:Researcher/lookup_fact"} +{"schema_version":"1.1","event_type":"llm_response","timestamp":1767225605.0,"session_id":"sess-outside-01","agent_name":"Researcher","sequence_num":7,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"response_tokens":14,"duration_ms":4950.0,"model":"gpt-4o-mini","response_content":"EV sales passed 17 million in 2024."},"prompt_tokens":310,"completion_tokens":14,"cost_usd":5.5e-05,"run_id":"team-outside-01","parent_seq":3,"node_id":"agent:Researcher"} +{"schema_version":"1.1","event_type":"agent_end","timestamp":1767225605.01,"session_id":"sess-outside-01","agent_name":"Researcher","sequence_num":8,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":3,"node_id":"agent:Researcher"} +{"schema_version":"1.1","event_type":"task_end","timestamp":1767225605.02,"session_id":"sess-outside-01","agent_name":"Researcher","sequence_num":9,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed","retries":0,"duration_ms":5000.0,"output_preview":"EV sales passed 17 million in 2024."},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":2,"node_id":"task:research"} +{"schema_version":"1.1","event_type":"task_start","timestamp":1767225605.03,"session_id":"sess-outside-01","agent_name":"Writer","sequence_num":10,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"name":"write","agent":"Writer","description_preview":"Write one sentence using the fact"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":1,"node_id":"task:write"} +{"schema_version":"1.1","event_type":"agent_start","timestamp":1767225605.04,"session_id":"sess-outside-01","agent_name":"Writer","sequence_num":11,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":10,"node_id":"agent:Writer"} +{"schema_version":"1.1","event_type":"llm_request","timestamp":1767225605.05,"session_id":"sess-outside-01","agent_name":"Writer","sequence_num":12,"messages_count":3,"tokens_used":200,"tokens_budget":128000,"data":{"model":"gpt-4o-mini"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":11,"node_id":"agent:Writer"} +{"schema_version":"1.1","event_type":"llm_response","timestamp":1767225606.1,"session_id":"sess-outside-01","agent_name":"Writer","sequence_num":13,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"response_tokens":25,"duration_ms":1050.0,"finish_reason":"stop","model":"gpt-4o-mini","response_content":"Global EV sales passed 17 million in 2024."},"prompt_tokens":200,"completion_tokens":25,"cost_usd":4.5e-05,"run_id":"team-outside-01","parent_seq":11,"node_id":"agent:Writer"} +{"schema_version":"1.1","event_type":"agent_end","timestamp":1767225606.11,"session_id":"sess-outside-01","agent_name":"Writer","sequence_num":14,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":11,"node_id":"agent:Writer"} +{"schema_version":"1.1","event_type":"task_end","timestamp":1767225606.12,"session_id":"sess-outside-01","agent_name":"Writer","sequence_num":15,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed","retries":0,"duration_ms":1090.0,"output_preview":"Global EV sales passed 17 million in 2024."},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":10,"node_id":"task:write"} +{"schema_version":"1.1","event_type":"workflow_end","timestamp":1767225606.13,"session_id":"sess-outside-01","agent_name":"","sequence_num":16,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed","duration_ms":6120.0},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":1} +{"schema_version":"1.1","event_type":"agent_start","timestamp":1767225606.2,"session_id":"sess-outside-01","agent_name":"Triage","sequence_num":17,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":0,"node_id":"agent:Triage"} +{"schema_version":"1.1","event_type":"llm_request","timestamp":1767225606.25,"session_id":"sess-outside-01","agent_name":"Triage","sequence_num":18,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"model":"gpt-4o-mini"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":17,"node_id":"agent:Triage"} +{"schema_version":"1.1","event_type":"tool_call_start","timestamp":1767225607.7,"session_id":"sess-outside-01","agent_name":"Triage","sequence_num":19,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"transfer_to_billing","tool_args":{}},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":17,"node_id":"tool:Triage/transfer_to_billing"} +{"schema_version":"1.1","event_type":"agent_handoff","timestamp":1767225607.71,"session_id":"sess-outside-01","agent_name":"Triage","sequence_num":20,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"from_agent":"Triage","to_agent":"Billing","kind":"handoff","reason":"Billing question","context_passed":{"context_policy":"summary"}},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":19,"node_id":"agent:Billing"} +{"schema_version":"1.1","event_type":"agent_start","timestamp":1767225607.72,"session_id":"sess-outside-01","agent_name":"Billing","sequence_num":21,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":19,"node_id":"agent:Billing"} +{"schema_version":"1.1","event_type":"llm_request","timestamp":1767225607.75,"session_id":"sess-outside-01","agent_name":"Billing","sequence_num":22,"messages_count":3,"tokens_used":150,"tokens_budget":128000,"data":{"model":"gpt-4o-mini"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":21,"node_id":"agent:Billing"} +{"schema_version":"1.1","event_type":"llm_response","timestamp":1767225608.67,"session_id":"sess-outside-01","agent_name":"Billing","sequence_num":23,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"response_tokens":19,"duration_ms":920.0,"finish_reason":"stop","model":"gpt-4o-mini","response_content":"Please check your statement for a duplicate charge."},"prompt_tokens":150,"completion_tokens":19,"cost_usd":3e-05,"run_id":"team-outside-01","parent_seq":21,"node_id":"agent:Billing"} +{"schema_version":"1.1","event_type":"agent_end","timestamp":1767225608.68,"session_id":"sess-outside-01","agent_name":"Billing","sequence_num":24,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":21,"node_id":"agent:Billing"} +{"schema_version":"1.1","event_type":"tool_call_end","timestamp":1767225608.7,"session_id":"sess-outside-01","agent_name":"Triage","sequence_num":25,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"transfer_to_billing","result":"Handoff successful","duration_ms":1000.0,"error":null},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":19,"node_id":"tool:Triage/transfer_to_billing"} +{"schema_version":"1.1","event_type":"llm_response","timestamp":1767225609.6,"session_id":"sess-outside-01","agent_name":"Triage","sequence_num":26,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"response_tokens":22,"duration_ms":3350.0,"model":"gpt-4o-mini"},"prompt_tokens":240,"completion_tokens":22,"cost_usd":5e-05,"run_id":"team-outside-01","parent_seq":17,"node_id":"agent:Triage"} +{"schema_version":"1.1","event_type":"agent_end","timestamp":1767225609.61,"session_id":"sess-outside-01","agent_name":"Triage","sequence_num":27,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":17,"node_id":"agent:Triage"} +{"schema_version":"1.1","event_type":"session_end","timestamp":1767225609.7,"session_id":"sess-outside-01","agent_name":"","sequence_num":28,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":0} diff --git a/tests/js/replay-core.test.mjs b/tests/js/replay-core.test.mjs index 65b217bc..42427ade 100644 --- a/tests/js/replay-core.test.mjs +++ b/tests/js/replay-core.test.mjs @@ -343,3 +343,57 @@ test('deriveGraph counts repeats and includes you for approvals', () => { test('module also registers itself on globalThis for the standalone build', () => { assert.equal(typeof globalThis.PraisonReplayCore.buildModel, 'function'); }); + +// ── observed graph / run map ───────────────────────────────────────── + +test('observedGraph: agents seen in events but not in the graph are "outside"', () => { + const { events, graph } = load('team_outside'); + const g = core.observedGraph(graph, events); + assert.deepEqual([...g.outside].sort(), ['agent:Billing', 'agent:Triage', 'tool:Triage/transfer_to_billing']); + // team members keep their recorded ids; nothing duplicated + assert.equal(g.nodes.filter((n) => n.id === 'agent:Researcher').length, 1); + assert.ok(g.edges.some((e) => e.id === 'agent:Triage->agent:Billing:handoff' && e.meta.observed)); + // the recorded uses-edge is not added twice + assert.equal(g.edges.filter((e) => e.source === 'agent:Researcher' && e.target === 'tool:Researcher/lookup_fact').length, 1); +}); + +test('observedGraph without a recorded graph is the derived map', () => { + const { events } = load('legacy_v10'); + assert.equal(core.observedGraph(null, events).derived, true); +}); + +test('layoutRunMap: outside agents form their own framed block under the team', () => { + const { events, graph } = load('team_outside'); + const lay = core.layoutRunMap(graph, events); + assert.equal(lay.groups.length, 1); + const grp = lay.groups[0]; + assert.equal(grp.label, 'Outside the team'); + const inside = lay.nodes.filter((n) => !n.outside); + const outside = lay.nodes.filter((n) => n.outside); + assert.deepEqual(outside.map((n) => n.id).sort(), ['agent:Billing', 'agent:Triage', 'tool:Triage/transfer_to_billing']); + const teamBottom = Math.max(...inside.map((n) => n.y + n.h)); + for (const n of outside) { + assert.ok(n.y > teamBottom, `${n.id} sits below the team`); + assert.ok(n.x >= grp.x && n.x + n.w <= grp.x + grp.w && n.y >= grp.y && n.y + n.h <= grp.y + grp.h, `${n.id} inside the frame`); + } + for (const n of lay.nodes) assert.ok(n.x + n.w <= lay.width && n.y + n.h <= lay.height, `${n.id} within bounds`); + assert.ok(lay.edges.some((e) => e.id === 'agent:Triage->agent:Billing:handoff')); +}); + +test('layoutRunMap: no outside group when the graph covers everyone', () => { + const { events, graph } = load('team_handoff'); + const lay = core.layoutRunMap(graph, events); + assert.equal(lay.groups.length, 0); + assert.equal(lay.nodes.length, graph.nodes.length); +}); + +test('real-run shape: LLM span encloses the tool call in the same lane', () => { + const { events, graph } = load('team_outside'); + const m = buildModel(events, graph); + const llm = m.blocks.find((b) => b.agent === 'Researcher' && b.kind === 'llm'); + const tool = m.blocks.find((b) => b.agent === 'Researcher' && b.kind === 'tool'); + assert.equal(llm.lane, tool.lane); + assert.ok(llm.start < tool.start && tool.end < llm.end); // the view must put them on separate rows + assert.ok(m.lanes.some((l) => l.id === 'agent:Triage') && m.lanes.some((l) => l.id === 'agent:Billing')); + assert.deepEqual(m.arrows.map((a) => `${a.kind}:${a.from}->${a.to}`), ['handoff:agent:Triage->agent:Billing']); +}); From e4a13c6ddec70882fb18f39c090d1c15d43ed962 Mon Sep 17 00:00:00 2001 From: MervinPraison <454862+MervinPraison@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:24:25 +0000 Subject: [PATCH 7/8] fix(dashboard): phone layout and SVG page icons; Replay gets its own mark - At <=1023 px the hidden sidebar still took 260 px of the flex row, because buildSidebar() sets an inline position:relative that beat the mobile position:fixed; and the shell header slot took another 53 px. Every page's content was squeezed to a few pixels at phone width. The sidebar is now fixed (!important), the header slot gets its own row, and phones get a 16 px gutter. - Pages may set icon_svg next to the text icon. The sidebar and page header parse it as SVG and drop it if it holds scripts, foreignObject, on* handlers or javascript: URLs; other UIs keep using `icon`. - Replay's heading uses its own inline mark (three lanes and a cursor) instead of the emoji, and its description no longer says "why" (it made Playwright's text=Why match the page header, not the tab). Co-Authored-By: Claude Opus 5.5 (1M context) --- src/praisonaiui/features/replay.py | 12 +++++ src/praisonaiui/server.py | 5 ++- .../templates/frontend/plugins/dashboard.js | 44 +++++++++++++++++-- tests/unit/test_replay_view.py | 26 +++++++++++ 4 files changed, 82 insertions(+), 5 deletions(-) diff --git a/src/praisonaiui/features/replay.py b/src/praisonaiui/features/replay.py index 6c7a64bb..6e2a0a30 100644 --- a/src/praisonaiui/features/replay.py +++ b/src/praisonaiui/features/replay.py @@ -36,6 +36,18 @@ #: Page id of the dashboard view; open ``/replay?run=`` (or ``/#/replay?run=``). REPLAY_PAGE_ID = "replay" +#: Replay's own mark (three lanes and a cursor), used as the page's ``icon_svg``. +REPLAY_MARK_SVG = ( + '' + '' + '' + '' + '' + "" +) + #: Pages a replay-only app should whitelist (Replay first, then its companions). REPLAY_PAGES = ("replay", "approvals", "traces") diff --git a/src/praisonaiui/server.py b/src/praisonaiui/server.py index 058eb292..1b6e7060 100644 --- a/src/praisonaiui/server.py +++ b/src/praisonaiui/server.py @@ -3280,6 +3280,8 @@ def _on_config_reload(): routes.extend(feature.routes()) # Register built-in dashboard pages via the same protocol + from praisonaiui.features.replay import REPLAY_MARK_SVG + _builtin_pages = [ { "id": "overview", @@ -3349,8 +3351,9 @@ def _on_config_reload(): "id": "replay", "title": "Replay", "icon": "⏮️", + "icon_svg": REPLAY_MARK_SVG, "group": "Control", - "description": "Replay recorded runs: who did what, when, why and at what cost", + "description": "Recorded agent runs, step by step: who did what, when, and at what cost", "order": 21, }, { diff --git a/src/praisonaiui/templates/frontend/plugins/dashboard.js b/src/praisonaiui/templates/frontend/plugins/dashboard.js index af293781..1cb1ca3f 100644 --- a/src/praisonaiui/templates/frontend/plugins/dashboard.js +++ b/src/praisonaiui/templates/frontend/plugins/dashboard.js @@ -380,6 +380,7 @@ const DASHBOARD_STYLE = ` .db-main.db-clean > #db-page-content { flex: 1; display: flex; flex-direction: column; min-height: 0; } .db-page-header { margin-bottom: 28px; } .db-page-title { font-size: 26px; font-weight: 700; letter-spacing: -0.02em; margin: 0 0 4px; } + .db-page-icon svg, .db-nav-icon svg { display: inline-block; vertical-align: -0.125em; } .db-page-desc { color: var(--db-text-dim); font-size: 14px; margin: 0; } /* Generic data viewer */ @@ -458,10 +459,20 @@ const DASHBOARD_STYLE = ` .db-mobile-overlay { display: none; position: fixed; inset: 0; z-index: 35; background: rgba(0,0,0,0.5); } @media (max-width: 1023px) { .db-mobile-header { display: flex; } - .db-sidebar { position: fixed; z-index: 45; transform: translateX(-100%); transition: transform 0.2s ease; } + /* !important: buildSidebar() sets an inline position:relative for its toggle, + which otherwise keeps the hidden sidebar's 260px in the flex row. */ + .db-sidebar { position: fixed !important; top: 0; left: 0; z-index: 45; transform: translateX(-100%); transition: transform 0.2s ease; } .db-sidebar.db-sidebar-open { transform: translateX(0); } .db-mobile-overlay.db-visible { display: block; } .db-main { padding-top: 48px; } + /* The shell header slot sits in the same flex row; give it its own line. */ + #root.db-active { flex-wrap: wrap; } + #db-shell-header-slot { flex: 0 0 100%; margin-top: 48px; } + #db-shell-header-slot + .db-main { padding-top: 16px; } + } + @media (max-width: 600px) { + .db-main { padding: 64px 16px 16px; } + #db-shell-header-slot + .db-main { padding-top: 16px; } } .db-card { background: var(--db-card-bg); border: 1px solid var(--db-border); @@ -1776,6 +1787,29 @@ async function buildDashboard() { }); } +// Pages may carry an inline SVG mark (`icon_svg`) next to the text `icon`. +// It is parsed as SVG and dropped if it holds scripts, foreignObject, +// event handlers or javascript: URLs; otherwise the text icon is used. +function safeSvgIcon(markup) { + if (typeof markup !== 'string' || !markup.trim().startsWith(' selectPage(page.id)); @@ -1896,7 +1930,7 @@ function buildSidebar(pages) { btn.className = 'db-nav-item'; const iconSpan = document.createElement('span'); iconSpan.className = 'db-nav-icon'; - iconSpan.textContent = page.icon || '⚙'; + setPageIcon(iconSpan, page, '⚙'); btn.appendChild(iconSpan); btn.appendChild(document.createTextNode(' ' + page.title)); btn.addEventListener('click', () => { @@ -1965,7 +1999,7 @@ async function selectPage(pageId) {
-

${page.icon || ''} ${page.title}

+

${page.icon_svg ? '' : (page.icon || '')} ${page.title}

${page.description ? `

${page.description}

` : ''}
@@ -1973,6 +2007,8 @@ async function selectPage(pageId) {
` : ''; main.innerHTML = `${headerHtml}
`; + const pageIcon = main.querySelector('.db-page-icon'); + if (pageIcon) setPageIcon(pageIcon, page, ''); if (showPageHeader) { const toolbarHost = document.getElementById('db-page-toolbar-slot'); if (toolbarHost && SLOT_REGISTRY[toolbarSlotId]) { diff --git a/tests/unit/test_replay_view.py b/tests/unit/test_replay_view.py index 0352adc8..5a4bb6fb 100644 --- a/tests/unit/test_replay_view.py +++ b/tests/unit/test_replay_view.py @@ -121,3 +121,29 @@ def test_replay_core_node_suite(): ) assert proc.returncode == 0, proc.stdout[-4000:] + proc.stderr[-2000:] assert re.search(r"# fail 0", proc.stdout) + + +def test_replay_page_uses_own_svg_mark_not_emoji_heading(): + from praisonaiui.features.replay import REPLAY_MARK_SVG + + assert REPLAY_MARK_SVG.startswith(' Date: Mon, 26 Oct 2026 12:24:25 +0000 Subject: [PATCH 8/8] docs(replay): rows, theme, map grouping and phone layout Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/adr/0001-replay-view-clean-room.md | 2 ++ docs/features/replay.md | 10 ++++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/adr/0001-replay-view-clean-room.md b/docs/adr/0001-replay-view-clean-room.md index bb764907..f7ae6335 100644 --- a/docs/adr/0001-replay-view-clean-room.md +++ b/docs/adr/0001-replay-view-clean-room.md @@ -61,6 +61,8 @@ Build our own view, in plain DOM and SVG, with our own layout code, palette and arrows differ by dash pattern and carry words ("hands off", "delegates", "routes"). Heat in the cost/latency overlay is a bar whose height and shade both grow, plus the value in text. +- **Theme.** Follows the system setting (`prefers-color-scheme`) by default, with a + Light/Dark override; the light theme is the design baseline. - **Light theme first.** Text tokens are at least 6.9:1 on their backgrounds in light mode and 7.7:1 in dark mode. In dark mode the palette fills keep white text at AA, and get a light 2 px outline so each shape's boundary stays above 3:1 against the dark page. diff --git a/docs/features/replay.md b/docs/features/replay.md index 8ad478b6..01fad865 100644 --- a/docs/features/replay.md +++ b/docs/features/replay.md @@ -38,13 +38,19 @@ aiui run "$(python -c 'import praisonaiui.replay_app as m; print(m.__file__)')" |---|---| | **Runs** | Recorded runs, newest first. A run that is still being written has a **Live** badge. | | **Timeline** (default) | One lane per agent, plus **Manager** and **You**. Time runs left to right. LLM calls and tool calls are blocks in their agent's lane. Arrows show hand-offs, delegation, routing and approval requests. Parallel branches get their own sub-lanes. A call or step repeated back to back is one block marked ×N. Tasks and steps run across the top. | -| **Map** | The team or flow as a graph, drawn from the graph recorded at the start of the run. Older recordings without a graph get a "who talked to whom" map built from the events. Nodes you have not reached yet have a dashed outline; the node the cursor is on has a bold one. | +| **Map** | The team or flow as a graph, drawn from the graph recorded at the start of the run and fitted to the page (−, Fit and + zoom; drag to pan). Agents that took part but are not in the graph, for example after a hand-off to an agent outside the team, appear in an **Outside the team** group with their hand-offs. Older recordings without a graph get a "who talked to whom" map built from the events. Nodes you have not reached yet have a dashed outline; the node the cursor is on has a bold one. | | **What happened** | One plain sentence per step, for example *"Researcher asked web_search for 'solar sail facts' — 1.2 s, $0.002."* Hovering or focusing a sentence highlights its block, and the other way round. | | **Details** | Three tabs for the selected step. **What happened**: when, who, how long, cost, tokens, output. **Why**: only what the recording holds, such as the input given to a tool, the context passed in a hand-off and its reason, the manager's task snapshot, the route a step chose, and an approval's decision and where an automatic approval came from. **Raw JSON**: the events themselves. | | **Cost & latency** | Adds a bar under each block (taller and darker means more cost, or more time) and per-agent totals in the lane labels. | Shapes always carry meaning as well as colour: agents are circles, tools are rounded -rectangles, you are a diamond and the manager is a square. +rectangles, you are a diamond and the manager is a square. In each lane, LLM calls sit on +the top row and that agent's tool calls on a thinner row under them, so a long LLM call +never hides its tools. Labels that do not fit end in "…"; hover or focus a block to see the +whole label. + +The **Theme** menu follows your system's light or dark setting by default; choose Light or +Dark to override it. On a phone the run list, timeline and steps stack in one column. ## Moving through a run