claude-dev-env 8.90.0 → 8.91.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.
- package/bin/install-constants.mjs +3 -0
- package/bin/install.cursor-rules.test.mjs +11 -1
- package/bin/install.mjs +5 -0
- package/bin/install.settings-defaults.test.mjs +0 -1
- package/hooks/features/session-context.md +5 -4
- package/hooks/hooks.json +0 -13
- package/hooks/hooks_constants/compaction_user_messages_constants.py +141 -47
- package/hooks/session/compaction_recall_prompt.py +29 -18
- package/hooks/session/compaction_trim.py +252 -0
- package/hooks/session/compaction_trim_eval.py +85 -279
- package/hooks/session/compaction_user_messages.py +42 -143
- package/hooks/session/fixtures/compaction_user_messages/codex_rollout.jsonl +22 -0
- package/hooks/session/fixtures/compaction_user_messages/random_9031_1.jsonl +42 -0
- package/hooks/session/fixtures/compaction_user_messages/random_9031_2.jsonl +42 -0
- package/hooks/session/fixtures/compaction_user_messages/trim_cases.json +46 -0
- package/hooks/session/test_compaction_recall_prompt.py +45 -82
- package/hooks/session/test_compaction_trim.py +169 -0
- package/hooks/session/test_compaction_trim_eval.py +32 -85
- package/hooks/session/test_compaction_user_messages.py +37 -95
- package/package.json +1 -1
- package/settings.json +1 -2
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Tests for compaction_trim: the caveman trim, the overruled marks, and the recall budget."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
_SESSION_DIR = Path(__file__).resolve().parent
|
|
7
|
+
_HOOKS_ROOT = _SESSION_DIR.parent
|
|
8
|
+
for each_sys_path_entry in (str(_SESSION_DIR), str(_HOOKS_ROOT)):
|
|
9
|
+
if each_sys_path_entry not in sys.path:
|
|
10
|
+
sys.path.insert(0, each_sys_path_entry)
|
|
11
|
+
|
|
12
|
+
import compaction_trim as trim
|
|
13
|
+
from compaction_user_messages import TypedMessage
|
|
14
|
+
from hooks_constants.compaction_user_messages_constants import (
|
|
15
|
+
MAXIMUM_MESSAGE_CHARACTERS,
|
|
16
|
+
MAXIMUM_RECALL_CHARACTERS,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def message(position: int, text: str) -> TypedMessage:
|
|
21
|
+
return TypedMessage(position=position, timestamp="t", text=text)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_caveman_text_should_drop_filler_and_articles() -> None:
|
|
25
|
+
assert (
|
|
26
|
+
trim.caveman_text("Could you please just add the retry to a deploy script , basically.")
|
|
27
|
+
== "just add retry to deploy script."
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_caveman_text_should_keep_a_restrictive_just() -> None:
|
|
32
|
+
assert trim.caveman_text("use just the staging db") == "use just staging db"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_caveman_text_should_keep_backtick_spans_and_path_tokens_as_typed() -> None:
|
|
36
|
+
assert (
|
|
37
|
+
trim.caveman_text("run `the just a tool` on just/the.py and the-a.md")
|
|
38
|
+
== "run `the just a tool` on just/the.py and the-a.md"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_caveman_text_should_keep_a_hedge() -> None:
|
|
43
|
+
assert trim.caveman_text("I think the cache is stale") == "I think cache is stale"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_caveman_text_should_keep_a_capital_letter_label() -> None:
|
|
47
|
+
assert trim.caveman_text("The plan A is good, an Option A or B") == "plan A is good, Option A or B"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_trimmed_lines_should_not_mark_a_line_a_reminder_repeats() -> None:
|
|
51
|
+
all_messages = [
|
|
52
|
+
message(1, "Set up the linter in CI"),
|
|
53
|
+
message(2, "Don't forget the linter config too"),
|
|
54
|
+
]
|
|
55
|
+
assert trim.trimmed_lines(all_messages) == [
|
|
56
|
+
"#1 Set up linter in CI",
|
|
57
|
+
"#2 Don't forget linter config too",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_trimmed_lines_should_not_read_a_cue_inside_a_longer_word() -> None:
|
|
62
|
+
all_messages = [
|
|
63
|
+
message(1, "Add docs for the API"),
|
|
64
|
+
message(2, "Document the undocumented parts of the API"),
|
|
65
|
+
]
|
|
66
|
+
assert trim.trimmed_lines(all_messages) == [
|
|
67
|
+
"#1 Add docs for API",
|
|
68
|
+
"#2 Document undocumented parts of API",
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_trimmed_lines_should_drop_gratitude_and_interrupts_and_keep_approvals() -> None:
|
|
73
|
+
all_messages = [
|
|
74
|
+
message(1, "ship it to staging"),
|
|
75
|
+
message(2, "thanks"),
|
|
76
|
+
message(3, "[Request interrupted by user]"),
|
|
77
|
+
message(4, "yes, go ahead"),
|
|
78
|
+
message(5, "ok but use port 8443"),
|
|
79
|
+
]
|
|
80
|
+
assert trim.trimmed_lines(all_messages) == [
|
|
81
|
+
"#1 ship it to staging",
|
|
82
|
+
"#4 yes, go ahead",
|
|
83
|
+
"#5 ok but use port 8443",
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_trimmed_lines_should_mark_the_earlier_message_a_reversal_overrules() -> None:
|
|
88
|
+
all_messages = [
|
|
89
|
+
message(1, "Store the uploads in S3."),
|
|
90
|
+
message(2, "Log every retry to logs/retry.csv"),
|
|
91
|
+
message(3, "Change of plan, keep the uploads on local disk, not S3."),
|
|
92
|
+
]
|
|
93
|
+
assert trim.trimmed_lines(all_messages) == [
|
|
94
|
+
"#1 Store uploads in S3. (overruled by #3)",
|
|
95
|
+
"#2 Log every retry to logs/retry.csv",
|
|
96
|
+
"#3 Change of plan, keep uploads on local disk, not S3.",
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
ARTIFACT_COMMENT_TEXT = """[Artifact comment sent to Claude]
|
|
101
|
+
Artifact: https://claude.ai/code/artifact/e5f0
|
|
102
|
+
Comment thread: 02ec
|
|
103
|
+
I sent you this comment from the artifact's comment thread. Handle it in this session.
|
|
104
|
+
=== BEGIN ARTIFACT COMMENT df7f ===
|
|
105
|
+
> Can you show me this?
|
|
106
|
+
> How is the agent spawned?
|
|
107
|
+
=== END ARTIFACT COMMENT df7f ===
|
|
108
|
+
<comment-anchor-context source="artifact content">
|
|
109
|
+
Location: What happens when a session compacts
|
|
110
|
+
Element: rect
|
|
111
|
+
</comment-anchor-context>
|
|
112
|
+
Answer this comment in its thread, not here."""
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_trimmed_lines_should_keep_only_the_link_anchor_and_quote_of_an_artifact_comment() -> None:
|
|
116
|
+
assert trim.trimmed_lines([message(1, ARTIFACT_COMMENT_TEXT)]) == [
|
|
117
|
+
"#1 Artifact comment on https://claude.ai/code/artifact/e5f0 at What happens when "
|
|
118
|
+
"session compacts: show me this? How is agent spawned?"
|
|
119
|
+
]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_trimmed_lines_should_drop_html_comments_and_a_message_left_empty() -> None:
|
|
123
|
+
assert trim.trimmed_lines(
|
|
124
|
+
[message(1, "<!-- reply 1 --> use port 8443"), message(2, "<!-- draft -->")]
|
|
125
|
+
) == ["#1 use port 8443"]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_trimmed_lines_should_cut_a_long_message_and_say_how_much() -> None:
|
|
129
|
+
long_text = "y" * (MAXIMUM_MESSAGE_CHARACTERS + 25)
|
|
130
|
+
assert trim.trimmed_lines([message(1, long_text)]) == [
|
|
131
|
+
"#1 " + "y" * MAXIMUM_MESSAGE_CHARACTERS + " [25 more characters left out]"
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_recall_text_should_keep_the_first_and_the_newest_lines_within_budget() -> None:
|
|
136
|
+
message_count = 3 * MAXIMUM_RECALL_CHARACTERS // MAXIMUM_MESSAGE_CHARACTERS
|
|
137
|
+
all_messages = [
|
|
138
|
+
message(each, f"m{each} " + "x" * (MAXIMUM_MESSAGE_CHARACTERS - 100))
|
|
139
|
+
for each in range(1, message_count + 1)
|
|
140
|
+
]
|
|
141
|
+
all_recall_lines = (trim.recall_text(all_messages) or "").splitlines()
|
|
142
|
+
assert all_recall_lines[0].startswith("#1 m1 ")
|
|
143
|
+
assert all_recall_lines[-1].startswith(f"#{message_count} m{message_count} ")
|
|
144
|
+
kept_line_count = len(all_recall_lines) - 2
|
|
145
|
+
left_out_count = message_count - 1 - kept_line_count
|
|
146
|
+
assert left_out_count > 0
|
|
147
|
+
assert all_recall_lines[1] == f"[{left_out_count} earlier messages left out to fit the budget]"
|
|
148
|
+
recall_without_note = "\n".join([all_recall_lines[0], *all_recall_lines[2:]])
|
|
149
|
+
assert len(recall_without_note) <= MAXIMUM_RECALL_CHARACTERS
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_recall_text_should_return_none_when_nothing_carries_content() -> None:
|
|
153
|
+
assert trim.recall_text([]) is None
|
|
154
|
+
assert trim.recall_text([message(1, "thank you"), message(2, "thanks")]) is None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_recall_text_should_keep_approval_and_continuation_messages() -> None:
|
|
158
|
+
for approval in (
|
|
159
|
+
"yes, go ahead",
|
|
160
|
+
"yes",
|
|
161
|
+
"ok",
|
|
162
|
+
"sure",
|
|
163
|
+
"sounds good",
|
|
164
|
+
"continue",
|
|
165
|
+
"proceed",
|
|
166
|
+
"you",
|
|
167
|
+
"thanks 8443",
|
|
168
|
+
):
|
|
169
|
+
assert trim.recall_text([message(1, approval)]) == f"#1 {approval}"
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
"""Tests for compaction_trim_eval: the grader
|
|
1
|
+
"""Tests for compaction_trim_eval: the grader and the fixture cases it scores."""
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
import sys
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
7
9
|
_SESSION_DIR = Path(__file__).resolve().parent
|
|
8
10
|
_HOOKS_ROOT = _SESSION_DIR.parent
|
|
9
11
|
for each_sys_path_entry in (str(_SESSION_DIR), str(_HOOKS_ROOT)):
|
|
@@ -11,105 +13,50 @@ for each_sys_path_entry in (str(_SESSION_DIR), str(_HOOKS_ROOT)):
|
|
|
11
13
|
sys.path.insert(0, each_sys_path_entry)
|
|
12
14
|
|
|
13
15
|
import compaction_trim_eval as trim_eval
|
|
14
|
-
from compaction_user_messages import rendered_extract, typed_user_messages
|
|
15
16
|
|
|
17
|
+
FIXTURE_DIRECTORY = _SESSION_DIR / "fixtures" / "compaction_user_messages"
|
|
16
18
|
DEPLOY_CASE = {
|
|
17
19
|
"transcript": "deploy_session.jsonl",
|
|
18
20
|
"facts": ["batch-07.internal", "137", "SQLite"],
|
|
19
21
|
"overruled": [{"earlier": 2, "later": 6}],
|
|
20
22
|
}
|
|
21
|
-
|
|
23
|
+
RAW_MESSAGES = "m" * 400
|
|
22
24
|
|
|
23
25
|
|
|
24
26
|
def test_score_condensed_should_count_every_fact_and_the_marked_reversal() -> None:
|
|
25
|
-
|
|
27
|
+
recall = (
|
|
26
28
|
"#1 deploy to BATCH-07.internal\n#2 keep Postgres (overruled by #6)\n"
|
|
27
29
|
"#5 exit code 137\n#6 switch to SQLite\n"
|
|
28
30
|
)
|
|
29
|
-
case_score = trim_eval.score_condensed(
|
|
31
|
+
case_score = trim_eval.score_condensed(recall, DEPLOY_CASE, RAW_MESSAGES)
|
|
30
32
|
assert (case_score.facts_kept, case_score.facts_total) == (3, 3)
|
|
31
33
|
assert (case_score.overruled_marked, case_score.overruled_total) == (1, 1)
|
|
32
|
-
assert case_score.compression_ratio == len(
|
|
34
|
+
assert case_score.compression_ratio == len(RAW_MESSAGES) / len(recall)
|
|
35
|
+
assert case_score.is_lossless
|
|
33
36
|
|
|
34
37
|
|
|
35
|
-
def
|
|
36
|
-
|
|
37
|
-
case_score = trim_eval.score_condensed(
|
|
38
|
+
def test_score_condensed_should_score_a_recall_that_drops_facts_lower() -> None:
|
|
39
|
+
lossy_recall = "#1 deploy\n#2 keep Postgres\n#6 switch db\n"
|
|
40
|
+
case_score = trim_eval.score_condensed(lossy_recall, DEPLOY_CASE, RAW_MESSAGES)
|
|
38
41
|
assert case_score.facts_kept == 0
|
|
39
42
|
assert case_score.overruled_marked == 0
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
all_cases = json.loads((
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
assert
|
|
51
|
-
assert
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def test_condensing_hook_group_should_keep_only_the_agent_hook_that_names_the_extractor() -> None:
|
|
63
|
-
hooks_json_text = json.dumps(
|
|
64
|
-
{
|
|
65
|
-
"hooks": {
|
|
66
|
-
"PostToolUse": [
|
|
67
|
-
{"matcher": "Bash", "hooks": [{"type": "command", "command": "x"}]},
|
|
68
|
-
{
|
|
69
|
-
"matcher": "Bash",
|
|
70
|
-
"hooks": [{"type": "command", "command": "y"}, CONDENSING_HOOK],
|
|
71
|
-
},
|
|
72
|
-
]
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
)
|
|
76
|
-
assert trim_eval.condensing_hook_group(hooks_json_text) == {
|
|
77
|
-
"matcher": "Bash",
|
|
78
|
-
"hooks": [CONDENSING_HOOK],
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def test_run_settings_should_register_the_hook_on_the_chosen_model_with_the_allow_rules() -> None:
|
|
83
|
-
hook_group = {"matcher": "Bash", "hooks": [CONDENSING_HOOK]}
|
|
84
|
-
all_allow_rules = ["Read(~/.claude/compaction-recall/**)"]
|
|
85
|
-
assert trim_eval.run_settings(hook_group, all_allow_rules, "sonnet") == {
|
|
86
|
-
"permissions": {"allow": all_allow_rules},
|
|
87
|
-
"hooks": {
|
|
88
|
-
"PostToolUse": [{"matcher": "Bash", "hooks": [{**CONDENSING_HOOK, "model": "sonnet"}]}]
|
|
89
|
-
},
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
def test_debug_log_reason_should_return_the_last_structured_output_reason() -> None:
|
|
94
|
-
debug_text = (
|
|
95
|
-
"2026 [DEBUG] Hooks: Processing agent hook with prompt: condense\n"
|
|
96
|
-
'2026 [DEBUG] Hooks: Got structured output: {"ok":false,"reason":"draft"}\n'
|
|
97
|
-
'2026 [DEBUG] Hooks: Got structured output: {"ok":false,"reason":"#1 deploy\\n#2 keep"}\n'
|
|
98
|
-
)
|
|
99
|
-
assert trim_eval.debug_log_reason(debug_text) == "#1 deploy\n#2 keep"
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
def test_debug_log_reason_should_return_none_when_no_hook_answered() -> None:
|
|
103
|
-
assert trim_eval.debug_log_reason("2026 [DEBUG] Read tool permission denied\n") is None
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
def test_hook_reason_should_return_the_last_decision_with_a_reason() -> None:
|
|
107
|
-
model_reply = (
|
|
108
|
-
'Draft {"ok": false, "reason": "first"} then {"note": 1} and '
|
|
109
|
-
'{"ok": false, "reason": "#1 use {braces} here"}'
|
|
110
|
-
)
|
|
111
|
-
assert trim_eval.hook_reason(model_reply) == "#1 use {braces} here"
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
def test_hook_reason_should_return_none_when_the_reply_has_no_decision() -> None:
|
|
115
|
-
assert trim_eval.hook_reason("I could not read the file {broken") is None
|
|
43
|
+
assert not case_score.is_lossless
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_scored_cases_should_keep_every_fact_and_reversal_in_each_fixture() -> None:
|
|
47
|
+
all_cases = json.loads((FIXTURE_DIRECTORY / "trim_cases.json").read_text(encoding="utf-8"))
|
|
48
|
+
all_rows = trim_eval.scored_cases(FIXTURE_DIRECTORY)
|
|
49
|
+
assert [each_row[0] for each_row in all_rows] == [
|
|
50
|
+
each_case["transcript"] for each_case in all_cases
|
|
51
|
+
]
|
|
52
|
+
for each_name, trim_score, oracle_score, null_score in all_rows:
|
|
53
|
+
assert trim_score.is_lossless, each_name
|
|
54
|
+
assert oracle_score.facts_kept == oracle_score.facts_total, each_name
|
|
55
|
+
assert null_score.facts_kept == 0, each_name
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_main_should_exit_zero_when_the_trim_keeps_every_fact(
|
|
59
|
+
capsys: pytest.CaptureFixture[str],
|
|
60
|
+
) -> None:
|
|
61
|
+
assert trim_eval.main() == 0
|
|
62
|
+
assert "trim: facts 1.00 overruled 1.00" in capsys.readouterr().out
|
|
@@ -1,13 +1,9 @@
|
|
|
1
|
-
"""Tests for compaction_user_messages: which transcript entries count as typed
|
|
1
|
+
"""Tests for compaction_user_messages: which Claude Code and Codex transcript entries count as typed."""
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
-
import os
|
|
5
|
-
import subprocess
|
|
6
4
|
import sys
|
|
7
5
|
from pathlib import Path
|
|
8
6
|
|
|
9
|
-
import pytest
|
|
10
|
-
|
|
11
7
|
_SESSION_DIR = Path(__file__).resolve().parent
|
|
12
8
|
_HOOKS_ROOT = _SESSION_DIR.parent
|
|
13
9
|
for each_sys_path_entry in (str(_SESSION_DIR), str(_HOOKS_ROOT)):
|
|
@@ -15,13 +11,7 @@ for each_sys_path_entry in (str(_SESSION_DIR), str(_HOOKS_ROOT)):
|
|
|
15
11
|
sys.path.insert(0, each_sys_path_entry)
|
|
16
12
|
|
|
17
13
|
import compaction_user_messages as extractor
|
|
18
|
-
from hooks_constants.compaction_user_messages_constants import (
|
|
19
|
-
MAXIMUM_EXTRACT_CHARACTERS,
|
|
20
|
-
MAXIMUM_MESSAGE_CHARACTERS,
|
|
21
|
-
MESSAGES_HEADING,
|
|
22
|
-
)
|
|
23
14
|
|
|
24
|
-
EXTRACTOR_PATH = _SESSION_DIR / "compaction_user_messages.py"
|
|
25
15
|
FIXTURE_DIRECTORY = _SESSION_DIR / "fixtures" / "compaction_user_messages"
|
|
26
16
|
|
|
27
17
|
|
|
@@ -137,92 +127,44 @@ def test_should_converge_when_a_second_compaction_appends_to_the_transcript() ->
|
|
|
137
127
|
]
|
|
138
128
|
|
|
139
129
|
|
|
140
|
-
def
|
|
141
|
-
return
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
def test_bounded_messages_should_keep_everything_that_fits() -> None:
|
|
145
|
-
all_messages = [message(1, "a"), message(2, "b"), message(3, "c")]
|
|
146
|
-
assert extractor.bounded_messages(all_messages) == all_messages
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
def test_bounded_messages_should_keep_the_first_and_the_newest_when_over_budget() -> None:
|
|
150
|
-
long_text = "x" * (MAXIMUM_MESSAGE_CHARACTERS - 100)
|
|
151
|
-
message_count = 2 * MAXIMUM_EXTRACT_CHARACTERS // MAXIMUM_MESSAGE_CHARACTERS
|
|
152
|
-
all_messages = [message(each, long_text) for each in range(1, message_count + 1)]
|
|
153
|
-
all_kept = extractor.bounded_messages(all_messages)
|
|
154
|
-
kept_positions = [each.position for each in all_kept]
|
|
155
|
-
assert kept_positions[0] == 1
|
|
156
|
-
assert kept_positions[-1] == message_count
|
|
157
|
-
assert kept_positions[1:] == list(range(kept_positions[1], message_count + 1))
|
|
158
|
-
assert len(extractor.rendered_extract(all_messages)) <= MAXIMUM_EXTRACT_CHARACTERS + 200
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
def test_bounded_messages_should_return_nothing_for_no_messages() -> None:
|
|
162
|
-
assert extractor.bounded_messages([]) == []
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
def test_rendered_extract_should_hold_only_the_numbered_messages() -> None:
|
|
166
|
-
rendered = extractor.rendered_extract([message(1, "fix login"), message(2, "use /srv")])
|
|
167
|
-
assert rendered.startswith(MESSAGES_HEADING)
|
|
168
|
-
assert "caveman" not in rendered
|
|
169
|
-
assert "## 1 | t\nfix login\n" in rendered
|
|
170
|
-
assert "## 2 | t\nuse /srv\n" in rendered
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
def test_rendered_extract_should_cut_a_long_message_and_say_how_much() -> None:
|
|
174
|
-
rendered = extractor.rendered_extract([message(1, "y" * (MAXIMUM_MESSAGE_CHARACTERS + 25))])
|
|
175
|
-
assert "[25 more characters left out]" in rendered
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
def test_rendered_extract_should_name_how_many_messages_were_left_out() -> None:
|
|
179
|
-
long_text = "x" * (MAXIMUM_MESSAGE_CHARACTERS - 100)
|
|
180
|
-
message_count = 2 * MAXIMUM_EXTRACT_CHARACTERS // MAXIMUM_MESSAGE_CHARACTERS
|
|
181
|
-
all_messages = [message(each, long_text) for each in range(1, message_count + 1)]
|
|
182
|
-
left_out_count = message_count - len(extractor.bounded_messages(all_messages))
|
|
183
|
-
assert left_out_count > 0
|
|
184
|
-
assert f"[{left_out_count} earlier messages left out" in extractor.rendered_extract(
|
|
185
|
-
all_messages
|
|
130
|
+
def codex_line(payload: dict[str, object]) -> str:
|
|
131
|
+
return json.dumps(
|
|
132
|
+
{"timestamp": "2026-10-19T11:00:00Z", "type": "event_msg", "payload": payload}
|
|
186
133
|
)
|
|
187
134
|
|
|
188
135
|
|
|
189
|
-
def
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
136
|
+
def test_should_keep_codex_user_messages_and_leave_out_injected_items() -> None:
|
|
137
|
+
all_lines = [
|
|
138
|
+
json.dumps({"type": "session_meta", "payload": {"cwd": "/w"}}),
|
|
139
|
+
json.dumps(
|
|
140
|
+
{
|
|
141
|
+
"type": "response_item",
|
|
142
|
+
"payload": {
|
|
143
|
+
"type": "message",
|
|
144
|
+
"role": "user",
|
|
145
|
+
"content": [{"type": "input_text", "text": "<environment_context>"}],
|
|
146
|
+
},
|
|
147
|
+
}
|
|
148
|
+
),
|
|
149
|
+
codex_line({"type": "user_message", "message": "port the hook to codex", "kind": "plain"}),
|
|
150
|
+
codex_line({"type": "agent_message", "message": "on it"}),
|
|
151
|
+
codex_line({"type": "user_message", "message": "see this", "images": ["data:image/png"]}),
|
|
152
|
+
codex_line({"type": "user_message", "message": "", "images": ["data:image/png"]}),
|
|
153
|
+
]
|
|
154
|
+
all_messages = extractor.typed_user_messages(all_lines)
|
|
155
|
+
assert [each_message.text for each_message in all_messages] == [
|
|
156
|
+
"port the hook to codex",
|
|
157
|
+
"see this [image]",
|
|
158
|
+
"[image]",
|
|
159
|
+
]
|
|
160
|
+
assert all_messages[0].timestamp == "2026-10-19T11:00:00Z"
|
|
200
161
|
|
|
201
162
|
|
|
202
|
-
def
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
check=True,
|
|
211
|
-
env={**os.environ, "HOME": str(home_directory), "USERPROFILE": str(home_directory)},
|
|
212
|
-
)
|
|
213
|
-
printed_path = Path(completed.stdout.strip())
|
|
214
|
-
assert printed_path == home_directory / ".claude" / "compaction-recall" / "session-xyz.md"
|
|
215
|
-
assert "ship the deploy fix" not in completed.stdout
|
|
216
|
-
assert "## 1 | 2026-10-19T10:00:00Z\nship the deploy fix" in printed_path.read_text()
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
def test_main_should_exit_with_usage_when_the_transcript_is_missing(tmp_path: Path) -> None:
|
|
220
|
-
for all_arguments in ([], [str(tmp_path / "absent.jsonl")]):
|
|
221
|
-
completed = subprocess.run(
|
|
222
|
-
[sys.executable, str(EXTRACTOR_PATH), *all_arguments],
|
|
223
|
-
capture_output=True,
|
|
224
|
-
text=True,
|
|
225
|
-
check=False,
|
|
226
|
-
)
|
|
227
|
-
assert completed.returncode == 2
|
|
228
|
-
assert completed.stdout == ""
|
|
163
|
+
def test_should_keep_a_codex_message_typed_again_after_the_agent_replied() -> None:
|
|
164
|
+
all_lines = [
|
|
165
|
+
codex_line({"type": "user_message", "message": "go"}),
|
|
166
|
+
codex_line({"type": "user_message", "message": "go"}),
|
|
167
|
+
codex_line({"type": "agent_message", "message": "done"}),
|
|
168
|
+
codex_line({"type": "user_message", "message": "go"}),
|
|
169
|
+
]
|
|
170
|
+
assert texts_of(all_lines) == ["go", "go"]
|
package/package.json
CHANGED