From 4f36731c46acca1a8ed580187d80a3317905564a Mon Sep 17 00:00:00 2001 From: Gen TANG Date: Tue, 4 Aug 2026 13:40:14 +0800 Subject: [PATCH 1/6] add trace html --- docs/cli-reference.md | 11 +- docs/dev/debugging.md | 14 + src/yada/agents/default.py | 4 + src/yada/models/base.py | 1 + src/yada/models/deepseek.py | 5 + src/yada/traces/__init__.py | 3 + src/yada/traces/cli.py | 19 +- src/yada/traces/html.py | 576 ++++++++++++++++++++++++++++++++ tests/agents/test_default.py | 10 + tests/models/test_deepseek.py | 12 + tests/traces/test_trace_html.py | 232 +++++++++++++ 11 files changed, 885 insertions(+), 2 deletions(-) create mode 100644 src/yada/traces/html.py create mode 100644 tests/traces/test_trace_html.py diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 2e32401..7088fd9 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -301,7 +301,7 @@ errors or non-verdict outcomes such as skipped grading. ## `yada-trace` ```text -yada-trace TRACE.jsonl [--step N | --verbose | --events] +yada-trace TRACE.jsonl [--step N | --verbose | --events | --html PATH] ``` | Option | Meaning | @@ -309,6 +309,7 @@ yada-trace TRACE.jsonl [--step N | --verbose | --events] | `--step N` | Expand one complete request → response → tools step. | | `--verbose` | Expand payloads inside every grouped step. | | `--events` | Show the flat event timeline with physical JSONL line numbers. | +| `--html PATH` | Write one self-contained offline HTML trace viewer. | The default view groups records by agent step and shows physical JSONL line references for the model request/response and each tool call/result pair: @@ -317,8 +318,16 @@ references for the model request/response and each tool call/result pair: uv run yada-trace TRACE.jsonl uv run yada-trace TRACE.jsonl --step 8 uv run yada-trace TRACE.jsonl --events +uv run yada-trace TRACE.jsonl --html trace.html ``` +The HTML viewer groups the same validated records by step and adds local search, +failure/file-change filters, collapsed large payloads, message-field presence, +and the final diff. It contains inline CSS and JavaScript only: opening the file +does not start a server or request external resources. The generated file can +contain prompts, reasoning, source, patches, and command output from the source +trace, so handle it with the same care as the JSONL. + `yada-trace` returns `0` after rendering and `2` for an unreadable or invalid trace. See the [debugging guide](dev/debugging.md) for event semantics, trace levels, `jq` recipes, and reproduction workflows. diff --git a/docs/dev/debugging.md b/docs/dev/debugging.md index 60dd920..55059ec 100644 --- a/docs/dev/debugging.md +++ b/docs/dev/debugging.md @@ -109,6 +109,20 @@ investigation: uv run yada-trace TRACE.jsonl --events ``` +For longer runs, generate a portable semantic view and open it directly in a +browser: + +```bash +uv run yada-trace TRACE.jsonl --html trace.html +``` + +The single HTML file works offline and groups requests, reasoning, responses, +plans, tool calls/results, failures, and the final diff by step. Search and +filters run locally in the browser. Large prompts, patches, and command output +are collapsed by default. The viewer preserves redaction from the JSONL and +cannot recover omitted or redacted fields; newer traces also show whether each +assistant message field was present or explicitly normalized from omission. + Then inspect exact records using the line references: ```bash diff --git a/src/yada/agents/default.py b/src/yada/agents/default.py index f5bb196..4c6c160 100644 --- a/src/yada/agents/default.py +++ b/src/yada/agents/default.py @@ -167,6 +167,10 @@ def run(self, task: str) -> AgentResult: "system_fingerprint": completion.system_fingerprint, "finish_reason": completion.finish_reason, } + if completion.message_field_presence is not None: + assistant_record["message_field_presence"] = ( + completion.message_field_presence + ) self.trace.write( "assistant", assistant_record, diff --git a/src/yada/models/base.py b/src/yada/models/base.py index 8218d79..eeb3498 100644 --- a/src/yada/models/base.py +++ b/src/yada/models/base.py @@ -16,6 +16,7 @@ class Completion: model: str | None = None system_fingerprint: str | None = None finish_reason: str | None = None + message_field_presence: dict[str, bool] | None = None class CompletionClient(Protocol): diff --git a/src/yada/models/deepseek.py b/src/yada/models/deepseek.py index 81dea1c..233886a 100644 --- a/src/yada/models/deepseek.py +++ b/src/yada/models/deepseek.py @@ -98,6 +98,10 @@ def complete( for key in ("role", "content", "reasoning_content", "tool_calls") if key in raw_message } + message_field_presence = { + key: key in raw_message + for key in ("role", "content", "reasoning_content", "tool_calls") + } message.setdefault("role", "assistant") message.setdefault("content", "") return Completion( @@ -107,6 +111,7 @@ def complete( model=response_data.get("model"), system_fingerprint=response_data.get("system_fingerprint"), finish_reason=choice.get("finish_reason"), + message_field_presence=message_field_presence, ) def request_payload( diff --git a/src/yada/traces/__init__.py b/src/yada/traces/__init__.py index 0889a2f..0ce0bde 100644 --- a/src/yada/traces/__init__.py +++ b/src/yada/traces/__init__.py @@ -1,5 +1,6 @@ """Trace persistence and human-readable run diagnostics.""" +from yada.traces.html import render_trace_html, write_trace_html from yada.traces.jsonl import TRACE_LEVELS, TRACE_SCHEMA_VERSION, TraceWriter from yada.traces.report import ( LocatedTraceEvent, @@ -27,5 +28,7 @@ "read_located_trace", "read_trace", "reconstruct_model_request", + "render_trace_html", "render_trace_report", + "write_trace_html", ] diff --git a/src/yada/traces/cli.py b/src/yada/traces/cli.py index d1d1a9b..b86a5ca 100644 --- a/src/yada/traces/cli.py +++ b/src/yada/traces/cli.py @@ -6,6 +6,7 @@ import sys from pathlib import Path +from yada.traces.html import write_trace_html from yada.traces.report import TraceFormatError, render_trace_report @@ -32,6 +33,12 @@ def build_parser() -> argparse.ArgumentParser: action="store_true", help="Show the legacy flat event timeline with physical line numbers.", ) + parser.add_argument( + "--html", + type=Path, + metavar="PATH", + help="Write a self-contained offline HTML viewer.", + ) return parser @@ -40,8 +47,18 @@ def run_cli(argv: list[str] | None = None) -> int: args = build_parser().parse_args(argv) try: + trace_path = args.trace.expanduser().resolve() + if args.html is not None: + if args.step is not None or args.verbose or args.events: + raise TraceFormatError( + "--html cannot be combined with --step, --verbose, or --events" + ) + output_path = args.html.expanduser().resolve() + write_trace_html(trace_path, output_path) + print(f"Wrote offline trace viewer: {output_path}") + return 0 report = render_trace_report( - args.trace.expanduser().resolve(), + trace_path, step=args.step, verbose=args.verbose, events=args.events, diff --git a/src/yada/traces/html.py b/src/yada/traces/html.py new file mode 100644 index 0000000..62612ab --- /dev/null +++ b/src/yada/traces/html.py @@ -0,0 +1,576 @@ +"""Self-contained offline HTML rendering for Yada traces.""" + +from __future__ import annotations + +import html +import json +from pathlib import Path +from typing import Any + +from yada.traces.report import ( + LocatedTraceEvent, + TraceFormatError, + TraceRun, + TraceStep, + TraceToolExecution, + build_trace_run, + read_located_trace, +) + +_FIELDS = ("role", "content", "reasoning_content", "tool_calls") +_EDIT_TOOLS = {"apply_patch", "replace_text"} +_COLLAPSE_CHARS = 4_000 + + +def render_trace_html(path: Path) -> str: + """Render one validated JSONL trace as a portable offline HTML document.""" + + run = build_trace_run(read_located_trace(path)) + start = run.run_start.data if run.run_start else {} + run_id = _run_id(run) + panels = "".join( + _render_step(step, selected=index == 0) for index, step in enumerate(run.steps) + ) + navigation = "".join( + _render_step_button(step, selected=index == 0) + for index, step in enumerate(run.steps) + ) + if not run.steps: + panels = '

No agent steps were recorded.

' + + content = ( + _render_header(path, run) + + _render_run_details(run) + + '
' + + '" + + f'
{panels}
' + + "
" + ) + title = f"Yada trace · {run_id} · {start.get('model', 'unknown')}" + return _DOCUMENT.replace("__TITLE__", _escape(title), 1).replace( + "__CONTENT__", content, 1 + ) + + +def write_trace_html(trace_path: Path, output_path: Path) -> None: + """Validate ``trace_path`` and write its self-contained HTML view.""" + + trace_path = trace_path.resolve() + output_path = output_path.resolve() + if trace_path == output_path: + raise TraceFormatError("--html output must differ from the source trace") + document = render_trace_html(trace_path) + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(document, encoding="utf-8") + + +def _render_header(path: Path, run: TraceRun) -> str: + start = run.run_start.data if run.run_start else {} + outcome, outcome_class = _outcome(run) + token_count = _run_tokens(run) + latency = _last_elapsed(run) + interrupted = "" + if run.run_end is None: + interrupted = ( + '

Interrupted trace: missing run_end. ' + "The final state may be unavailable.

" + ) + return f""" +
+
+

Yada trace viewer · offline

+

{_escape(start.get("task", "Untitled run"))}

+

{_escape(path)} · run {_escape(_run_id(run))}

+
+
+ {_metric("Outcome", outcome, outcome_class)} + {_metric("Model", start.get("model", "unknown"))} + {_metric("Tokens", f"{token_count:,}" if token_count is not None else "—")} + {_metric("Latency", _duration(latency) if latency is not None else "—")} + {_metric("Steps", len(run.steps))} +
+
+{interrupted} +""" + + +def _render_run_details(run: TraceRun) -> str: + start = run.run_start.data if run.run_start else {} + end = run.run_end.data if run.run_end else {} + details = '
' + details += _details( + "Model configuration", + start.get("model_config", "Unavailable"), + open_by_default=True, + ) + details += _details( + "Provenance", + start.get("provenance", "Unavailable"), + open_by_default=True, + ) + final_state = end.get("final_state") + if isinstance(final_state, dict): + details += _details( + "Final status", + { + "git_status": final_state.get("git_status"), + "diff_stat": final_state.get("diff_stat"), + }, + open_by_default=True, + ) + details += _details( + "Final diff", + final_state.get("diff") or "No diff recorded.", + open_by_default=False, + ) + else: + details += _details( + "Final diff", + "Unavailable: the trace has no captured final state.", + open_by_default=True, + ) + return details + "
" + + +def _render_filters() -> str: + filters = ( + ("model-error", "Model errors"), + ("protocol-violation", "Protocol violations"), + ("rejected-tool", "Rejected tools"), + ("nonzero-exit", "Non-zero exits"), + ("file-change", "File-changing steps"), + ) + controls = "".join( + f'' + for value, label in filters + ) + return f'
Filters{controls}
' + + +def _render_step_button(step: TraceStep, *, selected: bool) -> str: + flags = _step_flags(step) + response = step.model_response + duration = response.data.get("duration_ms") if response else None + tokens = _total_tokens(response.data.get("usage")) if response else None + facts = [] + if isinstance(duration, (int, float)): + facts.append(_duration(duration)) + if tokens is not None: + facts.append(f"{tokens:,} tokens") + if not facts: + facts.append("interrupted" if _step_interrupted(step) else "no metrics") + classes = "step-link selected" if selected else "step-link" + flag_text = " ".join(flags) + return ( + f'" + ) + + +def _render_step(step: TraceStep, *, selected: bool) -> str: + flags = _step_flags(step) + hidden = "" if selected else " hidden" + flag_text = " ".join(flags) + badges = "".join(f'{_escape(flag)}' for flag in flags) + warnings = _render_step_warnings(step) + request = _located(step.model_request) + response = _response_without_reasoning(step.model_response) + plan = _located(step.plan_decision) + calls = [_located(execution.call) for execution in step.tool_executions] + results = [_located(execution.result) for execution in step.tool_executions] + return f""" +
+
+

Lines {step.first_line}–{step.last_line}

+

Step {step.number}

+
{badges}
+
+ {warnings} + {_details("Request", request or "Missing model request", open_by_default=False)} + {_render_reasoning(step.model_response)} + {_render_presence(step.model_response)} + {_details("Response", response or "Missing model response", open_by_default=True)} + {_details("Plan", plan or "No plan decision recorded", open_by_default=True)} + {_details("Tool Calls", calls or "No tool calls", open_by_default=True)} + {_details("Tool Results", results or "No tool results", open_by_default=True)} +
+""" + + +def _render_reasoning(response: LocatedTraceEvent | None) -> str: + if response is None or response.name != "assistant": + return _details( + "Reasoning", "Unavailable: no assistant response.", open_by_default=True + ) + message = response.data.get("message") + if not isinstance(message, dict) or "reasoning_content" not in message: + return _details("Reasoning", "Omitted by the model.", open_by_default=True) + reasoning = message["reasoning_content"] + if isinstance(reasoning, dict) and reasoning.get("redacted") is True: + chars = reasoning.get("chars", "unknown") + note = f"Redacted in the source trace ({chars} characters)." + return ( + '
Reasoning' + f'

{_escape(note)}

{_json(reasoning)}
' + "
" + ) + return _details("Reasoning", reasoning, open_by_default=True) + + +def _render_presence(response: LocatedTraceEvent | None) -> str: + if response is None or response.name != "assistant": + return "" + presence = response.data.get("message_field_presence") + if not isinstance(presence, dict): + return ( + '

Message field presence

' + '

Unavailable for this legacy trace.

' + ) + fields = [] + for field in _FIELDS: + value = presence.get(field) + state = ( + "present" if value is True else "omitted" if value is False else "unknown" + ) + fields.append( + f'{field} {state}' + ) + return ( + '

Message field presence

' + f'
{"".join(fields)}
' + ) + + +def _render_step_warnings(step: TraceStep) -> str: + warnings = [] + if step.model_request is None: + warnings.append("Missing model request") + if step.model_request is not None and step.model_response is None: + warnings.append("Missing model response") + for execution in step.tool_executions: + if execution.call is None: + warnings.append("Unmatched tool result") + if execution.result is None: + warnings.append("Missing tool result") + if not warnings: + return "" + unique = " · ".join(dict.fromkeys(warnings)) + return f'

{_escape(unique)}

' + + +def _step_flags(step: TraceStep) -> tuple[str, ...]: + flags = [] + if step.model_response and step.model_response.name == "model_error": + flags.append("model-error") + if any(event.name == "protocol_violation" for event in step.protocol_events): + flags.append("protocol-violation") + if any( + execution.call and execution.call.data.get("rejected") is True + for execution in step.tool_executions + ): + flags.append("rejected-tool") + if any(_nonzero_exit(execution) for execution in step.tool_executions): + flags.append("nonzero-exit") + if any(_changes_files(execution) for execution in step.tool_executions): + flags.append("file-change") + if _step_interrupted(step): + flags.append("incomplete") + return tuple(flags) + + +def _step_interrupted(step: TraceStep) -> bool: + return bool( + (step.model_request and step.model_response is None) + or any( + execution.call is None or execution.result is None + for execution in step.tool_executions + ) + ) + + +def _nonzero_exit(execution: TraceToolExecution) -> bool: + result = _tool_result(execution) + exit_code = result.get("exit_code") + return isinstance(exit_code, int) and exit_code != 0 + + +def _changes_files(execution: TraceToolExecution) -> bool: + result = _tool_result(execution) + if result.get("ok") is not True: + return False + changed = result.get("changed_files") + if isinstance(changed, list) and changed: + return True + source = execution.call or execution.result + return bool(source and source.data.get("tool") in _EDIT_TOOLS) + + +def _tool_result(execution: TraceToolExecution) -> dict[str, Any]: + if execution.result is None: + return {} + result = execution.result.data.get("result") + return result if isinstance(result, dict) else {} + + +def _response_without_reasoning( + response: LocatedTraceEvent | None, +) -> dict[str, Any] | None: + located = _located(response) + if located is None or response is None or response.name != "assistant": + return located + data = dict(response.data) + message = data.get("message") + if isinstance(message, dict): + data["message"] = { + key: value for key, value in message.items() if key != "reasoning_content" + } + return {"jsonl_line": response.line_number, "event": response.name, "data": data} + + +def _located(event: LocatedTraceEvent | None) -> dict[str, Any] | None: + if event is None: + return None + return { + "jsonl_line": event.line_number, + "event": event.name, + "data": event.data, + } + + +def _details(title: str, value: Any, *, open_by_default: bool) -> str: + rendered = _json_text(value) + open_attribute = ( + " open" if open_by_default and len(rendered) <= _COLLAPSE_CHARS else "" + ) + return ( + f'
' + f"{_escape(title)}
{_escape(rendered)}
" + ) + + +def _json(value: Any) -> str: + return _escape(_json_text(value)) + + +def _json_text(value: Any) -> str: + if isinstance(value, str): + return value + return json.dumps( + value, + ensure_ascii=False, + indent=2, + sort_keys=True, + default=str, + ) + + +def _metric(label: str, value: Any, class_name: str = "") -> str: + css_class = f' class="{class_name}"' if class_name else "" + return ( + '
' + f"{_escape(label)}{_escape(value)}" + "
" + ) + + +def _outcome(run: TraceRun) -> tuple[str, str]: + if run.run_end is None: + return "Interrupted", "error-text" + steps = run.run_end.data.get("steps") + suffix = "" + if isinstance(steps, int): + suffix = f" · {steps} {'step' if steps == 1 else 'steps'}" + if run.run_end.data.get("finished") is True: + return f"Resolved{suffix}", "success-text" + return f"Unfinished{suffix}", "error-text" + + +def _run_id(run: TraceRun) -> str: + return str( + next( + ( + event.record.get("run_id") + for event in run.events + if event.record.get("run_id") + ), + "legacy", + ) + ) + + +def _run_tokens(run: TraceRun) -> int | None: + if run.run_end: + total = _total_tokens(run.run_end.data.get("usage")) + if total is not None: + return total + totals = [ + _total_tokens(event.data.get("usage")) + for event in run.events + if event.name == "assistant" + ] + values = [value for value in totals if value is not None] + return sum(values) if values else None + + +def _total_tokens(usage: Any) -> int | None: + if not isinstance(usage, dict): + return None + total = usage.get("total_tokens") + if isinstance(total, int): + return total + prompt = usage.get("prompt_tokens") + completion = usage.get("completion_tokens") + if isinstance(prompt, int) and isinstance(completion, int): + return prompt + completion + return None + + +def _last_elapsed(run: TraceRun) -> int | float | None: + values = [ + event.record.get("elapsed_ms") + for event in run.events + if isinstance(event.record.get("elapsed_ms"), (int, float)) + ] + return max(values) if values else None + + +def _duration(milliseconds: int | float) -> str: + if milliseconds < 1000: + return f"{milliseconds:g} ms" + seconds = milliseconds / 1000 + return f"{seconds:.1f} s" if seconds < 10 else f"{seconds:g} s" + + +def _escape(value: Any) -> str: + return html.escape(str(value), quote=True) + + +_DOCUMENT = """ + + + + + +__TITLE__ + + +
__CONTENT__
+ + + +""" diff --git a/tests/agents/test_default.py b/tests/agents/test_default.py index 2a9d479..949ca5f 100644 --- a/tests/agents/test_default.py +++ b/tests/agents/test_default.py @@ -32,6 +32,12 @@ def tool_call(call_id: str, name: str, arguments: dict[str, Any]) -> Completion: usage={"prompt_tokens": 10, "completion_tokens": 5}, model="fake-deepseek-v4-pro", finish_reason="tool_calls", + message_field_presence={ + "role": True, + "content": True, + "reasoning_content": True, + "tool_calls": True, + }, ) @@ -118,6 +124,10 @@ def add(a, b): trace = trace_path.read_text(encoding="utf-8") assert '"redacted": true' in trace assert "reasoning for read_file" not in trace + assistant = next( + event for event in read_trace(trace_path) if event["event"] == "assistant" + ) + assert assistant["data"]["message_field_presence"]["content"] is True # DeepSeek requires the prior assistant reasoning_content on the next request. assert client.seen_messages[1][-2]["reasoning_content"] == "reasoning for read_file" diff --git a/tests/models/test_deepseek.py b/tests/models/test_deepseek.py index 2b91fee..39406f6 100644 --- a/tests/models/test_deepseek.py +++ b/tests/models/test_deepseek.py @@ -55,6 +55,12 @@ def fake_send(request): assert "tool_choice" not in payload assert completion.message["content"] == "" assert completion.message["reasoning_content"] == "must be passed back" + assert completion.message_field_presence == { + "role": True, + "content": True, + "reasoning_content": True, + "tool_calls": True, + } assert completion.system_fingerprint == "fingerprint-1" assert client.trace_config()["provider"] == "deepseek" assert "api_key" not in client.trace_config() @@ -81,6 +87,12 @@ def test_completion_normalizes_missing_content(monkeypatch) -> None: assert completion.message["content"] == "" assert completion.message["reasoning_content"] == "call a tool" + assert completion.message_field_presence == { + "role": True, + "content": False, + "reasoning_content": True, + "tool_calls": True, + } def test_non_thinking_mode_uses_automatic_tool_choice(monkeypatch) -> None: diff --git a/tests/traces/test_trace_html.py b/tests/traces/test_trace_html.py new file mode 100644 index 0000000..738896f --- /dev/null +++ b/tests/traces/test_trace_html.py @@ -0,0 +1,232 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from yada.traces import TRACE_SCHEMA_VERSION, TraceWriter, render_trace_html +from yada.traces.cli import run_cli + + +def test_completed_trace_renders_offline_semantic_view(tmp_path: Path, capsys) -> None: + trace_path = tmp_path / "completed.jsonl" + output_path = tmp_path / "viewer.html" + trace = TraceWriter(trace_path, level="debug", run_id="html-test") + trace.write( + "run_start", + { + "model": "deepseek-v4-pro", + "task": "Fix parser", + "trace_level": "debug", + "model_config": {"thinking": True}, + "provenance": {"yada_version": "0.1.0", "case_id": "parser-1"}, + }, + ) + trace.write( + "model_request", + { + "step": 1, + "request_id": "request-1", + "payload": {"messages": [{"role": "user", "content": "fix it"}]}, + }, + ) + trace.write( + "assistant", + { + "step": 1, + "request_id": "request-1", + "duration_ms": 1250, + "usage": {"total_tokens": 42}, + "finish_reason": "tool_calls", + "message_field_presence": { + "role": True, + "content": False, + "reasoning_content": True, + "tool_calls": True, + }, + "message": { + "role": "assistant", + "content": "", + "reasoning_content": "The boundary is off by one.", + "tool_calls": [], + }, + }, + ) + trace.write( + "plan_decision", + {"step": 1, "action": "execute_tools", "tools": ["apply_patch"]}, + ) + trace.write( + "tool_call", + { + "step": 1, + "tool_call_id": "patch-1", + "tool": "apply_patch", + "arguments": {"patch": "diff --git a/a.py b/a.py"}, + }, + ) + trace.write( + "tool_result", + { + "step": 1, + "tool_call_id": "patch-1", + "tool": "apply_patch", + "duration_ms": 4, + "result": { + "ok": True, + "changed_files": [{"path": "a.py", "sha256": "abc"}], + }, + }, + ) + trace.write( + "run_end", + { + "finished": True, + "steps": 1, + "usage": {"total_tokens": 42}, + "final_state": { + "git_status": " M a.py", + "diff_stat": "a.py | 2 +-", + "diff": "-wrong\n+right", + }, + }, + ) + + assert run_cli([str(trace_path), "--html", str(output_path)]) == 0 + document = output_path.read_text(encoding="utf-8") + + assert "Wrote offline trace viewer" in capsys.readouterr().out + assert "Yada trace viewer · offline" in document + assert "Fix parser" in document + assert "Resolved · 1 step" in document + assert "The boundary is off by one." in document + assert "message_field_presence" in document + assert "content omitted" in document + assert "Final diff" in document + assert "-wrong\n+right" in document + assert 'data-flags="file-change"' in document + assert "" + ) + trace = TraceWriter(path, level="debug") + trace.write("run_start", {"task": attack, "model": "fake"}) + trace.write( + "assistant", + {"step": 1, "message": {"content": attack, "reasoning_content": attack}}, + ) + + document = render_trace_html(path) + + assert attack not in document + assert "</script><script>" in document + assert " dict: + return { + "schema_version": TRACE_SCHEMA_VERSION, + "run_id": "html-test", + "sequence": sequence, + "elapsed_ms": sequence, + "event": event, + "data": data, + } From d6c6fb37252634be8657d53a4e9d0e96812e3def Mon Sep 17 00:00:00 2001 From: Gen TANG Date: Tue, 4 Aug 2026 13:53:50 +0800 Subject: [PATCH 2/6] upgrade html open procedure --- docs/cli-reference.md | 9 ++++++--- docs/dev/debugging.md | 6 ++++-- src/yada/traces/cli.py | 33 +++++++++++++++++++++++++++++--- src/yada/traces/html.py | 18 +++++++++++++---- tests/traces/test_trace_html.py | 34 ++++++++++++++++++++++++++++++++- 5 files changed, 87 insertions(+), 13 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 7088fd9..5260a90 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -301,7 +301,7 @@ errors or non-verdict outcomes such as skipped grading. ## `yada-trace` ```text -yada-trace TRACE.jsonl [--step N | --verbose | --events | --html PATH] +yada-trace TRACE.jsonl [--step N | --verbose | --events | --html [PATH]] ``` | Option | Meaning | @@ -309,7 +309,7 @@ yada-trace TRACE.jsonl [--step N | --verbose | --events | --html PATH] | `--step N` | Expand one complete request → response → tools step. | | `--verbose` | Expand payloads inside every grouped step. | | `--events` | Show the flat event timeline with physical JSONL line numbers. | -| `--html PATH` | Write one self-contained offline HTML trace viewer. | +| `--html [PATH]` | Write one self-contained offline HTML viewer. Without `PATH`, write it beside the trace and open it. | The default view groups records by agent step and shows physical JSONL line references for the model request/response and each tool call/result pair: @@ -318,10 +318,13 @@ references for the model request/response and each tool call/result pair: uv run yada-trace TRACE.jsonl uv run yada-trace TRACE.jsonl --step 8 uv run yada-trace TRACE.jsonl --events +uv run yada-trace TRACE.jsonl --html uv run yada-trace TRACE.jsonl --html trace.html ``` -The HTML viewer groups the same validated records by step and adds local search, +Without an output path, `--html` writes `TRACE.html` beside `TRACE.jsonl` and +opens it in the default browser. Pass a path to export the viewer without opening +it. The HTML viewer groups the same validated records by step and adds local search, failure/file-change filters, collapsed large payloads, message-field presence, and the final diff. It contains inline CSS and JavaScript only: opening the file does not start a server or request external resources. The generated file can diff --git a/docs/dev/debugging.md b/docs/dev/debugging.md index 55059ec..fd4d016 100644 --- a/docs/dev/debugging.md +++ b/docs/dev/debugging.md @@ -113,10 +113,12 @@ For longer runs, generate a portable semantic view and open it directly in a browser: ```bash -uv run yada-trace TRACE.jsonl --html trace.html +uv run yada-trace TRACE.jsonl --html ``` -The single HTML file works offline and groups requests, reasoning, responses, +This writes `TRACE.html` beside the JSONL and opens it in the default browser. +Pass an explicit path after `--html` to export without opening it. The single +HTML file works offline and groups requests, reasoning, responses, plans, tool calls/results, failures, and the final diff by step. Search and filters run locally in the browser. Large prompts, patches, and command output are collapsed by default. The viewer preserves redaction from the JSONL and diff --git a/src/yada/traces/cli.py b/src/yada/traces/cli.py index b86a5ca..88de426 100644 --- a/src/yada/traces/cli.py +++ b/src/yada/traces/cli.py @@ -4,11 +4,14 @@ import argparse import sys +import webbrowser from pathlib import Path from yada.traces.html import write_trace_html from yada.traces.report import TraceFormatError, render_trace_report +_AUTO_HTML = object() + def build_parser() -> argparse.ArgumentParser: """Create the parser for the lightweight ``yada-trace`` command.""" @@ -35,9 +38,14 @@ def build_parser() -> argparse.ArgumentParser: ) parser.add_argument( "--html", + nargs="?", + const=_AUTO_HTML, type=Path, metavar="PATH", - help="Write a self-contained offline HTML viewer.", + help=( + "Write a self-contained offline HTML viewer; omit PATH to write " + "beside TRACE and open it." + ), ) return parser @@ -53,9 +61,21 @@ def run_cli(argv: list[str] | None = None) -> int: raise TraceFormatError( "--html cannot be combined with --step, --verbose, or --events" ) - output_path = args.html.expanduser().resolve() + auto_open = args.html is _AUTO_HTML + output_path = ( + trace_path.with_suffix(".html") + if auto_open + else args.html.expanduser().resolve() + ) write_trace_html(trace_path, output_path) - print(f"Wrote offline trace viewer: {output_path}") + if auto_open and _open_html(output_path): + print(f"Wrote and opened offline trace viewer: {output_path}") + else: + print(f"Wrote offline trace viewer: {output_path}") + if auto_open: + print( + "Could not open it automatically; open the file in a browser." + ) return 0 report = render_trace_report( trace_path, @@ -80,6 +100,13 @@ def _positive_step(value: str) -> int: return step +def _open_html(path: Path) -> bool: + try: + return webbrowser.open(path.as_uri(), new=2) + except (OSError, webbrowser.Error): + return False + + def main() -> None: """Console-script entry point.""" diff --git a/src/yada/traces/html.py b/src/yada/traces/html.py index 62612ab..adec348 100644 --- a/src/yada/traces/html.py +++ b/src/yada/traces/html.py @@ -46,6 +46,7 @@ def render_trace_html(path: Path) -> str: + '' + '' + + '

' + _render_filters() + f'' + '' @@ -494,8 +495,9 @@ def _escape(value: Any) -> str: border-radius:10px; padding:16px; } .sidebar { position:sticky; top:16px; max-height:calc(100vh - 32px); overflow:auto; } .sidebar > label, legend { font-weight:600; } -input[type="search"] { width:100%; margin:7px 0 14px; padding:9px 10px; +input[type="search"] { width:100%; margin:7px 0 5px; padding:9px 10px; color:var(--text); background:var(--bg); border:1px solid var(--border); border-radius:6px; } +.search-status { margin:0 0 14px; color:var(--muted); font-size:12px; } fieldset { margin:0 0 14px; padding:0; border:0; } .filter { display:block; margin:7px 0; color:var(--muted); } #step-list { display:grid; gap:6px; } @@ -534,6 +536,10 @@ def _escape(value: Any) -> str: const panels = new Map( [...document.querySelectorAll(".step-panel")].map(panel => [panel.id.slice(5), panel]) ); + const searchableText = new Map( + [...panels].map(([step, panel]) => [step, panel.textContent.toLocaleLowerCase()]) + ); + const searchStatus = document.getElementById("search-status"); const noResults = document.getElementById("no-results"); function select(button) { @@ -551,14 +557,17 @@ def _escape(value: Any) -> str: const active = filters.filter(item => item.checked).map(item => item.value); const visible = []; buttons.forEach(button => { - const panel = panels.get(button.dataset.step); const flags = new Set((button.dataset.flags || "").split(" ").filter(Boolean)); const matchesFilter = active.length === 0 || active.some(flag => flags.has(flag)); - const matchesSearch = !query || (panel && panel.textContent.toLocaleLowerCase().includes(query)); + const matchesSearch = !query || (searchableText.get(button.dataset.step) || "").includes(query); button.hidden = !(matchesFilter && matchesSearch); if (!button.hidden) visible.push(button); }); - noResults.hidden = visible.length !== 0; + const totalLabel = buttons.length === 1 ? "step" : "steps"; + searchStatus.textContent = visible.length === buttons.length + ? `${buttons.length} ${totalLabel}` + : `${visible.length} of ${buttons.length} ${totalLabel}`; + noResults.hidden = (!query && active.length === 0) || visible.length !== 0; const selected = buttons.find(item => item.getAttribute("aria-selected") === "true"); if (!selected || selected.hidden) { if (visible[0]) select(visible[0]); @@ -569,6 +578,7 @@ def _escape(value: Any) -> str: buttons.forEach(button => button.addEventListener("click", () => select(button))); search.addEventListener("input", applyFilters); filters.forEach(filter => filter.addEventListener("change", applyFilters)); + applyFilters(); })(); diff --git a/tests/traces/test_trace_html.py b/tests/traces/test_trace_html.py index 738896f..518b2ee 100644 --- a/tests/traces/test_trace_html.py +++ b/tests/traces/test_trace_html.py @@ -7,9 +7,16 @@ from yada.traces.cli import run_cli -def test_completed_trace_renders_offline_semantic_view(tmp_path: Path, capsys) -> None: +def test_completed_trace_renders_offline_semantic_view( + tmp_path: Path, capsys, monkeypatch +) -> None: trace_path = tmp_path / "completed.jsonl" output_path = tmp_path / "viewer.html" + opened = [] + monkeypatch.setattr( + "yada.traces.cli.webbrowser.open", + lambda url, new: opened.append((url, new)) or True, + ) trace = TraceWriter(trace_path, level="debug", run_id="html-test") trace.write( "run_start", @@ -110,6 +117,31 @@ def test_completed_trace_renders_offline_semantic_view(tmp_path: Path, capsys) - assert "XMLHttpRequest" not in document assert "default-src 'none'" not in document assert "default-src 'none'" in document + assert opened == [] + assert 'id="search-status"' in document + assert "const searchableText = new Map(" in document + assert "panel.textContent.toLocaleLowerCase()" in document + assert "applyFilters();" in document + + +def test_html_without_path_writes_beside_trace_and_opens( + tmp_path: Path, capsys, monkeypatch +) -> None: + trace_path = tmp_path / "nested" / "run.jsonl" + trace = TraceWriter(trace_path, level="summary", run_id="auto-html") + trace.write("run_start", {"model": "fake", "task": "open trace"}) + opened = [] + monkeypatch.setattr( + "yada.traces.cli.webbrowser.open", + lambda url, new: opened.append((url, new)) or True, + ) + + assert run_cli([str(trace_path), "--html"]) == 0 + + output_path = trace_path.with_suffix(".html").resolve() + assert output_path.is_file() + assert opened == [(output_path.as_uri(), 2)] + assert "Wrote and opened offline trace viewer" in capsys.readouterr().out def test_interrupted_trace_marks_missing_pairs_and_run_end(tmp_path: Path) -> None: From ab72914b91f463d2834376448ea295ea8c4af5e4 Mon Sep 17 00:00:00 2001 From: Gen TANG Date: Tue, 4 Aug 2026 14:09:07 +0800 Subject: [PATCH 3/6] upgrade html open procedure --- docs/cli-reference.md | 6 +- docs/dev/debugging.md | 7 +- src/yada/agents/default.py | 4 -- src/yada/models/base.py | 1 - src/yada/models/deepseek.py | 5 -- src/yada/traces/html.py | 116 +++++++++++--------------------- tests/agents/test_default.py | 10 --- tests/models/test_deepseek.py | 12 ---- tests/traces/test_trace_html.py | 29 ++++---- 9 files changed, 61 insertions(+), 129 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 5260a90..7a29447 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -324,9 +324,9 @@ uv run yada-trace TRACE.jsonl --html trace.html Without an output path, `--html` writes `TRACE.html` beside `TRACE.jsonl` and opens it in the default browser. Pass a path to export the viewer without opening -it. The HTML viewer groups the same validated records by step and adds local search, -failure/file-change filters, collapsed large payloads, message-field presence, -and the final diff. It contains inline CSS and JavaScript only: opening the file +it. The HTML viewer groups the same validated records by step and adds local step +filtering, failure/file-change filters, collapsed large payloads, and the final +diff. It contains inline CSS and JavaScript only: opening the file does not start a server or request external resources. The generated file can contain prompts, reasoning, source, patches, and command output from the source trace, so handle it with the same care as the JSONL. diff --git a/docs/dev/debugging.md b/docs/dev/debugging.md index fd4d016..c404094 100644 --- a/docs/dev/debugging.md +++ b/docs/dev/debugging.md @@ -119,11 +119,10 @@ uv run yada-trace TRACE.jsonl --html This writes `TRACE.html` beside the JSONL and opens it in the default browser. Pass an explicit path after `--html` to export without opening it. The single HTML file works offline and groups requests, reasoning, responses, -plans, tool calls/results, failures, and the final diff by step. Search and -filters run locally in the browser. Large prompts, patches, and command output +plans, tool calls/results, failures, and the final diff by step. Step filtering +runs locally in the browser. Large prompts, patches, and command output are collapsed by default. The viewer preserves redaction from the JSONL and -cannot recover omitted or redacted fields; newer traces also show whether each -assistant message field was present or explicitly normalized from omission. +cannot recover omitted or redacted fields. Then inspect exact records using the line references: diff --git a/src/yada/agents/default.py b/src/yada/agents/default.py index 4c6c160..f5bb196 100644 --- a/src/yada/agents/default.py +++ b/src/yada/agents/default.py @@ -167,10 +167,6 @@ def run(self, task: str) -> AgentResult: "system_fingerprint": completion.system_fingerprint, "finish_reason": completion.finish_reason, } - if completion.message_field_presence is not None: - assistant_record["message_field_presence"] = ( - completion.message_field_presence - ) self.trace.write( "assistant", assistant_record, diff --git a/src/yada/models/base.py b/src/yada/models/base.py index eeb3498..8218d79 100644 --- a/src/yada/models/base.py +++ b/src/yada/models/base.py @@ -16,7 +16,6 @@ class Completion: model: str | None = None system_fingerprint: str | None = None finish_reason: str | None = None - message_field_presence: dict[str, bool] | None = None class CompletionClient(Protocol): diff --git a/src/yada/models/deepseek.py b/src/yada/models/deepseek.py index 233886a..81dea1c 100644 --- a/src/yada/models/deepseek.py +++ b/src/yada/models/deepseek.py @@ -98,10 +98,6 @@ def complete( for key in ("role", "content", "reasoning_content", "tool_calls") if key in raw_message } - message_field_presence = { - key: key in raw_message - for key in ("role", "content", "reasoning_content", "tool_calls") - } message.setdefault("role", "assistant") message.setdefault("content", "") return Completion( @@ -111,7 +107,6 @@ def complete( model=response_data.get("model"), system_fingerprint=response_data.get("system_fingerprint"), finish_reason=choice.get("finish_reason"), - message_field_presence=message_field_presence, ) def request_payload( diff --git a/src/yada/traces/html.py b/src/yada/traces/html.py index adec348..2e82395 100644 --- a/src/yada/traces/html.py +++ b/src/yada/traces/html.py @@ -17,9 +17,9 @@ read_located_trace, ) -_FIELDS = ("role", "content", "reasoning_content", "tool_calls") _EDIT_TOOLS = {"apply_patch", "replace_text"} _COLLAPSE_CHARS = 4_000 +_TITLE_CHARS = 120 def render_trace_html(path: Path) -> str: @@ -43,9 +43,9 @@ def render_trace_html(path: Path) -> str: + _render_run_details(run) + '
' + '