11from __future__ import annotations
22
3+ import dataclasses
4+ import inspect
5+ import json
36from pathlib import Path
7+ from typing import Literal
48
59import pytest
610
2226
2327pytestmark = [pytest .mark .layer ("execution" ), pytest .mark .small ]
2428
29+ NOT_YET_LIVE : frozenset [str ] = frozenset (
30+ {
31+ "inspector_capable" ,
32+ "min_version" ,
33+ "patch_format" ,
34+ "required_session_files" ,
35+ "session_dir_symlinks" ,
36+ "supports_context_exhaustion_detection" ,
37+ "supports_thinking_blocks" ,
38+ }
39+ )
40+
41+ CAPABILITY_CLASSIFICATION : dict [str , Literal ["REQUIRED" , "OPTIONAL" ]] = {
42+ "applicable_guards" : "REQUIRED" ,
43+ "anthropic_provider_capable" : "OPTIONAL" ,
44+ "channel_b_capable" : "OPTIONAL" ,
45+ "completion_record_types" : "REQUIRED" ,
46+ "default_skill_sandbox_mode" : "REQUIRED" ,
47+ "env_denylist_prefixes" : "REQUIRED" ,
48+ "exit_code_is_terminal" : "REQUIRED" ,
49+ "food_truck_capable" : "OPTIONAL" ,
50+ "git_metadata_writable" : "REQUIRED" ,
51+ "has_unguarded_filesystem_access" : "REQUIRED" ,
52+ "hook_config_format" : "REQUIRED" ,
53+ "inspector_capable" : "OPTIONAL" ,
54+ "mcp_config_capable" : "OPTIONAL" ,
55+ "mcp_env_forward_vars" : "OPTIONAL" ,
56+ "min_version" : "OPTIONAL" ,
57+ "patch_format" : "OPTIONAL" ,
58+ "plugin_install_capable" : "OPTIONAL" ,
59+ "process_name" : "REQUIRED" ,
60+ "process_name_aliases" : "REQUIRED" ,
61+ "project_local_skills_capable" : "OPTIONAL" ,
62+ "pty_required" : "REQUIRED" ,
63+ "record_capable" : "OPTIONAL" ,
64+ "replay_capable" : "OPTIONAL" ,
65+ "required_session_files" : "OPTIONAL" ,
66+ "required_skill_fields" : "REQUIRED" ,
67+ "session_dir_persistent" : "OPTIONAL" ,
68+ "session_dir_symlinks" : "OPTIONAL" ,
69+ "session_record_types" : "REQUIRED" ,
70+ "session_resume_capable" : "OPTIONAL" ,
71+ "skill_injection_capable" : "REQUIRED" ,
72+ "skill_sigil" : "REQUIRED" ,
73+ "skills_subdir" : "REQUIRED" ,
74+ "supports_claude_format_stdout" : "REQUIRED" ,
75+ "supports_context_exhaustion_detection" : "OPTIONAL" ,
76+ "supports_context_window_suffix" : "OPTIONAL" ,
77+ "supports_thinking_blocks" : "OPTIONAL" ,
78+ "supports_tool_list_changed" : "OPTIONAL" ,
79+ "triage_capable" : "OPTIONAL" ,
80+ "version_check_command" : "REQUIRED" ,
81+ "write_detection_strategy" : "REQUIRED" ,
82+ "write_guard_tool_names" : "REQUIRED" ,
83+ }
84+
2585
2686def make_backend (backend_name : str ) -> CodingAgentBackend :
2787 return get_backend (backend_name )
@@ -42,26 +102,38 @@ def test_isinstance_coding_agent_backend(self) -> None:
42102 assert isinstance (self .backend , CodingAgentBackend )
43103
44104 def test_name_is_non_empty_string (self ) -> None :
105+ """BackendCapabilities.process_name — backend name from identity field."""
45106 assert isinstance (self .backend .name , str )
46107 assert len (self .backend .name ) > 0
47108
48109 def test_capabilities_returns_backend_capabilities (self ) -> None :
110+ """BackendCapabilities contract — exercises multiple fields.
111+
112+ Fields cited: applicable_guards, default_skill_sandbox_mode,
113+ git_metadata_writable, has_unguarded_filesystem_access,
114+ process_name_aliases, project_local_skills_capable, record_capable,
115+ replay_capable, session_dir_persistent, supports_context_window_suffix,
116+ supports_tool_list_changed, triage_capable, write_detection_strategy.
117+ """
49118 assert isinstance (self .backend .capabilities , BackendCapabilities )
50119
51120 def test_conventions_returns_backend_conventions (self ) -> None :
52121 assert isinstance (self .backend .conventions , BackendConventions )
53122
54123 def test_write_tool_names_returns_nonempty_frozenset (self ) -> None :
124+ """BackendCapabilities.write_guard_tool_names — write_tool_names returns frozenset."""
55125 result = self .backend .write_tool_names ()
56126 assert isinstance (result , frozenset )
57127 assert len (result ) >= 1
58128
59129 def test_binary_name_returns_non_empty_string (self ) -> None :
130+ """BackendCapabilities.version_check_command — binary_name is the executable."""
60131 result = self .backend .binary_name ()
61132 assert isinstance (result , str )
62133 assert len (result ) > 0
63134
64135 def test_translate_model_returns_string (self ) -> None :
136+ """BackendCapabilities.anthropic_provider_capable — maps provider models."""
65137 result = self .backend .translate_model ("sonnet" )
66138 assert isinstance (result , str )
67139
@@ -72,18 +144,22 @@ def test_model_config_overrides_returns_tuple(self) -> None:
72144 # --- Group 2: Sub-protocol Factories ---
73145
74146 def test_stream_parser_no_marker_returns_stream_parser (self ) -> None :
147+ """BackendCapabilities.channel_b_capable and supports_claude_format_stdout — parser dep."""
75148 result = self .backend .stream_parser ()
76149 assert isinstance (result , StreamParser )
77150
78151 def test_stream_parser_with_marker_stores_completion_marker (self ) -> None :
152+ """BackendCapabilities.completion_record_types — completion marker drives record filter."""
79153 parser = self .backend .stream_parser (completion_marker = "%%DONE%%" )
80154 assert parser .completion_marker == "%%DONE%%" # type: ignore[attr-defined]
81155
82156 def test_result_parser_returns_result_parser (self ) -> None :
157+ """BackendCapabilities.session_record_types — parser extracts content from entries."""
83158 result = self .backend .result_parser ()
84159 assert isinstance (result , ResultParser )
85160
86161 def test_env_policy_returns_env_policy (self ) -> None :
162+ """BackendCapabilities.env_denylist_prefixes — env policy built from prefixes."""
87163 result = self .backend .env_policy ()
88164 assert isinstance (result , EnvPolicy )
89165
@@ -98,18 +174,21 @@ def test_session_locator_locate_empty_string_returns_none(self) -> None:
98174 # --- Group 3: Command-builder Contracts ---
99175
100176 def test_build_cmd_returns_cmd_spec (self ) -> None :
177+ """BackendCapabilities.exit_code_is_terminal and pty_required — cmd construction deps."""
101178 result = self .backend .build_cmd (skill_command = "do stuff" , cwd = "/tmp" )
102179 assert isinstance (result , CmdSpec )
103180 assert isinstance (result .cmd , tuple )
104181
105182 def test_build_skill_session_cmd_with_default_config_returns_cmd_spec (self ) -> None :
183+ """BackendCapabilities.skill_injection_capable, required_skill_fields, skill_sigil."""
106184 result = self .backend .build_skill_session_cmd (
107185 skill_command = "/test-skill" , cwd = "/work" , config = SkillSessionConfig ()
108186 )
109187 assert isinstance (result , CmdSpec )
110188 assert isinstance (result .cmd , tuple )
111189
112190 def test_build_interactive_cmd_returns_cmd_spec (self ) -> None :
191+ """BackendCapabilities.mcp_config_capable — interactive cmd uses MCP config when true."""
113192 result = self .backend .build_interactive_cmd ()
114193 assert isinstance (result , CmdSpec )
115194 assert isinstance (result .cmd , tuple )
@@ -119,10 +198,12 @@ def test_validate_session_layout_returns_list(self, tmp_path: Path) -> None:
119198 assert isinstance (result , list )
120199
121200 def test_validate_skill_content_returns_list (self ) -> None :
201+ """BackendCapabilities.hook_config_format and skills_subdir — skill content validation."""
122202 result = self .backend .validate_skill_content ("" )
123203 assert isinstance (result , list )
124204
125205 def test_list_plugins_returns_list (self ) -> None :
206+ """BackendCapabilities.plugin_install_capable — list_plugins returns installed plugins."""
126207 result = self .backend .list_plugins ()
127208 assert isinstance (result , list )
128209
@@ -172,6 +253,7 @@ def test_session_log_path_returns_none_for_no_session_prefix(self) -> None:
172253 # --- Group 5: Capability-Gated Command Builders ---
173254
174255 def test_build_resume_cmd_when_capable (self ) -> None :
256+ """BackendCapabilities.session_resume_capable — build_resume_cmd returns valid CmdSpec."""
175257 self ._require_capability ("session_resume_capable" )
176258 result = self .backend .build_resume_cmd (
177259 resume_session_id = "test-session-id" , prompt = "test prompt"
@@ -180,6 +262,7 @@ def test_build_resume_cmd_when_capable(self) -> None:
180262 assert len (result .cmd ) > 0
181263
182264 def test_build_food_truck_cmd_when_capable (self ) -> None :
265+ """BackendCapabilities.food_truck_capable — build_food_truck_cmd returns valid CmdSpec."""
183266 self ._require_capability ("food_truck_capable" )
184267 result = self .backend .build_food_truck_cmd (
185268 orchestrator_prompt = "x" ,
@@ -190,8 +273,114 @@ def test_build_food_truck_cmd_when_capable(self) -> None:
190273 assert isinstance (result , CmdSpec )
191274
192275 def test_build_inspector_cmd_raises_when_not_capable (self ) -> None :
276+ """BackendCapabilities.inspector_capable — raises when not capable."""
193277 if self .backend .capabilities .inspector_capable :
194278 pytest .skip ("backend is inspector_capable — not testing the not-capable path" )
195279 with pytest .raises (RuntimeError ) as exc_info :
196280 self .backend .build_inspector_cmd ("test prompt" )
197281 assert "not yet implemented" in str (exc_info .value )
282+
283+ # --- Group 6: Behavioral Contracts ---
284+
285+ def test_build_cmd_env_contains_mcp_forward_vars (self ) -> None :
286+ """BackendCapabilities.mcp_env_forward_vars — vars via env injection (G6 defense)."""
287+ forward_vars = self .backend .capabilities .mcp_env_forward_vars
288+ if not forward_vars :
289+ pytest .skip (
290+ f"mcp_env_forward_vars is empty for { self .backend .name !r} "
291+ " — no env injection to verify"
292+ )
293+ result = self .backend .build_cmd (skill_command = "do stuff" , cwd = "/tmp" )
294+ for var in forward_vars :
295+ assert var in result .env , (
296+ f"{ var !r} declared in mcp_env_forward_vars"
297+ f" but missing from build_cmd env for { self .backend .name !r} "
298+ )
299+
300+ def test_build_resume_cmd_includes_session_id (self ) -> None :
301+ """BackendCapabilities.session_resume_capable — embeds the session ID in cmd tuple."""
302+ self ._require_capability ("session_resume_capable" )
303+ result = self .backend .build_resume_cmd (
304+ resume_session_id = "test-session-id" , prompt = "test prompt"
305+ )
306+ assert "test-session-id" in result .cmd , (
307+ f"'test-session-id' not found in result.cmd for { self .backend .name !r} "
308+ )
309+
310+ def test_setup_session_dir_and_locator_round_trip (
311+ self , tmp_path : Path , monkeypatch : pytest .MonkeyPatch
312+ ) -> None :
313+ """BackendCapabilities.session_dir_symlinks and setup_session_dir round-trip."""
314+ if self .backend .name == "claude-code" :
315+ self .backend .setup_session_dir (tmp_path )
316+ locator = self .backend .session_locator ()
317+ assert locator .locate_session ("any-id" ) is None
318+ elif self .backend .name == "codex" :
319+ fake_home = tmp_path / "fake_home"
320+ fake_home .mkdir ()
321+ monkeypatch .setattr (Path , "home" , staticmethod (lambda : fake_home ))
322+ fake_log_dir = tmp_path / "fake_logs"
323+ fake_log_dir .mkdir ()
324+ monkeypatch .setattr (
325+ "autoskillit.execution.backends.codex.default_log_dir" ,
326+ lambda : fake_log_dir ,
327+ )
328+ (fake_home / ".codex" ).mkdir ()
329+ (fake_home / ".codex" / "config.toml" ).write_text ('[model_provider]\n name = "fake"\n ' )
330+ session_dir = tmp_path / "session"
331+ session_dir .mkdir ()
332+ self .backend .setup_session_dir (session_dir )
333+ sessions_symlink = session_dir / "sessions"
334+ assert sessions_symlink .is_symlink ()
335+ sessions_target = sessions_symlink .resolve ()
336+ date_dir = sessions_target / "2026" / "01" / "01"
337+ date_dir .mkdir (parents = True )
338+ rollout_path = date_dir / "rollout-fake.jsonl"
339+ event = {
340+ "type" : "thread.started" ,
341+ "thread_id" : "fake-thread-id" ,
342+ }
343+ rollout_path .write_text (json .dumps (event ) + "\n " )
344+ locator = self .backend .session_locator ()
345+ found = locator .locate_session ("fake-thread-id" )
346+ assert found is not None , "CodexSessionLocator did not find the synthetic rollout file"
347+ assert found == rollout_path
348+ else :
349+ pytest .fail (
350+ f"test_setup_session_dir_and_locator_round_trip has no coverage"
351+ f" for backend { self .backend .name !r} "
352+ )
353+
354+
355+ def test_every_capability_field_exercised_or_not_yet_live () -> None :
356+ fields = {f .name for f in dataclasses .fields (BackendCapabilities )}
357+ methods = inspect .getmembers (TestCodingAgentBackendConformance , predicate = inspect .isfunction )
358+ cited : set [str ] = set ()
359+ for _name , method in methods :
360+ doc = inspect .getdoc (method ) or ""
361+ for field_name in fields :
362+ if field_name in doc :
363+ cited .add (field_name )
364+ uncovered = fields - cited - NOT_YET_LIVE
365+ assert not uncovered , (
366+ f"BackendCapabilities fields not exercised and not in NOT_YET_LIVE: { sorted (uncovered )} "
367+ )
368+
369+
370+ def test_every_capability_field_has_classification () -> None :
371+ fields = {f .name for f in dataclasses .fields (BackendCapabilities )}
372+ assert CAPABILITY_CLASSIFICATION .keys () == fields , (
373+ f"CAPABILITY_CLASSIFICATION key mismatch — "
374+ f"missing: { sorted (fields - CAPABILITY_CLASSIFICATION .keys ())} , "
375+ f"extra: { sorted (CAPABILITY_CLASSIFICATION .keys () - fields )} "
376+ )
377+ invalid = {
378+ k : v for k , v in CAPABILITY_CLASSIFICATION .items () if v not in ("REQUIRED" , "OPTIONAL" )
379+ }
380+ assert not invalid , f"Invalid classification values: { invalid } "
381+ not_yet_live_required = {
382+ f for f in NOT_YET_LIVE if CAPABILITY_CLASSIFICATION .get (f ) == "REQUIRED"
383+ }
384+ assert not not_yet_live_required , (
385+ f"NOT_YET_LIVE fields must be OPTIONAL — found REQUIRED: { sorted (not_yet_live_required )} "
386+ )
0 commit comments