diff --git a/tests/contracts/AGENTS.md b/tests/contracts/AGENTS.md index 5ff65e8ac..283fa6ea1 100644 --- a/tests/contracts/AGENTS.md +++ b/tests/contracts/AGENTS.md @@ -26,6 +26,7 @@ Protocol satisfaction, package gateway, and skill contract compliance tests. | `test_config_field_coverage.py` | REQ-CONFIG-001: every sub-config dataclass field must be referenced in from_dynaconf | | `test_core_public_api_surface.py` | Validates that every symbol in autoskillit.core.__all__ is importable via the public gateway | | `test_diagnose_ci_steps.py` | Contract tests for diagnose-ci SKILL.md step numbering and cross-reference integrity | +| `test_discipline_delivery_matrix.py` | Contract: session-type x backend discipline delivery channel matrix — interactive primary channel, headless prompt + completion marker, skill prompt, and sous-chef composition | | `test_docstring_skill_prefix.py` | Contract: source files must not use /autoskillit: prefix for skills_extended skills | | `test_environment_setup_design_contracts.py` | Contract tests verifying the environment-setup skill design doc completeness | | `test_ephemeral_skill_namespace.py` | Contract: ephemeral SKILL.md bodies use the correct namespace for the session delivery channel | diff --git a/tests/contracts/test_discipline_delivery_matrix.py b/tests/contracts/test_discipline_delivery_matrix.py new file mode 100644 index 000000000..b29d1e9aa --- /dev/null +++ b/tests/contracts/test_discipline_delivery_matrix.py @@ -0,0 +1,158 @@ +"""Discipline delivery channel matrix contract tests. + +Session-type x backend parametrized tests asserting that each combination's +PRIMARY delivery channel is populated via the correct builder. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from autoskillit.core import ( + SESSION_TYPE_ENV_VAR, + SESSION_TYPE_FLEET, + SESSION_TYPE_ORCHESTRATOR, + SESSION_TYPE_SKILL, + DirectInstall, +) +from autoskillit.execution.backends.claude import ClaudeCodeBackend +from autoskillit.execution.backends.codex import CodexBackend # noqa: F401 + +pytestmark = [pytest.mark.layer("contracts"), pytest.mark.small] + + +def _assert_interactive_primary_channel(backend, spec) -> None: + """Assert the interactive primary delivery channel is populated.""" + if isinstance(backend, ClaudeCodeBackend): + assert "--append-system-prompt" in spec.cmd + else: + assert any("developer_instructions=" in arg for arg in spec.cmd) + + +class TestFleetInteractive: + @pytest.mark.parametrize( + "backend", + [ClaudeCodeBackend(), CodexBackend()], + ids=["claude-code", "codex"], + ) + def test_primary_channel_populated(self, backend) -> None: + spec = backend.build_interactive_cmd( + system_prompt="Fleet discipline prompt", + env_extras={SESSION_TYPE_ENV_VAR: SESSION_TYPE_FLEET}, + ) + _assert_interactive_primary_channel(backend, spec) + + @pytest.mark.parametrize( + "backend", + [ClaudeCodeBackend(), CodexBackend()], + ids=["claude-code", "codex"], + ) + def test_session_type_fleet_in_env(self, backend) -> None: + spec = backend.build_interactive_cmd( + system_prompt="Fleet discipline prompt", + env_extras={SESSION_TYPE_ENV_VAR: SESSION_TYPE_FLEET}, + ) + assert spec.env.get(SESSION_TYPE_ENV_VAR) == SESSION_TYPE_FLEET + + +class TestOrchestratorInteractive: + @pytest.mark.parametrize( + "backend", + [ClaudeCodeBackend(), CodexBackend()], + ids=["claude-code", "codex"], + ) + def test_primary_channel_populated(self, backend) -> None: + spec = backend.build_interactive_cmd( + system_prompt="Orchestrator discipline prompt", + ) + _assert_interactive_primary_channel(backend, spec) + + @pytest.mark.parametrize( + "backend", + [ClaudeCodeBackend(), CodexBackend()], + ids=["claude-code", "codex"], + ) + def test_no_session_type_assertion(self, backend) -> None: + spec = backend.build_interactive_cmd( + system_prompt="Orchestrator discipline prompt", + ) + assert not spec.env.get(SESSION_TYPE_ENV_VAR, "") + + +class TestOrchestratorHeadless: + @pytest.mark.parametrize( + "backend", + [ClaudeCodeBackend(), CodexBackend()], + ids=["claude-code", "codex"], + ) + def test_orchestrator_prompt_non_empty(self, backend) -> None: + spec = backend.build_food_truck_cmd( + orchestrator_prompt="Run the pipeline", + plugin_source=DirectInstall(plugin_dir=Path("/tmp")), + cwd="/tmp", + completion_marker="%%DONE%%", + ) + assert any("Run the pipeline" in arg for arg in spec.cmd) + assert any("%%DONE%%" in arg for arg in spec.cmd) + + @pytest.mark.parametrize( + "backend", + [ClaudeCodeBackend(), CodexBackend()], + ids=["claude-code", "codex"], + ) + def test_session_type_orchestrator_in_env(self, backend) -> None: + spec = backend.build_food_truck_cmd( + orchestrator_prompt="Run the pipeline", + plugin_source=DirectInstall(plugin_dir=Path("/tmp")), + cwd="/tmp", + completion_marker="%%DONE%%", + ) + assert spec.env.get(SESSION_TYPE_ENV_VAR) == SESSION_TYPE_ORCHESTRATOR + + +class TestSkillSession: + @pytest.mark.parametrize( + "backend", + [ClaudeCodeBackend(), CodexBackend()], + ids=["claude-code", "codex"], + ) + def test_positional_prompt_non_empty(self, backend) -> None: + spec = backend.build_skill_session_cmd("/investigate foo", "/tmp") + assert any("investigate" in arg for arg in spec.cmd) + + @pytest.mark.parametrize( + "backend", + [ClaudeCodeBackend(), CodexBackend()], + ids=["claude-code", "codex"], + ) + def test_session_type_skill_in_env(self, backend) -> None: + spec = backend.build_skill_session_cmd("/investigate foo", "/tmp") + assert spec.env.get(SESSION_TYPE_ENV_VAR) == SESSION_TYPE_SKILL + + +class TestSousChefDelivery: + def test_sous_chef_in_orchestrator_prompt(self) -> None: + from autoskillit.cli._prompts import _build_orchestrator_prompt, _read_full_sous_chef + + sous_chef = _read_full_sous_chef() + assert sous_chef, "_read_full_sous_chef must return non-empty content" + prompt = _build_orchestrator_prompt("test-recipe", "mcp__autoskillit__") + assert sous_chef[:80] in prompt + + def test_sous_chef_in_open_kitchen_prompt(self) -> None: + from autoskillit.cli._prompts import _build_open_kitchen_prompt, _read_full_sous_chef + + sous_chef = _read_full_sous_chef() + assert sous_chef, "_read_full_sous_chef must return non-empty content" + prompt = _build_open_kitchen_prompt("mcp__autoskillit__") + assert sous_chef[:80] in prompt + + def test_sous_chef_not_in_fleet_dispatch_prompt(self) -> None: + from autoskillit.cli._prompts import _build_fleet_dispatch_prompt, _read_full_sous_chef + + sous_chef = _read_full_sous_chef() + assert sous_chef, "_read_full_sous_chef must return non-empty content" + prompt = _build_fleet_dispatch_prompt("mcp__autoskillit__") + assert sous_chef[:80] not in prompt