Compare commits
2 commits
b2b7026eb2
...
bca7294ec8
| Author | SHA1 | Date | |
|---|---|---|---|
| bca7294ec8 | |||
|
|
273d144381 |
2 changed files with 137 additions and 1 deletions
80
cli/main.py
80
cli/main.py
|
|
@ -6,7 +6,9 @@ ResearchResult contracts to the terminal.
|
|||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import click
|
||||
|
|
@ -20,6 +22,9 @@ from rich.text import Text
|
|||
from researchers.web.models import ResearchResult
|
||||
|
||||
|
||||
DEFAULT_TRACE_DIR = "~/.marchwarden/traces"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MCP client
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -197,5 +202,80 @@ def ask(
|
|||
render_result(result, console)
|
||||
|
||||
|
||||
def _resolve_trace_path(trace_id: str, trace_dir: Optional[str]) -> Path:
|
||||
"""Resolve the JSONL path for a trace_id."""
|
||||
base = Path(os.path.expanduser(trace_dir or DEFAULT_TRACE_DIR))
|
||||
return base / f"{trace_id}.jsonl"
|
||||
|
||||
|
||||
def render_trace(entries: list[dict], trace_id: str, console: Console) -> None:
|
||||
"""Pretty-print a list of trace entries."""
|
||||
console.print(
|
||||
Panel(
|
||||
f"[bold]trace_id:[/bold] {trace_id}\n[bold]steps:[/bold] {len(entries)}",
|
||||
title="[cyan]Replay[/cyan]",
|
||||
border_style="cyan",
|
||||
)
|
||||
)
|
||||
|
||||
if not entries:
|
||||
console.print("[dim]Trace file is empty.[/dim]")
|
||||
return
|
||||
|
||||
table = Table(show_lines=True, expand=True)
|
||||
table.add_column("#", style="dim", width=4)
|
||||
table.add_column("Action", style="magenta")
|
||||
table.add_column("Decision", overflow="fold")
|
||||
table.add_column("Details", overflow="fold")
|
||||
table.add_column("Hash", style="dim", overflow="fold")
|
||||
|
||||
reserved = {"step", "action", "decision", "timestamp", "content_hash"}
|
||||
for e in entries:
|
||||
step = str(e.get("step", "?"))
|
||||
action = str(e.get("action", ""))
|
||||
decision = str(e.get("decision", ""))
|
||||
content_hash = str(e.get("content_hash", "") or "")
|
||||
extras = {k: v for k, v in e.items() if k not in reserved}
|
||||
details = "\n".join(f"{k}: {v}" for k, v in extras.items())
|
||||
table.add_row(step, action, decision, details, content_hash)
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.argument("trace_id")
|
||||
@click.option(
|
||||
"--trace-dir",
|
||||
default=None,
|
||||
help=f"Trace directory (default: {DEFAULT_TRACE_DIR}).",
|
||||
)
|
||||
def replay(trace_id: str, trace_dir: Optional[str]) -> None:
|
||||
"""Replay a prior research run by TRACE_ID."""
|
||||
console = Console()
|
||||
path = _resolve_trace_path(trace_id, trace_dir)
|
||||
if not path.exists():
|
||||
console.print(
|
||||
f"[bold red]Error:[/bold red] no trace file found for "
|
||||
f"trace_id [bold]{trace_id}[/bold] at {path}"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
entries: list[dict] = []
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
for lineno, line in enumerate(f, 1):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
entries.append(json.loads(line))
|
||||
except json.JSONDecodeError as e:
|
||||
console.print(
|
||||
f"[bold red]Error:[/bold red] invalid JSON on line {lineno}: {e}"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
render_trace(entries, trace_id, console)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ from unittest.mock import patch
|
|||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from cli.main import cli, render_result
|
||||
from cli.main import cli, render_result, render_trace
|
||||
from researchers.web.models import (
|
||||
Citation,
|
||||
ConfidenceFactors,
|
||||
|
|
@ -130,3 +130,59 @@ class TestAskCommand:
|
|||
|
||||
assert result.exit_code == 1
|
||||
assert "mcp went sideways" in result.output
|
||||
|
||||
|
||||
class TestReplayCommand:
|
||||
def _write_trace(self, tmp_path, trace_id="trace-xyz"):
|
||||
path = tmp_path / f"{trace_id}.jsonl"
|
||||
path.write_text(
|
||||
'{"step": 1, "action": "search", "decision": "initial query", '
|
||||
'"timestamp": "2026-04-08T00:00:00Z", "query": "utah crops"}\n'
|
||||
'{"step": 2, "action": "fetch_url", "decision": "promising source", '
|
||||
'"timestamp": "2026-04-08T00:00:01Z", "url": "https://example.com", '
|
||||
'"content_hash": "sha256:deadbeef"}\n'
|
||||
'{"step": 3, "action": "synthesize", "decision": "have enough", '
|
||||
'"timestamp": "2026-04-08T00:00:02Z"}\n'
|
||||
)
|
||||
return path
|
||||
|
||||
def test_replay_renders_trace(self, tmp_path):
|
||||
runner = CliRunner()
|
||||
self._write_trace(tmp_path)
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
["replay", "trace-xyz", "--trace-dir", str(tmp_path)],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "trace-xyz" in result.output
|
||||
assert "search" in result.output
|
||||
assert "fetch_url" in result.output
|
||||
assert "synthesize" in result.output
|
||||
assert "sha256:deadbeef" in result.output
|
||||
assert "utah crops" in result.output
|
||||
|
||||
def test_replay_unknown_trace_id(self, tmp_path):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
["replay", "missing-id", "--trace-dir", str(tmp_path)],
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
assert "no trace file found" in result.output
|
||||
|
||||
def test_replay_invalid_json(self, tmp_path):
|
||||
runner = CliRunner()
|
||||
(tmp_path / "broken.jsonl").write_text("{not json}\n")
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
["replay", "broken", "--trace-dir", str(tmp_path)],
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
assert "invalid JSON" in result.output
|
||||
|
||||
def test_render_trace_empty(self):
|
||||
console = Console(record=True, width=120)
|
||||
render_trace([], "empty-trace", console)
|
||||
out = console.export_text()
|
||||
assert "empty-trace" in out
|
||||
assert "empty" in out.lower()
|
||||
|
|
|
|||
Loading…
Reference in a new issue