claude-dev-env 8.98.9 → 8.98.10
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/AGENTS.md +1 -1
- package/bin/install.prompt-context.test.mjs +1 -1
- package/docs/agents-guidance/asks.md +13 -12
- package/docs/rule-guides/session-title.md +15 -4
- package/hooks/blocking/open_tasks_stop_gate.py +60 -259
- package/hooks/blocking/open_tasks_transcript.py +104 -90
- package/hooks/blocking/session_title_turn_gate.py +142 -56
- package/hooks/blocking/test_open_tasks_stop_gate.py +203 -500
- package/hooks/blocking/test_open_tasks_transcript.py +123 -15
- package/hooks/blocking/test_session_title_turn_gate.py +173 -90
- package/hooks/features/conduct-gates.md +9 -9
- package/hooks/hooks.json +1 -1
- package/hooks/hooks_constants/open_tasks_stop_gate_constants.py +27 -60
- package/hooks/hooks_constants/session_title_constants.py +19 -1
- package/hooks/open_task_asks.py +6 -101
- package/hooks/session/test_tracking_nudge.py +124 -0
- package/hooks/session/tracking_nudge.py +113 -0
- package/hooks/test_open_task_asks.py +6 -13
- package/package.json +1 -1
- package/rules/index.md +1 -1
- package/scripts/policy_lint/adapter_configuration.py +6 -6
- package/hooks/session/open_task_ask_recorder.py +0 -38
- package/hooks/session/test_open_task_ask_recorder.py +0 -57
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
import json
|
|
2
2
|
from pathlib import Path
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from open_tasks_transcript import (
|
|
7
|
+
TaskItem,
|
|
8
|
+
claude_prompt_text,
|
|
9
|
+
codex_prompt_text,
|
|
10
|
+
is_assistant_record,
|
|
11
|
+
is_codex_work_call,
|
|
12
|
+
is_work_tool_name,
|
|
13
|
+
parsed_object,
|
|
14
|
+
replayed_transcript,
|
|
15
|
+
unwrapped_tool_call,
|
|
16
|
+
)
|
|
5
17
|
|
|
6
18
|
|
|
7
19
|
def write_transcript(transcript_path: Path, all_records: list[dict[str, object]]) -> Path:
|
|
@@ -26,7 +38,7 @@ def test_should_unwrap_a_call_dynamic_tool_todo_write_call() -> None:
|
|
|
26
38
|
assert unwrapped_tool_call("CallDynamicTool", {"toolName": "TodoWrite"}) == ("CallDynamicTool", {"toolName": "TodoWrite"})
|
|
27
39
|
|
|
28
40
|
|
|
29
|
-
def
|
|
41
|
+
def test_should_replay_cursor_dynamic_todo_writes_as_tracker_calls(tmp_path: Path) -> None:
|
|
30
42
|
all_first_todos = [
|
|
31
43
|
{"id": "1", "content": "write hello.txt", "status": "in_progress"},
|
|
32
44
|
{"id": "2", "content": "rotate the deploy key", "status": "pending"},
|
|
@@ -45,7 +57,103 @@ def test_should_replay_cursor_dynamic_todo_writes_and_the_last_reply(tmp_path: P
|
|
|
45
57
|
TaskItem("1", "write hello.txt", "completed"),
|
|
46
58
|
TaskItem("2", "rotate the deploy key", "pending"),
|
|
47
59
|
]
|
|
48
|
-
assert replay.
|
|
60
|
+
assert replay.has_turn_tracker_write is True
|
|
61
|
+
assert replay.has_turn_work_tool is False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_should_start_a_new_turn_at_each_prompt_and_reset_its_flags(tmp_path: Path) -> None:
|
|
65
|
+
bash_call = {"type": "tool_use", "id": "b1", "name": "Bash", "input": {"command": "ls"}}
|
|
66
|
+
transcript_path = write_transcript(
|
|
67
|
+
tmp_path / "claude.jsonl",
|
|
68
|
+
[
|
|
69
|
+
{"type": "user", "message": {"role": "user", "content": "Write the gate"}},
|
|
70
|
+
dynamic_todo_record([{"id": "1", "content": "Write the gate", "status": "in_progress"}], False),
|
|
71
|
+
{"type": "user", "message": {"role": "user", "content": "Export the APKs"}},
|
|
72
|
+
{"type": "assistant", "message": {"content": [bash_call]}},
|
|
73
|
+
],
|
|
74
|
+
)
|
|
75
|
+
replay = replayed_transcript(str(transcript_path))
|
|
76
|
+
assert replay.turn_prompt_text == "Export the APKs"
|
|
77
|
+
assert replay.has_turn_work_tool is True
|
|
78
|
+
assert replay.has_turn_tracker_write is False
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_should_not_count_a_question_card_lookup_or_title_call_as_work(tmp_path: Path) -> None:
|
|
82
|
+
all_non_work_calls = [
|
|
83
|
+
{"type": "tool_use", "id": f"n{each_index}", "name": each_name, "input": {}}
|
|
84
|
+
for each_index, each_name in enumerate(
|
|
85
|
+
["AskUserQuestion", "ToolSearch", "TaskList", "TaskGet", "mcp__claude-code-remote__set_session_title"]
|
|
86
|
+
)
|
|
87
|
+
]
|
|
88
|
+
transcript_path = write_transcript(
|
|
89
|
+
tmp_path / "claude.jsonl",
|
|
90
|
+
[
|
|
91
|
+
{"type": "user", "message": {"role": "user", "content": "Export the APKs"}},
|
|
92
|
+
{"type": "assistant", "message": {"content": all_non_work_calls}},
|
|
93
|
+
],
|
|
94
|
+
)
|
|
95
|
+
replay = replayed_transcript(str(transcript_path))
|
|
96
|
+
assert replay.has_turn_work_tool is False
|
|
97
|
+
assert replay.has_turn_tracker_write is False
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@pytest.mark.parametrize(
|
|
101
|
+
("record", "expected_text"),
|
|
102
|
+
[
|
|
103
|
+
({"type": "user", "message": {"role": "user", "content": "Export the APKs"}}, "Export the APKs"),
|
|
104
|
+
({"type": "user", "message": {"content": [{"type": "text", "text": "Fix it"}]}}, "Fix it"),
|
|
105
|
+
({"type": "user", "message": {"content": [{"type": "tool_result", "tool_use_id": "b1"}]}}, None),
|
|
106
|
+
({"type": "user", "isMeta": True, "message": {"content": "Stop hook feedback: hold"}}, None),
|
|
107
|
+
({"type": "user", "message": {"content": "Stop hook feedback:\nhold"}}, None),
|
|
108
|
+
({"type": "assistant", "message": {"content": "Done."}}, None),
|
|
109
|
+
],
|
|
110
|
+
)
|
|
111
|
+
def test_should_read_prompt_text_only_from_a_record_that_starts_a_turn(
|
|
112
|
+
record: dict[str, object], expected_text: str | None
|
|
113
|
+
) -> None:
|
|
114
|
+
assert claude_prompt_text(record) == expected_text
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_should_read_codex_user_message_text() -> None:
|
|
118
|
+
user_message = {
|
|
119
|
+
"type": "response_item",
|
|
120
|
+
"payload": {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "Ship it"}]},
|
|
121
|
+
}
|
|
122
|
+
assistant_message = {"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": "ok"}}
|
|
123
|
+
assert codex_prompt_text(user_message) == "Ship it"
|
|
124
|
+
assert codex_prompt_text(assistant_message) is None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_should_count_codex_tool_calls_other_than_the_plan_as_work() -> None:
|
|
128
|
+
def codex_call(call_type: str, tool_name: str) -> dict[str, object]:
|
|
129
|
+
return {"type": "response_item", "payload": {"type": call_type, "name": tool_name}}
|
|
130
|
+
|
|
131
|
+
assert is_codex_work_call(codex_call("function_call", "shell")) is True
|
|
132
|
+
assert is_codex_work_call(codex_call("local_shell_call", "")) is True
|
|
133
|
+
assert is_codex_work_call(codex_call("function_call", "update_plan")) is False
|
|
134
|
+
assert is_codex_work_call(codex_call("function_call", "request_user_input")) is False
|
|
135
|
+
assert is_codex_work_call(codex_call("function_call", "request_user_input_async")) is False
|
|
136
|
+
assert is_codex_work_call({"type": "user", "payload": {"type": "function_call"}}) is False
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_should_not_count_a_codex_title_call_as_work() -> None:
|
|
140
|
+
title_call = {"type": "response_item", "payload": {"type": "function_call", "name": "mcp__session__set_session_title"}}
|
|
141
|
+
assert is_codex_work_call(title_call) is False
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_should_treat_question_cards_and_the_title_tool_as_non_work() -> None:
|
|
145
|
+
assert is_work_tool_name("Bash") is True
|
|
146
|
+
assert is_work_tool_name(None) is True
|
|
147
|
+
assert is_work_tool_name("mcp__hearthbot__ask_decision") is False
|
|
148
|
+
assert is_work_tool_name("mcp__hearthbot__request_decision") is False
|
|
149
|
+
assert is_work_tool_name("mcp__ccd_session__set_session_title") is False
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_should_recognize_an_assistant_record_by_type_role_or_message_role() -> None:
|
|
153
|
+
assert is_assistant_record({"type": "assistant"}) is True
|
|
154
|
+
assert is_assistant_record({"role": "assistant"}) is True
|
|
155
|
+
assert is_assistant_record({"message": {"role": "assistant"}}) is True
|
|
156
|
+
assert is_assistant_record({"type": "user", "message": {"role": "user"}}) is False
|
|
49
157
|
|
|
50
158
|
|
|
51
159
|
def test_should_parse_only_json_objects() -> None:
|
|
@@ -59,9 +167,9 @@ def ccd_plan_record(all_plan_input: dict[str, object], record_type: str = "assis
|
|
|
59
167
|
return {"type": record_type, "timestamp": "2026-01-01T00:00:00.000Z", "message": {"content": [tool_use]}}
|
|
60
168
|
|
|
61
169
|
|
|
62
|
-
def plan_statuses(transcript_path: Path) -> list[tuple[str, str
|
|
170
|
+
def plan_statuses(transcript_path: Path) -> list[tuple[str, str]]:
|
|
63
171
|
all_items = replayed_transcript(str(transcript_path)).items() or []
|
|
64
|
-
return [(each_item.subject, each_item.status
|
|
172
|
+
return [(each_item.subject, each_item.status) for each_item in all_items]
|
|
65
173
|
|
|
66
174
|
|
|
67
175
|
def test_should_replay_ccd_turn_advance_once_and_skip_request_blob_copies(tmp_path: Path) -> None:
|
|
@@ -79,9 +187,9 @@ def test_should_replay_ccd_turn_advance_once_and_skip_request_blob_copies(tmp_pa
|
|
|
79
187
|
],
|
|
80
188
|
)
|
|
81
189
|
assert plan_statuses(transcript_path) == [
|
|
82
|
-
("Read the gate", "completed"
|
|
83
|
-
("Write the tests", "in_progress"
|
|
84
|
-
("Open the PR", "pending"
|
|
190
|
+
("Read the gate", "completed"),
|
|
191
|
+
("Write the tests", "in_progress"),
|
|
192
|
+
("Open the PR", "pending"),
|
|
85
193
|
]
|
|
86
194
|
|
|
87
195
|
|
|
@@ -99,9 +207,9 @@ def test_should_carry_unfinished_items_after_the_replacing_list(tmp_path: Path)
|
|
|
99
207
|
],
|
|
100
208
|
)
|
|
101
209
|
assert plan_statuses(transcript_path) == [
|
|
102
|
-
("Set rows in Neon", "in_progress"
|
|
103
|
-
("Export the OneUI8 APK", "in_progress"
|
|
104
|
-
("Submit both APKs", "pending"
|
|
210
|
+
("Set rows in Neon", "in_progress"),
|
|
211
|
+
("Export the OneUI8 APK", "in_progress"),
|
|
212
|
+
("Submit both APKs", "pending"),
|
|
105
213
|
]
|
|
106
214
|
|
|
107
215
|
|
|
@@ -120,9 +228,9 @@ def test_should_point_done_and_start_indices_at_the_tools_own_items(tmp_path: Pa
|
|
|
120
228
|
],
|
|
121
229
|
)
|
|
122
230
|
assert plan_statuses(transcript_path) == [
|
|
123
|
-
("Beta", "completed"
|
|
124
|
-
("Gamma", "in_progress"
|
|
125
|
-
("Alpha", "pending"
|
|
231
|
+
("Beta", "completed"),
|
|
232
|
+
("Gamma", "in_progress"),
|
|
233
|
+
("Alpha", "pending"),
|
|
126
234
|
]
|
|
127
235
|
|
|
128
236
|
|
|
@@ -140,4 +248,4 @@ def test_should_let_a_later_list_close_a_carried_item_by_subject(tmp_path: Path)
|
|
|
140
248
|
),
|
|
141
249
|
],
|
|
142
250
|
)
|
|
143
|
-
assert plan_statuses(transcript_path) == [("Beta", "completed"
|
|
251
|
+
assert plan_statuses(transcript_path) == [("Beta", "completed"), ("alpha", "completed")]
|
|
@@ -5,6 +5,7 @@ from pathlib import Path
|
|
|
5
5
|
import pytest
|
|
6
6
|
|
|
7
7
|
import session_title_turn_gate
|
|
8
|
+
from open_tasks_transcript import TaskItem
|
|
8
9
|
|
|
9
10
|
REMOTE_TITLE_TOOL = "mcp__claude-code-remote__set_session_title"
|
|
10
11
|
DESKTOP_TITLE_TOOL = "mcp__ccd_session_mgmt__set_session_title"
|
|
@@ -28,10 +29,16 @@ WORK_RESULT = {
|
|
|
28
29
|
"type": "user",
|
|
29
30
|
"message": {"content": [{"type": "tool_result", "tool_use_id": "w1", "content": "ok"}]},
|
|
30
31
|
}
|
|
32
|
+
SESSION_ID = "s-title"
|
|
33
|
+
RED_FLAG_TITLE = "\U0001f6a9 Merge approval"
|
|
34
|
+
HOURGLASS_TITLE = "\u23f3 Export"
|
|
35
|
+
QUESTION_PROMPT = {"type": "user", "message": {"role": "user", "content": "did the export fail?"}}
|
|
31
36
|
REPLY = {"type": "assistant", "message": {"content": [{"type": "text", "text": "Done."}]}}
|
|
32
37
|
|
|
33
38
|
|
|
34
|
-
def title_call(
|
|
39
|
+
def title_call(
|
|
40
|
+
tool_use_id: str, tool_name: str = REMOTE_TITLE_TOOL, title: str = "✅ Gate"
|
|
41
|
+
) -> dict[str, object]:
|
|
35
42
|
return {
|
|
36
43
|
"type": "assistant",
|
|
37
44
|
"message": {
|
|
@@ -40,7 +47,7 @@ def title_call(tool_use_id: str, tool_name: str = REMOTE_TITLE_TOOL) -> dict[str
|
|
|
40
47
|
"type": "tool_use",
|
|
41
48
|
"id": tool_use_id,
|
|
42
49
|
"name": tool_name,
|
|
43
|
-
"input": {"title":
|
|
50
|
+
"input": {"title": title},
|
|
44
51
|
}
|
|
45
52
|
]
|
|
46
53
|
},
|
|
@@ -76,6 +83,7 @@ def run_gate(
|
|
|
76
83
|
)
|
|
77
84
|
hook_input = {
|
|
78
85
|
"hook_event_name": "Stop",
|
|
86
|
+
"session_id": SESSION_ID,
|
|
79
87
|
"stop_hook_active": is_stop_hook_active,
|
|
80
88
|
"transcript_path": str(transcript_path),
|
|
81
89
|
}
|
|
@@ -90,9 +98,33 @@ def run_gate(
|
|
|
90
98
|
def isolated_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
91
99
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
92
100
|
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
|
101
|
+
monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False)
|
|
102
|
+
monkeypatch.delenv("CLAUDE_CODE_TASK_LIST_ID", raising=False)
|
|
93
103
|
|
|
94
104
|
|
|
95
|
-
def
|
|
105
|
+
def write_task_store(home_directory: Path, all_statuses: list[str]) -> None:
|
|
106
|
+
task_directory = home_directory / ".claude" / "tasks" / SESSION_ID
|
|
107
|
+
task_directory.mkdir(parents=True)
|
|
108
|
+
for each_number, each_status in enumerate(all_statuses, start=1):
|
|
109
|
+
each_task = {"id": str(each_number), "subject": f"Step {each_number}", "status": each_status}
|
|
110
|
+
(task_directory / f"{each_number}.json").write_text(json.dumps(each_task), encoding="utf-8")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def earlier_turn(title: str) -> list[dict[str, object]]:
|
|
114
|
+
return [TOOL_LISTING, PROMPT, title_call("t0", title=title), title_result("t0"), REPLY]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def block_reason(
|
|
118
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
119
|
+
capsys: pytest.CaptureFixture[str],
|
|
120
|
+
tmp_path: Path,
|
|
121
|
+
all_entries: list[dict[str, object]],
|
|
122
|
+
) -> str:
|
|
123
|
+
stdout_text = run_gate(monkeypatch, capsys, tmp_path, all_entries)[1]
|
|
124
|
+
return json.loads(stdout_text)["reason"] if stdout_text else ""
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_should_hold_a_work_turn_in_a_session_with_no_title(
|
|
96
128
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
97
129
|
) -> None:
|
|
98
130
|
exit_code, stdout_text = run_gate(
|
|
@@ -101,6 +133,7 @@ def test_should_hold_a_turn_that_set_no_title(
|
|
|
101
133
|
block_output = json.loads(stdout_text)
|
|
102
134
|
assert exit_code == 0
|
|
103
135
|
assert block_output["decision"] == "block"
|
|
136
|
+
assert "no title yet" in block_output["reason"]
|
|
104
137
|
assert REMOTE_TITLE_TOOL in block_output["reason"]
|
|
105
138
|
assert all(
|
|
106
139
|
each_emoji in block_output["reason"]
|
|
@@ -111,144 +144,136 @@ def test_should_hold_a_turn_that_set_no_title(
|
|
|
111
144
|
def test_should_pass_a_turn_that_set_a_title(
|
|
112
145
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
113
146
|
) -> None:
|
|
147
|
+
write_task_store(tmp_path, ["completed"])
|
|
114
148
|
all_entries = [
|
|
115
149
|
TOOL_LISTING,
|
|
116
150
|
PROMPT,
|
|
117
151
|
WORK_CALL,
|
|
118
152
|
WORK_RESULT,
|
|
119
|
-
title_call("t1"),
|
|
153
|
+
title_call("t1", title=HOURGLASS_TITLE),
|
|
120
154
|
title_result("t1"),
|
|
121
155
|
REPLY,
|
|
122
156
|
]
|
|
123
157
|
assert run_gate(monkeypatch, capsys, tmp_path, all_entries) == (0, "")
|
|
124
158
|
|
|
125
159
|
|
|
126
|
-
|
|
160
|
+
@pytest.mark.parametrize(
|
|
161
|
+
("earlier_title", "all_statuses"),
|
|
162
|
+
[
|
|
163
|
+
(HOURGLASS_TITLE, ["completed", "in_progress"]),
|
|
164
|
+
("✅ Gate", []),
|
|
165
|
+
("✅ Gate", ["completed"]),
|
|
166
|
+
("\U0001f6a7 Org secret", ["pending"]),
|
|
167
|
+
],
|
|
168
|
+
)
|
|
169
|
+
def test_should_pass_a_work_turn_whose_carried_title_still_fits(
|
|
170
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
171
|
+
capsys: pytest.CaptureFixture[str],
|
|
172
|
+
tmp_path: Path,
|
|
173
|
+
earlier_title: str,
|
|
174
|
+
all_statuses: list[str],
|
|
175
|
+
) -> None:
|
|
176
|
+
if all_statuses:
|
|
177
|
+
write_task_store(tmp_path, all_statuses)
|
|
178
|
+
all_entries = [*earlier_turn(earlier_title), PROMPT, WORK_CALL, WORK_RESULT, REPLY]
|
|
179
|
+
assert run_gate(monkeypatch, capsys, tmp_path, all_entries) == (0, "")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def test_should_hold_a_carried_red_flag_after_a_prompt_that_gives_work(
|
|
127
183
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
128
184
|
) -> None:
|
|
129
|
-
all_entries = [
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
185
|
+
all_entries = [*earlier_turn(RED_FLAG_TITLE), PROMPT, WORK_CALL, WORK_RESULT, REPLY]
|
|
186
|
+
reason_text = block_reason(monkeypatch, capsys, tmp_path, all_entries)
|
|
187
|
+
assert RED_FLAG_TITLE in reason_text
|
|
188
|
+
assert "red flag" in reason_text
|
|
133
189
|
|
|
134
190
|
|
|
135
|
-
def
|
|
191
|
+
def test_should_pass_a_carried_red_flag_after_a_question(
|
|
136
192
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
137
193
|
) -> None:
|
|
138
|
-
all_entries = [
|
|
139
|
-
assert run_gate(monkeypatch, capsys, tmp_path, all_entries
|
|
140
|
-
|
|
141
|
-
|
|
194
|
+
all_entries = [*earlier_turn(RED_FLAG_TITLE), QUESTION_PROMPT, WORK_CALL, WORK_RESULT, REPLY]
|
|
195
|
+
assert run_gate(monkeypatch, capsys, tmp_path, all_entries) == (0, "")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_should_hold_a_carried_check_mark_while_items_are_open(
|
|
199
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
200
|
+
) -> None:
|
|
201
|
+
write_task_store(tmp_path, ["completed", "pending"])
|
|
202
|
+
all_entries = [*earlier_turn("✅ Gate"), QUESTION_PROMPT, WORK_CALL, WORK_RESULT, REPLY]
|
|
203
|
+
assert "open items" in block_reason(monkeypatch, capsys, tmp_path, all_entries)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def test_should_hold_a_carried_hourglass_once_every_item_is_completed(
|
|
207
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
208
|
+
) -> None:
|
|
209
|
+
write_task_store(tmp_path, ["completed", "completed"])
|
|
210
|
+
all_entries = [*earlier_turn(HOURGLASS_TITLE), PROMPT, WORK_CALL, WORK_RESULT, REPLY]
|
|
211
|
+
assert "every task list item is completed" in block_reason(
|
|
212
|
+
monkeypatch, capsys, tmp_path, all_entries
|
|
142
213
|
)
|
|
143
214
|
|
|
144
215
|
|
|
145
|
-
def
|
|
216
|
+
def test_should_pass_a_held_turn_even_with_no_title(
|
|
146
217
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
147
218
|
) -> None:
|
|
148
219
|
all_entries = [TOOL_LISTING, PROMPT, WORK_CALL, WORK_RESULT, REPLY]
|
|
149
|
-
|
|
150
|
-
|
|
220
|
+
assert run_gate(monkeypatch, capsys, tmp_path, all_entries, is_stop_hook_active=True) == (0, "")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def test_should_pass_a_tool_free_turn_with_no_title(
|
|
224
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
225
|
+
) -> None:
|
|
226
|
+
assert run_gate(monkeypatch, capsys, tmp_path, [TOOL_LISTING, PROMPT, REPLY]) == (0, "")
|
|
151
227
|
|
|
152
228
|
|
|
153
|
-
def
|
|
229
|
+
def test_should_keep_showing_the_earlier_title_when_this_turns_title_call_was_denied(
|
|
154
230
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
155
231
|
) -> None:
|
|
156
232
|
all_entries = [
|
|
157
|
-
|
|
158
|
-
title_call("t1"),
|
|
159
|
-
title_result("t1"),
|
|
160
|
-
REPLY,
|
|
233
|
+
*earlier_turn(RED_FLAG_TITLE),
|
|
161
234
|
PROMPT,
|
|
162
235
|
WORK_CALL,
|
|
163
236
|
WORK_RESULT,
|
|
237
|
+
title_call("t1", title="⏳ bad title."),
|
|
238
|
+
title_result("t1", is_error=True),
|
|
164
239
|
REPLY,
|
|
165
240
|
]
|
|
166
|
-
|
|
167
|
-
assert block_output["decision"] == "block"
|
|
241
|
+
assert RED_FLAG_TITLE in block_reason(monkeypatch, capsys, tmp_path, all_entries)
|
|
168
242
|
|
|
169
243
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
[
|
|
173
|
-
[REPLY],
|
|
174
|
-
[title_call("t2"), title_result("t2", is_error=True), REPLY],
|
|
175
|
-
],
|
|
176
|
-
)
|
|
177
|
-
def test_should_pass_a_tool_free_turn_after_an_earlier_title(
|
|
178
|
-
monkeypatch: pytest.MonkeyPatch,
|
|
179
|
-
capsys: pytest.CaptureFixture[str],
|
|
180
|
-
tmp_path: Path,
|
|
181
|
-
all_turn_tail: list[dict[str, object]],
|
|
244
|
+
def test_should_ignore_a_title_set_on_a_sidechain(
|
|
245
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
182
246
|
) -> None:
|
|
247
|
+
sidechain_call = {**title_call("s1"), "isSidechain": True}
|
|
248
|
+
sidechain_result = {**title_result("s1"), "isSidechain": True}
|
|
183
249
|
all_entries = [
|
|
184
250
|
TOOL_LISTING,
|
|
185
251
|
PROMPT,
|
|
186
|
-
|
|
187
|
-
|
|
252
|
+
sidechain_call,
|
|
253
|
+
sidechain_result,
|
|
254
|
+
WORK_CALL,
|
|
255
|
+
WORK_RESULT,
|
|
188
256
|
REPLY,
|
|
189
|
-
PROMPT,
|
|
190
|
-
*all_turn_tail,
|
|
191
257
|
]
|
|
192
|
-
assert
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
@pytest.mark.parametrize(
|
|
196
|
-
"all_earlier_title_entries",
|
|
197
|
-
[
|
|
198
|
-
[],
|
|
199
|
-
[title_call("t1"), title_result("t1", is_error=True)],
|
|
200
|
-
[
|
|
201
|
-
{**title_call("s1"), "isSidechain": True},
|
|
202
|
-
{**title_result("s1"), "isSidechain": True},
|
|
203
|
-
],
|
|
204
|
-
],
|
|
205
|
-
)
|
|
206
|
-
def test_should_hold_a_tool_free_turn_without_an_earlier_title(
|
|
207
|
-
monkeypatch: pytest.MonkeyPatch,
|
|
208
|
-
capsys: pytest.CaptureFixture[str],
|
|
209
|
-
tmp_path: Path,
|
|
210
|
-
all_earlier_title_entries: list[dict[str, object]],
|
|
211
|
-
) -> None:
|
|
212
|
-
all_entries = [TOOL_LISTING, PROMPT, *all_earlier_title_entries, REPLY, PROMPT, REPLY]
|
|
213
|
-
stdout_text = run_gate(monkeypatch, capsys, tmp_path, all_entries)[1]
|
|
214
|
-
assert json.loads(stdout_text)["decision"] == "block"
|
|
258
|
+
assert "no title yet" in block_reason(monkeypatch, capsys, tmp_path, all_entries)
|
|
215
259
|
|
|
216
260
|
|
|
217
261
|
def test_should_start_a_turn_at_an_image_only_prompt(
|
|
218
262
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
219
263
|
) -> None:
|
|
264
|
+
write_task_store(tmp_path, ["pending"])
|
|
220
265
|
image_prompt = {
|
|
221
266
|
"type": "user",
|
|
222
267
|
"message": {"content": [{"type": "image", "source": {"type": "base64", "data": ""}}]},
|
|
223
268
|
}
|
|
224
|
-
all_entries = [
|
|
225
|
-
|
|
226
|
-
PROMPT,
|
|
227
|
-
title_call("t1"),
|
|
228
|
-
title_result("t1"),
|
|
229
|
-
REPLY,
|
|
230
|
-
image_prompt,
|
|
231
|
-
WORK_CALL,
|
|
232
|
-
WORK_RESULT,
|
|
233
|
-
REPLY,
|
|
234
|
-
]
|
|
235
|
-
stdout_text = run_gate(monkeypatch, capsys, tmp_path, all_entries)[1]
|
|
236
|
-
assert json.loads(stdout_text)["decision"] == "block"
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
def test_should_hold_a_turn_whose_only_title_call_ran_on_a_sidechain(
|
|
240
|
-
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
241
|
-
) -> None:
|
|
242
|
-
sidechain_call = {**title_call("s1"), "isSidechain": True}
|
|
243
|
-
sidechain_result = {**title_result("s1"), "isSidechain": True}
|
|
244
|
-
all_entries = [TOOL_LISTING, PROMPT, sidechain_call, sidechain_result, REPLY]
|
|
245
|
-
stdout_text = run_gate(monkeypatch, capsys, tmp_path, all_entries)[1]
|
|
246
|
-
assert json.loads(stdout_text)["decision"] == "block"
|
|
269
|
+
all_entries = [*earlier_turn("✅ Gate"), image_prompt, WORK_CALL, WORK_RESULT, REPLY]
|
|
270
|
+
assert "open items" in block_reason(monkeypatch, capsys, tmp_path, all_entries)
|
|
247
271
|
|
|
248
272
|
|
|
249
273
|
def test_should_keep_the_turn_across_a_stop_block_and_a_meta_entry(
|
|
250
274
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
251
275
|
) -> None:
|
|
276
|
+
write_task_store(tmp_path, ["pending"])
|
|
252
277
|
stop_block = {
|
|
253
278
|
"type": "attachment",
|
|
254
279
|
"attachment": {"type": "hook_blocking_error", "hookEvent": "Stop"},
|
|
@@ -257,6 +282,8 @@ def test_should_keep_the_turn_across_a_stop_block_and_a_meta_entry(
|
|
|
257
282
|
all_entries = [
|
|
258
283
|
TOOL_LISTING,
|
|
259
284
|
PROMPT,
|
|
285
|
+
WORK_CALL,
|
|
286
|
+
WORK_RESULT,
|
|
260
287
|
title_call("t1"),
|
|
261
288
|
title_result("t1"),
|
|
262
289
|
REPLY,
|
|
@@ -270,10 +297,8 @@ def test_should_keep_the_turn_across_a_stop_block_and_a_meta_entry(
|
|
|
270
297
|
def test_should_name_a_deferred_title_tool_in_the_reason(
|
|
271
298
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
272
299
|
) -> None:
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
)
|
|
276
|
-
assert DESKTOP_TITLE_TOOL in block_output["reason"]
|
|
300
|
+
all_entries = [DEFERRED_LISTING, PROMPT, WORK_CALL, WORK_RESULT, REPLY]
|
|
301
|
+
assert DESKTOP_TITLE_TOOL in block_reason(monkeypatch, capsys, tmp_path, all_entries)
|
|
277
302
|
|
|
278
303
|
|
|
279
304
|
@pytest.mark.parametrize(
|
|
@@ -290,6 +315,8 @@ def test_should_name_a_deferred_title_tool_in_the_reason(
|
|
|
290
315
|
},
|
|
291
316
|
},
|
|
292
317
|
PROMPT,
|
|
318
|
+
WORK_CALL,
|
|
319
|
+
WORK_RESULT,
|
|
293
320
|
REPLY,
|
|
294
321
|
],
|
|
295
322
|
],
|
|
@@ -311,3 +338,59 @@ def test_should_pass_when_the_transcript_cannot_be_read(
|
|
|
311
338
|
"sys.stdin", io.TextIOWrapper(io.BytesIO(json.dumps(hook_input).encode("utf-8")))
|
|
312
339
|
)
|
|
313
340
|
assert (session_title_turn_gate.main(), capsys.readouterr().out) == (0, "")
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def test_should_return_the_last_title_that_came_back_without_an_error() -> None:
|
|
344
|
+
all_entries = [
|
|
345
|
+
title_call("t1", title=HOURGLASS_TITLE),
|
|
346
|
+
title_result("t1"),
|
|
347
|
+
title_call("t2", title=RED_FLAG_TITLE),
|
|
348
|
+
title_result("t2", is_error=True),
|
|
349
|
+
]
|
|
350
|
+
assert session_title_turn_gate.shown_title(all_entries) == HOURGLASS_TITLE
|
|
351
|
+
assert session_title_turn_gate.shown_title([PROMPT]) is None
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
@pytest.mark.parametrize(
|
|
355
|
+
("title", "expected_status"),
|
|
356
|
+
[(RED_FLAG_TITLE, "\U0001f6a9"), ("⏳️ Export", "⏳"), ("Export", None)],
|
|
357
|
+
)
|
|
358
|
+
def test_should_read_the_status_emoji_without_its_variation_selector(
|
|
359
|
+
title: str, expected_status: str | None
|
|
360
|
+
) -> None:
|
|
361
|
+
assert session_title_turn_gate.title_status(title) == expected_status
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
@pytest.mark.parametrize(
|
|
365
|
+
("title", "is_ask_turn", "all_statuses", "is_expected_stale"),
|
|
366
|
+
[
|
|
367
|
+
(RED_FLAG_TITLE, True, [], True),
|
|
368
|
+
(RED_FLAG_TITLE, False, [], False),
|
|
369
|
+
("✅ Gate", False, ["pending"], True),
|
|
370
|
+
("✅ Gate", True, ["completed"], False),
|
|
371
|
+
("✅ Gate", True, ["completed", "deleted"], False),
|
|
372
|
+
(HOURGLASS_TITLE, True, ["completed", "deleted"], True),
|
|
373
|
+
(HOURGLASS_TITLE, True, ["completed"], True),
|
|
374
|
+
(HOURGLASS_TITLE, True, ["in_progress"], False),
|
|
375
|
+
(HOURGLASS_TITLE, True, [], False),
|
|
376
|
+
],
|
|
377
|
+
)
|
|
378
|
+
def test_should_call_a_status_stale_only_when_the_session_state_contradicts_it(
|
|
379
|
+
title: str, is_ask_turn: bool, all_statuses: list[str], is_expected_stale: bool
|
|
380
|
+
) -> None:
|
|
381
|
+
all_items = [
|
|
382
|
+
TaskItem(str(each_number), "Step", each_status)
|
|
383
|
+
for each_number, each_status in enumerate(all_statuses)
|
|
384
|
+
]
|
|
385
|
+
stale_reason = session_title_turn_gate.stale_status_reason(title, is_ask_turn, all_items)
|
|
386
|
+
assert (stale_reason is not None) is is_expected_stale
|
|
387
|
+
if stale_reason is not None:
|
|
388
|
+
assert title in stale_reason
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def test_should_build_the_block_reason_from_the_stale_status() -> None:
|
|
392
|
+
all_entries = [*earlier_turn(RED_FLAG_TITLE), PROMPT, WORK_CALL, WORK_RESULT, REPLY]
|
|
393
|
+
reason_text = session_title_turn_gate.turn_block_reason(all_entries, False, True, [])
|
|
394
|
+
assert reason_text is not None
|
|
395
|
+
assert reason_text.startswith("The title '" + RED_FLAG_TITLE)
|
|
396
|
+
assert session_title_turn_gate.turn_block_reason(all_entries, False, False, []) is None
|