@martinffx/atelier 3.7.0 → 3.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,919 @@
1
+ """Tests for extract_sessions.py. All data is synthetic and built inline."""
2
+ import contextlib
3
+ import io
4
+ import json
5
+ import os
6
+ import shutil
7
+ import sqlite3
8
+ import subprocess
9
+ import sys
10
+ import tempfile
11
+ import unittest
12
+ from datetime import datetime, timezone
13
+ from unittest import mock
14
+
15
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
16
+ import extract_sessions as ex # noqa: E402
17
+
18
+ REPO = "/work/proj"
19
+ ENC = "-work-proj"
20
+
21
+
22
+ def write_jsonl(path, rows):
23
+ os.makedirs(os.path.dirname(path), exist_ok=True)
24
+ with open(path, "w") as fh:
25
+ for r in rows:
26
+ fh.write(json.dumps(r) + "\n")
27
+
28
+
29
+ def run_main(argv, stdin=""):
30
+ buf = io.StringIO()
31
+ with contextlib.redirect_stdout(buf), mock.patch.object(sys, "stdin", io.StringIO(stdin)), \
32
+ mock.patch.object(ex, "list_worktrees", return_value=[]):
33
+ code = ex.main(argv)
34
+ return code, buf.getvalue()
35
+
36
+
37
+ def output_dir(text):
38
+ line = [l for l in text.splitlines() if l.startswith("output: ")][0]
39
+ return line[len("output: "):].split(" (")[0]
40
+
41
+
42
+ def cl_user(text, branch, **kw):
43
+ d = {"type": "user", "isSidechain": False, "gitBranch": branch,
44
+ "origin": {"kind": "human"}, "message": {"role": "user", "content": text}}
45
+ d.update(kw)
46
+ return d
47
+
48
+
49
+ def cl_assistant_text(text, branch):
50
+ return {"type": "assistant", "isSidechain": False, "gitBranch": branch,
51
+ "message": {"role": "assistant", "content": [{"type": "text", "text": text}]}}
52
+
53
+
54
+ def cl_tool_use(tid, name, inp, branch):
55
+ return {"type": "assistant", "isSidechain": False, "gitBranch": branch,
56
+ "message": {"role": "assistant",
57
+ "content": [{"type": "tool_use", "id": tid, "name": name, "input": inp}]}}
58
+
59
+
60
+ def cl_tool_result(tid, content, branch, **kw):
61
+ d = {"type": "user", "isSidechain": False, "gitBranch": branch,
62
+ "message": {"role": "user",
63
+ "content": [{"type": "tool_result", "tool_use_id": tid, "content": content}]}}
64
+ d.update(kw)
65
+ return d
66
+
67
+
68
+ class ClaudeTests(unittest.TestCase):
69
+ def setUp(self):
70
+ self.tmp = tempfile.TemporaryDirectory()
71
+ self.root = self.tmp.name
72
+ self.pdir = os.path.join(self.root, ENC)
73
+
74
+ def tearDown(self):
75
+ self.tmp.cleanup()
76
+
77
+ def run_claude(self, branch="feat/x", base="main", worktrees=()):
78
+ return ex.extract_claude(self.root, [REPO] + list(worktrees), branch, base)
79
+
80
+ def kinds(self, result):
81
+ return [(r["kind"], r["line"]) for rg in result["ranges"] for r in rg["records"]]
82
+
83
+ def test_project_dirs_cover_repo_and_worktrees_only(self):
84
+ for name in (ENC, ENC + "--worktrees-foo", ENC + "-other", "-elsewhere"):
85
+ os.makedirs(os.path.join(self.root, name))
86
+ found = sorted(os.path.basename(p) for p in ex.claude_project_dirs(self.root, [REPO]))
87
+ self.assertEqual(found, sorted([ENC, ENC + "--worktrees-foo"]))
88
+
89
+ def test_ranges_follow_git_branch_with_lead_in(self):
90
+ rows = [
91
+ cl_user("on main before", "main"), # 1 lead-in
92
+ cl_user("still main", "main"), # 2 lead-in
93
+ cl_user("on branch", "feat/x"), # 3
94
+ cl_user("elsewhere", "feat/other"), # 4 excluded
95
+ cl_user("back on branch", "feat/x"), # 5 second range
96
+ ]
97
+ write_jsonl(os.path.join(self.pdir, "s1.jsonl"), rows)
98
+ res = self.run_claude()
99
+ rngs = res["ranges"]
100
+ self.assertEqual([(r["start"], r["end"]) for r in rngs], [(3, 3), (5, 5)])
101
+ self.assertEqual(rngs[0]["lead_in_start"], 1)
102
+ self.assertIsNone(rngs[1]["lead_in_start"])
103
+ texts = [r["text"] for rg in rngs for r in rg["records"] if r["kind"] == "human"]
104
+ self.assertEqual(texts, ["on main before", "still main", "on branch", "back on branch"])
105
+ self.assertTrue(all(r["file"].endswith("s1.jsonl") for rg in rngs for r in rg["records"]))
106
+
107
+ def test_no_match_means_no_ranges(self):
108
+ write_jsonl(os.path.join(self.pdir, "s1.jsonl"), [cl_user("hi", "main")])
109
+ self.assertEqual(self.run_claude()["ranges"], [])
110
+
111
+ def test_human_filtering(self):
112
+ rows = [
113
+ cl_user("real prompt", "feat/x"),
114
+ cl_user("meta", "feat/x", isMeta=True),
115
+ cl_user("<command-name>/clear</command-name>", "feat/x", origin=None),
116
+ cl_user("coordinator note", "feat/x", origin={"kind": "coordinator"}),
117
+ cl_user("legacy prompt without origin", "feat/x", origin=None),
118
+ cl_user("sidechain prompt", "feat/x", isSidechain=True, userType="external"),
119
+ cl_user("[Request interrupted by user]", "feat/x", origin=None),
120
+ ]
121
+ write_jsonl(os.path.join(self.pdir, "s1.jsonl"), rows)
122
+ recs = self.run_claude()["ranges"][0]["records"]
123
+ got = [(r["kind"], r["text"]) for r in recs]
124
+ self.assertEqual(got, [("human", "real prompt"),
125
+ ("human", "legacy prompt without origin"),
126
+ ("interrupt", "[Request interrupted by user]")])
127
+
128
+ def test_subagent_logs_are_skipped_and_counted(self):
129
+ write_jsonl(os.path.join(self.pdir, "s1.jsonl"), [cl_user("keep", "feat/x")])
130
+ write_jsonl(os.path.join(self.pdir, "s1", "subagents", "agent-a.jsonl"),
131
+ [cl_user("parent prompt to subagent", "feat/x", isSidechain=True)])
132
+ res = self.run_claude()
133
+ self.assertEqual(res["notes"], ["1 subagent log(s) skipped"])
134
+ flat = json.dumps(res["ranges"])
135
+ self.assertNotIn("parent prompt to subagent", flat)
136
+
137
+ def test_question_answers_found_by_tool_name_not_wording(self):
138
+ rows = [
139
+ cl_tool_use("t1", "AskUserQuestion", {"questions": []}, "feat/x"),
140
+ cl_tool_result("t1", "totally different wording", "feat/x",
141
+ toolUseResult={"answers": {"Which one?": "Option B"},
142
+ "annotations": {"Which one?": {"notes": "because reasons"}}}),
143
+ cl_tool_use("t2", "Bash", {"command": "ls"}, "feat/x"),
144
+ cl_tool_result("t2", 'Your questions have been answered: "a"="b"', "feat/x",
145
+ toolUseResult={"stdout": "x"}),
146
+ ]
147
+ write_jsonl(os.path.join(self.pdir, "s1.jsonl"), rows)
148
+ recs = self.run_claude()["ranges"][0]["records"]
149
+ answers = [r for r in recs if r["kind"] == "answer"]
150
+ self.assertEqual(len(answers), 1)
151
+ self.assertEqual(answers[0]["line"], 2)
152
+ self.assertIn("Option B", answers[0]["text"])
153
+ self.assertIn("because reasons", answers[0]["text"])
154
+
155
+ def test_denial_marked_by_kind_and_reason_from_later_text(self):
156
+ boiler = ("The user doesn't want to proceed with this tool use. STOP.")
157
+ with_words = boiler + " To tell you how to proceed, the user said:\nuse the other file"
158
+ rows = [
159
+ cl_assistant_text("I will edit the file now", "feat/x"),
160
+ cl_tool_use("t1", "Edit", {"file_path": "/a/b.py"}, "feat/x"),
161
+ cl_tool_result("t1", boiler, "feat/x", toolDenialKind="user-rejected",
162
+ toolUseResult="User rejected tool use"),
163
+ cl_user("no, do the thing differently", "feat/x"),
164
+ cl_tool_use("t2", "Bash", {"command": "rm x"}, "feat/x"),
165
+ cl_tool_result("t2", with_words, "feat/x", toolDenialKind="user-rejected"),
166
+ cl_tool_use("t3", "Bash", {"command": "false"}, "feat/x"),
167
+ cl_tool_result("t3", "exit 1", "feat/x", toolUseResult="x"), # ordinary failure
168
+ ]
169
+ write_jsonl(os.path.join(self.pdir, "s1.jsonl"), rows)
170
+ recs = self.run_claude()["ranges"][0]["records"]
171
+ den = [r for r in recs if r["kind"] == "denial"]
172
+ self.assertEqual([d["tool"] for d in den], ["Edit", "Bash"])
173
+ self.assertNotIn("next_human_line", den[0])
174
+ self.assertNotIn("doesn't want", den[0]["text"])
175
+ self.assertEqual(den[1]["text"], "use the other file")
176
+ # nearby assistant text precedes the first event, truncated, with its own line
177
+ asst = [r for r in recs if r["kind"] == "assistant"]
178
+ self.assertEqual(asst[0]["line"], 1)
179
+ # ordinary tool output never appears
180
+ self.assertNotIn("exit 1", json.dumps(recs))
181
+
182
+ def test_long_text_is_truncated(self):
183
+ write_jsonl(os.path.join(self.pdir, "s1.jsonl"), [
184
+ cl_assistant_text("a" * 5000, "feat/x"),
185
+ cl_user("b" * 5000, "feat/x"),
186
+ ])
187
+ recs = self.run_claude()["ranges"][0]["records"]
188
+ for r in recs:
189
+ self.assertLess(len(r["text"]), 1500)
190
+ self.assertIn("more chars", r["text"])
191
+ asst = [r for r in recs if r["kind"] == "assistant"][0]
192
+ self.assertLess(len(asst["text"]), 450)
193
+
194
+ def test_bad_json_lines_are_skipped(self):
195
+ os.makedirs(self.pdir)
196
+ with open(os.path.join(self.pdir, "s1.jsonl"), "w") as fh:
197
+ fh.write("{not json\n")
198
+ fh.write(json.dumps(cl_user("ok", "feat/x")) + "\n")
199
+ res = self.run_claude()
200
+ self.assertEqual(res["ranges"][0]["records"][0]["line"], 2)
201
+
202
+ def test_non_dict_json_keeps_its_line_slot(self):
203
+ os.makedirs(self.pdir)
204
+ with open(os.path.join(self.pdir, "s1.jsonl"), "w") as fh:
205
+ fh.write('[1, 2]\n42\n"str"\n')
206
+ fh.write(json.dumps(cl_user("ok", "feat/x")) + "\n")
207
+ res = self.run_claude()
208
+ self.assertEqual(res["ranges"][0]["records"][0]["line"], 4)
209
+
210
+ def test_odd_shapes_do_not_abort(self):
211
+ rows = [
212
+ cl_tool_use("t1", "AskUserQuestion", {}, "feat/x"),
213
+ cl_tool_result("t1", "x", "feat/x", toolUseResult={"answers": ["not", "a", "dict"]}),
214
+ cl_tool_result(["unhashable"], "x", "feat/x"),
215
+ cl_user("fine", "feat/x"),
216
+ ]
217
+ write_jsonl(os.path.join(self.pdir, "s1.jsonl"), rows)
218
+ recs = self.run_claude()["ranges"][0]["records"]
219
+ self.assertEqual([r["text"] for r in recs if r["kind"] == "human"], ["fine"])
220
+
221
+ def test_list_form_tool_result_content_is_read_for_denials(self):
222
+ rows = [
223
+ cl_tool_use("t1", "Bash", {"command": "ls"}, "feat/x"),
224
+ cl_tool_result("t1", [{"type": "text", "text": "The user doesn't want to proceed. "
225
+ "the user said:\nuse make"}, "junk"], "feat/x",
226
+ toolDenialKind="user-rejected"),
227
+ ]
228
+ write_jsonl(os.path.join(self.pdir, "s1.jsonl"), rows)
229
+ recs = self.run_claude()["ranges"][0]["records"]
230
+ self.assertEqual([(r["kind"], r["text"]) for r in recs if r["kind"] == "denial"],
231
+ [("denial", "use make")])
232
+
233
+ def test_queued_command_attachment_is_human(self):
234
+ def att(prompt, kind="human"):
235
+ return {"type": "attachment", "isSidechain": False, "gitBranch": "feat/x",
236
+ "attachment": {"type": "queued_command", "prompt": prompt, "humanTurn": True,
237
+ "origin": {"kind": kind}}}
238
+ rows = [att("queued words"),
239
+ att([{"type": "text", "text": "block words"}]),
240
+ att("<task-notification>done</task-notification>"),
241
+ att("agent chatter", kind="task-notification")]
242
+ write_jsonl(os.path.join(self.pdir, "s1.jsonl"), rows)
243
+ recs = self.run_claude()["ranges"][0]["records"]
244
+ self.assertEqual([(r["kind"], r["line"], r["text"]) for r in recs],
245
+ [("human", 1, "queued words"), ("human", 2, "block words")])
246
+
247
+ def test_compact_summary_skipped_and_pasted_content_kept(self):
248
+ rows = [cl_user("summary of the session", "feat/x", isCompactSummary=True),
249
+ cl_user("<pasted_content id=1>stack trace</pasted_content> fix this", "feat/x")]
250
+ write_jsonl(os.path.join(self.pdir, "s1.jsonl"), rows)
251
+ recs = self.run_claude()["ranges"][0]["records"]
252
+ self.assertEqual([r["line"] for r in recs], [2])
253
+ self.assertTrue(recs[0]["text"].startswith("<pasted_content"))
254
+
255
+ def test_unknown_origin_kind_warns_known_kinds_do_not(self):
256
+ rows = [cl_user("a", "feat/x", origin={"kind": "weird"}),
257
+ cl_user("b", "feat/x", origin={"kind": "peer"}),
258
+ cl_user("c", "feat/x", origin={"kind": "task-notification"}),
259
+ cl_user("d", "feat/x")]
260
+ write_jsonl(os.path.join(self.pdir, "s1.jsonl"), rows)
261
+ warn = [w for w in self.run_claude()["warnings"] if "origin.kind" in w]
262
+ self.assertEqual(len(warn), 1)
263
+ self.assertIn("1 user row", warn[0])
264
+ self.assertIn("weird", warn[0])
265
+
266
+ def test_user_rows_without_text_warn(self):
267
+ rows = [cl_user("fine", "feat/x"),
268
+ cl_user("", "feat/x", message={"role": "user", "content": [{"type": "image"}]}),
269
+ cl_user("", "feat/x", origin={"kind": "peer"},
270
+ message={"role": "user", "content": []})]
271
+ write_jsonl(os.path.join(self.pdir, "s1.jsonl"), rows)
272
+ self.assertIn("1 user row(s) had no readable text", self.run_claude()["warnings"])
273
+
274
+ def test_no_gitbranch_warns(self):
275
+ write_jsonl(os.path.join(self.pdir, "s1.jsonl"), [{"type": "user", "message": {}}])
276
+ self.assertTrue(any("no gitBranch" in w for w in self.run_claude()["warnings"]))
277
+
278
+ def test_head_rows_warn(self):
279
+ write_jsonl(os.path.join(self.pdir, "s1.jsonl"),
280
+ [cl_user("x", "HEAD"), cl_user("y", "feat/x")])
281
+ write_jsonl(os.path.join(self.pdir, "s2.jsonl"), [cl_user("z", "HEAD")])
282
+ warns = self.run_claude()["warnings"]
283
+ self.assertTrue(any("border rows whose gitBranch is HEAD" in w for w in warns))
284
+ self.assertTrue(any("only gitBranch HEAD" in w for w in warns))
285
+
286
+ def test_cwd_outside_roots_is_skipped_with_warning(self):
287
+ rows = [cl_user("inside", "feat/x", cwd=REPO + "/sub"),
288
+ cl_user("outside", "feat/x", cwd="/elsewhere"),
289
+ cl_user("worktree", "feat/x", cwd="/work/proj-wt")]
290
+ write_jsonl(os.path.join(self.pdir, "s1.jsonl"), rows)
291
+ res = self.run_claude(worktrees=["/work/proj-wt"])
292
+ self.assertEqual([r["text"] for r in res["ranges"][0]["records"]], ["inside", "worktree"])
293
+ self.assertTrue(any("cwd is outside" in w for w in res["warnings"]))
294
+
295
+ def test_non_empty_worktree_list_adds_project_dirs(self):
296
+ write_jsonl(os.path.join(self.root, "-work-proj-wt", "s1.jsonl"), [cl_user("wt", "feat/x")])
297
+ self.assertEqual(self.run_claude()["ranges"], [])
298
+ self.assertEqual(len(self.run_claude(worktrees=["/work/proj-wt"])["ranges"]), 1)
299
+
300
+
301
+
302
+ class CodexTests(unittest.TestCase):
303
+ def setUp(self):
304
+ self.tmp = tempfile.TemporaryDirectory()
305
+ self.root = self.tmp.name
306
+
307
+ def tearDown(self):
308
+ self.tmp.cleanup()
309
+
310
+ def session(self, name, branch, cwd=REPO, source="cli", extra=()):
311
+ meta = {"type": "session_meta", "payload": {
312
+ "cwd": cwd, "source": source, "git": {"branch": branch, "repository_url": "x"}}}
313
+ rows = [meta,
314
+ {"type": "response_item", "payload": {"type": "message", "role": "user",
315
+ "content": [{"type": "input_text", "text": "# AGENTS.md instructions for x"}]}},
316
+ {"type": "response_item", "payload": {"type": "message", "role": "assistant",
317
+ "content": [{"type": "output_text", "text": "working on it"}]}},
318
+ {"type": "response_item", "payload": {"type": "message", "role": "user",
319
+ "content": [{"type": "input_text", "text": "please change approach"}]}},
320
+ {"type": "event_msg", "payload": {"type": "turn_aborted", "reason": "interrupted"}},
321
+ {"type": "response_item", "payload": {"type": "function_call",
322
+ "name": "request_user_input", "call_id": "c1", "arguments": "{}"}},
323
+ {"type": "response_item", "payload": {"type": "function_call_output",
324
+ "call_id": "c1", "output": json.dumps({"answers": {"q": {"answers": ["yes"]}}})}},
325
+ {"type": "response_item", "payload": {"type": "function_call_output",
326
+ "call_id": "other", "output": "ordinary output"}}] + list(extra)
327
+ write_jsonl(os.path.join(self.root, "2026", "01", "01", name + ".jsonl"), rows)
328
+
329
+ def test_matches_branch_and_repo_and_extracts(self):
330
+ self.session("a", "feat/x")
331
+ self.session("b", "main")
332
+ self.session("c", "feat/x", cwd="/elsewhere")
333
+ self.session("d", "feat/x", source={"subagent": {"thread_spawn": {}}})
334
+ res = ex.extract_codex(self.root, [REPO], "feat/x")
335
+ self.assertEqual(len(res["ranges"]), 1)
336
+ recs = res["ranges"][0]["records"]
337
+ self.assertEqual([r["kind"] for r in recs],
338
+ ["assistant", "human", "interrupt", "answer"])
339
+ self.assertEqual(recs[1]["text"], "please change approach")
340
+ self.assertEqual(recs[1]["line"], 4)
341
+ self.assertIn("yes", recs[3]["text"])
342
+ self.assertNotIn("ordinary output", json.dumps(recs))
343
+ self.assertTrue(any("approximate" in n for n in res["notes"]))
344
+
345
+ def test_unrecognised_header_warns(self):
346
+ self.session("a", "feat/x")
347
+ write_jsonl(os.path.join(self.root, "junk.jsonl"), [["not", "a", "dict"]])
348
+ with open(os.path.join(self.root, "empty.jsonl"), "w"):
349
+ pass
350
+ res = ex.extract_codex(self.root, [REPO], "feat/x")
351
+ self.assertTrue(any("2 .jsonl file(s) skipped" in w for w in res["warnings"]))
352
+
353
+ def test_worktree_cwd_matches_with_non_empty_list(self):
354
+ self.session("a", "feat/x", cwd="/work/proj-wt")
355
+ self.assertEqual(ex.extract_codex(self.root, [REPO], "feat/x")["ranges"], [])
356
+ self.assertEqual(len(ex.extract_codex(self.root, [REPO, "/work/proj-wt"], "feat/x")["ranges"]), 1)
357
+
358
+
359
+ class CliTests(unittest.TestCase):
360
+ def found(self, rows, root, **kw):
361
+ write_jsonl(os.path.join(root, ENC, "s1.jsonl"), rows)
362
+ return self.extract(root, **kw)
363
+
364
+ def extract(self, root, extra=()):
365
+ code, text = run_main(["--repo", REPO, "--branch", "feat/x", "--projects-dir", root]
366
+ + list(extra))
367
+ for line in text.splitlines():
368
+ if line.startswith("output: "):
369
+ self.addCleanup(shutil.rmtree, output_dir(text), True)
370
+ return code, text
371
+
372
+ def test_no_sessions_found_is_explicit_and_clean(self):
373
+ with tempfile.TemporaryDirectory() as root:
374
+ code, text = self.extract(root)
375
+ self.assertEqual(code, 0)
376
+ self.assertTrue(text.startswith("RESULT: no sessions found"))
377
+ self.assertNotIn("output:", text)
378
+
379
+ def test_missing_log_root_still_prints_result_and_warning(self):
380
+ code, text = run_main(["--repo", REPO, "--branch", "feat/x",
381
+ "--projects-dir", "/nonexistent-root-xyz"])
382
+ self.assertTrue(text.startswith("RESULT: no sessions found"))
383
+ self.assertIn("warning: log root /nonexistent-root-xyz does not exist", text)
384
+
385
+ def test_found_writes_range_files_and_summary(self):
386
+ with tempfile.TemporaryDirectory() as root:
387
+ ts = "2026-01-02T03:04:05Z"
388
+ code, text = self.found([cl_user("hello", "feat/x", timestamp=ts)], root)
389
+ out = output_dir(text)
390
+ rf = os.path.join(out, "range-001.jsonl")
391
+ self.assertEqual(code, 0)
392
+ self.assertTrue(text.startswith("RESULT: sessions found"))
393
+ self.assertTrue(os.path.isabs(out))
394
+ self.assertIn(" %s %s:1-1" % (rf, os.path.join(root, ENC, "s1.jsonl")), text)
395
+ self.assertIn("range start: earliest %s | latest %s" % (ts, ts), text)
396
+ self.assertNotIn("warning:", text)
397
+ self.assertEqual(sorted(os.listdir(out)), ["range-001.jsonl"])
398
+ with open(rf) as fh:
399
+ rec = json.loads(fh.readline())
400
+ self.assertEqual((rec["kind"], rec["line"], rec["text"]), ("human", 1, "hello"))
401
+
402
+ def test_unknown_range_start_warns(self):
403
+ with tempfile.TemporaryDirectory() as root:
404
+ _, text = self.found([cl_user("hello", "feat/x")], root)
405
+ self.assertIn("range start: earliest unknown | latest unknown", text)
406
+ self.assertIn("warning: 1 range(s) have no timestamp", text)
407
+
408
+ def test_codex_dispatch_through_main(self):
409
+ with tempfile.TemporaryDirectory() as root:
410
+ write_jsonl(os.path.join(root, "2026", "r.jsonl"), [
411
+ {"type": "session_meta", "payload": {"cwd": REPO, "source": "cli",
412
+ "timestamp": "2026-01-01T00:00:00Z",
413
+ "git": {"branch": "feat/x"}}},
414
+ {"type": "response_item", "payload": {"type": "message", "role": "user",
415
+ "content": [{"type": "input_text", "text": "codex words"}]}}])
416
+ code, text = run_main(["--repo", REPO, "--branch", "feat/x", "--harness", "codex",
417
+ "--projects-dir", root])
418
+ self.addCleanup(shutil.rmtree, output_dir(text), True)
419
+ self.assertEqual(code, 0)
420
+ self.assertIn("harness: codex", text)
421
+ self.assertIn("approximate", text)
422
+ self.assertIn("Codex tool denials are not extracted", text)
423
+ self.assertIn("range start: earliest 2026-01-01T00:00:00Z", text)
424
+
425
+ def test_lone_surrogate_does_not_crash_output(self):
426
+ with tempfile.TemporaryDirectory() as root:
427
+ code, text = self.found([cl_user("bad \ud800 text", "feat/x")], root)
428
+ self.assertEqual(code, 0)
429
+ with open(os.path.join(output_dir(text), "range-001.jsonl"), "rb") as fh:
430
+ data = fh.read()
431
+ self.assertIn(b"\\ud800", data)
432
+ self.assertIn("%d bytes" % len(data), text)
433
+
434
+ def test_extractor_error_still_prints_result(self):
435
+ with mock.patch.object(ex, "extract_claude", side_effect=RuntimeError("boom with secret")):
436
+ code, text = run_main(["--repo", REPO, "--branch", "feat/x"])
437
+ self.assertEqual(code, 1)
438
+ self.assertTrue(text.startswith("RESULT: extractor error"))
439
+ self.assertNotIn("secret", text)
440
+
441
+ def test_failure_after_mkdtemp_removes_the_directory(self):
442
+ made = []
443
+ real = tempfile.mkdtemp
444
+
445
+ def spy(*a, **kw):
446
+ made.append(real(*a, **kw))
447
+ return made[-1]
448
+ with tempfile.TemporaryDirectory() as root:
449
+ write_jsonl(os.path.join(root, ENC, "s1.jsonl"), [cl_user("hello", "feat/x")])
450
+ with mock.patch.object(ex.tempfile, "mkdtemp", spy), \
451
+ mock.patch.object(ex.json, "dumps", side_effect=OSError("disk full")):
452
+ code, text = run_main(["--repo", REPO, "--branch", "feat/x",
453
+ "--projects-dir", root])
454
+ self.assertEqual(code, 1)
455
+ self.assertTrue(text.startswith("RESULT: extractor error"))
456
+ self.assertEqual(len(made), 1)
457
+ self.assertFalse(os.path.exists(made[0]))
458
+
459
+ def test_unreadable_file_becomes_warning(self):
460
+ with tempfile.TemporaryDirectory() as root:
461
+ write_jsonl(os.path.join(root, ENC, "s1.jsonl"), [cl_user("hello", "feat/x")])
462
+ write_jsonl(os.path.join(root, ENC, "s2.jsonl"), [cl_user("again", "feat/x")])
463
+ real = ex.read_jsonl
464
+
465
+ def flaky(path):
466
+ if path.endswith("s2.jsonl"):
467
+ raise OSError("denied")
468
+ return real(path)
469
+ with mock.patch.object(ex, "read_jsonl", flaky):
470
+ _, text = self.extract(root)
471
+ self.assertTrue(text.startswith("RESULT: sessions found"))
472
+ self.assertIn("warning: s2.jsonl could not be read (OSError)", text)
473
+
474
+ def test_list_worktrees_reads_path_and_branch(self):
475
+ porcelain = ("worktree /work/proj\nHEAD abc\nbranch refs/heads/main\n\n"
476
+ "worktree /work/proj/.worktrees/a\nHEAD def\nbranch refs/heads/feat/a\n\n"
477
+ "worktree /work/detached\nHEAD 123\ndetached\n")
478
+ done = subprocess.CompletedProcess([], 0, stdout=porcelain)
479
+ with mock.patch.object(ex.subprocess, "run", return_value=done):
480
+ self.assertEqual(ex.list_worktrees(REPO), [
481
+ ("/work/proj", "main"), ("/work/proj/.worktrees/a", "feat/a"),
482
+ ("/work/detached", None)])
483
+
484
+
485
+ # ---------------------------------------------------------------- OpenCode
486
+
487
+ def ms(text):
488
+ dt = datetime.fromisoformat(text).replace(tzinfo=timezone.utc)
489
+ return int(dt.timestamp() * 1000)
490
+
491
+
492
+ def make_db(path, with_v2=True):
493
+ conn = sqlite3.connect(path)
494
+ if with_v2:
495
+ conn.execute("CREATE TABLE session_v2 (id TEXT PRIMARY KEY, parent_id TEXT, directory TEXT, "
496
+ "title TEXT, time_created INTEGER, time_updated INTEGER)")
497
+ conn.execute("CREATE TABLE session_message (id TEXT PRIMARY KEY, session_id TEXT, type TEXT, "
498
+ "seq INTEGER, time_created INTEGER, time_updated INTEGER, data TEXT, "
499
+ "UNIQUE (session_id, seq))")
500
+ else:
501
+ conn.execute("CREATE TABLE session (id TEXT)")
502
+ conn.commit()
503
+ return conn
504
+
505
+
506
+ def add_session(conn, sid, directory=REPO, parent=None, created="2026-03-04T10:00:00",
507
+ updated="2026-03-04T11:00:00", messages=()):
508
+ conn.execute("INSERT INTO session_v2 VALUES (?, ?, ?, ?, ?, ?)",
509
+ (sid, parent, directory, "title", ms(created), ms(updated)))
510
+ for seq, (mtype, data) in enumerate(messages, 1):
511
+ raw = data if isinstance(data, str) else json.dumps(data)
512
+ conn.execute("INSERT INTO session_message VALUES (?, ?, ?, ?, 0, 0, ?)",
513
+ ("%s-%d" % (sid, seq), sid, mtype, seq, raw))
514
+ conn.commit()
515
+
516
+
517
+ def oc_text(text):
518
+ return {"type": "text", "text": text}
519
+
520
+
521
+ def oc_tool(name, status="completed", inp=None, error=None, metadata=None):
522
+ state = {"status": status, "input": inp or {}, "metadata": metadata or {}}
523
+ if error:
524
+ state["error"] = error
525
+ return {"type": "tool", "id": "x", "name": name, "state": state}
526
+
527
+
528
+ SIGNALS = [
529
+ ("user", {"text": "please start", "time": 1}), # 1 human
530
+ ("assistant", {"content": [{"type": "reasoning", "text": "hidden"}, oc_text("on it")]}), # 2
531
+ ("assistant", {"content": [oc_tool("question", metadata={"answers": [["A", "B"], "C"]})]}), # 3 answer
532
+ ("synthetic", {"text": "injected context"}), # 4 not human
533
+ ("assistant", {"content": [oc_text("editing now"),
534
+ oc_tool("edit", "error", {"filePath": "/a/b.py"},
535
+ {"type": "tool.execution",
536
+ "message": "The user rejected this call: use make"})]}), # 5 denial
537
+ ("assistant", {"content": [oc_tool("bash", "error", {"command": "false"},
538
+ {"type": "tool.execution", "message": "exit 1 ordinary"})]}), # 6 ordinary
539
+ ("assistant", {"content": [oc_tool("bash", "error", {}, {"type": "tool.interrupted",
540
+ "message": "x"})]}), # 7 interrupt
541
+ ("assistant", {"content": [], "error": {"type": "aborted"}}), # 8 interrupt
542
+ ("system", {"text": "system note"}), # 9
543
+ ("compaction", {"text": "summary"}), # 10
544
+ ]
545
+
546
+
547
+ class OpenCodeTests(unittest.TestCase):
548
+ def setUp(self):
549
+ self.tmp = tempfile.TemporaryDirectory()
550
+ self.root = self.tmp.name
551
+ self.db = os.path.join(self.root, "opencode.db")
552
+ self.conn = make_db(self.db)
553
+
554
+ def tearDown(self):
555
+ self.conn.close()
556
+ self.tmp.cleanup()
557
+
558
+ def run_oc(self, worktrees=(), since=None, until=None, skip=()):
559
+ return ex.extract_opencode(self.root, [REPO] + list(worktrees), list(skip), since, until)
560
+
561
+ def test_every_signal_is_extracted_and_nothing_else(self):
562
+ add_session(self.conn, "ses_a", messages=SIGNALS)
563
+ res = self.run_oc()
564
+ self.assertEqual(len(res["ranges"]), 1)
565
+ rg = res["ranges"][0]
566
+ self.assertEqual((rg["start"], rg["end"]), (1, 10))
567
+ recs = rg["records"]
568
+ self.assertEqual([(r["kind"], r["line"]) for r in recs],
569
+ [("human", 1), ("assistant", 2), ("answer", 3), ("assistant", 5),
570
+ ("denial", 5), ("interrupt", 7), ("interrupt", 8)])
571
+ by = {(r["kind"], r["line"]): r for r in recs}
572
+ self.assertEqual(by[("human", 1)]["text"], "please start")
573
+ self.assertEqual(by[("answer", 3)]["text"], "A, B; C")
574
+ self.assertEqual(by[("assistant", 5)]["text"], "editing now")
575
+ den = by[("denial", 5)]
576
+ self.assertEqual((den["tool"], den["target"]), ("edit", "/a/b.py"))
577
+ self.assertIn("use make", den["text"])
578
+ self.assertIn(("interrupt", 7), by)
579
+ self.assertIn(("interrupt", 8), by)
580
+ flat = json.dumps(recs)
581
+ for hidden in ("hidden", "injected context", "ordinary", "system note", "summary"):
582
+ self.assertNotIn(hidden, flat)
583
+ self.assertTrue(all(r["file"] == "opencode.db#ses_a" for r in recs))
584
+ self.assertEqual(rg["file"], self.db + "#ses_a")
585
+ self.assertEqual(res["warnings"], ["no --since/--until given, so every top-level "
586
+ "session in the repo directories matched"])
587
+ self.assertTrue(any("approximate" in n for n in res["notes"]))
588
+
589
+ def test_directory_matching_and_worktrees(self):
590
+ add_session(self.conn, "in", REPO + "/sub", messages=[("user", {"text": "a"})])
591
+ add_session(self.conn, "wt", "/work/proj-wt", messages=[("user", {"text": "b"})])
592
+ add_session(self.conn, "out", "/elsewhere", messages=[("user", {"text": "c"})])
593
+ add_session(self.conn, "near", "/work/proj2", messages=[("user", {"text": "d"})])
594
+ ids = lambda res: [r["file"].split("#")[1] for r in res["ranges"]]
595
+ self.assertEqual(ids(self.run_oc()), ["in"])
596
+ self.assertEqual(sorted(ids(self.run_oc(["/work/proj-wt"]))), ["in", "wt"])
597
+
598
+ def test_subagent_sessions_are_skipped_and_counted(self):
599
+ add_session(self.conn, "top", messages=[("user", {"text": "keep"})])
600
+ add_session(self.conn, "kid", parent="top", messages=[("user", {"text": "parent prompt"})])
601
+ res = self.run_oc()
602
+ self.assertIn("1 subagent session(s) skipped", res["notes"])
603
+ self.assertEqual(len(res["ranges"]), 1)
604
+ self.assertNotIn("parent prompt", json.dumps(res["ranges"]))
605
+
606
+ def test_time_window_includes_and_excludes(self):
607
+ add_session(self.conn, "early", created="2026-03-01T09:00:00", updated="2026-03-01T10:00:00",
608
+ messages=[("user", {"text": "a"})])
609
+ add_session(self.conn, "mid", created="2026-03-04T09:00:00", updated="2026-03-04T10:00:00",
610
+ messages=[("user", {"text": "b"})])
611
+ add_session(self.conn, "late", created="2026-03-09T09:00:00", updated="2026-03-09T10:00:00",
612
+ messages=[("user", {"text": "c"})])
613
+ ids = lambda res: [r["file"].split("#")[1] for r in res["ranges"]]
614
+ got = self.run_oc(since=ms("2026-03-02T00:00:00"), until=ms("2026-03-05T00:00:00"))
615
+ self.assertEqual(ids(got), ["mid"])
616
+ self.assertEqual(got["warnings"], [])
617
+ self.assertEqual(ids(self.run_oc(since=ms("2026-03-04T09:30:00"))), ["mid", "late"])
618
+ self.assertEqual(ids(self.run_oc(until=ms("2026-03-04T09:30:00"))), ["early", "mid"])
619
+
620
+ def test_time_parsing(self):
621
+ z = ex.parse_since("2026-03-04T10:00:00Z")
622
+ self.assertEqual(z, ms("2026-03-04T10:00:00"))
623
+ self.assertEqual(ex.parse_since("2026-03-04T10:00:00+01:00"), ms("2026-03-04T09:00:00"))
624
+ self.assertEqual(ex.parse_since("2026-03-04T10:00:00"), z)
625
+ self.assertEqual(ex.parse_since("2026-03-04"), ms("2026-03-04T00:00:00"))
626
+ self.assertEqual(ex.parse_until("2026-03-04"), ms("2026-03-05T00:00:00") - 1)
627
+ self.assertEqual(ex.parse_until("2026-03-04T10:00:00Z"), z)
628
+ with self.assertRaises(Exception) as cm:
629
+ ex.parse_until("yesterday")
630
+ self.assertEqual(type(cm.exception).__name__, "ArgumentTypeError")
631
+
632
+ def test_database_without_session_v2_is_skipped_with_warning(self):
633
+ add_session(self.conn, "ses_a", messages=[("user", {"text": "hi"})])
634
+ other = make_db(os.path.join(self.root, "opencode-local.db"), with_v2=False)
635
+ other.close()
636
+ res = self.run_oc(since=0)
637
+ self.assertEqual(len(res["ranges"]), 1)
638
+ self.assertEqual(res["files_scanned"], 1)
639
+ self.assertEqual(res["warnings"], ["opencode-local.db has no session_v2 table and was skipped"])
640
+
641
+ def test_unreadable_database_and_wal_without_shm_warn(self):
642
+ add_session(self.conn, "ses_a", messages=[("user", {"text": "hi"})])
643
+ with open(os.path.join(self.root, "bad.db"), "wb") as fh:
644
+ fh.write(b"this is not a database" * 50)
645
+ wal = os.path.join(self.root, "walonly.db")
646
+ make_db(wal).close()
647
+ with open(wal + "-wal", "wb"):
648
+ pass
649
+ res = self.run_oc(since=0)
650
+ self.assertEqual(len(res["ranges"]), 1)
651
+ self.assertIn("bad.db could not be read (DatabaseError)", res["warnings"])
652
+ self.assertIn("walonly.db has a WAL file but no -shm file and was skipped", res["warnings"])
653
+ self.assertNotIn("this is not", json.dumps(res["warnings"]))
654
+
655
+ def test_unknown_types_and_odd_shapes_do_not_abort(self):
656
+ add_session(self.conn, "ses_a", messages=[
657
+ ("mystery", {"text": "x"}),
658
+ ("user", "{not json"),
659
+ ("user", {"text": 5}),
660
+ ("assistant", {"content": "nope"}),
661
+ ("assistant", {"content": [3, {"type": "tool", "state": "bad"},
662
+ oc_tool("question", metadata={"answers": "bad"}),
663
+ oc_tool("bash", "error", {}, {"type": "weird.kind", "message": 9}),
664
+ oc_tool("edit", "error", {}, "not a dict")],
665
+ "error": {"type": "other.kind"}}),
666
+ ("user", {"text": "fine"}),
667
+ ])
668
+ res = self.run_oc(since=0)
669
+ recs = res["ranges"][0]["records"]
670
+ self.assertEqual([(r["kind"], r["text"]) for r in recs], [("human", "fine")])
671
+ self.assertIn("1 message(s) have an unknown type (mystery) and were skipped", res["warnings"])
672
+ self.assertIn("2 user row(s) had no readable text", res["warnings"])
673
+ self.assertIn("2 error(s) have an unknown type (other.kind, weird.kind) and were not read",
674
+ res["warnings"])
675
+
676
+ def test_database_is_opened_with_a_read_only_uri_and_leaves_no_files(self):
677
+ add_session(self.conn, "ses_a", messages=[("user", {"text": "hi"})])
678
+ calls = []
679
+ real = sqlite3.connect
680
+
681
+ def spy(*a, **kw):
682
+ calls.append((a, kw))
683
+ return real(*a, **kw)
684
+ before = sorted(os.listdir(self.root))
685
+ with mock.patch.object(ex.sqlite3, "connect", spy):
686
+ self.run_oc(since=0)
687
+ self.assertEqual(len(calls), 1)
688
+ self.assertTrue(calls[0][0][0].startswith("file:") and "mode=ro" in calls[0][0][0])
689
+ self.assertTrue(calls[0][1].get("uri"))
690
+ self.assertEqual(sorted(os.listdir(self.root)), before)
691
+
692
+ def test_wal_mode_database_is_read_without_creating_side_files(self):
693
+ d = os.path.join(self.root, "wal")
694
+ os.makedirs(d)
695
+ conn = make_db(os.path.join(d, "opencode.db"))
696
+ conn.execute("PRAGMA journal_mode=WAL")
697
+ add_session(conn, "ses_w", messages=[("user", {"text": "x"})])
698
+ conn.close()
699
+ before = sorted(os.listdir(d))
700
+ res = ex.extract_opencode(d, [REPO], [], 0, None)
701
+ self.assertEqual(len(res["ranges"]), 1)
702
+ self.assertEqual(sorted(os.listdir(d)), before)
703
+
704
+ def test_one_unreadable_session_does_not_hide_the_others(self):
705
+ add_session(self.conn, "ses_ok", messages=[("user", {"text": "keep"})])
706
+ add_session(self.conn, "ses_boom", messages=[("user", {"text": "x"})])
707
+ real = ex._opencode_records
708
+
709
+ def flaky(rows, fname, stats):
710
+ if "ses_boom" in fname:
711
+ raise ValueError("boom with secret")
712
+ return real(rows, fname, stats)
713
+ with mock.patch.object(ex, "_opencode_records", flaky):
714
+ res = self.run_oc(since=0)
715
+ self.assertEqual([r["file"].split("#")[1] for r in res["ranges"]], ["ses_ok"])
716
+ self.assertIn("1 session(s) could not be read (ValueError)", res["warnings"])
717
+ self.assertNotIn("secret", json.dumps(res["warnings"]))
718
+
719
+ def test_sessions_under_a_worktree_on_another_branch_are_skipped(self):
720
+ add_session(self.conn, "mine", REPO + "/.worktrees/mine", messages=[("user", {"text": "a"})])
721
+ add_session(self.conn, "other", REPO + "/.worktrees/other/sub",
722
+ messages=[("user", {"text": "b"})])
723
+ add_session(self.conn, "main", REPO, messages=[("user", {"text": "c"})])
724
+ res = self.run_oc(skip=[REPO + "/.worktrees/other"], since=0)
725
+ self.assertEqual(sorted(r["file"].split("#")[1] for r in res["ranges"]), ["main", "mine"])
726
+ self.assertEqual({r["dir"] for r in res["ranges"]}, {REPO, REPO + "/.worktrees/mine"})
727
+
728
+ def refusals(self, error, name="question"):
729
+ add_session(self.conn, "ses_r", messages=[
730
+ ("assistant", {"content": [oc_tool(name, "error", {"filePath": "/a"}, error)]})])
731
+ return [(r["kind"], r["text"], r.get("rule")) for rg in self.run_oc(since=0)["ranges"]
732
+ for r in rg["records"]]
733
+
734
+ def test_rejection_wordings_are_denials(self):
735
+ for msg in ("The user rejected permission to use this specific tool call",
736
+ "Error: The user rejected permission to use this specific tool call",
737
+ "PermissionRejectedError", "QuestionRejectedError"):
738
+ with self.subTest(msg=msg):
739
+ self.conn.execute("DELETE FROM session_v2")
740
+ self.conn.execute("DELETE FROM session_message")
741
+ got = self.refusals({"type": "tool.execution", "message": msg}, "edit")
742
+ self.assertEqual(got, [("denial", msg, None)])
743
+
744
+ def test_question_dismissed_is_an_answer(self):
745
+ got = self.refusals({"type": "tool.execution",
746
+ "message": "The user dismissed this question"})
747
+ self.assertEqual(got, [("answer", "(dismissed)", None)])
748
+
749
+ def test_configured_rule_refusal_is_a_denial_marked_as_a_rule(self):
750
+ msg = "The user has specified a rule which prevents you from using this tool"
751
+ got = self.refusals({"type": "tool.execution", "message": msg}, "bash")
752
+ self.assertEqual(got, [("denial", msg, True)])
753
+
754
+ def test_ordinary_tool_error_mentioning_nothing_is_not_a_denial(self):
755
+ got = self.refusals({"type": "tool.execution", "message": "exit 1"}, "bash")
756
+ self.assertEqual(got, [])
757
+
758
+
759
+ class OpenCodeCliTests(unittest.TestCase):
760
+ def setUp(self):
761
+ self.tmp = tempfile.TemporaryDirectory()
762
+ self.root = os.path.join(self.tmp.name, "oc")
763
+ os.makedirs(self.root)
764
+ self.db = os.path.join(self.root, "opencode.db")
765
+ conn = make_db(self.db)
766
+ add_session(conn, "ses_a", messages=[("user", {"text": "first words"}),
767
+ ("user", {"text": "so use SECRETWORD here"})])
768
+ add_session(conn, "ses_b", created="2026-04-01T00:00:00", updated="2026-04-01T01:00:00",
769
+ messages=[("user", {"text": "other session"})])
770
+ add_session(conn, "kid", parent="ses_a", messages=[("user", {"text": "x"})])
771
+ conn.close()
772
+
773
+ def tearDown(self):
774
+ self.tmp.cleanup()
775
+
776
+ def extract(self, *extra):
777
+ code, text = run_main(["--repo", REPO, "--branch", "feat/x", "--harness", "opencode",
778
+ "--projects-dir", self.root] + list(extra))
779
+ if "output: " in text:
780
+ self.out = output_dir(text)
781
+ self.addCleanup(shutil.rmtree, self.out, True)
782
+ return code, text
783
+
784
+ def test_cli_window_summary_and_no_record_text(self):
785
+ code, text = self.extract("--since", "2026-03-01T00:00:00Z", "--until", "2026-03-04")
786
+ self.assertEqual(code, 0)
787
+ self.assertTrue(text.startswith("RESULT: sessions found"))
788
+ self.assertIn("harness: opencode", text)
789
+ self.assertIn(self.db + "#ses_a:1-2 (dir: %s)" % REPO, text)
790
+ self.assertNotIn("ses_b", text)
791
+ self.assertIn("1 subagent session(s) skipped", text)
792
+ self.assertIn("OpenCode records no branch", text)
793
+ self.assertIn("range start: earliest 2026-03-04T10:00:00Z", text)
794
+ self.assertNotIn("warning:", text)
795
+ self.assertNotIn("first words", text)
796
+ self.assertNotIn("SECRETWORD", text)
797
+
798
+ def test_cli_until_date_covers_the_whole_day(self):
799
+ _, text = self.extract("--until", "2026-03-04")
800
+ self.assertIn("#ses_a:", text)
801
+ _, text = self.extract("--until", "2026-03-03")
802
+ self.assertTrue(text.startswith("RESULT: no sessions found"))
803
+
804
+ def test_cli_bad_time_is_a_usage_error(self):
805
+ with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit):
806
+ self.extract("--since", "not a date")
807
+
808
+ def test_cli_accepts_gits_author_date_format(self):
809
+ # git log --format=%ai: a space for the T, a space before the offset, +HHMM
810
+ _, text = self.extract("--since", "2026-03-04 11:00:00 +0100",
811
+ "--until", "2026-03-04 12:30:00 -0100")
812
+ self.assertIn("#ses_a:", text)
813
+ self.assertEqual(ex.parse_since("2026-03-04 11:00:00 +0100"), ms("2026-03-04T10:00:00"))
814
+ self.assertEqual(ex.parse_since("2026-03-04T11:00:00+0100"), ms("2026-03-04T10:00:00"))
815
+
816
+ def test_verify_checks_the_record_at_the_line(self):
817
+ self.extract("--since", "2026-03-01")
818
+ rf = os.path.join(self.out, "range-001.jsonl")
819
+
820
+ def verify(quote, line):
821
+ code, text = run_main(["--verify", rf, "--line", line], stdin=quote)
822
+ self.assertEqual(code, 0)
823
+ return text.strip()
824
+ self.assertEqual(verify("use SECRETWORD", "2"), "match")
825
+ self.assertEqual(verify("use SECRETWORD", "1"), "no match")
826
+ self.assertEqual(verify("not there", "2"), "no match")
827
+
828
+ def test_assistant_text_at_a_denial_seq_is_not_a_match(self):
829
+ conn = sqlite3.connect(self.db)
830
+ add_session(conn, "ses_d", created="2026-05-04T10:30:00", updated="2026-05-04T10:40:00",
831
+ messages=[
832
+ ("assistant", {"content": [oc_text("I will delete the file"),
833
+ oc_tool("bash", "error", {"command": "rm x"},
834
+ {"type": "tool.execution",
835
+ "message": "The user rejected this: keep it"})]})])
836
+ conn.close()
837
+ _, text = self.extract("--since", "2026-05-04T10:15:00Z", "--until", "2026-05-04T10:45:00Z")
838
+ rf = os.path.join(self.out, "range-001.jsonl")
839
+
840
+ def verify(quote):
841
+ return run_main(["--verify", rf, "--line", "1"], stdin=quote)[1].strip()
842
+ self.assertEqual(verify("keep it"), "match")
843
+ self.assertEqual(verify("I will delete the file"), "no match")
844
+
845
+ def test_verify_rejects_a_non_numeric_line(self):
846
+ with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit):
847
+ run_main(["--verify", "x", "--line", "abc"], stdin="q")
848
+
849
+ def test_error_in_extractor_still_prints_result(self):
850
+ with mock.patch.object(ex, "extract_opencode", side_effect=RuntimeError("boom secret")):
851
+ code, text = run_main(["--repo", REPO, "--branch", "b", "--harness", "opencode"])
852
+ self.assertEqual(code, 1)
853
+ self.assertTrue(text.startswith("RESULT: extractor error"))
854
+ self.assertNotIn("secret", text)
855
+
856
+ def test_worktree_on_another_branch_is_excluded_through_the_cli(self):
857
+ conn = sqlite3.connect(self.db)
858
+ add_session(conn, "ses_wt", REPO + "/.worktrees/other", messages=[("user", {"text": "w"})])
859
+ conn.close()
860
+ wts = [(REPO, "main"), (REPO + "/.worktrees/other", "feat/other"),
861
+ (REPO + "/.worktrees/mine", "feat/x")]
862
+ argv = ["--repo", REPO, "--branch", "feat/x", "--harness", "opencode",
863
+ "--projects-dir", self.root, "--since", "2026-03-01"]
864
+ with mock.patch.object(ex, "list_worktrees", return_value=wts):
865
+ buf = io.StringIO()
866
+ with contextlib.redirect_stdout(buf):
867
+ ex.main(argv)
868
+ self.addCleanup(shutil.rmtree, output_dir(buf.getvalue()), True)
869
+ self.assertIn("#ses_a:", buf.getvalue())
870
+ self.assertNotIn("ses_wt", buf.getvalue())
871
+
872
+
873
+ class VerifyTests(unittest.TestCase):
874
+ BS = chr(92)
875
+ QUOTE = "it's \"quoted\" and C:" + BS + "path" + BS + "n with " + BS + BS + " backslash"
876
+
877
+ def setUp(self):
878
+ self.tmp = tempfile.TemporaryDirectory()
879
+ self.root = os.path.join(self.tmp.name, "logs")
880
+ write_jsonl(os.path.join(self.root, ENC, "s1.jsonl"), [
881
+ cl_assistant_text("assistant words", "feat/x"),
882
+ cl_user("so " + self.QUOTE + "\n and more text", "feat/x")])
883
+ _, text = run_main(["--repo", REPO, "--branch", "feat/x", "--projects-dir", self.root])
884
+ self.addCleanup(shutil.rmtree, output_dir(text), True)
885
+ self.rf = os.path.join(output_dir(text), "range-001.jsonl")
886
+
887
+ def tearDown(self):
888
+ self.tmp.cleanup()
889
+
890
+ def verify(self, quote, line=2, rf=None):
891
+ code, text = run_main(["--verify", rf or self.rf, "--line", str(line)], stdin=quote)
892
+ self.assertEqual(code, 0)
893
+ return text.strip()
894
+
895
+ def test_tricky_quote_matches(self):
896
+ self.assertEqual(self.verify(self.QUOTE), "match")
897
+
898
+ def test_whitespace_is_normalised(self):
899
+ self.assertEqual(self.verify("and\nmore text\n"), "match")
900
+
901
+ def test_wrong_text_wrong_line_and_empty(self):
902
+ self.assertEqual(self.verify("not in the record"), "no match")
903
+ self.assertEqual(self.verify(self.QUOTE, line=1), "no match")
904
+ self.assertEqual(self.verify(" "), "no match")
905
+
906
+ def test_assistant_record_never_matches(self):
907
+ self.assertEqual(self.verify("assistant words", line=1), "no match")
908
+
909
+ def test_unreadable_range_file_is_an_error_not_a_miss(self):
910
+ code, text = run_main(["--verify", os.path.join(self.tmp.name, "nope.jsonl"),
911
+ "--line", "1"], stdin="first")
912
+ self.assertEqual((code, text.strip()), (1, "error: cannot read range file"))
913
+
914
+ def test_never_echoes_record_text(self):
915
+ self.assertNotIn("quoted", self.verify(self.QUOTE))
916
+
917
+
918
+ if __name__ == "__main__":
919
+ unittest.main()