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.
- package/AGENTS.md +1 -1
- package/bin/install.prompt-context.test.mjs +1 -1
- package/docs/account-broker.md +1 -1
- package/docs/agents-guidance/asks.md +13 -12
- package/docs/codex-accounts.md +11 -0
- 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/codex_account_choice.py +98 -6
- package/scripts/dev_env_scripts_constants/codex_account_constants.py +17 -0
- package/scripts/policy_lint/adapter_configuration.py +6 -6
- package/scripts/test_account_broker.py +12 -0
- package/scripts/test_codex_account_choice.py +121 -0
- package/hooks/session/open_task_ask_recorder.py +0 -38
- package/hooks/session/test_open_task_ask_recorder.py +0 -57
|
@@ -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
|
-
|
|
28
|
-
|
|
29
|
-
) ->
|
|
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":
|
|
34
|
-
"last_assistant_message":
|
|
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
|
-
|
|
40
|
-
)
|
|
41
|
-
|
|
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
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
98
|
+
assert run_gate(monkeypatch, capsys, transcript_path) == {}
|
|
58
99
|
|
|
59
100
|
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
assert
|
|
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
|
|
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
|
-
|
|
75
|
-
tmp_path,
|
|
120
|
+
transcript_path = write_records(
|
|
121
|
+
tmp_path / "s.jsonl",
|
|
76
122
|
[
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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
|
-
|
|
98
|
-
assert run_gate(monkeypatch, capsys,
|
|
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
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
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
|
-
|
|
118
|
-
|
|
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
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
assert
|
|
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
|
|
144
|
-
monkeypatch
|
|
145
|
-
)
|
|
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
|
|
189
|
+
def test_should_allow_a_session_with_no_transcript(
|
|
150
190
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
151
191
|
) -> None:
|
|
152
|
-
monkeypatch
|
|
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
|
-
|
|
158
|
-
hook_input = {"hook_event_name": "Stop", "session_id": SESSION_ID, "
|
|
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
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
177
|
-
|
|
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
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
|
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
|
-
|
|
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": {"
|
|
279
|
+
"input": {"toolName": "TodoWrite", "arguments": {"todos": [{"id": "1", "content": "Export", "status": "in_progress"}]}},
|
|
263
280
|
}
|
|
264
|
-
|
|
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
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
378
|
-
|
|
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
|
|
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
|
-
|
|
388
|
-
|
|
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
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
assert
|
|
423
|
-
|
|
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
|
|
427
|
-
|
|
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
|
-
[
|
|
317
|
+
[tool_call_record("TodoWrite", {"todos": [{"id": "9", "content": "Submit", "status": "pending"}]})],
|
|
432
318
|
)
|
|
433
|
-
assert
|
|
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
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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
|
-
"
|
|
539
|
-
[
|
|
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
|
|
542
|
-
|
|
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
|
|
630
|
-
|
|
631
|
-
)
|
|
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"
|