band-aider-pub/tests/band_aider/test_trace_summary.py
codelens-analysis 93aceedeca Add --trace-file JSONL execution trace and trace summary tool
Wrap the StatusPublisher seam with TracePublisher to mirror every status
event into an append-only JSONL trace (loop transitions, escalations,
interventions, token budgets, run completion), closing the post-run
observability gap for unattended and resumed runs. Add
band_aider.reporting.trace_summary to fold a trace into a readable
digest (durations, loop sequence, escalation triggers, completion and
token facts). Tracing is fail-soft and secret-field redacting; the
summary tolerates truncated tails from killed processes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-28 05:55:32 +08:00

168 lines
5.9 KiB
Python

# SPDX-FileCopyrightText: 2026 Art Sea LLC
#
# SPDX-License-Identifier: AGPL-3.0-only
"""In-memory trace summary aggregation contracts."""
from __future__ import annotations
import io
import time
from band_aider.reporting import trace_summary
from band_aider.ui.dto.human_intervention import (
HumanInterventionRequestDTO,
HumanInterventionResponseDTO,
)
from band_aider.ui.dto.loop_status import LoopStatusDTO
from band_aider.ui.dto.model_escalation import ModelEscalationDTO
from band_aider.ui.dto.run_completion import RunCompletionDTO
from band_aider.ui.dto.supreme_status import SupremeStatusDTO
from band_aider.ui.dto.token_budget_stats import TokenBudgetStatsDTO
from band_aider.ui.trace_publisher import TRACE_FORMAT, TracePublisher
class _NullPublisher:
"""Discard events; only the trace stream matters in these contracts."""
def publish(self, event: object) -> None:
del event
def _loop_status(current_loop: str) -> LoopStatusDTO:
"""Build one loop transition event for the named loop."""
return LoopStatusDTO(
previous_loop=None,
current_loop=current_loop,
next_loop=None,
current_item=None,
work_focus=None,
)
def _escalation(trigger_name: str) -> ModelEscalationDTO:
"""Build one model escalation event for the named trigger."""
return ModelEscalationDTO(
trigger_name=trigger_name,
occurred_at=time.monotonic(),
prompt_preview="prompt",
reason="reason",
request_md5="0" * 32,
local_model="local",
active_model="active",
config_index=0,
config_count=1,
state="escalated",
)
def _synthetic_trace() -> io.StringIO:
"""Record one representative run into an in-memory trace stream.
Loop and token-budget status travel nested inside SupremeStatusDTO in
real runs (see band_aider.ui.status), so most events here take that
shape; one bare LoopStatusDTO proves the flat form still parses.
"""
stream = io.StringIO()
publisher = TracePublisher(_NullPublisher(), stream, origin="memory")
publisher.publish(SupremeStatusDTO(loop=_loop_status("BootstrapLoop")))
publisher.publish(SupremeStatusDTO(loop=_loop_status("BootstrapLoop")))
publisher.publish(SupremeStatusDTO(loop=_loop_status("StabilizationLoop")))
publisher.publish(_loop_status("AlphaLoop"))
publisher.publish(_escalation("FenceCrashingClient"))
publisher.publish(_escalation("FenceCrashingClient"))
publisher.publish(HumanInterventionRequestDTO(
request_id="r1",
check_name="StabilizationDecision",
original_prompt="prompt",
repeated_response_tail="tail",
request_md5="0" * 32,
response_md5="1" * 32,
local_model="local",
escalated_model="active",
))
publisher.publish(HumanInterventionResponseDTO(
request_id="r1", guidance="try smaller edits", action="guide",
))
publisher.publish(SupremeStatusDTO(token_budget=TokenBudgetStatsDTO(
max_prompt_tokens=1000,
last_ai_token_used=10,
total_tokens=123,
bucket_window_seconds=300,
buckets=(),
baseline_tokens=100,
escalated_tokens=23,
)))
publisher.publish(RunCompletionDTO(
final_summary="all done",
report_path="band-aider reports/run.md",
total_tokens_spent=123,
largest_query_tokens=50,
total_seconds=9.5,
average_seconds_per_cot=4.75,
cot_count=2,
files_modified=3,
files_added=1,
llm_call_count=8,
))
stream.seek(0)
return stream
def test_trace_summary_aggregates_events_loops_escalations_and_completion() -> None:
"""One synthetic run folds into counts, transitions, and terminal facts."""
stream = _synthetic_trace()
summary = trace_summary.summarize_stream(stream)
assert summary["format"] == TRACE_FORMAT
assert summary["origin"] == "memory"
assert summary["total_events"] == 10
assert summary["duration_seconds"] >= 0
assert summary["event_counts"]["SupremeStatusDTO"] == 4
assert summary["event_counts"]["LoopStatusDTO"] == 1
assert summary["loop_sequence"] == [
"BootstrapLoop",
"StabilizationLoop",
"AlphaLoop",
]
assert summary["loop_transitions"] == 2
assert summary["escalation_count"] == 2
assert summary["escalation_triggers"] == {"FenceCrashingClient": 2}
assert summary["intervention_requests"] == 1
assert summary["intervention_responses"] == 1
assert summary["run_completion"]["final_summary"] == "all done"
assert summary["run_completion"]["total_tokens_spent"] == 123
assert summary["run_completion"]["llm_call_count"] == 8
assert summary["final_token_budget"]["total_tokens"] == 123
assert summary["final_token_budget"]["escalated_tokens"] == 23
text = trace_summary.render_text(summary)
assert "BootstrapLoop -> StabilizationLoop -> AlphaLoop" in text
assert "escalations: 2" in text
assert "run completion:" in text
def test_trace_summary_tolerates_empty_and_truncated_traces() -> None:
"""Missing and half-written traces still yield a coherent digest."""
empty = trace_summary.summarize_stream(io.StringIO(""))
assert empty["total_events"] == 0
assert empty["loop_sequence"] == []
assert empty["run_completion"] is None
assert "no loop transitions" in trace_summary.render_text(empty)
truncated = io.StringIO(
'{"seq": 0, "ts": "2026-10-28T05:40:00.000+00:00", '
'"event": "trace_start", "data": {"format": "band-aider-trace/1"}}\n'
'{"seq": 1, "ts": "2026-10-28T05:40:01.000+00:00", '
'"event": "LoopStatusDTO", "data": {"current_loop": "AlphaLoop"}}\n'
'{"seq": 2, "ts": "2026-10-28T05:40:02.00' # process died mid-write
)
partial = trace_summary.summarize_stream(truncated)
assert partial["total_records"] == 2
assert partial["total_events"] == 1
assert partial["loop_sequence"] == ["AlphaLoop"]