ai-incidents 1.0.0
test / test (macos-latest, 3.13) (push) Canceled after 0s
test / test (ubuntu-latest, 3.11) (push) Successful in 17s
test / test (ubuntu-latest, 3.12) (push) Successful in 18s
test / test (ubuntu-latest, 3.13) (push) Successful in 15s

This commit is contained in:
Holden Salomon
2026-09-21 17:48:40 +00:00
commit a9e8287065
41 changed files with 4546 additions and 0 deletions
+48
View File
@@ -0,0 +1,48 @@
"""A stand-in judge for tests. Reads the prompt on stdin and answers according to FAKE_JUDGE_MODE.
file-first file the first candidate as a HIGH incident, exclude the rest (default)
exclude-all exclude every candidate
fail exit non-zero
prose exit 0 with a provider message instead of a verdict
leak file an incident whose text contains a secret
"""
import json
import os
import re
import sys
prompt = sys.stdin.read()
marker = os.environ.get("FAKE_JUDGE_MARKER")
if marker:
with open(marker, "a") as f:
f.write(prompt + "\n=====\n")
mode = os.environ.get("FAKE_JUDGE_MODE", "file-first")
ids = re.findall(r"^\[(C\d+)\]", prompt, flags=re.M)
title = os.environ.get("FAKE_JUDGE_TITLE", "Deleted the production table on a hunch")
if mode == "fail":
print("upstream exploded", file=sys.stderr)
sys.exit(3)
if mode == "prose":
print("You've hit your monthly spend limit. Raise it to keep going.")
sys.exit(0)
incidents, excluded = [], []
if mode in ("file-first", "leak") and ids:
what = "ran DROP TABLE on the live database"
if mode == "leak":
what += " and printed password=" + "hunter2hunter2"
incidents.append({
"title": title, "date": "2026-07-14", "severity": "HIGH", "category": "data-loss",
"what": what, "cost": "a day of orders was lost",
"lesson": "never run destructive SQL without a backup", "candidates": [ids[0]],
"why": "real, unrecoverable data loss",
})
rest = ids[1:]
else:
rest = ids
if rest:
excluded.append({"candidates": rest, "reason": "routine cleanup of its own scratch files"})
print("Here is my verdict:\n```json\n" + json.dumps({"incidents": incidents, "excluded": excluded, "patterns": []}) + "\n```")
+90
View File
@@ -0,0 +1,90 @@
"""Builders for synthetic transcripts in both supported formats."""
from __future__ import annotations
import json
import os
import sqlite3
import sys
FAKE_JUDGE = os.path.join(os.path.dirname(__file__), "fake_judge.py")
def cc(role: str, text: str | None = None, *, ts: str = "2026-07-14T10:00:00Z",
tool_use: str | None = None, tool_name: str = "Bash",
tool_result: str | None = None, is_error: bool = False) -> str:
"""One Claude Code JSONL line."""
content: list | str
blocks = []
if text is not None:
blocks.append({"type": "text", "text": text})
if tool_use is not None:
blocks.append({"type": "tool_use", "id": "t1", "name": tool_name, "input": {"command": tool_use}})
if tool_result is not None:
blocks.append({"type": "tool_result", "tool_use_id": "t1", "is_error": is_error,
"content": [{"type": "text", "text": tool_result}]})
content = blocks
return json.dumps({"type": role, "timestamp": ts, "message": {"role": role, "content": content}})
def write_session(root: str, project: str, sid: str, lines: list[str]) -> str:
d = os.path.join(root, project)
os.makedirs(d, exist_ok=True)
path = os.path.join(d, f"{sid}.jsonl")
with open(path, "w") as f:
f.write("\n".join(lines) + "\n")
return path
OC_SCHEMA = """
CREATE TABLE session (id TEXT PRIMARY KEY, project_id TEXT NOT NULL, parent_id TEXT, slug TEXT NOT NULL,
directory TEXT NOT NULL, title TEXT NOT NULL, version TEXT NOT NULL,
time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL);
CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, time_created INTEGER NOT NULL,
time_updated INTEGER NOT NULL, data TEXT NOT NULL);
CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT NOT NULL, session_id TEXT NOT NULL,
time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, data TEXT NOT NULL);
"""
def oc_db(path: str) -> sqlite3.Connection:
con = sqlite3.connect(path)
con.executescript(OC_SCHEMA)
con.execute("PRAGMA journal_mode=WAL")
return con
def oc_session(con: sqlite3.Connection, sid: str, turns: list[tuple[str, list[dict]]], t0: int = 1784023200000) -> None:
"""turns: [(role, [part dicts]), ...]"""
t = t0
con.execute("INSERT INTO session VALUES (?,?,?,?,?,?,?,?,?)",
(sid, "p1", None, "slug", "/work", "title", "1.0.0", t, t + 1000 * len(turns)))
for n, (role, parts) in enumerate(turns):
mid = f"msg_{sid}_{n:03d}"
t += 1000
con.execute("INSERT INTO message VALUES (?,?,?,?,?)", (mid, sid, t, t, json.dumps({"role": role})))
for k, p in enumerate(parts):
con.execute("INSERT INTO part VALUES (?,?,?,?,?,?)",
(f"prt_{sid}_{n:03d}_{k}", mid, sid, t + k, t + k, json.dumps(p)))
con.commit()
def judge_cmd() -> list[str]:
return [sys.executable, FAKE_JUDGE]
def config_toml(tmp, *, claude_root="", oc_glob="", ledger="", state="", seen_list="", extra="") -> str:
parts = []
if claude_root:
parts.append(f'[[source]]\ntype = "claude-code"\npath = "{claude_root}"\n')
if oc_glob:
parts.append(f'[[source]]\ntype = "opencode"\npath = "{oc_glob}"\n')
cmd = ", ".join(json.dumps(x) for x in judge_cmd())
parts.append(f'[judge]\nbackend = "command"\ncommand = [{cmd}]\ntimeout = 60\n')
parts.append(f'[ledger]\ndir = "{ledger}"\n')
parts.append(f'[state]\nfile = "{state}"\n' + (f'seen_list = "{seen_list}"\n' if seen_list else ""))
parts.append(extra)
path = os.path.join(tmp, "config.toml")
with open(path, "w") as f:
f.write("\n".join(parts))
return path
+204
View File
@@ -0,0 +1,204 @@
import http.server
import json
import threading
import pytest
from ai_incidents import judge
from ai_incidents.judge import JudgeConfig, JudgeError, build_message, claude_argv, extract_json, parse_verdict
from ai_incidents.ledger import Entry
from ai_incidents.prefilter import Candidate
from ai_incidents.redact import Redactor
from helpers import judge_cmd
def cands(n=3):
out = []
for i in range(1, n + 1):
c = Candidate("SNIPPET", "claude-code", f"sess{i:04d}", "2026-07-1" + str(i), f" [user] thing {i}")
c.id = f"C{i:02d}"
out.append(c)
return out
GOOD = {
"incidents": [{
"title": "Wiped the cache", "date": "2026-07-12", "severity": "high", "category": "data-loss",
"what": "w", "cost": "c", "lesson": "l", "candidates": ["C01", "C99"], "why": "because",
}],
"excluded": [{"candidates": ["C02"], "reason": "routine", "duplicate_of": "E01"}],
"patterns": [],
}
def test_extract_json_tolerates_fences_and_prose():
assert extract_json('Sure!\n```json\n{"a": 1}\n```\nthanks') == {"a": 1}
assert extract_json('verdict: {"a": {"b": 2}} trailing') == {"a": {"b": 2}}
with pytest.raises(JudgeError):
extract_json("You've hit your monthly spend limit.")
with pytest.raises(JudgeError):
extract_json("{not json")
def test_parse_verdict_validates_and_normalises():
v = parse_verdict(json.dumps(GOOD), cands(), "2026-09-21")
[i] = v.incidents
assert i.severity == "HIGH" and i.candidates == ["C01"] # unknown id dropped
assert v.excluded[0].duplicate_of == "E01"
assert v.unaddressed == ["C03"]
def test_malformed_incidents_are_rejected_not_filed():
bad = {"incidents": [{"title": "x", "severity": "CATASTROPHIC", "what": "w", "cost": "c", "lesson": "l"},
{"title": "no lesson", "severity": "LOW", "what": "w", "cost": "c"},
"not an object"],
"excluded": []}
v = parse_verdict(json.dumps(bad), cands(), "2026-09-21")
assert v.incidents == [] and len(v.rejected) == 3
def test_bad_date_falls_back_to_candidate_date():
raw = {"incidents": [dict(GOOD["incidents"][0], date="last Tuesday", candidates=["C02"])], "excluded": []}
assert parse_verdict(json.dumps(raw), cands(), "2026-09-21").incidents[0].date == "2026-07-12"
def test_unknown_category_becomes_other():
raw = {"incidents": [dict(GOOD["incidents"][0], category="vibes")], "excluded": []}
assert parse_verdict(json.dumps(raw), cands(), "2026-09-21").incidents[0].category == "other"
def test_verdict_text_is_redacted():
raw = {"incidents": [dict(GOOD["incidents"][0], what="printed password=abcdef123456 to the log")],
"excluded": []}
v = parse_verdict(json.dumps(raw), cands(), "2026-09-21", Redactor())
assert "abcdef123456" not in v.incidents[0].what
def test_non_object_answers_fail():
with pytest.raises(JudgeError):
parse_verdict('{"incidents": "none"}', cands(), "2026-09-21")
with pytest.raises(JudgeError):
parse_verdict('{"summary": "all quiet"}', cands(), "2026-09-21")
def test_build_message_lists_candidates_and_ledger():
msg = build_message(cands(2), [Entry.new("Old one", "2026-07-01", "LOW", "what it did", "c", "l")], "2026-09-21")
assert "[C01] SNIPPET · claude-code · sess0001" in msg
assert "[E01] Old one · 2026-07-01 · LOW -- what it did" in msg
def test_claude_argv_disables_every_tool_and_keeps_content_off_argv():
argv = claude_argv(JudgeConfig(model="sonnet"), "RUBRIC")
assert argv[:2] == ["claude", "-p"]
i = argv.index("--tools")
assert argv[i + 1] == ""
for flag in ("--strict-mcp-config", "--no-session-persistence"):
assert flag in argv
assert "--mcp-config" not in argv
assert argv[argv.index("--model") + 1] == "sonnet"
def _fake_claude(tmp_path, envelope: dict | str, code: int = 0) -> str:
"""A stand-in `claude` binary that also records its argv and stdin."""
p = tmp_path / "claude"
out = envelope if isinstance(envelope, str) else json.dumps(envelope)
p.write_text(
"#!/usr/bin/env python3\nimport sys, json, os\n"
f"open({str(tmp_path / 'argv.json')!r}, 'w').write(json.dumps(sys.argv[1:]))\n"
f"open({str(tmp_path / 'stdin.txt')!r}, 'w').write(sys.stdin.read())\n"
f"open({str(tmp_path / 'cwd.txt')!r}, 'w').write(os.getcwd())\n"
f"sys.stdout.write({out!r})\nsys.exit({code})\n"
)
p.chmod(0o755)
return str(p)
def test_claude_backend_reads_envelope_and_usage(tmp_path):
env = {"type": "result", "subtype": "success", "is_error": False, "result": json.dumps(GOOD),
"total_cost_usd": 0.12, "usage": {"input_tokens": 10, "cache_read_input_tokens": 5, "output_tokens": 7}}
cfg = JudgeConfig(binary=_fake_claude(tmp_path, env))
text, usage = judge.call(cfg, "RUBRIC", "CANDIDATES GO HERE")
assert json.loads(text) == GOOD
assert usage == {"total_cost_usd": 0.12, "input_tokens": 15, "output_tokens": 7}
assert (tmp_path / "stdin.txt").read_text() == "CANDIDATES GO HERE"
assert "CANDIDATES GO HERE" not in (tmp_path / "argv.json").read_text()
assert "ai-incidents-judge-" in (tmp_path / "cwd.txt").read_text()
def test_claude_backend_errors(tmp_path):
cfg = JudgeConfig(binary=_fake_claude(tmp_path, {"is_error": True, "result": "You've hit your spend limit"}))
with pytest.raises(JudgeError, match="spend limit"):
judge.call(cfg, "R", "M")
cfg = JudgeConfig(binary=_fake_claude(tmp_path, "plain text, no envelope"))
with pytest.raises(JudgeError, match="envelope"):
judge.call(cfg, "R", "M")
cfg = JudgeConfig(binary=_fake_claude(tmp_path, "", code=1))
with pytest.raises(JudgeError, match="exited 1"):
judge.call(cfg, "R", "M")
with pytest.raises(JudgeError, match="not found"):
judge.call(JudgeConfig(binary=str(tmp_path / "missing")), "R", "M")
def test_command_backend(monkeypatch):
monkeypatch.setenv("FAKE_JUDGE_MODE", "file-first")
cfg = JudgeConfig(backend="command", command=judge_cmd())
text, _ = judge.call(cfg, "RUBRIC", "[C01] SNIPPET · x\n[C02] SNIPPET · y")
v = parse_verdict(text, cands(2), "2026-09-21")
assert [i.candidates for i in v.incidents] == [["C01"]] and v.excluded[0].candidates == ["C02"]
def test_command_backend_timeout(tmp_path):
cfg = JudgeConfig(backend="command", command=["sleep", "5"], timeout=1)
with pytest.raises(JudgeError, match="timed out"):
judge.call(cfg, "R", "M")
class _Handler(http.server.BaseHTTPRequestHandler):
seen = {}
def do_POST(self):
body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
_Handler.seen = {"path": self.path, "body": body, "auth": self.headers.get("Authorization")}
resp = {"choices": [{"message": {"content": json.dumps(GOOD)}}],
"usage": {"prompt_tokens": 100, "completion_tokens": 20}}
data = json.dumps(resp).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def log_message(self, *a):
pass
def test_openai_backend(monkeypatch):
srv = http.server.HTTPServer(("127.0.0.1", 0), _Handler)
t = threading.Thread(target=srv.serve_forever, daemon=True)
t.start()
try:
monkeypatch.setenv("LOCAL_LLM_KEY", "k-123")
cfg = JudgeConfig(backend="openai", model="qwen3", base_url=f"http://127.0.0.1:{srv.server_port}/v1",
api_key_env="LOCAL_LLM_KEY")
text, usage = judge.call(cfg, "RUBRIC", "MSG")
finally:
srv.shutdown()
assert json.loads(text) == GOOD
assert usage == {"input_tokens": 100, "output_tokens": 20}
s = _Handler.seen
assert s["path"] == "/v1/chat/completions" and s["auth"] == "Bearer k-123"
assert s["body"]["messages"][0] == {"role": "system", "content": "RUBRIC"}
assert s["body"]["response_format"] == {"type": "json_object"} and s["body"]["temperature"] == 0
def test_openai_backend_missing_key_and_unreachable(monkeypatch):
monkeypatch.delenv("NOPE_KEY", raising=False)
with pytest.raises(JudgeError, match="NOPE_KEY"):
judge.call(JudgeConfig(backend="openai", api_key_env="NOPE_KEY"), "R", "M")
with pytest.raises(JudgeError, match="unreachable"):
judge.call(JudgeConfig(backend="openai", base_url="http://127.0.0.1:9/v1", timeout=2), "R", "M")
def test_default_rubric_ships_with_the_package():
r = judge.default_rubric()
assert "Answer format" in r and "misattribution" in r.lower()
+154
View File
@@ -0,0 +1,154 @@
import json
import os
from ai_incidents import ledger
from ai_incidents.ledger import Entry, add_entries, add_patterns, fingerprint, index, parse, render
HAND = """# AI incidents
Times AI-generated work bit me.
> Seeded by hand.
## Newer low thing · 2026-09-04 · LOW
- **What:** a
- **Cost:** b
- **Lesson:** c
## Leaked a key into the transcript · 2026-07-13 · HIGH
- **What:** printed the config
and a continuation line written by hand
- **Cost:** rotation
- **Lesson:** never cat a config
## Spiral with a version range · v0.5.8–v0.6.0 · MEDIUM
- **Project:** a hand-added field
- **What:** x
- **Cost:** y
- **Lesson:** z
## Recurring patterns
- **Config dumps leak secrets** -- redact at the source.
---
*Add new incidents at the top.*
"""
def test_parse_keeps_header_entries_and_footer():
led = parse(HAND)
assert led.header.startswith("# AI incidents") and led.header.endswith("> Seeded by hand.")
assert [(e.title, e.date, e.severity) for e in led.entries] == [
("Newer low thing", "2026-09-04", "LOW"),
("Leaked a key into the transcript", "2026-07-13", "HIGH"),
("Spiral with a version range", "v0.5.8–v0.6.0", "MEDIUM"),
]
assert led.footer.startswith("## Recurring patterns")
assert led.footer.endswith("*Add new incidents at the top.*")
assert " and a continuation line written by hand" in led.entries[1].body
def test_render_ranks_by_severity_then_date():
out = render(parse(HAND))
heads = [ln for ln in out.splitlines() if ln.startswith("## ") and "·" in ln]
assert heads == [
"## Leaked a key into the transcript · 2026-07-13 · HIGH",
"## Spiral with a version range · v0.5.8–v0.6.0 · MEDIUM",
"## Newer low thing · 2026-09-04 · LOW",
]
assert out.index("## Recurring patterns") > out.index("## Newer low thing")
def test_render_newest_order():
out = render(parse(HAND), order="newest")
heads = [ln.split(" · ")[0] for ln in out.splitlines() if ln.startswith("## ") and "·" in ln]
# undated (non-ISO) entries sort last
assert heads == ["## Newer low thing", "## Leaked a key into the transcript", "## Spiral with a version range"]
def test_round_trip_is_stable_and_lossless():
once = render(parse(HAND))
assert render(parse(once)) == once
for line in HAND.splitlines():
if line.strip() and line.strip() != "---":
assert line in once, line
def test_same_severity_ties_keep_existing_order():
text = "# L\n\n## B · 2026-01-01 · LOW\n- x\n\n## A · 2026-01-01 · LOW\n- y\n"
assert [e.title for e in ledger.ordered(parse(text).entries)] == ["B", "A"]
def test_add_entries_dedupes_by_title_fingerprint():
led = parse(HAND)
new = [Entry.new("leaked a KEY into the transcript!", "2026-07-20", "LOW", "w", "c", "l"),
Entry.new("Something new", "2026-09-10", "MEDIUM", "w", "c", "l")]
added, dupes = add_entries(led, new)
assert [e.title for e in added] == ["Something new"]
assert [e.title for e in dupes] == ["leaked a KEY into the transcript!"]
assert fingerprint("Leaked a key into the transcript") == fingerprint("leaked a KEY into the transcript!")
def test_model_output_cannot_break_the_structure():
e = Entry.new("## Title · with a dot\nand a newline", "2026-01-01", "HIGH",
"what\n## Injected heading · 2026-01-01 · HIGH", "cost", "lesson")
led = ledger.Ledger("# L", [e])
reparsed = parse(render(led))
assert len(reparsed.entries) == 1
assert reparsed.entries[0].title == "Title - with a dot and a newline"
assert "Injected heading" in reparsed.entries[0].body[0]
def test_new_entry_format():
e = Entry.new("Pushed to main", "2026-07-11", "MEDIUM", "pushed", "a revert", "ask first")
assert e.render() == (
"## Pushed to main · 2026-07-11 · MEDIUM\n"
"- **What:** pushed\n- **Cost:** a revert\n- **Lesson:** ask first"
)
def test_patterns_append_to_existing_section_and_dedupe():
led = parse(HAND)
added = add_patterns(led, ["Matching by name instead of an owned id", "Matching by name instead of an owned id"])
assert added == ["Matching by name instead of an owned id"]
lines = led.footer.splitlines()
i = lines.index("- Matching by name instead of an owned id")
assert lines[i - 1].startswith("- **Config dumps")
assert led.footer.rstrip().endswith("*Add new incidents at the top.*")
assert add_patterns(led, ["Matching by name instead of an owned id"]) == []
def test_patterns_section_created_when_missing():
led = ledger.Ledger("# L", [Entry.new("t", "2026-01-01", "LOW", "w", "c", "l")])
add_patterns(led, ["a pattern"])
assert render(led).rstrip().endswith("## Recurring patterns\n\n- a pattern")
def test_index_preserves_first_seen_and_counts():
led = parse(HAND)
fp = fingerprint("Leaked a key into the transcript")
prior = {"incidents": [{"fp": fp, "first_seen": "2026-07-13"}]}
idx = index(led, prior, "2026-09-21")
assert idx["counts"] == {"total": 3, "high": 1, "medium": 1, "low": 1}
by = {i["fp"]: i for i in idx["incidents"]}
assert by[fp]["first_seen"] == "2026-07-13"
assert by[fingerprint("Newer low thing")]["first_seen"] == "2026-09-21"
json.loads(ledger.dump_index(idx))
def test_fingerprint_matches_the_original_scheme():
# Same algorithm as the state.json of the sweep this was extracted from, so an existing index
# keeps its first_seen dates.
import hashlib
t = "Committed 14 embedded git repos to `main` with `git add -A`"
norm = "committed-14-embedded-git-repos-to-main-with-git-add-a"
assert fingerprint(t) == hashlib.sha1(norm.encode()).hexdigest()[:12]
def test_sample_ledger_is_in_canonical_form():
path = os.path.join(os.path.dirname(__file__), "..", "examples", "sample-ledger.md")
with open(path, encoding="utf-8") as f:
text = f.read()
led = parse(text)
assert 4 <= len(led.entries) <= 6
assert render(led) == text
+388
View File
@@ -0,0 +1,388 @@
"""End-to-end runs against synthetic transcripts and a stand-in judge.
The state tests port the verification scenarios of the sweep this tool was extracted from:
first run seeds a baseline; a failed judge loses nothing (the same candidates come back);
success records the sessions; a recorded session is not shown again; a clean session is recorded
without a judge; the candidate budget defers whole sessions without stranding clean ones after them.
"""
import hashlib
import json
import os
import subprocess
import pytest
from ai_incidents import cli, config, pipeline
from ai_incidents.envelope import EnvelopeError, WriteGuard
from ai_incidents.state import LockedError, RunLock, State
from helpers import cc, config_toml, oc_db, oc_session, write_session
@pytest.fixture
def env(tmp_path, monkeypatch):
monkeypatch.setenv("FAKE_JUDGE_MODE", "file-first")
marker = tmp_path / "judge-calls.txt"
monkeypatch.setenv("FAKE_JUDGE_MARKER", str(marker))
root = tmp_path / "claude"
root.mkdir()
led = tmp_path / "ledger"
st = tmp_path / "state" / "state.json"
cfgp = config_toml(str(tmp_path), claude_root=str(root), ledger=str(led), state=str(st),
extra="[scan]\nfirst_run = \"backfill\"\n")
class E:
pass
e = E()
e.tmp, e.root, e.ledger, e.state, e.cfg_path, e.marker = tmp_path, root, led, st, cfgp, marker
e.cfg = lambda **kw: config.load(cfgp)
e.calls = lambda: marker.read_text().count("=====") if marker.exists() else 0
return e
def bad_session(root, sid="deadbeef0001", date="2026-07-14"):
return write_session(str(root), "-work-proj", sid, [
cc("user", "clean up the database", ts=f"{date}T09:00:00Z"),
cc("assistant", "Running it.", tool_use="psql -c 'DROP TABLE orders'", ts=f"{date}T09:01:00Z"),
cc("assistant", "I made a mistake: that was the live table.", ts=f"{date}T09:02:00Z"),
cc("user", "why did you do that", ts=f"{date}T09:03:00Z"),
])
def clean_session(root, sid="c1ea000000001"):
return write_session(str(root), "-work-proj", sid, [cc("user", "add a test"), cc("assistant", "Added; passing.")])
def quiet(*a, **k):
pass
def run(e, **kw):
return pipeline.run(e.cfg(), say=quiet, **kw)
def snapshot(root):
out = {}
for d, _, files in os.walk(root):
for f in files:
p = os.path.join(d, f)
st = os.stat(p)
out[p] = (hashlib.sha1(open(p, "rb").read()).hexdigest(), st.st_mtime_ns)
return out
# --- the happy path -------------------------------------------------------------------------
def test_run_files_an_incident_and_writes_only_the_ledger(env):
bad_session(env.root)
clean_session(env.root)
o = run(env)
assert o.code == 0 and [i.title for i in o.filed] == ["Deleted the production table on a hunch"]
text = (env.ledger / "incidents.md").read_text()
assert "## Deleted the production table on a hunch · 2026-07-14 · HIGH" in text
assert "- **Lesson:** never run destructive SQL without a backup" in text
reports = os.listdir(env.ledger / "reports")
assert len(reports) == 1
rep = (env.ledger / "reports" / reports[0]).read_text()
assert rep == (env.ledger / "latest.md").read_text()
assert "## Excluded candidates" in rep and "routine cleanup of its own scratch files" in rep
idx = json.loads((env.ledger / "index.json").read_text())
assert idx["counts"]["high"] == 1
assert env.calls() == 1
def test_second_run_is_quiet_and_does_not_call_the_judge(env):
bad_session(env.root)
run(env)
o = run(env)
assert o.code == 0 and "judge not invoked" in o.headline
assert env.calls() == 1
def test_candidates_are_redacted_before_the_judge_sees_them(env):
write_session(str(env.root), "p", "leaky0000001", [
cc("assistant", "Oops, I leaked it: password=correcthorsebattery"),
])
run(env)
prompt = env.marker.read_text()
assert "correcthorsebattery" not in prompt and "[REDACTED]" in prompt
def test_verdict_is_redacted_before_the_ledger(env, monkeypatch):
monkeypatch.setenv("FAKE_JUDGE_MODE", "leak")
bad_session(env.root)
run(env)
assert "hunter2hunter2" not in (env.ledger / "incidents.md").read_text()
# --- state: nothing is lost, nothing is judged twice -----------------------------------------
def test_first_run_baseline_emits_nothing(env, tmp_path):
bad_session(env.root)
cfgp = config_toml(str(tmp_path), claude_root=str(env.root), ledger=str(env.ledger), state=str(env.state))
o = pipeline.run(config.load(cfgp), say=quiet)
assert "judge not invoked" in o.headline and env.calls() == 0
assert not (env.ledger / "incidents.md").exists()
# A session that appears after the baseline is judged.
bad_session(env.root, sid="newsession01", date="2026-07-15")
o = pipeline.run(config.load(cfgp), say=quiet)
assert env.calls() == 1 and len(o.filed) == 1
def test_failed_judge_loses_nothing(env, monkeypatch):
bad_session(env.root)
clean_session(env.root)
for mode in ("fail", "prose"):
monkeypatch.setenv("FAKE_JUDGE_MODE", mode)
o = run(env)
assert o.code == 1
assert not (env.ledger / "incidents.md").exists()
s = State(str(env.state))
# the clean session was recorded; the bad one was not
assert len(s.seen("claude-code")) == 1
monkeypatch.setenv("FAKE_JUDGE_MODE", "file-first")
o = run(env)
assert o.code == 0 and len(o.filed) == 1
assert len(State(str(env.state)).seen("claude-code")) == 2
def test_a_grown_session_is_looked_at_again(env):
p = bad_session(env.root)
run(env)
with open(p, "a") as f:
f.write(cc("user", "you broke the export too", ts="2026-07-16T10:00:00Z") + "\n")
o = run(env)
assert env.calls() == 2
# the same incident, re-surfaced, is not filed twice
assert o.filed == []
assert (env.ledger / "incidents.md").read_text().count("## Deleted the production table") == 1
def test_budget_defers_whole_sessions_and_still_records_clean_ones(env, tmp_path):
for i in range(3):
bad_session(env.root, sid=f"bad{i:09d}")
clean_session(env.root, sid="zzzclean0001") # sorts after the bad ones
cfgp = config_toml(str(tmp_path), claude_root=str(env.root), ledger=str(env.ledger), state=str(env.state),
extra="[scan]\nfirst_run = \"backfill\"\nmax_candidates = 4\nmax_per_session = 4\n")
cfg = config.load(cfgp)
s = State(str(env.state))
res = pipeline.scan(cfg, s)
assert res.stats["claude-code"].with_candidates == 1
assert res.stats["claude-code"].deferred == 2
assert res.stats["claude-code"].clean == 1
pipeline.run(cfg, say=quiet)
pipeline.run(cfg, say=quiet)
pipeline.run(cfg, say=quiet)
assert env.calls() == 3
assert len(State(str(env.state)).seen("claude-code")) == 4
def test_seen_list_export_and_import(env, tmp_path):
bad_session(env.root)
clean_session(env.root)
seen_list = tmp_path / "external" / "seen.txt"
seen_list.parent.mkdir()
cfgp = config_toml(str(tmp_path), claude_root=str(env.root), ledger=str(env.ledger), state=str(env.state),
seen_list=str(seen_list), extra="[scan]\nfirst_run = \"backfill\"\n")
pipeline.run(config.load(cfgp), say=quiet)
keys = seen_list.read_text().split()
assert sorted(k.split(":")[0] for k in keys) == ["c1ea000000001.jsonl", "deadbeef0001.jsonl"]
# A fresh state file with an existing list does not re-judge or re-baseline.
os.remove(env.state)
o = pipeline.run(config.load(cfgp), say=quiet)
assert "judge not invoked" in o.headline and env.calls() == 1
# --- dry run, lock, hooks -----------------------------------------------------------------
def test_dry_run_calls_no_model_and_writes_nothing(env):
bad_session(env.root)
before = snapshot(env.tmp)
o = run(env, dry_run=True)
assert o.code == 0 and "dry run" in o.headline
assert env.calls() == 0
assert snapshot(env.tmp) == before
def test_dry_run_with_judge_still_writes_nothing(env):
bad_session(env.root)
run(env, dry_run=True, with_judge=True)
assert env.calls() == 1
assert not env.ledger.exists() and not env.state.exists()
def test_concurrent_run_is_refused(env):
bad_session(env.root)
guard = WriteGuard(files=[str(env.state) + ".lock"])
with RunLock(guard, str(env.state)):
with pytest.raises(LockedError):
run(env)
def test_hooks(env, tmp_path):
out = tmp_path / "hooks.log"
script = tmp_path / "hook.sh"
script.write_text(f'#!/bin/sh\nprintf "%s %s %s\\n" "$1" "$AI_INCIDENTS_FILED" "$AI_INCIDENTS_HIGH" >> {out}\ncat >> {out}\n')
script.chmod(0o755)
text = open(env.cfg_path).read() + (
f'\n[hooks]\nnotify_cmd = "{script} notify"\non_success_cmd = "{script} ok"\n'
f'on_failure_cmd = "{script} failed"\n')
open(env.cfg_path, "w").write(text)
bad_session(env.root)
run(env)
log = out.read_text()
assert "notify 1 1" in log and "ok 1 1" in log
assert "HIGH Deleted the production table on a hunch" in log
out.unlink()
run(env) # quiet run: heartbeat only
assert out.read_text().startswith("ok 0 0")
def test_failure_hook(env, tmp_path, monkeypatch):
out = tmp_path / "fail.log"
open(env.cfg_path, "a").write(f'\n[hooks]\non_failure_cmd = "sh -c \'cat > {out}\'"\n')
monkeypatch.setenv("FAKE_JUDGE_MODE", "fail")
bad_session(env.root)
assert run(env).code == 1
assert "FAILED: judge" in out.read_text()
# --- the permission envelope ---------------------------------------------------------------
def test_write_guard_refuses_outside_paths(tmp_path):
g = WriteGuard(dirs=[str(tmp_path / "ledger")], files=[str(tmp_path / "state.json")])
g.write_text(str(tmp_path / "ledger" / "reports" / "x.md"), "ok")
g.write_text(str(tmp_path / "state.json"), "{}")
for bad in [tmp_path / "elsewhere.md", tmp_path / "ledger" / ".." / "escape.md", tmp_path / "ledger-evil" / "x"]:
with pytest.raises(EnvelopeError):
g.write_text(str(bad), "no")
os.symlink(tmp_path, tmp_path / "ledger" / "link")
with pytest.raises(EnvelopeError):
g.write_text(str(tmp_path / "ledger" / "link" / "escape.md"), "no")
def test_a_full_run_changes_nothing_outside_the_envelope(env, tmp_path):
bad_session(env.root)
clean_session(env.root)
db = tmp_path / "oc" / "opencode.db"
db.parent.mkdir()
con = oc_db(str(db))
oc_session(con, "ses_abc", [("user", [{"type": "text", "text": "you deleted my notes"}])])
con.close()
open(env.cfg_path, "a").write(f'\n[[source]]\ntype = "opencode"\npath = "{db}"\n')
open(env.cfg_path, "a").write(f'\n[[source]]\ntype = "claude-code"\nname = "cc"\npath = "{env.root}"\n')
before = snapshot(tmp_path)
assert run(env).code == 0
after = snapshot(tmp_path)
changed = {p for p in set(before) | set(after) if before.get(p) != after.get(p)}
# SQLite's WAL index (-shm) is reader bookkeeping, created as the database's own user; the
# database itself and every transcript are untouched.
allowed = (str(env.ledger) + os.sep, str(env.state), str(env.marker), str(db) + "-shm", str(db) + "-wal")
assert changed and all(p.startswith(allowed) for p in changed), sorted(changed)
assert before[str(db)] == after[str(db)]
def test_someone_elses_opencode_database_gets_no_sidecar_files(tmp_path, monkeypatch):
from ai_incidents import sources
db = tmp_path / "opencode.db"
con = oc_db(str(db))
oc_session(con, "ses_abc", [("user", [{"type": "text", "text": "you deleted my notes"}])])
con.close()
before = sorted(os.listdir(tmp_path))
monkeypatch.setattr(sources.os, "geteuid", lambda: os.stat(db).st_uid + 1)
[ref] = list(sources.iter_opencode("opencode", str(db)))
assert ref.load().turns[0].text == "you deleted my notes"
assert sorted(os.listdir(tmp_path)) == before
# --- git ------------------------------------------------------------------------------------
def git(repo, *args):
return subprocess.run(["git", "-C", str(repo), *args], capture_output=True, text=True, check=True).stdout
def test_git_commits_only_what_it_wrote(env):
env.ledger.mkdir()
git(env.ledger, "init", "-q", "-b", "main")
git(env.ledger, "config", "user.name", "Test")
git(env.ledger, "config", "user.email", "test@example.com")
(env.ledger / "notes.txt").write_text("my own uncommitted notes")
(env.ledger / "staged.txt").write_text("someone else's staged work")
git(env.ledger, "add", "staged.txt")
bad_session(env.root)
o = run(env)
assert o.code == 0
files = git(env.ledger, "show", "--name-only", "--format=", "HEAD").split()
assert sorted(files) == sorted(["incidents.md", "index.json", "latest.md", f"reports/{os.listdir(env.ledger / 'reports')[0]}"])
assert git(env.ledger, "log", "-1", "--format=%s").strip() == o.headline
status = git(env.ledger, "status", "--porcelain")
assert "A staged.txt" in status and "?? notes.txt" in status
def test_git_push_goes_to_the_configured_remote_and_verifies_it(env, tmp_path):
remote = tmp_path / "remote.git"
subprocess.run(["git", "init", "-q", "--bare", "-b", "main", str(remote)], check=True)
env.ledger.mkdir()
git(env.ledger, "init", "-q", "-b", "main")
git(env.ledger, "config", "user.name", "Test")
git(env.ledger, "config", "user.email", "test@example.com")
git(env.ledger, "commit", "-q", "--allow-empty", "-m", "init")
git(env.ledger, "remote", "add", "origin", str(remote))
git(env.ledger, "push", "-q", "origin", "main")
open(env.cfg_path, "a").write(f'\n[git]\npush = true\nexpected_remote_url = "{remote}"\n')
bad_session(env.root)
assert run(env).code == 0
assert "ai-incidents" in git(remote, "log", "-1", "--format=%s")
# A repointed checkout is refused before anything is pushed or written.
git(env.ledger, "remote", "set-url", "origin", str(tmp_path / "somewhere-else.git"))
bad_session(env.root, sid="another00001", date="2026-07-20")
o = run(env)
assert o.code == 1 and "refusing" in o.headline
# --- CLI ------------------------------------------------------------------------------------
def test_cli_init_scan_config_and_exit_codes(tmp_path, monkeypatch, capsys):
cfgp = str(tmp_path / "cfg" / "config.toml")
led = str(tmp_path / "led")
assert cli.main(["-c", cfgp, "init", "--ledger", led, "--git"]) == 0
assert os.path.isdir(os.path.join(led, ".git"))
assert cli.main(["-c", cfgp, "init", "--ledger", led]) == 2 # refuses to overwrite
cfg = config.load(cfgp)
assert cfg.ledger.dir == led and cfg.judge.backend == "claude" and cfg.judge.model == "sonnet"
assert cli.main(["-c", cfgp, "config"]) == 0
assert "writes:" in capsys.readouterr().out
assert cli.main(["-c", str(tmp_path / "missing.toml"), "run"]) == 2
def test_config_rejects_typos_and_bad_values(tmp_path):
with pytest.raises(config.ConfigError, match="unknown key"):
config.from_dict({"judge": {"modle": "x"}})
with pytest.raises(config.ConfigError, match="must be int"):
config.from_dict({"scan": {"max_candidates": "50"}})
with pytest.raises(config.ConfigError, match="first_run"):
config.from_dict({"scan": {"first_run": "sometimes"}})
with pytest.raises(config.ConfigError, match="used twice"):
config.from_dict({"source": [{"type": "claude-code"}, {"type": "claude-code"}]})
with pytest.raises(config.ConfigError, match="needs"):
config.from_dict({"judge": {"backend": "command"}})
cfg = config.from_dict({})
assert [s.type for s in cfg.sources] == ["claude-code", "opencode"]
def test_reindex_reranks_hand_edits(env):
env.ledger.mkdir()
(env.ledger / "incidents.md").write_text(
"# L\n\n## Low one · 2026-09-01 · LOW\n- **What:** a\n\n## High one · 2026-07-01 · HIGH\n- **What:** b\n")
assert pipeline.reindex(env.cfg(), say=quiet) == 0
text = (env.ledger / "incidents.md").read_text()
assert text.index("High one") < text.index("Low one")
assert json.loads((env.ledger / "index.json").read_text())["counts"]["total"] == 2
def test_example_config_is_valid():
path = os.path.join(os.path.dirname(__file__), "..", "examples", "config.toml")
cfg = config.load(path)
assert cfg.judge.backend == "claude" and cfg.ledger.order == "severity"
assert [s.type for s in cfg.sources] == ["claude-code", "opencode"]
+177
View File
@@ -0,0 +1,177 @@
"""The deterministic pre-filter: what counts as a candidate, and in which shape.
Several of these are regressions for misses in the sweep this tool was extracted from: a scanner
that only matched confessions scored MISS on three real incidents in one day, and a destructive
command that exited 0 was invisible until tool output was read.
"""
from ai_incidents.prefilter import (
AI_ADMIT, BLAME, SYNTHETIC, USER_SIG, Patterns, behaviour, danger_rank, extract, is_hit,
)
from ai_incidents.sources import Session, ToolCall, ToolResult, Turn
def sess(turns=(), calls=(), results=()):
return Session([Turn(r, "2026-07-14", t) for r, t in turns],
[ToolCall("Bash", c) for c in calls],
[ToolResult(e, t) for e, t in results])
# --- signals --------------------------------------------------------------------------------
def test_agent_admissions_match():
for s in ["Sorry, I made a mistake there", "That was my error.", "I accidentally deleted the branch",
"the outage was mine", "This leaked the API key", "I had to revert the migration"]:
assert AI_ADMIT.search(s), s
def test_user_blame_matches():
for s in ["you deleted my notes", "why did you push to main?", "that's not what I asked", "put it back"]:
assert USER_SIG.search(s), s
def test_ordinary_text_does_not_match():
for s in ["I added the endpoint and the tests pass", "Let's refactor the parser next"]:
assert not AI_ADMIT.search(s) and not USER_SIG.search(s) and not BLAME.search(s)
def test_blame_catches_is_as_well_as_contraction():
# "this IS a bug in X" is the commoner phrasing and was once missed by a pattern that only
# accepted "this's"/"that's".
assert BLAME.search("This is a bug in node_exporter's parser")
assert BLAME.search("that's a known issue with the upstream library")
assert BLAME.search("I'll pin it to an older version as a workaround for the regression")
def test_harness_injected_user_turns_are_not_blame():
for s in ["<system-reminder>why did you ...</system-reminder>",
"<local-command-caveat>Caveat: roll back</local-command-caveat>",
"This session is being continued from a previous conversation. You broke x."]:
assert SYNTHETIC.match(s)
assert not is_hit("user", s, Patterns())
def test_user_blame_only_counts_from_the_user():
assert is_hit("user", "you broke the build", Patterns())
assert not is_hit("assistant", "you broke the build", Patterns())
# --- behaviour ------------------------------------------------------------------------------
def test_destructive_commands_are_collected_and_scratch_is_benign():
s = sess(calls=["rm -rf /srv/data/uploads", "rm -rf /tmp/build-cache", "rm -rf mut # scratchpad",
"git worktree remove ../wt", "ls -la"])
danger, _ = behaviour(s, Patterns())
assert danger == ["Bash: rm -rf /srv/data/uploads"]
def test_file_paths_are_not_commands():
# A Read or Edit of a file called truncate.py is not a truncate.
s = Session(calls=[ToolCall("Edit", "")])
assert behaviour(s, Patterns()) == ([], [])
def test_success_output_warnings_are_evidence():
out = "\n".join(["warning: adding embedded git repository: rr/a"] * 14)
_, failures = behaviour(sess(results=[(False, out)]), Patterns())
assert failures == ["warning: adding embedded git repository: rr/a (x14)"]
def test_routine_failures_are_ignored_and_real_ones_kept():
s = sess(results=[
(True, "grep: foo: No such file or directory"),
(True, "fatal: not a git repository"),
(True, 'Traceback (most recent call last):\n File "<string>", line 1\nKeyError: x'),
(True, 'Traceback (most recent call last):\n File "/srv/app/main.py", line 9\nKeyError: x'),
(True, "You've hit your spend limit"),
])
_, failures = behaviour(s, Patterns())
assert len(failures) == 2
assert failures[0].startswith("Traceback (most recent call last): File \"/srv/app/main.py\"")
assert "spend limit" in failures[1]
def test_danger_rank_puts_history_rewrites_first():
cmds = ["pkill -f worker", "systemctl stop web", "rm -rf /tmp/x", "rm -rf ./data",
"git rm -r --cached .", "git reset --hard HEAD~3"]
ranked = sorted(cmds, key=danger_rank)
assert ranked[:2] == ["git rm -r --cached .", "git reset --hard HEAD~3"]
assert ranked[2] == "rm -rf ./data"
assert ranked[-1] == "rm -rf /tmp/x"
def test_extra_patterns_from_config():
pats = Patterns.with_extras(destructive=[r"\bterraform\s+destroy\b"], alarm=[r"MyJobOverdue"])
danger, _ = behaviour(sess(calls=["terraform destroy -auto-approve"]), pats)
assert danger
assert is_hit("assistant", "MyJobOverdue fired at 03:00", pats)
assert not is_hit("assistant", "MyJobOverdue fired at 03:00", Patterns())
# --- extraction shapes ----------------------------------------------------------------------
def test_clean_session_has_nothing_to_judge():
ex = extract(sess(turns=[("user", "add a test"), ("assistant", "done, tests pass")], calls=["pytest"]),
"claude-code", "abcd1234")
assert ex.clean and not ex.candidates
def test_snippet_carries_surrounding_turns():
ex = extract(sess(turns=[("user", "clean up the repo"), ("assistant", "I deleted the wrong directory"),
("user", "restore it")]), "claude-code", "abcd1234")
assert [c.kind for c in ex.candidates] == ["SNIPPET", "SNIPPET"]
body = ex.candidates[0].body
assert "[user] clean up the repo" in body and "[assistant] I deleted the wrong directory" in body
def test_digest_when_nobody_says_anything():
# The incidents nobody narrates: no confession, no complaint, only commands.
ex = extract(sess(turns=[("user", "tidy the checkout"), ("assistant", "Done.")],
calls=["git reset --hard origin/main"]), "claude-code", "abcd1234")
[c] = ex.candidates
assert c.kind == "DIGEST"
assert "[task] tidy the checkout" in c.body
assert "! Bash: git reset --hard origin/main" in c.body
assert "[ended] Done." in c.body
def test_a_session_that_talked_is_still_a_session_that_did():
# A session with confessions about one thing must still have its commands examined.
ex = extract(sess(turns=[("user", "commit it"), ("assistant", "My mistake, wrong branch name")],
calls=["git add -A && git commit -m wip"],
results=[(False, "warning: adding embedded git repository: a\n" * 3)]),
"claude-code", "abcd1234")
assert [c.kind for c in ex.candidates] == ["SNIPPET", "EVIDENCE"]
assert "embedded git repository" in ex.candidates[1].body
def test_evidence_keeps_the_worst_commands_when_capped():
calls = [f"rm -rf /tmp/scratch-{i} && true" for i in range(10)] + ["git rm -r --cached ."]
# the /tmp ones are not BENIGN here (not matched by the scratch patterns) so they compete
calls = [c.replace("/tmp/", "./tmp-") for c in calls]
ex = extract(sess(turns=[("user", "go")], calls=calls), "claude-code", "abcd1234")
assert "git rm -r --cached ." in ex.candidates[0].body.splitlines()[2]
def test_excerpts_are_one_line_each():
ex = extract(sess(turns=[("assistant", "I broke it\n## Fake heading\nmore")]), "claude-code", "abcd1234")
lines = ex.candidates[0].body.splitlines()
assert len(lines) == 1 and "## Fake heading" in lines[0]
def test_talkative_session_is_cut_once_and_keeps_its_evidence():
turns = [("assistant", f"I made a mistake #{i}") for i in range(30)]
ex = extract(sess(turns=turns, calls=["git push --force origin main"]), "claude-code", "abcd1234",
per_session=5)
assert len(ex.candidates) == 5
assert ex.candidates[-1].kind == "EVIDENCE"
assert ex.dropped == 26
assert "not shown" in ex.candidates[-1].body
def test_candidate_render_has_id_and_shape():
ex = extract(sess(turns=[("user", "go")], calls=["rm -rf ./build"]), "opencode", "f44d5651")
c = ex.candidates[0]
c.id = "C07"
assert c.render().splitlines()[0] == (
"[C07] DIGEST · opencode · f44d5651 · 2026-07-14 -- nobody said anything; the commands did")
+52
View File
@@ -0,0 +1,52 @@
"""Secret-shaped strings never reach the judge or the ledger. Test tokens are assembled at runtime
so this file does not itself look like it contains credentials."""
from ai_incidents.redact import MASK, Redactor
r = Redactor()
def test_token_formats():
samples = [
"gh" + "p_" + "A" * 36,
"github" + "_pat_" + "B" * 30,
"s" + "k-ant-" + "c" * 40,
"AK" + "IA" + "ABCDEFGHIJKLMNOP",
"xo" + "xb-" + "1234567890-abcdef",
"ey" + "J" + "a" * 20 + "." + "b" * 20 + "." + "c" * 20,
]
for s in samples:
out = r(f"token is {s} here")
assert s not in out and MASK in out, s
def test_private_key_block():
pem = "-----BEGIN " + "OPENSSH PRIVATE KEY-----\nabc\ndef\n-----END OPENSSH PRIVATE KEY-----"
assert r("key:\n" + pem + "\ndone") == "key:\n" + MASK + "\ndone"
def test_assignments_keep_the_key_and_drop_the_value():
assert r("db_password=s3cr3t-value") == f"db_password={MASK}"
assert r('"api_key": "abcdef123456"') == f'"api_key": "{MASK}' + '"'
assert r("SMTP_PASS" + "WORD: hunter2hunter2") == f"SMTP_PASSWORD: {MASK}"
def test_placeholders_and_numbers_are_left_alone():
for s in ["password=$DB_PASSWORD", "token: ${TOKEN}", "input_tokens: 123456", "password=********",
"secret: true"]:
assert r(s) == s, s
def test_bearer_and_url_credentials():
assert r("Authorization: Bearer " + "x" * 30) == f"Authorization: Bearer {MASK}"
assert r("postgres://app:" + "pw123456" + "@db.example.com/x") == f"postgres://app:{MASK}@db.example.com/x"
def test_ordinary_prose_untouched():
s = "The author reverted the password-reset flow; tokens were fine. See docs at https://example.com/a:b"
assert r(s) == s
def test_disabled_and_extra_patterns():
assert Redactor(enabled=False)("password=abcdefgh") == "password=abcdefgh"
assert Redactor([r"INTERNAL-\d{6}"])("id INTERNAL-123456") == f"id {MASK}"
+94
View File
@@ -0,0 +1,94 @@
import os
import sqlite3
import pytest
from ai_incidents.sources import (
SourceError, claude_key, iter_claude_code, iter_opencode, open_readonly, parse_claude_jsonl,
)
from helpers import cc, oc_db, oc_session, write_session
def test_claude_jsonl_parse(tmp_path):
p = write_session(str(tmp_path), "-home-me-proj", "0123456789abcdef", [
cc("user", "please clean up"),
"not json at all",
"",
'{"type": "summary"}',
cc("assistant", "running it", tool_use="rm -rf ./build"),
cc("user", tool_result="removed 3 files"),
cc("user", tool_result="boom", is_error=True),
])
s = parse_claude_jsonl(p)
assert [(t.role, t.text) for t in s.turns] == [("user", "please clean up"), ("assistant", "running it")]
assert s.turns[0].date == "2026-07-14"
assert [(c.name, c.command) for c in s.calls] == [("Bash", "rm -rf ./build")]
assert [(r.is_error, r.text) for r in s.results] == [(False, "removed 3 files"), (True, "boom")]
def test_claude_key_shape_and_change_on_growth(tmp_path):
p = write_session(str(tmp_path), "proj", "sess", [cc("user", "hi")])
k1 = claude_key(p)
name, mtime, size = k1.split(":")
assert name == "sess.jsonl" and int(size) == os.path.getsize(p) and mtime.isdigit()
with open(p, "a") as f:
f.write(cc("assistant", "more") + "\n")
assert claude_key(p) != k1
def test_claude_iter_includes_subagents_and_missing_root(tmp_path):
write_session(str(tmp_path), "proj", "aaaaaaaa1111", [cc("user", "x")])
write_session(str(tmp_path), "proj/aaaaaaaa1111/subagents", "agent-1", [cc("user", "y")])
refs = list(iter_claude_code("claude-code", str(tmp_path)))
assert sorted(r.short_id for r in refs) == ["aaaaaaaa", "agent-1"]
assert list(iter_claude_code("claude-code", str(tmp_path / "nope"))) == []
def test_opencode_parse(tmp_path):
db = str(tmp_path / "opencode.db")
con = oc_db(db)
oc_session(con, "ses_f44d56513ffe8czutv5E", [
("user", [{"type": "text", "text": "tidy the repo"},
{"type": "text", "text": "<file contents>", "synthetic": True}]),
("assistant", [
{"type": "reasoning", "text": "thinking"},
{"type": "text", "text": "I deleted the wrong folder"},
{"type": "tool", "tool": "bash", "state": {"status": "completed", "input": {"command": "rm -rf data"},
"output": "ok"}},
{"type": "tool", "tool": "read", "state": {"status": "error", "input": {"filePath": "/x"},
"error": "Traceback (most recent call last)"}},
]),
])
con.close()
[ref] = list(iter_opencode("opencode", str(tmp_path / "opencode*.db")))
assert ref.short_id == "f44d5651"
s = ref.load()
assert [(t.role, t.text) for t in s.turns] == [("user", "tidy the repo"), ("assistant", "I deleted the wrong folder")]
assert s.turns[0].date == "2026-07-14"
assert [(c.name, c.command) for c in s.calls] == [("bash", "rm -rf data"), ("read", "")]
assert [(r.is_error, r.text) for r in s.results] == [(False, "ok"), (True, "Traceback (most recent call last)")]
def test_opencode_key_changes_when_session_updates(tmp_path):
db = str(tmp_path / "opencode.db")
con = oc_db(db)
oc_session(con, "ses_1", [("user", [{"type": "text", "text": "hi"}])])
k1 = next(iter_opencode("opencode", db)).key
con.execute("UPDATE session SET time_updated = time_updated + 5")
con.commit()
assert next(iter_opencode("opencode", db)).key != k1
def test_opencode_is_opened_read_only(tmp_path):
db = str(tmp_path / "opencode.db")
oc_db(db).close()
con = open_readonly(db)
with pytest.raises(sqlite3.OperationalError):
con.execute("INSERT INTO session VALUES ('x','p',NULL,'s','/','t','v',1,1)")
def test_opencode_rejects_a_foreign_database(tmp_path):
db = str(tmp_path / "opencode.db")
sqlite3.connect(db).execute("CREATE TABLE unrelated (x)").connection.close()
with pytest.raises(SourceError):
list(iter_opencode("opencode", db))