-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtest_extract_session.py
More file actions
1886 lines (1584 loc) · 87.8 KB
/
Copy pathtest_extract_session.py
File metadata and controls
1886 lines (1584 loc) · 87.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import importlib.util
import json
import os
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SPEC = importlib.util.spec_from_file_location(
"extract_session", ROOT / "scripts" / "extract_session.py"
)
extract_session = importlib.util.module_from_spec(SPEC)
assert SPEC.loader is not None
# Determinism pin (scoped + restored): DEFAULT_TIMEZONE is resolved at import, so
# pin the suite's default zone to +08:00 *for module exec only*, then restore the
# environment. This freezes the in-process defaults at +08:00 (deterministic date
# bucketing) WITHOUT leaking the override into subprocess.run children — so CLI/
# subprocess tests still exercise the real publish default ("local") and a local-
# default regression cannot hide. The publish default is also asserted
# directly via default_timezone({}).
_prev_extract_tz = os.environ.get("SESSION_EXTRACT_TZ")
os.environ["SESSION_EXTRACT_TZ"] = "+08:00"
SPEC.loader.exec_module(extract_session)
if _prev_extract_tz is None:
os.environ.pop("SESSION_EXTRACT_TZ", None)
else:
os.environ["SESSION_EXTRACT_TZ"] = _prev_extract_tz
def write_jsonl(path: Path, role: str, text: str, ts: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"type": role,
"timestamp": ts,
"message": {"role": role, "content": [{"type": "text", "text": text}]},
}
path.write_text(json.dumps(payload, ensure_ascii=False) + "\n", encoding="utf-8")
def append_jsonl(path: Path, payloads: list[dict]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
"".join(json.dumps(payload, ensure_ascii=False) + "\n" for payload in payloads),
encoding="utf-8",
)
def test_process_all_groups_multiple_sessions_per_date(tmp_path):
sessions_dir = tmp_path / "sessions"
out_dir = tmp_path / "out"
write_jsonl(
sessions_dir / "first.jsonl",
"user",
"first session useful request",
"2026-06-17T01:00:00Z",
)
write_jsonl(
sessions_dir / "second.jsonl",
"assistant",
"second session useful answer that is long enough to avoid ack filtering",
"2026-06-17T02:00:00Z",
)
extract_session.process_all(
out_dir=out_dir,
sessions_dir=sessions_dir,
recursive=False,
)
output = (out_dir / "2026-06-17.md").read_text(encoding="utf-8")
assert "**Sources:** 2 JSONL session file(s)" in output
assert "Session `first.jsonl`" in output
assert "Session `second.jsonl`" in output
assert (out_dir / ".extract_session_state.json").exists()
def test_iter_session_files_recursive_is_explicit(tmp_path):
sessions_dir = tmp_path / "sessions"
write_jsonl(
sessions_dir / "top.jsonl",
"user",
"top-level session",
"2026-06-17T01:00:00Z",
)
write_jsonl(
sessions_dir / "top" / "subagents" / "agent-a.jsonl",
"assistant",
"nested subagent session with enough text to survive filtering",
"2026-06-17T02:00:00Z",
)
top_level = [p.relative_to(sessions_dir) for p in extract_session.iter_session_files(sessions_dir)]
recursive = [
p.relative_to(sessions_dir)
for p in extract_session.iter_session_files(sessions_dir, recursive=True)
]
assert top_level == [Path("top.jsonl")]
assert Path("top.jsonl") in recursive
assert Path("top/subagents/agent-a.jsonl") in recursive
def test_is_noise_does_not_drop_multiline_turn_with_ok_line():
text = "Here is a useful note:\n\nok.\n\nContinue with the actual finding."
assert not extract_session.is_noise(text)
assert extract_session.is_noise("ok.")
def test_extract_text_blocks_string_strips_embedded_noise():
raw = "Hello\n<system-reminder>INTERNAL</system-reminder>\nWorld"
result = extract_session.extract_text_blocks(raw)
assert "INTERNAL" not in result
assert "Hello" in result
assert "World" in result
def test_infer_date_default_bucket_uses_system_local_zone(tmp_path):
# The default (no explicit tz) buckets by the system LOCAL zone — the publish
# default — not a frozen owner offset. Pin TZ so this is deterministic.
import time
path = tmp_path / "session.jsonl"
old_tz = os.environ.get("TZ")
os.environ["TZ"] = "America/New_York" # EST -05:00 in winter
time.tzset()
try:
# 02:00Z Jan 2 is 21:00 EST Jan 1 → local default yields 2026-01-01.
# The old +08:00 owner default would have yielded 2026-01-02.
assert extract_session.infer_date([{"ts": "2026-01-02T02:00:00Z"}], path) == "2026-01-01"
finally:
if old_tz is None:
os.environ.pop("TZ", None)
else:
os.environ["TZ"] = old_tz
time.tzset()
def test_default_timezone_defaults_to_local_not_owner_offset():
# Publish contract: no owner-specific offset baked into the default.
assert extract_session.default_timezone({}) == "local"
# SESSION_EXTRACT_TZ still overrides.
assert extract_session.default_timezone({"SESSION_EXTRACT_TZ": "UTC"}) == "UTC"
assert extract_session.default_timezone({"SESSION_EXTRACT_TZ": "+05:30"}) == "+05:30"
def test_cli_default_timezone_is_local():
# The published CLI default must be "local" regardless of the suite's in-process
# +08:00 pin AND of any SESSION_EXTRACT_TZ the test runner/CI itself set. Strip
# the override so this asserts the BINARY's default, not the environment
# (a runner with the var preset must not flip this assertion).
env = {k: v for k, v in os.environ.items() if k != "SESSION_EXTRACT_TZ"}
result = subprocess.run(
[sys.executable, str(ROOT / "scripts" / "extract_session.py"), "--help"],
capture_output=True, text=True, env=env,
)
assert result.returncode == 0
assert "default: local" in result.stdout
assert "default: +08:00" not in result.stdout
def test_suite_tz_pin_is_restored_after_import():
# The module-exec determinism pin must be restored, never left leaking in the
# process env (and thus into subprocess children). Whatever
# SESSION_EXTRACT_TZ was before the suite touched it is what it must be now.
assert os.environ.get("SESSION_EXTRACT_TZ") == _prev_extract_tz
def test_default_sessions_dir_uses_env_override(monkeypatch, tmp_path):
custom = tmp_path / "custom-sessions"
monkeypatch.setenv("CLAUDE_SESSIONS_DIR", str(custom))
assert extract_session.default_sessions_dir() == custom
def test_default_sessions_dir_supports_codex(monkeypatch, tmp_path):
custom = tmp_path / "codex-sessions"
monkeypatch.setenv("CODEX_SESSIONS_DIR", str(custom))
assert extract_session.default_sessions_dir("codex") == custom
def test_parse_codex_jsonl_keeps_messages_and_drops_tools(tmp_path):
# Uses real Codex rollout format: session_meta + response_item wrapper
path = tmp_path / "rollout.jsonl"
append_jsonl(
path,
[
{"type": "session_meta", "timestamp": "2026-06-17T01:00:00Z", "payload": {}},
{
"type": "response_item",
"timestamp": "2026-06-17T01:00:00Z",
"payload": {
"type": "message",
"role": "user",
"content": [
{
"type": "input_text",
"text": "<environment_context>ignore</environment_context><user_instructions>ignore</user_instructions>",
}
],
},
},
{
"type": "response_item",
"timestamp": "2026-06-17T01:00:00Z",
"payload": {
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "mine this useful Codex request"}],
},
},
{
"type": "response_item",
"payload": {"type": "function_call", "name": "shell", "arguments": "{}"},
},
{
"type": "response_item",
"timestamp": "2026-06-17T01:00:00Z",
"payload": {
"type": "message",
"role": "assistant",
"content": [
{
"type": "output_text",
"text": "This is a useful Codex answer that is long enough to avoid ack filtering.",
}
],
},
},
{"type": "response_item", "payload": {"type": "reasoning", "summary": ["drop me"]}},
],
)
turns = extract_session.parse_jsonl(path, source="codex")
assert [turn["role"] for turn in turns] == ["user", "assistant"]
assert "mine this useful Codex request" in turns[0]["text"]
assert "useful Codex answer" in turns[1]["text"]
assert "environment_context" not in "\n".join(turn["text"] for turn in turns)
assert "user_instructions" not in "\n".join(turn["text"] for turn in turns)
assert all(turn["ts"] == "2026-06-17T01:00:00Z" for turn in turns)
def test_detect_source_for_codex_and_claude(tmp_path):
codex = tmp_path / "codex.jsonl"
append_jsonl(
codex,
[
{"type": "session_meta", "timestamp": "2026-06-17T01:00:00Z", "payload": {}},
{
"type": "response_item",
"timestamp": "2026-06-17T01:00:00Z",
"payload": {
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "hello"}],
},
},
],
)
claude = tmp_path / "claude.jsonl"
write_jsonl(claude, "user", "hello from Claude", "2026-06-17T01:00:00Z")
assert extract_session.detect_source(codex) == "codex"
assert extract_session.detect_source(claude) == "claude"
def test_codex_batch_is_recursive_by_default(tmp_path):
sessions_dir = tmp_path / "codex"
out_dir = tmp_path / "out"
append_jsonl(
sessions_dir / "2026" / "06" / "17" / "rollout.jsonl",
[
{"type": "session_meta", "timestamp": "2026-06-17T01:00:00Z", "payload": {}},
{
"type": "response_item",
"timestamp": "2026-06-17T01:00:00Z",
"payload": {
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "codex nested useful request"}],
},
},
],
)
extract_session.process_all(out_dir=out_dir, sessions_dir=sessions_dir, source="codex")
output = (out_dir / "2026-06-17.md").read_text(encoding="utf-8")
state = json.loads((out_dir / ".extract_session_state.json").read_text(encoding="utf-8"))
assert "codex nested useful request" in output
assert state["source"] == "codex"
assert state["recursive"] is True
def test_parse_timezone_supports_utc_and_offsets():
turns = [{"ts": "2026-06-26T17:00:00Z"}]
path = Path("unused.jsonl")
assert extract_session.infer_date(turns, path, extract_session.parse_timezone("+08:00")) == "2026-06-27"
assert extract_session.infer_date(turns, path, extract_session.parse_timezone("UTC")) == "2026-06-26"
def test_local_timezone_applies_per_timestamp_dst():
"""--timezone local must convert each timestamp with its own DST offset, not a
single frozen current offset (historical offsets for local grouping)."""
import os
import time
path = Path("unused.jsonl")
old_tz = os.environ.get("TZ")
os.environ["TZ"] = "America/New_York"
time.tzset()
try:
tz = extract_session.parse_timezone("local")
# 04:30Z in winter is EST (-05:00) → 23:30 the previous day
winter = extract_session.infer_date([{"ts": "2026-01-01T04:30:00Z"}], path, tz)
# 04:30Z in summer is EDT (-04:00) → 00:30 the same day
summer = extract_session.infer_date([{"ts": "2026-07-01T04:30:00Z"}], path, tz)
assert winter == "2025-12-31"
assert summer == "2026-07-01"
finally:
if old_tz is None:
os.environ.pop("TZ", None)
else:
os.environ["TZ"] = old_tz
time.tzset()
def test_process_all_second_run_skips_unchanged_sources(tmp_path, capsys):
sessions_dir = tmp_path / "sessions"
out_dir = tmp_path / "out"
write_jsonl(
sessions_dir / "first.jsonl",
"user",
"first session useful request",
"2026-06-17T01:00:00Z",
)
extract_session.process_all(out_dir=out_dir, sessions_dir=sessions_dir)
first = (out_dir / "2026-06-17.md").read_text(encoding="utf-8")
capsys.readouterr()
extract_session.process_all(out_dir=out_dir, sessions_dir=sessions_dir)
second = (out_dir / "2026-06-17.md").read_text(encoding="utf-8")
err = capsys.readouterr().err
assert first == second
assert "[skip] 2026-06-17: unchanged" in err
assert "nothing new or changed" in err
def test_process_all_rewrites_only_when_source_changes(tmp_path, capsys):
sessions_dir = tmp_path / "sessions"
out_dir = tmp_path / "out"
path = sessions_dir / "first.jsonl"
write_jsonl(path, "user", "first session useful request", "2026-06-17T01:00:00Z")
extract_session.process_all(out_dir=out_dir, sessions_dir=sessions_dir)
capsys.readouterr()
write_jsonl(path, "user", "updated session useful request", "2026-06-17T01:00:00Z")
extract_session.process_all(out_dir=out_dir, sessions_dir=sessions_dir)
output = (out_dir / "2026-06-17.md").read_text(encoding="utf-8")
err = capsys.readouterr().err
assert "updated session useful request" in output
assert "[rewrite] 2026-06-17" in err
def test_process_all_period_filter(tmp_path):
sessions_dir = tmp_path / "sessions"
out_dir = tmp_path / "out"
write_jsonl(
sessions_dir / "old.jsonl",
"user",
"old session useful request",
"2026-06-16T01:00:00Z",
)
write_jsonl(
sessions_dir / "new.jsonl",
"user",
"new session useful request",
"2026-06-17T01:00:00Z",
)
extract_session.process_all(
out_dir=out_dir,
sessions_dir=sessions_dir,
since="2026-06-17",
until="2026-06-17",
)
assert not (out_dir / "2026-06-16.md").exists()
assert (out_dir / "2026-06-17.md").exists()
def test_process_all_refuses_source_or_mode_mismatch_without_force(tmp_path):
sessions_dir = tmp_path / "sessions"
out_dir = tmp_path / "out"
write_jsonl(
sessions_dir / "first.jsonl",
"user",
"first session useful request",
"2026-06-17T01:00:00Z",
)
extract_session.process_all(out_dir=out_dir, sessions_dir=sessions_dir, recursive=False)
try:
extract_session.process_all(out_dir=out_dir, sessions_dir=sessions_dir, recursive=True)
except ValueError as exc:
assert "Use --force or a different --out" in str(exc)
else:
raise AssertionError("expected source/mode mismatch to fail")
def test_process_all_refuses_source_mismatch_without_force(tmp_path):
sessions_dir = tmp_path / "sessions"
out_dir = tmp_path / "out"
write_jsonl(
sessions_dir / "first.jsonl",
"user",
"first session useful request",
"2026-06-17T01:00:00Z",
)
extract_session.process_all(out_dir=out_dir, sessions_dir=sessions_dir, source="claude")
try:
extract_session.process_all(out_dir=out_dir, sessions_dir=sessions_dir, source="codex")
except ValueError as exc:
assert "source='claude'" in str(exc)
else:
raise AssertionError("expected source mismatch to fail")
def test_cli_help_mentions_out_dir():
result = subprocess.run(
[sys.executable, str(ROOT / "scripts" / "extract_session.py"), "--help"],
check=True,
capture_output=True,
text=True,
)
assert "--out-dir" in result.stdout
assert "--source {claude,codex,openclaw,auto}" in result.stdout
assert ".extract_session_state.json" in result.stdout
def test_parse_codex_jsonl_real_format(tmp_path):
"""parse_codex_jsonl must handle real Codex rollout format (response_item wrapper)."""
path = tmp_path / "rollout.jsonl"
append_jsonl(
path,
[
{"type": "session_meta", "timestamp": "2026-06-26T00:27:36.194Z", "payload": {}},
{
"type": "response_item",
"timestamp": "2026-06-26T00:27:36.213Z",
"payload": {
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "mine this useful Codex request"}],
},
},
{
"type": "response_item",
"timestamp": "2026-06-26T00:27:37.000Z",
"payload": {"type": "function_call", "name": "shell", "arguments": "{}"},
},
{
"type": "response_item",
"timestamp": "2026-06-26T00:27:38.000Z",
"payload": {
"type": "message",
"role": "assistant",
"content": [
{
"type": "output_text",
"text": "This is a useful Codex answer that is long enough to avoid ack filtering.",
}
],
},
},
],
)
turns = extract_session.parse_codex_jsonl(path)
assert [t["role"] for t in turns] == ["user", "assistant"]
assert "mine this useful Codex request" in turns[0]["text"]
assert "useful Codex answer" in turns[1]["text"]
def test_batch_all_defaults_source_to_claude(tmp_path):
"""python3 extract_session.py --all must not fail when --source is omitted (defaults to claude)."""
sessions_dir = tmp_path / "sessions"
out_dir = tmp_path / "out"
write_jsonl(
sessions_dir / "s.jsonl",
"user",
"useful session content for default source test",
"2026-06-17T01:00:00Z",
)
# Invoke via CLI: default --source is "auto", main() should translate it to "claude" for --all
result = subprocess.run(
[
sys.executable,
str(ROOT / "scripts" / "extract_session.py"),
"--all",
"--timezone",
"UTC", # explicit: subprocess no longer inherits the suite's +08:00 pin
"--sessions-dir",
str(sessions_dir),
"--out-dir",
str(out_dir),
],
capture_output=True,
text=True,
)
assert result.returncode == 0, f"Expected success, got stderr: {result.stderr}"
assert (out_dir / "2026-06-17.md").exists()
def test_max_chars_change_triggers_rewrite(tmp_path):
"""Changing --max-chars must invalidate cached extracts."""
sessions_dir = tmp_path / "sessions"
out_dir = tmp_path / "out"
write_jsonl(
sessions_dir / "s.jsonl",
"user",
"useful session content for max chars test",
"2026-06-17T01:00:00Z",
)
extract_session.process_all(max_chars=3000, out_dir=out_dir, sessions_dir=sessions_dir)
try:
extract_session.process_all(max_chars=100, out_dir=out_dir, sessions_dir=sessions_dir)
except ValueError as exc:
assert "Use --force or a different --out" in str(exc)
else:
raise AssertionError("expected max_chars mismatch to raise ValueError")
def test_deleted_source_file_removes_stale_extract(tmp_path):
"""Deleting a source JSONL must remove its content from the daily extract on the next run."""
sessions_dir = tmp_path / "sessions"
out_dir = tmp_path / "out"
path = sessions_dir / "s.jsonl"
write_jsonl(path, "user", "useful session content for deletion test", "2026-06-17T01:00:00Z")
# Run 1: normal extraction — creates the daily file and saves state
extract_session.process_all(out_dir=out_dir, sessions_dir=sessions_dir)
assert (out_dir / "2026-06-17.md").exists()
# Delete the only source file
path.unlink()
# Run 2: normal re-run (no --force) — must detect deletion and remove the stale output
extract_session.process_all(out_dir=out_dir, sessions_dir=sessions_dir)
assert not (out_dir / "2026-06-17.md").exists()
def test_session_moving_to_new_date_rebuilds_previous_date(tmp_path):
"""When a session's inferred date changes, the OLD date's extract must be
rebuilt without it ('rebuild the previous date when a session moves')."""
sessions_dir = tmp_path / "sessions"
out_dir = tmp_path / "out"
a = sessions_dir / "a.jsonl"
b = sessions_dir / "b.jsonl"
write_jsonl(a, "user", "alpha session text long enough to survive", "2026-06-17T01:00:00Z")
write_jsonl(b, "user", "bravo session text long enough to survive", "2026-06-17T02:00:00Z")
# Run 1: both land on the 17th
extract_session.process_all(out_dir=out_dir, sessions_dir=sessions_dir)
day17 = (out_dir / "2026-06-17.md").read_text(encoding="utf-8")
assert "alpha session text" in day17 and "bravo session text" in day17
# a.jsonl's timestamp moves to the 18th
write_jsonl(a, "user", "alpha session text long enough to survive", "2026-06-18T01:00:00Z")
# Run 2: the 18th gains alpha; the 17th must be rebuilt to drop alpha, keep bravo
extract_session.process_all(out_dir=out_dir, sessions_dir=sessions_dir)
day17 = (out_dir / "2026-06-17.md").read_text(encoding="utf-8")
day18 = (out_dir / "2026-06-18.md").read_text(encoding="utf-8")
assert "alpha session text" in day18
assert "bravo session text" in day17
assert "alpha session text" not in day17 # stale copy must be gone
def test_changed_session_becoming_empty_removes_stale_extract(tmp_path):
"""A previously-indexed session that changes to only noise/tool content
(zero useful turns) must have its stale extract removed
(remove stale output when a changed session becomes empty)."""
sessions_dir = tmp_path / "sessions"
out_dir = tmp_path / "out"
path = sessions_dir / "s.jsonl"
write_jsonl(path, "user", "useful session content before going empty", "2026-06-17T01:00:00Z")
extract_session.process_all(out_dir=out_dir, sessions_dir=sessions_dir)
assert (out_dir / "2026-06-17.md").exists()
# Rewrite the session to only a system-reminder (filtered to zero turns)
append_jsonl(path, [{
"type": "user",
"timestamp": "2026-06-17T01:00:00Z",
"message": {"role": "user", "content": [
{"type": "text", "text": "<system-reminder>noise only</system-reminder>"}
]},
}])
extract_session.process_all(out_dir=out_dir, sessions_dir=sessions_dir)
assert not (out_dir / "2026-06-17.md").exists()
def test_changed_session_becoming_empty_is_not_reparsed_forever(tmp_path, capsys):
"""After a session becomes empty, its zero-turn result must be recorded in
state so an unchanged re-run skips it instead of reparsing every time."""
sessions_dir = tmp_path / "sessions"
out_dir = tmp_path / "out"
path = sessions_dir / "s.jsonl"
write_jsonl(path, "user", "useful session content before going empty", "2026-06-17T01:00:00Z")
extract_session.process_all(out_dir=out_dir, sessions_dir=sessions_dir)
append_jsonl(path, [{
"type": "user",
"timestamp": "2026-06-17T01:00:00Z",
"message": {"role": "user", "content": [
{"type": "text", "text": "<system-reminder>noise only</system-reminder>"}
]},
}])
extract_session.process_all(out_dir=out_dir, sessions_dir=sessions_dir)
capsys.readouterr()
# Third run: file is unchanged since it went empty → must report nothing changed
extract_session.process_all(out_dir=out_dir, sessions_dir=sessions_dir)
err = capsys.readouterr().err
assert "nothing new or changed" in err
def test_session_recovering_from_empty_is_re_extracted(tmp_path):
"""A file that went empty (recorded as a zero-turn marker) must be picked up
again when it later gains real content."""
sessions_dir = tmp_path / "sessions"
out_dir = tmp_path / "out"
path = sessions_dir / "s.jsonl"
write_jsonl(path, "user", "useful content before going empty", "2026-06-17T01:00:00Z")
extract_session.process_all(out_dir=out_dir, sessions_dir=sessions_dir)
# Go empty (zero useful turns) → stale 17th removed, empty marker recorded
append_jsonl(path, [{
"type": "user",
"timestamp": "2026-06-17T01:00:00Z",
"message": {"role": "user", "content": [
{"type": "text", "text": "<system-reminder>noise only</system-reminder>"}
]},
}])
extract_session.process_all(out_dir=out_dir, sessions_dir=sessions_dir)
assert not (out_dir / "2026-06-17.md").exists()
# Recover with real content on a later date → must be extracted again
write_jsonl(path, "user", "useful content after recovery", "2026-06-18T01:00:00Z")
extract_session.process_all(out_dir=out_dir, sessions_dir=sessions_dir)
day18 = (out_dir / "2026-06-18.md").read_text(encoding="utf-8")
assert "useful content after recovery" in day18
def test_session_moving_out_of_window_cleans_old_date(tmp_path):
"""A session moving from inside a --since/--until window to outside it must
still rebuild/remove the in-window old date ('mark the old day
dirty before skipping moved sessions')."""
sessions_dir = tmp_path / "sessions"
out_dir = tmp_path / "out"
a = sessions_dir / "a.jsonl"
write_jsonl(a, "user", "alpha text inside the window", "2026-06-17T01:00:00Z")
extract_session.process_all(
out_dir=out_dir, sessions_dir=sessions_dir, since="2026-06-17", until="2026-06-17"
)
assert (out_dir / "2026-06-17.md").exists()
# Move it to the 18th (outside the [17,17] window) and rerun with the same window
write_jsonl(a, "user", "alpha text inside the window", "2026-06-18T01:00:00Z")
extract_session.process_all(
out_dir=out_dir, sessions_dir=sessions_dir, since="2026-06-17", until="2026-06-17"
)
# The 17th had only this session, now moved out of window → its stale extract must go
assert not (out_dir / "2026-06-17.md").exists()
# And the 18th is outside the window → not produced
assert not (out_dir / "2026-06-18.md").exists()
def test_out_of_window_deletion_is_cleaned_on_later_unfiltered_run(tmp_path):
"""Deleting a source whose date is outside the active --since window must not
drop its state entry, so a later unfiltered run can still remove the stale
extract ('preserve out-of-window deletions for later cleanup')."""
sessions_dir = tmp_path / "sessions"
out_dir = tmp_path / "out"
a = sessions_dir / "a.jsonl"
b = sessions_dir / "b.jsonl"
write_jsonl(a, "user", "alpha on the sixteenth long enough", "2026-06-16T01:00:00Z")
write_jsonl(b, "user", "bravo on the seventeenth long enough", "2026-06-17T01:00:00Z")
# Run 1: unfiltered — both days created
extract_session.process_all(out_dir=out_dir, sessions_dir=sessions_dir)
assert (out_dir / "2026-06-16.md").exists()
# Delete the 16th's source, then run with a window that EXCLUDES the 16th
a.unlink()
extract_session.process_all(
out_dir=out_dir, sessions_dir=sessions_dir, since="2026-06-17"
)
# 16th is out of window → not cleaned yet, but its state entry must be preserved
assert (out_dir / "2026-06-16.md").exists()
# Run 3: unfiltered again — the preserved entry lets the stale 16th be removed
extract_session.process_all(out_dir=out_dir, sessions_dir=sessions_dir)
assert not (out_dir / "2026-06-16.md").exists()
def test_same_size_same_mtime_content_change_is_detected(tmp_path):
"""A content change with identical byte length AND identical mtime must still
be detected (hash contents, don't trust mtime+size alone)."""
import os
sessions_dir = tmp_path / "sessions"
out_dir = tmp_path / "out"
path = sessions_dir / "s.jsonl"
# alpha / bravo are equal length, AAA / BBB are equal length → identical byte size
write_jsonl(path, "user", "alpha content fixed length AAA", "2026-06-17T01:00:00Z")
extract_session.process_all(out_dir=out_dir, sessions_dir=sessions_dir)
st = path.stat()
write_jsonl(path, "user", "bravo content fixed length BBB", "2026-06-17T01:00:00Z")
assert path.stat().st_size == st.st_size, "test setup: sizes must collide"
os.utime(path, ns=(st.st_atime_ns, st.st_mtime_ns)) # restore mtime → full collision
extract_session.process_all(out_dir=out_dir, sessions_dir=sessions_dir)
output = (out_dir / "2026-06-17.md").read_text(encoding="utf-8")
assert "bravo content" in output # change detected despite identical mtime+size
def test_out_of_window_move_is_recorded_and_not_reparsed(tmp_path, monkeypatch):
"""A session that moves to a date outside the active window must be written
back to state so repeated window-scoped runs skip it
(no perpetual reparse for out-of-window moves)."""
sessions_dir = tmp_path / "sessions"
out_dir = tmp_path / "out"
a = sessions_dir / "a.jsonl"
write_jsonl(a, "user", "alpha text inside the window", "2026-06-17T01:00:00Z")
extract_session.process_all(
out_dir=out_dir, sessions_dir=sessions_dir, since="2026-06-17", until="2026-06-17"
)
# Move outside the window and rerun the same window (records the move)
write_jsonl(a, "user", "alpha text inside the window", "2026-06-18T01:00:00Z")
extract_session.process_all(
out_dir=out_dir, sessions_dir=sessions_dir, since="2026-06-17", until="2026-06-17"
)
# Third run, same window, file unchanged → it must NOT be reparsed
calls = []
real_parse = extract_session.parse_jsonl
monkeypatch.setattr(
extract_session, "parse_jsonl",
lambda *a, **k: (calls.append(a[0]), real_parse(*a, **k))[1],
)
extract_session.process_all(
out_dir=out_dir, sessions_dir=sessions_dir, since="2026-06-17", until="2026-06-17"
)
assert calls == [] # settled out-of-window file is skipped, not reparsed
def test_out_of_window_move_between_out_of_window_dates_reconciles_later(tmp_path):
"""Moving a session between two dates that are BOTH outside the active window
must not lose the old date's cleanup: a later unfiltered run removes the stale
old extract and creates the new one (out-of-window move cleanup)."""
sessions_dir = tmp_path / "sessions"
out_dir = tmp_path / "out"
a = sessions_dir / "a.jsonl"
write_jsonl(a, "user", "alpha on the sixteenth long enough", "2026-06-16T01:00:00Z")
extract_session.process_all(out_dir=out_dir, sessions_dir=sessions_dir) # unfiltered
assert (out_dir / "2026-06-16.md").exists()
# Move to the 15th, then run a window that excludes BOTH the 15th and 16th
write_jsonl(a, "user", "alpha on the fifteenth long enough", "2026-06-15T01:00:00Z")
extract_session.process_all(out_dir=out_dir, sessions_dir=sessions_dir, since="2026-06-18")
# The window run touched neither date; the 16th's stale extract still exists for now
assert (out_dir / "2026-06-16.md").exists()
# A later unfiltered run must reconcile both: drop the stale 16th, create the 15th
extract_session.process_all(out_dir=out_dir, sessions_dir=sessions_dir)
assert not (out_dir / "2026-06-16.md").exists()
assert (out_dir / "2026-06-15.md").exists()
def test_out_of_window_session_becoming_empty_reconciles_later(tmp_path):
"""A file outside the active window that changes to zero useful turns must not
lose its old date's cleanup: a later unfiltered run removes the stale extract
(out-of-window empty cleanup)."""
sessions_dir = tmp_path / "sessions"
out_dir = tmp_path / "out"
a = sessions_dir / "a.jsonl"
write_jsonl(a, "user", "alpha on the sixteenth content", "2026-06-16T01:00:00Z")
extract_session.process_all(out_dir=out_dir, sessions_dir=sessions_dir) # unfiltered
assert (out_dir / "2026-06-16.md").exists()
# Goes empty during a window that excludes the 16th
append_jsonl(a, [{
"type": "user",
"timestamp": "2026-06-16T01:00:00Z",
"message": {"role": "user", "content": [
{"type": "text", "text": "<system-reminder>noise only</system-reminder>"}
]},
}])
extract_session.process_all(out_dir=out_dir, sessions_dir=sessions_dir, since="2026-06-18")
assert (out_dir / "2026-06-16.md").exists() # not touched by the window run yet
# Later unfiltered run reconciles: the now-empty source's stale extract is removed
extract_session.process_all(out_dir=out_dir, sessions_dir=sessions_dir)
assert not (out_dir / "2026-06-16.md").exists()
def test_out_of_window_content_edit_is_reconciled_later(tmp_path):
"""Editing a source whose date stays OUTSIDE the active window must still
refresh that date's extract on a later unfiltered run, not leave stale text
(mark out-of-window edits dirty)."""
sessions_dir = tmp_path / "sessions"
out_dir = tmp_path / "out"
a = sessions_dir / "a.jsonl"
write_jsonl(a, "user", "original sixteenth content here", "2026-06-16T01:00:00Z")
extract_session.process_all(out_dir=out_dir, sessions_dir=sessions_dir) # unfiltered
assert "original sixteenth content" in (out_dir / "2026-06-16.md").read_text(encoding="utf-8")
# Edit the 16th's content (same date), then run a window that EXCLUDES the 16th
write_jsonl(a, "user", "REVISED sixteenth content present", "2026-06-16T01:00:00Z")
extract_session.process_all(out_dir=out_dir, sessions_dir=sessions_dir, since="2026-06-17")
# A later unfiltered run must refresh 16.md with the revised content
extract_session.process_all(out_dir=out_dir, sessions_dir=sessions_dir)
text = (out_dir / "2026-06-16.md").read_text(encoding="utf-8")
assert "REVISED sixteenth content" in text
assert "original sixteenth content" not in text
def test_codex_agents_instructions_preamble_is_dropped(tmp_path):
"""The Codex-injected AGENTS `<INSTRUCTIONS>…</INSTRUCTIONS>` preamble (a
role=user message) must be filtered out, not kept as Turn 1 in the extract
(strip Codex injected AGENTS instructions)."""
path = tmp_path / "rollout.jsonl"
append_jsonl(path, [
{"type": "session_meta", "timestamp": "2026-06-27T10:00:00Z", "payload": {}},
{"type": "response_item", "timestamp": "2026-06-27T10:00:01Z", "payload": {
"type": "message", "role": "user",
"content": [{"type": "input_text",
"text": "# AGENTS.md instructions for /repo\n\n"
"<INSTRUCTIONS>\n# OPERATIONS INDEX\nrepo rules here\n</INSTRUCTIONS>"}],
}},
{"type": "response_item", "timestamp": "2026-06-27T10:00:02Z", "payload": {
"type": "message", "role": "user",
"content": [{"type": "input_text",
"text": "real human question that is long enough to be kept here"}],
}},
])
turns = extract_session.parse_codex_jsonl(path)
texts = [t["text"] for t in turns]
assert not any("OPERATIONS INDEX" in x or "INSTRUCTIONS" in x for x in texts)
assert any("real human question" in x for x in texts)
def test_codex_instructions_stripped_keeping_inline_prompt(tmp_path):
"""When the AGENTS preamble and the real request share one input_text, the
instruction block is STRIPPED but the human prompt is preserved — not dropped
as a whole turn (strip, don't drop the prompt)."""
path = tmp_path / "rollout.jsonl"
append_jsonl(path, [
{"type": "session_meta", "timestamp": "2026-06-27T10:00:00Z", "payload": {}},
{"type": "response_item", "timestamp": "2026-06-27T10:00:01Z", "payload": {
"type": "message", "role": "user",
"content": [{"type": "input_text",
"text": "# AGENTS.md instructions for /repo\n\n"
"<INSTRUCTIONS>\nrepo ops rules here\n</INSTRUCTIONS>\n\n"
"Now please refactor the parser for clarity"}],
}},
])
turns = extract_session.parse_codex_jsonl(path)
texts = [t["text"] for t in turns]
assert any("refactor the parser for clarity" in x for x in texts)
assert not any("repo ops rules" in x or "INSTRUCTIONS" in x for x in texts)
def test_force_rebuild_removes_orphaned_extract_after_deletion(tmp_path):
"""--force after a source was deleted must still remove that source's stale
extract, not orphan it (preserve deletion state during forced
rebuilds)."""
sessions_dir = tmp_path / "sessions"
out_dir = tmp_path / "out"
a = sessions_dir / "a.jsonl"
b = sessions_dir / "b.jsonl"
write_jsonl(a, "user", "alpha on the sixteenth long enough", "2026-06-16T01:00:00Z")
write_jsonl(b, "user", "bravo on the seventeenth long enough", "2026-06-17T01:00:00Z")
extract_session.process_all(out_dir=out_dir, sessions_dir=sessions_dir)
assert (out_dir / "2026-06-16.md").exists()
a.unlink()
# Forced rebuild must not leave the orphaned 16th behind
extract_session.process_all(out_dir=out_dir, sessions_dir=sessions_dir, force=True)
assert not (out_dir / "2026-06-16.md").exists()
assert (out_dir / "2026-06-17.md").exists()
def test_cli_rejects_explicit_source_auto_in_batch(tmp_path):
"""Explicitly passing --source auto with --all must error, not silently run as
claude (reject explicit batch --source auto)."""
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
result = subprocess.run(
[
sys.executable, str(ROOT / "scripts" / "extract_session.py"),
"--all", "--source", "auto",
"--sessions-dir", str(sessions_dir),
"--out-dir", str(tmp_path / "out"),
],
capture_output=True, text=True,
)
assert result.returncode != 0
assert "auto" in result.stderr.lower()
def test_cli_batch_without_source_defaults_to_claude(tmp_path):
"""Omitting --source in batch mode still defaults to claude (not rejected)."""
sessions_dir = tmp_path / "sessions"
write_jsonl(
sessions_dir / "s.jsonl", "user", "useful default-source content", "2026-06-17T01:00:00Z"
)
result = subprocess.run(
[
sys.executable, str(ROOT / "scripts" / "extract_session.py"),
"--all",
"--timezone", "UTC", # subprocess no longer inherits the suite's +08:00 pin
"--sessions-dir", str(sessions_dir),
"--out-dir", str(tmp_path / "out"),
],
capture_output=True, text=True,
)
assert result.returncode == 0
assert (tmp_path / "out" / "2026-06-17.md").exists()
def test_default_output_dir_and_files_are_private(tmp_path, monkeypatch):
"""The predictable default scratch dir (and its extracts) must be private:
0700 dir, 0600 files — session logs can carry prompts/repo context/secrets."""
import os
import stat
sessions_dir = tmp_path / "sessions"
write_jsonl(
sessions_dir / "s.jsonl", "user", "sensitive prompt content here", "2026-06-17T01:00:00Z"
)
fake_tmp = tmp_path / "tmproot"
fake_tmp.mkdir()
monkeypatch.setattr(extract_session.tempfile, "gettempdir", lambda: str(fake_tmp))
monkeypatch.setattr(extract_session, "SCRATCH_DIR", fake_tmp / "session-extracts", raising=False)
extract_session.process_all(out_dir=None, sessions_dir=sessions_dir)
dirs = [p for p in fake_tmp.iterdir() if p.is_dir()]
assert len(dirs) == 1, f"expected exactly one default scratch dir, got {dirs}"
scratch = dirs[0]
assert stat.S_IMODE(scratch.stat().st_mode) == 0o700, "default scratch dir must be 0700"
md = scratch / "2026-06-17.md"
assert md.exists()
assert stat.S_IMODE(md.stat().st_mode) == 0o600, "extract .md must be 0600"
state = scratch / ".extract_session_state.json"
assert stat.S_IMODE(state.stat().st_mode) == 0o600, "state file must be 0600"
def test_default_dir_refuses_symlink(tmp_path, monkeypatch):
"""If the predictable default dir is pre-created as a symlink (tampering), the
tool must refuse rather than follow it and write secrets to the target."""
import os
fake_tmp = tmp_path / "tmproot"
fake_tmp.mkdir()
monkeypatch.setattr(extract_session.tempfile, "gettempdir", lambda: str(fake_tmp))
monkeypatch.setattr(extract_session, "SCRATCH_DIR", fake_tmp / "session-extracts", raising=False)
target = tmp_path / "attacker-dir"
target.mkdir()
(fake_tmp / f"session-extracts-{os.getuid()}").symlink_to(target)
sessions_dir = tmp_path / "sessions"
write_jsonl(sessions_dir / "s.jsonl", "user", "secret content here", "2026-06-17T01:00:00Z")
try:
extract_session.process_all(out_dir=None, sessions_dir=sessions_dir)
except ValueError as exc:
assert "symlink" in str(exc).lower()
else:
raise AssertionError("expected refusal on a symlinked default scratch dir")
def test_cli_rejects_max_chars_below_two(tmp_path):
"""--max-chars 1 must be rejected: half = 1 // 2 = 0 makes truncate keep the