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.
@@ -1,7 +1,19 @@
1
1
  import json
2
2
  from pathlib import Path
3
3
 
4
- from open_tasks_transcript import TaskItem, parsed_object, replayed_transcript, unwrapped_tool_call
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 test_should_replay_cursor_dynamic_todo_writes_and_the_last_reply(tmp_path: Path) -> None:
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.last_assistant_text == "Done."
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, bool]]:
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, each_item.is_carried) for each_item in all_items]
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", False),
83
- ("Write the tests", "in_progress", False),
84
- ("Open the PR", "pending", False),
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", False),
103
- ("Export the OneUI8 APK", "in_progress", True),
104
- ("Submit both APKs", "pending", True),
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", False),
124
- ("Gamma", "in_progress", False),
125
- ("Alpha", "pending", True),
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", False), ("alpha", "completed", False)]
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(tool_use_id: str, tool_name: str = REMOTE_TITLE_TOOL) -> dict[str, object]:
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": "✅ Gate"},
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 test_should_hold_a_turn_that_set_no_title(
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
- def test_should_hold_a_turn_whose_title_call_was_denied(
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 = [TOOL_LISTING, PROMPT, title_call("t1"), title_result("t1", is_error=True), REPLY]
130
- assert (
131
- json.loads(run_gate(monkeypatch, capsys, tmp_path, all_entries)[1])["decision"] == "block"
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 test_should_pass_a_held_turn_whose_title_call_failed(
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 = [TOOL_LISTING, PROMPT, title_call("t1"), title_result("t1", is_error=True), REPLY]
139
- assert run_gate(monkeypatch, capsys, tmp_path, all_entries, is_stop_hook_active=True) == (
140
- 0,
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 test_should_hold_a_held_turn_with_no_title_call(
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
- stdout_text = run_gate(monkeypatch, capsys, tmp_path, all_entries, is_stop_hook_active=True)[1]
150
- assert json.loads(stdout_text)["decision"] == "block"
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 test_should_hold_when_the_title_was_set_only_in_an_earlier_turn(
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
- PROMPT,
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
- block_output = json.loads(run_gate(monkeypatch, capsys, tmp_path, all_entries)[1])
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
- @pytest.mark.parametrize(
171
- "all_turn_tail",
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
- title_call("t1"),
187
- title_result("t1"),
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 run_gate(monkeypatch, capsys, tmp_path, all_entries) == (0, "")
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
- TOOL_LISTING,
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
- block_output = json.loads(
274
- run_gate(monkeypatch, capsys, tmp_path, [DEFERRED_LISTING, PROMPT, REPLY])[1]
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