"""Configuration parser and schema contract for AGENT-EXECUTION.md.""" from __future__ import annotations import tempfile import textwrap import unittest from pathlib import Path from scripts.agent_exec_config import ( DEFAULT_IDLE_TIMEOUT_SECONDS, ConfigError, ExecutionConfig, load_config, validate_config, ) SCHEMA = "agent-execution/v1" MINIMAL_BLOCK = textwrap.dedent( """\ schema: agent-execution/v1 execution: fresh_process_per_unit: true permission: run-everything fallback_to_host: all-unavailable max_parallel: 3 timeout_seconds: 7200 summary_bytes: 16384 raw_output_bytes: 10485760 roles: implementation: [cursor, claude] repair: [cursor] test: [claude] review: [claude] profiles: cursor: adapter: cursor executable: cursor-agent model: auto enabled: true claude: adapter: claude executable: claude model: auto enabled: true """ ) def write_repo(root: Path, body: str, *, extra_blocks: list[str] | None = None) -> Path: parts = ["# Fixture\n", "```agent-execution\n", body.rstrip(), "\n```\n"] if extra_blocks: for block in extra_blocks: parts.extend(["\n```agent-execution\n", block.rstrip(), "\n```\n"]) (root / "AGENT-EXECUTION.md").write_text("".join(parts), encoding="utf-8") return root class AgentExecConfigTests(unittest.TestCase): def setUp(self) -> None: self.tmp = tempfile.TemporaryDirectory(prefix="agent-exec-config-") self.addCleanup(self.tmp.cleanup) self.repo = Path(self.tmp.name) def test_loads_single_agent_execution_block(self) -> None: write_repo(self.repo, MINIMAL_BLOCK) self.assertTrue(issubclass(ConfigError, ValueError)) cfg = load_config(self.repo) self.assertIsInstance(cfg, ExecutionConfig) self.assertEqual(cfg.schema, SCHEMA) self.assertTrue(cfg.execution.fresh_process_per_unit) self.assertEqual(cfg.execution.permission, "run-everything") self.assertEqual(cfg.execution.fallback_to_host, "all-unavailable") self.assertEqual(cfg.roles["implementation"], ("cursor", "claude")) self.assertEqual(cfg.profiles["cursor"].executable, "cursor-agent") self.assertEqual(cfg.custom_adapters, {}) self.assertEqual(validate_config(self.repo), []) def test_rejects_missing_or_duplicate_blocks(self) -> None: (self.repo / "AGENT-EXECUTION.md").write_text("# no block\n", encoding="utf-8") with self.assertRaises(ConfigError): load_config(self.repo) write_repo(self.repo, MINIMAL_BLOCK, extra_blocks=[MINIMAL_BLOCK]) with self.assertRaises(ConfigError): load_config(self.repo) def test_rejects_unknown_schema_and_unknown_keys(self) -> None: bad_schema = MINIMAL_BLOCK.replace( "schema: agent-execution/v1", "schema: agent-execution/v0" ) write_repo(self.repo, bad_schema) with self.assertRaises(ConfigError): load_config(self.repo) bad_key = MINIMAL_BLOCK + "\nextra_top_level: true\n" write_repo(self.repo, bad_key) with self.assertRaises(ConfigError): load_config(self.repo) bad_exec = MINIMAL_BLOCK.replace( "raw_output_bytes: 10485760\n", "raw_output_bytes: 10485760\n mystery: 1\n", ) write_repo(self.repo, bad_exec) with self.assertRaises(ConfigError): load_config(self.repo) def test_role_order_does_not_follow_profile_order(self) -> None: # profiles listed claude before cursor; role order must stay cursor -> claude block = textwrap.dedent( """\ schema: agent-execution/v1 execution: fresh_process_per_unit: true permission: run-everything fallback_to_host: all-unavailable max_parallel: 1 timeout_seconds: 60 summary_bytes: 1024 raw_output_bytes: 4096 roles: implementation: [cursor, claude] repair: [cursor] test: [claude] review: [claude] profiles: claude: adapter: claude executable: claude model: auto enabled: true cursor: adapter: cursor executable: cursor-agent model: auto enabled: true """ ) write_repo(self.repo, block) cfg = load_config(self.repo) self.assertEqual(cfg.roles["implementation"], ("cursor", "claude")) self.assertEqual(list(cfg.profiles.keys())[0], "claude") def test_model_auto_is_preserved_as_none(self) -> None: write_repo(self.repo, MINIMAL_BLOCK) cfg = load_config(self.repo) self.assertIsNone(cfg.profiles["cursor"].model) self.assertIsNone(cfg.profiles["claude"].model) def test_rejects_unknown_profile_reference(self) -> None: block = MINIMAL_BLOCK.replace( "implementation: [cursor, claude]", "implementation: [cursor, claude, missing]", ) write_repo(self.repo, block) with self.assertRaises(ConfigError): load_config(self.repo) def test_rejects_shell_command_and_secret_shaped_values(self) -> None: shell = MINIMAL_BLOCK.replace( "executable: cursor-agent", "executable: \"cursor-agent && rm -rf /\"", ) write_repo(self.repo, shell) with self.assertRaises(ConfigError): load_config(self.repo) secret_key = MINIMAL_BLOCK.replace( "enabled: true\n\n claude:", "enabled: true\n api_token: \"sk-secret-value\"\n\n claude:", ) write_repo(self.repo, secret_key) with self.assertRaises(ConfigError): load_config(self.repo) secret_val = MINIMAL_BLOCK.replace( "executable: claude", "executable: \"Bearer abcdef0123456789abcdef0123456789\"", ) write_repo(self.repo, secret_val) with self.assertRaises(ConfigError): load_config(self.repo) def test_custom_placeholders_must_fill_whole_argv_items(self) -> None: good = textwrap.dedent( """\ schema: agent-execution/v1 execution: fresh_process_per_unit: true permission: run-everything fallback_to_host: all-unavailable max_parallel: 1 timeout_seconds: 60 summary_bytes: 1024 raw_output_bytes: 4096 roles: implementation: [mycustom] repair: [mycustom] test: [mycustom] review: [mycustom] profiles: mycustom: adapter: custom executable: mycli model: auto enabled: true custom_adapters: mycustom: base_argv: ["run", "${unit_packet}"] run_everything_argv: ["--yes"] workspace_argv: ["--cwd", "${workspace}"] model_argv: ["--model", "${model}"] prompt_transport: stdin output_format: json """ ) write_repo(self.repo, good) cfg = load_config(self.repo) self.assertEqual( cfg.custom_adapters["mycustom"].workspace_argv, ("--cwd", "${workspace}"), ) bad = good.replace( 'workspace_argv: ["--cwd", "${workspace}"]', 'workspace_argv: ["--cwd=${workspace}"]', ) write_repo(self.repo, bad) with self.assertRaises(ConfigError): load_config(self.repo) unknown_ph = good.replace( 'base_argv: ["run", "${unit_packet}"]', 'base_argv: ["run", "${other}"]', ) write_repo(self.repo, unknown_ph) with self.assertRaises(ConfigError): load_config(self.repo) def test_numeric_limits_are_positive_and_bounded(self) -> None: zero = MINIMAL_BLOCK.replace("max_parallel: 3", "max_parallel: 0") write_repo(self.repo, zero) with self.assertRaises(ConfigError): load_config(self.repo) negative = MINIMAL_BLOCK.replace("timeout_seconds: 7200", "timeout_seconds: -1") write_repo(self.repo, negative) with self.assertRaises(ConfigError): load_config(self.repo) huge = MINIMAL_BLOCK.replace("summary_bytes: 16384", "summary_bytes: 999999999") write_repo(self.repo, huge) with self.assertRaises(ConfigError): load_config(self.repo) inverted = MINIMAL_BLOCK.replace( "summary_bytes: 16384\n raw_output_bytes: 10485760", "summary_bytes: 10485760\n raw_output_bytes: 16384", ) write_repo(self.repo, inverted) with self.assertRaises(ConfigError): load_config(self.repo) def test_fresh_process_per_unit_must_be_true(self) -> None: block = MINIMAL_BLOCK.replace( "fresh_process_per_unit: true", "fresh_process_per_unit: false", ) write_repo(self.repo, block) with self.assertRaises(ConfigError) as ctx: load_config(self.repo) self.assertIn("fresh_process_per_unit", str(ctx.exception)) def test_rejects_indentation_jump(self) -> None: # root mapping child jumps from 0 spaces to 4 spaces (must be exactly +2) jumped = textwrap.dedent( """\ schema: agent-execution/v1 execution: fresh_process_per_unit: true permission: run-everything fallback_to_host: all-unavailable max_parallel: 1 timeout_seconds: 60 summary_bytes: 1024 raw_output_bytes: 4096 roles: implementation: [cursor] repair: [cursor] test: [cursor] review: [cursor] profiles: cursor: adapter: cursor executable: cursor-agent model: auto enabled: true """ ) write_repo(self.repo, jumped) with self.assertRaises(ConfigError) as ctx: load_config(self.repo) self.assertRegex(str(ctx.exception).lower(), r"indent|indentation") def test_quoted_unicode_string_round_trips(self) -> None: block = MINIMAL_BLOCK.replace( "executable: claude", 'executable: "模型-β-测试"', ) write_repo(self.repo, block) cfg = load_config(self.repo) self.assertEqual(cfg.profiles["claude"].executable, "模型-β-测试") escaped = MINIMAL_BLOCK.replace( "executable: claude", r'executable: "say \"hi\" 模型"', ) write_repo(self.repo, escaped) cfg = load_config(self.repo) self.assertEqual(cfg.profiles["claude"].executable, 'say "hi" 模型') def test_idle_timeout_defaults_within_total_and_rejects_bounds(self) -> None: self.assertEqual(DEFAULT_IDLE_TIMEOUT_SECONDS, 600) write_repo(self.repo, MINIMAL_BLOCK) cfg = load_config(self.repo) self.assertEqual(cfg.execution.idle_timeout_seconds, 600) self.assertLessEqual(cfg.execution.idle_timeout_seconds, cfg.execution.timeout_seconds) short = MINIMAL_BLOCK.replace("timeout_seconds: 7200", "timeout_seconds: 60") write_repo(self.repo, short) cfg = load_config(self.repo) self.assertEqual(cfg.execution.timeout_seconds, 60) self.assertEqual(cfg.execution.idle_timeout_seconds, 60) explicit = MINIMAL_BLOCK.replace( "timeout_seconds: 7200\n", "timeout_seconds: 7200\n idle_timeout_seconds: 600\n", ) write_repo(self.repo, explicit) self.assertEqual(load_config(self.repo).execution.idle_timeout_seconds, 600) equal = MINIMAL_BLOCK.replace( "timeout_seconds: 7200\n", "timeout_seconds: 100\n idle_timeout_seconds: 100\n", ) write_repo(self.repo, equal) cfg = load_config(self.repo) self.assertEqual(cfg.execution.idle_timeout_seconds, 100) self.assertEqual(cfg.execution.timeout_seconds, 100) above = MINIMAL_BLOCK.replace( "timeout_seconds: 7200\n", "timeout_seconds: 100\n idle_timeout_seconds: 101\n", ) write_repo(self.repo, above) with self.assertRaises(ConfigError) as ctx: load_config(self.repo) self.assertIn("idle_timeout_seconds", str(ctx.exception)) zero = MINIMAL_BLOCK.replace( "timeout_seconds: 7200\n", "timeout_seconds: 7200\n idle_timeout_seconds: 0\n", ) write_repo(self.repo, zero) with self.assertRaises(ConfigError): load_config(self.repo) negative = MINIMAL_BLOCK.replace( "timeout_seconds: 7200\n", "timeout_seconds: 7200\n idle_timeout_seconds: -5\n", ) write_repo(self.repo, negative) with self.assertRaises(ConfigError): load_config(self.repo) flagged = MINIMAL_BLOCK.replace( "timeout_seconds: 7200\n", "timeout_seconds: 7200\n idle_timeout_seconds: true\n", ) write_repo(self.repo, flagged) with self.assertRaises(ConfigError): load_config(self.repo) fractional = MINIMAL_BLOCK.replace( "timeout_seconds: 7200\n", "timeout_seconds: 7200\n idle_timeout_seconds: 1.5\n", ) write_repo(self.repo, fractional) with self.assertRaises(ConfigError): load_config(self.repo) def test_exposes_max_attempts_per_profile_and_emergency_profile(self) -> None: block = MINIMAL_BLOCK.replace( " raw_output_bytes: 10485760\n", " raw_output_bytes: 10485760\n" " max_attempts_per_profile: 3\n" " emergency_profile: codex\n", ).replace( " claude:\n" " adapter: claude\n" " executable: claude\n" " model: auto\n" " enabled: true\n", " claude:\n" " adapter: claude\n" " executable: claude\n" " model: auto\n" " enabled: true\n" "\n" " codex:\n" " adapter: codex\n" " executable: codex\n" " model: auto\n" " enabled: true\n", ) write_repo(self.repo, block) cfg = load_config(self.repo) self.assertEqual(cfg.execution.max_attempts_per_profile, 3) self.assertEqual(cfg.execution.emergency_profile, "codex") def test_configured_emergency_profile_must_exist_be_enabled_and_use_adapter_codex( self, ) -> None: write_repo(self.repo, _emergency_block(include_profile=False)) with self.assertRaises(ConfigError) as ctx: load_config(self.repo) self.assertIn("does not exist", str(ctx.exception)) write_repo(self.repo, _emergency_block(enabled="false")) with self.assertRaises(ConfigError) as ctx: load_config(self.repo) self.assertIn("must be enabled", str(ctx.exception)) write_repo(self.repo, _emergency_block(adapter="pi")) with self.assertRaises(ConfigError) as ctx: load_config(self.repo) self.assertIn("adapter codex", str(ctx.exception)) def test_emergency_profile_is_excluded_from_normal_roles(self) -> None: for role, replacement in ( ("implementation", "[cursor, claude, codex]"), ("repair", "[cursor, codex]"), ("test", "[claude, codex]"), ): write_repo(self.repo, _emergency_block(**{role: replacement})) with self.assertRaises(ConfigError) as ctx: load_config(self.repo) self.assertIn("must not appear", str(ctx.exception)) self.assertIn(role, str(ctx.exception)) def test_review_may_include_emergency_profile(self) -> None: write_repo(self.repo, _emergency_block(review="[claude, codex]")) cfg = load_config(self.repo) self.assertEqual(cfg.execution.emergency_profile, "codex") self.assertTrue(cfg.profiles["codex"].enabled) self.assertEqual(cfg.profiles["codex"].adapter, "codex") self.assertEqual(cfg.roles["review"], ("claude", "codex")) self.assertNotIn("codex", cfg.roles["implementation"]) self.assertNotIn("codex", cfg.roles["repair"]) self.assertNotIn("codex", cfg.roles["test"]) def _emergency_block( *, include_profile: bool = True, adapter: str = "codex", enabled: str = "true", implementation: str = "[cursor, claude]", repair: str = "[cursor]", test: str = "[claude]", review: str = "[claude]", ) -> str: block = MINIMAL_BLOCK.replace( " raw_output_bytes: 10485760\n", " raw_output_bytes: 10485760\n emergency_profile: codex\n", ) block = block.replace("implementation: [cursor, claude]", f"implementation: {implementation}") block = block.replace("repair: [cursor]", f"repair: {repair}") block = block.replace("test: [claude]", f"test: {test}") block = block.replace("review: [claude]", f"review: {review}") if not include_profile: return block return block.replace( " enabled: true\n", " enabled: true\n" "\n" " codex:\n" f" adapter: {adapter}\n" " executable: codex\n" " model: auto\n" f" enabled: {enabled}\n", 1, ) if __name__ == "__main__": unittest.main()