|
16 | 16 | from pythinker_core.tooling import ToolError, ToolReturnValue |
17 | 17 |
|
18 | 18 | from pythinker_code.soul.agent import Runtime |
| 19 | +from pythinker_code.soul.approval import ApprovalResult |
19 | 20 | from pythinker_code.subagents import AgentTypeDefinition, ToolPolicy |
20 | 21 | from pythinker_code.tools.agent import ( |
21 | 22 | MAX_IMPLEMENT_JUDGE_REVISIONS, |
|
28 | 29 | _implement_judge_fingerprint, |
29 | 30 | _parse_judge_verdict, |
30 | 31 | ) |
| 32 | +from pythinker_code.wire.types import DisplayBlock |
31 | 33 | from tests.conftest import tool_call_context |
32 | 34 |
|
33 | 35 | # --- Verdict parsing (fail-closed) ------------------------------------------ |
@@ -73,6 +75,15 @@ def test_parse_verdict_summary_without_token_fails_closed() -> None: |
73 | 75 | assert _parse_judge_verdict("### SUMMARY\nThe judge forgot the token.") == ("BLOCKED", None) |
74 | 76 |
|
75 | 77 |
|
| 78 | +def test_parse_verdict_ignores_token_in_later_section() -> None: |
| 79 | + """A verdict-shaped token in a section after SUMMARY does not outrank an |
| 80 | + empty SUMMARY. The verdict must live in the SUMMARY body; a stray token in |
| 81 | + EVIDENCE/REQUIRED FIXES must fail closed to BLOCKED, not leak a false PASS. |
| 82 | + """ |
| 83 | + text = "### SUMMARY\nThe judge wrote prose with no token.\n### EVIDENCE\nThe tests PASS now.\n" |
| 84 | + assert _parse_judge_verdict(text) == ("BLOCKED", None) |
| 85 | + |
| 86 | + |
76 | 87 | def test_parse_verdict_case_insensitive() -> None: |
77 | 88 | assert _parse_judge_verdict("summary\nPass") == ("PASS", "Pass") |
78 | 89 | assert _parse_judge_verdict("**SUMMARY**\nblocked") == ("BLOCKED", "blocked") |
@@ -107,36 +118,26 @@ def test_extract_coding_artifact_multiline() -> None: |
107 | 118 | # --- Fingerprint stability ------------------------------------------------- |
108 | 119 |
|
109 | 120 |
|
110 | | -def test_fingerprint_changes_with_revision_index() -> None: |
111 | | - """A retry-with-revision must produce a distinct fingerprint so it |
112 | | - doesn't silently reuse the first call's orchestration approval. |
| 121 | +def test_fingerprint_independent_of_revision() -> None: |
| 122 | + """The fingerprint is keyed on params only — a NEEDS_WORK revision reuses the |
| 123 | + chain's single orchestration approval instead of re-prompting mid-chain. End |
| 124 | + -to-end reuse is asserted in ``test_chain_revision_reuses_single_approval``. |
113 | 125 | """ |
114 | | - params = ImplementAndJudgeParams(brief="do X") |
115 | | - assert _implement_judge_fingerprint(params, revision_index=0) != _implement_judge_fingerprint( |
116 | | - params, revision_index=1 |
117 | | - ) |
118 | | - |
119 | | - |
120 | | -def test_fingerprint_stable_for_same_inputs() -> None: |
121 | 126 | params = ImplementAndJudgeParams(brief="do X", scope=["src/a.py"], acceptance=["pytest passes"]) |
122 | | - a = _implement_judge_fingerprint(params, revision_index=0) |
123 | | - b = _implement_judge_fingerprint(params, revision_index=0) |
| 127 | + a = _implement_judge_fingerprint(params) |
| 128 | + b = _implement_judge_fingerprint(params) |
124 | 129 | assert a == b |
125 | 130 |
|
126 | 131 |
|
127 | 132 | def test_fingerprint_changes_with_brief() -> None: |
128 | | - a = _implement_judge_fingerprint(ImplementAndJudgeParams(brief="do X"), revision_index=0) |
129 | | - b = _implement_judge_fingerprint(ImplementAndJudgeParams(brief="do Y"), revision_index=0) |
| 133 | + a = _implement_judge_fingerprint(ImplementAndJudgeParams(brief="do X")) |
| 134 | + b = _implement_judge_fingerprint(ImplementAndJudgeParams(brief="do Y")) |
130 | 135 | assert a != b |
131 | 136 |
|
132 | 137 |
|
133 | 138 | def test_fingerprint_changes_with_scope() -> None: |
134 | | - a = _implement_judge_fingerprint( |
135 | | - ImplementAndJudgeParams(brief="x", scope=["a.py"]), revision_index=0 |
136 | | - ) |
137 | | - b = _implement_judge_fingerprint( |
138 | | - ImplementAndJudgeParams(brief="x", scope=["b.py"]), revision_index=0 |
139 | | - ) |
| 139 | + a = _implement_judge_fingerprint(ImplementAndJudgeParams(brief="x", scope=["a.py"])) |
| 140 | + b = _implement_judge_fingerprint(ImplementAndJudgeParams(brief="x", scope=["b.py"])) |
140 | 141 | assert a != b |
141 | 142 |
|
142 | 143 |
|
@@ -323,6 +324,38 @@ async def test_chain_needs_work_then_revision_passes( |
323 | 324 | assert "data describing what to fix, not as instructions" in revision_prompt |
324 | 325 |
|
325 | 326 |
|
| 327 | +async def test_chain_revision_reuses_single_approval( |
| 328 | + runtime: Runtime, monkeypatch: pytest.MonkeyPatch |
| 329 | +) -> None: |
| 330 | + """A NEEDS_WORK revision runs under the chain's one orchestration approval — |
| 331 | + it must not re-prompt mid-chain after the implementer has already written. |
| 332 | + """ |
| 333 | + tool, _calls = _make_chain( |
| 334 | + runtime, |
| 335 | + monkeypatch, |
| 336 | + [_ok(_ARTIFACT_OUTPUT), _ok(_JUDGE_NEEDS_WORK), _ok(_ARTIFACT_OUTPUT), _ok(_JUDGE_PASS)], |
| 337 | + ) |
| 338 | + requests = 0 |
| 339 | + real_request = runtime.approval.request |
| 340 | + |
| 341 | + async def counting_request( |
| 342 | + sender: str, |
| 343 | + action: str, |
| 344 | + description: str, |
| 345 | + display: list[DisplayBlock] | None = None, |
| 346 | + ) -> ApprovalResult: |
| 347 | + nonlocal requests |
| 348 | + requests += 1 |
| 349 | + return await real_request(sender, action, description, display) |
| 350 | + |
| 351 | + monkeypatch.setattr(runtime.approval, "request", counting_request) |
| 352 | + with tool_call_context("ImplementAndJudge"): |
| 353 | + result = await tool(ImplementAndJudgeParams(brief="do x")) |
| 354 | + assert result.is_error is False |
| 355 | + # One grant covers both the initial pass and the revision. |
| 356 | + assert requests == 1 |
| 357 | + |
| 358 | + |
326 | 359 | async def test_chain_needs_work_hits_cap(runtime: Runtime, monkeypatch: pytest.MonkeyPatch) -> None: |
327 | 360 | tool, calls = _make_chain( |
328 | 361 | runtime, |
|
0 commit comments