testsAndMisc/python_pkg/session_autopsy/tests/test_traces.py
Krzysztof kuhy Rudnicki a83b5abc27
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
python_pkg: track session_autopsy, finishing its interrupted refactor
The transcript analyzer behind /compile-candidate lived only in the working
tree. Untracked meant unlinted — pre-commit only inspects files handed to it —
so nothing had ever checked it, and it had been abandoned mid-refactor with
every parse raising AttributeError: '_Accumulator' object has no attribute
'first_timestamp'.

The accumulator had been moved onto the grouped records dataclasses, but three
increment sites and parse_session still read flat attributes that no longer
existed. parse_session now hands over acc.meta and acc.counts directly; only
subagent_count comes from elsewhere, since no transcript line carries it.

Two modules broke the 500-line limit, so they were split along seams that were
already there. signatures.py takes the text-signature helpers — traces.py was
importing them from parse.py, making parse an accidental hub, and now neither
imports the other. stats.py takes the pure number and time helpers. parse.py
521 -> 458, detectors.py 559 -> 467.

The structural lint findings were refactored, not configured away.
_counter_candidates took eight arguments; the five naming which counter field to
mine and at what thresholds became a frozen _CounterSpec, so both call sites are
one line. _extract_skill carried in_span and a line budget as loop variables
across thirteen branches; a _SpanBuffer owns that state now, leaving the walk at
four. record() took twelve flat kwargs and now takes one override per record
block, with session_meta() so a partial meta override keeps the standard
timestamp — a bare SessionMeta(slug=...) would have nulled started_at and
quietly changed the per-week arithmetic. All 36 call sites rewritten by an AST
pass. tests/factories.py moved into conftest.py, where every other package here
keeps shared builders and where name-tests-test does not object.

965 tests pass at 100% statement and branch coverage, every hook is green, and
no suppression was added anywhere. `candidates` and `traces skill-finish` both
run against the real corpus.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 16:56:43 +02:00

267 lines
9.8 KiB
Python

"""Tests for compact trace extraction."""
from __future__ import annotations
from typing import TYPE_CHECKING
from python_pkg.session_autopsy import traces
from python_pkg.session_autopsy.detectors import Candidate
from python_pkg.session_autopsy.tests.conftest import (
FileFacts,
assistant_line,
bash_block,
prompt_line,
record,
skill_block,
text_block,
tool_block,
tool_result_line,
write_transcript,
)
if TYPE_CHECKING:
from pathlib import Path
import pytest
from python_pkg.session_autopsy.records import SessionRecord
DENIED = "rm: cannot remove '/home/kuhy/.cache/yay/pkg': Permission denied"
def _candidate(kind: str, sig: str, session_ids: list[str]) -> Candidate:
"""A candidate pointing at the given evidence sessions."""
return Candidate(
id=f"{kind}-x",
kind=kind,
title="t",
sig=sig,
sessions=len(session_ids),
occurrences=1,
avg_tokens=1,
per_week=0.0,
est_weekly_savings=0,
score=1.0,
action="a",
session_ids=session_ids,
)
def _session(
tmp_path: Path, session_id: str, lines: list[dict[str, object] | str]
) -> SessionRecord:
"""Write a transcript and a record whose path points at it."""
path = write_transcript(tmp_path / "projects", session_id, lines)
return record(session_id, file=FileFacts(path=str(path)))
def test_skill_traces_full_span(tmp_path: Path) -> None:
"""Skill spans render TOOL/TEXT/RESULT lines and close on prompts."""
lines: list[dict[str, object] | str] = [
{"type": "attachment"},
{"type": "assistant", "message": "junk"},
{"type": "assistant", "message": {"content": "not-a-list"}},
prompt_line("start", user_type="agent"),
prompt_line("side", sidechain=True),
{"type": "user", "message": "junk"},
assistant_line(skill_block("finish")),
assistant_line(
bash_block("git status"), text_block("Running checks now on this repo.")
),
assistant_line(text_block(" ")),
tool_result_line("line one\n\nline three\nline four"),
tool_result_line(None),
prompt_line("done, thanks"),
assistant_line(skill_block("other")),
assistant_line(bash_block("should not appear")),
]
rec = _session(tmp_path, "sess", lines)
text = traces.render_traces(
_candidate("skill", "finish", ["sess"]), {"sess": rec}, 5
)
assert "TOOL Skill | finish" in text
assert "TOOL Bash" in text
assert "TEXT | Running checks now" in text
assert "RESULT | line one" in text
assert "should not appear" not in text
def test_skill_traces_ignores_other_block_types(tmp_path: Path) -> None:
"""A block that is neither tool_use nor text contributes nothing."""
lines: list[dict[str, object] | str] = [
assistant_line(skill_block("finish")),
assistant_line({"type": "thinking", "thinking": "quietly"}),
assistant_line(bash_block("git status")),
]
rec = _session(tmp_path, "sess", lines)
text = traces.render_traces(
_candidate("skill", "finish", ["sess"]), {"sess": rec}, 5
)
assert "TOOL Skill | finish" in text
assert "TOOL Bash" in text
assert "quietly" not in text
def test_capture_block_ignores_unrecognised_blocks() -> None:
"""Only tool_use and text blocks can contribute a line."""
span = traces._SpanBuffer()
span.start(matched=True, header="TOOL Skill | finish")
traces._capture_block(span, {"type": "thinking", "thinking": "quietly"}, "finish")
traces._capture_block(span, {"type": "tool_result"}, "finish")
traces._capture_block(span, {}, "finish")
# A text block still needs actual text: neither a non-string nor whitespace
# counts.
traces._capture_block(span, {"type": "text", "text": 5}, "finish")
traces._capture_block(span, {"type": "text", "text": " "}, "finish")
assert span.lines == ["TOOL Skill | finish"]
def test_span_buffer_only_captures_inside_a_matching_span() -> None:
"""Lines outside a span, or inside another skill's span, are dropped."""
span = traces._SpanBuffer()
span.add("TEXT | before any span")
span.start(matched=False, header="TOOL Skill | other")
span.add("TEXT | wrong skill")
span.start(matched=True, header="TOOL Skill | finish")
span.add("TEXT | inside")
span.close()
span.add("TEXT | after the span closed")
assert span.lines == ["TOOL Skill | finish", "TEXT | inside"]
def test_span_buffer_honours_the_per_span_budget(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The header is free; captured lines stop at the cap."""
monkeypatch.setattr(traces, "MAX_LINES_PER_SPAN", 2)
span = traces._SpanBuffer()
span.start(matched=True, header="TOOL Skill | finish")
for index in range(5):
span.add(f"TEXT | line {index}")
assert span.lines == ["TOOL Skill | finish", "TEXT | line 0", "TEXT | line 1"]
# A fresh span gets a fresh budget.
span.start(matched=True, header="TOOL Skill | finish")
span.add("TEXT | second span")
assert span.lines[-2:] == ["TOOL Skill | finish", "TEXT | second span"]
def test_skill_traces_span_cap(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""The per-span line cap stops TOOL/TEXT/RESULT accumulation."""
monkeypatch.setattr(traces, "MAX_LINES_PER_SPAN", 1)
lines: list[dict[str, object] | str] = [
assistant_line(skill_block("finish")),
assistant_line(bash_block("one"), bash_block("two")),
assistant_line(text_block("long narration " * 20)),
tool_result_line("r1\nr2"),
]
rec = _session(tmp_path, "cap", lines)
text = traces.render_traces(_candidate("skill", "finish", ["cap"]), {"cap": rec}, 5)
assert text.count("TOOL Bash") == 1
assert "TEXT |" not in text
def test_render_traces_missing_and_cap(tmp_path: Path) -> None:
"""Unknown ids are skipped, capture stops at max, empty → message."""
lines: list[dict[str, object] | str] = [
assistant_line(skill_block("finish")),
assistant_line(bash_block("x")),
]
rec_a = _session(tmp_path, "a", lines)
rec_b = _session(tmp_path, "b", lines)
gone = record("gone", file=FileFacts(path=str(tmp_path / "nope.jsonl")))
by_id = {"a": rec_a, "b": rec_b, "gone": gone}
capped = traces.render_traces(
_candidate("skill", "finish", ["missing", "a", "b"]), by_id, 1
)
assert "## session a" in capped
assert "## session b" not in capped
empty = traces.render_traces(_candidate("skill", "finish", ["gone"]), by_id, 5)
assert "no matching spans found" in empty
def test_error_traces(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Error hits pair the causing command with the line, capped."""
monkeypatch.setattr(traces, "MAX_ERR_HITS_PER_SESSION", 1)
lines: list[dict[str, object] | str] = [
assistant_line(tool_block("Bash", command=5)),
assistant_line(tool_block("Read", file_path="/x")),
assistant_line(bash_block("rm -rf /home/kuhy/.cache/yay/pkg")),
tool_result_line(f"unrelated noise line\n{DENIED}"),
tool_result_line({"stderr": DENIED}),
]
rec = _session(tmp_path, "err", lines)
sig = "rm: cannot remove '<PATH>': Permission denied"
text = traces.render_traces(_candidate("err", sig, ["err"]), {"err": rec}, 5)
assert text.count("CMD | rm -rf") == 1
assert text.count("ERR |") == 1
def test_ngram_traces(tmp_path: Path) -> None:
"""The first window matching the gram renders its raw commands."""
lines: list[dict[str, object] | str] = [
assistant_line(text_block("hi")),
assistant_line(tool_block("Bash", command=7)),
assistant_line(
bash_block("git status"), bash_block("pytest -q"), bash_block("git push")
),
]
rec = _session(tmp_path, "ng", lines)
hit = traces.render_traces(
_candidate("ngram", "git → pytest → git", ["ng"]), {"ng": rec}, 5
)
assert "CMD | git status" in hit
assert "CMD | git push" in hit
miss = traces.render_traces(
_candidate("ngram", "adb → adb", ["ng"]), {"ng": rec}, 5
)
assert "no matching spans found" in miss
def test_prompt_traces(tmp_path: Path) -> None:
"""Matching prompts render with the tools that followed."""
lines: list[dict[str, object] | str] = [
prompt_line("fix the tests please"),
assistant_line(
text_block("narration"),
bash_block("pytest -q"),
tool_block("Read", file_path="/x"),
),
prompt_line("unrelated message here"),
assistant_line(bash_block("ls")),
prompt_line("fix the tests please"),
]
rec = _session(tmp_path, "pr", lines)
text = traces.render_traces(
_candidate("prompt", "fix the tests please", ["pr"]), {"pr": rec}, 5
)
assert text.count("PROMPT | fix the tests please") == 2
assert "THEN | Bash" in text
assert "THEN | Read" in text
assert text.count("THEN |") == 2
def test_iter_lines_tolerates_junk(tmp_path: Path) -> None:
"""Blank, malformed, and non-dict transcript lines are skipped."""
lines: list[dict[str, object] | str] = [
"",
"{bad",
"[1]",
assistant_line(bash_block("ls")),
]
rec = _session(tmp_path, "junk", lines)
text = traces.render_traces(_candidate("ngram", "ls", ["junk"]), {"junk": rec}, 5)
assert "CMD | ls" in text
def test_result_texts_and_clip() -> None:
"""toolUseResult text extraction and clipping edge cases."""
assert traces._result_texts({"toolUseResult": {"stderr": "", "stdout": 3}}) == []
assert traces._result_texts({"toolUseResult": 42}) == []
assert traces._result_texts({"toolUseResult": {"stderr": "e", "stdout": "o"}}) == [
"e",
"o",
]
assert traces._clip("") == ""
assert traces._clip("x" * 500) == "x" * traces.MAX_LINE_CHARS