@research-copilot/plugin 1.1.15 → 1.1.16
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/dist/.claude-plugin/plugin.json +3 -2
- package/dist/.codex-plugin/plugin.toml +2 -1
- package/dist/.cursor-plugin/plugin.json +3 -2
- package/dist/.gemini-plugin/plugin.json +3 -2
- package/dist/.opencode-plugin/plugin.json +3 -2
- package/dist/.windsurf-plugin/plugin.json +3 -2
- package/dist/agents/copilot-conductor.agent.md +60 -0
- package/dist/agents/copilot-experiment.agent.md +56 -0
- package/dist/agents/copilot-ideation.agent.md +45 -0
- package/dist/agents/copilot-literature.agent.md +34 -0
- package/dist/agents/copilot-polisher.agent.md +30 -0
- package/dist/agents/copilot-rebuttal.agent.md +35 -0
- package/dist/agents/copilot-reviewer.agent.md +35 -0
- package/dist/agents/copilot-writer.agent.md +39 -0
- package/dist/hooks/dispatch-reminder.json +17 -0
- package/dist/hooks/loop-armer.json +17 -0
- package/dist/hooks/research-copilot-guard.hook.md +51 -0
- package/dist/hooks/scientist-guardrails.json +17 -0
- package/dist/hooks/scripts/__tests__/__init__.py +0 -0
- package/dist/hooks/scripts/__tests__/test_post_tool_loop_armer.py +88 -0
- package/dist/hooks/scripts/__tests__/test_research_copilot_guard_main_session.py +150 -0
- package/dist/hooks/scripts/__tests__/test_session_start_memory_injector.py +66 -0
- package/dist/hooks/scripts/__tests__/test_user_prompt_dispatch_reminder.py +37 -0
- package/dist/hooks/scripts/_copilot_hook_lib.py +564 -0
- package/dist/hooks/scripts/copilot_subagent_stop.py +203 -0
- package/dist/hooks/scripts/copilot_write_guard.py +96 -0
- package/dist/hooks/scripts/post_tool_loop_armer.py +61 -0
- package/dist/hooks/scripts/research_copilot_guard.py +208 -0
- package/dist/hooks/scripts/scientist_guardrails.py +29 -0
- package/dist/hooks/scripts/session_start_memory_injector.py +188 -0
- package/dist/hooks/scripts/user_prompt_dispatch_reminder.py +40 -0
- package/dist/hooks/session-memory-injector.json +17 -0
- package/dist/hooks/tests/__init__.py +0 -0
- package/dist/hooks/tests/conftest.py +61 -0
- package/dist/hooks/tests/fixtures/transcript_copilot_experiment_complete.jsonl +2 -0
- package/dist/hooks/tests/fixtures/transcript_copilot_experiment_state_jump.jsonl +2 -0
- package/dist/hooks/tests/fixtures/transcript_copilot_literature.jsonl +2 -0
- package/dist/hooks/tests/fixtures/transcript_main_only.jsonl +2 -0
- package/dist/hooks/tests/fixtures/transcript_malformed_state_output.jsonl +2 -0
- package/dist/hooks/tests/integration_run.ps1 +65 -0
- package/dist/hooks/tests/test_copilot_hook_lib.py +398 -0
- package/dist/hooks/tests/test_copilot_subagent_stop.py +186 -0
- package/dist/hooks/tests/test_copilot_write_guard.py +137 -0
- package/dist/hooks/tests/test_session_start_snapshot.py +116 -0
- package/dist/hooks/tests/test_state_machine_consistency.py +75 -0
- package/dist/skills/arxivsub-skill/SKILL.md +98 -0
- package/dist/skills/arxivsub-skill/skill.json +5 -0
- package/dist/skills/de-ai-checker/SKILL.md +110 -0
- package/dist/skills/de-ai-checker/skill.json +5 -0
- package/dist/skills/deep-interview/SKILL.md +91 -0
- package/dist/skills/deep-interview/skill.json +5 -0
- package/dist/skills/grill-with-docs/SKILL.md +120 -0
- package/dist/skills/grill-with-docs/skill.json +5 -0
- package/dist/skills/init-mcp/SKILL.md +83 -0
- package/dist/skills/init-mcp/skill.json +5 -0
- package/dist/skills/model-escalation/SKILL.md +93 -0
- package/dist/skills/model-escalation/skill.json +5 -0
- package/dist/skills/paper-architecture-web-drawing/SKILL.md +282 -0
- package/dist/skills/paper-architecture-web-drawing/skill.json +5 -0
- package/dist/skills/paper-deai/SKILL.md +53 -0
- package/dist/skills/paper-deai/skill.json +5 -0
- package/dist/skills/paper-en2zh/SKILL.md +29 -0
- package/dist/skills/paper-en2zh/skill.json +5 -0
- package/dist/skills/paper-expand/SKILL.md +43 -0
- package/dist/skills/paper-expand/skill.json +5 -0
- package/dist/skills/paper-experiment-analysis/SKILL.md +38 -0
- package/dist/skills/paper-experiment-analysis/skill.json +5 -0
- package/dist/skills/paper-figure-caption/SKILL.md +29 -0
- package/dist/skills/paper-figure-caption/skill.json +5 -0
- package/dist/skills/paper-logic-check/SKILL.md +30 -0
- package/dist/skills/paper-logic-check/skill.json +5 -0
- package/dist/skills/paper-polish/SKILL.md +34 -305
- package/dist/skills/paper-polish/skill.json +5 -0
- package/dist/skills/paper-review/SKILL.md +49 -0
- package/dist/skills/paper-review/skill.json +5 -0
- package/dist/skills/paper-sanity-check/SKILL.md +122 -0
- package/dist/skills/paper-sanity-check/skill.json +5 -0
- package/dist/skills/paper-shorten/SKILL.md +42 -0
- package/dist/skills/paper-shorten/skill.json +5 -0
- package/dist/skills/paper-table-caption/SKILL.md +29 -0
- package/dist/skills/paper-table-caption/skill.json +5 -0
- package/dist/skills/paper-translate/SKILL.md +48 -0
- package/dist/skills/paper-translate/skill.json +5 -0
- package/dist/skills/plugin-dev-agent-development/SKILL.md +95 -0
- package/dist/skills/plugin-dev-agent-development/skill.json +5 -0
- package/dist/skills/research-workflow/SKILL.md +116 -0
- package/dist/skills/research-workflow/skill.json +5 -0
- package/dist/skills/scientist-experiment-runner/SKILL.md +76 -0
- package/dist/skills/scientist-experiment-runner/skill.json +5 -0
- package/dist/skills/scientist-ideation/SKILL.md +52 -0
- package/dist/skills/scientist-ideation/skill.json +5 -0
- package/dist/skills/scientist-plotting/SKILL.md +49 -0
- package/dist/skills/scientist-plotting/skill.json +5 -0
- package/dist/skills/scientist-review/SKILL.md +40 -0
- package/dist/skills/scientist-review/skill.json +5 -0
- package/dist/skills/scientist-runtime-init/SKILL.md +46 -0
- package/dist/skills/scientist-runtime-init/skill.json +5 -0
- package/dist/skills/scientist-writeup/SKILL.md +60 -0
- package/dist/skills/scientist-writeup/skill.json +5 -0
- package/dist/skills/talk-normal/SKILL.md +73 -0
- package/dist/skills/talk-normal/skill.json +5 -0
- package/package.json +1 -1
- package/dist/agents/rc-experiment.md +0 -203
- package/dist/agents/rc-ideation.md +0 -224
- package/dist/agents/rc-literature.md +0 -228
- package/dist/agents/rc-plan.md +0 -189
- package/dist/agents/rc-polisher.md +0 -166
- package/dist/agents/rc-rebuttal.md +0 -194
- package/dist/agents/rc-reviewer.md +0 -187
- package/dist/agents/rc-update-spec.md +0 -231
- package/dist/agents/rc-verify.md +0 -234
- package/dist/agents/rc-writer.md +0 -161
- package/dist/skills/experiment-design/SKILL.md +0 -331
- package/dist/skills/full-research-workflow/SKILL.md +0 -363
- package/dist/skills/literature-search/SKILL.md +0 -244
- package/dist/skills/sanity-check/SKILL.md +0 -449
- package/dist/skills/submission-sprint/SKILL.md +0 -361
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
"""Tests for _copilot_hook_lib."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
import _copilot_hook_lib as lib
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TestDetectActiveAgent:
|
|
12
|
+
def test_main_only_returns_none(self, fixtures_dir):
|
|
13
|
+
p = fixtures_dir / "transcript_main_only.jsonl"
|
|
14
|
+
assert lib.detect_active_agent(str(p)) is None
|
|
15
|
+
|
|
16
|
+
def test_copilot_literature(self, fixtures_dir):
|
|
17
|
+
p = fixtures_dir / "transcript_copilot_literature.jsonl"
|
|
18
|
+
assert lib.detect_active_agent(str(p)) == "copilot-literature"
|
|
19
|
+
|
|
20
|
+
def test_copilot_experiment(self, fixtures_dir):
|
|
21
|
+
p = fixtures_dir / "transcript_copilot_experiment_complete.jsonl"
|
|
22
|
+
assert lib.detect_active_agent(str(p)) == "copilot-experiment"
|
|
23
|
+
|
|
24
|
+
def test_missing_path_returns_none(self):
|
|
25
|
+
assert lib.detect_active_agent("") is None
|
|
26
|
+
assert lib.detect_active_agent("/nonexistent/path") is None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TestScopePredicates:
|
|
30
|
+
def test_is_copilot_agent_positive(self):
|
|
31
|
+
for n in ["copilot-literature", "copilot-ideation", "copilot-experiment",
|
|
32
|
+
"copilot-writer", "copilot-polisher", "copilot-reviewer",
|
|
33
|
+
"copilot-rebuttal"]:
|
|
34
|
+
assert lib.is_copilot_agent(n) is True
|
|
35
|
+
|
|
36
|
+
def test_research_copilot_is_not_a_subagent(self):
|
|
37
|
+
assert lib.is_copilot_agent("research-copilot") is False
|
|
38
|
+
|
|
39
|
+
def test_is_copilot_agent_negative(self):
|
|
40
|
+
for n in [None, "", "general-purpose", "Explore", "code-reviewer", "main"]:
|
|
41
|
+
assert lib.is_copilot_agent(n) is False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class TestPathNormalize:
|
|
45
|
+
def test_backslash_to_forward(self):
|
|
46
|
+
assert lib.normalize_path("D:\\article\\.copilot\\state.md") == \
|
|
47
|
+
"d:/article/.copilot/state.md"
|
|
48
|
+
|
|
49
|
+
def test_already_forward(self):
|
|
50
|
+
assert lib.normalize_path(".copilot/state.md") == ".copilot/state.md"
|
|
51
|
+
|
|
52
|
+
def test_relative_workspace(self, workspace):
|
|
53
|
+
f = workspace / ".copilot" / "state.md"
|
|
54
|
+
assert lib.normalize_path(str(f), workspace=workspace) == ".copilot/state.md"
|
|
55
|
+
|
|
56
|
+
def test_empty(self):
|
|
57
|
+
assert lib.normalize_path("") == ""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class TestGlobMatch:
|
|
61
|
+
def test_exact(self):
|
|
62
|
+
assert lib.glob_match(".copilot/state.md", ".copilot/state.md") is True
|
|
63
|
+
|
|
64
|
+
def test_star(self):
|
|
65
|
+
assert lib.glob_match(".copilot/reviews/round-3.md",
|
|
66
|
+
".copilot/reviews/round-*.md") is True
|
|
67
|
+
|
|
68
|
+
def test_no_match(self):
|
|
69
|
+
assert lib.glob_match(".copilot/ideas.md", ".copilot/state.md") is False
|
|
70
|
+
|
|
71
|
+
def test_s2_pipelines(self):
|
|
72
|
+
assert lib.glob_match(".copilot/pipelines/2026-05-24-S2-ideation-round-1.md",
|
|
73
|
+
".copilot/pipelines/*-s2-*.md") is True
|
|
74
|
+
assert lib.glob_match(".copilot/pipelines/2026-05-24-S3-experiment-1.md",
|
|
75
|
+
".copilot/pipelines/*-s2-*.md") is False
|
|
76
|
+
|
|
77
|
+
def test_star_does_not_cross_slash(self):
|
|
78
|
+
# Tightened semantics: `*` is single-segment only
|
|
79
|
+
assert lib.glob_match("sections/sub/foo.tex", "sections/*.tex") is False
|
|
80
|
+
assert lib.glob_match(".copilot/reviews/round-2/sub/x.md",
|
|
81
|
+
".copilot/reviews/round-*.md") is False
|
|
82
|
+
|
|
83
|
+
def test_star_single_segment_still_matches(self):
|
|
84
|
+
assert lib.glob_match("sections/intro.tex", "sections/*.tex") is True
|
|
85
|
+
assert lib.glob_match(".copilot/pipelines/2026-05-24-S2-ideation.md",
|
|
86
|
+
".copilot/pipelines/*-s2-*.md") is True
|
|
87
|
+
|
|
88
|
+
def test_empty_pattern(self):
|
|
89
|
+
assert lib.glob_match("", "") is True
|
|
90
|
+
assert lib.glob_match("anything", "") is False
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class TestOwnedMatrix:
|
|
94
|
+
def test_literature_owns_literature(self):
|
|
95
|
+
assert lib.is_owned("copilot-literature", ".copilot/literature.md") is True
|
|
96
|
+
|
|
97
|
+
def test_literature_does_not_own_ideas(self):
|
|
98
|
+
assert lib.is_owned("copilot-literature", ".copilot/ideas.md") is False
|
|
99
|
+
|
|
100
|
+
def test_ideation_owns_s2_pipelines(self):
|
|
101
|
+
assert lib.is_owned("copilot-ideation",
|
|
102
|
+
".copilot/pipelines/2026-05-24-s2-ideation.md") is True
|
|
103
|
+
|
|
104
|
+
def test_experiment_owns_s3_pipelines(self):
|
|
105
|
+
assert lib.is_owned("copilot-experiment",
|
|
106
|
+
".copilot/pipelines/2026-05-24-s3-exp.md") is True
|
|
107
|
+
|
|
108
|
+
def test_writer_owns_sections_tex(self):
|
|
109
|
+
assert lib.is_owned("copilot-writer", "sections/intro.tex") is True
|
|
110
|
+
|
|
111
|
+
def test_writer_owns_handoff(self):
|
|
112
|
+
assert lib.is_owned("copilot-writer", ".copilot/handoff.md") is True
|
|
113
|
+
|
|
114
|
+
def test_unknown_agent(self):
|
|
115
|
+
assert lib.is_owned("unknown", ".copilot/state.md") is False
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class TestIsKnownArtifact:
|
|
119
|
+
def test_dot_copilot(self):
|
|
120
|
+
assert lib.is_known_research_artifact(".copilot/state.md") is True
|
|
121
|
+
assert lib.is_known_research_artifact(".copilot/handoff.md") is True
|
|
122
|
+
|
|
123
|
+
def test_sections(self):
|
|
124
|
+
assert lib.is_known_research_artifact("sections/intro.tex") is True
|
|
125
|
+
|
|
126
|
+
def test_references_bib(self):
|
|
127
|
+
assert lib.is_known_research_artifact("references.bib") is True
|
|
128
|
+
|
|
129
|
+
def test_unrelated_scratch(self):
|
|
130
|
+
assert lib.is_known_research_artifact("scratch/note.txt") is False
|
|
131
|
+
assert lib.is_known_research_artifact("README.md") is False
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class TestStateMachine:
|
|
135
|
+
def test_literature_machine_present(self):
|
|
136
|
+
sm = lib.STATE_MACHINE["copilot-literature"]
|
|
137
|
+
assert sm["UNINITIALIZED"] == ["SCANNING"]
|
|
138
|
+
assert "END" in sm["BASELINE_LOCKED"]
|
|
139
|
+
|
|
140
|
+
def test_experiment_machine_present(self):
|
|
141
|
+
sm = lib.STATE_MACHINE["copilot-experiment"]
|
|
142
|
+
assert sm["UNINITIALIZED"] == ["CONTEXT_LOADED"]
|
|
143
|
+
assert "END" in sm["JUDGED"]
|
|
144
|
+
|
|
145
|
+
def test_all_subagents_have_machines(self):
|
|
146
|
+
for agent in lib.COPILOT_AGENTS:
|
|
147
|
+
assert agent in lib.STATE_MACHINE, f"missing state machine for {agent}"
|
|
148
|
+
|
|
149
|
+
def test_transition_legal(self):
|
|
150
|
+
assert lib.is_transition_legal(
|
|
151
|
+
"copilot-literature", "UNINITIALIZED", "SCANNING") is True
|
|
152
|
+
|
|
153
|
+
def test_transition_illegal(self):
|
|
154
|
+
assert lib.is_transition_legal(
|
|
155
|
+
"copilot-experiment", "UNINITIALIZED", "END") is False
|
|
156
|
+
|
|
157
|
+
def test_transition_unknown_agent_allowed(self):
|
|
158
|
+
assert lib.is_transition_legal("unknown", "X", "Y") is True
|
|
159
|
+
|
|
160
|
+
def test_transition_unknown_state_allowed(self):
|
|
161
|
+
assert lib.is_transition_legal(
|
|
162
|
+
"copilot-literature", "NEW_STATE", "END") is True
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class TestExtractHandoff:
|
|
166
|
+
def test_full_block(self):
|
|
167
|
+
text = """# Some artifact
|
|
168
|
+
|
|
169
|
+
Body.
|
|
170
|
+
|
|
171
|
+
## __HANDOFF__
|
|
172
|
+
- last_updated: 2026-05-24T10:30:00Z
|
|
173
|
+
- written_by: copilot-literature
|
|
174
|
+
- key_facts:
|
|
175
|
+
- locked baseline: 1706.03762
|
|
176
|
+
- 3 nearest prior works
|
|
177
|
+
- next_owner: copilot-ideation
|
|
178
|
+
"""
|
|
179
|
+
h = lib.extract_handoff(text)
|
|
180
|
+
assert h is not None
|
|
181
|
+
assert h["last_updated"] == "2026-05-24T10:30:00Z"
|
|
182
|
+
assert h["written_by"] == "copilot-literature"
|
|
183
|
+
assert h["next_owner"] == "copilot-ideation"
|
|
184
|
+
assert "locked baseline: 1706.03762" in h["key_facts"]
|
|
185
|
+
|
|
186
|
+
def test_no_block(self):
|
|
187
|
+
assert lib.extract_handoff("# header only\nno handoff block") is None
|
|
188
|
+
|
|
189
|
+
def test_empty(self):
|
|
190
|
+
assert lib.extract_handoff("") is None
|
|
191
|
+
|
|
192
|
+
def test_block_without_last_updated(self):
|
|
193
|
+
h = lib.extract_handoff("## __HANDOFF__\n- written_by: x\n")
|
|
194
|
+
assert h is not None
|
|
195
|
+
assert h.get("last_updated") is None
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class TestExtractStateOutput:
|
|
199
|
+
def test_full_block(self):
|
|
200
|
+
text = """prose
|
|
201
|
+
[STATE_OUTPUT]
|
|
202
|
+
Previous: SCANNING
|
|
203
|
+
Current: BASELINE_LOCKED
|
|
204
|
+
Action completed: Locked baseline
|
|
205
|
+
Capability gate: passed
|
|
206
|
+
Evidence: literature.md:42
|
|
207
|
+
Next allowed: [RELATED_WORK_AUGMENTED, END]
|
|
208
|
+
Transition reason: 2 MCP queries
|
|
209
|
+
[/STATE_OUTPUT]
|
|
210
|
+
trailing"""
|
|
211
|
+
so = lib.extract_state_output(text)
|
|
212
|
+
assert so["Previous"] == "SCANNING"
|
|
213
|
+
assert so["Current"] == "BASELINE_LOCKED"
|
|
214
|
+
assert so["Capability gate"] == "passed"
|
|
215
|
+
|
|
216
|
+
def test_missing_block(self):
|
|
217
|
+
assert lib.extract_state_output("no block") is None
|
|
218
|
+
|
|
219
|
+
def test_missing_fields(self):
|
|
220
|
+
text = "[STATE_OUTPUT]\nPrevious: X\nCurrent: Y\n[/STATE_OUTPUT]"
|
|
221
|
+
so = lib.extract_state_output(text)
|
|
222
|
+
assert so["Previous"] == "X"
|
|
223
|
+
assert "Capability gate" not in so
|
|
224
|
+
|
|
225
|
+
def test_required_fields_missing_returns_list(self):
|
|
226
|
+
so = lib.extract_state_output("[STATE_OUTPUT]\nPrevious: X\nCurrent: Y\n[/STATE_OUTPUT]")
|
|
227
|
+
missing = lib.state_output_missing_fields(so)
|
|
228
|
+
assert set(missing) == {"Action completed", "Capability gate", "Evidence", "Next allowed"}
|
|
229
|
+
|
|
230
|
+
def test_required_fields_complete(self):
|
|
231
|
+
so = {
|
|
232
|
+
"Previous": "X", "Current": "Y", "Action completed": "did stuff",
|
|
233
|
+
"Capability gate": "passed", "Evidence": "x:1", "Next allowed": "[A]",
|
|
234
|
+
}
|
|
235
|
+
assert lib.state_output_missing_fields(so) == []
|
|
236
|
+
|
|
237
|
+
def test_state_output_none_returns_all_missing(self):
|
|
238
|
+
missing = lib.state_output_missing_fields(None)
|
|
239
|
+
assert len(missing) >= 6
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
import json as _json_in_tests
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class TestSnapshotIO:
|
|
246
|
+
def test_read_missing(self, workspace):
|
|
247
|
+
assert lib.read_snapshot(workspace) == {}
|
|
248
|
+
|
|
249
|
+
def test_write_and_read(self, workspace):
|
|
250
|
+
lib.write_snapshot(workspace, {"state.md": "2026-05-24T10:00:00Z"})
|
|
251
|
+
assert lib.read_snapshot(workspace) == {"state.md": "2026-05-24T10:00:00Z"}
|
|
252
|
+
|
|
253
|
+
def test_corrupt_returns_empty(self, workspace):
|
|
254
|
+
(workspace / ".copilot" / ".session_snapshot.json").write_text("not json")
|
|
255
|
+
assert lib.read_snapshot(workspace) == {}
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
class TestCounterIO:
|
|
259
|
+
def test_read_missing(self, workspace):
|
|
260
|
+
assert lib.counter_read(workspace) == {}
|
|
261
|
+
|
|
262
|
+
def test_inc_from_zero(self, workspace):
|
|
263
|
+
assert lib.counter_inc(workspace, "copilot-literature", "literature.md") == 1
|
|
264
|
+
assert lib.counter_inc(workspace, "copilot-literature", "literature.md") == 2
|
|
265
|
+
|
|
266
|
+
def test_reset_bucket(self, workspace):
|
|
267
|
+
lib.counter_inc(workspace, "copilot-literature", "literature.md")
|
|
268
|
+
lib.counter_reset(workspace, "copilot-literature", "literature.md")
|
|
269
|
+
assert lib.counter_get(workspace, "copilot-literature", "literature.md") == 0
|
|
270
|
+
|
|
271
|
+
def test_reset_all_for_agent(self, workspace):
|
|
272
|
+
lib.counter_inc(workspace, "copilot-experiment", "experiments.md")
|
|
273
|
+
lib.counter_inc(workspace, "copilot-experiment", "other.md")
|
|
274
|
+
lib.counter_reset_all(workspace, "copilot-experiment")
|
|
275
|
+
for v in lib.counter_read(workspace).get("copilot-experiment", {}).values():
|
|
276
|
+
assert v["count"] == 0
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
class TestViolationsLog:
|
|
280
|
+
def test_log_appends(self, workspace):
|
|
281
|
+
lib.log_violation(workspace, "HARD", "DENY", "copilot-literature",
|
|
282
|
+
"test detail", file=".copilot/ideas.md")
|
|
283
|
+
log = (workspace / ".copilot" / "__violations.log").read_text(encoding="utf-8")
|
|
284
|
+
line = log.strip().splitlines()[-1]
|
|
285
|
+
rec = _json_in_tests.loads(line)
|
|
286
|
+
assert rec["sev"] == "HARD"
|
|
287
|
+
assert rec["kind"] == "DENY"
|
|
288
|
+
assert rec["agent"] == "copilot-literature"
|
|
289
|
+
|
|
290
|
+
def test_log_multiple(self, workspace):
|
|
291
|
+
lib.log_violation(workspace, "SOFT", "WARN", "copilot-experiment", "warn 1")
|
|
292
|
+
lib.log_violation(workspace, "SOFT", "WARN", "copilot-experiment", "warn 2")
|
|
293
|
+
lines = (workspace / ".copilot" / "__violations.log").read_text(encoding="utf-8").strip().splitlines()
|
|
294
|
+
assert len(lines) == 2
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
class TestOverride:
|
|
298
|
+
def test_env_var_off(self, monkeypatch):
|
|
299
|
+
monkeypatch.setenv("COPILOT_HOOK_GUARD", "off")
|
|
300
|
+
assert lib.env_guard_disabled() is True
|
|
301
|
+
|
|
302
|
+
def test_env_var_unset(self, monkeypatch):
|
|
303
|
+
monkeypatch.delenv("COPILOT_HOOK_GUARD", raising=False)
|
|
304
|
+
assert lib.env_guard_disabled() is False
|
|
305
|
+
|
|
306
|
+
def test_env_var_other_value(self, monkeypatch):
|
|
307
|
+
monkeypatch.setenv("COPILOT_HOOK_GUARD", "on")
|
|
308
|
+
assert lib.env_guard_disabled() is False
|
|
309
|
+
|
|
310
|
+
def test_override_file_missing(self, workspace):
|
|
311
|
+
assert lib.override_match(workspace, "copilot-literature", "skip-handoff-check") is False
|
|
312
|
+
|
|
313
|
+
def test_override_file_match(self, workspace):
|
|
314
|
+
(workspace / ".copilot" / ".guard_override").write_text(
|
|
315
|
+
"copilot-literature: skip-handoff-check until 2099-01-01T00:00:00Z\n",
|
|
316
|
+
encoding="utf-8")
|
|
317
|
+
assert lib.override_match(workspace, "copilot-literature", "skip-handoff-check") is True
|
|
318
|
+
|
|
319
|
+
def test_override_file_expired(self, workspace):
|
|
320
|
+
(workspace / ".copilot" / ".guard_override").write_text(
|
|
321
|
+
"copilot-literature: skip-handoff-check until 2000-01-01T00:00:00Z\n",
|
|
322
|
+
encoding="utf-8")
|
|
323
|
+
assert lib.override_match(workspace, "copilot-literature", "skip-handoff-check") is False
|
|
324
|
+
|
|
325
|
+
def test_override_skip_all(self, workspace):
|
|
326
|
+
(workspace / ".copilot" / ".guard_override").write_text(
|
|
327
|
+
"copilot-experiment: skip-all until 2099-01-01T00:00:00Z\n",
|
|
328
|
+
encoding="utf-8")
|
|
329
|
+
assert lib.override_match(workspace, "copilot-experiment", "skip-handoff-check") is True
|
|
330
|
+
assert lib.override_match(workspace, "copilot-experiment", "skip-owned-check") is True
|
|
331
|
+
|
|
332
|
+
def test_override_comment(self, workspace):
|
|
333
|
+
(workspace / ".copilot" / ".guard_override").write_text(
|
|
334
|
+
"# comment\ncopilot-literature: skip-handoff-check until 2099-01-01T00:00:00Z\n",
|
|
335
|
+
encoding="utf-8")
|
|
336
|
+
assert lib.override_match(workspace, "copilot-literature", "skip-handoff-check") is True
|
|
337
|
+
|
|
338
|
+
def test_override_wrong_agent(self, workspace):
|
|
339
|
+
(workspace / ".copilot" / ".guard_override").write_text(
|
|
340
|
+
"copilot-literature: skip-handoff-check until 2099-01-01T00:00:00Z\n",
|
|
341
|
+
encoding="utf-8")
|
|
342
|
+
assert lib.override_match(workspace, "copilot-experiment", "skip-handoff-check") is False
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
class TestDecisions:
|
|
346
|
+
def test_allow(self):
|
|
347
|
+
d = lib.allow_decision()
|
|
348
|
+
assert d["hookSpecificOutput"]["permissionDecision"] == "allow"
|
|
349
|
+
|
|
350
|
+
def test_deny(self):
|
|
351
|
+
d = lib.deny_decision("nope")
|
|
352
|
+
assert d["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
353
|
+
assert "nope" in d["hookSpecificOutput"]["permissionDecisionReason"]
|
|
354
|
+
|
|
355
|
+
def test_block(self):
|
|
356
|
+
d = lib.block_decision("come back and write handoff")
|
|
357
|
+
assert d["decision"] == "block"
|
|
358
|
+
assert "come back" in d["reason"]
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
class TestSafeMain:
|
|
362
|
+
def test_clean_exit(self, capsys):
|
|
363
|
+
def real():
|
|
364
|
+
print(_json_in_tests.dumps(lib.allow_decision()))
|
|
365
|
+
return 0
|
|
366
|
+
rc = lib.safe_main(real)
|
|
367
|
+
assert rc == 0
|
|
368
|
+
assert "allow" in capsys.readouterr().out
|
|
369
|
+
|
|
370
|
+
def test_exception_falls_open(self, capsys):
|
|
371
|
+
def real():
|
|
372
|
+
raise RuntimeError("boom")
|
|
373
|
+
rc = lib.safe_main(real)
|
|
374
|
+
assert rc == 0
|
|
375
|
+
assert "allow" in capsys.readouterr().out
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
class TestOriginAttribution:
|
|
379
|
+
def test_no_agent_id_is_main(self):
|
|
380
|
+
assert lib.is_main_session({"tool_name": "Bash", "tool_input": {}}) is True
|
|
381
|
+
|
|
382
|
+
def test_empty_agent_id_is_main(self):
|
|
383
|
+
assert lib.is_main_session({"agent_id": "", "agent_type": ""}) is True
|
|
384
|
+
|
|
385
|
+
def test_present_agent_id_is_subagent(self):
|
|
386
|
+
p = {"agent_id": "sa_01", "agent_type": "copilot-experiment"}
|
|
387
|
+
assert lib.is_main_session(p) is False
|
|
388
|
+
|
|
389
|
+
def test_exempt_copilot_subagent(self):
|
|
390
|
+
p = {"agent_id": "sa_01", "agent_type": "copilot-experiment"}
|
|
391
|
+
assert lib.is_exempt_subagent(p) is True
|
|
392
|
+
|
|
393
|
+
def test_non_copilot_subagent_not_exempt(self):
|
|
394
|
+
p = {"agent_id": "sa_02", "agent_type": "Explore"}
|
|
395
|
+
assert lib.is_exempt_subagent(p) is False
|
|
396
|
+
|
|
397
|
+
def test_main_session_not_exempt(self):
|
|
398
|
+
assert lib.is_exempt_subagent({"tool_name": "Bash"}) is False
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Tests for copilot_subagent_stop.py (SubagentStop)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from io import StringIO
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
import copilot_subagent_stop as guard
|
|
11
|
+
import _copilot_hook_lib as lib
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _run(monkeypatch, payload: dict, workspace: Path) -> dict:
|
|
15
|
+
monkeypatch.setattr("sys.stdin", StringIO(json.dumps(payload)))
|
|
16
|
+
monkeypatch.chdir(workspace)
|
|
17
|
+
out = StringIO()
|
|
18
|
+
monkeypatch.setattr("sys.stdout", out)
|
|
19
|
+
guard.real_main()
|
|
20
|
+
return json.loads(out.getvalue().strip().splitlines()[-1])
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _stop_payload(transcript_path: str, stop_hook_active: bool = False) -> dict:
|
|
24
|
+
return {"transcript_path": transcript_path, "stop_hook_active": stop_hook_active}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TestScope:
|
|
28
|
+
def test_main_agent_allowed(self, monkeypatch, workspace, fixtures_dir):
|
|
29
|
+
d = _run(monkeypatch, _stop_payload(str(fixtures_dir / "transcript_main_only.jsonl")), workspace)
|
|
30
|
+
assert "decision" not in d
|
|
31
|
+
assert d["hookSpecificOutput"]["permissionDecision"] == "allow"
|
|
32
|
+
|
|
33
|
+
def test_non_copilot_agent_allowed(self, monkeypatch, workspace, tmp_path):
|
|
34
|
+
t = tmp_path / "trans.jsonl"
|
|
35
|
+
t.write_text('{"role":"assistant","metadata":{"subagent_type":"general-purpose"}}\n')
|
|
36
|
+
d = _run(monkeypatch, _stop_payload(str(t)), workspace)
|
|
37
|
+
assert d["hookSpecificOutput"]["permissionDecision"] == "allow"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TestOverride:
|
|
41
|
+
def test_env_off_allows(self, monkeypatch, workspace, fixtures_dir):
|
|
42
|
+
monkeypatch.setenv("COPILOT_HOOK_GUARD", "off")
|
|
43
|
+
d = _run(monkeypatch,
|
|
44
|
+
_stop_payload(str(fixtures_dir / "transcript_copilot_literature.jsonl")),
|
|
45
|
+
workspace)
|
|
46
|
+
assert d["hookSpecificOutput"]["permissionDecision"] == "allow"
|
|
47
|
+
|
|
48
|
+
def test_skip_handoff_check_allows(self, monkeypatch, workspace, fixtures_dir):
|
|
49
|
+
(workspace / ".copilot" / ".guard_override").write_text(
|
|
50
|
+
"copilot-literature: skip-handoff-check until 2099-01-01T00:00:00Z\n",
|
|
51
|
+
encoding="utf-8")
|
|
52
|
+
d = _run(monkeypatch,
|
|
53
|
+
_stop_payload(str(fixtures_dir / "transcript_copilot_literature.jsonl")),
|
|
54
|
+
workspace)
|
|
55
|
+
assert d["hookSpecificOutput"]["permissionDecision"] == "allow"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class TestHandoffFreshness:
|
|
59
|
+
def test_missing_file_blocks(self, monkeypatch, workspace, fixtures_dir):
|
|
60
|
+
# Pre-populate snapshot so we trigger HARD_FAIL (not SOFT_FAIL first-boot path)
|
|
61
|
+
lib.write_snapshot(workspace, {"literature.md": None})
|
|
62
|
+
d = _run(monkeypatch,
|
|
63
|
+
_stop_payload(str(fixtures_dir / "transcript_copilot_literature.jsonl")),
|
|
64
|
+
workspace)
|
|
65
|
+
assert d.get("decision") == "block"
|
|
66
|
+
assert "literature.md" in d["reason"] or "HANDOFF" in d["reason"]
|
|
67
|
+
|
|
68
|
+
def test_stale_handoff_blocks(self, monkeypatch, workspace, fixtures_dir, handoff_writer):
|
|
69
|
+
f = workspace / ".copilot" / "literature.md"
|
|
70
|
+
old = "2026-05-23T00:00:00Z"
|
|
71
|
+
handoff_writer(f, last_updated=old)
|
|
72
|
+
lib.write_snapshot(workspace, {"literature.md": old})
|
|
73
|
+
d = _run(monkeypatch,
|
|
74
|
+
_stop_payload(str(fixtures_dir / "transcript_copilot_literature.jsonl")),
|
|
75
|
+
workspace)
|
|
76
|
+
assert d.get("decision") == "block"
|
|
77
|
+
|
|
78
|
+
def test_fresh_handoff_allows(self, monkeypatch, workspace, fixtures_dir, handoff_writer):
|
|
79
|
+
f = workspace / ".copilot" / "literature.md"
|
|
80
|
+
handoff_writer(f, last_updated="2026-05-24T10:00:00Z")
|
|
81
|
+
lib.write_snapshot(workspace, {"literature.md": "2026-05-23T00:00:00Z"})
|
|
82
|
+
d = _run(monkeypatch,
|
|
83
|
+
_stop_payload(str(fixtures_dir / "transcript_copilot_literature.jsonl")),
|
|
84
|
+
workspace)
|
|
85
|
+
assert d["hookSpecificOutput"]["permissionDecision"] == "allow"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class TestFuse:
|
|
89
|
+
def test_strikes_1_and_2_block(self, monkeypatch, workspace, fixtures_dir):
|
|
90
|
+
lib.write_snapshot(workspace, {"literature.md": None})
|
|
91
|
+
for _ in range(2):
|
|
92
|
+
d = _run(monkeypatch,
|
|
93
|
+
_stop_payload(str(fixtures_dir / "transcript_copilot_literature.jsonl")),
|
|
94
|
+
workspace)
|
|
95
|
+
assert d.get("decision") == "block"
|
|
96
|
+
assert lib.counter_get(workspace, "copilot-literature", "literature.md") == 2
|
|
97
|
+
|
|
98
|
+
def test_strike_3_releases(self, monkeypatch, workspace, fixtures_dir):
|
|
99
|
+
lib.write_snapshot(workspace, {"literature.md": None})
|
|
100
|
+
for _ in range(2):
|
|
101
|
+
_run(monkeypatch,
|
|
102
|
+
_stop_payload(str(fixtures_dir / "transcript_copilot_literature.jsonl")),
|
|
103
|
+
workspace)
|
|
104
|
+
d = _run(monkeypatch,
|
|
105
|
+
_stop_payload(str(fixtures_dir / "transcript_copilot_literature.jsonl")),
|
|
106
|
+
workspace)
|
|
107
|
+
assert d["hookSpecificOutput"]["permissionDecision"] == "allow"
|
|
108
|
+
assert lib.counter_get(workspace, "copilot-literature", "literature.md") == 0
|
|
109
|
+
log = (workspace / ".copilot" / "__violations.log").read_text(encoding="utf-8")
|
|
110
|
+
assert "RELEASE" in log
|
|
111
|
+
|
|
112
|
+
def test_pass_resets_counter(self, monkeypatch, workspace, fixtures_dir, handoff_writer):
|
|
113
|
+
lib.write_snapshot(workspace, {"literature.md": None})
|
|
114
|
+
_run(monkeypatch,
|
|
115
|
+
_stop_payload(str(fixtures_dir / "transcript_copilot_literature.jsonl")),
|
|
116
|
+
workspace)
|
|
117
|
+
assert lib.counter_get(workspace, "copilot-literature", "literature.md") == 1
|
|
118
|
+
f = workspace / ".copilot" / "literature.md"
|
|
119
|
+
handoff_writer(f, last_updated="2026-05-24T10:00:00Z")
|
|
120
|
+
lib.write_snapshot(workspace, {"literature.md": "2026-05-23T00:00:00Z"})
|
|
121
|
+
d = _run(monkeypatch,
|
|
122
|
+
_stop_payload(str(fixtures_dir / "transcript_copilot_literature.jsonl")),
|
|
123
|
+
workspace)
|
|
124
|
+
assert d["hookSpecificOutput"]["permissionDecision"] == "allow"
|
|
125
|
+
assert lib.counter_get(workspace, "copilot-literature", "literature.md") == 0
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class TestFirstBoot:
|
|
129
|
+
def test_no_snapshot_fresh_handoff_passes(self, monkeypatch, workspace,
|
|
130
|
+
fixtures_dir, handoff_writer):
|
|
131
|
+
s = workspace / ".copilot" / ".session_snapshot.json"
|
|
132
|
+
if s.exists():
|
|
133
|
+
s.unlink()
|
|
134
|
+
f = workspace / ".copilot" / "literature.md"
|
|
135
|
+
handoff_writer(f, last_updated="2026-05-24T10:00:00Z")
|
|
136
|
+
d = _run(monkeypatch,
|
|
137
|
+
_stop_payload(str(fixtures_dir / "transcript_copilot_literature.jsonl")),
|
|
138
|
+
workspace)
|
|
139
|
+
assert d["hookSpecificOutput"]["permissionDecision"] == "allow"
|
|
140
|
+
|
|
141
|
+
def test_no_snapshot_no_handoff_soft_not_hard(self, monkeypatch, workspace, fixtures_dir):
|
|
142
|
+
d = _run(monkeypatch,
|
|
143
|
+
_stop_payload(str(fixtures_dir / "transcript_copilot_literature.jsonl")),
|
|
144
|
+
workspace)
|
|
145
|
+
assert d["hookSpecificOutput"]["permissionDecision"] == "allow"
|
|
146
|
+
log = (workspace / ".copilot" / "__violations.log").read_text(encoding="utf-8")
|
|
147
|
+
assert "NO-SNAPSHOT" in log
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class TestSoftWarns:
|
|
151
|
+
def _setup_pass(self, workspace, handoff_writer, fname):
|
|
152
|
+
handoff_writer(workspace / ".copilot" / fname, last_updated="2026-05-24T10:00:00Z")
|
|
153
|
+
lib.write_snapshot(workspace, {fname: "2026-05-23T00:00:00Z"})
|
|
154
|
+
|
|
155
|
+
def test_malformed_state_output_warned(self, monkeypatch, workspace,
|
|
156
|
+
fixtures_dir, handoff_writer):
|
|
157
|
+
self._setup_pass(workspace, handoff_writer, "literature.md")
|
|
158
|
+
d = _run(monkeypatch,
|
|
159
|
+
_stop_payload(str(fixtures_dir / "transcript_malformed_state_output.jsonl")),
|
|
160
|
+
workspace)
|
|
161
|
+
assert d["hookSpecificOutput"]["permissionDecision"] == "allow"
|
|
162
|
+
log = (workspace / ".copilot" / "__violations.log").read_text(encoding="utf-8")
|
|
163
|
+
assert "SOFT" in log and "WARN" in log
|
|
164
|
+
assert "STATE_OUTPUT" in log
|
|
165
|
+
|
|
166
|
+
def test_illegal_state_jump_warned(self, monkeypatch, workspace,
|
|
167
|
+
fixtures_dir, handoff_writer):
|
|
168
|
+
self._setup_pass(workspace, handoff_writer, "experiments.md")
|
|
169
|
+
d = _run(monkeypatch,
|
|
170
|
+
_stop_payload(str(fixtures_dir / "transcript_copilot_experiment_state_jump.jsonl")),
|
|
171
|
+
workspace)
|
|
172
|
+
assert d["hookSpecificOutput"]["permissionDecision"] == "allow"
|
|
173
|
+
log = (workspace / ".copilot" / "__violations.log").read_text(encoding="utf-8")
|
|
174
|
+
assert "SOFT" in log
|
|
175
|
+
assert "UNINITIALIZED" in log or "transition" in log.lower()
|
|
176
|
+
|
|
177
|
+
def test_clean_run_no_soft_warns(self, monkeypatch, workspace,
|
|
178
|
+
fixtures_dir, handoff_writer):
|
|
179
|
+
self._setup_pass(workspace, handoff_writer, "experiments.md")
|
|
180
|
+
d = _run(monkeypatch,
|
|
181
|
+
_stop_payload(str(fixtures_dir / "transcript_copilot_experiment_complete.jsonl")),
|
|
182
|
+
workspace)
|
|
183
|
+
assert d["hookSpecificOutput"]["permissionDecision"] == "allow"
|
|
184
|
+
log_path = workspace / ".copilot" / "__violations.log"
|
|
185
|
+
if log_path.exists():
|
|
186
|
+
assert "SOFT" not in log_path.read_text(encoding="utf-8")
|