claude-dev-env 8.98.9 → 8.98.11

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,4 +1,3 @@
1
- import importlib
2
1
  import io
3
2
  import json
4
3
  import subprocess
@@ -8,9 +7,11 @@ from pathlib import Path
8
7
  import pytest
9
8
 
10
9
  import open_tasks_stop_gate
10
+ from open_tasks_transcript import TaskItem, replayed_transcript
11
11
 
12
12
  GATE_PATH = Path(open_tasks_stop_gate.__file__)
13
13
  SESSION_ID = "s-123"
14
+ WORK_PROMPT = "Export the APKs and submit them"
14
15
 
15
16
 
16
17
  @pytest.fixture(autouse=True)
@@ -21,141 +22,179 @@ def isolated_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
21
22
  monkeypatch.delenv("CLAUDE_CODE_TASK_LIST_ID", raising=False)
22
23
 
23
24
 
25
+ def user_prompt_record(prompt_text: str) -> dict[str, object]:
26
+ return {"type": "user", "message": {"role": "user", "content": prompt_text}}
27
+
28
+
29
+ def tool_call_record(tool_name: str, all_input_fields: dict[str, object] | None = None) -> dict[str, object]:
30
+ tool_use = {"type": "tool_use", "id": f"id-{tool_name}", "name": tool_name, "input": all_input_fields or {}}
31
+ return {"type": "assistant", "message": {"role": "assistant", "content": [tool_use]}}
32
+
33
+
34
+ def stop_feedback_record() -> dict[str, object]:
35
+ return {"type": "user", "isMeta": True, "message": {"role": "user", "content": "Stop hook feedback: update the list"}}
36
+
37
+
38
+ def write_records(transcript_path: Path, all_records: list[dict[str, object]]) -> Path:
39
+ transcript_path.write_text("\n".join(json.dumps(each_record) for each_record in all_records), encoding="utf-8")
40
+ return transcript_path
41
+
42
+
43
+ def write_task_store(home_directory: Path, all_tasks: list[dict[str, str]]) -> None:
44
+ task_directory = home_directory / ".claude" / "tasks" / SESSION_ID
45
+ task_directory.mkdir(parents=True)
46
+ for each_task in all_tasks:
47
+ (task_directory / f"{each_task['id']}.json").write_text(json.dumps(each_task), encoding="utf-8")
48
+
49
+
24
50
  def run_gate(
25
51
  monkeypatch: pytest.MonkeyPatch,
26
52
  capsys: pytest.CaptureFixture[str],
27
- reply_text: str,
28
- transcript_path: Path | None = None,
29
- ) -> tuple[int, str]:
53
+ transcript_path: Path | None,
54
+ is_stop_hook_active: bool = False,
55
+ ) -> dict[str, str]:
30
56
  hook_input: dict[str, object] = {
31
57
  "hook_event_name": "Stop",
32
58
  "session_id": SESSION_ID,
33
- "stop_hook_active": False,
34
- "last_assistant_message": reply_text,
59
+ "stop_hook_active": is_stop_hook_active,
60
+ "last_assistant_message": "Done.",
35
61
  }
36
62
  if transcript_path is not None:
37
63
  hook_input["transcript_path"] = str(transcript_path)
38
- monkeypatch.setattr(
39
- "sys.stdin", io.TextIOWrapper(io.BytesIO(json.dumps(hook_input).encode("utf-8")))
40
- )
41
- exit_code = open_tasks_stop_gate.main()
42
- return exit_code, capsys.readouterr().out
64
+ monkeypatch.setattr("sys.stdin", io.TextIOWrapper(io.BytesIO(json.dumps(hook_input).encode("utf-8"))))
65
+ assert open_tasks_stop_gate.main() == 0
66
+ printed_output = capsys.readouterr().out
67
+ return json.loads(printed_output) if printed_output else {}
43
68
 
44
69
 
45
- def write_task_store(home_directory: Path, all_tasks: list[dict[str, str]]) -> None:
46
- task_directory = home_directory / ".claude" / "tasks" / SESSION_ID
47
- task_directory.mkdir(parents=True)
48
- for each_task in all_tasks:
49
- (task_directory / f"{each_task['id']}.json").write_text(json.dumps(each_task), encoding="utf-8")
70
+ def test_should_block_a_work_turn_that_left_the_task_list_unchanged(
71
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
72
+ ) -> None:
73
+ transcript_path = write_records(tmp_path / "s.jsonl", [user_prompt_record(WORK_PROMPT), tool_call_record("Bash")])
74
+ block_output = run_gate(monkeypatch, capsys, transcript_path)
75
+ assert block_output["decision"] == "block"
76
+ assert "task list" in block_output["reason"]
77
+ assert "followup_message" not in block_output
50
78
 
51
79
 
52
- def write_todo_transcript(transcript_path: Path, all_todos: list[dict[str, str]]) -> Path:
53
- tool_use = {"type": "tool_use", "id": "t1", "name": "TodoWrite", "input": {"todos": all_todos}}
54
- transcript_path.write_text(
55
- json.dumps({"type": "assistant", "message": {"content": [tool_use]}}), encoding="utf-8"
80
+ @pytest.mark.parametrize(
81
+ "tracker_record",
82
+ [
83
+ tool_call_record("TaskCreate", {"subject": "Export the APKs"}),
84
+ tool_call_record("TaskUpdate", {"taskId": "1", "status": "in_progress"}),
85
+ tool_call_record("TodoWrite", {"todos": [{"content": "Export", "status": "in_progress"}]}),
86
+ tool_call_record("mcp__ccd_turn__update_plan", {"items": [{"content": "Export", "status": "in_progress"}]}),
87
+ ],
88
+ )
89
+ def test_should_allow_a_work_turn_that_made_a_tracker_call(
90
+ monkeypatch: pytest.MonkeyPatch,
91
+ capsys: pytest.CaptureFixture[str],
92
+ tmp_path: Path,
93
+ tracker_record: dict[str, object],
94
+ ) -> None:
95
+ transcript_path = write_records(
96
+ tmp_path / "s.jsonl", [user_prompt_record(WORK_PROMPT), tracker_record, tool_call_record("Bash")]
56
97
  )
57
- return transcript_path
98
+ assert run_gate(monkeypatch, capsys, transcript_path) == {}
58
99
 
59
100
 
60
- def test_should_block_when_a_pending_task_is_not_reported(
61
- monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
101
+ @pytest.mark.parametrize(
102
+ "non_work_tool_name",
103
+ ["AskUserQuestion", "ToolSearch", "TaskList", "TaskGet", "mcp__claude-code-remote__set_session_title"],
104
+ )
105
+ def test_should_allow_a_work_prompt_turn_that_only_asked_looked_up_or_set_the_title(
106
+ monkeypatch: pytest.MonkeyPatch,
107
+ capsys: pytest.CaptureFixture[str],
108
+ tmp_path: Path,
109
+ non_work_tool_name: str,
62
110
  ) -> None:
63
- write_task_store(tmp_path, [{"id": "1", "subject": "Rotate the deploy key", "status": "pending"}])
64
- exit_code, stdout_text = run_gate(monkeypatch, capsys, "All done.")
65
- decision = json.loads(stdout_text)
66
- assert exit_code == 0
67
- assert decision["decision"] == "block"
68
- assert "Rotate the deploy key" in decision["reason"]
111
+ transcript_path = write_records(
112
+ tmp_path / "s.jsonl", [user_prompt_record(WORK_PROMPT), tool_call_record(non_work_tool_name)]
113
+ )
114
+ assert run_gate(monkeypatch, capsys, transcript_path) == {}
69
115
 
70
116
 
71
- def test_should_allow_when_every_open_task_is_reported_as_blocked_or_open(
117
+ def test_should_block_when_the_only_tracker_call_came_in_an_earlier_turn(
72
118
  monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
73
119
  ) -> None:
74
- write_task_store(
75
- tmp_path,
120
+ transcript_path = write_records(
121
+ tmp_path / "s.jsonl",
76
122
  [
77
- {"id": "1", "subject": "Rotate the deploy key", "status": "pending"},
78
- {"id": "2", "subject": "Wait on checks", "status": "in_progress"},
79
- {"id": "3", "subject": "Write tests", "status": "completed"},
123
+ user_prompt_record("Port the hook"),
124
+ tool_call_record("TaskCreate", {"subject": "Port the hook"}),
125
+ user_prompt_record(WORK_PROMPT),
126
+ tool_call_record("Bash"),
80
127
  ],
81
128
  )
82
- reply_text = "Tests written.\nBlocked: Rotate the deploy key - the owner pastes the secret\n- Open: #2 - checks running"
83
- assert run_gate(monkeypatch, capsys, reply_text) == (0, "")
129
+ assert run_gate(monkeypatch, capsys, transcript_path)["decision"] == "block"
84
130
 
85
131
 
86
- def test_should_block_when_the_item_is_only_mentioned_in_prose(
132
+ def test_should_count_a_tracker_call_made_after_stop_hook_feedback(
87
133
  monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
88
134
  ) -> None:
89
- write_task_store(tmp_path, [{"id": "1", "subject": "Rotate the deploy key", "status": "pending"}])
90
- _, stdout_text = run_gate(monkeypatch, capsys, "I will rotate the deploy key later.")
91
- assert json.loads(stdout_text)["decision"] == "block"
135
+ transcript_path = write_records(
136
+ tmp_path / "s.jsonl",
137
+ [
138
+ user_prompt_record(WORK_PROMPT),
139
+ tool_call_record("Bash"),
140
+ stop_feedback_record(),
141
+ tool_call_record("TaskCreate", {"subject": "Export the APKs"}),
142
+ ],
143
+ )
144
+ assert run_gate(monkeypatch, capsys, transcript_path) == {}
92
145
 
93
146
 
94
- def test_should_allow_when_every_task_is_completed(
95
- monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
147
+ @pytest.mark.parametrize(
148
+ "prompt_text",
149
+ ["did the export fail?", "thanks!", "<task-notification>\n<task-id>b1</task-id>\n</task-notification>"],
150
+ )
151
+ def test_should_allow_a_turn_whose_prompt_asks_for_no_work(
152
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path, prompt_text: str
96
153
  ) -> None:
97
- write_task_store(tmp_path, [{"id": "1", "subject": "Rotate the deploy key", "status": "completed"}])
98
- assert run_gate(monkeypatch, capsys, "Done.") == (0, "")
154
+ transcript_path = write_records(tmp_path / "s.jsonl", [user_prompt_record(prompt_text), tool_call_record("Bash")])
155
+ assert run_gate(monkeypatch, capsys, transcript_path) == {}
99
156
 
100
157
 
101
- def test_should_read_todowrite_from_the_transcript_when_no_task_store_exists(
158
+ def test_should_allow_a_work_turn_that_ran_no_tool(
102
159
  monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
103
160
  ) -> None:
104
- transcript_path = write_todo_transcript(
105
- tmp_path / "s.jsonl",
106
- [{"content": "Ship the fix", "status": "in_progress"}, {"content": "Lint", "status": "completed"}],
107
- )
108
- _, stdout_text = run_gate(monkeypatch, capsys, "Done.", transcript_path)
109
- reason_text = json.loads(stdout_text)["reason"]
110
- assert "Ship the fix" in reason_text
111
- assert "Lint" not in reason_text
161
+ text_record = {"type": "assistant", "message": {"role": "assistant", "content": [{"type": "text", "text": "On it."}]}}
162
+ transcript_path = write_records(tmp_path / "s.jsonl", [user_prompt_record(WORK_PROMPT), text_record])
163
+ assert run_gate(monkeypatch, capsys, transcript_path) == {}
112
164
 
113
165
 
114
- def test_should_replay_taskcreate_and_taskupdate_from_the_transcript(
166
+ def test_should_allow_a_stop_the_gate_already_held(
115
167
  monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
116
168
  ) -> None:
117
- transcript_records = [
118
- {"type": "assistant", "message": {"content": [{"type": "tool_use", "id": "c1", "name": "TaskCreate", "input": {"subject": "Alpha"}}]}},
119
- {"type": "user", "message": {"content": [{"type": "tool_result", "tool_use_id": "c1", "content": "Task #1 created successfully: Alpha"}]}},
120
- {"type": "assistant", "message": {"content": [{"type": "tool_use", "id": "c2", "name": "TaskCreate", "input": {"subject": "Beta"}}]}},
121
- {"type": "user", "message": {"content": [{"type": "tool_result", "tool_use_id": "c2", "content": "Task #2 created successfully: Beta"}]}},
122
- {"type": "assistant", "message": {"content": [{"type": "tool_use", "id": "u1", "name": "TaskUpdate", "input": {"taskId": "1", "status": "completed"}}]}},
123
- ]
124
- transcript_path = tmp_path / "s.jsonl"
125
- transcript_path.write_text("\n".join(json.dumps(each) for each in transcript_records), encoding="utf-8")
126
- _, stdout_text = run_gate(monkeypatch, capsys, "Done.", transcript_path)
127
- reason_text = json.loads(stdout_text)["reason"]
128
- assert "Beta" in reason_text
129
- assert "Alpha" not in reason_text
169
+ transcript_path = write_records(tmp_path / "s.jsonl", [user_prompt_record(WORK_PROMPT), tool_call_record("Bash")])
170
+ assert run_gate(monkeypatch, capsys, transcript_path, is_stop_hook_active=True) == {}
130
171
 
131
172
 
132
- def test_should_read_codex_update_plan_from_the_rollout(
173
+ def test_should_ignore_open_items_when_the_turn_updated_the_list(
133
174
  monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
134
175
  ) -> None:
135
- plan_arguments = json.dumps({"plan": [{"step": "Port the hook", "status": "pending"}]})
136
- rollout_record = {"type": "response_item", "payload": {"type": "function_call", "name": "update_plan", "arguments": plan_arguments}}
137
- transcript_path = tmp_path / "rollout.jsonl"
138
- transcript_path.write_text(json.dumps(rollout_record), encoding="utf-8")
139
- _, stdout_text = run_gate(monkeypatch, capsys, "Done.", transcript_path)
140
- assert "Port the hook" in json.loads(stdout_text)["reason"]
176
+ write_task_store(tmp_path, [{"id": "1", "subject": "Rotate the deploy key", "status": "pending"}])
177
+ transcript_path = write_records(
178
+ tmp_path / "s.jsonl",
179
+ [user_prompt_record(WORK_PROMPT), tool_call_record("TaskUpdate", {"taskId": "1", "status": "in_progress"})],
180
+ )
181
+ assert run_gate(monkeypatch, capsys, transcript_path) == {}
141
182
 
142
183
 
143
- def test_should_allow_a_session_with_no_task_list(
144
- monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
145
- ) -> None:
146
- assert run_gate(monkeypatch, capsys, "Done.") == (0, "")
184
+ def test_should_allow_malformed_stdin(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
185
+ monkeypatch.setattr("sys.stdin", io.TextIOWrapper(io.BytesIO(b"not json")))
186
+ assert (open_tasks_stop_gate.main(), capsys.readouterr().out) == (0, "")
147
187
 
148
188
 
149
- def test_should_allow_malformed_stdin(
189
+ def test_should_allow_a_session_with_no_transcript(
150
190
  monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
151
191
  ) -> None:
152
- monkeypatch.setattr("sys.stdin", io.TextIOWrapper(io.BytesIO(b"not json")))
153
- assert (open_tasks_stop_gate.main(), capsys.readouterr().out) == (0, "")
192
+ assert run_gate(monkeypatch, capsys, None) == {}
154
193
 
155
194
 
156
195
  def test_should_block_through_a_subprocess_with_the_documented_contract(tmp_path: Path) -> None:
157
- write_task_store(tmp_path, [{"id": "1", "subject": "Rotate the deploy key", "status": "pending"}])
158
- hook_input = {"hook_event_name": "Stop", "session_id": SESSION_ID, "last_assistant_message": "Done."}
196
+ transcript_path = write_records(tmp_path / "s.jsonl", [user_prompt_record(WORK_PROMPT), tool_call_record("Edit")])
197
+ hook_input = {"hook_event_name": "Stop", "session_id": SESSION_ID, "transcript_path": str(transcript_path)}
159
198
  completed_process = subprocess.run(
160
199
  [sys.executable, str(GATE_PATH)],
161
200
  input=json.dumps(hook_input),
@@ -168,14 +207,34 @@ def test_should_block_through_a_subprocess_with_the_documented_contract(tmp_path
168
207
  assert json.loads(completed_process.stdout)["decision"] == "block"
169
208
 
170
209
 
171
- def write_cursor_transcript(transcript_path: Path, all_records: list[dict[str, object]]) -> Path:
172
- transcript_path.write_text("\n".join(json.dumps(each_record) for each_record in all_records), encoding="utf-8")
173
- return transcript_path
210
+ def codex_record(payload: dict[str, object]) -> dict[str, object]:
211
+ return {"type": "response_item", "payload": payload}
212
+
213
+
214
+ def codex_prompt(prompt_text: str) -> dict[str, object]:
215
+ return codex_record({"type": "message", "role": "user", "content": [{"type": "input_text", "text": prompt_text}]})
216
+
217
+
218
+ def test_should_block_a_codex_work_turn_with_no_update_plan_call(
219
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
220
+ ) -> None:
221
+ shell_call = codex_record({"type": "function_call", "name": "shell", "arguments": "{}"})
222
+ transcript_path = write_records(tmp_path / "rollout.jsonl", [codex_prompt(WORK_PROMPT), shell_call])
223
+ assert run_gate(monkeypatch, capsys, transcript_path)["decision"] == "block"
224
+
225
+
226
+ def test_should_allow_a_codex_work_turn_with_an_update_plan_call(
227
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
228
+ ) -> None:
229
+ plan_arguments = json.dumps({"plan": [{"step": "Export the APKs", "status": "in_progress"}]})
230
+ plan_call = codex_record({"type": "function_call", "name": "update_plan", "arguments": plan_arguments})
231
+ shell_call = codex_record({"type": "function_call", "name": "shell", "arguments": "{}"})
232
+ transcript_path = write_records(tmp_path / "rollout.jsonl", [codex_prompt(WORK_PROMPT), plan_call, shell_call])
233
+ assert run_gate(monkeypatch, capsys, transcript_path) == {}
174
234
 
175
235
 
176
- def cursor_todo_record(all_todos: list[dict[str, str]], is_merge: bool, reply_text: str) -> dict[str, object]:
177
- tool_use = {"type": "tool_use", "name": "TodoWrite", "input": {"todos": all_todos, "merge": is_merge}}
178
- return {"role": "assistant", "message": {"content": [{"type": "text", "text": reply_text}, tool_use]}}
236
+ def cursor_record(content_blocks: list[dict[str, object]]) -> dict[str, object]:
237
+ return {"role": "assistant", "message": {"content": content_blocks}}
179
238
 
180
239
 
181
240
  def run_cursor_gate(
@@ -198,448 +257,92 @@ def run_cursor_gate(
198
257
  return json.loads(printed_output) if printed_output else {}
199
258
 
200
259
 
201
- def test_should_send_a_cursor_followup_when_a_merged_todo_stays_pending(
202
- monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
203
- ) -> None:
204
- all_first_todos = [
205
- {"id": "1", "content": "write hello.txt", "status": "in_progress"},
206
- {"id": "2", "content": "rotate the deploy key", "status": "pending"},
207
- ]
208
- transcript_path = write_cursor_transcript(
209
- tmp_path / "cursor.jsonl",
210
- [
211
- cursor_todo_record(all_first_todos, False, "Starting."),
212
- cursor_todo_record([{"id": "1", "status": "completed"}], True, "Done."),
213
- ],
214
- )
215
- block_output = run_cursor_gate(monkeypatch, capsys, transcript_path)
216
- assert "rotate the deploy key" in block_output["followup_message"]
217
- assert "write hello.txt" not in block_output["followup_message"]
218
- assert block_output["decision"] == "block"
260
+ def write_cursor_work_turn(tmp_path: Path, all_tool_blocks: list[dict[str, object]]) -> Path:
261
+ cursor_prompt = {"role": "user", "message": {"content": [{"type": "text", "text": WORK_PROMPT}]}}
262
+ return write_records(tmp_path / "cursor.jsonl", [cursor_prompt, cursor_record(all_tool_blocks)])
219
263
 
220
264
 
221
- @pytest.mark.parametrize("stop_status", ["aborted", "error"])
222
- def test_should_allow_a_cursor_stop_the_user_aborted_or_that_errored(
223
- monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path, stop_status: str
224
- ) -> None:
225
- all_todos = [{"id": "2", "content": "rotate the deploy key", "status": "pending"}]
226
- transcript_path = write_cursor_transcript(
227
- tmp_path / "cursor.jsonl", [cursor_todo_record(all_todos, False, "Stopping.")]
228
- )
229
- assert run_cursor_gate(monkeypatch, capsys, transcript_path, stop_status) == {}
230
-
231
-
232
- def test_should_allow_cursor_when_the_last_assistant_text_reports_the_item(
265
+ def test_should_send_a_cursor_followup_for_an_untracked_work_turn(
233
266
  monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
234
267
  ) -> None:
235
- all_todos = [{"id": "2", "content": "rotate the deploy key", "status": "pending"}]
236
- transcript_path = write_cursor_transcript(
237
- tmp_path / "cursor.jsonl",
238
- [
239
- cursor_todo_record(all_todos, False, "Working."),
240
- {"role": "assistant", "message": {"content": [
241
- {"type": "text", "text": "Blocked: rotate the deploy key - the owner pastes the org secret"}
242
- ]}},
243
- ],
244
- )
245
- assert run_cursor_gate(monkeypatch, capsys, transcript_path) == {}
268
+ transcript_path = write_cursor_work_turn(tmp_path, [{"type": "tool_use", "name": "Shell", "input": {}}])
269
+ block_output = run_cursor_gate(monkeypatch, capsys, transcript_path)
270
+ assert block_output["followup_message"] == block_output["reason"]
246
271
 
247
272
 
248
- def test_should_leave_claude_block_output_without_a_followup_message(
273
+ def test_should_count_a_todowrite_the_cursor_cli_wraps_in_call_dynamic_tool(
249
274
  monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
250
275
  ) -> None:
251
- transcript_path = write_todo_transcript(
252
- tmp_path / "t.jsonl", [{"content": "rotate the deploy key", "status": "pending"}]
253
- )
254
- _, printed_output = run_gate(monkeypatch, capsys, "Done.", transcript_path)
255
- assert set(json.loads(printed_output)) == {"decision", "reason"}
256
-
257
-
258
- def cursor_dynamic_todo_record(all_todos: list[dict[str, str]], is_merge: bool) -> dict[str, object]:
259
- tool_use = {
276
+ dynamic_call = {
260
277
  "type": "tool_use",
261
278
  "name": "CallDynamicTool",
262
- "input": {"namespace": "cursor", "toolName": "TodoWrite", "arguments": {"merge": is_merge, "todos": all_todos}},
279
+ "input": {"toolName": "TodoWrite", "arguments": {"todos": [{"id": "1", "content": "Export", "status": "in_progress"}]}},
263
280
  }
264
- return {"role": "assistant", "message": {"content": [tool_use]}}
265
-
266
-
267
- def test_should_read_todos_the_cursor_cli_writes_through_call_dynamic_tool(
268
- monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
269
- ) -> None:
270
- all_first_todos = [
271
- {"id": "1", "content": "write hello.txt", "status": "in_progress"},
272
- {"id": "2", "content": "rotate the deploy key", "status": "pending"},
273
- ]
274
- all_merged_todos = [
275
- {"id": "1", "content": "write hello.txt", "status": "completed"},
276
- {"id": "2", "content": "rotate the deploy key", "status": "pending"},
277
- ]
278
- transcript_path = write_cursor_transcript(
279
- tmp_path / "cursor.jsonl",
280
- [
281
- cursor_dynamic_todo_record(all_first_todos, False),
282
- cursor_dynamic_todo_record(all_merged_todos, True),
283
- {"role": "assistant", "message": {"content": [{"type": "text", "text": "Done."}]}},
284
- {"type": "turn_ended", "status": "success"},
285
- ],
286
- )
287
- block_output = run_cursor_gate(monkeypatch, capsys, transcript_path)
288
- assert "rotate the deploy key" in block_output["followup_message"]
289
- assert "write hello.txt" not in block_output["followup_message"]
290
-
291
-
292
- def test_should_allow_a_cursor_stop_after_five_followups(
293
- monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
294
- ) -> None:
295
- all_todos = [{"id": "2", "content": "rotate the deploy key", "status": "pending"}]
296
- transcript_path = write_cursor_transcript(tmp_path / "cursor.jsonl", [cursor_todo_record(all_todos, False, "Done.")])
297
- assert "rotate the deploy key" in run_cursor_gate(monkeypatch, capsys, transcript_path, loop_count=4)["followup_message"]
298
- assert run_cursor_gate(monkeypatch, capsys, transcript_path, loop_count=5) == {}
299
-
300
-
301
- def ccd_plan_record(all_plan_input: dict[str, object]) -> dict[str, object]:
302
- tool_use = {"type": "tool_use", "id": "p1", "name": "mcp__ccd_turn__update_plan", "input": all_plan_input}
303
- return {"type": "assistant", "timestamp": "2026-01-01T00:00:00.000Z", "message": {"content": [tool_use]}}
304
-
305
-
306
- def assistant_text_record(reply_text: str) -> dict[str, object]:
307
- return {"type": "assistant", "message": {"role": "assistant", "content": [{"type": "text", "text": reply_text}]}}
308
-
309
-
310
- def write_records(transcript_path: Path, all_records: list[dict[str, object]]) -> Path:
311
- transcript_path.write_text("\n".join(json.dumps(each_record) for each_record in all_records), encoding="utf-8")
312
- return transcript_path
281
+ transcript_path = write_cursor_work_turn(tmp_path, [dynamic_call, {"type": "tool_use", "name": "Shell", "input": {}}])
282
+ assert run_cursor_gate(monkeypatch, capsys, transcript_path) == {}
313
283
 
314
284
 
315
- def block_reason(
285
+ @pytest.mark.parametrize(("stop_status", "loop_count"), [("aborted", 0), ("error", 0), ("completed", 1)])
286
+ def test_should_allow_a_cursor_stop_that_was_aborted_or_already_followed_up(
316
287
  monkeypatch: pytest.MonkeyPatch,
317
288
  capsys: pytest.CaptureFixture[str],
318
- reply_text: str,
319
- transcript_path: Path | None = None,
320
- ) -> str:
321
- _, stdout_text = run_gate(monkeypatch, capsys, reply_text, transcript_path)
322
- return json.loads(stdout_text)["reason"] if stdout_text else ""
323
-
324
-
325
- def record_ask(prompt_text: str) -> str | None:
326
- return importlib.import_module("open_task_asks").record_prompt(SESSION_ID, prompt_text)
327
-
328
-
329
- def submit_and_replace_list(tmp_path: Path) -> Path:
330
- return write_records(
331
- tmp_path / "s.jsonl",
332
- [
333
- ccd_plan_record({"items": [{"content": "Submit both APKs", "status": "pending"}], "scope": "first_task"}),
334
- ccd_plan_record({"items": [{"content": "Set rows in Neon", "status": "completed"}], "scope": "continuation"}),
335
- ],
336
- )
337
-
338
-
339
- def test_should_hold_an_interrupted_ask_after_the_user_redirects(
340
- monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
341
- ) -> None:
342
- assert record_ask("Export the OneUI8 and OneUI9 APKs and submit them") == "ask-1"
343
- assert record_ask("Set the 8 rows to Ready to Export in Neon") == "ask-2"
344
- transcript_path = write_records(
345
- tmp_path / "s.jsonl",
346
- [ccd_plan_record({"items": [{"content": "Set the 8 rows in Neon", "status": "completed"}], "scope": "continuation"})],
347
- )
348
- reason_text = block_reason(monkeypatch, capsys, "Rows set.\nDone: #ask-2", transcript_path)
349
- assert "#ask-1" in reason_text
350
- assert "OneUI9" in reason_text
351
- assert "#ask-2" not in reason_text
352
-
353
-
354
- def test_should_hold_plan_items_a_continuation_list_dropped(
355
- monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
356
- ) -> None:
357
- first_items = [
358
- {"content": "Export the OneUI8 APK", "status": "in_progress"},
359
- {"content": "Submit both APKs", "status": "pending"},
360
- ]
361
- transcript_path = write_records(
362
- tmp_path / "s.jsonl",
363
- [
364
- ccd_plan_record({"items": first_items, "scope": "first_task"}),
365
- ccd_plan_record({"scope": "continuation", "items": [{"content": "Set rows in Neon", "status": "completed"}]}),
366
- ],
367
- )
368
- reason_text = block_reason(monkeypatch, capsys, "Rows set.", transcript_path)
369
- assert "Export the OneUI8 APK" in reason_text
370
- assert "Submit both APKs" in reason_text
371
- assert "Set rows in Neon" not in reason_text
372
-
373
-
374
- def test_should_hold_an_ask_that_never_reached_a_plan(
375
- monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
289
+ tmp_path: Path,
290
+ stop_status: str,
291
+ loop_count: int,
376
292
  ) -> None:
377
- record_ask("Export the OneUI8 and OneUI9 APKs and submit them")
378
- reason_text = block_reason(monkeypatch, capsys, "Done.")
379
- assert "#ask-1: Export the OneUI8 and OneUI9 APKs and submit them" in reason_text
380
- assert "Done: #ask-N" in reason_text
293
+ transcript_path = write_cursor_work_turn(tmp_path, [{"type": "tool_use", "name": "Shell", "input": {}}])
294
+ assert run_cursor_gate(monkeypatch, capsys, transcript_path, stop_status, loop_count) == {}
381
295
 
382
296
 
383
- def test_should_merge_the_task_store_with_the_ccd_plan(
384
- monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
385
- ) -> None:
297
+ def test_should_read_the_task_store_items_for_the_session(tmp_path: Path) -> None:
386
298
  write_task_store(tmp_path, [{"id": "1", "subject": "Rotate the deploy key", "status": "pending"}])
387
- transcript_path = write_records(
388
- tmp_path / "s.jsonl",
389
- [ccd_plan_record({"items": [{"content": "Port the hook", "status": "in_progress"}], "scope": "first_task"})],
390
- )
391
- reason_text = block_reason(monkeypatch, capsys, "Done.", transcript_path)
392
- assert "Rotate the deploy key" in reason_text
393
- assert "Port the hook" in reason_text
394
-
395
-
396
- def test_should_hold_an_ask_whose_subject_matches_a_completed_task(
397
- monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
398
- ) -> None:
399
- write_task_store(tmp_path, [{"id": "1", "subject": "Fix login", "status": "completed"}])
400
- assert record_ask("Fix login") == "ask-1"
401
- reason_text = block_reason(monkeypatch, capsys, "Done.")
402
- assert "#ask-1: Fix login" in reason_text
403
-
404
-
405
- def test_should_hold_a_pending_task_that_shares_a_subject_with_a_completed_task(
406
- monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
407
- ) -> None:
408
- write_task_store(
409
- tmp_path,
410
- [
411
- {"id": "1", "subject": "Run tests", "status": "completed"},
412
- {"id": "2", "subject": "Run tests", "status": "pending"},
413
- ],
414
- )
415
- assert "Run tests" in block_reason(monkeypatch, capsys, "Done.")
299
+ assert open_tasks_stop_gate.task_store_items(SESSION_ID) == [TaskItem("1", "Rotate the deploy key", "pending")]
300
+ assert open_tasks_stop_gate.task_store_items("s-none") is None
416
301
 
417
302
 
418
- def test_should_record_nothing_for_a_question_only_prompt(
419
- monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
420
- ) -> None:
421
- assert record_ask("wait, did the export fail?") is None
422
- assert record_ask("are you monitoring these? yes or no") is None
423
- assert run_gate(monkeypatch, capsys, "No, it passed.") == (0, "")
303
+ def test_should_let_the_task_store_win_over_a_transcript_item_of_the_same_subject() -> None:
304
+ store_item = TaskItem("1", "Export the APKs", "completed")
305
+ transcript_item = TaskItem("plan-1", "export the APKs", "pending")
306
+ other_item = TaskItem("plan-2", "Submit", "pending")
307
+ assert open_tasks_stop_gate.deduplicated_by_subject([[store_item], [transcript_item, other_item]]) == [
308
+ store_item,
309
+ other_item,
310
+ ]
424
311
 
425
312
 
426
- def test_should_not_close_a_plan_item_on_a_done_line(
427
- monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
428
- ) -> None:
313
+ def test_should_merge_the_task_store_with_the_transcript_items(tmp_path: Path) -> None:
314
+ write_task_store(tmp_path, [{"id": "1", "subject": "Export the APKs", "status": "completed"}])
429
315
  transcript_path = write_records(
430
316
  tmp_path / "s.jsonl",
431
- [ccd_plan_record({"items": [{"content": "Port the hook", "status": "in_progress"}], "scope": "first_task"})],
317
+ [tool_call_record("TodoWrite", {"todos": [{"id": "9", "content": "Submit", "status": "pending"}]})],
432
318
  )
433
- assert "Port the hook" in block_reason(monkeypatch, capsys, "Done: Port the hook", transcript_path)
434
-
435
-
436
- def test_should_close_an_ask_and_a_carried_item_on_a_dropped_line_quoting_the_user(
437
- monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
438
- ) -> None:
439
- record_ask("Export the OneUI8 and OneUI9 APKs and submit them")
440
- transcript_path = submit_and_replace_list(tmp_path)
441
- record_ask("Skip the APK export for now, set the rows instead")
442
- reply_text = (
443
- "Rows set.\nDone: #ask-2\n"
444
- 'Dropped: #ask-1 - "skip the APK export for now"\n'
445
- 'Dropped: Submit both APKs - "Skip the APK export"'
446
- )
447
- assert run_gate(monkeypatch, capsys, reply_text, transcript_path) == (0, "")
448
- with transcript_path.open("a", encoding="utf-8") as transcript_handle:
449
- transcript_handle.write("\n" + json.dumps(assistant_text_record(reply_text)))
450
- assert run_gate(monkeypatch, capsys, "Nothing else.", transcript_path) == (0, "")
451
-
452
-
453
- def test_should_hold_a_dropped_line_whose_quote_the_user_never_wrote(
454
- monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
455
- ) -> None:
456
- record_ask("Export the OneUI8 and OneUI9 APKs and submit them")
457
- transcript_path = submit_and_replace_list(tmp_path)
458
- record_ask("Set the rows next")
459
- reply_text = 'Dropped: #ask-1 - "the user cancelled the export"\nDropped: Submit both APKs - "skip it, it is done"'
460
- reason_text = block_reason(monkeypatch, capsys, reply_text, transcript_path)
461
- assert "#ask-1" in reason_text
462
- assert "Submit both APKs" in reason_text
463
-
464
-
465
- def test_should_hold_a_dropped_line_quoting_the_ask_itself(
466
- monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
467
- ) -> None:
468
- record_ask("Export the OneUI8 and OneUI9 APKs and submit them")
469
- reason_text = block_reason(monkeypatch, capsys, 'Dropped: #ask-1 - "Export the OneUI8"')
470
- assert "#ask-1" in reason_text
471
-
472
-
473
- def test_should_hold_an_ask_again_when_the_next_reply_omits_its_open_line(
474
- monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
475
- ) -> None:
476
- record_ask("Export the OneUI8 and OneUI9 APKs and submit them")
477
- first_reply = "Open: #ask-1 - the export job runs next"
478
- assert run_gate(monkeypatch, capsys, first_reply) == (0, "")
479
- transcript_path = write_records(tmp_path / "s.jsonl", [assistant_text_record(first_reply)])
480
- assert "#ask-1" in block_reason(monkeypatch, capsys, "Still running.", transcript_path)
481
-
482
-
483
- def test_should_allow_the_stop_once_a_carried_item_is_listed_completed(
484
- monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
485
- ) -> None:
486
- transcript_path = submit_and_replace_list(tmp_path)
487
- assert "Submit both APKs" in block_reason(monkeypatch, capsys, "Rows set.", transcript_path)
488
- with transcript_path.open("a", encoding="utf-8") as transcript_handle:
489
- relisted = ccd_plan_record({"items": [{"content": "Submit both APKs", "status": "completed"}], "scope": "continuation"})
490
- transcript_handle.write("\n" + json.dumps(relisted))
491
- assert run_gate(monkeypatch, capsys, "Both submitted.", transcript_path) == (0, "")
492
-
493
-
494
- def test_should_mark_the_right_item_for_a_done_index_after_a_continuation(
495
- monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
496
- ) -> None:
497
- replacing_items = [
498
- {"content": "Read the schema", "status": "in_progress"},
499
- {"content": "Set rows in Neon", "status": "pending"},
319
+ assert open_tasks_stop_gate.session_task_items(SESSION_ID, replayed_transcript(str(transcript_path))) == [
320
+ TaskItem("1", "Export the APKs", "completed"),
321
+ TaskItem("9", "Submit", "pending"),
500
322
  ]
501
- transcript_path = write_records(
502
- tmp_path / "s.jsonl",
503
- [
504
- ccd_plan_record({"items": [{"content": "Submit both APKs", "status": "pending"}], "scope": "first_task"}),
505
- ccd_plan_record({"items": replacing_items, "scope": "continuation"}),
506
- ccd_plan_record({"done": [1, 2]}),
507
- ],
508
- )
509
- reason_text = block_reason(monkeypatch, capsys, "Rows set.", transcript_path)
510
- assert "Submit both APKs" in reason_text
511
- assert "Read the schema" not in reason_text
512
- assert "Set rows in Neon" not in reason_text
513
323
 
514
324
 
515
- DECISION_BLOCKED_REPLY = "Branch pushed.\nBlocked: #ask-1 - your OK to merge the app branch first."
516
-
517
-
518
- def user_prompt_record(prompt_text: str) -> dict[str, object]:
519
- return {"type": "user", "message": {"role": "user", "content": prompt_text}}
520
-
521
-
522
- def tool_call_record(tool_name: str) -> dict[str, object]:
523
- tool_use = {"type": "tool_use", "id": "q1", "name": tool_name, "input": {}}
524
- return {"type": "assistant", "message": {"role": "assistant", "content": [tool_use]}}
525
-
526
-
527
- def test_should_block_a_decision_blocked_line_with_no_card_this_turn(
528
- monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
529
- ) -> None:
530
- record_ask("Merge the release branch")
531
- transcript_path = write_records(tmp_path / "s.jsonl", [user_prompt_record("Merge the release branch")])
532
- reason_text = block_reason(monkeypatch, capsys, DECISION_BLOCKED_REPLY, transcript_path)
533
- assert "AskUserQuestion" in reason_text
534
- assert "your OK to merge" in reason_text
325
+ def test_should_call_a_turn_untracked_only_for_work_without_a_tracker_call(tmp_path: Path) -> None:
326
+ untracked_path = write_records(tmp_path / "a.jsonl", [user_prompt_record(WORK_PROMPT), tool_call_record("Bash")])
327
+ question_path = write_records(tmp_path / "b.jsonl", [user_prompt_record("did it fail?"), tool_call_record("Bash")])
328
+ assert open_tasks_stop_gate.is_untracked_work_turn(replayed_transcript(str(untracked_path))) is True
329
+ assert open_tasks_stop_gate.is_untracked_work_turn(replayed_transcript(str(question_path))) is False
535
330
 
536
331
 
537
332
  @pytest.mark.parametrize(
538
- "card_tool_name",
539
- ["AskUserQuestion", "mcp__hearthbot__ask_decision", "mcp__hearthbot__request_decision"],
333
+ ("hook_input", "is_expected_held"),
334
+ [
335
+ ({"stop_hook_active": True}, True),
336
+ ({"stop_hook_active": False}, False),
337
+ ({"conversation_id": "c-1", "status": "completed", "loop_count": 0}, False),
338
+ ({"conversation_id": "c-1", "status": "completed", "loop_count": 1}, True),
339
+ ({"conversation_id": "c-1", "status": "aborted", "loop_count": 0}, True),
340
+ ],
540
341
  )
541
- def test_should_allow_a_decision_blocked_line_after_a_card_this_turn(
542
- monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path, card_tool_name: str
543
- ) -> None:
544
- record_ask("Merge the release branch")
545
- transcript_path = write_records(
546
- tmp_path / "s.jsonl", [user_prompt_record("Merge the release branch"), tool_call_record(card_tool_name)]
547
- )
548
- assert run_gate(monkeypatch, capsys, DECISION_BLOCKED_REPLY, transcript_path) == (0, "")
549
-
550
-
551
- def test_should_block_a_decision_blocked_line_when_the_card_came_in_an_earlier_turn(
552
- monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
553
- ) -> None:
554
- record_ask("Merge the release branch")
555
- transcript_path = write_records(
556
- tmp_path / "s.jsonl",
557
- [
558
- tool_call_record("AskUserQuestion"),
559
- user_prompt_record("Merge the release branch"),
560
- ],
561
- )
562
- assert "AskUserQuestion" in block_reason(monkeypatch, capsys, DECISION_BLOCKED_REPLY, transcript_path)
563
-
564
-
565
- def test_should_allow_a_blocked_line_for_an_input_the_user_supplies(
566
- monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
567
- ) -> None:
568
- record_ask("Rotate the deploy key")
569
- transcript_path = write_records(tmp_path / "s.jsonl", [user_prompt_record("Rotate the deploy key")])
570
- reply_text = "Key script ready.\nBlocked: #ask-1 - the new deploy key secret, pasted into the session"
571
- assert run_gate(monkeypatch, capsys, reply_text, transcript_path) == (0, "")
572
-
573
-
574
- def test_should_keep_the_card_through_stop_hook_feedback(
575
- monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
576
- ) -> None:
577
- record_ask("Merge the release branch")
578
- transcript_path = write_records(
579
- tmp_path / "s.jsonl",
580
- [
581
- user_prompt_record("Merge the release branch"),
582
- tool_call_record("AskUserQuestion"),
583
- {"type": "user", "isMeta": True, "message": {"content": "Stop hook feedback"}},
584
- ],
585
- )
586
- assert run_gate(monkeypatch, capsys, DECISION_BLOCKED_REPLY, transcript_path) == (0, "")
587
-
588
-
589
- def test_should_ignore_a_card_call_copied_into_an_api_request_blob(
590
- monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
591
- ) -> None:
592
- record_ask("Merge the release branch")
593
- blob_record = {**tool_call_record("AskUserQuestion"), "type": "api-request-blob"}
594
- transcript_path = write_records(tmp_path / "s.jsonl", [user_prompt_record("Merge the release branch"), blob_record])
595
- assert "AskUserQuestion" in block_reason(monkeypatch, capsys, DECISION_BLOCKED_REPLY, transcript_path)
596
-
597
-
598
- def test_should_allow_an_input_blocked_line_that_says_whether(
599
- monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
600
- ) -> None:
601
- record_ask("Merge the release branch")
602
- transcript_path = write_records(tmp_path / "s.jsonl", [user_prompt_record("Merge the release branch")])
603
- input_reply = "Branch pushed.\nBlocked: #ask-1 - paste the log showing whether the migration ran."
604
- assert run_gate(monkeypatch, capsys, input_reply, transcript_path) == (0, "")
605
-
606
-
607
- def test_should_start_a_new_turn_at_an_image_only_prompt(
608
- monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
609
- ) -> None:
610
- record_ask("Merge the release branch")
611
- image_prompt = {"type": "user", "message": {"role": "user", "content": [{"type": "image", "source": {}}]}}
612
- transcript_path = write_records(
613
- tmp_path / "s.jsonl",
614
- [user_prompt_record("Merge the release branch"), tool_call_record("AskUserQuestion"), image_prompt],
615
- )
616
- assert "AskUserQuestion" in block_reason(monkeypatch, capsys, DECISION_BLOCKED_REPLY, transcript_path)
617
-
618
-
619
- def test_should_allow_a_decision_blocked_line_after_a_codex_request_user_input_call(
620
- monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
621
- ) -> None:
622
- record_ask("Merge the release branch")
623
- user_message = {"type": "response_item", "payload": {"type": "message", "role": "user", "content": []}}
624
- card_call = {"type": "response_item", "payload": {"type": "function_call", "name": "request_user_input", "arguments": "{}"}}
625
- transcript_path = write_records(tmp_path / "rollout.jsonl", [user_message, card_call])
626
- assert run_gate(monkeypatch, capsys, DECISION_BLOCKED_REPLY, transcript_path) == (0, "")
342
+ def test_should_tell_a_held_stop_from_a_first_stop(hook_input: dict[str, object], is_expected_held: bool) -> None:
343
+ assert open_tasks_stop_gate.is_held_stop(hook_input) is is_expected_held
627
344
 
628
345
 
629
- def test_should_allow_a_blocked_line_for_a_device_step_the_user_takes(
630
- monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
631
- ) -> None:
632
- record_ask("Install the runner")
633
- transcript_path = write_records(tmp_path / "s.jsonl", [user_prompt_record("Install the runner")])
634
- reply_text = "Installer built.\nBlocked: #ask-1 - run the installer on your PC and paste the log"
635
- assert run_gate(monkeypatch, capsys, reply_text, transcript_path) == (0, "")
636
-
637
-
638
- def test_should_leave_a_cursor_decision_blocked_line_alone(
639
- monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
640
- ) -> None:
641
- transcript_path = write_cursor_transcript(
642
- tmp_path / "cursor.jsonl",
643
- [{"role": "assistant", "message": {"content": [{"type": "text", "text": DECISION_BLOCKED_REPLY}]}}],
644
- )
645
- assert run_cursor_gate(monkeypatch, capsys, transcript_path) == {}
346
+ def test_should_add_a_followup_message_only_for_a_cursor_payload() -> None:
347
+ assert open_tasks_stop_gate.block_output_for({}, "why") == {"decision": "block", "reason": "why"}
348
+ assert open_tasks_stop_gate.block_output_for({"conversation_id": "c-1"}, "why")["followup_message"] == "why"