Skip to content

Commit 4af2ea2

Browse files
Trecekclaude
andauthored
Implementation Plan: T5-P3-A5-WP1 B6 Cross-Layer Predicate Spanning Test (#4084)
## Summary Create `tests/execution/backends/test_failure_predicate_spanning.py` with three test classes that validate the FAILURE PREDICATES blocks across both prompt builders (`_build_orchestrator_prompt` and `_build_food_truck_prompt`) against the server tool TypedDicts in `server/tools/_types.py`. No production source files are modified. Test infrastructure registries (`.autoskillit/test-source-map.json` and `tests/_test_filter.py` cascade entries) are updated to route source changes to the new test. ## Implementation Plan Plan file: `/home/talon/projects/autoskillit-runs/impl-20260611-205810-907005/.autoskillit/temp/make-plan/t5_p3_a5_wp1_failure_predicate_spanning_plan_2026-06-11_210500.md` Closes #4013 🤖 Generated with [Claude Code](https://claude.com/claude-code) via AutoSkillit <!-- autoskillit:pipeline-signature steps=prepare_pr,run_arch_lenses,compose_pr,annotate_pr_diff,review_pr --> ## Token Usage Summary | Step | Model | count | uncached | output | cache_read | peak_ctx | turns | cache_write | time | |------|-------|-------|----------|--------|------------|----------|-------|-------------|------| | plan* | opus[1m] | 1 | 1.7k | 15.0k | 2.0M | 94.9k | 63 | 73.7k | 12m 10s | | verify* | sonnet | 1 | 68 | 17.2k | 357.0k | 64.7k | 21 | 43.8k | 9m 57s | | implement* | MiniMax-M3 | 1 | 1.1M | 17.7k | 0 | 0 | 52 | 0 | 6m 30s | | fix* | sonnet | 1 | 174 | 6.3k | 987.8k | 56.7k | 53 | 35.9k | 4m 1s | | audit_impl* | sonnet | 1 | 36 | 1.8k | 131.4k | 43.8k | 11 | 24.9k | 8m 4s | | prepare_pr* | MiniMax-M3 | 1 | 198.1k | 1.7k | 0 | 0 | 12 | 0 | 52s | | compose_pr* | MiniMax-M3 | 1 | 178.6k | 2.1k | 0 | 0 | 13 | 0 | 53s | | **Total** | | | 1.5M | 61.8k | 3.5M | 94.9k | | 178.3k | 42m 30s | \* *Step used a non-Anthropic provider; caching behavior may differ.* ## Token Efficiency | Step | LoC Changed | cache_read/LoC | cache_write/LoC | output/LoC | |------|-------------|----------------|-----------------|------------| | plan | 0 | — | — | — | | verify | 0 | — | — | — | | implement | 240 | 0.0 | 0.0 | 73.7 | | fix | 2 | 493875.0 | 17949.0 | 3162.5 | | audit_impl | 0 | — | — | — | | prepare_pr | 0 | — | — | — | | compose_pr | 0 | — | — | — | | **Total** | **242** | 14439.5 | 736.7 | 255.2 | ## Model Usage Breakdown | Model | steps | uncached | output | cache_read | cache_write | time | |-------|-------|----------|--------|------------|-------------|------| | opus[1m] | 1 | 1.7k | 15.0k | 2.0M | 73.7k | 12m 10s | | sonnet | 3 | 278 | 25.3k | 1.5M | 104.6k | 22m 4s | | MiniMax-M3 | 3 | 1.5M | 21.5k | 0 | 0 | 8m 15s | --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent b757169 commit 4af2ea2

3 files changed

Lines changed: 247 additions & 1 deletion

File tree

.autoskillit/test-source-map.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,8 @@
142142
"tests/contracts/test_exogenous_string_coupling.py",
143143
"tests/contracts/test_instruction_surface.py",
144144
"tests/contracts/test_sous_chef_routing.py",
145-
"tests/contracts/test_sous_chef_scheduling.py"
145+
"tests/contracts/test_sous_chef_scheduling.py",
146+
"tests/execution/backends/test_failure_predicate_spanning.py"
146147
],
147148
"src/autoskillit/cli/_restart.py": [
148149
"tests/cli/test_restart.py"
@@ -4573,6 +4574,7 @@
45734574
"tests/cli/test_orchestrator_prompt_contract.py",
45744575
"tests/contracts/test_exogenous_string_coupling.py",
45754576
"tests/contracts/test_instruction_surface.py",
4577+
"tests/execution/backends/test_failure_predicate_spanning.py",
45764578
"tests/fleet/test_campaign_capture.py",
45774579
"tests/fleet/test_capture_roundtrip.py",
45784580
"tests/fleet/test_food_truck_prompt.py",
@@ -14571,6 +14573,11 @@
1457114573
"tests/server/test_tools_run_python.py",
1457214574
"tests/server/test_tools_run_python_cwd.py"
1457314575
],
14576+
"src/autoskillit/server/tools/_types.py": [
14577+
"tests/execution/backends/test_failure_predicate_spanning.py",
14578+
"tests/infra/test_pretty_output_hook_infra.py",
14579+
"tests/server/test_tools_types_module.py"
14580+
],
1457414581
"src/autoskillit/server/tools/tools_agents.py": [
1457514582
"tests/server/test_tools_agents.py"
1457614583
],

tests/_test_filter.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -808,6 +808,10 @@ class ImportContext(enum.StrEnum):
808808
"fleet",
809809
"server",
810810
"cli",
811+
# file-level: cross-layer test that imports fleet prompt builder
812+
"execution/backends/test_failure_predicate_spanning.py",
813+
# file-level: execution test that imports fleet.build_protected_campaign_ids
814+
"execution/test_session_log_retention.py",
811815
}
812816
),
813817
# L3
@@ -823,12 +827,16 @@ class ImportContext(enum.StrEnum):
823827
"fleet/test_gate_state_persistence.py",
824828
"fleet/test_dispatch_identity_continuity.py",
825829
"fleet/test_dispatch_envelope_fields.py",
830+
# file-level: cross-layer test that imports server TypedDict schemas
831+
"execution/backends/test_failure_predicate_spanning.py",
826832
}
827833
),
828834
"cli": frozenset(
829835
{
830836
"cli",
831837
"__main__",
838+
# file-level: cross-layer test that imports cli prompt builder
839+
"execution/backends/test_failure_predicate_spanning.py",
832840
}
833841
),
834842
# Infra (non-layered)
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
from __future__ import annotations
2+
3+
import inspect
4+
import re
5+
from typing import get_type_hints
6+
7+
import pytest
8+
9+
from autoskillit.cli._prompts_orchestrator import _build_orchestrator_prompt
10+
from autoskillit.fleet._prompts import _build_food_truck_prompt
11+
from autoskillit.server.tools._types import (
12+
MergeWorktreeResult,
13+
RunCmdResult,
14+
RunSkillResult,
15+
TestCheckResult,
16+
)
17+
18+
pytestmark = [pytest.mark.layer("execution"), pytest.mark.small]
19+
20+
# ── Shared constants ──────────────────────────────────────────────
21+
22+
_TOOL_TYPEDDICT_MAP: dict[str, type] = {
23+
"test_check": TestCheckResult,
24+
"merge_worktree": MergeWorktreeResult,
25+
"run_cmd": RunCmdResult,
26+
"run_skill": RunSkillResult,
27+
}
28+
29+
_EXPECTED_FIELD_MAP: dict[str, str] = {
30+
"test_check": "passed",
31+
"merge_worktree": "error",
32+
"run_cmd": "success",
33+
"run_skill": "success",
34+
}
35+
36+
_ALL_TOOL_NAMES = ["test_check", "merge_worktree", "run_cmd", "run_skill", "classify_fix"]
37+
38+
39+
# ── Shared helper ─────────────────────────────────────────────────
40+
41+
42+
def _extract_predicate_block(source_text: str) -> str:
43+
"""Locate the 'FAILURE PREDICATES' section dynamically and extract it.
44+
45+
Searches for the header line containing 'FAILURE PREDICATES — when to follow'
46+
and extracts all subsequent '- tool_name:' lines until a blank line or
47+
next section boundary.
48+
"""
49+
match = re.search(
50+
r"(FAILURE PREDICATES — when to follow on_failure:.*?)(?=\n\n|\n[A-Z])",
51+
source_text,
52+
re.DOTALL,
53+
)
54+
assert match is not None, (
55+
"Could not find 'FAILURE PREDICATES — when to follow on_failure:' section"
56+
)
57+
return match.group(1)
58+
59+
60+
def _extract_open_kitchen_predicate_block(rendered_text: str) -> str:
61+
"""Extract the 'FAILURE PREDICATE — open_kitchen:' block from rendered prompt."""
62+
match = re.search(
63+
r"(FAILURE PREDICATE — open_kitchen:.*?)(?=\n\n|\nFAILURE PREDICATE —[^o]|\Z)",
64+
rendered_text,
65+
re.DOTALL,
66+
)
67+
assert match is not None, "Could not find 'FAILURE PREDICATE — open_kitchen:' section"
68+
return match.group(1)
69+
70+
71+
def _parse_predicate_lines(block: str) -> dict[str, str]:
72+
"""Parse '- tool_name: ...' lines from a predicate block into a dict."""
73+
lines: dict[str, str] = {}
74+
for m in re.finditer(r"^- (\w+): (.+)$", block, re.MULTILINE):
75+
lines[m.group(1)] = m.group(0)
76+
return lines
77+
78+
79+
# ── Stub helpers ──────────────────────────────────────────────────
80+
81+
82+
def _render_orchestrator() -> str:
83+
return _build_orchestrator_prompt(
84+
recipe_name="stub-recipe",
85+
mcp_prefix="mcp__stub",
86+
)
87+
88+
89+
def _render_food_truck() -> str:
90+
return _build_food_truck_prompt(
91+
recipe="stub-recipe",
92+
task="stub task",
93+
ingredients={},
94+
mcp_prefix="mcp__stub",
95+
dispatch_id="d-00000000",
96+
campaign_id="c-00000000",
97+
l3_timeout_sec=300,
98+
)
99+
100+
101+
_RENDER_HELPER_MAP = {
102+
_build_orchestrator_prompt: _render_orchestrator,
103+
_build_food_truck_prompt: _render_food_truck,
104+
}
105+
106+
107+
# ── Test classes ──────────────────────────────────────────────────
108+
109+
110+
class TestToolPredicateFieldsMatchSchema:
111+
"""Validate each FAILURE PREDICATE field name against its TypedDict schema."""
112+
113+
@pytest.mark.parametrize(
114+
"builder_fn",
115+
[_build_orchestrator_prompt, _build_food_truck_prompt],
116+
ids=["orchestrator", "food_truck"],
117+
)
118+
@pytest.mark.parametrize("tool_name", list(_TOOL_TYPEDDICT_MAP.keys()))
119+
def test_predicate_field_in_typeddict(self, tool_name: str, builder_fn: object) -> None:
120+
rendered = _RENDER_HELPER_MAP[builder_fn]() # type: ignore[index]
121+
block = _extract_predicate_block(rendered)
122+
parsed = _parse_predicate_lines(block)
123+
124+
assert tool_name in parsed, f"{tool_name} not found in predicate block"
125+
126+
expected_field = _EXPECTED_FIELD_MAP[tool_name]
127+
td_cls = _TOOL_TYPEDDICT_MAP[tool_name]
128+
valid_keys = set(get_type_hints(td_cls))
129+
130+
assert expected_field in valid_keys, (
131+
f"Predicate field '{expected_field}' for {tool_name} "
132+
f"is not a key in {td_cls.__name__}. Valid keys: {sorted(valid_keys)}"
133+
)
134+
135+
line_text = parsed[tool_name]
136+
assert f'"{expected_field}:' in line_text or f'"{expected_field}"' in line_text, (
137+
f"Expected field '{expected_field}' not found in predicate line: {line_text}"
138+
)
139+
140+
@pytest.mark.parametrize(
141+
"builder_fn",
142+
[_build_orchestrator_prompt, _build_food_truck_prompt],
143+
ids=["orchestrator", "food_truck"],
144+
)
145+
def test_classify_fix_contains_error_colon(self, builder_fn: object) -> None:
146+
rendered = _RENDER_HELPER_MAP[builder_fn]() # type: ignore[index]
147+
block = _extract_predicate_block(rendered)
148+
parsed = _parse_predicate_lines(block)
149+
150+
assert "classify_fix" in parsed, "classify_fix not found in predicate block"
151+
assert "error:" in parsed["classify_fix"], (
152+
f"classify_fix predicate does not contain 'error:': {parsed['classify_fix']}"
153+
)
154+
155+
@pytest.mark.parametrize(
156+
"builder_fn",
157+
[_build_orchestrator_prompt, _build_food_truck_prompt],
158+
ids=["orchestrator", "food_truck"],
159+
)
160+
def test_predicate_block_contains_all_tools(self, builder_fn: object) -> None:
161+
rendered = _RENDER_HELPER_MAP[builder_fn]() # type: ignore[index]
162+
block = _extract_predicate_block(rendered)
163+
for tool_name in _ALL_TOOL_NAMES:
164+
assert tool_name in block, f"Tool '{tool_name}' missing from FAILURE PREDICATES block"
165+
166+
167+
class TestOpenKitchenIngredientTableMarker:
168+
"""POST-C1 regression guard: open_kitchen predicate must reference JSON fields,
169+
not the hook-injected display marker."""
170+
171+
def test_no_ingredients_table_marker(self) -> None:
172+
rendered = _render_food_truck()
173+
block = _extract_open_kitchen_predicate_block(rendered)
174+
assert "--- INGREDIENTS TABLE ---" not in block, (
175+
"open_kitchen FAILURE PREDICATE must not reference the hook-injected "
176+
"'--- INGREDIENTS TABLE ---' marker"
177+
)
178+
179+
def test_references_success_and_ingredients_table(self) -> None:
180+
rendered = _render_food_truck()
181+
block = _extract_open_kitchen_predicate_block(rendered)
182+
assert "success" in block, (
183+
"open_kitchen predicate block must reference 'success' as a JSON field"
184+
)
185+
assert "ingredients_table" in block, (
186+
"open_kitchen predicate block must reference 'ingredients_table' as a JSON field"
187+
)
188+
189+
190+
class TestPredicateParity:
191+
"""Assert the five shared tool predicate lines are character-identical
192+
between orchestrator and food truck builders."""
193+
194+
def test_shared_predicate_lines_character_identical(self) -> None:
195+
orch_rendered = _render_orchestrator()
196+
ft_rendered = _render_food_truck()
197+
198+
orch_block = _extract_predicate_block(orch_rendered)
199+
ft_block = _extract_predicate_block(ft_rendered)
200+
201+
orch_lines = _parse_predicate_lines(orch_block)
202+
ft_lines = _parse_predicate_lines(ft_block)
203+
204+
for tool_name in _ALL_TOOL_NAMES:
205+
assert tool_name in orch_lines, (
206+
f"{tool_name} missing from orchestrator predicate block"
207+
)
208+
assert tool_name in ft_lines, f"{tool_name} missing from food truck predicate block"
209+
assert orch_lines[tool_name] == ft_lines[tool_name], (
210+
f"Predicate parity violation for {tool_name}:\n"
211+
f" orchestrator: {orch_lines[tool_name]!r}\n"
212+
f" food_truck: {ft_lines[tool_name]!r}"
213+
)
214+
215+
def test_predicate_extraction_is_dynamic(self) -> None:
216+
"""Verify extraction works on both source text and rendered output,
217+
proving it searches by header not by hardcoded offsets."""
218+
orch_source = inspect.getsource(_build_orchestrator_prompt)
219+
ft_source = inspect.getsource(_build_food_truck_prompt)
220+
221+
for source in [orch_source, ft_source]:
222+
block = _extract_predicate_block(source)
223+
assert "FAILURE PREDICATES" in block
224+
for tool_name in _ALL_TOOL_NAMES:
225+
assert tool_name in block
226+
227+
for rendered in [_render_orchestrator(), _render_food_truck()]:
228+
block = _extract_predicate_block(rendered)
229+
assert "FAILURE PREDICATES" in block
230+
for tool_name in _ALL_TOOL_NAMES:
231+
assert tool_name in block

0 commit comments

Comments
 (0)